diff --git a/Tests/Helpers/RedisFixture.cs b/Tests/Helpers/RedisFixture.cs
new file mode 100644
index 0000000..f22ee72
--- /dev/null
+++ b/Tests/Helpers/RedisFixture.cs
@@ -0,0 +1,78 @@
+using System.Diagnostics;
+using Xunit;
+
+namespace VRCAuthProxy.Tests.Helpers
+{
+ ///
+ /// Collection definition for Redis tests
+ ///
+ [CollectionDefinition("Redis")]
+ public class RedisCollection : ICollectionFixture
+ {
+ // This class has no code, and is never created. Its purpose is simply
+ // to be the place to apply [CollectionDefinition] and all the
+ // ICollectionFixture<> interfaces.
+ }
+
+ ///
+ /// Redis test fixture that ensures a Redis instance is available for tests
+ ///
+ public class RedisFixture : IDisposable
+ {
+ private Process? _redisProcess; // Kept for future implementation where we might start Redis
+ private bool _disposedValue;
+
+ public RedisFixture()
+ {
+ // For CI environments, this would start Redis using Docker
+ // For local development, we assume Redis is already running
+
+ // Check if Redis is available
+ if (!IsRedisRunning())
+ {
+ // In a real implementation, we could start Redis here if needed
+ Console.WriteLine("Warning: Redis is not running. Redis integration tests will be skipped.");
+ }
+ }
+
+ ///
+ /// Check if Redis is available on localhost:6379
+ ///
+ private bool IsRedisRunning()
+ {
+ try
+ {
+ using var client = new System.Net.Sockets.TcpClient();
+ var result = client.BeginConnect("localhost", 6379, null, null);
+ var success = result.AsyncWaitHandle.WaitOne(TimeSpan.FromSeconds(1));
+ client.EndConnect(result);
+ return success;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (!_disposedValue)
+ {
+ if (disposing)
+ {
+ // Stop Redis if we started it
+ _redisProcess?.Kill();
+ _redisProcess?.Dispose();
+ }
+
+ _disposedValue = true;
+ }
+ }
+
+ public void Dispose()
+ {
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Integration/ProgramRedisIntegrationTests.cs b/Tests/Integration/ProgramRedisIntegrationTests.cs
new file mode 100644
index 0000000..c2f9304
--- /dev/null
+++ b/Tests/Integration/ProgramRedisIntegrationTests.cs
@@ -0,0 +1,143 @@
+using System.Net;
+using System.Text;
+using FluentAssertions;
+using VRCAuthProxy.Services;
+using VRCAuthProxy.Tests.Helpers;
+using WireMock.RequestBuilders;
+using WireMock.ResponseBuilders;
+using Xunit;
+
+namespace VRCAuthProxy.Tests.Integration
+{
+ [Trait("Category", "Integration")]
+ [Collection("Redis")]
+ public class ProgramRedisIntegrationTests : IClassFixture
+ {
+ private readonly TestSetup _testSetup;
+ private readonly RedisService _redisService;
+
+ public ProgramRedisIntegrationTests(TestSetup testSetup)
+ {
+ _testSetup = testSetup;
+ _redisService = new RedisService("localhost:6379");
+ }
+
+ [Fact(Skip = "Requires Redis instance - for manual integration testing")]
+ public async Task LoginShouldStoreAuthTokenInRedis()
+ {
+ // Arrange
+ var mockServer = _testSetup.MockVRChatApi;
+ var testUsername = "redis-test-user";
+ var testPassword = "redis-test-password";
+
+ // Clean up any existing tokens
+ await _redisService.RemoveAuthToken(testUsername);
+
+ // Mock the authentication endpoint
+ mockServer.Given(Request.Create()
+ .WithPath("/api/1/auth/user")
+ .UsingGet()
+ .WithHeader("Authorization", "*"))
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithHeader("Set-Cookie", "auth=test-redis-auth-token; path=/; secure; httponly")
+ .WithBodyAsJson(new VRCAuthProxy.types.User { displayName = "RedisTestUser" }));
+
+ var mockServerUrl = mockServer.Urls.First();
+
+ // Create a config with test account
+ var config = TestSetup.CreateTestConfig();
+ config.Accounts.Clear();
+ config.Accounts.Add(new ConfigAccount
+ {
+ username = testUsername,
+ password = testPassword
+ });
+
+ // Act - Call the login method (simplified version of what Program.cs does)
+ var handler = new HttpClientHandler { UseCookies = true };
+ var cookieContainer = handler.CookieContainer;
+ var httpClient = new HttpClientCookieContainer(handler)
+ {
+ BaseAddress = new Uri(mockServerUrl),
+ Username = testUsername
+ };
+
+ // Simulate login process
+ string authString = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{testUsername}:{testPassword}"));
+ 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);
+
+ // Store cookies in Redis (like in Program.cs)
+ var cookies = cookieContainer.GetAllCookies().Cast()
+ .ToDictionary(c => c.Name, c => c.Value);
+ await _redisService.SaveAuthToken(testUsername, cookies);
+
+ // Assert
+ var storedToken = await _redisService.GetAuthToken(testUsername);
+ storedToken.Should().NotBeNull();
+ if (storedToken != null)
+ {
+ storedToken.Should().ContainKey("auth");
+ storedToken["auth"].Should().Be("test-redis-auth-token");
+ }
+
+ // Clean up
+ await _redisService.RemoveAuthToken(testUsername);
+ }
+
+ [Fact(Skip = "Requires Redis instance - for manual integration testing")]
+ public async Task LoginWithStoredTokenShouldReuseRedisToken()
+ {
+ // Arrange
+ var mockServer = _testSetup.MockVRChatApi;
+ var testUsername = "redis-existing-user";
+
+ // Setup stored token in Redis
+ var existingCookies = new Dictionary
+ {
+ { "auth", "existing-redis-token" }
+ };
+ await _redisService.SaveAuthToken(testUsername, existingCookies);
+
+ // Mock the user endpoint for a successful request with the token
+ mockServer.Given(Request.Create()
+ .WithPath("/api/1/auth/user")
+ .UsingGet()
+ .WithCookie("auth", "existing-redis-token"))
+ .RespondWith(Response.Create()
+ .WithStatusCode(200)
+ .WithHeader("Content-Type", "application/json")
+ .WithBodyAsJson(new VRCAuthProxy.types.User { displayName = "ExistingRedisUser" }));
+
+ var mockServerUrl = mockServer.Urls.First();
+
+ // Act - Simulate restoring cookies and making request
+ var handler = new HttpClientHandler { UseCookies = true };
+ var cookieContainer = handler.CookieContainer;
+
+ // Restore cookies from Redis
+ var storedCookies = await _redisService.GetAuthToken(testUsername);
+ if (storedCookies != null)
+ {
+ foreach (var cookie in storedCookies)
+ {
+ cookieContainer.Add(new Uri(mockServerUrl), new Cookie(cookie.Key, cookie.Value));
+ }
+ }
+
+ var httpClient = new HttpClient(handler);
+ var response = await httpClient.GetAsync($"{mockServerUrl}/api/1/auth/user");
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ var responseContent = await response.Content.ReadAsStringAsync();
+ responseContent.Should().Contain("ExistingRedisUser");
+
+ // Clean up
+ await _redisService.RemoveAuthToken(testUsername);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Integration/RedisIntegrationTests.cs b/Tests/Integration/RedisIntegrationTests.cs
new file mode 100644
index 0000000..279b361
--- /dev/null
+++ b/Tests/Integration/RedisIntegrationTests.cs
@@ -0,0 +1,104 @@
+using System.Text.Json;
+using VRCAuthProxy.Services;
+using Xunit;
+using FluentAssertions;
+
+namespace VRCAuthProxy.Tests.Integration
+{
+ [Trait("Category", "Integration")]
+ [Collection("Redis")]
+ public class RedisIntegrationTests
+ {
+ private readonly RedisService _redisService;
+ private const string TEST_USERNAME = "testuser";
+
+ public RedisIntegrationTests()
+ {
+ // For integration tests, use the actual Redis service
+ // This assumes Redis is running locally or in a Docker container
+ // as configured in docker-compose.yml (accessible at localhost:6379)
+ _redisService = new RedisService("localhost:6379");
+
+ // Clean up any previous test data
+ _redisService.RemoveAuthToken(TEST_USERNAME).GetAwaiter().GetResult();
+ }
+
+ [Fact(Skip = "Requires Redis instance - for manual integration testing")]
+ public async Task SaveAndGetAuthToken_ShouldStoreAndRetrieveTokens()
+ {
+ // Arrange
+ var cookies = new Dictionary
+ {
+ { "auth", "integration-test-token" },
+ { "session", "integration-test-session" }
+ };
+
+ // Act - Save token
+ await _redisService.SaveAuthToken(TEST_USERNAME, cookies);
+
+ // Act - Retrieve token
+ var retrievedCookies = await _redisService.GetAuthToken(TEST_USERNAME);
+
+ // Assert
+ retrievedCookies.Should().NotBeNull();
+ if (retrievedCookies != null)
+ {
+ retrievedCookies.Should().ContainKey("auth");
+ retrievedCookies.Should().ContainKey("session");
+ retrievedCookies["auth"].Should().Be("integration-test-token");
+ retrievedCookies["session"].Should().Be("integration-test-session");
+ }
+ }
+
+ [Fact(Skip = "Requires Redis instance - for manual integration testing")]
+ public async Task RemoveAuthToken_ShouldDeleteTokenFromRedis()
+ {
+ // Arrange
+ var cookies = new Dictionary
+ {
+ { "auth", "token-to-delete" }
+ };
+ await _redisService.SaveAuthToken(TEST_USERNAME, cookies);
+
+ // Verify token exists
+ var tokenBeforeDelete = await _redisService.GetAuthToken(TEST_USERNAME);
+ tokenBeforeDelete.Should().NotBeNull();
+
+ // Act
+ await _redisService.RemoveAuthToken(TEST_USERNAME);
+
+ // Assert
+ var tokenAfterDelete = await _redisService.GetAuthToken(TEST_USERNAME);
+ tokenAfterDelete.Should().BeNull();
+ }
+
+ [Fact(Skip = "Requires Redis instance - for manual integration testing")]
+ public async Task GetAllAuthTokens_ShouldReturnAllStoredTokens()
+ {
+ // Arrange
+ var user1 = "testuser1";
+ var user2 = "testuser2";
+
+ // Clean up
+ await _redisService.RemoveAuthToken(user1);
+ await _redisService.RemoveAuthToken(user2);
+
+ // Add test data
+ await _redisService.SaveAuthToken(user1, new Dictionary { { "auth", "token1" } });
+ await _redisService.SaveAuthToken(user2, new Dictionary { { "auth", "token2" } });
+
+ // Act
+ var allTokens = await _redisService.GetAllAuthTokens();
+
+ // Assert
+ allTokens.Should().ContainKey(user1);
+ allTokens.Should().ContainKey(user2);
+ allTokens[user1]["auth"].Should().Be("token1");
+ allTokens[user2]["auth"].Should().Be("token2");
+
+ // Cleanup
+ await _redisService.RemoveAuthToken(user1);
+ await _redisService.RemoveAuthToken(user2);
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/Unit/RedisServiceTests.cs b/Tests/Unit/RedisServiceTests.cs
new file mode 100644
index 0000000..140e47c
--- /dev/null
+++ b/Tests/Unit/RedisServiceTests.cs
@@ -0,0 +1,144 @@
+using System.Text.Json;
+using FluentAssertions;
+using VRCAuthProxy.Services;
+using Xunit;
+
+namespace VRCAuthProxy.Tests.Unit
+{
+ public class RedisServiceTests
+ {
+ // Using a minimal implementation for testing
+ private class InMemoryRedisService
+ {
+ private readonly Dictionary> _store = new();
+
+ public Task SaveAuthToken(string username, Dictionary cookies)
+ {
+ _store[username] = new Dictionary(cookies);
+ return Task.CompletedTask;
+ }
+
+ public Task?> GetAuthToken(string username)
+ {
+ if (_store.TryGetValue(username, out var cookies))
+ return Task.FromResult?>(new Dictionary(cookies));
+
+ return Task.FromResult?>(null);
+ }
+
+ public Task>> GetAllAuthTokens()
+ {
+ var result = new Dictionary>();
+
+ foreach (var pair in _store)
+ {
+ result[pair.Key] = new Dictionary(pair.Value);
+ }
+
+ return Task.FromResult(result);
+ }
+
+ public Task RemoveAuthToken(string username)
+ {
+ _store.Remove(username);
+ return Task.CompletedTask;
+ }
+ }
+
+ private readonly InMemoryRedisService _redisService;
+
+ public RedisServiceTests()
+ {
+ _redisService = new InMemoryRedisService();
+ }
+
+ [Fact]
+ public async Task SaveAndGetAuthToken_ShouldStoreAndRetrieveTokens()
+ {
+ // Arrange
+ var username = "testuser";
+ var cookies = new Dictionary
+ {
+ { "auth", "test-auth-token" },
+ { "session", "test-session-token" }
+ };
+
+ // Act - Save token
+ await _redisService.SaveAuthToken(username, cookies);
+
+ // Act - Retrieve token
+ var retrievedCookies = await _redisService.GetAuthToken(username);
+
+ // Assert
+ retrievedCookies.Should().NotBeNull();
+ if (retrievedCookies != null)
+ {
+ retrievedCookies.Should().ContainKey("auth");
+ retrievedCookies.Should().ContainKey("session");
+ retrievedCookies["auth"].Should().Be("test-auth-token");
+ retrievedCookies["session"].Should().Be("test-session-token");
+ }
+ }
+
+ [Fact]
+ public async Task GetAuthToken_WhenTokenDoesNotExist_ShouldReturnNull()
+ {
+ // Arrange
+ var username = "nonexistentuser";
+
+ // Act
+ var result = await _redisService.GetAuthToken(username);
+
+ // Assert
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task GetAllAuthTokens_ShouldReturnAllStoredTokens()
+ {
+ // Arrange
+ var user1 = "testuser1";
+ var user2 = "testuser2";
+
+ // Clear any existing tokens
+ await _redisService.RemoveAuthToken(user1);
+ await _redisService.RemoveAuthToken(user2);
+
+ // Add test data
+ await _redisService.SaveAuthToken(user1, new Dictionary { { "auth", "token1" } });
+ await _redisService.SaveAuthToken(user2, new Dictionary { { "auth", "token2" } });
+
+ // Act
+ var allTokens = await _redisService.GetAllAuthTokens();
+
+ // Assert
+ allTokens.Should().ContainKey(user1);
+ allTokens.Should().ContainKey(user2);
+ allTokens[user1]["auth"].Should().Be("token1");
+ allTokens[user2]["auth"].Should().Be("token2");
+ }
+
+ [Fact]
+ public async Task RemoveAuthToken_ShouldDeleteToken()
+ {
+ // Arrange
+ var username = "testuser";
+ var cookies = new Dictionary
+ {
+ { "auth", "token-to-delete" }
+ };
+ await _redisService.SaveAuthToken(username, cookies);
+
+ // Verify token exists
+ var tokenBeforeDelete = await _redisService.GetAuthToken(username);
+ tokenBeforeDelete.Should().NotBeNull();
+
+ // Act
+ await _redisService.RemoveAuthToken(username);
+
+ // Assert
+ var tokenAfterDelete = await _redisService.GetAuthToken(username);
+ tokenAfterDelete.Should().BeNull();
+ }
+ }
+}
\ No newline at end of file
diff --git a/Tests/VRCAuthProxy.Tests.csproj b/Tests/VRCAuthProxy.Tests.csproj
index 75e082d..d7eae3e 100644
--- a/Tests/VRCAuthProxy.Tests.csproj
+++ b/Tests/VRCAuthProxy.Tests.csproj
@@ -9,9 +9,9 @@
-
-
-
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
all
@@ -22,7 +22,8 @@
-
+
+
diff --git a/VRCAuthProxy/Services/RedisService.cs b/VRCAuthProxy/Services/RedisService.cs
index ca253fd..347bcf8 100644
--- a/VRCAuthProxy/Services/RedisService.cs
+++ b/VRCAuthProxy/Services/RedisService.cs
@@ -15,20 +15,27 @@ public class RedisService
_db = _redis.GetDatabase();
}
- public async Task SaveAuthToken(string username, Dictionary cookies)
+ // Constructor for testing with mocked dependencies
+ internal RedisService(IConnectionMultiplexer redis, IDatabase database)
+ {
+ _redis = redis;
+ _db = database;
+ }
+
+ public virtual async Task SaveAuthToken(string username, Dictionary cookies)
{
var serializedCookies = JsonSerializer.Serialize(cookies);
await _db.HashSetAsync(AUTH_TOKEN_KEY, username, serializedCookies);
}
- public async Task?> GetAuthToken(string username)
+ public virtual async Task?> GetAuthToken(string username)
{
var value = await _db.HashGetAsync(AUTH_TOKEN_KEY, username);
if (!value.HasValue) return null;
return JsonSerializer.Deserialize>(value!);
}
- public async Task>> GetAllAuthTokens()
+ public virtual async Task>> GetAllAuthTokens()
{
var entries = await _db.HashGetAllAsync(AUTH_TOKEN_KEY);
var result = new Dictionary>();
@@ -45,7 +52,7 @@ public class RedisService
return result;
}
- public async Task RemoveAuthToken(string username)
+ public virtual async Task RemoveAuthToken(string username)
{
await _db.HashDeleteAsync(AUTH_TOKEN_KEY, username);
}