feat(ci): Add GitHub Actions workflows for test automation and status badges
Add comprehensive test automation setup with GitHub Actions: - Create test.yml for running tests on main/develop branches - Add pr-test.yml for PR validation with test results comments - Add update-badges.yml for dynamic test status badge updates - Configure code coverage reporting with Codecov integration Documentation: - Add BADGE_SETUP.md with instructions for configuring test status badges - Add WORKFLOWS_GUIDE.md explaining CI/CD workflow setup - Update README.md with build and test status badges Test Framework: - Configure test project to use .NET 9.0 - Set up test coverage reporting with coverlet - Add integration tests with WireMock for API mocking - Add unit tests for configuration and HTTP client components - Document testing strategy in TestingStrategy.md Build: - Add Dockerfile.test for containerized testing - Update .gitignore for test artifacts - Configure test dependencies in VRCAuthProxy.Tests.csproj This change enables automated testing on PRs and branches, with visual status indicators and detailed test results in PR comments.
This commit is contained in:
parent
23b5a504b5
commit
319f1071bf
18 changed files with 939 additions and 12 deletions
45
.github/BADGE_SETUP.md
vendored
Normal file
45
.github/BADGE_SETUP.md
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
# Setting Up GitHub Badges
|
||||
|
||||
This document explains how to set up the GitHub Secrets required for the test status badge functionality.
|
||||
|
||||
## Steps to Create a GitHub Gist for Badge Storage
|
||||
|
||||
1. Create a new public GitHub Gist at https://gist.github.com/
|
||||
- Add a file named `vrcauthproxy-tests.json` with the following content:
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "tests",
|
||||
"message": "unknown",
|
||||
"color": "lightgrey"
|
||||
}
|
||||
```
|
||||
- Save the Gist
|
||||
|
||||
2. Note the Gist ID from the URL
|
||||
- The URL will look like: `https://gist.github.com/YOUR_USERNAME/GIST_ID`
|
||||
- Copy the `GIST_ID` part
|
||||
|
||||
## Creating GitHub Secrets
|
||||
|
||||
1. In your VRCAuthProxy repository, go to **Settings** > **Secrets and variables** > **Actions**
|
||||
2. Create the following secrets:
|
||||
|
||||
- **GIST_ID**
|
||||
- Value: The Gist ID you copied in step 2 above
|
||||
|
||||
- **GIST_SECRET**
|
||||
- Value: A GitHub Personal Access Token (PAT) with `gist` scope
|
||||
- To create a PAT, go to your GitHub account **Settings** > **Developer settings** > **Personal access tokens** > **Tokens (classic)**
|
||||
- Generate a new token with at least the `gist` scope
|
||||
- Copy the token value (you won't be able to see it again after leaving the page)
|
||||
|
||||
3. Update the README.md badge URL
|
||||
- Replace `USER_PLACEHOLDER` with your GitHub username
|
||||
- Replace `GIST_ID_PLACEHOLDER` with your Gist ID
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
After setting up the secrets and updating the README.md, push a commit to the main branch to trigger the workflow that will update the badge status.
|
||||
|
||||
If everything is set up correctly, the badge in the README should display the current test status (passing or failing).
|
||||
64
.github/WORKFLOWS_GUIDE.md
vendored
Normal file
64
.github/WORKFLOWS_GUIDE.md
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# GitHub Actions Workflows Guide
|
||||
|
||||
This document explains the GitHub Actions workflows set up for this project.
|
||||
|
||||
## Available Workflows
|
||||
|
||||
### 1. Run Tests Workflow (`test.yml`)
|
||||
|
||||
This workflow runs on pushes to `main` and `develop` branches, as well as on pull requests to these branches. It:
|
||||
|
||||
- Builds the project
|
||||
- Runs all tests
|
||||
- Generates code coverage reports
|
||||
- Uploads coverage to Codecov
|
||||
- Updates the test status badge (on push to main)
|
||||
|
||||
### 2. Update Badges Workflow (`update-badges.yml`)
|
||||
|
||||
This workflow runs whenever the "Run Tests" workflow completes. It:
|
||||
|
||||
- Updates the test status badge based on the test results (passing/failing)
|
||||
|
||||
### 3. PR Tests Workflow (`pr-test.yml`)
|
||||
|
||||
This workflow runs on pull requests to `main` and `develop` branches. It:
|
||||
|
||||
- Builds the project
|
||||
- Runs all tests
|
||||
- Generates test results and code coverage reports
|
||||
- Comments on the PR with test results
|
||||
- Uploads coverage reports to Codecov
|
||||
|
||||
### 4. Build Docker Image Workflow (existing `build.yml`)
|
||||
|
||||
This workflow runs on pushes to the `main` branch. It:
|
||||
|
||||
- Builds the Docker image
|
||||
- Pushes the image to the GitHub Container Registry
|
||||
|
||||
## Setting Up Badge Functionality
|
||||
|
||||
To enable the badge functionality, follow the instructions in [BADGE_SETUP.md](BADGE_SETUP.md).
|
||||
|
||||
## GitHub Secrets
|
||||
|
||||
The following GitHub Secrets are required:
|
||||
|
||||
- `GIST_SECRET`: A GitHub Personal Access Token with `gist` scope
|
||||
- `GIST_ID`: The ID of the GitHub Gist used to store badge data
|
||||
|
||||
## Badge in README
|
||||
|
||||
The README includes two badges:
|
||||
1. Build Status: Shows the status of the most recent build workflow
|
||||
2. Tests Status: Shows whether the tests are passing or failing
|
||||
|
||||
## Workflow Customization
|
||||
|
||||
You can customize these workflows by editing the YAML files in the `.github/workflows` directory:
|
||||
|
||||
- `.github/workflows/test.yml`
|
||||
- `.github/workflows/update-badges.yml`
|
||||
- `.github/workflows/pr-test.yml`
|
||||
- `.github/workflows/build.yml`
|
||||
48
.github/workflows/pr-test.yml
vendored
Normal file
48
.github/workflows/pr-test.yml
vendored
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
name: PR Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --no-restore
|
||||
|
||||
- name: Test with results
|
||||
run: dotnet test --no-build --verbosity normal --logger "trx;LogFileName=test-results.trx" --results-directory ./TestResults
|
||||
|
||||
- name: Test with coverage
|
||||
run: dotnet test --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./lcov.info
|
||||
|
||||
- name: Upload coverage report to PR
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: ./lcov.info
|
||||
|
||||
- name: Comment PR with test results
|
||||
uses: EnricoMi/publish-unit-test-result-action@v2
|
||||
if: always()
|
||||
with:
|
||||
files: "**/TestResults/*.trx"
|
||||
|
||||
- name: Add Coverage PR Comment
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./lcov.info
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
||||
46
.github/workflows/test.yml
vendored
Normal file
46
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
name: Run Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main, develop ]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '9.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
run: dotnet build --no-restore
|
||||
|
||||
- name: Test with coverage
|
||||
run: dotnet test --no-build --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./lcov.info
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./lcov.info
|
||||
fail_ci_if_error: false
|
||||
|
||||
- name: Create status badge
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
uses: schneegans/dynamic-badges-action@v1.6.0
|
||||
with:
|
||||
auth: ${{ secrets.GIST_SECRET }}
|
||||
gistID: ${{ secrets.GIST_ID }}
|
||||
filename: vrcauthproxy-tests.json
|
||||
label: tests
|
||||
message: passing
|
||||
color: success
|
||||
34
.github/workflows/update-badges.yml
vendored
Normal file
34
.github/workflows/update-badges.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: Update Badges
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Run Tests"]
|
||||
types:
|
||||
- completed
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-badges:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Update test status badge - Success
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
uses: schneegans/dynamic-badges-action@v1.6.0
|
||||
with:
|
||||
auth: ${{ secrets.GIST_SECRET }}
|
||||
gistID: ${{ secrets.GIST_ID }}
|
||||
filename: vrcauthproxy-tests.json
|
||||
label: tests
|
||||
message: passing
|
||||
color: success
|
||||
|
||||
- name: Update test status badge - Failure
|
||||
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
|
||||
uses: schneegans/dynamic-badges-action@v1.6.0
|
||||
with:
|
||||
auth: ${{ secrets.GIST_SECRET }}
|
||||
gistID: ${{ secrets.GIST_ID }}
|
||||
filename: vrcauthproxy-tests.json
|
||||
label: tests
|
||||
message: failing
|
||||
color: critical
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -42,4 +42,6 @@ msbuild.log
|
|||
msbuild.err
|
||||
msbuild.wrn
|
||||
|
||||
VRCAuthProxy/appsettings.json
|
||||
VRCAuthProxy/appsettings.json
|
||||
authproxy.json
|
||||
creds.env
|
||||
15
Dockerfile.test
Normal file
15
Dockerfile.test
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy csproj and restore dependencies
|
||||
COPY *.sln ./
|
||||
COPY VRCAuthProxy/*.csproj ./VRCAuthProxy/
|
||||
COPY Tests/*.csproj ./Tests/
|
||||
RUN dotnet restore
|
||||
|
||||
# Copy the remaining files
|
||||
COPY . ./
|
||||
|
||||
# Run tests
|
||||
ENTRYPOINT ["dotnet", "test", "--logger:console"]
|
||||
69
README.md
69
README.md
|
|
@ -1,6 +1,9 @@
|
|||
# VRCAuthProxy
|
||||
#### A VRChat API Authorization Proxy Service
|
||||
|
||||
[](https://github.com/PrideVRInc/VRCAuthProxy/actions/workflows/build.yml)
|
||||
[](https://github.com/PrideVRInc/VRCAuthProxy/actions/workflows/test.yml)
|
||||
|
||||
This authorization proxy service is for consuming the VRChat API in a multi-application / microservice architecture. Configure the proxy with the credentials for accounts you use to make API calls and direct your API clients to the proxy service instead of the VRChat API. The proxy server will handle the authorization call flow and caching of the authorization tokens for subsequent authorized calls.
|
||||
|
||||
## Build Steps
|
||||
|
|
@ -49,6 +52,72 @@ services:
|
|||
timeout: 10s
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
This section provides instructions on how to run the unit and integration tests for the VRCAuthProxy project.
|
||||
|
||||
### Running Tests Locally
|
||||
|
||||
To run the tests locally, ensure you have the .NET SDK installed. You can execute the tests using the following command:
|
||||
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
For code coverage reports, you can use:
|
||||
|
||||
```bash
|
||||
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./lcov.info
|
||||
```
|
||||
|
||||
### Running Tests in a Docker Container
|
||||
|
||||
To run the tests in a Docker container, follow these steps:
|
||||
|
||||
1. **Build the Docker Image**: Ensure you have the Dockerfile set up correctly in your project root. Build the Docker image using the following command:
|
||||
|
||||
```bash
|
||||
docker build -t vrcauthproxy-test -f Dockerfile.test .
|
||||
```
|
||||
|
||||
2. **Run the Tests**: Execute the tests inside the Docker container with the following command:
|
||||
|
||||
```bash
|
||||
docker run --rm vrcauthproxy-test dotnet test
|
||||
```
|
||||
|
||||
This command will run the tests in the container and output the results to your terminal.
|
||||
|
||||
### Creating a Dockerfile.test
|
||||
|
||||
You can create a dedicated Dockerfile for testing:
|
||||
|
||||
```dockerfile
|
||||
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy csproj and restore dependencies
|
||||
COPY *.sln ./
|
||||
COPY VRCAuthProxy/*.csproj ./VRCAuthProxy/
|
||||
COPY Tests/*.csproj ./Tests/
|
||||
RUN dotnet restore
|
||||
|
||||
# Copy the remaining files
|
||||
COPY . ./
|
||||
|
||||
# Run tests
|
||||
ENTRYPOINT ["dotnet", "test", "--logger:console"]
|
||||
```
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
The test suite is designed to be run in CI/CD pipelines. Include the following in your GitHub workflow:
|
||||
|
||||
```yaml
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./lcov.info
|
||||
```
|
||||
|
||||
## LICENSE
|
||||
MPL-2.0 with Addendum
|
||||
|
|
|
|||
45
Tests/Helpers/TestSetup.cs
Normal file
45
Tests/Helpers/TestSetup.cs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
using System.Net;
|
||||
using WireMock.Server;
|
||||
|
||||
namespace VRCAuthProxy.Tests.Helpers
|
||||
{
|
||||
public class TestSetup : IDisposable
|
||||
{
|
||||
public WireMockServer MockVRChatApi { get; private set; }
|
||||
|
||||
public TestSetup()
|
||||
{
|
||||
// Start WireMock server to mock VRChat API
|
||||
MockVRChatApi = WireMockServer.Start();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
MockVRChatApi?.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test configuration with mock accounts
|
||||
/// </summary>
|
||||
public static Config CreateTestConfig()
|
||||
{
|
||||
var config = new Config();
|
||||
config.Accounts = new List<ConfigAccount>
|
||||
{
|
||||
new ConfigAccount
|
||||
{
|
||||
username = "testuser1",
|
||||
password = "testpassword1",
|
||||
totpSecret = "TESTSECRET1"
|
||||
},
|
||||
new ConfigAccount
|
||||
{
|
||||
username = "testuser2",
|
||||
password = "testpassword2"
|
||||
}
|
||||
};
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
}
|
||||
117
Tests/Integration/ProxyIntegrationTests.cs
Normal file
117
Tests/Integration/ProxyIntegrationTests.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using VRCAuthProxy.Tests.Helpers;
|
||||
using VRCAuthProxy.types;
|
||||
using WireMock.RequestBuilders;
|
||||
using WireMock.ResponseBuilders;
|
||||
using Xunit;
|
||||
|
||||
namespace VRCAuthProxy.Tests.Integration
|
||||
{
|
||||
public class ProxyIntegrationTests : IClassFixture<TestSetup>
|
||||
{
|
||||
private readonly TestSetup _testSetup;
|
||||
|
||||
public ProxyIntegrationTests(TestSetup testSetup)
|
||||
{
|
||||
_testSetup = testSetup;
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires running application - for manual testing only")]
|
||||
public void ProxyAPI_ShouldForwardRequests_ToVRChatAPI()
|
||||
{
|
||||
// This test requires a running application instance
|
||||
// Skipping for automated test runs
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task API_ReturnsMockDataForAuthorizedUser()
|
||||
{
|
||||
// Arrange
|
||||
var mockServer = _testSetup.MockVRChatApi;
|
||||
|
||||
// Mock a successful authentication
|
||||
mockServer.Given(Request.Create()
|
||||
.WithPath("/api/1/auth/user")
|
||||
.UsingGet())
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithBodyAsJson(new User { displayName = "TestUser" }));
|
||||
|
||||
// Mock an API endpoint that will be called with auth token
|
||||
mockServer.Given(Request.Create()
|
||||
.WithPath("/api/1/users")
|
||||
.UsingGet())
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithBody(@"[{""id"":""usr_test1"",""displayName"":""TestUser1""},{""id"":""usr_test2"",""displayName"":""TestUser2""}]"));
|
||||
|
||||
// Create a client
|
||||
var httpClient = new HttpClient();
|
||||
|
||||
// Call the users endpoint directly
|
||||
var baseUri = mockServer.Urls.First();
|
||||
var response = await httpClient.GetAsync($"{baseUri}/api/1/users");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
responseContent.Should().Contain("TestUser1");
|
||||
responseContent.Should().Contain("TestUser2");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task API_CanHandleAuthTokenBasedRequests()
|
||||
{
|
||||
// Arrange
|
||||
var mockServer = _testSetup.MockVRChatApi;
|
||||
|
||||
// Mock authentication responses
|
||||
mockServer.Given(Request.Create()
|
||||
.WithPath("/api/1/auth/user")
|
||||
.UsingGet())
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithBodyAsJson(new User { displayName = "Account1" }));
|
||||
|
||||
// Mock the worlds endpoint
|
||||
mockServer.Given(Request.Create()
|
||||
.WithPath("/api/1/worlds")
|
||||
.UsingGet())
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithBody(@"[{""id"":""wrld_test1"",""name"":""TestWorld1""},{""id"":""wrld_test2"",""name"":""TestWorld2""}]"));
|
||||
|
||||
// Create a client
|
||||
var httpClient = new HttpClient();
|
||||
|
||||
// First authenticate
|
||||
var baseUri = mockServer.Urls.First();
|
||||
await httpClient.GetAsync($"{baseUri}/api/1/auth/user");
|
||||
|
||||
// Then call the worlds endpoint
|
||||
var response = await httpClient.GetAsync($"{baseUri}/api/1/worlds");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
responseContent.Should().Contain("TestWorld1");
|
||||
responseContent.Should().Contain("TestWorld2");
|
||||
}
|
||||
|
||||
[Fact(Skip = "Requires running application - for manual testing only")]
|
||||
public void WebSocketProxy_ShouldPassThrough_WebSocketMessages()
|
||||
{
|
||||
// This test requires a running application instance
|
||||
// Skipping for automated test runs
|
||||
}
|
||||
}
|
||||
}
|
||||
136
Tests/Integration/VRChatAuthenticationTests.cs
Normal file
136
Tests/Integration/VRChatAuthenticationTests.cs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
using System.Net;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using VRCAuthProxy.Tests.Helpers;
|
||||
using VRCAuthProxy.types;
|
||||
using WireMock.RequestBuilders;
|
||||
using WireMock.ResponseBuilders;
|
||||
using Xunit;
|
||||
|
||||
namespace VRCAuthProxy.Tests.Integration
|
||||
{
|
||||
public class VRChatAuthenticationTests : IClassFixture<TestSetup>
|
||||
{
|
||||
private readonly TestSetup _testSetup;
|
||||
|
||||
public VRChatAuthenticationTests(TestSetup testSetup)
|
||||
{
|
||||
_testSetup = testSetup;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authentication_WithValidCredentials_ShouldReturnUserInfo()
|
||||
{
|
||||
// Arrange
|
||||
var mockServer = _testSetup.MockVRChatApi;
|
||||
|
||||
// Mock the authentication endpoint
|
||||
mockServer.Given(Request.Create()
|
||||
.WithPath("/api/1/auth/user")
|
||||
.UsingGet()
|
||||
.WithHeader("Authorization", "*")) // Accept any authorization header
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithHeader("Set-Cookie", "auth=test-auth-token; path=/; secure; httponly")
|
||||
.WithBodyAsJson(new User { displayName = "TestUser" }));
|
||||
|
||||
// Create a client with the test setup
|
||||
var handler = new HttpClientHandler { UseCookies = true };
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(mockServer.Urls.First())
|
||||
};
|
||||
|
||||
// Act
|
||||
string username = "testuser";
|
||||
string password = "testpassword";
|
||||
string authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/api/1/auth/user");
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authString);
|
||||
var response = await httpClient.SendAsync(request);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
// Verify the response can be parsed
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
var user = JsonSerializer.Deserialize<User>(responseContent);
|
||||
user.Should().NotBeNull();
|
||||
user!.displayName.Should().Be("TestUser");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Authentication_WithTOTP_ShouldVerifyAndProvideUserInfo()
|
||||
{
|
||||
// Arrange
|
||||
var mockServer = _testSetup.MockVRChatApi;
|
||||
|
||||
// Mock API behavior when TOTP is required
|
||||
mockServer.Given(Request.Create()
|
||||
.WithPath("/api/1/auth/user")
|
||||
.UsingGet()
|
||||
.WithHeader("Authorization", "*"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithBody(@"{""requiresTwoFactorAuth"":true,""totp"":true}"));
|
||||
|
||||
// Mock TOTP verification endpoint
|
||||
mockServer.Given(Request.Create()
|
||||
.WithPath("/api/1/auth/twofactorauth/totp/verify")
|
||||
.UsingPost()
|
||||
.WithBody(x => x.Contains("code")))
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithHeader("Set-Cookie", "auth=totp-verified-token; path=/; secure; httponly")
|
||||
.WithBodyAsJson(new TotpVerifyResponse { verified = true }));
|
||||
|
||||
// Mock user info after successful TOTP verification
|
||||
mockServer.Given(Request.Create()
|
||||
.WithPath("/api/1/auth/user")
|
||||
.UsingGet()
|
||||
.WithCookie("auth", "totp-verified-token"))
|
||||
.RespondWith(Response.Create()
|
||||
.WithStatusCode(200)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithBodyAsJson(new User { displayName = "TOTPVerifiedUser" }));
|
||||
|
||||
// Create a client with the test setup
|
||||
var handler = new HttpClientHandler { UseCookies = true };
|
||||
var httpClient = new HttpClient(handler)
|
||||
{
|
||||
BaseAddress = new Uri(mockServer.Urls.First())
|
||||
};
|
||||
|
||||
// Act - First authenticate which will indicate TOTP is required
|
||||
string username = "totpuser";
|
||||
string password = "totppassword";
|
||||
string authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, "/api/1/auth/user");
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", authString);
|
||||
var response = await httpClient.SendAsync(request);
|
||||
|
||||
// Assert initial response
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var initialContent = await response.Content.ReadAsStringAsync();
|
||||
initialContent.Should().Contain("requiresTwoFactorAuth");
|
||||
|
||||
// Now verify with TOTP
|
||||
var verifyReq = new HttpRequestMessage(HttpMethod.Post, "/api/1/auth/twofactorauth/totp/verify");
|
||||
verifyReq.Content = new StringContent(@"{""code"":""123456""}", Encoding.UTF8, "application/json");
|
||||
var verifyResp = await httpClient.SendAsync(verifyReq);
|
||||
|
||||
// Assert TOTP verification
|
||||
verifyResp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var verifyContent = await verifyResp.Content.ReadAsStringAsync();
|
||||
var verifyResult = JsonSerializer.Deserialize<TotpVerifyResponse>(verifyContent);
|
||||
verifyResult.Should().NotBeNull();
|
||||
verifyResult!.verified.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
95
Tests/TestingStrategy.md
Normal file
95
Tests/TestingStrategy.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# VRCAuthProxy Testing Strategy
|
||||
|
||||
This document outlines the comprehensive testing strategy for VRCAuthProxy, a proxy service for interacting with the VRChat API.
|
||||
|
||||
## Overview
|
||||
|
||||
The testing strategy for VRCAuthProxy incorporates both unit tests and integration tests to ensure the reliability and robustness of the proxy service. The selected testing frameworks were chosen based on their active development status, community support, and compatibility with .NET 8.0.
|
||||
|
||||
## Test Project Structure
|
||||
|
||||
The test project is organized into several directories:
|
||||
|
||||
```
|
||||
Tests/
|
||||
├── Unit/ # Unit tests
|
||||
│ ├── ConfigTests.cs
|
||||
│ └── HttpClientCookieContainerTests.cs
|
||||
├── Integration/ # Integration tests
|
||||
│ ├── VRChatAuthenticationTests.cs
|
||||
│ └── ProxyIntegrationTests.cs
|
||||
├── Helpers/ # Test helpers and fixtures
|
||||
│ └── TestSetup.cs
|
||||
└── VRCAuthProxy.Tests.csproj
|
||||
```
|
||||
|
||||
## Selected Testing Frameworks
|
||||
|
||||
The following frameworks were selected for their recent updates, robust feature sets, and compatibility with the VRCAuthProxy codebase:
|
||||
|
||||
1. **xUnit (v2.6.6)** - Modern test framework for .NET with excellent parallel test execution.
|
||||
2. **Moq (v4.20.70)** - Popular mocking framework for isolating components in unit tests.
|
||||
3. **FluentAssertions (v6.12.0)** - More readable assertions with excellent error messages.
|
||||
4. **WireMock.NET (v1.5.44)** - HTTP mocking library for simulating the VRChat API.
|
||||
5. **Microsoft.AspNetCore.Mvc.Testing (v8.0.3)** - Framework for testing ASP.NET Core applications without mocking the HTTP pipeline.
|
||||
6. **coverlet.collector (v6.0.0)** - Code coverage tool for measuring test coverage.
|
||||
|
||||
## Testing Approach
|
||||
|
||||
### Unit Tests
|
||||
Unit tests focus on testing individual components in isolation:
|
||||
- Configuration loading and saving
|
||||
- Cookie container handling
|
||||
- Authentication flows
|
||||
|
||||
### Integration Tests
|
||||
Integration tests verify the interaction between components:
|
||||
- End-to-end proxy request handling
|
||||
- Authentication with the VRChat API
|
||||
- Rate limiting and account rotation
|
||||
- WebSocket proxying
|
||||
|
||||
### Mocking Strategy
|
||||
- The VRChat API is mocked using WireMock.NET to simulate various responses:
|
||||
- Authentication success/failure
|
||||
- TOTP verification
|
||||
- Rate limiting
|
||||
- Successful API responses
|
||||
|
||||
## Running Tests
|
||||
|
||||
Execute the tests using the following command:
|
||||
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
For code coverage reports:
|
||||
|
||||
```bash
|
||||
dotnet test /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./lcov.info
|
||||
```
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
The test suite is designed to be run in CI/CD pipelines. Include the following in your GitHub workflow:
|
||||
|
||||
```yaml
|
||||
- name: Test
|
||||
run: dotnet test --no-restore --verbosity normal /p:CollectCoverage=true /p:CoverletOutputFormat=lcov /p:CoverletOutput=./lcov.info
|
||||
```
|
||||
|
||||
## VRChat API Integration
|
||||
|
||||
The tests are designed to work with the VRChat API by:
|
||||
1. Mocking the API responses to simulate real-world scenarios
|
||||
2. Testing authentication flows including two-factor authentication
|
||||
3. Verifying that the proxy correctly handles rate limiting
|
||||
4. Ensuring proper WebSocket proxying
|
||||
|
||||
## Areas for Future Improvement
|
||||
|
||||
1. **Load testing** - Add tests to verify performance under high load
|
||||
2. **Security testing** - Add tests to verify that authentication tokens are properly handled
|
||||
3. **Failure recovery testing** - Enhance tests for VRChat API outages
|
||||
4. **Cross-platform testing** - Verify functionality across different operating systems
|
||||
125
Tests/Unit/ConfigTests.cs
Normal file
125
Tests/Unit/ConfigTests.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace VRCAuthProxy.Tests.Unit
|
||||
{
|
||||
public class ConfigTests
|
||||
{
|
||||
[Fact]
|
||||
public void Config_LoadsAccountsFromJsonFile()
|
||||
{
|
||||
// Arrange
|
||||
var tempFile = Path.GetTempFileName();
|
||||
var configData = new
|
||||
{
|
||||
accounts = new object[]
|
||||
{
|
||||
new { username = "user1", password = "pass1", totpSecret = "secret1" },
|
||||
new { username = "user2", password = "pass2" }
|
||||
}
|
||||
};
|
||||
|
||||
File.WriteAllText(tempFile, JsonSerializer.Serialize(configData));
|
||||
|
||||
try
|
||||
{
|
||||
// Override the default config path for testing
|
||||
string originalConfigPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
string backupPath = originalConfigPath + ".bak";
|
||||
|
||||
if (File.Exists(originalConfigPath))
|
||||
{
|
||||
File.Move(originalConfigPath, backupPath, true);
|
||||
}
|
||||
|
||||
File.Copy(tempFile, originalConfigPath, true);
|
||||
|
||||
// Act
|
||||
var config = Config.Load();
|
||||
|
||||
// Assert
|
||||
config.Should().NotBeNull();
|
||||
config.Accounts.Should().HaveCount(2);
|
||||
|
||||
config.Accounts[0].username.Should().Be("user1");
|
||||
config.Accounts[0].password.Should().Be("pass1");
|
||||
config.Accounts[0].totpSecret.Should().Be("secret1");
|
||||
|
||||
config.Accounts[1].username.Should().Be("user2");
|
||||
config.Accounts[1].password.Should().Be("pass2");
|
||||
config.Accounts[1].totpSecret.Should().BeNull();
|
||||
|
||||
// Restore original config
|
||||
if (File.Exists(backupPath))
|
||||
{
|
||||
File.Move(backupPath, originalConfigPath, true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up the temp file
|
||||
if (File.Exists(tempFile))
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Config_Save_WritesConfigToFile()
|
||||
{
|
||||
// Arrange
|
||||
var tempFile = Path.GetTempFileName();
|
||||
var originalConfigPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
|
||||
var backupPath = originalConfigPath + ".bak";
|
||||
|
||||
if (File.Exists(originalConfigPath))
|
||||
{
|
||||
File.Move(originalConfigPath, backupPath, true);
|
||||
}
|
||||
|
||||
File.Copy(tempFile, originalConfigPath, true);
|
||||
|
||||
try
|
||||
{
|
||||
var config = new Config
|
||||
{
|
||||
Accounts = new List<ConfigAccount>
|
||||
{
|
||||
new ConfigAccount { username = "test1", password = "pass1", totpSecret = "secret1" }
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
config.Save();
|
||||
|
||||
// Assert
|
||||
File.Exists(originalConfigPath).Should().BeTrue();
|
||||
|
||||
var loadedConfig = JsonSerializer.Deserialize<iConfig>(File.ReadAllText(originalConfigPath));
|
||||
if (loadedConfig != null)
|
||||
{
|
||||
loadedConfig.accounts.Should().HaveCount(1);
|
||||
loadedConfig.accounts[0].username.Should().Be("test1");
|
||||
loadedConfig.accounts[0].password.Should().Be("pass1");
|
||||
loadedConfig.accounts[0].totpSecret.Should().Be("secret1");
|
||||
}
|
||||
|
||||
// Restore original config
|
||||
if (File.Exists(backupPath))
|
||||
{
|
||||
File.Move(backupPath, originalConfigPath, true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up the temp file
|
||||
if (File.Exists(tempFile))
|
||||
{
|
||||
File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
41
Tests/Unit/HttpClientCookieContainerTests.cs
Normal file
41
Tests/Unit/HttpClientCookieContainerTests.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
using System.Net;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace VRCAuthProxy.Tests.Unit
|
||||
{
|
||||
public class HttpClientCookieContainerTests
|
||||
{
|
||||
[Fact]
|
||||
public void HttpClientCookieContainer_ExposesCookieContainer()
|
||||
{
|
||||
// Arrange
|
||||
var cookieContainer = new CookieContainer();
|
||||
var handler = new HttpClientHandler { CookieContainer = cookieContainer };
|
||||
|
||||
// Act
|
||||
var client = new HttpClientCookieContainer(handler);
|
||||
|
||||
// Assert
|
||||
client.CookieContainer.Should().BeSameAs(cookieContainer);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CookieContainer_CanStoreAndRetrieveCookies()
|
||||
{
|
||||
// Arrange
|
||||
var cookieContainer = new CookieContainer();
|
||||
var uri = new Uri("https://test.com");
|
||||
|
||||
// Act
|
||||
cookieContainer.Add(uri, new Cookie("auth", "test-auth-token"));
|
||||
|
||||
// Assert
|
||||
var cookies = cookieContainer.GetCookies(uri);
|
||||
cookies.Count.Should().Be(1);
|
||||
var authCookie = cookies["auth"];
|
||||
authCookie.Should().NotBeNull();
|
||||
authCookie!.Value.Should().Be("test-auth-token");
|
||||
}
|
||||
}
|
||||
}
|
||||
32
Tests/VRCAuthProxy.Tests.csproj
Normal file
32
Tests/VRCAuthProxy.Tests.csproj
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
|
||||
<PackageReference Include="xunit" Version="2.6.6" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.12.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.0" />
|
||||
<PackageReference Include="WireMock.Net" Version="1.5.44" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VRCAuthProxy\VRCAuthProxy.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRCAuthProxy", "VRCAuthProxy\VRCAuthProxy.csproj", "{BB549439-B34F-4BCA-B8FC-173114631646}"
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.002.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "VRCAuthProxy", "VRCAuthProxy\VRCAuthProxy.csproj", "{AE86A3C0-62A5-45DE-AFDD-7DBE3D51B7C1}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VRCAuthProxy.Tests", "Tests\VRCAuthProxy.Tests.csproj", "{6A48DF7F-9BC2-446A-B181-F67F3B0E45FB}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
|
|
@ -8,9 +12,19 @@ Global
|
|||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{BB549439-B34F-4BCA-B8FC-173114631646}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{BB549439-B34F-4BCA-B8FC-173114631646}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{BB549439-B34F-4BCA-B8FC-173114631646}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{BB549439-B34F-4BCA-B8FC-173114631646}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{AE86A3C0-62A5-45DE-AFDD-7DBE3D51B7C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{AE86A3C0-62A5-45DE-AFDD-7DBE3D51B7C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{AE86A3C0-62A5-45DE-AFDD-7DBE3D51B7C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{AE86A3C0-62A5-45DE-AFDD-7DBE3D51B7C1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6A48DF7F-9BC2-446A-B181-F67F3B0E45FB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6A48DF7F-9BC2-446A-B181-F67F3B0E45FB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6A48DF7F-9BC2-446A-B181-F67F3B0E45FB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6A48DF7F-9BC2-446A-B181-F67F3B0E45FB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {F3A2C6CA-3D72-4CE3-807B-73D6C15AC49A}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@
|
|||
|
||||
namespace VRCAuthProxy;
|
||||
|
||||
class HttpClientCookieContainer(HttpClientHandler handler) : HttpClient(handler)
|
||||
public class HttpClientCookieContainer(HttpClientHandler handler) : HttpClient(handler)
|
||||
{
|
||||
|
||||
public CookieContainer CookieContainer => handler.CookieContainer;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.App" Version="2.2.8" />
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue