/ 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  }