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