/ GUNRPG.Tests / SessionAuthTests.cs
SessionAuthTests.cs
1 using System.Net; 2 using System.Text; 3 using System.Text.Json; 4 using GUNRPG.Application.Backend; 5 using GUNRPG.Application.Identity.Dtos; 6 using GUNRPG.ConsoleClient.Auth; 7 using GUNRPG.ConsoleClient.Identity; 8 using GUNRPG.Infrastructure; 9 using GUNRPG.Infrastructure.Persistence; 10 using Hex1b; 11 using LiteDB; 12 13 namespace GUNRPG.Tests; 14 15 // ─── SessionStore tests ────────────────────────────────────────────────────── 16 17 public sealed class SessionStoreTests : IDisposable 18 { 19 private readonly string _tempDir; 20 private readonly SessionStore _store; 21 22 public SessionStoreTests() 23 { 24 _tempDir = Path.Combine(Path.GetTempPath(), $"gunrpg-ss-test-{Guid.NewGuid():N}"); 25 _store = new SessionStore(_tempDir); 26 } 27 28 public void Dispose() 29 { 30 if (Directory.Exists(_tempDir)) 31 Directory.Delete(_tempDir, recursive: true); 32 } 33 34 [Fact] 35 public async Task SaveAndLoad_RoundTrips_AllSessionFields() 36 { 37 var session = new SessionData("rt-abc", "user-123", DateTimeOffset.UtcNow); 38 39 await _store.SaveAsync(session); 40 var loaded = await _store.LoadAsync(); 41 42 Assert.NotNull(loaded); 43 Assert.Equal("rt-abc", loaded.RefreshToken); 44 Assert.Equal("user-123", loaded.UserId); 45 } 46 47 [Fact] 48 public async Task LoadAsync_ReturnsNull_WhenFileAbsent() 49 { 50 var result = await _store.LoadAsync(); 51 Assert.Null(result); 52 } 53 54 [Fact] 55 public async Task LoadAsync_ReturnsNull_WhenFileCorrupt() 56 { 57 var filePath = Path.Combine(_tempDir, "session.json"); 58 await File.WriteAllTextAsync(filePath, "not valid json {{{{"); 59 60 var result = await _store.LoadAsync(); 61 Assert.Null(result); 62 } 63 64 [Fact] 65 public async Task Delete_RemovesFile() 66 { 67 await _store.SaveAsync(new SessionData("rt-xyz", "u1", DateTimeOffset.UtcNow)); 68 _store.Delete(); 69 70 var result = await _store.LoadAsync(); 71 Assert.Null(result); 72 } 73 74 [Fact] 75 public async Task Delete_IsNoOp_WhenFileAbsent() 76 { 77 // Should not throw. 78 _store.Delete(); 79 var result = await _store.LoadAsync(); 80 Assert.Null(result); 81 } 82 83 [Fact] 84 public async Task SaveAsync_WritesSessionJson_NotAuthJson() 85 { 86 await _store.SaveAsync(new SessionData("my-refresh-token", "uid", DateTimeOffset.UtcNow)); 87 88 // The file must be session.json, not auth.json. 89 Assert.True(File.Exists(Path.Combine(_tempDir, "session.json"))); 90 Assert.False(File.Exists(Path.Combine(_tempDir, "auth.json"))); 91 } 92 93 [Fact] 94 public async Task SaveAsync_PersistedJson_ContainsRequiredFields() 95 { 96 await _store.SaveAsync(new SessionData("refresh-tok", "user-999", DateTimeOffset.UtcNow)); 97 98 var rawJson = await File.ReadAllTextAsync(Path.Combine(_tempDir, "session.json")); 99 using var doc = JsonDocument.Parse(rawJson); 100 var props = doc.RootElement.EnumerateObject().Select(p => p.Name).ToList(); 101 102 Assert.Contains("refreshToken", props); 103 Assert.Contains("userId", props); 104 Assert.Contains("createdAt", props); 105 } 106 107 [Fact] 108 public async Task SaveAsync_PersistedJson_DoesNotContainAccessToken() 109 { 110 await _store.SaveAsync(new SessionData("refresh-tok", "user-999", DateTimeOffset.UtcNow)); 111 112 var rawJson = await File.ReadAllTextAsync(Path.Combine(_tempDir, "session.json")); 113 using var doc = JsonDocument.Parse(rawJson); 114 var props = doc.RootElement.EnumerateObject().Select(p => p.Name).ToList(); 115 116 Assert.DoesNotContain("accessToken", props); 117 } 118 119 [Fact] 120 public async Task SaveAsync_Overwrites_PreviousSession() 121 { 122 await _store.SaveAsync(new SessionData("rt-1", "user-1", DateTimeOffset.UtcNow)); 123 await _store.SaveAsync(new SessionData("rt-2", "user-2", DateTimeOffset.UtcNow)); 124 125 var loaded = await _store.LoadAsync(); 126 Assert.NotNull(loaded); 127 Assert.Equal("rt-2", loaded.RefreshToken); 128 Assert.Equal("user-2", loaded.UserId); 129 } 130 } 131 132 // ─── SessionManager tests ──────────────────────────────────────────────────── 133 134 public sealed class SessionManagerTests : IDisposable 135 { 136 private static readonly JsonSerializerOptions s_json = new(JsonSerializerDefaults.Web); 137 private static readonly object s_userProfileEnvironmentLock = new(); 138 private const string GunrpgConfigDirectoryName = ".gunrpg"; 139 private const string BaseUrl = "https://node.example.com"; 140 141 private readonly string _tempDir; 142 private readonly SessionStore _sessionStore; 143 private readonly TokenStore _tokenStore; 144 145 public SessionManagerTests() 146 { 147 _tempDir = Path.Combine(Path.GetTempPath(), $"gunrpg-sm-test-{Guid.NewGuid():N}"); 148 _sessionStore = new SessionStore(_tempDir); 149 _tokenStore = new TokenStore(_tempDir); 150 } 151 152 public void Dispose() 153 { 154 if (Directory.Exists(_tempDir)) 155 Directory.Delete(_tempDir, recursive: true); 156 } 157 158 // ─── TryAutoLoginAsync ─────────────────────────────────────────────────── 159 160 [Fact] 161 public async Task TryAutoLoginAsync_ReturnsFalse_WhenNoSessionStored() 162 { 163 var (manager, _) = MakeManager(new FakeHandler()); 164 165 var result = await manager.TryAutoLoginAsync(); 166 167 Assert.False(result); 168 Assert.Equal(AuthState.NotAuthenticated, manager.State); 169 } 170 171 [Fact] 172 public async Task TryAutoLoginAsync_RefreshesToken_WhenSessionExists() 173 { 174 await _sessionStore.SaveAsync(new SessionData("stored-rt", "user-1", DateTimeOffset.UtcNow)); 175 176 var newTokens = MakeTokenResponse("new-access", "new-refresh"); 177 var fake = new FakeHandler(); 178 fake.Enqueue(HttpStatusCode.OK, Json(newTokens)); 179 180 var (manager, authHandler) = MakeManager(fake); 181 182 var result = await manager.TryAutoLoginAsync(); 183 184 Assert.True(result); 185 Assert.Equal(AuthState.Authenticated, manager.State); 186 Assert.Equal("new-access", authHandler.AccessToken); 187 Assert.Contains("/auth/token/refresh", fake.Calls[0].Uri ?? ""); 188 } 189 190 [Fact] 191 public async Task TryAutoLoginAsync_ReturnsFalse_WhenRefreshFails() 192 { 193 await _sessionStore.SaveAsync(new SessionData("expired-rt", "user-1", DateTimeOffset.UtcNow)); 194 195 var fake = new FakeHandler(); 196 fake.Enqueue(HttpStatusCode.Unauthorized, null); 197 198 var (manager, _) = MakeManager(fake); 199 200 var result = await manager.TryAutoLoginAsync(); 201 202 Assert.False(result); 203 Assert.Equal(AuthState.NotAuthenticated, manager.State); 204 } 205 206 [Fact] 207 public async Task TryAutoLoginAsync_UpdatesSessionFile_OnSuccess() 208 { 209 await _sessionStore.SaveAsync(new SessionData("old-rt", "user-1", DateTimeOffset.UtcNow)); 210 211 var newTokens = MakeTokenResponse("access", "new-rotation-rt"); 212 var fake = new FakeHandler(); 213 fake.Enqueue(HttpStatusCode.OK, Json(newTokens)); 214 215 var (manager, _) = MakeManager(fake); 216 await manager.TryAutoLoginAsync(); 217 218 var stored = await _sessionStore.LoadAsync(); 219 Assert.NotNull(stored); 220 Assert.Equal("new-rotation-rt", stored.RefreshToken); 221 } 222 223 // ─── StartLogin ────────────────────────────────────────────────────────── 224 225 [Fact] 226 public async Task StartLogin_TransitionsToAuthenticating_Immediately() 227 { 228 var fake = new FakeHandler(); 229 // Device flow will block in PollForTokenAsync — use a long delay or a TaskCompletionSource. 230 // We only care about the immediate state transition; cancel after checking. 231 var cts = new CancellationTokenSource(); 232 fake.Enqueue(HttpStatusCode.OK, Json(MakeDeviceFlow())); 233 fake.Enqueue(HttpStatusCode.OK, Json(new DevicePollResponse("authorization_pending", null))); 234 235 var (manager, _) = MakeManager(fake); 236 manager.StartLogin(cts.Token); 237 238 // State should be Authenticating immediately. 239 Assert.Equal(AuthState.Authenticating, manager.State); 240 241 cts.Cancel(); 242 await Task.Delay(200); // Allow background task to complete with cancellation. 243 } 244 245 [Fact] 246 public async Task StartLogin_TransitionsToAuthenticated_OnSuccess() 247 { 248 var tokens = MakeTokenResponse("access-from-device", "refresh-from-device"); 249 var fake = new FakeHandler(); 250 fake.Enqueue(HttpStatusCode.OK, Json(MakeDeviceFlow())); 251 fake.Enqueue(HttpStatusCode.OK, Json(new DevicePollResponse("authorized", tokens))); 252 253 var (manager, authHandler) = MakeManager(fake); 254 255 var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); 256 manager.StartLogin(cts.Token); 257 258 // Wait for the background task to finish. 259 await WaitForStateAsync(manager, AuthState.Authenticated, cts.Token); 260 261 Assert.Equal(AuthState.Authenticated, manager.State); 262 Assert.Equal("access-from-device", authHandler.AccessToken); 263 } 264 265 [Fact] 266 public async Task StartLogin_SavesSession_OnSuccess() 267 { 268 var tokens = MakeTokenResponse("access-fd", "refresh-fd"); 269 var fake = new FakeHandler(); 270 fake.Enqueue(HttpStatusCode.OK, Json(MakeDeviceFlow())); 271 fake.Enqueue(HttpStatusCode.OK, Json(new DevicePollResponse("authorized", tokens))); 272 273 var (manager, _) = MakeManager(fake); 274 275 var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); 276 manager.StartLogin(cts.Token); 277 await WaitForStateAsync(manager, AuthState.Authenticated, cts.Token); 278 279 var stored = await _sessionStore.LoadAsync(); 280 Assert.NotNull(stored); 281 Assert.Equal("refresh-fd", stored.RefreshToken); 282 } 283 284 [Fact] 285 public async Task StartLogin_SetsVerificationUrlAndUserCode() 286 { 287 var deviceFlow = MakeDeviceFlow(); 288 var tokens = MakeTokenResponse("a", "r"); 289 var fake = new FakeHandler(); 290 fake.Enqueue(HttpStatusCode.OK, Json(deviceFlow)); 291 fake.Enqueue(HttpStatusCode.OK, Json(new DevicePollResponse("authorized", tokens))); 292 293 var (manager, _) = MakeManager(fake); 294 var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); 295 manager.StartLogin(cts.Token); 296 297 // Give the background task time to call /auth/device/start and set the properties. 298 await Task.Delay(300); 299 300 // Verification details should be available for the TUI to render. 301 Assert.Equal(deviceFlow.VerificationUri, manager.VerificationUrl); 302 Assert.Equal(deviceFlow.UserCode, manager.UserCode); 303 } 304 305 [Fact] 306 public async Task StartLogin_SetsLoginError_OnFailure() 307 { 308 var fake = new FakeHandler(); 309 fake.Enqueue(HttpStatusCode.InternalServerError, null); // /auth/device/start fails 310 311 var (manager, _) = MakeManager(fake); 312 var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); 313 manager.StartLogin(cts.Token); 314 315 await WaitForStateAsync(manager, AuthState.NotAuthenticated, cts.Token); 316 317 Assert.Equal(AuthState.NotAuthenticated, manager.State); 318 Assert.NotNull(manager.LoginError); 319 } 320 321 [Fact] 322 public async Task BuildUI_TransitionsToMainMenu_WhenLoginCompletes() 323 { 324 var tokens = MakeTokenResponse("access-from-device", "refresh-from-device"); 325 var fake = new FakeHandler(); 326 fake.Enqueue(HttpStatusCode.OK, Json(MakeDeviceFlow())); 327 fake.Enqueue(HttpStatusCode.OK, Json(new DevicePollResponse("authorized", tokens))); 328 329 var (manager, _) = MakeManager(fake); 330 using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); 331 manager.StartLogin(cts.Token); 332 await WaitForStateAsync(manager, AuthState.Authenticated, cts.Token); 333 334 using var gameStateScope = CreateAuthenticatingGameState(manager, "auth-success-test.db"); 335 var gameState = gameStateScope.GameState; 336 using var uiCts = new CancellationTokenSource(); 337 338 await gameState.BuildUI(new RootContext(), uiCts); 339 340 Assert.Equal(Screen.MainMenu, gameState.CurrentScreen); 341 } 342 343 [Fact] 344 public async Task BuildUI_ReturnsToLoginMenu_WhenLoginFails() 345 { 346 var fake = new FakeHandler(); 347 fake.Enqueue(HttpStatusCode.InternalServerError, null); 348 349 var (manager, _) = MakeManager(fake); 350 using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); 351 manager.StartLogin(cts.Token); 352 await WaitForStateAsync(manager, AuthState.NotAuthenticated, cts.Token); 353 354 using var gameStateScope = CreateAuthenticatingGameState(manager, "auth-failure-test.db"); 355 var gameState = gameStateScope.GameState; 356 using var uiCts = new CancellationTokenSource(); 357 358 await gameState.BuildUI(new RootContext(), uiCts); 359 360 Assert.Equal(Screen.LoginMenu, gameState.CurrentScreen); 361 } 362 363 // ─── Logout ────────────────────────────────────────────────────────────── 364 365 [Fact] 366 public async Task Logout_ClearsSessionFile() 367 { 368 await _sessionStore.SaveAsync(new SessionData("rt", "uid", DateTimeOffset.UtcNow)); 369 370 var (manager, _) = MakeManager(new FakeHandler()); 371 manager.Logout(); 372 373 var result = await _sessionStore.LoadAsync(); 374 Assert.Null(result); 375 } 376 377 [Fact] 378 public void Logout_ClearsAccessToken() 379 { 380 var (manager, authHandler) = MakeManager(new FakeHandler()); 381 authHandler.SetAccessToken("some-token"); 382 383 manager.Logout(); 384 385 Assert.Null(authHandler.AccessToken); 386 } 387 388 [Fact] 389 public void Logout_TransitionsToNotAuthenticated() 390 { 391 var (manager, _) = MakeManager(new FakeHandler()); 392 393 manager.Logout(); 394 395 Assert.Equal(AuthState.NotAuthenticated, manager.State); 396 } 397 398 [Fact] 399 public void Logout_ClearsLoginError() 400 { 401 var fake = new FakeHandler(); 402 fake.Enqueue(HttpStatusCode.InternalServerError, null); 403 404 var (manager, _) = MakeManager(fake); 405 // Simulate a prior error state by logging out with no prior session. 406 manager.Logout(); 407 408 Assert.Null(manager.LoginError); 409 } 410 411 // ─── Helpers ───────────────────────────────────────────────────────────── 412 413 private (SessionManager Manager, AuthDelegatingHandler AuthHandler) MakeManager(FakeHandler fake) 414 { 415 var authHandler = new AuthDelegatingHandler(_tokenStore, BaseUrl) 416 { 417 InnerHandler = fake 418 }; 419 var manager = new SessionManager(_sessionStore, authHandler, BaseUrl); 420 return (manager, authHandler); 421 } 422 423 private static string Json<T>(T value) => 424 System.Text.Json.JsonSerializer.Serialize(value, s_json); 425 426 private static TokenResponse MakeTokenResponse(string access, string refresh) => 427 new(access, refresh, 428 DateTimeOffset.UtcNow.AddMinutes(15), 429 DateTimeOffset.UtcNow.AddDays(30)); 430 431 private static DeviceCodeResponse MakeDeviceFlow() => 432 new("device-code-xyz", "ABCD-1234", "https://node.example.com/device", 300, 0); 433 434 private static async Task WaitForStateAsync(SessionManager manager, AuthState expected, CancellationToken ct) 435 { 436 while (!ct.IsCancellationRequested && manager.State != expected) 437 await Task.Delay(50, ct); 438 } 439 440 private TestGameStateScope CreateAuthenticatingGameState(SessionManager manager, string databaseFileName) 441 { 442 var profileScope = new UserProfileScope(_tempDir); 443 var databasePath = Path.Combine(_tempDir, databaseFileName); 444 var offlineDb = new LiteDatabase(databasePath); 445 var offlineStore = new OfflineStore(offlineDb); 446 var httpClient = new HttpClient { BaseAddress = new Uri(BaseUrl) }; 447 var resolver = new GameBackendResolver(httpClient, offlineStore); 448 var gameState = new GameState( 449 httpClient, 450 new JsonSerializerOptions(JsonSerializerDefaults.Web), 451 new StubGameBackend(), 452 resolver, 453 offlineStore, 454 offlineDb, 455 manager, 456 Screen.Authenticating); 457 458 return new TestGameStateScope(gameState, httpClient, offlineDb, databasePath, profileScope); 459 } 460 461 private sealed class TestGameStateScope(GameState gameState, HttpClient httpClient, LiteDatabase offlineDb, string databasePath, UserProfileScope profileScope) : IDisposable 462 { 463 public GameState GameState { get; } = gameState; 464 465 public void Dispose() 466 { 467 httpClient.Dispose(); 468 offlineDb.Dispose(); 469 if (File.Exists(databasePath)) 470 File.Delete(databasePath); 471 profileScope.Dispose(); 472 } 473 } 474 475 private sealed class UserProfileScope : IDisposable 476 { 477 private readonly string? _originalHome; 478 private readonly string? _originalUserProfile; 479 private readonly string _profileDirectory; 480 private bool _disposed; 481 private bool _lockHeld; 482 483 public UserProfileScope(string tempDir) 484 { 485 if (!Monitor.TryEnter(s_userProfileEnvironmentLock, TimeSpan.FromSeconds(30))) 486 throw new TimeoutException("Timed out while waiting to isolate the user profile environment for auth UI tests."); 487 488 _lockHeld = true; 489 490 try 491 { 492 _originalHome = Environment.GetEnvironmentVariable("HOME"); 493 _originalUserProfile = Environment.GetEnvironmentVariable("USERPROFILE"); 494 _profileDirectory = Path.Combine(tempDir, "test-home"); 495 var configDirectoryPath = Path.Combine(_profileDirectory, GunrpgConfigDirectoryName); 496 try 497 { 498 Directory.CreateDirectory(configDirectoryPath); 499 } 500 catch (Exception ex) 501 { 502 throw new InvalidOperationException( 503 $"Failed to create isolated auth test profile directory '{configDirectoryPath}'.", 504 ex); 505 } 506 Environment.SetEnvironmentVariable("HOME", _profileDirectory); 507 Environment.SetEnvironmentVariable("USERPROFILE", _profileDirectory); 508 } 509 catch 510 { 511 Dispose(); 512 throw; 513 } 514 } 515 516 public void Dispose() 517 { 518 if (_disposed) 519 return; 520 521 _disposed = true; 522 523 try 524 { 525 Environment.SetEnvironmentVariable("HOME", _originalHome); 526 Environment.SetEnvironmentVariable("USERPROFILE", _originalUserProfile); 527 } 528 finally 529 { 530 if (_lockHeld) 531 { 532 Monitor.Exit(s_userProfileEnvironmentLock); 533 _lockHeld = false; 534 } 535 } 536 } 537 } 538 539 private sealed class StubGameBackend : IGameBackend 540 { 541 public Task<OperatorDto?> GetOperatorAsync(string id) => Task.FromResult<OperatorDto?>(null); 542 543 public Task<OperatorDto> InfilOperatorAsync(string id) => throw new NotSupportedException(); 544 545 public Task<bool> OperatorExistsAsync(string id) => Task.FromResult(false); 546 } 547 548 private sealed class FakeHandler : HttpMessageHandler 549 { 550 private readonly Queue<(HttpStatusCode Status, string? Body)> _queue = new(); 551 552 public List<(string? Method, string? Uri, string? AuthHeader, string? RequestBody)> Calls { get; } = new(); 553 554 public void Enqueue(HttpStatusCode status, string? body = null) 555 => _queue.Enqueue((status, body)); 556 557 protected override async Task<HttpResponseMessage> SendAsync( 558 HttpRequestMessage request, CancellationToken ct) 559 { 560 var body = request.Content is not null 561 ? await request.Content.ReadAsStringAsync(ct) 562 : null; 563 564 Calls.Add(( 565 request.Method.Method, 566 request.RequestUri?.ToString(), 567 request.Headers.Authorization?.ToString(), 568 body)); 569 570 await Task.Yield(); 571 572 if (_queue.TryDequeue(out var entry)) 573 { 574 var msg = new HttpResponseMessage(entry.Status); 575 if (entry.Body is not null) 576 msg.Content = new StringContent(entry.Body, Encoding.UTF8, "application/json"); 577 return msg; 578 } 579 580 return new HttpResponseMessage(HttpStatusCode.InternalServerError); 581 } 582 } 583 }