/ GUNRPG.Tests / AccountIdProvisioningTests.cs
AccountIdProvisioningTests.cs
  1  using System.Net;
  2  using System.Text;
  3  using JsonSerializer = System.Text.Json.JsonSerializer;
  4  using GUNRPG.Infrastructure.Identity;
  5  using GUNRPG.WebClient.Services;
  6  using LiteDB;
  7  using Microsoft.AspNetCore.Identity;
  8  using Microsoft.Extensions.DependencyInjection;
  9  using Microsoft.Extensions.Logging;
 10  using Microsoft.Extensions.Options;
 11  using Microsoft.JSInterop;
 12  
 13  namespace GUNRPG.Tests;
 14  
 15  public sealed class AccountIdProvisioningTests : IDisposable
 16  {
 17      private readonly LiteDatabase _db = new(":memory:");
 18  
 19      public void Dispose() => _db.Dispose();
 20  
 21      [Fact]
 22      public async Task EnsureAssignedAsync_AssignsAndPersistsAccountId_WhenMissing()
 23      {
 24          using var userManager = CreateUserManager();
 25          var user = new ApplicationUser
 26          {
 27              Id = "user-1",
 28              UserName = "alice",
 29              NormalizedUserName = "ALICE",
 30          };
 31  
 32          var createResult = await userManager.CreateAsync(user);
 33          Assert.True(createResult.Succeeded);
 34          Assert.Null(user.AccountId);
 35  
 36          var result = await AccountIdProvisioning.EnsureAssignedAsync(userManager, user);
 37  
 38          Assert.True(result.Succeeded);
 39          Assert.NotNull(user.AccountId);
 40          Assert.NotEqual(Guid.Empty, user.AccountId.Value);
 41  
 42          var persisted = await userManager.FindByIdAsync(user.Id);
 43          Assert.Equal(user.AccountId, persisted?.AccountId);
 44      }
 45  
 46      [Fact]
 47      public async Task EnsureAssignedAsync_PreservesExistingAccountId()
 48      {
 49          using var userManager = CreateUserManager();
 50          var existingAccountId = Guid.NewGuid();
 51          var user = new ApplicationUser
 52          {
 53              Id = "user-2",
 54              UserName = "bob",
 55              NormalizedUserName = "BOB",
 56              AccountId = existingAccountId,
 57          };
 58  
 59          var createResult = await userManager.CreateAsync(user);
 60          Assert.True(createResult.Succeeded);
 61  
 62          var result = await AccountIdProvisioning.EnsureAssignedAsync(userManager, user);
 63  
 64          Assert.True(result.Succeeded);
 65          Assert.Equal(existingAccountId, user.AccountId);
 66      }
 67  
 68      [Fact]
 69      public async Task EnsureAssignedAsync_UsesPersistedAccountId_ForStaleCallerInstance()
 70      {
 71          using var userManager = CreateUserManager();
 72          var user = new ApplicationUser
 73          {
 74              Id = "user-3",
 75              UserName = "charlie",
 76              NormalizedUserName = "CHARLIE",
 77          };
 78  
 79          var createResult = await userManager.CreateAsync(user);
 80          Assert.True(createResult.Succeeded);
 81  
 82          var persistedUser = await userManager.FindByIdAsync(user.Id);
 83          Assert.NotNull(persistedUser);
 84  
 85          var assignedAccountId = Guid.NewGuid();
 86          persistedUser!.AccountId = assignedAccountId;
 87          var updateResult = await userManager.UpdateAsync(persistedUser);
 88          Assert.True(updateResult.Succeeded);
 89  
 90          var staleCallerUser = new ApplicationUser
 91          {
 92              Id = user.Id,
 93              UserName = user.UserName,
 94              NormalizedUserName = user.NormalizedUserName,
 95          };
 96  
 97          var result = await AccountIdProvisioning.EnsureAssignedAsync(userManager, staleCallerUser);
 98  
 99          Assert.True(result.Succeeded);
100          Assert.Equal(assignedAccountId, staleCallerUser.AccountId);
101  
102          var reloadedUser = await userManager.FindByIdAsync(user.Id);
103          Assert.Equal(assignedAccountId, reloadedUser?.AccountId);
104      }
105  
106      private UserManager<ApplicationUser> CreateUserManager()
107      {
108          var store = new LiteDbUserStore(_db);
109          var services = new ServiceCollection()
110              .AddLogging()
111              .BuildServiceProvider();
112  
113          return new UserManager<ApplicationUser>(
114              store,
115              Options.Create(new IdentityOptions()),
116              new PasswordHasher<ApplicationUser>(),
117              [new UserValidator<ApplicationUser>()],
118              [],
119              new UpperInvariantLookupNormalizer(),
120              new IdentityErrorDescriber(),
121              services,
122              services.GetRequiredService<ILogger<UserManager<ApplicationUser>>>());
123      }
124  }
125  
126  public sealed class ApiClientTests
127  {
128      [Fact]
129      public async Task PostAsync_RetriesUnauthorizedRequestWithFreshJsonContent()
130      {
131          var handler = new QueueHandler();
132          var refreshed = new TokenResponse
133          {
134              AccessToken = "new-access",
135              RefreshToken = "new-refresh",
136              AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddMinutes(15).ToString("O"),
137              RefreshTokenExpiresAt = DateTimeOffset.UtcNow.AddDays(7).ToString("O"),
138          };
139  
140          var unauthorizedResponse = new HttpResponseMessage(HttpStatusCode.Unauthorized)
141          {
142              Content = new TrackingStringContent("{}"),
143          };
144          handler.Enqueue(unauthorizedResponse);
145          handler.Enqueue(HttpStatusCode.OK, JsonSerializer.Serialize(refreshed));
146          handler.Enqueue(HttpStatusCode.Created, "{}");
147  
148          using var http = new HttpClient(handler);
149          var js = new FakeJsRuntime();
150          var nodeService = new NodeConnectionService(js);
151          await nodeService.SetBaseUrlAsync("https://node.example.com");
152          var auth = new AuthService(js, http, nodeService);
153          await auth.SetTokensAsync("old-access", "refresh-token");
154          var client = new ApiClient(http, nodeService, auth);
155  
156          var response = await client.PostAsync("/operators", new { Name = "Viper" });
157  
158          Assert.Equal(HttpStatusCode.Created, response.StatusCode);
159          Assert.Equal(3, handler.Calls.Count);
160          Assert.Equal("Bearer old-access", handler.Calls[0].Authorization);
161          Assert.Equal("Bearer new-access", handler.Calls[2].Authorization);
162          Assert.Equal(handler.Calls[0].Body, handler.Calls[2].Body);
163          Assert.Contains("\"name\":\"Viper\"", handler.Calls[0].Body, StringComparison.Ordinal);
164          Assert.True(((TrackingStringContent)unauthorizedResponse.Content).Disposed);
165      }
166  
167      private sealed class QueueHandler : HttpMessageHandler
168      {
169          private readonly Queue<HttpResponseMessage> _responses = new();
170  
171          public List<(string? Uri, string? Authorization, string? Body)> Calls { get; } = [];
172  
173          public void Enqueue(HttpStatusCode statusCode, string? body = null)
174          {
175              var response = new HttpResponseMessage(statusCode);
176              if (body is not null)
177                  response.Content = new StringContent(body, Encoding.UTF8, "application/json");
178              _responses.Enqueue(response);
179          }
180  
181          public void Enqueue(HttpResponseMessage response)
182              => _responses.Enqueue(response);
183  
184          protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
185          {
186              Calls.Add((
187                  request.RequestUri?.ToString(),
188                  request.Headers.Authorization?.ToString(),
189                  request.Content is null ? null : await request.Content.ReadAsStringAsync(cancellationToken)));
190  
191              return _responses.Dequeue();
192          }
193      }
194  
195      private sealed class TrackingStringContent(string content) : StringContent(content, Encoding.UTF8, "application/json")
196      {
197          public bool Disposed { get; private set; }
198  
199          protected override void Dispose(bool disposing)
200          {
201              Disposed = true;
202              base.Dispose(disposing);
203          }
204      }
205  
206      private sealed class FakeJsRuntime : IJSRuntime
207      {
208          private readonly Dictionary<string, object?> _values = new(StringComparer.Ordinal);
209  
210          public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object?[]? args)
211          {
212              return InvokeCoreAsync<TValue>(identifier, args);
213          }
214  
215          public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object?[]? args)
216              => InvokeCoreAsync<TValue>(identifier, args);
217  
218          private ValueTask<TValue> InvokeCoreAsync<TValue>(string identifier, object?[]? args)
219          {
220              switch (identifier)
221              {
222                  case "localStorage.getItem":
223                      return new ValueTask<TValue>((TValue?)GetValue(args, 0) ?? default!);
224                  case "tokenStorage.getRefreshToken":
225                      return new ValueTask<TValue>((TValue?)GetValue("refreshToken") ?? default!);
226                  case "tokenStorage.getAccessToken":
227                      return new ValueTask<TValue>((TValue?)GetValue("accessToken") ?? default!);
228                  case "localStorage.setItem":
229                      _values[Convert.ToString(args?[0])!] = args?[1];
230                      return new ValueTask<TValue>(default(TValue)!);
231                  case "localStorage.removeItem":
232                      _values.Remove(Convert.ToString(args?[0])!);
233                      return new ValueTask<TValue>(default(TValue)!);
234                  case "tokenStorage.storeRefreshToken":
235                      _values["refreshToken"] = args?[0];
236                      return new ValueTask<TValue>(default(TValue)!);
237                  case "tokenStorage.storeAccessToken":
238                      _values["accessToken"] = args?[0];
239                      return new ValueTask<TValue>(default(TValue)!);
240                  case "tokenStorage.removeRefreshToken":
241                      _values.Remove("refreshToken");
242                      return new ValueTask<TValue>(default(TValue)!);
243                  case "tokenStorage.removeAccessToken":
244                      _values.Remove("accessToken");
245                      return new ValueTask<TValue>(default(TValue)!);
246                  case "tokenStorage.clearTokens":
247                      _values.Remove("accessToken");
248                      _values.Remove("refreshToken");
249                      return new ValueTask<TValue>(default(TValue)!);
250                  default:
251                      throw new NotSupportedException(identifier);
252              }
253          }
254  
255          private object? GetValue(object?[]? args, int keyIndex)
256          {
257              var key = Convert.ToString(args?[keyIndex]);
258              return key is not null && _values.TryGetValue(key, out var value)
259                  ? value
260                  : null;
261          }
262  
263          private object? GetValue(string key) =>
264              _values.TryGetValue(key, out var value) ? value : null;
265      }
266  }