diff --git a/.claude/skills/ops__doctor_check/SKILL.md b/.claude/skills/ops__doctor_check/SKILL.md index 3564ad8e..f09e4443 100644 --- a/.claude/skills/ops__doctor_check/SKILL.md +++ b/.claude/skills/ops__doctor_check/SKILL.md @@ -20,7 +20,16 @@ Perform a health check on the development environment to ensure all prerequisite - **Requirement**: Aspire CLI must be installed. - *Action*: If missing, see [Install instructions](https://aspire.dev/get-started/install-cli/) -5. **Report Summary** +5. **Playwright Browsers** (for `BookStore.AppHost.Tests`) + - Check if the chromium binary exists inside the build output: `ls tests/BookStore.AppHost.Tests/bin/Debug/net10.0/.playwright/package/` (requires the project to have been built) + - **Requirement**: Chromium must be installed for browser-based integration tests. + - *Action*: If missing or the project hasn't been built yet, run: + ```bash + dotnet build tests/BookStore.AppHost.Tests/BookStore.AppHost.Tests.csproj + node tests/BookStore.AppHost.Tests/bin/Debug/net10.0/.playwright/package/index.js install chromium + ``` + +6. **Report Summary** - If all checks pass: "✅ Environment is healthy" - If issues found: List specific missing tools with installation instructions diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..fd72d6be --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "microsoft-aspire.aspire-vscode", + "ms-dotnettools.csdevkit", + "ms-azuretools.vscode-containers", + "editorconfig.editorconfig", + "bits.csharp-test-filter" + ] +} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..70a54c48 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,31 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug TUnit Test", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${input:dllPath}", + "args": [ + "--treenode-filter", + "${input:testFilter}" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false + } + ], + "inputs": [ + { + "id": "dllPath", + "type": "command", + "command": "csharp-test-filter.getDllPath" + }, + { + "id": "testFilter", + "type": "command", + "command": "csharp-test-filter.getFilter" + } + ] +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index c05af657..16c5b00c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,27 @@ Use this file for agent-only context: build and test commands, conventions, and ## Quick Reference -- **Stack**: .NET 10, C# 14, Marten, Wolverine, HybridCache, Aspire +- **Stack**: .NET 10, C# 14, Marten, Wolverine, HybridCache, Aspire, Playwright - **Solution**: `BookStore.slnx` (new .NET 10 solution format) - **Common commands**: `dotnet restore`, `aspire run`, `dotnet test`, `dotnet format` - **Docs**: `docs/getting-started.md`, `docs/guides/` - **Testing instructions**: `tests/AGENTS.md` +### Running Tests (TUnit) + +TUnit-specific arguments must be passed after `--` so they are forwarded as program arguments rather than parsed by `dotnet test`: + +```bash +# Run all tests (uses all available cores by default) +dotnet test + +# Limit parallelism in resource-constrained environments +dotnet test -- --maximum-parallel-tests 4 + +# Filter tests by category +dotnet test -- --treenode-filter "/*/*/*/*[Category=Integration]" +``` + ## Repository Map - `src/BookStore.ApiService/`: Event-sourced API (Marten + Wolverine) @@ -38,9 +53,9 @@ Use this file for agent-only context: build and test commands, conventions, and 2. Write verification first 3. Implement 4. Verify all steps pass -5. Run `dotnet format` to ensure code style compliance +5. Run `dotnet format` to ensure code style compliance. Issues not automatically fixed must be resolved manually. -**A feature is not complete until `dotnet format` has been executed successfully.** +**A feature is not complete until `dotnet format --verify-no-changes` exits with code 0.** ## Code Rules (MUST follow) @@ -52,6 +67,7 @@ Use this file for agent-only context: build and test commands, conventions, and ✅ [Test] async Task (TUnit) ❌ [Fact] (xUnit) ✅ WaitForConditionAsync ❌ Task.Delay / Thread.Sleep ✅ [LoggerMessage(...)] ❌ _logger.LogInformation(...) / LogWarning / LogError / etc. +✅ MultiTenancyConstants.* ❌ Hardcoded "*DEFAULT*" / "default" ``` ### Logging Pattern @@ -82,6 +98,16 @@ Use this file for agent-only context: build and test commands, conventions, and - SSE not working: run `/frontend__debug_sse` - Cache issues: run `/cache__debug_cache` - Environment issues: run `/ops__doctor_check` +- Playwright browser missing (integration tests fail with browser launch error): install browsers with `node tests/BookStore.AppHost.Tests/bin/Debug/net10.0/.playwright/package/index.js install chromium` (build the project first) + +## MCP Servers for Documentation + +Use MCP servers to get up-to-date documentation instead of relying on training data: + +- **Context7** (`mcp_context7_resolve-library-id` → `mcp_context7_get-library-docs`): Use for any library in the stack — Marten, Wolverine, Aspire, Refit, Bogus, TUnit, etc. +- **Microsoft Learn** (`mcp_microsoftdocs_microsoft_docs_search` / `mcp_microsoftdocs_microsoft_docs_fetch`): Use for .NET, ASP.NET Core, Entity Framework, Azure, and any Microsoft technology. + +Always prefer MCP server lookups over guessing API shapes or behaviour from training data. ## Documentation Index diff --git a/Directory.Packages.props b/Directory.Packages.props index e9065055..7e38ab1f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -38,6 +38,7 @@ + @@ -59,4 +60,4 @@ - \ No newline at end of file + diff --git a/docs/getting-started.md b/docs/getting-started.md index 2a837ee3..3d32dd9a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -271,7 +271,7 @@ curl -H "Accept-Language: pt-PT" http://localhost:5000/api/categories -- Connect to the database via PgAdmin -- View all events -SELECT +SELECT id, seq_id, type, @@ -283,12 +283,12 @@ ORDER BY timestamp DESC LIMIT 10; -- View events for a specific correlation ID -SELECT * FROM mt_events +SELECT * FROM mt_events WHERE correlation_id = 'getting-started-001' ORDER BY timestamp; -- View a specific stream -SELECT * FROM mt_streams +SELECT * FROM mt_streams WHERE id = ''; ``` @@ -364,6 +364,18 @@ BookStore/ The project uses **TUnit**, a modern testing framework for .NET with built-in code coverage and source-generated tests. +### Playwright Browser Setup + +The integration tests (`BookStore.AppHost.Tests`) use **Microsoft.Playwright** for browser-based flows. Playwright browsers must be installed once after building the project: + +```bash +dotnet build tests/BookStore.AppHost.Tests/BookStore.AppHost.Tests.csproj +node tests/BookStore.AppHost.Tests/bin/Debug/net10.0/.playwright/package/index.js install chromium +``` + +> [!NOTE] +> Re-run the install command after a `dotnet clean` or if you switch between `Debug` and `Release` configurations — the `.playwright` directory is regenerated by the build. + ### Running Tests ```bash @@ -451,7 +463,7 @@ curl http://localhost:5000/health **Error**: "Container runtime 'docker' was found but appears to be unhealthy" -**Solution**: +**Solution**: 1. Open Docker Desktop 2. Wait for it to fully start 3. Run `aspire run` again diff --git a/src/BookStore.ApiService/AGENTS.md b/src/BookStore.ApiService/AGENTS.md index 7585581f..00a093c3 100644 --- a/src/BookStore.ApiService/AGENTS.md +++ b/src/BookStore.ApiService/AGENTS.md @@ -24,6 +24,8 @@ **Error Handling**: Use `/lang__problem_details` skill for all errors. Return `Result.Failure(Error.(code, message)).ToProblemDetails()`. +**CRITICAL**: ALL failures MUST return RFC 7807 ProblemDetails with a machine-readable error code. This includes endpoints, handlers, AND middleware. Never return plain JSON errors. + ## Common Mistakes - ❌ Business logic in endpoints → Put logic in aggregates/handlers - ❌ Missing SSE notification → Add to `MartenCommitListener` @@ -31,6 +33,7 @@ - ❌ Manually running Marten async daemon → Async projections are updated by Wolverine - ❌ Skipping tenant context → Use tenant-scoped sessions and cache keys - ❌ Ignoring ETag checks → Use `IHaveETag` and `ETagHelper` +- ❌ Returning plain JSON errors → ALL failures must return ProblemDetails with error codes (endpoints, handlers, middleware) ## Project Layout | Path | Purpose | diff --git a/src/BookStore.ApiService/BookStore.ApiService.csproj b/src/BookStore.ApiService/BookStore.ApiService.csproj index 3a51485e..00a0a3ce 100644 --- a/src/BookStore.ApiService/BookStore.ApiService.csproj +++ b/src/BookStore.ApiService/BookStore.ApiService.csproj @@ -1,51 +1,52 @@ - - - - - - true - - - true - - - true - - - true - true - $(NoWarn);EXTEXP0018 - enable - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + true + + + true + + + true + + + true + true + $(NoWarn);EXTEXP0018 + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/BookStore.ApiService/Endpoints/JwtAuthenticationEndpoints.cs b/src/BookStore.ApiService/Endpoints/JwtAuthenticationEndpoints.cs index 25c8888e..cb47839e 100644 --- a/src/BookStore.ApiService/Endpoints/JwtAuthenticationEndpoints.cs +++ b/src/BookStore.ApiService/Endpoints/JwtAuthenticationEndpoints.cs @@ -308,15 +308,15 @@ static async Task RefreshTokenAsync( RefreshRequest request, JwtTokenService jwtTokenService, UserManager userManager, - Marten.IDocumentSession session, + IDocumentStore store, ITenantContext tenantContext, ILogger logger, CancellationToken cancellationToken) { - // 1. Find user with this refresh token - // Since we store tokens in the user document, we need to query based on the token - var user = await session.Query() - .FirstOrDefaultAsync(u => u.RefreshTokens.Any(rt => rt.Token == request.RefreshToken), cancellationToken); + // 1. Find user with this refresh token across ALL tenants (needed for cross-tenant theft detection) + await using var globalSession = store.LightweightSession(); + var user = await globalSession.Query() + .FirstOrDefaultAsync(u => u.AnyTenant() && u.RefreshTokens.Any(rt => rt.Token == request.RefreshToken), cancellationToken); if (user == null) { @@ -344,15 +344,45 @@ static async Task RefreshTokenAsync( // CRITICAL SECURITY VIOLATION: Cross-tenant token theft attempt Log.Users.CrossTenantTokenTheft(logger, user.Id, existingToken.TenantId, tenantContext.TenantId); - // Lock account and clear all refresh tokens - _ = await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddHours(24)); + // Lock account and clear all refresh tokens. + // NOTE: We bypass UserManager here because it is scoped to an IDocumentSession. + // The user's actual home tenant may differ from tenantContext.TenantId: + // - Spoofed token scenario: user IS in tenantContext.TenantId, token.TenantId is fake + // - Cross-tenant use scenario: user IS in existingToken.TenantId, request targets wrong tenant + // Determine the home tenant by checking which one actually contains the user. + var lockTenantId = tenantContext.TenantId; + await using (var checkSession = store.QuerySession(tenantContext.TenantId)) + { + var userInCurrentTenant = await checkSession.LoadAsync(user.Id, cancellationToken); + if (userInCurrentTenant is null) + { + lockTenantId = existingToken.TenantId; + } + } + + user.LockoutEnd = DateTimeOffset.UtcNow.AddHours(24); + user.LockoutEnabled = true; user.RefreshTokens.Clear(); + await using var victimSession = store.LightweightSession(lockTenantId); + victimSession.Update(user); + await victimSession.SaveChangesAsync(cancellationToken); + + return Result.Failure(Error.Forbidden(ErrorCodes.Auth.SecurityViolation, "Security violation detected. Account has been locked.")).ToProblemDetails(); + } + + // 4. Validate security stamp hasn't changed (invalidates tokens after password change, passkey addition, etc.) + if (!string.Equals(existingToken.SecurityStamp, user.SecurityStamp, StringComparison.Ordinal)) + { + Log.Users.RefreshFailedSecurityStampMismatch(logger, user.UserName); + + // Remove the invalid token + _ = user.RefreshTokens.Remove(existingToken); _ = await userManager.UpdateAsync(user); - return Result.Failure(Error.Unauthorized(ErrorCodes.Auth.SecurityViolation, "Security violation detected. Account has been locked.")).ToProblemDetails(); + return Result.Failure(Error.Unauthorized(ErrorCodes.Auth.TokenExpired, "Refresh token has been invalidated due to security changes.")).ToProblemDetails(); } - // 4. Generate new tokens using the original tenant from the refresh token + // 5. Generate new tokens using the original tenant from the refresh token var roles = await userManager.GetRolesAsync(user); var newAccessToken = jwtTokenService.GenerateAccessToken(user, existingToken.TenantId, roles); var newRefreshToken = jwtTokenService.RotateRefreshToken(user, existingToken.TenantId, existingToken.Token); @@ -427,6 +457,8 @@ static async Task ChangePasswordAsync( var result = await userManager.ChangePasswordAsync(appUser, request.CurrentPassword, request.NewPassword); if (result.Succeeded) { + // ChangePasswordAsync already calls UpdateSecurityStampAsync internally, + // so no explicit call is needed here. return Results.Ok(new { message = "Password changed successfully." }); } @@ -465,6 +497,9 @@ static async Task RemovePasswordAsync( var result = await userManager.RemovePasswordAsync(appUser); if (result.Succeeded) { + // RemovePasswordAsync does NOT update the security stamp internally in ASP.NET Identity, + // so we do it explicitly to invalidate all existing JWTs. + _ = await userManager.UpdateSecurityStampAsync(appUser); return Results.Ok(new { message = "Password removed successfully." }); } @@ -485,6 +520,9 @@ static async Task AddPasswordAsync( var result = await userManager.AddPasswordAsync(appUser, request.NewPassword); if (result.Succeeded) { + // AddPasswordAsync does NOT update the security stamp internally in ASP.NET Identity, + // so we do it explicitly to invalidate all existing JWTs. + _ = await userManager.UpdateSecurityStampAsync(appUser); return Results.Ok(new { message = "Password set successfully." }); } diff --git a/src/BookStore.ApiService/Endpoints/PasskeyEndpoints.cs b/src/BookStore.ApiService/Endpoints/PasskeyEndpoints.cs index 94de75f0..67fc724a 100644 --- a/src/BookStore.ApiService/Endpoints/PasskeyEndpoints.cs +++ b/src/BookStore.ApiService/Endpoints/PasskeyEndpoints.cs @@ -1,4 +1,5 @@ using System.Security.Claims; +using System.Text.Json; using BookStore.ApiService.Infrastructure.Extensions; using BookStore.ApiService.Infrastructure.Logging; using BookStore.ApiService.Infrastructure.Tenant; @@ -202,91 +203,102 @@ public static IEndpointRouteBuilder MapPasskeyEndpoints(this IEndpointRouteBuild // 4. Verify Attestation // We need the User Handle (ID) that was signed by the client. // This is critical because the authenticator is now bound to THAT ID. - var attestationNew = await signInManager.PerformPasskeyAttestationAsync(request.CredentialJson); - if (!attestationNew.Succeeded) + // Wrap in try-catch: PerformPasskeyAttestationAsync throws when the session + // challenge has already been consumed (e.g., concurrent duplicate requests). + try { - return Result.Failure(Error.Validation(ErrorCodes.Passkey.AttestationFailed, $"Attestation failed: {attestationNew.Failure?.Message}")).ToProblemDetails(); - } - - // Capture Device Name from User-Agent - var registrationUserAgent = context.Request.Headers.UserAgent.ToString(); - if (attestationNew.Passkey != null) - { - attestationNew.Passkey.Name = BookStore.ApiService.Infrastructure.DeviceNameParser.Parse(registrationUserAgent); - } + var attestationNew = await signInManager.PerformPasskeyAttestationAsync(request.CredentialJson); + if (!attestationNew.Succeeded) + { + return Result.Failure(Error.Validation(ErrorCodes.Passkey.AttestationFailed, $"Attestation failed: {attestationNew.Failure?.Message}")).ToProblemDetails(); + } - Log.Users.PasskeyCreatingNewUser(logger, newUserGuid, userIdSource); + // Capture Device Name from User-Agent + var registrationUserAgent = context.Request.Headers.UserAgent.ToString(); + if (attestationNew.Passkey != null) + { + attestationNew.Passkey.Name = BookStore.ApiService.Infrastructure.DeviceNameParser.Parse(registrationUserAgent); + } - var newUser = new ApplicationUser - { - Id = newUserGuid, - UserName = request.Email, - Email = request.Email, - EmailConfirmed = !verificationRequired - }; + Log.Users.PasskeyCreatingNewUser(logger, newUserGuid, userIdSource); - // Create the user in DB - var createResult = await userManager.CreateAsync(newUser); - if (!createResult.Succeeded) - { - // Security: Mask "User already exists" errors - if (createResult.Errors.Any(e => e.Code is "DuplicateUserName" or "DuplicateEmail")) + var newUser = new ApplicationUser { - Log.Users.RegistrationFailed(logger, request.Email, "Passkey Registration: User already exists (masked)"); + Id = newUserGuid, + UserName = request.Email, + Email = request.Email, + EmailConfirmed = !verificationRequired + }; + + // Create the user in DB + var createResult = await userManager.CreateAsync(newUser); + if (!createResult.Succeeded) + { + // Security: Mask "User already exists" errors + if (createResult.Errors.Any(e => e.Code is "DuplicateUserName" or "DuplicateEmail")) + { + Log.Users.RegistrationFailed(logger, request.Email, "Passkey Registration: User already exists (masked)"); - // Return success message. - // Note: If email verification is required, we might trigger a "Password Reset" or "Account Exists" email here in a real app. - // For now, we mimic the success response. - return Results.Ok(new { Message = "Registration successful. Please check your email to verify your account." }); + // Return success message. + // Note: If email verification is required, we might trigger a "Password Reset" or "Account Exists" email here in a real app. + // For now, we mimic the success response. + return Results.Ok(new { Message = "Registration successful. Please check your email to verify your account." }); + } + + return Result.Failure(Error.Validation(ErrorCodes.Auth.InvalidCredentials, string.Join(", ", createResult.Errors.Select(e => e.Description)))).ToProblemDetails(); } - return Result.Failure(Error.Validation(ErrorCodes.Auth.InvalidCredentials, string.Join(", ", createResult.Errors.Select(e => e.Description)))).ToProblemDetails(); - } + if (attestationNew.Passkey != null) + { + var addResult = await userManager.AddOrUpdatePasskeyAsync(newUser, attestationNew.Passkey); + if (!addResult.Succeeded) + { + _ = await userManager.DeleteAsync(newUser); + return Result.Failure(Error.Validation(ErrorCodes.Passkey.InvalidCredential, string.Join(", ", addResult.Errors.Select(e => e.Description)))).ToProblemDetails(); + } - if (attestationNew.Passkey != null) - { - var addResult = await userManager.AddOrUpdatePasskeyAsync(newUser, attestationNew.Passkey); - if (!addResult.Succeeded) + _ = await userManager.UpdateSecurityStampAsync(newUser); + } + else { + Log.Users.PasskeyIsNull(logger); _ = await userManager.DeleteAsync(newUser); - return Result.Failure(Error.Validation(ErrorCodes.Passkey.InvalidCredential, string.Join(", ", addResult.Errors.Select(e => e.Description)))).ToProblemDetails(); + return Result.Failure(Error.Validation(ErrorCodes.Passkey.AttestationFailed, "Failed to create passkey")).ToProblemDetails(); } - _ = await userManager.UpdateSecurityStampAsync(newUser); - } - else - { - Log.Users.PasskeyIsNull(logger); - _ = await userManager.DeleteAsync(newUser); - return Result.Failure(Error.Validation(ErrorCodes.Passkey.AttestationFailed, "Failed to create passkey")).ToProblemDetails(); - } - - // Initialize UserProfile stream on account creation - _ = session.Events.StartStream(newUser.Id, new UserProfileCreated(newUser.Id)); - await session.SaveChangesAsync(cancellationToken); + // Initialize UserProfile stream on account creation + _ = session.Events.StartStream(newUser.Id, new UserProfileCreated(newUser.Id)); + await session.SaveChangesAsync(cancellationToken); - if (verificationRequired) - { - var code = await userManager.GenerateEmailConfirmationTokenAsync(newUser); - await bus.PublishAsync(new Messages.Commands.SendUserVerificationEmail(newUser.Id, newUser.Email!, code, newUser.UserName!)); + if (verificationRequired) + { + var code = await userManager.GenerateEmailConfirmationTokenAsync(newUser); + await bus.PublishAsync(new Messages.Commands.SendUserVerificationEmail(newUser.Id, newUser.Email!, code, newUser.UserName!)); - // Don't auto-login when verification is required - return Results.Ok(new { Message = "Registration successful. Please check your email to verify your account." }); - } + // Don't auto-login when verification is required + return Results.Ok(new { Message = "Registration successful. Please check your email to verify your account." }); + } - // Auto Login - Issue Token (only when verification is not required) - // Build claims and generate tokens - var accessToken = tokenService.GenerateAccessToken(newUser, tenantContext.TenantId, []); - var refreshToken = tokenService.RotateRefreshToken(newUser, tenantContext.TenantId); + // Auto Login - Issue Token (only when verification is not required) + // Build claims and generate tokens + var accessToken = tokenService.GenerateAccessToken(newUser, tenantContext.TenantId, []); + var refreshToken = tokenService.RotateRefreshToken(newUser, tenantContext.TenantId); - _ = await userManager.UpdateAsync(newUser); + _ = await userManager.UpdateAsync(newUser); - return Results.Ok(new LoginResponse( - "Bearer", - accessToken, - 3600, - refreshToken - )); + return Results.Ok(new LoginResponse( + "Bearer", + accessToken, + 3600, + refreshToken + )); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Attestation threw (e.g., session challenge already consumed by a concurrent request) + Log.Users.PasskeyAttestationFailed(logger, request.Email, ex.Message); + return Result.Failure(Error.Validation(ErrorCodes.Passkey.AttestationFailed, "Attestation failed. Please try again.")).ToProblemDetails(); + } } catch (Exception ex) { @@ -328,6 +340,37 @@ public static IEndpointRouteBuilder MapPasskeyEndpoints(this IEndpointRouteBuild var assertion = await signInManager.PerformPasskeyAssertionAsync(request.CredentialJson); if (!assertion.Succeeded || assertion.User is null) { + // Check if the assertion failed due to a sign counter mismatch (possible cloned authenticator). + // The framework validates the counter before returning the result, so if the failure + // message indicates a counter issue, we must find the user and trigger a lockout. + if (assertion.Failure?.Message.Contains("signature counter", StringComparison.OrdinalIgnoreCase) == true + && userStore is IUserPasskeyStore passkeyStoreForLockout) + { + try + { + using var doc = JsonDocument.Parse(request.CredentialJson); + if (doc.RootElement.TryGetProperty("id", out var idElement)) + { + var credentialIdBase64Url = idElement.GetString(); + if (!string.IsNullOrEmpty(credentialIdBase64Url)) + { + var credentialId = WebEncoders.Base64UrlDecode(credentialIdBase64Url); + var lockedUser = await passkeyStoreForLockout.FindByPasskeyIdAsync(credentialId, cancellationToken); + if (lockedUser != null) + { + Log.Users.PasskeyCounterMismatch(logger, lockedUser.Email, 0, 0); + _ = await userManager.SetLockoutEndDateAsync(lockedUser, DateTimeOffset.UtcNow.AddHours(1)); + _ = await userManager.UpdateAsync(lockedUser); + } + } + } + } + catch (JsonException) + { + // If JSON parsing fails, continue with the generic failure response + } + } + Log.Users.PasskeyAssertionFailed(logger, false, false, false); return Result.Failure(Error.Unauthorized(ErrorCodes.Passkey.AssertionFailed, "Invalid passkey assertion.")).ToProblemDetails(); } @@ -354,26 +397,6 @@ public static IEndpointRouteBuilder MapPasskeyEndpoints(this IEndpointRouteBuild if (assertion.Passkey is not null) { - // Validate credential counter to detect cloned authenticators - if (userStore is IUserPasskeyStore passkeyStore) - { - var storedPasskey = await passkeyStore.FindPasskeyAsync(user, assertion.Passkey.CredentialId, cancellationToken); - if (storedPasskey != null && storedPasskey.SignCount > 0) - { - // Log counter validation for security monitoring - Log.Users.PasskeyCounterValidationTriggered(logger, user.Email, storedPasskey.SignCount, assertion.Passkey.SignCount); - - // Counter should always increment. If it doesn't, the authenticator may be cloned. - if (assertion.Passkey.SignCount <= storedPasskey.SignCount) - { - Log.Users.PasskeyCounterMismatch(logger, user.Email, storedPasskey.SignCount, assertion.Passkey.SignCount); - _ = await userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddHours(1)); - _ = await userManager.UpdateAsync(user); - return Result.Failure(Error.Unauthorized(ErrorCodes.Passkey.CounterMismatch, "Security violation detected. Account temporarily locked.")).ToProblemDetails(); - } - } - } - var updateResult = await userManager.AddOrUpdatePasskeyAsync(user, assertion.Passkey); if (!updateResult.Succeeded) { diff --git a/src/BookStore.ApiService/Infrastructure/DatabaseSeeder.cs b/src/BookStore.ApiService/Infrastructure/DatabaseSeeder.cs index 7cd830c7..7a4fb0f7 100644 --- a/src/BookStore.ApiService/Infrastructure/DatabaseSeeder.cs +++ b/src/BookStore.ApiService/Infrastructure/DatabaseSeeder.cs @@ -5,6 +5,7 @@ using BookStore.ApiService.Infrastructure.Tenant; using BookStore.ApiService.Projections; using BookStore.ApiService.Services; +using BookStore.Shared; using BookStore.Shared.Models; using JasperFx; using Marten; @@ -119,9 +120,10 @@ public async Task SeedAsync(string tenantId) ILogger? logger = null) { // Generate tenant-specific email if not provided - var adminEmail = email ?? (StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) - ? "admin@bookstore.com" - : $"admin@{tenantId}.com"); + var tenantAlias = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) + ? MultiTenancyConstants.DefaultTenantAlias + : tenantId; + var adminEmail = email ?? $"admin@{tenantAlias}.com"; var adminPassword = password ?? "Admin123!"; @@ -280,7 +282,7 @@ static Dictionary SeedAuthors(IDocumentSession session, ILo (Key: "LeGuin", Event: new AuthorAdded(Guid.CreateVersion7(), "Ursula K. Le Guin", new Dictionary { ["en"] = new("Ursula Kroeber Le Guin was an American author best known for her works of speculative fiction, including science fiction works set in her Hainish universe, and the Earthsea fantasy series.") }, DateTimeOffset.UtcNow)), - + // Spanish Authors (Key: "Borges", Event: new AuthorAdded(Guid.CreateVersion7(), "Jorge Luis Borges", new Dictionary { ["es"] = new("Jorge Francisco Isidoro Luis Borges fue un escritor, poeta, ensayista y traductor argentino, extensamente considerado una figura clave tanto para la literatura en habla hispana como para la literatura universal."), @@ -294,7 +296,7 @@ static Dictionary SeedAuthors(IDocumentSession session, ILo ["es"] = new("Miguel de Cervantes Saavedra fue un novelista, poeta, dramaturgo y soldado español. Es ampliamente considerado una de las máximas figuras de la literatura española."), ["en"] = new("Miguel de Cervantes Saavedra was a Spanish writer widely regarded as the greatest writer in the Spanish language and one of the world's pre-eminent novelists.") }, DateTimeOffset.UtcNow)), - + // French Authors (Key: "Hugo", Event: new AuthorAdded(Guid.CreateVersion7(), "Victor Hugo", new Dictionary { ["fr"] = new("Victor-Marie Hugo est un poète, dramaturge, et prosateur romantique considéré comme l'un des plus importants écrivains de la langue française."), diff --git a/src/BookStore.ApiService/Infrastructure/Extensions/ApplicationServicesExtensions.cs b/src/BookStore.ApiService/Infrastructure/Extensions/ApplicationServicesExtensions.cs index 3b1f269f..c9f2dd4e 100644 --- a/src/BookStore.ApiService/Infrastructure/Extensions/ApplicationServicesExtensions.cs +++ b/src/BookStore.ApiService/Infrastructure/Extensions/ApplicationServicesExtensions.cs @@ -1,3 +1,4 @@ +using Marten; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; @@ -232,14 +233,33 @@ static void AddIdentityServices(IServiceCollection services, IConfiguration conf // Validate security stamp on each request to detect token revocation options.Events = new Microsoft.AspNetCore.Authentication.JwtBearer.JwtBearerEvents { + OnChallenge = async context => + { + // If authentication failed (either by exception or by calling context.Fail()), ensure 401 is returned + if (context.AuthenticateFailure != null || !string.IsNullOrEmpty(context.Error)) + { + context.HandleResponse(); // Prevent default challenge behavior + context.Response.StatusCode = 401; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new + { + error = context.Error ?? "unauthorized", + error_description = context.ErrorDescription ?? context.AuthenticateFailure?.Message ?? "Authentication failed" + }); + } + }, OnTokenValidated = async context => { - var userManager = context.HttpContext.RequestServices.GetRequiredService>(); + // Get user directly from Marten session to bypass identity map caching + var session = context.HttpContext.RequestServices.GetRequiredService(); var userId = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - if (!string.IsNullOrEmpty(userId)) + if (!string.IsNullOrEmpty(userId) && Guid.TryParse(userId, out var userGuid)) { - var user = await userManager.FindByIdAsync(userId); + // Use Query instead of Load to bypass Marten's identity map caching + var user = await session.Query() + .FirstOrDefaultAsync(u => u.Id == userGuid, context.HttpContext.RequestAborted); + if (user != null) { // Get security stamp from token (null if claim doesn't exist) @@ -251,11 +271,15 @@ static void AddIdentityServices(IServiceCollection services, IConfiguration conf !string.IsNullOrEmpty(user.SecurityStamp) && tokenSecurityStamp != user.SecurityStamp) { + // CRITICAL: Clear the principal to prevent downstream middleware from seeing an authenticated user + context.HttpContext.User = new System.Security.Claims.ClaimsPrincipal(); context.Fail("Token has been revoked due to security stamp change."); } } else { + // CRITICAL: Clear the principal + context.HttpContext.User = new System.Security.Claims.ClaimsPrincipal(); context.Fail("User not found."); } } diff --git a/src/BookStore.ApiService/Infrastructure/Extensions/RateLimitingExtensions.cs b/src/BookStore.ApiService/Infrastructure/Extensions/RateLimitingExtensions.cs index d3c8e523..b6822151 100644 --- a/src/BookStore.ApiService/Infrastructure/Extensions/RateLimitingExtensions.cs +++ b/src/BookStore.ApiService/Infrastructure/Extensions/RateLimitingExtensions.cs @@ -5,71 +5,87 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; namespace BookStore.ApiService.Infrastructure.Extensions; public static class RateLimitingExtensions { public static IServiceCollection AddCustomRateLimiting(this IServiceCollection services, IConfiguration configuration) => services.AddRateLimiter(options => - { - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - var rateLimitOptions = new RateLimitOptions(); - configuration.GetSection(RateLimitOptions.SectionName).Bind(rateLimitOptions); + var rateLimitOptions = new RateLimitOptions(); + configuration.GetSection(RateLimitOptions.SectionName).Bind(rateLimitOptions); - options.GlobalLimiter = PartitionedRateLimiter.Create(context => - { - // Exempt health checks and metrics - if (context.Request.Path.StartsWithSegments("/health") || - context.Request.Path.StartsWithSegments("/metrics")) - { - return RateLimitPartition.GetNoLimiter("exempt"); - } + // Check if rate limiting should be disabled (for tests) + var disableRateLimiting = configuration.GetValue("RateLimit:Disabled"); - // Per-tenant rate limiting - var tenantId = context.Items["TenantId"]?.ToString() - ?? JasperFx.StorageConstants.DefaultTenantId; + options.GlobalLimiter = PartitionedRateLimiter.Create(context => + { + // Disable rate limiting if configured + if (disableRateLimiting) + { + return RateLimitPartition.GetNoLimiter("disabled"); + } - return RateLimitPartition.GetFixedWindowLimiter(tenantId, _ => - new FixedWindowRateLimiterOptions - { - PermitLimit = rateLimitOptions.PermitLimit, - Window = TimeSpan.FromMinutes(rateLimitOptions.WindowInMinutes), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = 100 - }); - }); + // Exempt health checks and metrics + if (context.Request.Path.StartsWithSegments("/health") || + context.Request.Path.StartsWithSegments("/metrics")) + { + return RateLimitPartition.GetNoLimiter("exempt"); + } - // Stricter rate limiting for authentication endpoints - _ = options.AddPolicy("AuthPolicy", httpContext => - { - var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + // Per-tenant rate limiting + var tenantId = context.Items["TenantId"]?.ToString() + ?? JasperFx.StorageConstants.DefaultTenantId; - return RateLimitPartition.GetFixedWindowLimiter(ipAddress, _ => - new FixedWindowRateLimiterOptions - { - PermitLimit = rateLimitOptions.AuthPermitLimit, - Window = TimeSpan.FromSeconds(rateLimitOptions.AuthWindowSeconds), - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - QueueLimit = rateLimitOptions.AuthQueueLimit - }); - }); + return RateLimitPartition.GetFixedWindowLimiter(tenantId, _ => + new FixedWindowRateLimiterOptions + { + PermitLimit = rateLimitOptions.PermitLimit, + Window = TimeSpan.FromMinutes(rateLimitOptions.WindowInMinutes), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 100 + }); + }); - options.OnRejected = async (context, cancellationToken) => - { - context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + // Stricter rate limiting for authentication endpoints + _ = options.AddPolicy("AuthPolicy", httpContext => + { + // Disable rate limiting if configured + if (disableRateLimiting) + { + return RateLimitPartition.GetNoLimiter("disabled"); + } - double? retryAfterSeconds = null; - if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) - { - retryAfterSeconds = retryAfter.TotalSeconds; - } + var ipAddress = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - await context.HttpContext.Response.WriteAsJsonAsync(new - { - error = "Rate limit exceeded", - retryAfter = retryAfterSeconds - }, cancellationToken); - }; - }); + return RateLimitPartition.GetFixedWindowLimiter(ipAddress, _ => + new FixedWindowRateLimiterOptions + { + PermitLimit = rateLimitOptions.AuthPermitLimit, + Window = TimeSpan.FromSeconds(rateLimitOptions.AuthWindowSeconds), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = rateLimitOptions.AuthQueueLimit + }); + }); + + options.OnRejected = async (context, cancellationToken) => + { + context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests; + + double? retryAfterSeconds = null; + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + retryAfterSeconds = retryAfter.TotalSeconds; + } + + await context.HttpContext.Response.WriteAsJsonAsync(new + { + error = "Rate limit exceeded", + retryAfter = retryAfterSeconds + }, cancellationToken); + }; + }); } diff --git a/src/BookStore.ApiService/Infrastructure/Identity/MartenUserStore.cs b/src/BookStore.ApiService/Infrastructure/Identity/MartenUserStore.cs index 08582af4..433198c0 100644 --- a/src/BookStore.ApiService/Infrastructure/Identity/MartenUserStore.cs +++ b/src/BookStore.ApiService/Infrastructure/Identity/MartenUserStore.cs @@ -2,6 +2,7 @@ using Marten; using Marten.Linq.MatchesSql; using Microsoft.AspNetCore.Identity; +using Npgsql; namespace BookStore.ApiService.Infrastructure.Identity; @@ -27,9 +28,43 @@ public sealed class MartenUserStore : public async Task CreateAsync(ApplicationUser user, CancellationToken cancellationToken) { - _session.Store(user); - await _session.SaveChangesAsync(cancellationToken); - return IdentityResult.Success; + try + { + _session.Store(user); + await _session.SaveChangesAsync(cancellationToken); + return IdentityResult.Success; + } + catch (Exception ex) when (IsUniqueConstraintViolation(ex)) + { + // PostgreSQL unique constraint violation (error code 23505) occurs during + // concurrent registration attempts with the same email/username. + // Return a structured Identity error so the caller can react appropriately. + return IdentityResult.Failed(new IdentityError + { + Code = "DuplicateUserName", + Description = $"A user with the name '{user.Email}' already exists." + }); + } + } + + /// + /// Determines whether the exception chain contains a PostgreSQL unique constraint + /// violation (error code 23505). + /// + static bool IsUniqueConstraintViolation(Exception ex) + { + var current = ex; + while (current is not null) + { + if (current is PostgresException pgEx && pgEx.SqlState == "23505") + { + return true; + } + + current = current.InnerException; + } + + return false; } public async Task UpdateAsync(ApplicationUser user, CancellationToken cancellationToken) diff --git a/src/BookStore.ApiService/Infrastructure/Logging/Log.Users.cs b/src/BookStore.ApiService/Infrastructure/Logging/Log.Users.cs index 2c855754..d710256d 100644 --- a/src/BookStore.ApiService/Infrastructure/Logging/Log.Users.cs +++ b/src/BookStore.ApiService/Infrastructure/Logging/Log.Users.cs @@ -61,6 +61,11 @@ public static partial class Users Message = "Refresh failed: Token expired or invalid for user {User}")] public static partial void RefreshFailedTokenExpiredOrInvalid(ILogger logger, string? user); + [LoggerMessage( + Level = LogLevel.Warning, + Message = "Refresh failed: Security stamp mismatch for user {User}")] + public static partial void RefreshFailedSecurityStampMismatch(ILogger logger, string? user); + // Passkeys [LoggerMessage( Level = LogLevel.Error, diff --git a/src/BookStore.ApiService/Infrastructure/Tenant/MartenTenantStore.cs b/src/BookStore.ApiService/Infrastructure/Tenant/MartenTenantStore.cs index 2e424781..cb8dd5ab 100644 --- a/src/BookStore.ApiService/Infrastructure/Tenant/MartenTenantStore.cs +++ b/src/BookStore.ApiService/Infrastructure/Tenant/MartenTenantStore.cs @@ -1,5 +1,5 @@ using BookStore.ApiService.Models; -using JasperFx; +using BookStore.Shared; using Marten; namespace BookStore.ApiService.Infrastructure.Tenant; @@ -9,7 +9,9 @@ public class MartenTenantStore(IDocumentStore store) : ITenantStore public async Task IsValidTenantAsync(string tenantId) { // Special case for "*DEFAULT*" which might not always be in the DB depending on initialization - if (StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase)) + // Also accept "default" (case-insensitive) as an alias for the default tenant + if (MultiTenancyConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) || + MultiTenancyConstants.DefaultTenantAlias.Equals(tenantId, StringComparison.OrdinalIgnoreCase)) { return true; } diff --git a/src/BookStore.ApiService/Infrastructure/Tenant/TenantConstants.cs b/src/BookStore.ApiService/Infrastructure/Tenant/TenantConstants.cs index d8e3cb9d..61b3d4c8 100644 --- a/src/BookStore.ApiService/Infrastructure/Tenant/TenantConstants.cs +++ b/src/BookStore.ApiService/Infrastructure/Tenant/TenantConstants.cs @@ -1,15 +1,15 @@ -using JasperFx; +using BookStore.Shared; namespace BookStore.ApiService.Infrastructure.Tenant; /// /// Central constants for multi-tenancy configuration. -/// For the default tenant ID, use JasperFx.StorageConstants.DefaultTenantId directly. +/// For the default tenant ID, use MultiTenancyConstants.DefaultTenantId from BookStore.Shared. /// public static class TenantConstants { /// /// All known tenant IDs for seeding and configuration. /// - public static readonly string[] KnownTenants = [StorageConstants.DefaultTenantId, "acme", "contoso"]; + public static readonly string[] KnownTenants = [MultiTenancyConstants.DefaultTenantId, "acme", "contoso"]; } diff --git a/src/BookStore.ApiService/Infrastructure/Tenant/TenantResolutionMiddleware.cs b/src/BookStore.ApiService/Infrastructure/Tenant/TenantResolutionMiddleware.cs index 89d6bfa8..addb87bd 100644 --- a/src/BookStore.ApiService/Infrastructure/Tenant/TenantResolutionMiddleware.cs +++ b/src/BookStore.ApiService/Infrastructure/Tenant/TenantResolutionMiddleware.cs @@ -1,4 +1,5 @@ using BookStore.ApiService.Infrastructure.Logging; +using BookStore.Shared.Models; using Microsoft.AspNetCore.Http; namespace BookStore.ApiService.Infrastructure.Tenant; @@ -31,10 +32,21 @@ public async Task InvokeAsync(HttpContext context, ITenantContext tenantContext, } else { - // Invalid tenant - return 400 Bad Request + // Invalid tenant - return 400 Bad Request with ProblemDetails Log.Tenants.InvalidTenantRequested(_logger, tenantId); context.Response.StatusCode = StatusCodes.Status400BadRequest; - await context.Response.WriteAsJsonAsync(new { error = "Invalid or unknown tenant" }); + context.Response.ContentType = "application/problem+json"; + + var problemDetails = new + { + type = "https://tools.ietf.org/html/rfc7231#section-6.5.1", + title = "Bad Request", + status = 400, + detail = "The specified tenant is invalid or does not exist.", + error = ErrorCodes.Tenancy.TenantNotFound + }; + + await context.Response.WriteAsJsonAsync(problemDetails); return; } } diff --git a/src/BookStore.ApiService/Models/ApplicationUser.cs b/src/BookStore.ApiService/Models/ApplicationUser.cs index 12903e2c..6489e6ec 100644 --- a/src/BookStore.ApiService/Models/ApplicationUser.cs +++ b/src/BookStore.ApiService/Models/ApplicationUser.cs @@ -108,4 +108,5 @@ public record RefreshTokenInfo( string Token, DateTimeOffset Expires, DateTimeOffset Created, - string TenantId); + string TenantId, + string SecurityStamp); diff --git a/src/BookStore.ApiService/Services/JwtTokenService.cs b/src/BookStore.ApiService/Services/JwtTokenService.cs index 096ba571..e664b522 100644 --- a/src/BookStore.ApiService/Services/JwtTokenService.cs +++ b/src/BookStore.ApiService/Services/JwtTokenService.cs @@ -108,7 +108,8 @@ public string RotateRefreshToken(BookStore.ApiService.Models.ApplicationUser use newToken, DateTimeOffset.UtcNow.AddDays(7), DateTimeOffset.UtcNow, - tenantId)); + tenantId, + user.SecurityStamp ?? string.Empty)); // 4. Prune old tokens (keep latest 5) if (user.RefreshTokens.Count > 5) diff --git a/src/BookStore.AppHost/AppHost.cs b/src/BookStore.AppHost/AppHost.cs index 77167af4..31b83ce5 100644 --- a/src/BookStore.AppHost/AppHost.cs +++ b/src/BookStore.AppHost/AppHost.cs @@ -36,6 +36,13 @@ url.Url += ResourceNames.ApiReferenceUrl; }); +// Pass rate limit configuration to disable for tests +var disableRateLimit = builder.Configuration["RateLimit:Disabled"]; +if (!string.IsNullOrEmpty(disableRateLimit)) +{ + _ = apiService.WithEnvironment("RateLimit__Disabled", disableRateLimit); +} + var seedingEnabled = builder.Configuration["Seeding:Enabled"]; if (!string.IsNullOrEmpty(seedingEnabled)) { diff --git a/src/BookStore.Shared/MultiTenancyConstants.cs b/src/BookStore.Shared/MultiTenancyConstants.cs index e3ac4abd..1fa4663e 100644 --- a/src/BookStore.Shared/MultiTenancyConstants.cs +++ b/src/BookStore.Shared/MultiTenancyConstants.cs @@ -9,4 +9,10 @@ public static class MultiTenancyConstants /// The default tenant ID. /// public const string DefaultTenantId = "*DEFAULT*"; + + /// + /// Human-readable alias for the default tenant that can be used in test scenarios. + /// This maps to DefaultTenantId ("*DEFAULT*"). + /// + public const string DefaultTenantAlias = "default"; } diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 1ade6e52..054b4e5b 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -1,7 +1,7 @@ # Tests — Agent Instructions ## Quick Reference -- **Stack**: .NET 10, TUnit, Bogus, NSubstitute +- **Stack**: .NET 10, TUnit, Bogus, NSubstitute, Playwright - **Docs**: `docs/guides/testing-guide.md`, `docs/guides/integration-testing-guide.md` - **Test**: `dotnet test` | **Project**: `dotnet test --project tests//.csproj` - **Helpers**: `tests/BookStore.AppHost.Tests/TestHelpers.cs` @@ -42,6 +42,7 @@ - **Flaky tests**: Ensure per-test data creation and unique IDs - **Write-side timing**: Prefer SSE helpers over polling or delays - **Analyzer tests failing**: Keep `TestData` inputs out of compilation +- **Playwright browser missing**: Build `BookStore.AppHost.Tests` first, then run `node tests/BookStore.AppHost.Tests/bin/Debug/net10.0/.playwright/package/index.js install chromium` ## Documentation Index | Topic | Guide | diff --git a/tests/BookStore.ApiService.UnitTests/AggregateFactory.cs b/tests/BookStore.ApiService.UnitTests/AggregateFactory.cs index 6dc6bce9..e2eea23e 100644 --- a/tests/BookStore.ApiService.UnitTests/AggregateFactory.cs +++ b/tests/BookStore.ApiService.UnitTests/AggregateFactory.cs @@ -12,6 +12,7 @@ public static T Hydrate(params object[] events) where T : class { var aggregate = (T)Activator.CreateInstance(typeof(T), true)!; var type = typeof(T); + long version = 0; foreach (var @event in events) { @@ -23,6 +24,7 @@ public static T Hydrate(params object[] events) where T : class if (applyMethod != null) { _ = applyMethod.Invoke(aggregate, [@event]); + version++; } else { @@ -31,6 +33,11 @@ public static T Hydrate(params object[] events) where T : class } } + // Mimic Marten's behaviour: Version equals the number of events applied + var versionProperty = type.GetProperty("Version", + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + versionProperty?.SetValue(aggregate, version); + return aggregate; } } diff --git a/tests/BookStore.ApiService.UnitTests/Handlers/AuthorHandlerTests.cs b/tests/BookStore.ApiService.UnitTests/Handlers/AuthorHandlerTests.cs index 9916f03b..6791ee9c 100644 --- a/tests/BookStore.ApiService.UnitTests/Handlers/AuthorHandlerTests.cs +++ b/tests/BookStore.ApiService.UnitTests/Handlers/AuthorHandlerTests.cs @@ -63,7 +63,7 @@ public async Task UpdateAuthorHandler_ShouldAppendAuthorUpdatedEvent() "Robert C. Martin Updated", new Dictionary { ["en"] = new AuthorTranslationDto("Uncle Bob Updated") } ) - { ETag = "test-etag" }; + { ETag = "\"1\"" }; // Mock Stream State _ = Session.Events.FetchStreamStateAsync(command.Id).Returns(new Marten.Events.StreamState { Version = 1 }); diff --git a/tests/BookStore.ApiService.UnitTests/Handlers/CategoryHandlerTests.cs b/tests/BookStore.ApiService.UnitTests/Handlers/CategoryHandlerTests.cs index db81e048..9240a47b 100644 --- a/tests/BookStore.ApiService.UnitTests/Handlers/CategoryHandlerTests.cs +++ b/tests/BookStore.ApiService.UnitTests/Handlers/CategoryHandlerTests.cs @@ -63,7 +63,7 @@ public async Task UpdateCategoryHandler_ShouldAppendCategoryUpdatedEvent() Guid.CreateVersion7(), new Dictionary { ["en"] = new CategoryTranslationDto("Technology Updated") } ) - { ETag = "test-etag" }; + { ETag = "\"1\"" }; // Mock Stream State _ = Session.Events.FetchStreamStateAsync(command.Id).Returns(new Marten.Events.StreamState { Version = 1 }); diff --git a/tests/BookStore.ApiService.UnitTests/Handlers/PublisherHandlerTests.cs b/tests/BookStore.ApiService.UnitTests/Handlers/PublisherHandlerTests.cs index 4ef33d0d..19f6f7ba 100644 --- a/tests/BookStore.ApiService.UnitTests/Handlers/PublisherHandlerTests.cs +++ b/tests/BookStore.ApiService.UnitTests/Handlers/PublisherHandlerTests.cs @@ -32,7 +32,7 @@ public async Task CreatePublisherHandler_ShouldStartStreamWithPublisherAddedEven public async Task UpdatePublisherHandler_ShouldAppendPublisherUpdatedEvent() { // Arrange - var command = new UpdatePublisher(Guid.CreateVersion7(), "O'Reilly Media Updated") { ETag = "test-etag" }; + var command = new UpdatePublisher(Guid.CreateVersion7(), "O'Reilly Media Updated") { ETag = "\"1\"" }; // Mock Stream State _ = Session.Events.FetchStreamStateAsync(command.Id).Returns(new Marten.Events.StreamState { Version = 1 }); diff --git a/tests/BookStore.ApiService.UnitTests/Projections/AuthorProjectionTests.cs b/tests/BookStore.ApiService.UnitTests/Projections/AuthorProjectionTests.cs index a4a16dd3..73c1f677 100644 --- a/tests/BookStore.ApiService.UnitTests/Projections/AuthorProjectionTests.cs +++ b/tests/BookStore.ApiService.UnitTests/Projections/AuthorProjectionTests.cs @@ -11,7 +11,7 @@ public class AuthorProjectionTests public async Task Create_ShouldInitializeProjectionFromEvent() { // Arrange - var id = Guid.NewGuid(); + var id = Guid.CreateVersion7(); var timestamp = DateTimeOffset.UtcNow; var @event = new AuthorAdded( id, @@ -43,7 +43,7 @@ public async Task Apply_ShouldUpdateProjectionFromEvent() // Arrange var projection = new AuthorProjection { - Id = Guid.NewGuid(), + Id = Guid.CreateVersion7(), Name = "Old Name", Biographies = new Dictionary { ["en"] = "Old Bio" } }; diff --git a/tests/BookStore.ApiService.UnitTests/Projections/BookSearchProjectionTests.cs b/tests/BookStore.ApiService.UnitTests/Projections/BookSearchProjectionTests.cs index b781e011..46cc2820 100644 --- a/tests/BookStore.ApiService.UnitTests/Projections/BookSearchProjectionTests.cs +++ b/tests/BookStore.ApiService.UnitTests/Projections/BookSearchProjectionTests.cs @@ -14,9 +14,9 @@ public class BookSearchProjectionTests public async Task Create_ShouldInitializeProjectionAndLoadDenormalizedData() { // Arrange - var id = Guid.NewGuid(); - var publisherId = Guid.NewGuid(); - var authorId = Guid.NewGuid(); + var id = Guid.CreateVersion7(); + var publisherId = Guid.CreateVersion7(); + var authorId = Guid.CreateVersion7(); var @event = new BookAdded( id, @@ -68,10 +68,10 @@ public async Task Create_ShouldInitializeProjectionAndLoadDenormalizedData() public async Task Apply_ShouldUpdateProjectionAndReloadDenormalizedData() { // Arrange - var projection = new BookSearchProjection { Id = Guid.NewGuid(), Title = "Old Title" }; + var projection = new BookSearchProjection { Id = Guid.CreateVersion7(), Title = "Old Title" }; - var publisherId = Guid.NewGuid(); - var authorId = Guid.NewGuid(); + var publisherId = Guid.CreateVersion7(); + var authorId = Guid.CreateVersion7(); var @event = new BookUpdated( projection.Id, @@ -134,7 +134,7 @@ static IMartenQueryable CreateMartenQueryable(IEnumerable source) public async Task Apply_BookSaleScheduled_ShouldAddSale() { // Arrange - var projection = new BookSearchProjection { Id = Guid.NewGuid() }; + var projection = new BookSearchProjection { Id = Guid.CreateVersion7() }; var sale = new BookSale(10m, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow.AddDays(1)); var @event = new BookSaleScheduled(projection.Id, sale); @@ -156,7 +156,7 @@ public async Task Apply_BookSaleScheduled_ShouldAddSale() public async Task Apply_BookSaleScheduled_WithExistingOverlap_ShouldReplace() { // Arrange - var projection = new BookSearchProjection { Id = Guid.NewGuid() }; + var projection = new BookSearchProjection { Id = Guid.CreateVersion7() }; var start = DateTimeOffset.UtcNow; var existingSale = new BookSale(10m, start, start.AddDays(1)); projection.Sales.Add(existingSale); @@ -183,7 +183,7 @@ public async Task Apply_BookSaleScheduled_WithExistingOverlap_ShouldReplace() public async Task Apply_BookSaleCancelled_ShouldRemoveSale() { // Arrange - var projection = new BookSearchProjection { Id = Guid.NewGuid() }; + var projection = new BookSearchProjection { Id = Guid.CreateVersion7() }; var start = DateTimeOffset.UtcNow; var sale = new BookSale(10m, start, start.AddDays(1)); projection.Sales.Add(sale); @@ -207,7 +207,7 @@ public async Task Apply_BookSaleCancelled_ShouldRemoveSale() public async Task Apply_BookDiscountUpdated_ShouldRecalculateCurrentPrices() { // Arrange - var id = Guid.NewGuid(); + var id = Guid.CreateVersion7(); var projection = new BookSearchProjection { Id = id, @@ -236,7 +236,7 @@ public async Task Apply_BookDiscountUpdated_ShouldRecalculateCurrentPrices() public async Task Apply_BookDiscountUpdated_WithZeroDiscount_ShouldResetToOriginalPrices() { // Arrange - var id = Guid.NewGuid(); + var id = Guid.CreateVersion7(); var projection = new BookSearchProjection { Id = id, @@ -263,7 +263,7 @@ public async Task Apply_BookDiscountUpdated_WithZeroDiscount_ShouldResetToOrigin public async Task Apply_BookUpdated_ShouldPreserveDiscount() { // Arrange - var id = Guid.NewGuid(); + var id = Guid.CreateVersion7(); var projection = new BookSearchProjection { Id = id, diff --git a/tests/BookStore.ApiService.UnitTests/Projections/CategoryProjectionTests.cs b/tests/BookStore.ApiService.UnitTests/Projections/CategoryProjectionTests.cs index af3daf01..d86c617b 100644 --- a/tests/BookStore.ApiService.UnitTests/Projections/CategoryProjectionTests.cs +++ b/tests/BookStore.ApiService.UnitTests/Projections/CategoryProjectionTests.cs @@ -11,7 +11,7 @@ public class CategoryProjectionTests public async Task Create_ShouldInitializeProjectionFromEvent() { // Arrange - var id = Guid.NewGuid(); + var id = Guid.CreateVersion7(); var timestamp = DateTimeOffset.UtcNow; var @event = new CategoryAdded( id, @@ -41,7 +41,7 @@ public async Task Apply_ShouldUpdateProjectionFromEvent() // Arrange var projection = new CategoryProjection { - Id = Guid.NewGuid(), + Id = Guid.CreateVersion7(), Names = new Dictionary { ["en"] = "Old Tech" } }; diff --git a/tests/BookStore.ApiService.UnitTests/ValidationTests.cs b/tests/BookStore.ApiService.UnitTests/ValidationTests.cs index 9e6f7a18..5db293c2 100644 --- a/tests/BookStore.ApiService.UnitTests/ValidationTests.cs +++ b/tests/BookStore.ApiService.UnitTests/ValidationTests.cs @@ -11,7 +11,7 @@ public class ValidationTests public async Task BookAggregate_Validation_ReturnsResult() { // Arrange - var id = Guid.NewGuid(); + var id = Guid.CreateVersion7(); var title = ""; // Invalid var isbn = "123"; // Invalid @@ -38,7 +38,7 @@ public async Task BookAggregate_Validation_ReturnsResult() public async Task AuthorAggregate_Validation_ReturnsResult() { // Arrange - var id = Guid.NewGuid(); + var id = Guid.CreateVersion7(); var name = ""; // Invalid // Act @@ -54,7 +54,7 @@ public async Task AuthorAggregate_Validation_ReturnsResult() public async Task CategoryAggregate_Validation_ReturnsResult() { // Arrange - var id = Guid.NewGuid(); + var id = Guid.CreateVersion7(); // Act var result = CategoryAggregate.CreateEvent(id, []); diff --git a/tests/BookStore.AppHost.Tests/AGENTS.md b/tests/BookStore.AppHost.Tests/AGENTS.md index c01a7d6c..cea75a63 100644 --- a/tests/BookStore.AppHost.Tests/AGENTS.md +++ b/tests/BookStore.AppHost.Tests/AGENTS.md @@ -6,6 +6,7 @@ - **Test**: `dotnet test tests/BookStore.AppHost.Tests/` - **Filter**: `dotnet test --filter "FullyQualifiedName~BookCrudTests"` - **Helpers**: `tests/BookStore.AppHost.Tests/TestHelpers.cs` +- **Playwright browsers**: Must be installed before first run — see setup note below ## Key Rules (MUST follow) ``` @@ -18,8 +19,21 @@ ✅ Verify tenant isolation ❌ Shared data across tenants ``` +## Playwright Setup + +These tests use **Microsoft.Playwright** for browser-based authentication flows. Playwright browsers must be installed separately after building the project: + +```bash +dotnet build tests/BookStore.AppHost.Tests/BookStore.AppHost.Tests.csproj +node tests/BookStore.AppHost.Tests/bin/Debug/net10.0/.playwright/package/index.js install chromium +``` + +> [!IMPORTANT] +> The `node` command path is relative to the repo root. The `.playwright` directory is created by the build; run the build step first. Re-run after a `dotnet clean` or switching build configurations (`Debug`/`Release`). + ## Common Mistakes - ❌ Ignoring tests/AGENTS.md rules → This file extends `tests/AGENTS.md` +- ❌ Running browser tests without installing Playwright browsers → Run the install step above first - ❌ Using polling/delays for async commands → Always await SSE events - ❌ Redundant polling after event helpers → `ExecuteAndWaitForEventAsync` already ensures consistency - ❌ Skipping infra startup → Use Aspire `DistributedApplicationTestingBuilder` diff --git a/tests/BookStore.AppHost.Tests/AccountIsolationTests.cs b/tests/BookStore.AppHost.Tests/AccountIsolationTests.cs index 9e4dfb78..e87f38d7 100644 --- a/tests/BookStore.AppHost.Tests/AccountIsolationTests.cs +++ b/tests/BookStore.AppHost.Tests/AccountIsolationTests.cs @@ -2,8 +2,6 @@ using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; -using JasperFx; -using Marten; using Refit; namespace BookStore.AppHost.Tests; @@ -14,112 +12,72 @@ namespace BookStore.AppHost.Tests; /// public class AccountIsolationTests { - [Before(Class)] - public static async Task ClassSetup() - { - if (GlobalHooks.App == null) - { - throw new InvalidOperationException("App is not initialized"); - } - - // Ensure tenants exist - var connectionString = await GlobalHooks.App.GetConnectionStringAsync("bookstore"); - if (string.IsNullOrEmpty(connectionString)) - { - throw new InvalidOperationException("Could not retrieve connection string for 'bookstore' resource."); - } - - using var store = DocumentStore.For(opts => - { - opts.Connection(connectionString); - _ = opts.Policies.AllDocumentsAreMultiTenanted(); - opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; - }); - - await DatabaseHelpers.SeedTenantAsync(store, "acme"); - await DatabaseHelpers.SeedTenantAsync(store, "contoso"); - } - [Test] - public async Task User_RegisteredOnContoso_CannotLoginOnAcme() + public async Task User_RegisteredOnTenantA_CannotLoginOnTenantB() { - // Arrange: Create a unique user email for this test + // Arrange: two fresh isolated tenants and a unique user + var tenantA = FakeDataGenerators.GenerateFakeTenantId(); + var tenantB = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantA); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantB); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); var password = FakeDataGenerators.GenerateFakePassword(); - var contosoClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("contoso")); - var acmeClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("acme")); - - // Act 1: Register user on Contoso tenant - _ = await contosoClient.RegisterAsync(new RegisterRequest(userEmail, password)); + var clientA = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantA)); + var clientB = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantB)); - // Act 2: Attempt to login with the same credentials on Acme tenant - var loginTask = acmeClient.LoginAsync(new LoginRequest(userEmail, password)); + // Act 1: Register user on tenant A + _ = await clientA.RegisterAsync(new RegisterRequest(userEmail, password)); - // Assert: Login should FAIL because user is registered on Contoso, not Acme - var exception = await Assert.That(async () => await loginTask).Throws(); + // Act 2: Attempt login on tenant B with same credentials + var exception = await Assert.That(async () => + await clientB.LoginAsync(new LoginRequest(userEmail, password))) + .Throws(); _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } [Test] - public async Task User_RegisteredOnContoso_CanLoginOnContoso() + public async Task User_RegisteredOnTenant_CanLoginOnSameTenant() { - // Arrange: Create a unique user email for this test + // Arrange: fresh tenant and unique user + var tenantId = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantId); + var userEmail = FakeDataGenerators.GenerateFakeEmail(); var password = FakeDataGenerators.GenerateFakePassword(); - var contosoClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("contoso")); - - // Act 1: Register user on Contoso tenant - _ = await contosoClient.RegisterAsync(new RegisterRequest(userEmail, password)); + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantId)); - // Act 2: Login with the same credentials on Contoso tenant (correct tenant) - var loginResult = await contosoClient.LoginAsync(new LoginRequest(userEmail, password)); + // Act: register then login on the same tenant + _ = await client.RegisterAsync(new RegisterRequest(userEmail, password)); + var loginResult = await client.LoginAsync(new LoginRequest(userEmail, password)); - // Assert: Login should succeed on the correct tenant + // Assert: should succeed _ = await Assert.That(loginResult).IsNotNull(); _ = await Assert.That(loginResult.AccessToken).IsNotEmpty(); } [Test] - public async Task User_RegisteredOnAcme_CannotLoginOnContoso() + public async Task User_RegisteredOnDefault_CannotLoginOnAnotherTenant() { - // Arrange: Create a unique user email for this test - var userEmail = FakeDataGenerators.GenerateFakeEmail(); - var password = FakeDataGenerators.GenerateFakePassword(); + // Arrange: a fresh non-default tenant and a unique user + var otherTenant = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(otherTenant); - var acmeClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("acme")); - var contosoClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("contoso")); - - // Act 1: Register user on Acme tenant - _ = await acmeClient.RegisterAsync(new RegisterRequest(userEmail, password)); - - // Act 2: Attempt to login with the same credentials on Contoso tenant - var loginTask = contosoClient.LoginAsync(new LoginRequest(userEmail, password)); - - // Assert: Login should FAIL because user is registered on Acme, not Contoso - var exception = await Assert.That(async () => await loginTask).Throws(); - _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); - } - - [Test] - public async Task User_RegisteredOnDefault_CannotLoginOnAcme() - { - // Arrange: Create a unique user email for this test var userEmail = FakeDataGenerators.GenerateFakeEmail(); var password = FakeDataGenerators.GenerateFakePassword(); var defaultClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); - var acmeClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("acme")); + var otherClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(otherTenant)); - // Act 1: Register user on Default tenant (no X-Tenant-ID header) + // Act 1: Register on default tenant _ = await defaultClient.RegisterAsync(new RegisterRequest(userEmail, password)); - // Act 2: Attempt to login with the same credentials on Acme tenant - var loginTask = acmeClient.LoginAsync(new LoginRequest(userEmail, password)); - - // Assert: Login should FAIL because user is registered on Default, not Acme - var exception = await Assert.That(async () => await loginTask).Throws(); + // Act 2: Attempt login on the other tenant + var exception = await Assert.That(async () => + await otherClient.LoginAsync(new LoginRequest(userEmail, password))) + .Throws(); _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } } diff --git a/tests/BookStore.AppHost.Tests/AccountLockoutTests.cs b/tests/BookStore.AppHost.Tests/AccountLockoutTests.cs new file mode 100644 index 00000000..9f95e80b --- /dev/null +++ b/tests/BookStore.AppHost.Tests/AccountLockoutTests.cs @@ -0,0 +1,251 @@ +using System.Net; +using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; +using BookStore.Client; +using BookStore.Shared.Models; +using JasperFx; +using Refit; +using TUnit; + +namespace BookStore.AppHost.Tests; + +/// +/// Tests to verify account lockout behavior for both password and passkey authentication. +/// CRITICAL: These tests validate that accounts are locked after repeated failures and lockout persists. +/// +public class AccountLockoutTests +{ + [Before(Class)] + public static async Task ClassSetup() + { + if (GlobalHooks.App == null) + { + throw new InvalidOperationException("App is not initialized"); + } + + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-a"); + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-b"); + } + + [Test] + [Arguments("default")] + [Arguments("tenant-a")] + [Arguments("tenant-b")] + public async Task FailedPasswordAttempts_TriggerAccountLockout(string tenantId) + { + // Arrange: Create user + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); + + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantId)); + _ = await client.RegisterAsync(new RegisterRequest(email, password)); + + // Act: Attempt login with wrong password 5 times (configured max attempts) + var wrongPassword = "WrongPassword123!"; + for (var i = 0; i < 5; i++) + { + try + { + _ = await client.LoginAsync(new LoginRequest(email, wrongPassword)); + } + catch (ApiException) + { + // Expected to fail + } + } + + // Wait for lockout to be reflected + _ = await WaitForAccountLockoutAsync(email, tenantId); + + // Act: Try to login with CORRECT password after lockout + var exception = await Assert.That(async () => + await client.LoginAsync(new LoginRequest(email, password))) + .Throws(); + + // Assert: Should return locked out error + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + var problem = await exception.GetContentAsAsync(); + _ = await Assert.That(problem?.Error).IsEqualTo(ErrorCodes.Auth.LockedOut); + } + + [Test] + public async Task LockedAccount_PreventsPasswordLogin() + { + // Arrange: Create user and manually lock account + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); + + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); + _ = await client.RegisterAsync(new RegisterRequest(email, password)); + + // Manually lock the account + await ManuallyLockAccountAsync(email, DateTimeOffset.UtcNow.AddMinutes(5)); + + // Act: Try to login with correct password + var exception = await Assert.That(async () => + await client.LoginAsync(new LoginRequest(email, password))) + .Throws(); + + // Assert: Should return locked out error + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + var problem = await exception.GetContentAsAsync(); + _ = await Assert.That(problem?.Error).IsEqualTo(ErrorCodes.Auth.LockedOut); + } + + [Test] + public async Task LockedAccount_PreventsPasskeyAuthentication() + { + // Arrange: Create user and register a real passkey via the WebAuthn virtual authenticator + var tenantId = StorageConstants.DefaultTenantId; + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenantId); + + await using var webAuthn = await WebAuthnTestHelper.CreateAsync(); + var registeredPasskey = await webAuthn.RegisterPasskeyAsync(email, tenantId, loginResponse.AccessToken); + + // Manually lock the account directly in the database + await ManuallyLockAccountAsync(email, DateTimeOffset.UtcNow.AddMinutes(5), tenantId); + + // Act: Try to login using the real passkey assertion flow + var loginException = await Assert.That( + async () => await webAuthn.LoginWithPasskeyAsync(registeredPasskey)) + .Throws(); + + // Assert: Passkey login should be rejected because the account is locked + _ = await Assert.That(loginException).IsNotNull(); + } + + [Test] + [Arguments("tenant-a")] + [Arguments("tenant-b")] + public async Task PasskeyCounterDecrement_TriggersLockout(string tenantId) + { + // Arrange: Register a real passkey (virtual authenticator counter starts at 0) + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenantId); + + await using var webAuthn = await WebAuthnTestHelper.CreateAsync(); + _ = await webAuthn.RegisterPasskeyAsync(email, tenantId, loginResponse.AccessToken); + + // Perform one real passkey login so the DB sign count advances + _ = await webAuthn.LoginWithPasskeyAsync(new RegisteredPasskey("", email, "", tenantId)); + + // Now read the credential ID from the database + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); + await using var session = store.LightweightSession(tenantId); + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); + var credentialId = user!.Passkeys.First().CredentialId; + var currentCount = user.Passkeys.First().SignCount; + + // Artificially inflate the stored counter well above what the authenticator will produce next + // This simulates detecting a cloned authenticator: stored counter > assertion counter + await ManuallySetPasskeySignCountAsync(email, credentialId, currentCount + 100, tenantId); + + // Act: Attempt another passkey login — the virtual authenticator will produce a counter <= stored + _ = await Assert.That( + async () => await webAuthn.LoginWithPasskeyAsync(new RegisteredPasskey("", email, "", tenantId))) + .Throws(); + + // Assert: Account should now be locked + var isLocked = await WaitForAccountLockoutAsync(email, tenantId); + _ = await Assert.That(isLocked).IsTrue(); + } + + [Test] + public async Task LockoutDuration_EnforcesConfiguredTimeout() + { + // Arrange: Create user + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); + + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); + _ = await client.RegisterAsync(new RegisterRequest(email, password)); + + // Lock account with short duration (3 seconds) — generous enough for parallel test load + await ManuallyLockAccountAsync(email, DateTimeOffset.UtcNow.AddSeconds(3)); + + // Act 1: Verify account is locked + var exception = await Assert.That(async () => + await client.LoginAsync(new LoginRequest(email, password))) + .Throws(); + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + + // Wait for lockout to expire by polling until login succeeds + LoginResponse? loginResult = null; + await SseEventHelpers.WaitForConditionAsync( + async () => + { + try + { + loginResult = await client.LoginAsync(new LoginRequest(email, password)); + return true; + } + catch (ApiException) + { + return false; + } + }, + TimeSpan.FromSeconds(10), + "Account did not unlock within expected duration"); + + // Assert: Should succeed after lockout expires + _ = await Assert.That(loginResult).IsNotNull(); + _ = await Assert.That(loginResult.AccessToken).IsNotEmpty(); + } + + async Task ManuallyLockAccountAsync(string email, DateTimeOffset lockoutEnd, string? tenantId = null) + { + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); + var actualTenantId = tenantId ?? StorageConstants.DefaultTenantId; + await using var session = store.LightweightSession(actualTenantId); + + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); + _ = await Assert.That(user).IsNotNull(); + + if (user != null) + { + user.LockoutEnd = lockoutEnd; + session.Store(user); + await session.SaveChangesAsync(); + } + } + + async Task WaitForAccountLockoutAsync(string email, string? tenantId = null) + { + var actualTenantId = tenantId ?? StorageConstants.DefaultTenantId; + var isLocked = false; + + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); + await SseEventHelpers.WaitForConditionAsync( + async () => + { + await using var session = store.LightweightSession(actualTenantId); + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); + isLocked = user?.LockoutEnd != null && user.LockoutEnd > DateTimeOffset.UtcNow; + return isLocked; + }, + TimeSpan.FromSeconds(5), + $"Account was not locked for {email}"); + + return isLocked; + } + + async Task ManuallySetPasskeySignCountAsync(string email, byte[] credentialId, uint signCount, string? tenantId = null) + { + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); + var actualTenantId = tenantId ?? StorageConstants.DefaultTenantId; + await using var session = store.LightweightSession(actualTenantId); + + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); + _ = await Assert.That(user).IsNotNull(); + + if (user != null) + { + var passkey = user.Passkeys.FirstOrDefault(p => p.CredentialId.SequenceEqual(credentialId)); + if (passkey != null) + { + passkey.SignCount = signCount; + session.Store(user); + await session.SaveChangesAsync(); + } + } + } +} diff --git a/tests/BookStore.AppHost.Tests/AdminTenantTests.cs b/tests/BookStore.AppHost.Tests/AdminTenantTests.cs index 2941ae7b..e47aeb7b 100644 --- a/tests/BookStore.AppHost.Tests/AdminTenantTests.cs +++ b/tests/BookStore.AppHost.Tests/AdminTenantTests.cs @@ -3,10 +3,8 @@ using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; -using JasperFx.Events; using Marten; using Refit; -using Weasel.Core; namespace BookStore.AppHost.Tests; @@ -84,7 +82,7 @@ public async Task CreateTenant_WithValidRequest_ReturnsCreated() RestService.For(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!)); // Arrange - var tenantId = $"valid-tenant-{Guid.NewGuid():N}"; + var tenantId = $"valid-tenant-{Guid.CreateVersion7():N}"; var command = new CreateTenantCommand( Id: tenantId, Name: "Valid Tenant", @@ -111,7 +109,7 @@ public async Task CreateTenant_WithEmailVerification_CreatesUnconfirmedUser() RestService.For(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken!)); // Arrange - var tenantId = $"verify-tenant-{Guid.NewGuid():N}"; + var tenantId = $"verify-tenant-{Guid.CreateVersion7():N}"; var adminEmail = FakeDataGenerators.GenerateFakeEmail(); var command = new CreateTenantCommand( Id: tenantId, @@ -133,24 +131,9 @@ public async Task CreateTenant_WithEmailVerification_CreatesUnconfirmedUser() _ = await Assert.That(received).IsTrue(); - // Verify side effect: User in tenant DB has EmailConfirmed = false - // (Assuming the test environment has Email:DeliveryMethod != "None") - // In our tests, it usually is "Logging" which means verification is REQUIRED. - - var connectionString = await GlobalHooks.App.GetConnectionStringAsync("bookstore"); - using var store = DocumentStore.For(opts => - { - opts.Connection(connectionString!); - opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase); - _ = opts.Policies.AllDocumentsAreMultiTenanted(); - - opts.Events.AppendMode = EventAppendMode.Quick; - opts.Events.UseArchivedStreamPartitioning = true; - opts.Events.EnableEventSkippingInProjectionsOrSubscriptions = true; - - opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; - }); - + // Verify side effect: The admin user in the new tenant is created with EmailConfirmed = true + // because Email:DeliveryMethod=None is configured in GlobalSetup (no email verification required). + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(tenantId); var user = await session.Query() @@ -161,4 +144,21 @@ public async Task CreateTenant_WithEmailVerification_CreatesUnconfirmedUser() _ = await Assert.That(user!.EmailConfirmed).IsTrue(); _ = await Assert.That(user.Roles).Contains("Admin"); } + + [Test] + public async Task Admin_CanListAllTenants() + { + if (GlobalHooks.App == null || GlobalHooks.AdminAccessToken == null) + { + throw new InvalidOperationException("App or AdminAccessToken is not initialized"); + } + + var client = + RestService.For(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken)); + + var result = await client.GetAllTenantsAdminAsync(); + + _ = await Assert.That(result).IsNotNull(); + _ = await Assert.That(result).IsNotEmpty(); + } } diff --git a/tests/BookStore.AppHost.Tests/AdminUserTests.cs b/tests/BookStore.AppHost.Tests/AdminUserTests.cs index adb67042..40f89fee 100644 --- a/tests/BookStore.AppHost.Tests/AdminUserTests.cs +++ b/tests/BookStore.AppHost.Tests/AdminUserTests.cs @@ -1,6 +1,7 @@ using System.Net; using BookStore.AppHost.Tests.Helpers; using BookStore.Client; +using BookStore.Shared; using BookStore.Shared.Models; using JasperFx; using Refit; @@ -33,7 +34,7 @@ public async Task GetUsers_ReturnsListOfUsers() _ = await Assert.That(users).IsNotNull(); _ = await Assert.That(users).IsNotEmpty(); - var admin = users.First(u => u.Email == "admin@bookstore.com"); + var admin = users.First(u => u.Email == $"admin@{MultiTenancyConstants.DefaultTenantAlias}.com"); _ = await Assert.That(admin).IsNotNull(); _ = await Assert.That(admin.HasPassword).IsTrue(); _ = await Assert.That(admin.HasPasskey).IsFalse(); @@ -75,7 +76,7 @@ public async Task PromoteSelf_ReturnsBadRequest() var result = await client.GetUsersAsync(); var users = result.Items; - var self = users.First(u => u.Email == "admin@bookstore.com"); + var self = users.First(u => u.Email == $"admin@{MultiTenancyConstants.DefaultTenantAlias}.com"); // Act & Assert var exception = await Assert.That(async () => await client.PromoteToAdminAsync(self.Id)).Throws(); @@ -94,7 +95,7 @@ public async Task DemoteSelf_ReturnsBadRequest() var result = await client.GetUsersAsync(); var users = result.Items; - var self = users.First(u => u.Email == "admin@bookstore.com"); + var self = users.First(u => u.Email == $"admin@{MultiTenancyConstants.DefaultTenantAlias}.com"); // Act & Assert var exception = await Assert.That(async () => await client.DemoteFromAdminAsync(self.Id)) @@ -223,7 +224,7 @@ public async Task PromoteUser_LowercaseAdmin_IsNormalizedToPascalCase() var updatedUsers = updatedResult.Items; var promotedUser = updatedUsers.First(u => u.Id == user.Id); _ = await Assert.That(promotedUser.Roles).Contains("Admin"); - // We can't strictly assert DoesNotContain("admin") unless we trust the backend normalization, + // We can't strictly assert DoesNotContain("admin") unless we trust the backend normalization, // but checking Contains("Admin") is the key requirement. } diff --git a/tests/BookStore.AppHost.Tests/AuthTests.cs b/tests/BookStore.AppHost.Tests/AuthTests.cs index 373f4d81..5e8b7412 100644 --- a/tests/BookStore.AppHost.Tests/AuthTests.cs +++ b/tests/BookStore.AppHost.Tests/AuthTests.cs @@ -1,22 +1,20 @@ using System.Net; -using Bogus; using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; using Refit; +using TUnit; namespace BookStore.AppHost.Tests; public class AuthTests { readonly IIdentityClient _client; - readonly Faker _faker; public AuthTests() { var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); _client = RestService.For(httpClient); - _faker = new Faker(); } [Test] @@ -40,7 +38,7 @@ public async Task Register_WithValidData_ShouldReturnOk() public async Task Register_WithExistingUser_ShouldReturnOk() { // Arrange - var email = _faker.Internet.Email(); + var email = FakeDataGenerators.GenerateFakeEmail(); var password = FakeDataGenerators.GenerateFakePassword(); var request = new RegisterRequest(email, password); @@ -52,13 +50,18 @@ public async Task Register_WithExistingUser_ShouldReturnOk() // Assert - Should return OK to prevent enumeration (mapped to successful response in Refit) _ = await Assert.That(response).IsNotNull(); + + // Assert - The original user's credentials still work (re-registration did not overwrite the account) + var loginResult = await _client.LoginAsync(new LoginRequest(email, password)); + _ = await Assert.That(loginResult).IsNotNull(); + _ = await Assert.That(loginResult.AccessToken).IsNotEmpty(); } [Test] public async Task Login_WithValidCredentials_ShouldReturnToken() { // Arrange - var email = _faker.Internet.Email(); + var email = FakeDataGenerators.GenerateFakeEmail(); var password = FakeDataGenerators.GenerateFakePassword(); // Register first @@ -111,8 +114,9 @@ public async Task RawLoginError_ShouldContainStandardizedCode() [Test] public async Task Login_AsTenantAdmin_ShouldSucceed() { - // Arrange - var tenantId = "contoso"; + // Arrange: create a fresh tenant so the admin user is guaranteed to exist + var tenantId = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantId); var email = $"admin@{tenantId}.com"; var password = "Admin123!"; diff --git a/tests/BookStore.AppHost.Tests/AuthorCrudTests.cs b/tests/BookStore.AppHost.Tests/AuthorCrudTests.cs index 988dd76f..685dd151 100644 --- a/tests/BookStore.AppHost.Tests/AuthorCrudTests.cs +++ b/tests/BookStore.AppHost.Tests/AuthorCrudTests.cs @@ -26,10 +26,6 @@ public async Task CreateAuthor_EndToEndFlow_ShouldReturnOk() ["AuthorCreated", "AuthorUpdated"], async () => await client.CreateAuthorAsync(createAuthorRequest), TestConstants.DefaultEventTimeout); - // The original assertion for 'author' cannot be directly translated as ExecuteAndWaitForEventAsync doesn't return the author. - // If the intent was to verify the author was created, a subsequent GetAuthor call would be needed. - // For now, removing the assertion that relies on the 'author' variable. - // _ = await Assert.That(author!.Id).IsNotEqualTo(Guid.Empty); } [Test] @@ -95,7 +91,7 @@ public async Task DeleteAuthor_ShouldReturnNoContent() try { _ = await client.GetAuthorAsync(author!.Id); - // Admin API GetAuthor might still return it? Or return 404? + // Admin API GetAuthor might still return it? Or return 404? // If SoftDelete, it typically returns 404 for regular Get unless included. // Assuming failure or handled exception. // If it returns, we might check IsDeleted if available. diff --git a/tests/BookStore.AppHost.Tests/BookCrudTests.cs b/tests/BookStore.AppHost.Tests/BookCrudTests.cs index f385747e..c77aa312 100644 --- a/tests/BookStore.AppHost.Tests/BookCrudTests.cs +++ b/tests/BookStore.AppHost.Tests/BookCrudTests.cs @@ -26,41 +26,29 @@ public async Task UploadBookImage_ShouldReturnOk() _ = await Assert.That(etag).IsNotNull(); // Create dummy image content - var fileContent = new ByteArrayContent([0xFF, 0xD8, 0xFF, 0xE0]); // JPEG header mostly - fileContent.Headers.ContentType = new MediaTypeHeaderValue("image/jpeg"); - - // Act - // Refit StreamPart uses stream. using var stream = new MemoryStream([0xFF, 0xD8, 0xFF, 0xE0]); var streamPart = new StreamPart(stream, "cover.jpg", "image/jpeg"); + // Act await client.UploadBookCoverAsync(createdBook.Id, streamPart, etag); - - // Assert - // Refit throws if not success, so if we reached here it's OK. - // But we can assert on verified response if we wanted, or catching assertions. - // Verify via Get? The test checked StatusCode OK/NoContent. Refit void task implies success. - // We can double check strict status if we change return type to Task, but Task is fine for "ShouldReturnOk". } - // I will modify ONLY the parts that DON'T need ETag for now? - // No, most tests use ETag. - // I absolutely need to solve the ETag retrieval with Refit. - // Standard Refit pattern: use ApiResponse. - - // Let's assume I CAN update IGetBookEndpoint. - [Test] public async Task CreateBook_EndToEndFlow_ShouldReturnOk() { // Arrange var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); + var createRequest = FakeDataGenerators.GenerateFakeBookRequest(); // Act - var createdBook = await BookHelpers.CreateBookAsync(client); + var createdBook = await BookHelpers.CreateBookAsync(client, createRequest); - // Assert + // Assert: verify the returned book reflects the creation request _ = await Assert.That(createdBook).IsNotNull(); + _ = await Assert.That(createdBook.Id).IsEqualTo(createRequest.Id); + _ = await Assert.That(createdBook.Title).IsEqualTo(createRequest.Title); + _ = await Assert.That(createdBook.Isbn).IsEqualTo(createRequest.Isbn); + _ = await Assert.That(createdBook.Language).IsEqualTo(createRequest.Language); } [Test] @@ -135,9 +123,15 @@ public async Task AddToFavorites_ShouldReturnNoContent() // Act await BookHelpers.AddToFavoritesAsync(client, createdBook.Id); - // Assert - var getResponse = await client.GetBookAsync(createdBook.Id); - _ = await Assert.That(getResponse!.IsFavorite).IsTrue(); + // Assert - Wait for projection to complete + await SseEventHelpers.WaitForConditionAsync( + async () => + { + var book = await client.GetBookAsync(createdBook.Id); + return book?.IsFavorite == true; + }, + TestConstants.DefaultTimeout, + "Timed out waiting for book to be marked as favorite after projection update"); } [Test] @@ -150,16 +144,28 @@ public async Task RemoveFromFavorites_ShouldReturnNoContent() // Add to favorites first await BookHelpers.AddToFavoritesAsync(client, createdBook.Id); - // Verify it IS favorite initially - var initialGet = await client.GetBookAsync(createdBook.Id); - _ = await Assert.That(initialGet!.IsFavorite).IsTrue(); + // Verify it IS favorite initially - Wait for projection to complete + await SseEventHelpers.WaitForConditionAsync( + async () => + { + var book = await client.GetBookAsync(createdBook.Id); + return book?.IsFavorite == true; + }, + TestConstants.DefaultTimeout, + "Timed out waiting for book to be marked as favorite after projection update"); // Act await BookHelpers.RemoveFromFavoritesAsync(client, createdBook.Id); - // Assert - var getResponse = await client.GetBookAsync(createdBook.Id); - _ = await Assert.That(getResponse!.IsFavorite).IsFalse(); + // Assert - Wait for projection to complete + await SseEventHelpers.WaitForConditionAsync( + async () => + { + var book = await client.GetBookAsync(createdBook.Id); + return book?.IsFavorite == false; + }, + TestConstants.DefaultTimeout, + "Timed out waiting for book to be unmarked as favorite after projection update"); } [Test] diff --git a/tests/BookStore.AppHost.Tests/BookFilterRegressionTests.cs b/tests/BookStore.AppHost.Tests/BookFilterRegressionTests.cs index 7059798b..22f790b8 100644 --- a/tests/BookStore.AppHost.Tests/BookFilterRegressionTests.cs +++ b/tests/BookStore.AppHost.Tests/BookFilterRegressionTests.cs @@ -2,9 +2,7 @@ using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; -using Marten; using Refit; -using Weasel.Core; using CreateAuthorRequest = BookStore.Client.CreateAuthorRequest; using CreateBookRequest = BookStore.Client.CreateBookRequest; @@ -16,20 +14,10 @@ public class BookFilterRegressionTests public async Task SearchBooks_InNonDefaultTenant_ShouldRespectAuthorFilter() { // Debugging Multi-Tenant Author Filter - var tenantId = $"book-filter-test-{Guid.NewGuid():N}"; - - // Seed Tenant - var connectionString = await GlobalHooks.App!.GetConnectionStringAsync("bookstore"); - using (var store = DocumentStore.For(opts => - { - opts.Connection(connectionString!); - _ = opts.Policies.AllDocumentsAreMultiTenanted(); - opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; - opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase); - })) - { - await DatabaseHelpers.SeedTenantAsync(store, tenantId); - } + var tenantId = $"book-filter-test-{Guid.CreateVersion7():N}"; + + // Seed Tenant via API + await DatabaseHelpers.CreateTenantViaApiAsync(tenantId); // Authenticate as Admin in the new tenant var loginRes = await AuthenticationHelpers.LoginAsAdminAsync(tenantId); @@ -81,7 +69,7 @@ public async Task SearchBooks_WithMultiCurrencyPrices_ShouldRespectCurrencyFilte var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); - var uniqueTitle = $"MultiCurrency-{Guid.NewGuid()}"; + var uniqueTitle = $"MultiCurrency-{Guid.CreateVersion7()}"; // Create book with: USD=10, EUR=50 var createRequest = new CreateBookRequest { @@ -131,7 +119,7 @@ public async Task SearchBooks_WithActiveSale_ShouldFilterByDiscountedPrice() var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); - var uniqueTitle = $"SaleBook-{Guid.NewGuid()}"; + var uniqueTitle = $"SaleBook-{Guid.CreateVersion7()}"; // Create book with Price=50 USD var createRequest = new CreateBookRequest { diff --git a/tests/BookStore.AppHost.Tests/BookStore.AppHost.Tests.csproj b/tests/BookStore.AppHost.Tests/BookStore.AppHost.Tests.csproj index c66801c4..75195b22 100644 --- a/tests/BookStore.AppHost.Tests/BookStore.AppHost.Tests.csproj +++ b/tests/BookStore.AppHost.Tests/BookStore.AppHost.Tests.csproj @@ -11,6 +11,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/BookStore.AppHost.Tests/CategoryCrudTests.cs b/tests/BookStore.AppHost.Tests/CategoryCrudTests.cs index f667b3e5..f1a63ec5 100644 --- a/tests/BookStore.AppHost.Tests/CategoryCrudTests.cs +++ b/tests/BookStore.AppHost.Tests/CategoryCrudTests.cs @@ -64,7 +64,6 @@ public async Task DeleteCategory_ShouldReturnNoContent() // Act createdCategory = await CategoryHelpers.DeleteCategoryAsync(client, createdCategory!); - // Verify it's gone from public API // Verify it's gone from public API var publicClient = RestService.For( diff --git a/tests/BookStore.AppHost.Tests/CategoryOrderingTests.cs b/tests/BookStore.AppHost.Tests/CategoryOrderingTests.cs index b6aa6b51..ed189d67 100644 --- a/tests/BookStore.AppHost.Tests/CategoryOrderingTests.cs +++ b/tests/BookStore.AppHost.Tests/CategoryOrderingTests.cs @@ -13,7 +13,7 @@ public class CategoryOrderingTests string _prefix = ""; [Before(Test)] - public async Task Before() => _prefix = Guid.NewGuid().ToString("N")[..8]; + public async Task Before() => _prefix = Guid.CreateVersion7().ToString("N")[^8..]; [Test] public async Task GetCategories_OrderedByName_ShouldReturnInCorrectOrder() diff --git a/tests/BookStore.AppHost.Tests/ConcurrencyTests.cs b/tests/BookStore.AppHost.Tests/ConcurrencyTests.cs index cee29d43..8b9f1127 100644 --- a/tests/BookStore.AppHost.Tests/ConcurrencyTests.cs +++ b/tests/BookStore.AppHost.Tests/ConcurrencyTests.cs @@ -93,11 +93,10 @@ public async Task UpdateAuthor_MissingETag_ShouldFail() var updateRequest = FakeDataGenerators.GenerateFakeUpdateAuthorRequest(); - // Act - Update without ETag should fail (once we make it mandatory) + // Act - Update without ETag should fail var updateResponse = await client.UpdateAuthorWithResponseAsync(author.Id, updateRequest, null); // Assert - // Currently it might succeed because it's optional. TDD: we want it to fail. _ = await Assert.That((int)updateResponse.StatusCode).IsEqualTo((int)HttpStatusCode.PreconditionRequired); } } diff --git a/tests/BookStore.AppHost.Tests/CorrelationTests.cs b/tests/BookStore.AppHost.Tests/CorrelationTests.cs index d2912d28..58f36dd6 100644 --- a/tests/BookStore.AppHost.Tests/CorrelationTests.cs +++ b/tests/BookStore.AppHost.Tests/CorrelationTests.cs @@ -20,9 +20,9 @@ public async Task ShouldPropagateCorrelationIdToMartenEvents() var httpClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); - var correlationId = Guid.NewGuid().ToString(); + var correlationId = Guid.CreateVersion7().ToString(); var fakeBookId = - Guid.NewGuid(); // Random ID, it will fail conceptually but event should be stored or rejected, + Guid.CreateVersion7(); // Random ID, it will fail conceptually but event should be stored or rejected, // actually better to use a real action that succeeds to guarantee persistence. // Let's use AddToCart which doesn't check for book existence in the aggregate *before* stream load? // Actually UserCommandHandler loads user profile. @@ -124,7 +124,7 @@ public async Task ShouldGenerateAndPropagateCorrelationIdWhenMissing() var httpClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); - var fakeBookId = Guid.NewGuid(); + var fakeBookId = Guid.CreateVersion7(); var request = new HttpRequestMessage(HttpMethod.Post, $"/api/books/{fakeBookId}/rating"); request.Content = JsonContent.Create(new { Rating = 4 }); diff --git a/tests/BookStore.AppHost.Tests/CrossTenantAuthenticationTests.cs b/tests/BookStore.AppHost.Tests/CrossTenantAuthenticationTests.cs new file mode 100644 index 00000000..a33d2b53 --- /dev/null +++ b/tests/BookStore.AppHost.Tests/CrossTenantAuthenticationTests.cs @@ -0,0 +1,169 @@ +using System.Net; +using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; +using BookStore.Client; +using BookStore.Shared.Models; +using Refit; +using TUnit; + +namespace BookStore.AppHost.Tests; + +/// +/// Tests to verify that authentication is properly isolated between tenants. +/// CRITICAL: These tests validate that users cannot authenticate across tenant boundaries. +/// +public class CrossTenantAuthenticationTests +{ + [Before(Class)] + public static async Task ClassSetup() + { + if (GlobalHooks.App == null) + { + throw new InvalidOperationException("App is not initialized"); + } + + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-a"); + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-b"); + } + + [Test] + [Arguments("tenant-a", "tenant-b")] + [Arguments("tenant-a", "default")] + [Arguments("tenant-b", "default")] + public async Task User_RegisteredInSourceTenant_CannotLoginToTargetTenant(string sourceTenant, string targetTenant) + { + // Arrange: Create a unique user email for this test + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); + + var sourceClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(sourceTenant)); + var targetClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(targetTenant)); + + // Act 1: Register user in source tenant + _ = await sourceClient.RegisterAsync(new RegisterRequest(userEmail, password)); + + // Act 2: Attempt to login with the same credentials in target tenant + var loginTask = targetClient.LoginAsync(new LoginRequest(userEmail, password)); + + // Assert: Login should FAIL because user is registered in source tenant, not target + var exception = await Assert.That(async () => await loginTask).Throws(); + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + [Arguments("tenant-a")] + [Arguments("tenant-b")] + [Arguments("default")] + public async Task User_RegisteredInSourceTenant_CanLoginInSourceTenant(string sourceTenant) + { + // Arrange: Create a unique user email for this test + var userEmail = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); + + var sourceClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(sourceTenant)); + + // Act 1: Register user in source tenant + _ = await sourceClient.RegisterAsync(new RegisterRequest(userEmail, password)); + + // Act 2: Login with the same credentials in source tenant (correct tenant) + var loginResult = await sourceClient.LoginAsync(new LoginRequest(userEmail, password)); + + // Assert: Login should succeed in the correct tenant + _ = await Assert.That(loginResult).IsNotNull(); + _ = await Assert.That(loginResult.AccessToken).IsNotNull().And.IsNotEmpty(); + _ = await Assert.That(loginResult.RefreshToken).IsNotNull().And.IsNotEmpty(); + } + + [Test] + [Arguments("tenant-a", "tenant-b")] + [Arguments("tenant-b", "tenant-a")] + public async Task Passkey_ListWithCrossTenantJWT_IsRejected(string sourceTenant, string targetTenant) + { + // Arrange: Create a user with a passkey in the source tenant. + // This test verifies that a JWT issued for the source tenant is REJECTED when used + // to list passkeys on the target tenant — it does NOT test passkey-based login. + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(sourceTenant); + var credentialId = Guid.CreateVersion7().ToByteArray(); + await PasskeyTestHelpers.AddPasskeyToUserAsync(sourceTenant, email, "Test Passkey", credentialId); + + // Get fresh token after adding passkey (security stamp changed) + var identityClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(sourceTenant)); + var refreshedResponse = await identityClient.LoginAsync(new LoginRequest(email, password)); + + // Verify passkey exists in source tenant + var sourcePasskeyClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(refreshedResponse.AccessToken, sourceTenant)); + var sourcePasskeys = await sourcePasskeyClient.ListPasskeysAsync(); + _ = await Assert.That(sourcePasskeys.Any(p => p.Name == "Test Passkey")).IsTrue(); + + // Act: Send a source-tenant JWT to a target-tenant passkey endpoint. + var targetPasskeyClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(refreshedResponse.AccessToken, targetTenant)); + + var exception = await Assert.That(async () => await targetPasskeyClient.ListPasskeysAsync()).Throws(); + + // Assert: Cross-tenant JWT must be rejected (403 Forbidden or 401 Unauthorized). + var isRejected = exception!.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized; + _ = await Assert.That(isRejected).IsTrue(); + } + + [Test] + public async Task RefreshToken_FromSourceTenant_FailsInTargetTenant() + { + // Arrange: Create user and get tokens in tenant-a + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync("tenant-a"); + var refreshToken = loginResponse.RefreshToken; + var accessToken = loginResponse.AccessToken; + + // Act: Try to use refresh token in tenant-b (cross-tenant attack scenario) + var targetClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(accessToken, "tenant-b")); + + var exception = await Assert.That(async () => + await targetClient.RefreshTokenAsync(new RefreshRequest(refreshToken))) + .Throws(); + + // Assert: Should fail with Forbidden (tenant mismatch detected) + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); + } + + [Test] + [Arguments("tenant-a", "tenant-b")] + [Arguments("tenant-b", "default")] + public async Task SameEmailDifferentTenants_AreIsolated(string tenant1, string tenant2) + { + // Arrange: Use the same email address for both tenants + var sharedEmail = FakeDataGenerators.GenerateFakeEmail(); + var password1 = FakeDataGenerators.GenerateFakePassword(); + var password2 = FakeDataGenerators.GenerateFakePassword(); // Different password for tenant2 + + var client1 = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenant1)); + var client2 = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenant2)); + + // Act: Register the same email in both tenants with different passwords + _ = await client1.RegisterAsync(new RegisterRequest(sharedEmail, password1)); + _ = await client2.RegisterAsync(new RegisterRequest(sharedEmail, password2)); + + // Assert: Login with tenant1 credentials should work in tenant1 + var login1 = await client1.LoginAsync(new LoginRequest(sharedEmail, password1)); + _ = await Assert.That(login1).IsNotNull(); + _ = await Assert.That(login1.AccessToken).IsNotEmpty(); + + // Assert: Login with tenant2 credentials should work in tenant2 + var login2 = await client2.LoginAsync(new LoginRequest(sharedEmail, password2)); + _ = await Assert.That(login2).IsNotNull(); + _ = await Assert.That(login2.AccessToken).IsNotEmpty(); + + // Assert: Tenant1 password should NOT work in tenant2 + var wrongPasswordException = await Assert.That(async () => + await client2.LoginAsync(new LoginRequest(sharedEmail, password1))) + .Throws(); + _ = await Assert.That(wrongPasswordException!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + + // Assert: Tenant2 password should NOT work in tenant1 + var wrongPasswordException2 = await Assert.That(async () => + await client1.LoginAsync(new LoginRequest(sharedEmail, password2))) + .Throws(); + _ = await Assert.That(wrongPasswordException2!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } +} diff --git a/tests/BookStore.AppHost.Tests/EmailVerificationTests.cs b/tests/BookStore.AppHost.Tests/EmailVerificationTests.cs index b3c37c9e..518706ba 100644 --- a/tests/BookStore.AppHost.Tests/EmailVerificationTests.cs +++ b/tests/BookStore.AppHost.Tests/EmailVerificationTests.cs @@ -7,6 +7,7 @@ using JasperFx; using Marten; using Refit; +using TUnit; using Weasel.Core; namespace BookStore.AppHost.Tests; @@ -16,6 +17,18 @@ public class EmailVerificationTests readonly IIdentityClient _client; readonly Faker _faker; + [Before(Class)] + public static async Task ClassSetup() + { + if (GlobalHooks.App == null) + { + throw new InvalidOperationException("App is not initialized"); + } + + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-a"); + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-b"); + } + public EmailVerificationTests() { var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); @@ -27,8 +40,8 @@ public EmailVerificationTests() public async Task EmailVerification_FullFlow_ShouldSucceed() { // 1. Register a new user - var email = _faker.Internet.Email(); - var password = _faker.Internet.Password(8, false, "\\w", "Aa1!"); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); var registerRequest = new RegisterRequest(email, password); _ = await _client.RegisterAsync(registerRequest); @@ -69,8 +82,8 @@ public async Task ResendVerification_ForNonExistentUser_ShouldReturnGenericSucce public async Task ResendVerification_ForAlreadyConfirmedUser_ShouldReturnGenericSuccess() { // Arrange - var email = _faker.Internet.Email(); - var password = _faker.Internet.Password(8, false, "\\w", "Aa1!"); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); // Register _ = await _client.RegisterAsync(new RegisterRequest(email, password)); @@ -86,8 +99,8 @@ public async Task ResendVerification_ForAlreadyConfirmedUser_ShouldReturnGeneric public async Task ResendVerification_ShouldEnforceCooldown() { // Arrange - var email = _faker.Internet.Email(); - var password = _faker.Internet.Password(8, false, "\\w", "Aa1!"); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); // Register _ = await _client.RegisterAsync(new RegisterRequest(email, password)); @@ -110,6 +123,89 @@ public async Task ResendVerification_ShouldEnforceCooldown() _ = await Assert.That(userAfterSecond?.LastVerificationEmailSent).IsEqualTo(timestamp1); } + [Test] + [Arguments("default")] + [Arguments("tenant-a")] + [Arguments("tenant-b")] + public async Task LoginAttempt_WithUnconfirmedEmail_ReturnsEmailUnconfirmedError(string tenantId) + { + // Arrange: Register user in specific tenant + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); + + var tenantClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantId)); + _ = await tenantClient.RegisterAsync(new RegisterRequest(email, password)); + + // Manually set email to unconfirmed + await ManuallySetEmailConfirmedAsync(email, false, tenantId); + + // Act: Attempt login with unconfirmed email + var exception = await Assert.That(async () => + await tenantClient.LoginAsync(new LoginRequest(email, password))) + .Throws(); + + // Assert: Should return ERR_AUTH_EMAIL_UNCONFIRMED + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + var problem = await exception.GetContentAsAsync(); + _ = await Assert.That(problem?.Error).IsEqualTo(ErrorCodes.Auth.EmailUnconfirmed); + } + + [Test] + public async Task EmailConfirmation_WithInvalidToken_ReturnsError() + { + // Arrange: Register a user + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); + _ = await _client.RegisterAsync(new RegisterRequest(email, password)); + await ManuallySetEmailConfirmedAsync(email, false); + + // Act: Try to confirm with completely invalid token + var invalidToken = "invalid-token-12345"; + + // Note: ConfirmEmailAsync takes userId and code as query parameters, but we don't have userId + // So this test verifies the endpoint returns error for invalid input + var exception = await Assert.That(async () => + await _client.ConfirmEmailAsync(Guid.Empty.ToString(), invalidToken)) + .Throws(); + + // Assert: Should return bad request or unauthorized + var isExpectedError = exception!.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Unauthorized; + _ = await Assert.That(isExpectedError).IsTrue(); + } + + [Test] + [Arguments("default")] + [Arguments("tenant-a")] + public async Task ResendVerification_RespectsCooldownBoundary(string tenantId) + { + // Arrange: Register and set up for verification in specific tenant + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); + + var tenantClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantId)); + _ = await tenantClient.RegisterAsync(new RegisterRequest(email, password)); + await ManuallySetEmailConfirmedAsync(email, false, tenantId); + + // Act 1: First resend + await tenantClient.ResendVerificationAsync(new ResendVerificationRequest(email)); + var userAfterFirst = await GetUserAsync(email, tenantId); + var timestamp1 = userAfterFirst!.LastVerificationEmailSent; + _ = await Assert.That(timestamp1).IsNotNull(); + + // Act 2: Immediate second resend (within cooldown) + await tenantClient.ResendVerificationAsync(new ResendVerificationRequest(email)); + var userAfterSecond = await GetUserAsync(email, tenantId); + _ = await Assert.That(userAfterSecond!.LastVerificationEmailSent).IsEqualTo(timestamp1); + + // Act 3: Wait past cooldown (60 seconds + buffer) and resend again + await ManuallySetVerificationTimestampAsync(email, tenantId, DateTimeOffset.UtcNow.AddSeconds(-61)); + await tenantClient.ResendVerificationAsync(new ResendVerificationRequest(email)); + var userAfterCooldown = await GetUserAsync(email, tenantId); + + // Assert: Timestamp should be updated after cooldown period + _ = await Assert.That(userAfterCooldown!.LastVerificationEmailSent!.Value).IsGreaterThan(timestamp1!.Value); + } + async Task GetStoreAsync() { var connectionString = await GlobalHooks.App!.GetConnectionStringAsync("bookstore"); @@ -122,20 +218,22 @@ async Task GetStoreAsync() }); } - async Task GetUserAsync(string email) + async Task GetUserAsync(string email, string? tenantId = null) { using var store = await GetStoreAsync(); - await using var session = store.LightweightSession(StorageConstants.DefaultTenantId); + var actualTenantId = tenantId ?? StorageConstants.DefaultTenantId; + await using var session = store.LightweightSession(actualTenantId); var normalizedEmail = email.ToUpperInvariant(); return await session.Query() .Where(u => u.NormalizedEmail == normalizedEmail) .FirstOrDefaultAsync(); } - async Task ManuallySetEmailConfirmedAsync(string email, bool confirmed) + async Task ManuallySetEmailConfirmedAsync(string email, bool confirmed, string? tenantId = null) { using var store = await GetStoreAsync(); - await using var session = store.LightweightSession(StorageConstants.DefaultTenantId); + var actualTenantId = tenantId ?? StorageConstants.DefaultTenantId; + await using var session = store.LightweightSession(actualTenantId); var normalizedEmail = email.ToUpperInvariant(); var user = await session.Query() @@ -151,4 +249,24 @@ async Task ManuallySetEmailConfirmedAsync(string email, bool confirmed) await session.SaveChangesAsync(); } } + + async Task ManuallySetVerificationTimestampAsync(string email, string tenantId, DateTimeOffset timestamp) + { + using var store = await GetStoreAsync(); + await using var session = store.LightweightSession(tenantId); + + var normalizedEmail = email.ToUpperInvariant(); + var user = await session.Query() + .Where(u => u.NormalizedEmail == normalizedEmail) + .FirstOrDefaultAsync(); + + _ = await Assert.That(user).IsNotNull(); + + if (user != null) + { + user.LastVerificationEmailSent = timestamp; + session.Store(user); + await session.SaveChangesAsync(); + } + } } diff --git a/tests/BookStore.AppHost.Tests/ErrorScenarioTests.cs b/tests/BookStore.AppHost.Tests/ErrorScenarioTests.cs index 612af4b7..0f230c04 100644 --- a/tests/BookStore.AppHost.Tests/ErrorScenarioTests.cs +++ b/tests/BookStore.AppHost.Tests/ErrorScenarioTests.cs @@ -52,7 +52,7 @@ public async Task GetBook_NotFound_ShouldReturn404() { // Arrange var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); - var nonExistentId = Guid.NewGuid(); + var nonExistentId = Guid.CreateVersion7(); // Act & Assert var exception = await Assert.That(async () => await client.GetBookAsync(nonExistentId)).Throws(); diff --git a/tests/BookStore.AppHost.Tests/FavoriteBooksTests.cs b/tests/BookStore.AppHost.Tests/FavoriteBooksTests.cs index 937de273..9abd6dfc 100644 --- a/tests/BookStore.AppHost.Tests/FavoriteBooksTests.cs +++ b/tests/BookStore.AppHost.Tests/FavoriteBooksTests.cs @@ -112,11 +112,11 @@ public async Task GetFavoriteBooks_WithSorting_ShouldApplySort() // Create books with specific titles for sorting var requestA = FakeDataGenerators.GenerateFakeBookRequest(); - requestA.Title = $"AAA Book {Guid.NewGuid()}"; + requestA.Title = $"AAA Book {Guid.CreateVersion7()}"; var bookA = await BookHelpers.CreateBookAsync(adminClient, requestA); var requestZ = FakeDataGenerators.GenerateFakeBookRequest(); - requestZ.Title = $"ZZZ Book {Guid.NewGuid()}"; + requestZ.Title = $"ZZZ Book {Guid.CreateVersion7()}"; var bookZ = await BookHelpers.CreateBookAsync(adminClient, requestZ); // Add to favorites diff --git a/tests/BookStore.AppHost.Tests/GlobalSetup.cs b/tests/BookStore.AppHost.Tests/GlobalSetup.cs index 11cabd07..6944f717 100644 --- a/tests/BookStore.AppHost.Tests/GlobalSetup.cs +++ b/tests/BookStore.AppHost.Tests/GlobalSetup.cs @@ -4,6 +4,7 @@ using BookStore.ApiService.Aggregates; using BookStore.ApiService.Events; using BookStore.AppHost.Tests.Helpers; +using BookStore.Shared; using BookStore.Shared.Models; using JasperFx; using JasperFx.Core; @@ -32,9 +33,8 @@ public static async Task SetUp() try { var builder = await DistributedApplicationTestingBuilder.CreateAsync([ + "--RateLimit:Disabled=true", "--Seeding:Enabled=false", - "--RateLimit:AuthPermitLimit=2000", - "--RateLimit:PermitLimit=2000", "--Email:DeliveryMethod=None", "--Jwt:ExpirationMinutes=240" ]); @@ -110,32 +110,23 @@ static async Task AuthenticateAdminAsync() { // Tenant documents use Marten's native default tenant bucket await using var tenantSession = store.LightweightSession(); - var tenants = new[] { StorageConstants.DefaultTenantId, "acme", "contoso" }; - foreach (var tenantId in tenants) - { - var existingTenant = await tenantSession.LoadAsync(tenantId); - var tenantName = tenantId switch - { - "acme" => "Acme Corp", - "contoso" => "Contoso Ltd", - _ => "BookStore" - }; + var defaultTenantId = StorageConstants.DefaultTenantId; - if (existingTenant == null) - { - tenantSession.Store(new BookStore.ApiService.Models.Tenant - { - Id = tenantId, - Name = tenantName, - IsEnabled = true, - CreatedAt = DateTimeOffset.UtcNow - }); - } - else + var existingDefault = await tenantSession.LoadAsync(defaultTenantId); + if (existingDefault == null) + { + tenantSession.Store(new BookStore.ApiService.Models.Tenant { - existingTenant.Name = tenantName; - tenantSession.Update(existingTenant); - } + Id = defaultTenantId, + Name = "BookStore", + IsEnabled = true, + CreatedAt = DateTimeOffset.UtcNow + }); + } + else + { + existingDefault.Name = "BookStore"; + tenantSession.Update(existingDefault); } await tenantSession.SaveChangesAsync(); @@ -143,11 +134,9 @@ static async Task AuthenticateAdminAsync() // Seed minimal books for testing (default tenant) await SeedBooksAsync(store, StorageConstants.DefaultTenantId); - // We reuse the logic from DatabaseSeeder (or duplicate it for isolation) - // Here we duplicate the critical part to avoid dependency on internal implementation details of DatabaseSeeder + // Only seed the default tenant admin directly. + // Other tenant admins are created via the API as part of tenant creation (full tenant isolation). await SeedTenantAdminAsync(store, StorageConstants.DefaultTenantId); - await SeedTenantAdminAsync(store, "acme"); - await SeedTenantAdminAsync(store, "contoso"); } // Retry login mechanism (less aggressive now that we control seeding) @@ -157,7 +146,7 @@ await SseEventHelpers.WaitForConditionAsync(async () => try { loginResponse = await httpClient.PostAsJsonAsync("/account/login", - new { Email = "admin@bookstore.com", Password = "Admin123!" }); + new { Email = $"admin@{MultiTenancyConstants.DefaultTenantAlias}.com", Password = "Admin123!" }); return loginResponse.IsSuccessStatusCode; } catch @@ -183,9 +172,10 @@ await SseEventHelpers.WaitForConditionAsync(async () => static async Task SeedTenantAdminAsync(IDocumentStore store, string tenantId) { await using var session = store.LightweightSession(tenantId); - var adminEmail = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) - ? "admin@bookstore.com" - : $"admin@{tenantId}.com"; + var tenantAlias = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) + ? MultiTenancyConstants.DefaultTenantAlias + : tenantId; + var adminEmail = $"admin@{tenantAlias}.com"; var existingAdmin = await session.Query() .Where(u => u.Email == adminEmail) @@ -224,9 +214,9 @@ static async Task SeedBooksAsync(IDocumentStore store, string tenantId) return; } - var publisherId = Guid.NewGuid(); - var authorId = Guid.NewGuid(); - var categoryId = Guid.NewGuid(); + var publisherId = Guid.CreateVersion7(); + var authorId = Guid.CreateVersion7(); + var categoryId = Guid.CreateVersion7(); // Seed Publisher var publisherEvent = new PublisherAdded(publisherId, "Test Publisher", DateTimeOffset.UtcNow); @@ -245,7 +235,7 @@ static async Task SeedBooksAsync(IDocumentStore store, string tenantId) // Seed Books for (var i = 1; i <= 5; i++) { - var bookId = Guid.NewGuid(); + var bookId = Guid.CreateVersion7(); var bookEvent = new BookAdded( bookId, $"Test Book {i}", diff --git a/tests/BookStore.AppHost.Tests/Helpers/AuthenticationHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/AuthenticationHelpers.cs index 632c7caf..ff3b3104 100644 --- a/tests/BookStore.AppHost.Tests/Helpers/AuthenticationHelpers.cs +++ b/tests/BookStore.AppHost.Tests/Helpers/AuthenticationHelpers.cs @@ -3,6 +3,7 @@ using System.Text.Json.Serialization; using Aspire.Hosting; using BookStore.ApiService.Infrastructure.Tenant; +using BookStore.Shared; using JasperFx; using Refit; @@ -19,9 +20,10 @@ public static class AuthenticationHelpers public static async Task LoginAsAdminAsync(HttpClient client, string tenantId) { - var email = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) - ? "admin@bookstore.com" - : $"admin@{tenantId}.com"; + var tenantAlias = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) + ? MultiTenancyConstants.DefaultTenantAlias + : tenantId; + var email = $"admin@{tenantAlias}.com"; var credentials = new { email, password = "Admin123!" }; @@ -59,35 +61,24 @@ public static async Task CreateUserAndGetClientAsync(string? tenantI var actualTenantId = tenantId ?? StorageConstants.DefaultTenantId; publicClient.DefaultRequestHeaders.Add("X-Tenant-ID", actualTenantId); - var email = $"user_{Guid.NewGuid()}@example.com"; - var password = "Password123!"; + var email = $"user_{Guid.CreateVersion7()}@example.com"; + var password = FakeDataGenerators.GenerateFakePassword(); // Register var registerRequest = new { email, password }; var registerResponse = await publicClient.PostAsJsonAsync("/account/register", registerRequest); - if (!registerResponse.IsSuccessStatusCode) - { - } - _ = registerResponse.EnsureSuccessStatusCode(); // Login var loginRequest = new { email, password }; var loginResponse = await publicClient.PostAsJsonAsync("/account/login", loginRequest); - if (!loginResponse.IsSuccessStatusCode) - { - } - _ = loginResponse.EnsureSuccessStatusCode(); var tokenResponse = await loginResponse.Content.ReadFromJsonAsync(); - // Decode JWT to verify claims + // Decode JWT to extract the user ID var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); - _ = handler.ReadJwtToken(tokenResponse!.AccessToken); - - var userId = Guid.Parse(handler.ReadJwtToken(tokenResponse!.AccessToken).Claims.First(c => c.Type == "sub") - .Value); + var userId = Guid.Parse(handler.ReadJwtToken(tokenResponse!.AccessToken).Claims.First(c => c.Type == "sub").Value); // Create authenticated client var authenticatedClient = app.CreateHttpClient("apiservice"); @@ -107,10 +98,10 @@ public static async Task CreateUserAndGetClientAsync(string? tenantId = nu } public static async Task<(string email, string password, LoginResponse loginResponse, string tenantId)> - RegisterAndLoginUserAsync(string? tenantId = null) + RegisterAndLoginUserAsync(string? tenantId = null, string? email = null) { tenantId ??= StorageConstants.DefaultTenantId; - var email = FakeDataGenerators.GenerateFakeEmail(); + email ??= FakeDataGenerators.GenerateFakeEmail(); var password = FakeDataGenerators.GenerateFakePassword(); var client = HttpClientHelpers.GetUnauthenticatedClient(tenantId); @@ -129,7 +120,12 @@ public static async Task CreateUserAndGetClientAsync(string? tenantId = nu return (email, password, tokenResponse, tenantId); } - public record LoginResponse(string AccessToken, string RefreshToken); + public record LoginResponse( + string TokenType, + string AccessToken, + int ExpiresIn, + string RefreshToken, + Guid? UserId = null); public record ErrorResponse( [property: JsonPropertyName("error")] diff --git a/tests/BookStore.AppHost.Tests/Helpers/AuthorHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/AuthorHelpers.cs index ea26784e..0a944413 100644 --- a/tests/BookStore.AppHost.Tests/Helpers/AuthorHelpers.cs +++ b/tests/BookStore.AppHost.Tests/Helpers/AuthorHelpers.cs @@ -71,23 +71,23 @@ public static async Task UpdateAuthorAsync(IAuthorsClient client public static async Task DeleteAuthorAsync(IAuthorsClient client, AuthorDto author) { - var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + var etag = author.ETag; + if (string.IsNullOrEmpty(etag)) + { + var latestAuthor = await client.GetAuthorAdminAsync(author.Id); + etag = latestAuthor?.ETag; + } + + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( author.Id, "AuthorDeleted", - async () => - { - var etag = author.ETag; - if (string.IsNullOrEmpty(etag)) - { - var latestAuthor = await client.GetAuthorAdminAsync(author.Id); - etag = latestAuthor?.ETag; - } - - await client.SoftDeleteAuthorAsync(author.Id, etag); - }, - TestConstants.DefaultEventTimeout); + async () => await client.SoftDeleteAuthorAsync(author.Id, etag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); - if (!received) + if (!received.Success) { throw new Exception("Failed to receive AuthorDeleted event after DeleteAuthor."); } @@ -97,23 +97,23 @@ public static async Task DeleteAuthorAsync(IAuthorsClient client, Aut public static async Task DeleteAuthorAsync(IAuthorsClient client, AdminAuthorDto author) { - var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + var etag = author.ETag; + if (string.IsNullOrEmpty(etag)) + { + var latestAuthor = await client.GetAuthorAdminAsync(author.Id); + etag = latestAuthor?.ETag; + } + + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( author.Id, "AuthorDeleted", - async () => - { - var etag = author.ETag; - if (string.IsNullOrEmpty(etag)) - { - var latestAuthor = await client.GetAuthorAdminAsync(author.Id); - etag = latestAuthor?.ETag; - } - - await client.SoftDeleteAuthorAsync(author.Id, etag); - }, - TestConstants.DefaultEventTimeout); + async () => await client.SoftDeleteAuthorAsync(author.Id, etag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); - if (!received) + if (!received.Success) { throw new Exception("Failed to receive AuthorDeleted event after DeleteAuthor."); } @@ -123,19 +123,19 @@ public static async Task DeleteAuthorAsync(IAuthorsClient client public static async Task RestoreAuthorAsync(IAuthorsClient client, AuthorDto author) { - var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + var latestAuthor = await client.GetAuthorAdminAsync(author.Id); + var etag = latestAuthor?.ETag; + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; + + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( author.Id, "AuthorUpdated", - async () => - { - var latestAuthor = await client.GetAuthorAdminAsync(author.Id); - var etag = latestAuthor?.ETag; - - await client.RestoreAuthorAsync(author.Id, etag); - }, - TestConstants.DefaultEventTimeout); + async () => await client.RestoreAuthorAsync(author.Id, etag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); - if (!received) + if (!received.Success) { throw new Exception("Failed to receive AuthorUpdated event after RestoreAuthor."); } @@ -145,19 +145,19 @@ public static async Task RestoreAuthorAsync(IAuthorsClient client, Au public static async Task RestoreAuthorAsync(IAuthorsClient client, AdminAuthorDto author) { - var received = await SseEventHelpers.ExecuteAndWaitForEventAsync( + var latestAuthor = await client.GetAuthorAdminAsync(author.Id); + var etag = latestAuthor?.ETag; + var version = BookStore.ApiService.Infrastructure.ETagHelper.ParseETag(etag) ?? 0; + + var received = await SseEventHelpers.ExecuteAndWaitForEventWithVersionAsync( author.Id, "AuthorUpdated", - async () => - { - var latestAuthor = await client.GetAuthorAdminAsync(author.Id); - var etag = latestAuthor?.ETag; - - await client.RestoreAuthorAsync(author.Id, etag); - }, - TestConstants.DefaultEventTimeout); + async () => await client.RestoreAuthorAsync(author.Id, etag), + TestConstants.DefaultEventTimeout, + minVersion: version + 1, + minTimestamp: DateTimeOffset.UtcNow); - if (!received) + if (!received.Success) { throw new Exception("Failed to receive AuthorUpdated event after RestoreAuthor."); } diff --git a/tests/BookStore.AppHost.Tests/Helpers/BookHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/BookHelpers.cs index 5cc7b227..e92c5819 100644 --- a/tests/BookStore.AppHost.Tests/Helpers/BookHelpers.cs +++ b/tests/BookStore.AppHost.Tests/Helpers/BookHelpers.cs @@ -145,10 +145,6 @@ public static async Task UpdateBookAsync(HttpClient client, Guid bookId, object updateRequest.Headers.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(etag)); var updateResponse = await client.SendAsync(updateRequest); - if (!updateResponse.IsSuccessStatusCode) - { - } - _ = await Assert.That(updateResponse.IsSuccessStatusCode).IsTrue(); }, TestConstants.DefaultEventTimeout, @@ -222,10 +218,6 @@ public static async Task DeleteBookAsync(HttpClient client, Guid bookId, string deleteRequest.Headers.IfMatch.Add(new System.Net.Http.Headers.EntityTagHeaderValue(etag)); var deleteResponse = await client.SendAsync(deleteRequest); - if (!deleteResponse.IsSuccessStatusCode) - { - } - _ = await Assert.That(deleteResponse.IsSuccessStatusCode).IsTrue(); }, TestConstants.DefaultEventTimeout, @@ -278,10 +270,6 @@ public static async Task RestoreBookAsync(HttpClient client, Guid bookId, string async () => { var restoreResponse = await client.PostAsync($"/api/admin/books/{bookId}/restore", null); - if (!restoreResponse.IsSuccessStatusCode) - { - } - _ = await Assert.That(restoreResponse.StatusCode).IsEqualTo(HttpStatusCode.NoContent); }, TestConstants.DefaultEventTimeout, diff --git a/tests/BookStore.AppHost.Tests/Helpers/DatabaseHelpers.cs b/tests/BookStore.AppHost.Tests/Helpers/DatabaseHelpers.cs index 9fc1ca41..a1c81938 100644 --- a/tests/BookStore.AppHost.Tests/Helpers/DatabaseHelpers.cs +++ b/tests/BookStore.AppHost.Tests/Helpers/DatabaseHelpers.cs @@ -1,67 +1,63 @@ using Aspire.Hosting; using BookStore.ApiService.Infrastructure.Tenant; +using BookStore.Client; +using BookStore.Shared.Models; using JasperFx; using Marten; +using Refit; using Weasel.Core; namespace BookStore.AppHost.Tests.Helpers; public static class DatabaseHelpers { - public static async Task SeedTenantAsync(Marten.IDocumentStore store, string tenantId) + /// + /// Creates a tenant and its admin user via the API (enforcing full tenant isolation). + /// Uses the default tenant admin token to call POST /api/admin/tenants. + /// Idempotent: if the tenant already exists the 409 conflict is silently ignored. + /// + public static async Task SeedTenantAsync(Marten.IDocumentStore _, string tenantId) { - // 1. Ensure Tenant document exists in Marten's native default bucket (for validation) - await using (var tenantSession = store.LightweightSession()) + // Default tenant is bootstrapped directly in GlobalSetup — skip here. + if (StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase)) { - var existingTenant = await tenantSession.LoadAsync(tenantId); - if (existingTenant == null) - { - tenantSession.Store(new BookStore.ApiService.Models.Tenant - { - Id = tenantId, - Name = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) - ? "BookStore" - : (char.ToUpper(tenantId[0]) + tenantId[1..] + " Corp"), - IsEnabled = true, - CreatedAt = DateTimeOffset.UtcNow - }); - await tenantSession.SaveChangesAsync(); - } + return; } - // 2. Seed Admin User in the tenant's own bucket - await using var session = store.LightweightSession(tenantId); - - var adminEmail = StorageConstants.DefaultTenantId.Equals(tenantId, StringComparison.OrdinalIgnoreCase) - ? "admin@bookstore.com" - : $"admin@{tenantId}.com"; - - // We still use manual store here as TestHelpers might be used in light setup contexts - // but we fix the normalization mismatch - var existingUser = await session.Query() - .Where(u => u.Email == adminEmail) - .FirstOrDefaultAsync(); + await CreateTenantViaApiAsync(tenantId); + } - if (existingUser == null) + /// + /// Creates a tenant via the admin API, which also seeds the tenant admin user. + /// The admin email follows the convention admin@{tenantId}.com with password Admin123!. + /// Idempotent: duplicate-tenant responses (400/409) are silently ignored. + /// + public static async Task CreateTenantViaApiAsync(string tenantId) + { + if (GlobalHooks.AdminAccessToken == null) { - var adminUser = new BookStore.ApiService.Models.ApplicationUser - { - UserName = adminEmail, - NormalizedUserName = adminEmail.ToUpperInvariant(), - Email = adminEmail, - NormalizedEmail = adminEmail.ToUpperInvariant(), - EmailConfirmed = true, - Roles = ["Admin"], - SecurityStamp = Guid.CreateVersion7().ToString("D"), - ConcurrencyStamp = Guid.CreateVersion7().ToString("D") - }; + throw new InvalidOperationException("AdminAccessToken is not set. Ensure GlobalSetup has completed."); + } + + var tenantName = char.ToUpper(tenantId[0]) + tenantId[1..]; - var hasher = - new Microsoft.AspNetCore.Identity.PasswordHasher(); - adminUser.PasswordHash = hasher.HashPassword(adminUser, "Admin123!"); + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken)); - session.Store(adminUser); - await session.SaveChangesAsync(); + try + { + await client.CreateTenantAsync(new CreateTenantCommand( + Id: tenantId, + Name: tenantName, + Tagline: null, + ThemePrimaryColor: null, + IsEnabled: true, + AdminEmail: $"admin@{tenantId}.com", + AdminPassword: "Admin123!")); + } + catch (Refit.ApiException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Conflict + or System.Net.HttpStatusCode.BadRequest) + { + // Tenant already exists — idempotent, ignore. } } @@ -69,13 +65,34 @@ public static async Task SeedTenantAsync(Marten.IDocumentStore store, string ten /// Gets a configured IDocumentStore instance for direct database access in tests. /// /// A configured IDocumentStore with multi-tenancy support. + /// + /// IMPORTANT: Callers MUST dispose the returned IDocumentStore to prevent connection leaks. + /// Use the 'await using' pattern: + /// + /// await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); + /// await using var session = store.LightweightSession(tenantId); + /// // ... use session ... + /// + /// Each DocumentStore maintains its own connection pool. Failing to dispose will exhaust + /// PostgreSQL's connection limit during parallel test execution. + /// public static async Task GetDocumentStoreAsync() { - var connectionString = await GlobalHooks.App!.GetConnectionStringAsync("bookstore"); + var baseConnectionString = await GlobalHooks.App!.GetConnectionStringAsync("bookstore"); + + // Configure connection pooling to limit connections per DocumentStore + // This prevents parallel tests from exhausting PostgreSQL's connection limit + var builder = new Npgsql.NpgsqlConnectionStringBuilder(baseConnectionString) + { + MaxPoolSize = 10, // Reduced from default 100 - each store gets max 10 connections + MinPoolSize = 0, // Don't pre-allocate connections + ConnectionLifetime = 300 // 5 minutes - recycle connections periodically + }; + return DocumentStore.For(opts => { opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase); - opts.Connection(connectionString!); + opts.Connection(builder.ConnectionString); _ = opts.Policies.AllDocumentsAreMultiTenanted(); opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; }); diff --git a/tests/BookStore.AppHost.Tests/Helpers/FakeDataGenerators.cs b/tests/BookStore.AppHost.Tests/Helpers/FakeDataGenerators.cs index 1f593962..58b6cf60 100644 --- a/tests/BookStore.AppHost.Tests/Helpers/FakeDataGenerators.cs +++ b/tests/BookStore.AppHost.Tests/Helpers/FakeDataGenerators.cs @@ -12,7 +12,7 @@ public static class FakeDataGenerators /// Generates a random password that meets common password requirements. /// /// A password with at least 12 characters including uppercase, lowercase, numbers, and special characters. - public static string GenerateFakePassword() => _faker.Internet.Password(12, false, "", "Aa1!"); + public static string GenerateFakePassword() => _faker.Internet.Password(12, false, "\\w", "Aa1!"); /// /// Generates a random email address for testing. @@ -20,6 +20,13 @@ public static class FakeDataGenerators /// A valid email address. public static string GenerateFakeEmail() => _faker.Internet.Email(); + /// + /// Generates a random tenant ID that satisfies the TenantIdValidator rules: + /// lowercase letters, digits, and hyphens only; minimum 3 characters. + /// + /// A URL-friendly lowercase tenant ID. + public static string GenerateFakeTenantId() => _faker.Internet.DomainWord(); + /// /// Generates a fake book creation request with random data using Bogus. /// diff --git a/tests/BookStore.AppHost.Tests/Helpers/WebAuthnTestHelper.cs b/tests/BookStore.AppHost.Tests/Helpers/WebAuthnTestHelper.cs new file mode 100644 index 00000000..5afcf2ca --- /dev/null +++ b/tests/BookStore.AppHost.Tests/Helpers/WebAuthnTestHelper.cs @@ -0,0 +1,383 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Playwright; + +namespace BookStore.AppHost.Tests.Helpers; + +/// +/// Provides end-to-end WebAuthn (passkey) flows for integration tests using a +/// headless Chromium browser with a Playwright virtual authenticator. +/// +/// The virtual authenticator satisfies the ASP.NET Core Identity passkey +/// cryptographic verification (real CBOR / COSE key pair is generated), so +/// the test exercises the full attestation / assertion code paths in +/// PasskeyEndpoints.cs without requiring a physical security key. +/// +/// Usage: +/// await using var webAuthn = await WebAuthnTestHelper.CreateAsync(); +/// var credential = await webAuthn.RegisterPasskeyAsync(email, tenantId); +/// var loginResponse = await webAuthn.LoginWithPasskeyAsync(credential, tenantId); +/// +public sealed class WebAuthnTestHelper : IAsyncDisposable +{ + readonly IPlaywright _playwright; + readonly IBrowser _browser; + readonly IBrowserContext _context; + readonly IPage _page; + readonly string _apiBaseUrl; + + WebAuthnTestHelper( + IPlaywright playwright, + IBrowser browser, + IBrowserContext context, + IPage page, + string apiBaseUrl) + { + _playwright = playwright; + _browser = browser; + _context = context; + _page = page; + _apiBaseUrl = apiBaseUrl; + } + + /// + /// Creates a configured helper with a virtual authenticator attached to a + /// headless Chromium browser that is pointed at the API origin. + /// + public static async Task CreateAsync() + { + var app = GlobalHooks.App + ?? throw new InvalidOperationException("GlobalHooks.App must be initialized before using WebAuthnTestHelper."); + + // Resolve the base URL of the running API service + var httpClient = app.CreateHttpClient("apiservice"); + var baseUrl = httpClient.BaseAddress?.ToString().TrimEnd('/') ?? "http://localhost"; + + var playwright = await Playwright.CreateAsync(); + var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true }); + + var context = await browser.NewContextAsync(new BrowserNewContextOptions + { + // Must match the rpId ("localhost") configured in IdentityPasskeyOptions + BaseURL = baseUrl, + IgnoreHTTPSErrors = true, + }); + + // Enable WebAuthn virtual authenticator support via CDP + await context.AddCookiesAsync([]); + var page = await context.NewPageAsync(); + + // Navigate to the API origin so navigator.credentials API is available + // under the correct origin (http://localhost:). + // A 404 or JSON response is fine — we only need the origin to be set. + _ = await page.GotoAsync(baseUrl, new PageGotoOptions + { + WaitUntil = WaitUntilState.Commit, + Timeout = 10_000 + }); + + // Add a virtual authenticator via CDP: CTAP2, internal (platform), user-verifying. + // IVirtualAuthenticator does not exist in Playwright .NET — CDP is the correct approach. + var cdp = await context.NewCDPSessionAsync(page); + _ = await cdp.SendAsync("WebAuthn.enable", new Dictionary { ["enableUI"] = false }); + _ = await cdp.SendAsync("WebAuthn.addVirtualAuthenticator", new Dictionary + { + ["options"] = new Dictionary + { + ["protocol"] = "ctap2", + ["transport"] = "internal", + ["hasResidentKey"] = true, + ["hasUserVerification"] = true, + ["isUserVerified"] = true, + ["automaticPresenceSimulation"] = true, + } + }); + + return new WebAuthnTestHelper(playwright, browser, context, page, baseUrl); + } + + /// + /// Performs a full passkey registration flow via the API. + /// Returns the credential info needed for subsequent login calls. + /// + public async Task RegisterPasskeyAsync( + string email, + string tenantId, + string? accessToken = null) + { + var httpClient = BuildHttpClient(tenantId, accessToken); + + // Step 1 — get attestation options from the API + var optionsResponse = await httpClient.PostAsJsonAsync("/account/attestation/options", new { email }); + _ = optionsResponse.EnsureSuccessStatusCode(); + var optionsJson = await optionsResponse.Content.ReadAsStringAsync(); + + // optionsJson shape: { options: { ... }, userId: "..." } + var envelope = JsonDocument.Parse(optionsJson); + var optionsElement = envelope.RootElement.GetProperty("options"); + var userId = envelope.RootElement.GetProperty("userId").GetString()!; + var challengeJson = optionsElement.GetRawText(); + + // Step 2 — call navigator.credentials.create() inside the browser + var credentialJson = await _page.EvaluateAsync( + @"async (challengeJson) => { + const options = JSON.parse(challengeJson); + + // Convert base64url fields to ArrayBuffers + function b64urlToBuffer(b64url) { + const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr.buffer; + } + function bufferToB64url(buf) { + const arr = new Uint8Array(buf); + let str = ''; + for (let i = 0; i < arr.length; i++) str += String.fromCharCode(arr[i]); + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + options.challenge = b64urlToBuffer(options.challenge); + options.user.id = b64urlToBuffer(options.user.id); + if (options.excludeCredentials) { + options.excludeCredentials = options.excludeCredentials.map(c => ({ + ...c, + id: b64urlToBuffer(c.id) + })); + } + + const cred = await navigator.credentials.create({ publicKey: options }); + + return JSON.stringify({ + id: cred.id, + rawId: bufferToB64url(cred.rawId), + type: cred.type, + response: { + clientDataJSON: bufferToB64url(cred.response.clientDataJSON), + attestationObject: bufferToB64url(cred.response.attestationObject), + }, + clientExtensionResults: cred.getClientExtensionResults(), + }); + }", + challengeJson); + + // Step 3 — submit attestation result to the API + var resultResponse = await httpClient.PostAsJsonAsync("/account/attestation/result", new + { + credentialJson, + email, + userId + }); + + if (!resultResponse.IsSuccessStatusCode) + { + var errorBody = await resultResponse.Content.ReadAsStringAsync(); + throw new HttpRequestException( + $"POST /account/attestation/result failed with {(int)resultResponse.StatusCode} {resultResponse.ReasonPhrase}. Body: {errorBody}", + null, + resultResponse.StatusCode); + } + + // Extract credential ID so we can use it in login flows + var credDoc = JsonDocument.Parse(credentialJson); + var rawId = credDoc.RootElement.GetProperty("rawId").GetString()!; + + return new RegisteredPasskey(rawId, email, userId, tenantId); + } + + /// + /// Creates a real WebAuthn attestation credential via the browser's virtual authenticator + /// but does NOT post the result to the API. + /// Use this to get a valid / + /// for concurrent-registration race-condition tests. + /// + /// + /// The returned carries the attestation-state cookie that must + /// be included in the follow-up POST to /account/attestation/result. + /// All concurrent requests should use this same client so they share the cookie. + /// + public async Task<(string CredentialJson, string Email, string UserId, HttpClient Client)> CreateAttestationCredentialAsync( + string email, + string tenantId) + { + var httpClient = BuildHttpClient(tenantId); + + var optionsResponse = await httpClient.PostAsJsonAsync("/account/attestation/options", new { email }); + _ = optionsResponse.EnsureSuccessStatusCode(); + var optionsJson = await optionsResponse.Content.ReadAsStringAsync(); + + var envelope = JsonDocument.Parse(optionsJson); + var optionsElement = envelope.RootElement.GetProperty("options"); + var userId = envelope.RootElement.GetProperty("userId").GetString()!; + var challengeJson = optionsElement.GetRawText(); + + var credentialJson = await _page.EvaluateAsync( + @"async (challengeJson) => { + const options = JSON.parse(challengeJson); + + function b64urlToBuffer(b64url) { + const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr.buffer; + } + function bufferToB64url(buf) { + const arr = new Uint8Array(buf); + let str = ''; + for (let i = 0; i < arr.length; i++) str += String.fromCharCode(arr[i]); + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + options.challenge = b64urlToBuffer(options.challenge); + options.user.id = b64urlToBuffer(options.user.id); + if (options.excludeCredentials) { + options.excludeCredentials = options.excludeCredentials.map(c => ({ + ...c, + id: b64urlToBuffer(c.id) + })); + } + + const cred = await navigator.credentials.create({ publicKey: options }); + + return JSON.stringify({ + id: cred.id, + rawId: bufferToB64url(cred.rawId), + type: cred.type, + response: { + clientDataJSON: bufferToB64url(cred.response.clientDataJSON), + attestationObject: bufferToB64url(cred.response.attestationObject), + }, + clientExtensionResults: cred.getClientExtensionResults(), + }); + }", + challengeJson); + + return (credentialJson, email, userId, httpClient); + } + + /// + /// Performs a full passkey login (assertion) flow via the API. + /// Returns the login response containing access and refresh tokens. + /// + public async Task LoginWithPasskeyAsync(RegisteredPasskey passkey) + { + var httpClient = BuildHttpClient(passkey.TenantId); + + // Step 1 — get assertion options + var optionsResponse = await httpClient.PostAsJsonAsync("/account/assertion/options", new { email = passkey.Email }); + _ = optionsResponse.EnsureSuccessStatusCode(); + var assertionOptionsJson = await optionsResponse.Content.ReadAsStringAsync(); + + // Step 2 — call navigator.credentials.get() in the browser + var credentialJson = await _page.EvaluateAsync( + @"async (assertionOptionsJson) => { + const options = JSON.parse(assertionOptionsJson); + + function b64urlToBuffer(b64url) { + const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); + const bin = atob(b64); + const arr = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); + return arr.buffer; + } + function bufferToB64url(buf) { + const arr = new Uint8Array(buf); + let str = ''; + for (let i = 0; i < arr.length; i++) str += String.fromCharCode(arr[i]); + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + options.challenge = b64urlToBuffer(options.challenge); + if (options.allowCredentials) { + options.allowCredentials = options.allowCredentials.map(c => ({ + ...c, + id: b64urlToBuffer(c.id) + })); + } + + const cred = await navigator.credentials.get({ publicKey: options }); + + return JSON.stringify({ + id: cred.id, + rawId: bufferToB64url(cred.rawId), + type: cred.type, + response: { + clientDataJSON: bufferToB64url(cred.response.clientDataJSON), + authenticatorData: bufferToB64url(cred.response.authenticatorData), + signature: bufferToB64url(cred.response.signature), + userHandle: cred.response.userHandle ? bufferToB64url(cred.response.userHandle) : null, + }, + clientExtensionResults: cred.getClientExtensionResults(), + }); + }", + assertionOptionsJson); + + // Step 3 — submit assertion result + var resultResponse = await httpClient.PostAsJsonAsync("/account/assertion/result", new { credentialJson }); + + if (!resultResponse.IsSuccessStatusCode) + { + var errorBody = await resultResponse.Content.ReadAsStringAsync(); + throw new HttpRequestException( + $"POST /account/assertion/result failed with {(int)resultResponse.StatusCode} {resultResponse.ReasonPhrase}. Body: {errorBody}", + null, + resultResponse.StatusCode); + } + + var result = await resultResponse.Content.ReadFromJsonAsync() + ?? throw new InvalidOperationException("Passkey assertion result was null."); + + return result; + } + + /// + /// Creates a fresh with its own isolated + /// pointed at the API service base URL. + /// + /// Using a dedicated (instead of one from ) + /// ensures that the passkey state cookie (.AspNetCore.Identity.TwoFactorUserId) set by + /// MakePasskeyCreationOptionsAsync / MakePasskeyRequestOptionsAsync cannot leak into + /// or be overwritten by cookies from other tests running in the same process. + /// + HttpClient BuildHttpClient(string tenantId, string? accessToken = null) + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = true, + CookieContainer = new System.Net.CookieContainer(), + }; + var client = new HttpClient(handler) + { + BaseAddress = new Uri(_apiBaseUrl.TrimEnd('/') + "/"), + }; + client.DefaultRequestHeaders.Add("X-Tenant-ID", tenantId); + if (!string.IsNullOrEmpty(accessToken)) + { + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", accessToken); + } + + return client; + } + + public async ValueTask DisposeAsync() + { + await _page.CloseAsync(); + await _context.DisposeAsync(); + await _browser.DisposeAsync(); + _playwright.Dispose(); + } +} + +/// Represents a passkey credential that has been successfully registered. +public record RegisteredPasskey(string RawId, string Email, string UserId, string TenantId); + +/// JWT token response from a successful assertion. +public record LoginResult( + string TokenType, + string AccessToken, + int ExpiresIn, + string RefreshToken); diff --git a/tests/BookStore.AppHost.Tests/ManagementIntegrationTests.cs b/tests/BookStore.AppHost.Tests/ManagementIntegrationTests.cs index b391996c..8d1b511d 100644 --- a/tests/BookStore.AppHost.Tests/ManagementIntegrationTests.cs +++ b/tests/BookStore.AppHost.Tests/ManagementIntegrationTests.cs @@ -18,7 +18,7 @@ public async Task GetAllData_AsAdmin_ShouldReturnAllEntities() var categoriesClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var publishersClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); - var suffix = Guid.NewGuid().ToString()[..8]; + var suffix = Guid.CreateVersion7().ToString()[..8]; var authorName = $"GetAll Auth {suffix}"; var catName = $"GetAll Cat {suffix}"; var pubName = $"GetAll Pub {suffix}"; @@ -93,7 +93,7 @@ public async Task Search_WithFilter_ShouldReturnMatchedItems() var categoriesClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var publishersClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); - var suffix = Guid.NewGuid().ToString()[..8]; + var suffix = Guid.CreateVersion7().ToString()[..8]; var authorName = $"SearchMatch Auth {suffix}"; var catName = $"SearchMatch Cat {suffix}"; var pubName = $"SearchMatch Pub {suffix}"; @@ -125,7 +125,7 @@ public async Task SoftDelete_ShouldHideItem_AndRestoreShouldShowIt() { // Arrange var authorsClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); - var suffix = Guid.NewGuid().ToString()[..8]; + var suffix = Guid.CreateVersion7().ToString()[..8]; var authorName = $"Delete Auth {suffix}"; var author = await AuthorHelpers.CreateAuthorAsync(authorsClient, diff --git a/tests/BookStore.AppHost.Tests/MultiLanguageTranslationTests.cs b/tests/BookStore.AppHost.Tests/MultiLanguageTranslationTests.cs index 0b2a64bf..919236b0 100644 --- a/tests/BookStore.AppHost.Tests/MultiLanguageTranslationTests.cs +++ b/tests/BookStore.AppHost.Tests/MultiLanguageTranslationTests.cs @@ -13,7 +13,7 @@ public async Task Author_Update_ShouldPreserveAllBiographies() { // Arrange var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); - var authorName = "Translation Author " + Guid.NewGuid().ToString()[..8]; + var authorName = "Translation Author " + Guid.CreateVersion7().ToString()[..8]; var createRequest = new CreateAuthorRequest { @@ -58,7 +58,7 @@ public async Task Category_Update_ShouldPreserveAllNames() { // Arrange var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); - var englishName = "English Cat " + Guid.NewGuid().ToString()[..8]; + var englishName = "English Cat " + Guid.CreateVersion7().ToString()[..8]; var createRequest = new CreateCategoryRequest { @@ -102,7 +102,7 @@ public async Task Book_Update_ShouldPreserveAllDescriptions() // 1. Create Book with Translations var client = await HttpClientHelpers.GetAuthenticatedClientAsync(); - var title = "TransBook " + Guid.NewGuid().ToString()[..8]; + var title = "TransBook " + Guid.CreateVersion7().ToString()[..8]; var createRequest = new CreateBookRequest { diff --git a/tests/BookStore.AppHost.Tests/MultiTenancyTests.cs b/tests/BookStore.AppHost.Tests/MultiTenancyTests.cs index 05ec06ec..a62cd0e3 100644 --- a/tests/BookStore.AppHost.Tests/MultiTenancyTests.cs +++ b/tests/BookStore.AppHost.Tests/MultiTenancyTests.cs @@ -2,13 +2,15 @@ using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; -using Marten; using Refit; namespace BookStore.AppHost.Tests; public class MultiTenancyTests { + static string _tenant1 = string.Empty; + static string _tenant2 = string.Empty; + [Before(Class)] public static async Task ClassSetup() { @@ -17,65 +19,49 @@ public static async Task ClassSetup() throw new InvalidOperationException("App is not initialized"); } - var connectionString = await GlobalHooks.App.GetConnectionStringAsync("bookstore"); - if (string.IsNullOrEmpty(connectionString)) - { - throw new InvalidOperationException("Could not retrieve connection string for 'bookstore' resource."); - } - - using var store = DocumentStore.For(opts => - { - opts.Connection(connectionString); - _ = opts.Policies.AllDocumentsAreMultiTenanted(); - opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; - }); - - await DatabaseHelpers.SeedTenantAsync(store, "acme"); - await DatabaseHelpers.SeedTenantAsync(store, "contoso"); + _tenant1 = FakeDataGenerators.GenerateFakeTenantId(); + _tenant2 = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(_tenant1); + await DatabaseHelpers.CreateTenantViaApiAsync(_tenant2); } [Test] public async Task EntitiesAreIsolatedByTenant() { // 1. Setup Clients - // Login as Acme Admin - var acmeLogin = await AuthenticationHelpers.LoginAsAdminAsync("acme"); - _ = await Assert.That(acmeLogin).IsNotNull(); - var acmeClient = - RestService.For(HttpClientHelpers.GetAuthenticatedClient(acmeLogin!.AccessToken, "acme")); + var tenant1Login = await AuthenticationHelpers.LoginAsAdminAsync(_tenant1); + _ = await Assert.That(tenant1Login).IsNotNull(); + var tenant1Client = + RestService.For(HttpClientHelpers.GetAuthenticatedClient(tenant1Login!.AccessToken, _tenant1)); - // Login as Contoso Admin - var contosoLogin = await AuthenticationHelpers.LoginAsAdminAsync("contoso"); - _ = await Assert.That(contosoLogin).IsNotNull(); - var contosoClient = - RestService.For(HttpClientHelpers.GetAuthenticatedClient(contosoLogin!.AccessToken, "contoso")); + var tenant2Login = await AuthenticationHelpers.LoginAsAdminAsync(_tenant2); + _ = await Assert.That(tenant2Login).IsNotNull(); + var tenant2Client = + RestService.For(HttpClientHelpers.GetAuthenticatedClient(tenant2Login!.AccessToken, _tenant2)); - // 2. Create Book in ACME + // 2. Create Book in tenant1 var createRequest = FakeDataGenerators.GenerateFakeBookRequest(); - // Use CreateBookAsync helper that handles dependencies and SSE waiting - // Wait, BookHelpers.CreateBookAsync takes IBooksClient and CreateBookRequest. - // It should handle it. - var createdBook = await BookHelpers.CreateBookAsync(acmeClient, createRequest); + var createdBook = await BookHelpers.CreateBookAsync(tenant1Client, createRequest); _ = await Assert.That(createdBook).IsNotNull(); var bookId = createdBook.Id; - // 3. Verify visible in ACME - var acmeBook = await acmeClient.GetBookAsync(bookId); - _ = await Assert.That(acmeBook).IsNotNull(); - _ = await Assert.That(acmeBook.Id).IsEqualTo(bookId); + // 3. Verify visible in tenant1 + var tenant1Book = await tenant1Client.GetBookAsync(bookId); + _ = await Assert.That(tenant1Book).IsNotNull(); + _ = await Assert.That(tenant1Book.Id).IsEqualTo(bookId); - // 4. Verify NOT visible in CONTOSO - var exception = await Assert.That(async () => await contosoClient.GetBookAsync(bookId)).Throws(); + // 4. Verify NOT visible in tenant2 + var exception = await Assert.That(async () => await tenant2Client.GetBookAsync(bookId)).Throws(); _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.NotFound); - // 5. Verify Search Isolation - var acmeSearch = await acmeClient.GetBooksAsync(new BookSearchRequest { Search = createdBook.Title }); - _ = await Assert.That(acmeSearch).IsNotNull(); - _ = await Assert.That(acmeSearch.Items.Any(b => b.Id == bookId)).IsTrue(); + // 5. Verify search isolation + var tenant1Search = await tenant1Client.GetBooksAsync(new BookSearchRequest { Search = createdBook.Title }); + _ = await Assert.That(tenant1Search).IsNotNull(); + _ = await Assert.That(tenant1Search.Items.Any(b => b.Id == bookId)).IsTrue(); - var contosoSearch = await contosoClient.GetBooksAsync(new BookSearchRequest { Search = createdBook.Title }); - _ = await Assert.That(contosoSearch).IsNotNull(); - _ = await Assert.That(contosoSearch.Items.Any(b => b.Id == bookId)).IsFalse(); + var tenant2Search = await tenant2Client.GetBooksAsync(new BookSearchRequest { Search = createdBook.Title }); + _ = await Assert.That(tenant2Search).IsNotNull(); + _ = await Assert.That(tenant2Search.Items.Any(b => b.Id == bookId)).IsFalse(); } [Test] diff --git a/tests/BookStore.AppHost.Tests/MultiTenantAuthenticationTests.cs b/tests/BookStore.AppHost.Tests/MultiTenantAuthenticationTests.cs index 73704ee2..814094d5 100644 --- a/tests/BookStore.AppHost.Tests/MultiTenantAuthenticationTests.cs +++ b/tests/BookStore.AppHost.Tests/MultiTenantAuthenticationTests.cs @@ -3,15 +3,17 @@ using System.Net.Http.Json; using BookStore.AppHost.Tests.Helpers; using BookStore.Client; +using BookStore.Shared; using BookStore.Shared.Models; -using JasperFx; -using Marten; using Refit; namespace BookStore.AppHost.Tests; -public class MultiTenantAuthenticationTests : IDisposable +public class MultiTenantAuthenticationTests { + static string _tenant1 = string.Empty; + static string _tenant2 = string.Empty; + HttpClient? _client; [Before(Class)] @@ -22,22 +24,10 @@ public static async Task ClassSetup() throw new InvalidOperationException("App is not initialized"); } - // Manually seed tenants and admin users once per test class - var connectionString = await GlobalHooks.App.GetConnectionStringAsync("bookstore"); - if (string.IsNullOrEmpty(connectionString)) - { - throw new InvalidOperationException("Could not retrieve connection string for 'bookstore' resource."); - } - - using var store = DocumentStore.For(opts => - { - opts.Connection(connectionString); - _ = opts.Policies.AllDocumentsAreMultiTenanted(); - opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; - }); - - await DatabaseHelpers.SeedTenantAsync(store, "acme"); - await DatabaseHelpers.SeedTenantAsync(store, "contoso"); + _tenant1 = FakeDataGenerators.GenerateFakeTenantId(); + _tenant2 = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(_tenant1); + await DatabaseHelpers.CreateTenantViaApiAsync(_tenant2); } [Before(Test)] @@ -56,59 +46,52 @@ public async Task Setup() [After(Test)] public void Cleanup() => _client?.Dispose(); - public void Dispose() - { - Cleanup(); - GC.SuppressFinalize(this); - } - /// /// Helper to login as admin for aspecific tenant /// // LoginAsAdminAsync moved to TestHelpers [Test] - public async Task SeedAsync_CreatesAdminForEachTenant() + public async Task TenantCreation_CreatesAdminForEachTenant() { - // Act: Try to login as each tenant's admin - var defaultLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, StorageConstants.DefaultTenantId); - var acmeLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "acme"); - var contosoLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "contoso"); + // Assert: All tenant admins (created via API during ClassSetup) can log in + var defaultLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, MultiTenancyConstants.DefaultTenantId); + var tenant1Login = await AuthenticationHelpers.LoginAsAdminAsync(_client!, _tenant1); + var tenant2Login = await AuthenticationHelpers.LoginAsAdminAsync(_client!, _tenant2); - // Assert: All admins should exist and be able to login _ = await Assert.That(defaultLogin).IsNotNull(); _ = await Assert.That(defaultLogin!.AccessToken).IsNotEmpty(); - _ = await Assert.That(acmeLogin).IsNotNull(); - _ = await Assert.That(acmeLogin!.AccessToken).IsNotEmpty(); + _ = await Assert.That(tenant1Login).IsNotNull(); + _ = await Assert.That(tenant1Login!.AccessToken).IsNotEmpty(); - _ = await Assert.That(contosoLogin).IsNotNull(); - _ = await Assert.That(contosoLogin!.AccessToken).IsNotEmpty(); + _ = await Assert.That(tenant2Login).IsNotNull(); + _ = await Assert.That(tenant2Login!.AccessToken).IsNotEmpty(); } [Test] - public async Task Login_AdminFromAcme_CannotLoginToContoso() + public async Task Login_AdminFromTenant1_CannotLoginToTenant2() { - // Arrange: Acme admin credentials - var credentials = new { email = "admin@acme.com", password = "Admin123!" }; + // Arrange: tenant1 admin credentials + var credentials = new { email = $"admin@{_tenant1}.com", password = "Admin123!" }; - // Act: Try to login with Acme credentials using Contoso tenant header + // Act: Try to login with tenant1 credentials using tenant2 header var request = new HttpRequestMessage(HttpMethod.Post, "/account/login") { Content = JsonContent.Create(credentials) }; - request.Headers.Add("X-Tenant-ID", "contoso"); + request.Headers.Add("X-Tenant-ID", _tenant2); var response = await _client!.SendAsync(request); - // Assert: Should fail because admin@acme.com doesn't exist in contoso tenant + // Assert: Should fail because admin@{_tenant1}.com doesn't exist in tenant2 _ = await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); } [Test] - public async Task Login_AdminFromAcme_SucceedsInAcmeTenant() + public async Task Login_AdminFromTenant1_SucceedsInTenant1() { - // Act: Login with Acme credentials using helper (which has retry logic) - var response = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "acme"); + // Act: Login with tenant1 credentials + var response = await AuthenticationHelpers.LoginAsAdminAsync(_client!, _tenant1); // Assert: Should succeed _ = await Assert.That(response).IsNotNull(); @@ -116,16 +99,16 @@ public async Task Login_AdminFromAcme_SucceedsInAcmeTenant() } [Test] - public async Task AdminToken_FromAcme_CanAccessAcmeBooks() + public async Task AdminToken_FromTenant1_CanAccessTenant1Books() { - // Arrange: Login as Acme admin and get token - var acmeLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "acme"); - _ = await Assert.That(acmeLogin).IsNotNull(); + // Arrange: Login as tenant1 admin and get token + var tenant1Login = await AuthenticationHelpers.LoginAsAdminAsync(_client!, _tenant1); + _ = await Assert.That(tenant1Login).IsNotNull(); - // Act: Try to access acme books with acme token and acme tenant header + // Act: Access books with matching token and tenant header var request = new HttpRequestMessage(HttpMethod.Get, "/api/books"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", acmeLogin!.AccessToken); - request.Headers.Add("X-Tenant-ID", "acme"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tenant1Login!.AccessToken); + request.Headers.Add("X-Tenant-ID", _tenant1); var response = await _client!.SendAsync(request); @@ -134,22 +117,20 @@ public async Task AdminToken_FromAcme_CanAccessAcmeBooks() } [Test] - public async Task AdminToken_FromAcme_WithContosoHeader_IsRejected() + public async Task AdminToken_FromTenant1_WithTenant2Header_IsRejected() { - // Arrange: Login as Acme admin - var acmeLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "acme"); - _ = await Assert.That(acmeLogin).IsNotNull(); + // Arrange: Login as tenant1 admin + var tenant1Login = await AuthenticationHelpers.LoginAsAdminAsync(_client!, _tenant1); + _ = await Assert.That(tenant1Login).IsNotNull(); - // Act: Try to access books with acme JWT but contoso tenant header + // Act: Use tenant1 JWT with tenant2 header (cross-tenant attack) var request = new HttpRequestMessage(HttpMethod.Get, "/api/cart"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", acmeLogin!.AccessToken); - request.Headers.Add("X-Tenant-ID", "contoso"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tenant1Login!.AccessToken); + request.Headers.Add("X-Tenant-ID", _tenant2); var response = await _client!.SendAsync(request); - // Assert: Should be rejected - // The middleware should detect tenant mismatch (JWT vs header) - // Expected: Either 400 Bad Request (invalid tenant) or 403 Forbidden (tenant mismatch) + // Assert: Middleware detects JWT/header tenant mismatch var isRejected = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized; @@ -160,67 +141,52 @@ HttpStatusCode.Forbidden or [Test] public async Task Admin_CanCreateBookInOwnTenant() { - // Arrange: Login as Acme admin - var acmeLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "acme"); - _ = await Assert.That(acmeLogin).IsNotNull(); - - // Create minimal book data - var bookData = new - { - title = "Test Book for Acme", - isbn = "978-0-00-000000-0", - originalLanguage = "en", - publisherId = Guid.Empty, // We'd need to seed a publisher, but for isolation test this is OK to fail - authorIds = Array.Empty(), - categoryIds = Array.Empty(), - prices = new { USD = 29.99m } - }; - - // Act: Try to create a book - var acmeClientHttpClient = GlobalHooks.App!.CreateHttpClient("apiservice"); - acmeClientHttpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", acmeLogin!.AccessToken); - acmeClientHttpClient.DefaultRequestHeaders.Add("X-Tenant-ID", "acme"); - var acmeBooksClient = RestService.For(acmeClientHttpClient); - - var request = new CreateBookRequest - { - Id = Guid.CreateVersion7(), - Title = bookData.title, - Isbn = bookData.isbn, - Language = bookData.originalLanguage, - PublisherId = bookData.publisherId, - AuthorIds = bookData.authorIds, - CategoryIds = bookData.categoryIds, - Prices = new Dictionary { ["USD"] = bookData.prices.USD }, - PublicationDate = new PartialDate(2024), - Translations = new Dictionary() - }; - - try - { - _ = await acmeBooksClient.CreateBookAsync(request); - return; - } - catch (ApiException ex) when (ex.StatusCode == HttpStatusCode.BadRequest) - { - // If Bad Request (validation failure), it means Authorization worked. - // (If it was Unauthorized/Forbidden, we would fail) - return; - } + // Arrange: Login as tenant1 admin + var tenant1Login = await AuthenticationHelpers.LoginAsAdminAsync(_client!, _tenant1); + _ = await Assert.That(tenant1Login).IsNotNull(); + + // Build tenant1-scoped HTTP client + var tenantHttpClient = GlobalHooks.App!.CreateHttpClient("apiservice"); + tenantHttpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", tenant1Login!.AccessToken); + tenantHttpClient.DefaultRequestHeaders.Add("X-Tenant-ID", _tenant1); + + // Create tenant1-scoped Refit clients + var publishersClient = RestService.For(tenantHttpClient); + var authorsClient = RestService.For(tenantHttpClient); + var categoriesClient = RestService.For(tenantHttpClient); + var booksClient = RestService.For(tenantHttpClient); + + // Create required dependencies within tenant1 + var publisher = await PublisherHelpers.CreatePublisherAsync( + publishersClient, FakeDataGenerators.GenerateFakePublisherRequest()); + var author = await AuthorHelpers.CreateAuthorAsync( + authorsClient, FakeDataGenerators.GenerateFakeAuthorRequest()); + var category = await CategoryHelpers.CreateCategoryAsync( + categoriesClient, FakeDataGenerators.GenerateFakeCategoryRequest()); + + var createBookRequest = FakeDataGenerators.GenerateFakeBookRequest( + publisher.Id, [author.Id], [category.Id]); + + // Act - Create a book inside tenant1 + var book = await BookHelpers.CreateBookAsync(booksClient, createBookRequest); + + // Assert - Book was created and belongs to tenant1 + _ = await Assert.That(book).IsNotNull(); + _ = await Assert.That(book.Id).IsNotEqualTo(Guid.Empty); } [Test] - public async Task Login_ContosoAdmin_CannotAccessAcmeData() + public async Task Login_Tenant2Admin_CannotAccessTenant1Data() { - // Arrange: Login as Contoso admin - var contosoLogin = await AuthenticationHelpers.LoginAsAdminAsync(_client!, "contoso"); - _ = await Assert.That(contosoLogin).IsNotNull(); + // Arrange: Login as tenant2 admin + var tenant2Login = await AuthenticationHelpers.LoginAsAdminAsync(_client!, _tenant2); + _ = await Assert.That(tenant2Login).IsNotNull(); - // Act: Try to access acme books with contoso credentials + // Act: Send tenant2 JWT with tenant1 header (cross-tenant attack) var request = new HttpRequestMessage(HttpMethod.Get, "/api/cart"); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", contosoLogin!.AccessToken); - request.Headers.Add("X-Tenant-ID", "acme"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tenant2Login!.AccessToken); + request.Headers.Add("X-Tenant-ID", _tenant1); var response = await _client!.SendAsync(request); diff --git a/tests/BookStore.AppHost.Tests/PasskeyDeletionTests.cs b/tests/BookStore.AppHost.Tests/PasskeyDeletionTests.cs index 38db07ea..962d26f1 100644 --- a/tests/BookStore.AppHost.Tests/PasskeyDeletionTests.cs +++ b/tests/BookStore.AppHost.Tests/PasskeyDeletionTests.cs @@ -30,8 +30,8 @@ public PasskeyDeletionTests() public async Task DeletePasskey_WithUrlUnsafeId_ShouldSucceed() { // Arrange - var email = _faker.Internet.Email(); - var password = "Password123!"; + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); // 1. Register and login to get token _ = await _client.RegisterAsync(new RegisterRequest(email, password)); @@ -50,6 +50,11 @@ await PasskeyTestHelpers.AddPasskeyToUserAsync( "Unsafe Passkey", unsafeCredentialId); + // Get fresh token after adding passkey (security stamp changed) + var refreshedLoginResult = await _client.LoginAsync(new LoginRequest(email, password)); + var refreshedAuthClient = HttpClientHelpers.GetAuthenticatedClient(refreshedLoginResult.AccessToken); + authenticatedPasskeyClient = RestService.For(refreshedAuthClient); + // 3. List passkeys to get the encoded ID var passkeys = await authenticatedPasskeyClient.ListPasskeysAsync(); @@ -68,7 +73,7 @@ await PasskeyTestHelpers.AddPasskeyToUserAsync( // 5. Assert // Verify it's gone from DB - var store = await DatabaseHelpers.GetDocumentStoreAsync(); + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(StorageConstants.DefaultTenantId); var user = await session.Query() .Where(u => u.NormalizedEmail == email.ToUpperInvariant()) diff --git a/tests/BookStore.AppHost.Tests/PasskeyRegistrationSecurityTests.cs b/tests/BookStore.AppHost.Tests/PasskeyRegistrationSecurityTests.cs index 2449f87a..3c9bca46 100644 --- a/tests/BookStore.AppHost.Tests/PasskeyRegistrationSecurityTests.cs +++ b/tests/BookStore.AppHost.Tests/PasskeyRegistrationSecurityTests.cs @@ -17,34 +17,28 @@ public class PasskeyRegistrationSecurityTests [Test] public async Task PasskeyRegistration_ConcurrentAttempts_OnlyOneSucceeds() { - // Arrange - Get registration options to obtain a user ID + // Arrange: Create a REAL credential JSON via the virtual WebAuthn authenticator. + // The browser generates a proper attestation object for one email/challenge pair. var email = FakeDataGenerators.GenerateFakeEmail(); var tenantId = MultiTenancyConstants.DefaultTenantId; - var client = HttpClientHelpers.GetUnauthenticatedClient(tenantId); - - // Get creation options first - var optionsResponse = await client.PostAsJsonAsync("/account/attestation/options", new - { - email - }); - - _ = await Assert.That(optionsResponse.IsSuccessStatusCode).IsTrue(); - var options = await optionsResponse.Content.ReadFromJsonAsync(); - _ = await Assert.That(options).IsNotNull(); - _ = await Assert.That(options!.UserId).IsNotEmpty(); - var userId = options.UserId; + await using var webAuthn = await WebAuthnTestHelper.CreateAsync(); + // CreateAttestationCredentialAsync returns the client that holds the + // attestation-state cookie from the /attestation/options call. + var (credentialJson, _, userId, client) = + await webAuthn.CreateAttestationCredentialAsync(email, tenantId); - // Act - Simulate concurrent registration attempts with the SAME user ID - // This simulates a race condition where two clients try to register at the same time - var registrationResults = new List<(HttpStatusCode StatusCode, string Content)>(); + // Act: Fire 5 concurrent POST /account/attestation/result with the SAME credential JSON. + // All requests use the same client (sharing the attestation-state cookie) so they all + // reach the attestation validation step. The first one wins and creates the user; + // the rest are rejected by the database unique-email constraint. var registrationTasks = Enumerable.Range(0, 5).Select(async _ => { try { var response = await client.PostAsJsonAsync("/account/attestation/result", new { - credentialJson = "{\"mock\":\"credential\"}", + credentialJson, email, userId }); @@ -59,19 +53,23 @@ public async Task PasskeyRegistration_ConcurrentAttempts_OnlyOneSucceeds() var results = await Task.WhenAll(registrationTasks); - // Assert - Only ONE registration should succeed - var successfulRequests = results.Count(r => r.Item1 is HttpStatusCode.OK or HttpStatusCode.Created); - var failedRequests = results.Count(r => r.Item1 is HttpStatusCode.BadRequest or HttpStatusCode.Conflict); - - // Due to race condition fix (conflict check before attestation), we expect: - // - Only the first request to reach the conflict check succeeds - // - All others fail with validation error (not 500 Internal Server Error) - _ = await Assert.That(successfulRequests).IsLessThanOrEqualTo(1); - _ = await Assert.That(failedRequests).IsGreaterThanOrEqualTo(4); - - // Verify no Internal Server Errors occurred (no unhandled exceptions) + // Assert: No internal server errors — data integrity must be maintained under load. var serverErrors = results.Count(r => r.Item1 == HttpStatusCode.InternalServerError); _ = await Assert.That(serverErrors).IsEqualTo(0); + + // Assert: Exactly ONE HTTP request succeeded. The test name claims "OnlyOneSucceeds" + // so we must verify that at the HTTP level only one 2xx response was produced. + var successCount = results.Count(r => + r.Item1 is HttpStatusCode.OK or HttpStatusCode.Created or HttpStatusCode.NoContent); + _ = await Assert.That(successCount).IsEqualTo(1); + + // Assert: Exactly ONE user was created in the database for this email. + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); + await using var dbSession = store.LightweightSession(tenantId); + var user = await DatabaseHelpers.GetUserByEmailAsync(dbSession, email); + + _ = await Assert.That(user).IsNotNull(); + _ = await Assert.That(user!.Email).IsEqualTo(email); } [Test] @@ -81,7 +79,7 @@ public async Task PasskeyRegistration_WithExistingUserId_ReturnsGenericError() var (email, _, _, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); // Get the existing user's ID - var store = await DatabaseHelpers.GetDocumentStoreAsync(); + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(tenantId); var existingUser = await DatabaseHelpers.GetUserByEmailAsync(session, email); _ = await Assert.That(existingUser).IsNotNull(); diff --git a/tests/BookStore.AppHost.Tests/PasskeySecurityTests.cs b/tests/BookStore.AppHost.Tests/PasskeySecurityTests.cs index 61299a32..1dab2c56 100644 --- a/tests/BookStore.AppHost.Tests/PasskeySecurityTests.cs +++ b/tests/BookStore.AppHost.Tests/PasskeySecurityTests.cs @@ -9,6 +9,7 @@ using Marten; using Microsoft.AspNetCore.Identity; using Refit; +using TUnit; using Weasel.Core; namespace BookStore.AppHost.Tests; @@ -19,36 +20,64 @@ namespace BookStore.AppHost.Tests; /// - Security stamp validation (token revocation) /// - Cross-tenant token theft detection /// - Refresh token cleanup on passkey login +/// - Multi-tenant passkey isolation /// public class PasskeySecurityTests { + [Before(Class)] + public static async Task ClassSetup() + { + if (GlobalHooks.App == null) + { + throw new InvalidOperationException("App is not initialized"); + } + + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-a"); + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-b"); + } + [Test] public async Task PasskeyLogin_WithClonedAuthenticator_LocksAccount() { - // Arrange - Create user with a passkey that has a sign count of 5 - var (email, password, _, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); - - var credentialId = Guid.CreateVersion7().ToByteArray(); - const uint initialSignCount = 5; - await PasskeyTestHelpers.AddPasskeyToUserAsync(tenantId, email, "Test Passkey", credentialId, initialSignCount); + // Arrange: Register user and attach a real passkey via the virtual authenticator + var (email, _, loginResponse, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); - // Simulate a cloned authenticator by setting the sign count LOWER than stored value - // This would happen if an attacker cloned the hardware key - await PasskeyTestHelpers.UpdatePasskeySignCountAsync(tenantId, email, credentialId, signCount: 3); + await using var webAuthn = await WebAuthnTestHelper.CreateAsync(); + var passkey = await webAuthn.RegisterPasskeyAsync(email, tenantId, loginResponse.AccessToken); - // Act - Try to use a passkey with a DECREASING counter (cloned authenticator) - var client = HttpClientHelpers.GetUnauthenticatedClient(tenantId); - var response = await client.PostAsJsonAsync("/account/assertion/result", new + // Read the credential ID from the DB so we can manipulate its sign counter + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); + byte[] credentialId; + await using (var readSession = store.LightweightSession(tenantId)) { - credentialJson = "{\"mock\":\"data\"}", // Would normally be WebAuthn credential - }); + var u = await DatabaseHelpers.GetUserByEmailAsync(readSession, email); + _ = await Assert.That(u).IsNotNull(); + credentialId = u!.Passkeys.First().CredentialId; + } - // Assert - The endpoint should return error, and account should be locked - var store = await DatabaseHelpers.GetDocumentStoreAsync(); - await using var session = store.LightweightSession(tenantId); - var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); - _ = await Assert.That(user).IsNotNull(); - // The actual lockout would be tested via a full WebAuthn flow which requires browser automation + // Inflate the stored counter far above what the virtual authenticator will produce next + // (virtual authenticator counter ~= 1 on first assertion, 100 >> 1 → mismatch → lockout) + await PasskeyTestHelpers.UpdatePasskeySignCountAsync(tenantId, email, credentialId, signCount: 100); + + // Act: Attempt passkey login — assertion counter (~1) ≤ stored (100) → should trigger lockout + _ = await Assert.That( + async () => await webAuthn.LoginWithPasskeyAsync(passkey)) + .Throws(); + + // Assert: Account should now be locked in the database + var isLocked = false; + await SseEventHelpers.WaitForConditionAsync( + async () => + { + await using var lockSession = store.LightweightSession(tenantId); + var lockedUser = await DatabaseHelpers.GetUserByEmailAsync(lockSession, email); + isLocked = lockedUser?.LockoutEnd != null && lockedUser.LockoutEnd.Value > DateTimeOffset.UtcNow; + return isLocked; + }, + TimeSpan.FromSeconds(5), + "Account was not locked after counter-mismatch passkey assertion"); + + _ = await Assert.That(isLocked).IsTrue(); } [Test] @@ -89,19 +118,9 @@ public async Task Token_AfterAddingPasskey_BecomesInvalid() var initialStatus = await authClient.GetPasswordStatusAsync(); _ = await Assert.That(initialStatus).IsNotNull(); - // Act - Add a passkey (this updates security stamp via UpdateSecurityStampAsync) - var credentialId = Guid.CreateVersion7().ToByteArray(); - await PasskeyTestHelpers.AddPasskeyToUserAsync(tenantId, email, "New Passkey", credentialId, signCount: 0); - - // Manually trigger security stamp update like the endpoint does - var store = await DatabaseHelpers.GetDocumentStoreAsync(); - await using (var session = store.LightweightSession(tenantId)) - { - var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); - user!.SecurityStamp = Guid.CreateVersion7().ToString(); - session.Update(user); - await session.SaveChangesAsync(); - } + // Act - Add a passkey via the real WebAuthn endpoint (which calls UpdateSecurityStampAsync internally) + await using var webAuthn = await WebAuthnTestHelper.CreateAsync(); + _ = await webAuthn.RegisterPasskeyAsync(email, tenantId, loginResponse.AccessToken); // Assert - Old token should now be rejected var exception = await Assert.That(async () => @@ -112,8 +131,9 @@ public async Task Token_AfterAddingPasskey_BecomesInvalid() [Test] public async Task RefreshToken_FromDifferentTenant_LocksAccountAndClearsTokens() { - // Arrange - Create a user in tenant1 - var tenant1 = "acme"; + // Arrange - Create a user in a unique tenant per run + var tenant1 = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenant1); var email = FakeDataGenerators.GenerateFakeEmail(); var password = FakeDataGenerators.GenerateFakePassword(); @@ -128,7 +148,7 @@ public async Task RefreshToken_FromDifferentTenant_LocksAccountAndClearsTokens() // Manually add the same refresh token with a DIFFERENT tenant ID to simulate cross-tenant token theft // In a real scenario, this would require a security breach or bug that allows token reuse across tenants - var store = await DatabaseHelpers.GetDocumentStoreAsync(); + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using (var session = store.LightweightSession(tenant1)) { var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); @@ -136,11 +156,14 @@ public async Task RefreshToken_FromDifferentTenant_LocksAccountAndClearsTokens() if (user != null) { // Add a token with wrong tenant ID (simulating the attack scenario the code defends against) + // Use a different generated tenant ID to simulate the attack — it doesn't need to be a real tenant + var spoofedTenantId = FakeDataGenerators.GenerateFakeTenantId(); user.RefreshTokens.Add(new RefreshTokenInfo( Token: "malicious-token-123", Expires: DateTimeOffset.UtcNow.AddDays(7), Created: DateTimeOffset.UtcNow, - TenantId: "contoso" // Different tenant! + TenantId: spoofedTenantId, // Different tenant! + SecurityStamp: user.SecurityStamp ?? string.Empty )); session.Update(user); await session.SaveChangesAsync(); @@ -153,8 +176,8 @@ public async Task RefreshToken_FromDifferentTenant_LocksAccountAndClearsTokens() refreshToken = "malicious-token-123" }); - // Assert - Should be rejected with 401 - _ = await Assert.That(refreshResponse.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + // Assert - Should be rejected with 403 Forbidden (user is authenticated but not permitted across tenants) + _ = await Assert.That(refreshResponse.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); // Verify tenant1 account is now locked and all tokens cleared await using (var sessionVerify = store.LightweightSession(tenant1)) @@ -171,32 +194,24 @@ public async Task RefreshToken_FromDifferentTenant_LocksAccountAndClearsTokens() [Test] public async Task PasskeyLogin_ClearsAllExistingRefreshTokens() { - // Arrange - Create user and establish multiple sessions with refresh tokens + // Arrange: Create user and establish 3 separate password sessions (3 refresh tokens) var (email, password, login1, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); - // Create 2 additional sessions (with RegisterAndLoginUserAsync we already have 1) var client = HttpClientHelpers.GetUnauthenticatedClient(tenantId); var loginResponse2 = await client.PostAsJsonAsync("/account/login", new { email, password }); var login2 = await loginResponse2.Content.ReadFromJsonAsync(); var loginResponse3 = await client.PostAsJsonAsync("/account/login", new { email, password }); var login3 = await loginResponse3.Content.ReadFromJsonAsync(); - // Act - Add a passkey and trigger passkey login flow - // In a real passkey login, all refresh tokens are cleared for security - var credentialId = Guid.CreateVersion7().ToByteArray(); - await PasskeyTestHelpers.AddPasskeyToUserAsync(tenantId, email, "Login Passkey", credentialId, signCount: 0); + // Register a real passkey using the access token from the first session + await using var webAuthn = await WebAuthnTestHelper.CreateAsync(); + var passkey = await webAuthn.RegisterPasskeyAsync(email, tenantId, login1.AccessToken); - // Simulate the token clearing that happens in passkey login - var store = await DatabaseHelpers.GetDocumentStoreAsync(); - await using (var session = store.LightweightSession(tenantId)) - { - var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); - user!.RefreshTokens.Clear(); - session.Update(user); - await session.SaveChangesAsync(); - } + // Act: Perform a real passkey login — this MUST clear all existing refresh tokens + var passkeyLogin = await webAuthn.LoginWithPasskeyAsync(passkey); + _ = await Assert.That(passkeyLogin).IsNotNull(); - // Assert - All old refresh tokens should be invalid, even token1 and token2 + // Assert: All 3 old refresh tokens should now be rejected var response1 = await client.PostAsJsonAsync("/account/refresh-token", new { refreshToken = login1.RefreshToken }); var response2 = await client.PostAsJsonAsync("/account/refresh-token", new { refreshToken = login2!.RefreshToken }); var response3 = await client.PostAsJsonAsync("/account/refresh-token", new { refreshToken = login3!.RefreshToken }); @@ -205,11 +220,166 @@ public async Task PasskeyLogin_ClearsAllExistingRefreshTokens() _ = await Assert.That(response2.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); _ = await Assert.That(response3.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); - // Verify database state + // Verify the DB contains only the new passkey-login refresh token, not the old ones + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var sessionFinal = store.LightweightSession(tenantId); var userAfter = await DatabaseHelpers.GetUserByEmailAsync(sessionFinal, email); - _ = await Assert.That(userAfter!.RefreshTokens).IsEmpty(); + _ = await Assert.That(userAfter).IsNotNull(); + var oldTokens = userAfter!.RefreshTokens + .Where(t => t.Token == login1.RefreshToken + || t.Token == login2.RefreshToken + || t.Token == login3.RefreshToken) + .ToList(); + _ = await Assert.That(oldTokens).IsEmpty(); + } + + [Test] + [Arguments("tenant-a", "tenant-b")] + [Arguments("tenant-a", "default")] + [Arguments("tenant-b", "default")] + public async Task ConcurrentPasskeyRegistrations_SameEmailDifferentTenants_Succeed(string tenant1, string tenant2) + { + // Arrange: Use the same email for both tenants + var sharedEmail = FakeDataGenerators.GenerateFakeEmail(); + + // Create users in both tenants with the same email + var (_, _, login1, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenant1, sharedEmail); + var (_, _, login2, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenant2, sharedEmail); + + // Act: Add passkeys to both users (same email, different tenants) + var credentialId1 = Guid.CreateVersion7().ToByteArray(); + var credentialId2 = Guid.CreateVersion7().ToByteArray(); + + await PasskeyTestHelpers.AddPasskeyToUserAsync(tenant1, sharedEmail, "Tenant1 Passkey", credentialId1); + await PasskeyTestHelpers.AddPasskeyToUserAsync(tenant2, sharedEmail, "Tenant2 Passkey", credentialId2); + + // Assert: Both passkeys should exist in their respective tenants + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); + + await using (var session1 = store.LightweightSession(tenant1)) + { + var user1 = await DatabaseHelpers.GetUserByEmailAsync(session1, sharedEmail); + _ = await Assert.That(user1).IsNotNull(); + _ = await Assert.That(user1!.Passkeys.Count).IsEqualTo(1); + _ = await Assert.That(user1.Passkeys[0].Name).IsEqualTo("Tenant1 Passkey"); + } + + await using var session2 = store.LightweightSession(tenant2); + var user2 = await DatabaseHelpers.GetUserByEmailAsync(session2, sharedEmail); + _ = await Assert.That(user2).IsNotNull(); + _ = await Assert.That(user2!.Passkeys.Count).IsEqualTo(1); + _ = await Assert.That(user2.Passkeys[0].Name).IsEqualTo("Tenant2 Passkey"); + } + + [Test] + [Arguments("default")] + [Arguments("tenant-a")] + [Arguments("tenant-b")] + public async Task UserWithPasskeyOnly_CanAccessProtectedEndpoints(string tenantId) + { + // Arrange: Create user with password + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenantId); + + // Register a passkey via the real WebAuthn endpoint (security stamp changes) + await using var webAuthn = await WebAuthnTestHelper.CreateAsync(); + var passkey = await webAuthn.RegisterPasskeyAsync(email, tenantId, loginResponse.AccessToken); + + // Get a fresh JWT via password after the security-stamp change + var unauthClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantId)); + var freshLogin = await unauthClient.LoginAsync(new LoginRequest(email, password)); + + // Remove password — user is now passkey-only (security stamp changes again) + var authClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(freshLogin.AccessToken, tenantId)); + await authClient.RemovePasswordAsync(new RemovePasswordRequest()); + + // Act: Login via passkey as a passkey-only user and access a protected endpoint + var passkeyLogin = await webAuthn.LoginWithPasskeyAsync(passkey); + var passkeyAuthClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(passkeyLogin.AccessToken, tenantId)); + + var passwordStatus = await passkeyAuthClient.GetPasswordStatusAsync(); + + // Assert: Endpoint is accessible and reports no password + _ = await Assert.That(passwordStatus).IsNotNull(); + _ = await Assert.That(passwordStatus.HasPassword).IsFalse(); + } + + [Test] + public async Task UserWithPasswordOnly_CanAccessBasicEndpoints() + { + // Arrange: Create user with password only (no passkey) + var (email, password, loginResponse, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); + + // Verify user has no passkeys + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); + await using (var session = store.LightweightSession(tenantId)) + { + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); + _ = await Assert.That(user).IsNotNull(); + _ = await Assert.That(user!.Passkeys).IsEmpty(); + _ = await Assert.That(user.PasswordHash).IsNotNull(); + } + + // Act: Access passkey endpoints - should work (list will be empty) + var passkeyClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(loginResponse.AccessToken, tenantId)); + + var passkeys = await passkeyClient.ListPasskeysAsync(); + + // Assert: User can list passkeys (empty list) even without having any + _ = await Assert.That(passkeys).IsEmpty(); + + // User can access password management endpoints + var identityClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(loginResponse.AccessToken, tenantId)); + + var passwordStatus = await identityClient.GetPasswordStatusAsync(); + _ = await Assert.That(passwordStatus).IsNotNull(); + _ = await Assert.That(passwordStatus.HasPassword).IsTrue(); + } + + [Test] + public async Task CannotDeleteLastPasskey_WithoutPassword() + { + // Arrange: Create user with password + var (email, password, loginResponse, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); + + // Register a passkey via the real WebAuthn endpoint (security stamp changes) + await using var webAuthn = await WebAuthnTestHelper.CreateAsync(); + var passkey = await webAuthn.RegisterPasskeyAsync(email, tenantId, loginResponse.AccessToken); + + // Get a fresh JWT via password after the security-stamp change + var unauthClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantId)); + var freshLogin = await unauthClient.LoginAsync(new LoginRequest(email, password)); + + // Remove password — user is now passkey-only (security stamp changes again) + var authClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(freshLogin.AccessToken, tenantId)); + await authClient.RemovePasswordAsync(new RemovePasswordRequest()); + + // Act: Login via passkey and try to delete the last (and only) passkey + var passkeyLogin = await webAuthn.LoginWithPasskeyAsync(passkey); + var passkeyClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(passkeyLogin.AccessToken, tenantId)); + + var passkeys = await passkeyClient.ListPasskeysAsync(); + _ = await Assert.That(passkeys.Count).IsEqualTo(1); + + var lastPasskeyId = passkeys[0].Id; + + // Assert: Deleting the only passkey when the user has no password is rejected. + // The "*" wildcard ETag passes ETagValidationMiddleware (which only checks presence); + // the 400 comes from business logic, not from ETag value mismatch. + var exception = await Assert.That(async () => + await passkeyClient.DeletePasskeyAsync(lastPasskeyId, "*")) + .Throws(); + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + + // The passkey must still be present + var passkeysAfter = await passkeyClient.ListPasskeysAsync(); + _ = await Assert.That(passkeysAfter.Count).IsEqualTo(1); } [Test] @@ -225,7 +395,7 @@ public async Task SecurityStamp_InToken_MustMatchUserSecurityStamp() _ = await Assert.That(securityStampClaim).IsNotNull(); // Verify it matches the user's actual security stamp - var store = await DatabaseHelpers.GetDocumentStoreAsync(); + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(tenantId); var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); @@ -236,26 +406,34 @@ public async Task SecurityStamp_InToken_MustMatchUserSecurityStamp() [Test] public async Task PasskeySignCount_MustBeStoredAndIncrement() { - // Arrange - var (email, _, _, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); - var credentialId = Guid.CreateVersion7().ToByteArray(); + // Arrange: Register user and attach a real passkey via the virtual authenticator + var (email, _, loginResponse, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); - // Add initial passkey with sign count 0 - await PasskeyTestHelpers.AddPasskeyToUserAsync(tenantId, email, "Test Device", credentialId, signCount: 0); + await using var webAuthn = await WebAuthnTestHelper.CreateAsync(); + var passkey = await webAuthn.RegisterPasskeyAsync(email, tenantId, loginResponse.AccessToken); - var store = await DatabaseHelpers.GetDocumentStoreAsync(); + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); - // Act - Simulate successful logins that increment the counter - await PasskeyTestHelpers.UpdatePasskeySignCountAsync(tenantId, email, credentialId, signCount: 1); - await PasskeyTestHelpers.UpdatePasskeySignCountAsync(tenantId, email, credentialId, signCount: 2); - await PasskeyTestHelpers.UpdatePasskeySignCountAsync(tenantId, email, credentialId, signCount: 3); + // Helper: read the stored sign count for this passkey from the DB + async Task GetStoredSignCountAsync() + { + await using var session = store.LightweightSession(tenantId); + var u = await DatabaseHelpers.GetUserByEmailAsync(session, email); + return u!.Passkeys.First().SignCount; + } - // Assert - Verify counter is properly stored and incremented - await using var session = store.LightweightSession(tenantId); - var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); + var countAfterRegistration = await GetStoredSignCountAsync(); - _ = await Assert.That(user).IsNotNull(); - var passkey = user!.Passkeys.First(p => p.CredentialId.SequenceEqual(credentialId)); - _ = await Assert.That(passkey.SignCount).IsEqualTo(3u); + // Act: First passkey login — virtual authenticator produces counter 1 + _ = await webAuthn.LoginWithPasskeyAsync(passkey); + var countAfterLogin1 = await GetStoredSignCountAsync(); + + // Second passkey login — virtual authenticator produces counter 2 + _ = await webAuthn.LoginWithPasskeyAsync(passkey); + var countAfterLogin2 = await GetStoredSignCountAsync(); + + // Assert: counter must strictly increase with each successful assertion + _ = await Assert.That(countAfterLogin1).IsGreaterThan(countAfterRegistration); + _ = await Assert.That(countAfterLogin2).IsGreaterThan(countAfterLogin1); } } diff --git a/tests/BookStore.AppHost.Tests/PasskeyTenantIsolationTests.cs b/tests/BookStore.AppHost.Tests/PasskeyTenantIsolationTests.cs index a93fac4e..a2bc97df 100644 --- a/tests/BookStore.AppHost.Tests/PasskeyTenantIsolationTests.cs +++ b/tests/BookStore.AppHost.Tests/PasskeyTenantIsolationTests.cs @@ -5,6 +5,7 @@ using BookStore.ApiService.Models; using BookStore.AppHost.Tests.Helpers; using BookStore.Client; +using BookStore.Shared.Models; using Marten; using Refit; using Weasel.Core; @@ -16,27 +17,37 @@ public class PasskeyTenantIsolationTests [Test] public async Task Passkeys_AreTenantScoped() { - // Arrange - var (acmeEmail, _, acmeLoginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync("acme"); - var (_, _, contosoLoginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync("contoso"); + // Arrange: two fresh isolated tenants + var tenant1 = FakeDataGenerators.GenerateFakeTenantId(); + var tenant2 = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenant1); + await DatabaseHelpers.CreateTenantViaApiAsync(tenant2); + + var (email1, password1, _, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenant1); + var (_, _, tenant2LoginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenant2); var credentialId = Guid.CreateVersion7().ToByteArray(); - await PasskeyTestHelpers.AddPasskeyToUserAsync("acme", acmeEmail, "Acme Passkey", credentialId); + const string passkeyName = "My Passkey"; + await PasskeyTestHelpers.AddPasskeyToUserAsync(tenant1, email1, passkeyName, credentialId); + + // Get fresh token after adding passkey (security stamp changed) + var tenant1IdentityClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenant1)); + var tenant1RefreshedResponse = await tenant1IdentityClient.LoginAsync(new LoginRequest(email1, password1)); - var acmeClient = RestService.For(HttpClientHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "acme")); - var contosoClient = - RestService.For(HttpClientHelpers.GetAuthenticatedClient(contosoLoginResponse.AccessToken, "contoso")); + var tenant1Client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(tenant1RefreshedResponse.AccessToken, tenant1)); + var tenant2Client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(tenant2LoginResponse.AccessToken, tenant2)); // Act - var acmePasskeys = await acmeClient.ListPasskeysAsync(); - var contosoPasskeys = await contosoClient.ListPasskeysAsync(); + var tenant1Passkeys = await tenant1Client.ListPasskeysAsync(); + var tenant2Passkeys = await tenant2Client.ListPasskeysAsync(); - // Assert - _ = await Assert.That(acmePasskeys.Any(p => p.Name == "Acme Passkey")).IsTrue(); - _ = await Assert.That(contosoPasskeys.Any(p => p.Name == "Acme Passkey")).IsFalse(); + // Assert: passkey is visible only in the tenant it was created in + _ = await Assert.That(tenant1Passkeys.Any(p => p.Name == passkeyName)).IsTrue(); + _ = await Assert.That(tenant2Passkeys.Any(p => p.Name == passkeyName)).IsFalse(); - var mismatchedClient = - RestService.For(HttpClientHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "contoso")); + // Assert: using tenant1 token against tenant2 header is rejected + var mismatchedClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(tenant1RefreshedResponse.AccessToken, tenant2)); var mismatchException = await Assert.That(async () => await mismatchedClient.ListPasskeysAsync()).Throws(); var isRejected = mismatchException!.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized; @@ -46,53 +57,70 @@ public async Task Passkeys_AreTenantScoped() [Test] public async Task DeletePasskey_WithMismatchedTenantHeader_IsRejected() { - // Arrange - var (acmeEmail, _, acmeLoginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync("acme"); + // Arrange: a fresh tenant for the real user, and a second tenant whose header will be spoofed + var tenant1 = FakeDataGenerators.GenerateFakeTenantId(); + var tenant2 = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenant1); + await DatabaseHelpers.CreateTenantViaApiAsync(tenant2); + + var (email, password, _, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenant1); var credentialId = Guid.CreateVersion7().ToByteArray(); - await PasskeyTestHelpers.AddPasskeyToUserAsync("acme", acmeEmail, "Acme Passkey", credentialId); + const string passkeyName = "My Passkey"; + await PasskeyTestHelpers.AddPasskeyToUserAsync(tenant1, email, passkeyName, credentialId); + + // Get fresh token after adding passkey (security stamp changed) + var identityClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenant1)); + var refreshedResponse = await identityClient.LoginAsync(new LoginRequest(email, password)); - var acmeClient = RestService.For(HttpClientHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "acme")); - var passkeys = await acmeClient.ListPasskeysAsync(); - var passkeyId = passkeys.Single(p => p.Name == "Acme Passkey").Id; + var tenant1Client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(refreshedResponse.AccessToken, tenant1)); + var passkeys = await tenant1Client.ListPasskeysAsync(); + var passkeyId = passkeys.Single(p => p.Name == passkeyName).Id; - var mismatchedClient = - RestService.For(HttpClientHelpers.GetAuthenticatedClient(acmeLoginResponse.AccessToken, "contoso")); + // Use tenant1 access token but send tenant2 header (cross-tenant attack) + var mismatchedClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(refreshedResponse.AccessToken, tenant2)); // Act var mismatchException = await Assert.That( async () => await mismatchedClient.DeletePasskeyAsync(passkeyId, "\"0\"")) .Throws(); - // Assert + // Assert: request is rejected var isRejected = mismatchException!.StatusCode is HttpStatusCode.Forbidden or HttpStatusCode.Unauthorized; _ = await Assert.That(isRejected).IsTrue(); - var remaining = await acmeClient.ListPasskeysAsync(); - _ = await Assert.That(remaining.Any(p => p.Name == "Acme Passkey")).IsTrue(); + // Assert: passkey still exists in the correct tenant + var remaining = await tenant1Client.ListPasskeysAsync(); + _ = await Assert.That(remaining.Any(p => p.Name == passkeyName)).IsTrue(); } [Test] public async Task PasskeyCreationOptions_WithEmailFromOtherTenant_ReturnsFreshUserId() { - // Arrange - var (acmeEmail, _, acmeLoginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync("acme"); - var contosoClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("contoso")); - - // Get acme user ID from JWT token - var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); - var token = handler.ReadJwtToken(acmeLoginResponse.AccessToken); - var acmeUserId = Guid.Parse(token.Claims.First(c => c.Type == "sub").Value); - - // Act - var response = await contosoClient.GetPasskeyCreationOptionsAsync(new PasskeyCreationRequest + // Arrange: two fresh isolated tenants sharing the same email + var tenant1 = FakeDataGenerators.GenerateFakeTenantId(); + var tenant2 = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenant1); + await DatabaseHelpers.CreateTenantViaApiAsync(tenant2); + + var (sharedEmail, _, tenant1LoginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenant1); + var tenant2PasskeyClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenant2)); + + // Get tenant1 user ID from JWT + var handler = new JwtSecurityTokenHandler(); + var token = handler.ReadJwtToken(tenant1LoginResponse.AccessToken); + var tenant1UserId = Guid.Parse(token.Claims.First(c => c.Type == "sub").Value); + + // Act: request passkey creation on tenant2 using the same email + var response = await tenant2PasskeyClient.GetPasskeyCreationOptionsAsync(new PasskeyCreationRequest { - Email = acmeEmail + Email = sharedEmail }); - // Assert + // Assert: tenant2 assigns a brand-new user ID (not the same as tenant1's) _ = await Assert.That(response).IsNotNull(); _ = await Assert.That(response.Options).IsNotNull(); - _ = await Assert.That(Guid.TryParse(response.UserId, out var contosoUserId)).IsTrue(); - _ = await Assert.That(contosoUserId).IsNotEqualTo(acmeUserId); + _ = await Assert.That(Guid.TryParse(response.UserId, out var tenant2UserId)).IsTrue(); + _ = await Assert.That(tenant2UserId).IsNotEqualTo(tenant1UserId); } } diff --git a/tests/BookStore.AppHost.Tests/PasskeyTestHelpers.cs b/tests/BookStore.AppHost.Tests/PasskeyTestHelpers.cs index c8d86734..0392c116 100644 --- a/tests/BookStore.AppHost.Tests/PasskeyTestHelpers.cs +++ b/tests/BookStore.AppHost.Tests/PasskeyTestHelpers.cs @@ -38,6 +38,7 @@ public static UserPasskeyInfo CreatePasskeyInfo( /// /// Adds a passkey to a user in the database. + /// Also updates the security stamp to simulate the behavior of the API endpoint. /// public static async Task AddPasskeyToUserAsync( string tenantId, @@ -46,7 +47,7 @@ public static async Task AddPasskeyToUserAsync( byte[] credentialId, uint signCount = 0) { - var store = await DatabaseHelpers.GetDocumentStoreAsync(); + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(tenantId); var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); @@ -58,6 +59,10 @@ public static async Task AddPasskeyToUserAsync( var passkey = CreatePasskeyInfo(credentialId, name, signCount); user.Passkeys.Add(passkey); + // Update the security stamp to match the behavior of PasskeyEndpoints.cs + // This is critical for JWT invalidation tests + user.SecurityStamp = Guid.CreateVersion7().ToString(); + session.Update(user); await session.SaveChangesAsync(); } @@ -71,7 +76,7 @@ public static async Task UpdatePasskeySignCountAsync( byte[] credentialId, uint signCount) { - var store = await DatabaseHelpers.GetDocumentStoreAsync(); + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); await using var session = store.LightweightSession(tenantId); var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); diff --git a/tests/BookStore.AppHost.Tests/PasskeyTests.cs b/tests/BookStore.AppHost.Tests/PasskeyTests.cs index ccac2496..783395be 100644 --- a/tests/BookStore.AppHost.Tests/PasskeyTests.cs +++ b/tests/BookStore.AppHost.Tests/PasskeyTests.cs @@ -26,8 +26,8 @@ public PasskeyTests() public async Task GetAssertionOptions_WithUserWithNoPasskeys_ShouldReturnOptions() { // Arrange - var email = _faker.Internet.Email(); - var password = _faker.Internet.Password(8, false, "\\w", "Aa1!"); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); var registerResponse = await _identityClient.RegisterAsync(new RegisterRequest(email, password)); _ = await Assert.That(registerResponse).IsNotNull(); @@ -45,8 +45,8 @@ public async Task GetAssertionOptions_WithUserWithNoPasskeys_ShouldReturnOptions public async Task GetAttestationOptions_WithExistingUser_ShouldReturnOk() { // Arrange - var email = _faker.Internet.Email(); - var password = _faker.Internet.Password(8, false, "\\w", "Aa1!"); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); // Register a user _ = await _identityClient.RegisterAsync(new RegisterRequest(email, password)); @@ -64,8 +64,8 @@ public async Task GetAttestationOptions_WithExistingUser_ShouldReturnOk() public async Task GetAttestationOptions_WhenAuthenticated_ShouldReturnOptions() { // Arrange - Need to be logged in to register a passkey - var email = _faker.Internet.Email(); - var password = _faker.Internet.Password(8, false, "\\w", "Aa1!"); + var email = FakeDataGenerators.GenerateFakeEmail(); + var password = FakeDataGenerators.GenerateFakePassword(); // Register _ = await _identityClient.RegisterAsync(new RegisterRequest(email, password)); diff --git a/tests/BookStore.AppHost.Tests/PasswordGeneratorTests.cs b/tests/BookStore.AppHost.Tests/PasswordGeneratorTests.cs new file mode 100644 index 00000000..6c130d1a --- /dev/null +++ b/tests/BookStore.AppHost.Tests/PasswordGeneratorTests.cs @@ -0,0 +1,39 @@ +using Bogus; + +namespace BookStore.AppHost.Tests.Helpers; + +public class PasswordGeneratorTests +{ + [Test] + public async Task GenerateFakePassword_ShouldMeetAllRequirements() + { + // Generate 100 passwords and verify they all meet requirements + for (var i = 0; i < 100; i++) + { + var password = FakeDataGenerators.GenerateFakePassword(); + + // ASP.NET Core Identity requirements + _ = await Assert.That(password.Length).IsGreaterThanOrEqualTo(8); + _ = await Assert.That(password.Any(char.IsUpper)).IsTrue(); + _ = await Assert.That(password.Any(char.IsLower)).IsTrue(); + _ = await Assert.That(password.Any(char.IsDigit)).IsTrue(); + _ = await Assert.That(password.Any(c => !char.IsLetterOrDigit(c))).IsTrue(); + } + } + + [Test] + public void GenerateFakePassword_OutputSamples() + { + // Output 10 sample passwords for manual inspection + for (var i = 0; i < 10; i++) + { + var password = FakeDataGenerators.GenerateFakePassword(); + var hasUpper = password.Any(char.IsUpper); + var hasLower = password.Any(char.IsLower); + var hasDigit = password.Any(char.IsDigit); + var hasSpecial = password.Any(c => !char.IsLetterOrDigit(c)); + + Console.WriteLine($"Password: '{password}' (len:{password.Length}, Upper:{hasUpper}, Lower:{hasLower}, Digit:{hasDigit}, Special:{hasSpecial})"); + } + } +} diff --git a/tests/BookStore.AppHost.Tests/PasswordManagementTests.cs b/tests/BookStore.AppHost.Tests/PasswordManagementTests.cs index 6e51737e..1e5eb9a2 100644 --- a/tests/BookStore.AppHost.Tests/PasswordManagementTests.cs +++ b/tests/BookStore.AppHost.Tests/PasswordManagementTests.cs @@ -1,5 +1,4 @@ using System.Net; -using Bogus; using BookStore.ApiService.Models; using BookStore.AppHost.Tests.Helpers; using BookStore.Client; @@ -15,13 +14,11 @@ namespace BookStore.AppHost.Tests; public class PasswordManagementTests { readonly IIdentityClient _client; - readonly Faker _faker; public PasswordManagementTests() { var httpClient = HttpClientHelpers.GetUnauthenticatedClient(); _client = RestService.For(httpClient); - _faker = new Faker(); } [Test] @@ -195,7 +192,7 @@ public async Task RemovePassword_Succeeds_WhenUserHasPasskey() _ = await Assert.That(user).IsNotNull(); user!.Passkeys.Add(new UserPasskeyInfo( - Guid.NewGuid().ToByteArray(), // credentialId + Guid.CreateVersion7().ToByteArray(), // credentialId [], // publicKey DateTimeOffset.UtcNow, // createdAt 0, // signCount @@ -215,8 +212,9 @@ public async Task RemovePassword_Succeeds_WhenUserHasPasskey() await authClient.RemovePasswordAsync(new RemovePasswordRequest()); // Verify password hash is null directly in database - // Note: RemovePasswordAsync updates the security stamp, invalidating the current token - // This is correct security behavior - security-sensitive operations should invalidate sessions + // Note: The RemovePassword endpoint explicitly calls UpdateSecurityStampAsync after + // RemovePasswordAsync (which does not update the stamp internally in ASP.NET Identity), + // so the current token is invalidated as a result. using var verifyStore = await GetStoreAsync(); await using var verifySession = verifyStore.LightweightSession(StorageConstants.DefaultTenantId); var updatedUser = await verifySession.Query() diff --git a/tests/BookStore.AppHost.Tests/PriceFilterRegressionTests.cs b/tests/BookStore.AppHost.Tests/PriceFilterRegressionTests.cs index d048f68c..b8e28ebc 100644 --- a/tests/BookStore.AppHost.Tests/PriceFilterRegressionTests.cs +++ b/tests/BookStore.AppHost.Tests/PriceFilterRegressionTests.cs @@ -34,7 +34,7 @@ public async Task SearchBooks_WithVariousPriceAndDiscountScenarios_ShouldFilterC var publicClient = Refit.RestService.For(publicHttpClient); var uniqueTitle = - $"PriceScenario-{originalPrice.ToString(CultureInfo.InvariantCulture)}-{discountPercentage.ToString(CultureInfo.InvariantCulture)}-{Guid.NewGuid()}"; + $"PriceScenario-{originalPrice.ToString(CultureInfo.InvariantCulture)}-{discountPercentage.ToString(CultureInfo.InvariantCulture)}-{Guid.CreateVersion7()}"; var createRequest = new CreateBookRequest { @@ -98,7 +98,7 @@ public async Task SearchBooks_WithMixedCurrency_ShouldRequireSingleCurrencyToMat var publicHttpClient = HttpClientHelpers.GetUnauthenticatedClient(); var publicClient = Refit.RestService.For(publicHttpClient); - var uniqueTitle = $"Mixed-NoMatch-{Guid.NewGuid()}"; + var uniqueTitle = $"Mixed-NoMatch-{Guid.CreateVersion7()}"; // USD=10, EUR=200. Range [50, 150]. No single currency fits. var createRequest = new CreateBookRequest { @@ -138,7 +138,7 @@ public async Task SearchBooks_WithDiscount_AfterBookUpdate_ShouldStillFilterByDi var publicHttpClient = HttpClientHelpers.GetUnauthenticatedClient(); var publicClient = Refit.RestService.For(publicHttpClient); - var uniqueTitle = $"UpdateResetsDiscount-{Guid.NewGuid()}"; + var uniqueTitle = $"UpdateResetsDiscount-{Guid.CreateVersion7()}"; var createRequest = new CreateBookRequest { Id = Guid.CreateVersion7(), diff --git a/tests/BookStore.AppHost.Tests/PublisherCrudTests.cs b/tests/BookStore.AppHost.Tests/PublisherCrudTests.cs index d705e652..531434d0 100644 --- a/tests/BookStore.AppHost.Tests/PublisherCrudTests.cs +++ b/tests/BookStore.AppHost.Tests/PublisherCrudTests.cs @@ -59,7 +59,6 @@ public async Task DeletePublisher_ShouldReturnNoContent() // Act createdPublisher = await PublisherHelpers.DeletePublisherAsync(client, createdPublisher); - // Verify it's gone from public API // Verify it's gone from public API var publicClient = Refit.RestService.For( diff --git a/tests/BookStore.AppHost.Tests/RateLimitTests.cs b/tests/BookStore.AppHost.Tests/RateLimitTests.cs index 0ce89f83..85c906d1 100644 --- a/tests/BookStore.AppHost.Tests/RateLimitTests.cs +++ b/tests/BookStore.AppHost.Tests/RateLimitTests.cs @@ -1,6 +1,7 @@ using System.Net; using BookStore.AppHost.Tests.Helpers; using BookStore.Client; +using BookStore.Shared; using Refit; using SharedModels = BookStore.Shared.Models; @@ -22,7 +23,7 @@ public async Task GetFromAuthEndpoint_RepeatedRequests_ShouldConsumeQuota() // Act & Assert // Make an initial request to login endpoint - var loginRequest = new SharedModels.LoginRequest("admin@bookstore.com", "WrongPassword!"); + var loginRequest = new SharedModels.LoginRequest($"admin@{MultiTenancyConstants.DefaultTenantAlias}.com", "WrongPassword!"); try { diff --git a/tests/BookStore.AppHost.Tests/RefitMartenRegressionTests.cs b/tests/BookStore.AppHost.Tests/RefitMartenRegressionTests.cs index eb20998b..3252a39b 100644 --- a/tests/BookStore.AppHost.Tests/RefitMartenRegressionTests.cs +++ b/tests/BookStore.AppHost.Tests/RefitMartenRegressionTests.cs @@ -1,9 +1,7 @@ using BookStore.AppHost.Tests.Helpers; using BookStore.Client; using BookStore.Shared.Models; -using Marten; using Refit; -using Weasel.Core; using CreateAuthorRequest = BookStore.Client.CreateAuthorRequest; using CreateBookRequest = BookStore.Client.CreateBookRequest; @@ -25,7 +23,7 @@ public async Task GetPublishers_ShouldReturnPagedListDto_MatchingRefitExpectatio // Assert _ = await Assert.That(response).IsNotNull(); _ = await Assert.That(response.Items).IsNotNull(); - // The endpoint might return empty list if no publishers + // The endpoint might return empty list if no publishers } [Test] @@ -36,7 +34,7 @@ public async Task SearchBooks_WithPriceFilter_ShouldNotThrow500() var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); // Create a book with a specific price to ensure we have data to query against - var uniqueTitle = $"PriceTest-{Guid.NewGuid()}"; + var uniqueTitle = $"PriceTest-{Guid.CreateVersion7()}"; var createRequest = new CreateBookRequest { Id = Guid.CreateVersion7(), @@ -73,7 +71,7 @@ public async Task SearchBooks_WithPriceFilter_ShouldExcludeOutOfRange() var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); // Create a book with price 20.0 (outside range 5-15) - var uniqueTitle = $"OutOfRange-{Guid.NewGuid()}"; + var uniqueTitle = $"OutOfRange-{Guid.CreateVersion7()}"; var createRequest = new CreateBookRequest { Id = Guid.CreateVersion7(), @@ -106,7 +104,7 @@ public async Task SearchBooks_WithDateSort_ShouldNotThrow500() // Arrange // Create a book to ensure data exists with the new PublicationDateString field populated var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); - var uniqueTitle = $"DateSort-{Guid.NewGuid()}"; + var uniqueTitle = $"DateSort-{Guid.CreateVersion7()}"; _ = await BookHelpers.CreateBookAsync(authClient, new CreateBookRequest { @@ -143,7 +141,7 @@ public async Task SearchBooks_WithPriceFilter_ShouldExcludeBooksWithHighPrimaryP var authClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var publicClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); - var uniqueTitle = $"CurrencyMismatch-{Guid.NewGuid()}"; + var uniqueTitle = $"CurrencyMismatch-{Guid.CreateVersion7()}"; var createRequest = new CreateBookRequest { Id = Guid.CreateVersion7(), @@ -185,26 +183,9 @@ public async Task SearchBooks_InNonDefaultTenant_WithAuthorFilter_ShouldReturnBo // "the author filter works fine in the default tenant but not in other tenants" // Arrange - var tenantId = $"author-filter-test-{Guid.NewGuid():N}"; + var tenantId = $"author-filter-test-{Guid.CreateVersion7():N}"; - // We need to seed the tenant manually effectively because GlobalHooks doesn't expose the Store. - var connectionString = await GlobalHooks.App!.GetConnectionStringAsync("bookstore"); - if (string.IsNullOrEmpty(connectionString)) - { - Assert.Fail("Could not retrieve connection string for 'bookstore' resource."); - } - - using (var store = DocumentStore.For(opts => - { - opts.Connection(connectionString!); - _ = opts.Policies.AllDocumentsAreMultiTenanted(); - opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; - // Use SystemTextJson to match the app - opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase); - })) - { - await DatabaseHelpers.SeedTenantAsync(store, tenantId); - } + await DatabaseHelpers.CreateTenantViaApiAsync(tenantId); // 1. Authenticate as Admin in the new tenant var loginRes = await AuthenticationHelpers.LoginAsAdminAsync(tenantId); @@ -244,21 +225,11 @@ public async Task GetAuthors_InDifferentTenants_ShouldNotReturnCachedResultsFrom // Root cause suspect: GetAuthors cache key does not include TenantId. // Arrange - var tenantA = $"tenant-a-{Guid.NewGuid():N}"; - var tenantB = $"tenant-b-{Guid.NewGuid():N}"; - - // Seed Tenants - var connectionString = await GlobalHooks.App!.GetConnectionStringAsync("bookstore"); - using var store = DocumentStore.For(opts => - { - opts.Connection(connectionString!); - _ = opts.Policies.AllDocumentsAreMultiTenanted(); - opts.Events.TenancyStyle = Marten.Storage.TenancyStyle.Conjoined; - opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase); - }); + var tenantA = $"tenant-a-{Guid.CreateVersion7():N}"; + var tenantB = $"tenant-b-{Guid.CreateVersion7():N}"; - await DatabaseHelpers.SeedTenantAsync(store, tenantA); - await DatabaseHelpers.SeedTenantAsync(store, tenantB); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantA); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantB); var loginResA = await AuthenticationHelpers.LoginAsAdminAsync(tenantA); var adminClientA = diff --git a/tests/BookStore.AppHost.Tests/RefreshTokenSecurityTests.cs b/tests/BookStore.AppHost.Tests/RefreshTokenSecurityTests.cs new file mode 100644 index 00000000..a7bf87b2 --- /dev/null +++ b/tests/BookStore.AppHost.Tests/RefreshTokenSecurityTests.cs @@ -0,0 +1,209 @@ +using System.Net; +using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; +using BookStore.Client; +using BookStore.Shared.Models; +using JasperFx; +using Refit; +using TUnit; + +namespace BookStore.AppHost.Tests; + +/// +/// Tests to verify refresh token security and invalidation scenarios. +/// CRITICAL: These tests validate token rotation, security stamp validation, and expiration. +/// +public class RefreshTokenSecurityTests +{ + [Before(Class)] + public static async Task ClassSetup() + { + if (GlobalHooks.App == null) + { + throw new InvalidOperationException("App is not initialized"); + } + + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-a"); + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-b"); + } + + [Test] + public async Task ExpiredRefreshToken_ReturnsTokenInvalidError() + { + // Arrange: Create user and get tokens + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); + var refreshToken = loginResponse.RefreshToken; + var accessToken = loginResponse.AccessToken; + + // Manually expire the refresh token in database + await ManuallyExpireRefreshTokenAsync(email, refreshToken); + + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(accessToken)); + + // Act: Try to use expired refresh token + var exception = await Assert.That(async () => + await client.RefreshTokenAsync(new RefreshRequest(refreshToken))) + .Throws(); + + // Assert: Should return unauthorized or bad request + var isExpectedError = exception!.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.BadRequest; + _ = await Assert.That(isExpectedError).IsTrue(); + } + + [Test] + [Arguments("default")] + [Arguments("tenant-a")] + [Arguments("tenant-b")] + public async Task RefreshToken_AfterPasswordChange_BecomesInvalid(string tenantId) + { + // Arrange: Create user and get tokens + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenantId); + var refreshToken = loginResponse.RefreshToken; + var accessToken = loginResponse.AccessToken; + + var authClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(accessToken, tenantId)); + + // Act 1: Change password (this updates security stamp) + var newPassword = FakeDataGenerators.GenerateFakePassword(); + await authClient.ChangePasswordAsync(new ChangePasswordRequest(password, newPassword)); + + // Act 2: Try to use old refresh token (should fail due to security stamp change) + var exception = await Assert.That(async () => + await authClient.RefreshTokenAsync(new RefreshRequest(refreshToken))) + .Throws(); + + // Assert: Should be rejected + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task RefreshToken_AfterPasskeyAddition_BecomesInvalid() + { + // Arrange: Create user and get tokens + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); + var refreshToken = loginResponse.RefreshToken; + var accessToken = loginResponse.AccessToken; + var userId = loginResponse.UserId; + + // Add a passkey (this updates security stamp per PasskeyEndpoints.cs#L263-L264) + var credentialId = Guid.CreateVersion7().ToByteArray(); + await PasskeyTestHelpers.AddPasskeyToUserAsync(StorageConstants.DefaultTenantId, email, "New Passkey", credentialId); + + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(accessToken)); + + // Act: Try to use old refresh token (should fail due to security stamp change) + var exception = await Assert.That(async () => + await client.RefreshTokenAsync(new RefreshRequest(refreshToken))) + .Throws(); + + // Assert: Should be rejected + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task TokenRotation_ReusingOldRefreshToken_Fails() + { + // Arrange: Create user and get initial tokens + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); + var oldRefreshToken = loginResponse.RefreshToken; + var oldAccessToken = loginResponse.AccessToken; + + var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(oldAccessToken)); + + // Act 1: Use refresh token to get new tokens (rotation) + var refreshResult = await client.RefreshTokenAsync(new RefreshRequest(oldRefreshToken)); + _ = await Assert.That(refreshResult).IsNotNull(); + _ = await Assert.That(refreshResult.RefreshToken).IsNotEqualTo(oldRefreshToken); + + // Act 2: Try to reuse the old refresh token (should fail - token already rotated) + var exception = await Assert.That(async () => + await client.RefreshTokenAsync(new RefreshRequest(oldRefreshToken))) + .Throws(); + + // Assert: Old token should be invalid after rotation + var isExpectedError = exception!.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.BadRequest; + _ = await Assert.That(isExpectedError).IsTrue(); + } + + [Test] + public async Task RefreshToken_KeepsLatestFiveTokens() + { + // Arrange: Use a unique tenant per run to avoid parallel token-pool contamination + var tenantId = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantId); + + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenantId); + + var tokens = new List<(string RefreshToken, string AccessToken)> + { + (loginResponse.RefreshToken, loginResponse.AccessToken) + }; + + // Act: Simulate 6 additional login sessions (multi-device scenario) to exceed the limit of 5 + // Use separate logins rather than rotations because rotation removes the old token immediately. + // The 5-token limit applies to concurrent sessions (e.g. multiple devices). + var unauthClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(tenantId)); + for (var i = 0; i < 6; i++) + { + var loginResult = await unauthClient.LoginAsync(new LoginRequest(email, password)); + tokens.Add((loginResult.RefreshToken, loginResult.AccessToken)); + } + + // After 7 sessions, the pool is pruned to 5 newest. + // tokens[0] and tokens[1] are the oldest and should have been removed. + // tokens[^2] (tokens[5]) and tokens[^1] (tokens[6]) should still be valid. + + // Assert: First token should be invalid (pruned as beyond the 5-token limit) + var firstTokenClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(tokens[0].AccessToken, tenantId)); + + var exception = await Assert.That(async () => + await firstTokenClient.RefreshTokenAsync(new RefreshRequest(tokens[0].RefreshToken))) + .Throws(); + + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + + // Assert: Second oldest token is also invalid (both oldest are pruned to keep exactly 5) + var secondTokenClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(tokens[1].AccessToken, tenantId)); + + var exception2 = await Assert.That(async () => + await secondTokenClient.RefreshTokenAsync(new RefreshRequest(tokens[1].RefreshToken))) + .Throws(); + + _ = await Assert.That(exception2!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + + // Assert: One of the recent tokens should still be valid + var recentTokenClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(tokens[^2].AccessToken, tenantId)); + + var validRefresh = await recentTokenClient.RefreshTokenAsync( + new RefreshRequest(tokens[^2].RefreshToken)); + + _ = await Assert.That(validRefresh).IsNotNull(); + } + + async Task ManuallyExpireRefreshTokenAsync(string email, string refreshToken, string? tenantId = null) + { + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); + var actualTenantId = tenantId ?? StorageConstants.DefaultTenantId; + await using var session = store.LightweightSession(actualTenantId); + + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); + _ = await Assert.That(user).IsNotNull(); + + if (user != null) + { + var token = user.RefreshTokens.FirstOrDefault(t => t.Token == refreshToken); + if (token != null) + { + // RefreshTokenInfo is immutable, so we need to replace it + _ = user.RefreshTokens.Remove(token); + user.RefreshTokens.Add(token with { Expires = DateTimeOffset.UtcNow.AddDays(-1) }); + session.Store(user); + await session.SaveChangesAsync(); + } + } + } +} diff --git a/tests/BookStore.AppHost.Tests/SearchTests.cs b/tests/BookStore.AppHost.Tests/SearchTests.cs index 78c55a70..6ad43275 100644 --- a/tests/BookStore.AppHost.Tests/SearchTests.cs +++ b/tests/BookStore.AppHost.Tests/SearchTests.cs @@ -11,7 +11,7 @@ public async Task SearchBooks_WithValidQuery_ShouldReturnMatches() { // Arrange var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); - var uniqueTitle = $"UniqueSearchTerm-{Guid.NewGuid()}"; + var uniqueTitle = $"UniqueSearchTerm-{Guid.CreateVersion7()}"; // Create a book with a unique title using proper request model var createRequest = new CreateBookRequest @@ -49,7 +49,7 @@ public async Task SearchBooks_WithNoMatches_ShouldReturnEmpty() _ = await globalHooks!.WaitForResourceHealthyAsync("apiservice", CancellationToken.None) .WaitAsync(TestConstants.DefaultTimeout); - var impossibleTerm = $"ImpossibleTerm-{Guid.NewGuid()}"; + var impossibleTerm = $"ImpossibleTerm-{Guid.CreateVersion7()}"; // Act var response = await publicClient.GetBooksAsync(new BookSearchRequest { Search = impossibleTerm }); diff --git a/tests/BookStore.AppHost.Tests/SecurityStampValidationTests.cs b/tests/BookStore.AppHost.Tests/SecurityStampValidationTests.cs new file mode 100644 index 00000000..ebff1ed0 --- /dev/null +++ b/tests/BookStore.AppHost.Tests/SecurityStampValidationTests.cs @@ -0,0 +1,296 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using BookStore.ApiService.Models; +using BookStore.AppHost.Tests.Helpers; +using BookStore.Client; +using BookStore.Shared.Models; +using JasperFx; +using Refit; +using TUnit; + +namespace BookStore.AppHost.Tests; + +/// +/// Tests to verify security stamp validation invalidates JWTs after credential changes. +/// CRITICAL: These tests validate that tokens are properly invalidated when security-sensitive operations occur. +/// +public class SecurityStampValidationTests +{ + [Before(Class)] + public static async Task ClassSetup() + { + if (GlobalHooks.App == null) + { + throw new InvalidOperationException("App is not initialized"); + } + + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-a"); + await DatabaseHelpers.CreateTenantViaApiAsync("tenant-b"); + } + + [Test] + [Arguments("default")] + [Arguments("tenant-a")] + [Arguments("tenant-b")] + public async Task JWT_WithMismatchedSecurityStamp_IsRejected(string tenantId) + { + // Arrange: Create user and get JWT + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenantId); + var oldAccessToken = loginResponse.AccessToken; + + // Manually update security stamp in database (simulating credential change) + await ManuallyUpdateSecurityStampAsync(email, tenantId); + + // Poll until the old JWT is rejected (security stamp propagated to identity middleware) + var booksClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(oldAccessToken, tenantId)); + + ApiException? exception = null; + await SseEventHelpers.WaitForConditionAsync( + async () => + { + try + { + _ = await booksClient.GetFavoriteBooksAsync(new OrderedPagedRequest()); + return false; + } + catch (ApiException ex) + { + exception = ex; + return ex.StatusCode == HttpStatusCode.Unauthorized; + } + }, + TimeSpan.FromSeconds(5), + "Old JWT was not rejected after security stamp update"); + + // Assert: Should return unauthorized due to security stamp mismatch + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task AccessProtectedEndpoint_AfterPasswordChange_OldJWTRejected() + { + // Arrange: Create user and get JWT + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); + var oldAccessToken = loginResponse.AccessToken; + + var authClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(oldAccessToken)); + + // Act 1: Change password (updates security stamp) + var newPassword = FakeDataGenerators.GenerateFakePassword(); + await authClient.ChangePasswordAsync(new ChangePasswordRequest(password, newPassword)); + + // Act 2: Try to access protected endpoint with old JWT + var booksClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(oldAccessToken)); + + var exception = await Assert.That(async () => + await booksClient.GetFavoriteBooksAsync(new OrderedPagedRequest())) + .Throws(); + + // Assert: Old JWT should be rejected + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + + // Assert: New login with new password should work + var unauthClient = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); + var newLoginResponse = await unauthClient.LoginAsync(new LoginRequest(email, newPassword)); + _ = await Assert.That(newLoginResponse).IsNotNull(); + _ = await Assert.That(newLoginResponse.AccessToken).IsNotEmpty(); + } + + [Test] + public async Task SecurityStampUpdate_InvalidatesAllExistingJWTs() + { + // Arrange: Create user and get multiple JWTs + var (email, password, loginResponse1, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); + + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); + var loginResponse2 = await client.LoginAsync(new LoginRequest(email, password)); + var loginResponse3 = await client.LoginAsync(new LoginRequest(email, password)); + + var jwt1 = loginResponse1.AccessToken; + var jwt2 = loginResponse2.AccessToken; + var jwt3 = loginResponse3.AccessToken; + + // Act: Update security stamp + await ManuallyUpdateSecurityStampAsync(email); + + // Assert: All three JWTs should be rejected (poll until propagated) + var booksClient1 = RestService.For(HttpClientHelpers.GetAuthenticatedClient(jwt1)); + ApiException? exception1 = null; + await SseEventHelpers.WaitForConditionAsync( + async () => + { + try + { + _ = await booksClient1.GetFavoriteBooksAsync(new OrderedPagedRequest()); + return false; + } + catch (ApiException ex) + { + exception1 = ex; + return ex.StatusCode == HttpStatusCode.Unauthorized; + } + }, + TimeSpan.FromSeconds(5), "jwt1 was not invalidated after security stamp update"); + _ = await Assert.That(exception1!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + + var booksClient2 = RestService.For(HttpClientHelpers.GetAuthenticatedClient(jwt2)); + ApiException? exception2 = null; + await SseEventHelpers.WaitForConditionAsync( + async () => + { + try + { + _ = await booksClient2.GetFavoriteBooksAsync(new OrderedPagedRequest()); + return false; + } + catch (ApiException ex) + { + exception2 = ex; + return ex.StatusCode == HttpStatusCode.Unauthorized; + } + }, + TimeSpan.FromSeconds(5), "jwt2 was not invalidated after security stamp update"); + _ = await Assert.That(exception2!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + + var booksClient3 = RestService.For(HttpClientHelpers.GetAuthenticatedClient(jwt3)); + ApiException? exception3 = null; + await SseEventHelpers.WaitForConditionAsync( + async () => + { + try + { + _ = await booksClient3.GetFavoriteBooksAsync(new OrderedPagedRequest()); + return false; + } + catch (ApiException ex) + { + exception3 = ex; + return ex.StatusCode == HttpStatusCode.Unauthorized; + } + }, + TimeSpan.FromSeconds(5), "jwt3 was not invalidated after security stamp update"); + _ = await Assert.That(exception3!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + [Arguments("tenant-a")] + [Arguments("tenant-b")] + public async Task AddPasskey_UpdatesSecurityStamp_InvalidatesOldJWT(string tenantId) + { + // Arrange: Create user and get JWT + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(tenantId); + var oldAccessToken = loginResponse.AccessToken; + + // Act: Add a passkey (this updates security stamp via PasskeyTestHelpers) + var credentialId = Guid.CreateVersion7().ToByteArray(); + await PasskeyTestHelpers.AddPasskeyToUserAsync(tenantId, email, "New Passkey", credentialId); + + // Assert: Old JWT should be rejected (poll until propagated) + var booksClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(oldAccessToken, tenantId)); + + ApiException? exception = null; + await SseEventHelpers.WaitForConditionAsync( + async () => + { + try + { + _ = await booksClient.GetFavoriteBooksAsync(new OrderedPagedRequest()); + return false; + } + catch (ApiException ex) + { + exception = ex; + return ex.StatusCode == HttpStatusCode.Unauthorized; + } + }, + TimeSpan.FromSeconds(5), + "Old JWT was not rejected after passkey addition"); + + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task RemovePassword_UpdatesSecurityStamp_InvalidatesOldJWT() + { + // Arrange: Create user with both password and passkey + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); + var oldAccessToken = loginResponse.AccessToken; + + // Add passkey first (required before removing password) + var credentialId = Guid.CreateVersion7().ToByteArray(); + await PasskeyTestHelpers.AddPasskeyToUserAsync(StorageConstants.DefaultTenantId, email, "Passkey", credentialId); + + // Get new JWT after adding passkey (old one is now invalid) + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); + var newLoginResponse = await client.LoginAsync(new LoginRequest(email, password)); + var newAccessToken = newLoginResponse.AccessToken; + + var authClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(newAccessToken)); + + // Act: Remove password (updates security stamp via explicit UpdateSecurityStampAsync in endpoint) + await authClient.RemovePasswordAsync(new RemovePasswordRequest()); + + // Assert: JWT used to remove password should now be rejected (poll until propagated) + var booksClient = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(newAccessToken)); + + ApiException? exception = null; + await SseEventHelpers.WaitForConditionAsync( + async () => + { + try + { + _ = await booksClient.GetFavoriteBooksAsync(new OrderedPagedRequest()); + return false; + } + catch (ApiException ex) + { + exception = ex; + return ex.StatusCode == HttpStatusCode.Unauthorized; + } + }, + TimeSpan.FromSeconds(5), + "JWT was not rejected after password removal"); + + _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } + + [Test] + public async Task SecurityStampClaim_IsPresentInJWT() + { + // Arrange & Act: Create user and get JWT + var (email, password, loginResponse, _) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); + var accessToken = loginResponse.AccessToken; + + // Parse JWT + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(accessToken); + + // Assert: security_stamp claim should be present + var securityStampClaim = jwt.Claims.FirstOrDefault(c => c.Type == "security_stamp"); + _ = await Assert.That(securityStampClaim).IsNotNull(); + _ = await Assert.That(securityStampClaim!.Value).IsNotEmpty(); + } + + async Task ManuallyUpdateSecurityStampAsync(string email, string? tenantId = null) + { + await using var store = await DatabaseHelpers.GetDocumentStoreAsync(); + var actualTenantId = tenantId ?? StorageConstants.DefaultTenantId; + await using var session = store.LightweightSession(actualTenantId); + + var user = await DatabaseHelpers.GetUserByEmailAsync(session, email); + _ = await Assert.That(user).IsNotNull(); + + if (user != null) + { + user.SecurityStamp = Guid.CreateVersion7().ToString(); + session.Store(user); + await session.SaveChangesAsync(); + } + } +} diff --git a/tests/BookStore.AppHost.Tests/Services/BlobStorageTests.cs b/tests/BookStore.AppHost.Tests/Services/BlobStorageTests.cs index 85cf8dfa..7ac860ac 100644 --- a/tests/BookStore.AppHost.Tests/Services/BlobStorageTests.cs +++ b/tests/BookStore.AppHost.Tests/Services/BlobStorageTests.cs @@ -49,7 +49,7 @@ public async Task Cleanup() public async Task UploadBookCoverAsync_ShouldUploadAndReturnUri() { // Arrange - var bookId = Guid.NewGuid(); + var bookId = Guid.CreateVersion7(); var content = new byte[] { 0x1, 0x2, 0x3, 0x4 }; using var stream = new MemoryStream(content); var contentType = "image/jpeg"; @@ -75,7 +75,7 @@ public async Task UploadBookCoverAsync_ShouldUploadAndReturnUri() public async Task GetBookCoverAsync_ShouldRetrieveContent() { // Arrange - var bookId = Guid.NewGuid(); + var bookId = Guid.CreateVersion7(); var content = new byte[] { 0xCA, 0xFE, 0xBA, 0xBE }; using var stream = new MemoryStream(content); var contentType = "image/png"; @@ -97,7 +97,7 @@ public async Task GetBookCoverAsync_ShouldRetrieveContent() public async Task GetBookCoverAsync_WhenBookDoesNotExist_ShouldThrowFileNotFoundException() { // Arrange - var bookId = Guid.NewGuid(); + var bookId = Guid.CreateVersion7(); // Act & Assert _ = await Assert.That(async () => await _blobStorageService!.GetBookCoverAsync(bookId)) @@ -109,7 +109,7 @@ public async Task GetBookCoverAsync_WhenBookDoesNotExist_ShouldThrowFileNotFound public async Task DeleteBookCoverAsync_ShouldRemoveBlob() { // Arrange - var bookId = Guid.NewGuid(); + var bookId = Guid.CreateVersion7(); var content = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }; using var stream = new MemoryStream(content); var contentType = "image/webp"; diff --git a/tests/BookStore.AppHost.Tests/ShoppingCartTests.cs b/tests/BookStore.AppHost.Tests/ShoppingCartTests.cs index e5ca5e70..6f7d0e64 100644 --- a/tests/BookStore.AppHost.Tests/ShoppingCartTests.cs +++ b/tests/BookStore.AppHost.Tests/ShoppingCartTests.cs @@ -161,7 +161,7 @@ public async Task AddToCart_WithInvalidQuantity_ShouldReturnBadRequest(int quant // Act & Assert var exception = await Assert - .That(() => client.AddToCartAsync(new AddToCartClientRequest(Guid.NewGuid(), quantity))) + .That(() => client.AddToCartAsync(new AddToCartClientRequest(Guid.CreateVersion7(), quantity))) .Throws(); _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); var error = await exception.GetContentAsAsync(); @@ -189,7 +189,7 @@ public async Task CartOperations_WhenUnauthenticated_ShouldReturnUnauthorized() // Act & Assert - Add to cart try { - await client.AddToCartAsync(new AddToCartClientRequest(Guid.NewGuid(), 1)); + await client.AddToCartAsync(new AddToCartClientRequest(Guid.CreateVersion7(), 1)); Assert.Fail("Should have thrown ApiException"); } catch (ApiException ex) @@ -200,7 +200,7 @@ public async Task CartOperations_WhenUnauthenticated_ShouldReturnUnauthorized() // Act & Assert - Update cart item try { - await client.UpdateCartItemAsync(Guid.NewGuid(), new UpdateCartItemClientRequest(5)); + await client.UpdateCartItemAsync(Guid.CreateVersion7(), new UpdateCartItemClientRequest(5)); Assert.Fail("Should have thrown ApiException"); } catch (ApiException ex) @@ -211,7 +211,7 @@ public async Task CartOperations_WhenUnauthenticated_ShouldReturnUnauthorized() // Act & Assert - Remove from cart try { - await client.RemoveFromCartAsync(Guid.NewGuid()); + await client.RemoveFromCartAsync(Guid.CreateVersion7()); Assert.Fail("Should have thrown ApiException"); } catch (ApiException ex) diff --git a/tests/BookStore.AppHost.Tests/TenantInfoTests.cs b/tests/BookStore.AppHost.Tests/TenantInfoTests.cs index 70fb8f26..c9483963 100644 --- a/tests/BookStore.AppHost.Tests/TenantInfoTests.cs +++ b/tests/BookStore.AppHost.Tests/TenantInfoTests.cs @@ -9,21 +9,21 @@ namespace BookStore.AppHost.Tests; public class TenantInfoTests { [Test] - public async Task GetTenantInfo_ReturnsCorrectName() + public async Task GetTenantInfo_ReturnsCorrectInfo() { + // Arrange: create a fresh tenant so we control both Id and Name + var tenantId = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantId); + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient()); - // 1. Get info for "acme" - var acmeInfo = await client.GetTenantAsync("acme"); - _ = await Assert.That(acmeInfo).IsNotNull(); - _ = await Assert.That(acmeInfo.Id).IsEqualTo("acme"); - _ = await Assert.That(acmeInfo.Name).IsEqualTo("Acme Corp"); + // Act + var info = await client.GetTenantAsync(tenantId); - // 2. Get info for "contoso" - var contosoInfo = await client.GetTenantAsync("contoso"); - _ = await Assert.That(contosoInfo).IsNotNull(); - _ = await Assert.That(contosoInfo.Id).IsEqualTo("contoso"); - _ = await Assert.That(contosoInfo.Name).IsEqualTo("Contoso Ltd"); + // Assert: the API echoes back the same ID and a non-empty name + _ = await Assert.That(info).IsNotNull(); + _ = await Assert.That(info.Id).IsEqualTo(tenantId); + _ = await Assert.That(info.Name).IsNotNullOrEmpty(); } [Test] diff --git a/tests/BookStore.AppHost.Tests/TenantSecurityTests.cs b/tests/BookStore.AppHost.Tests/TenantSecurityTests.cs index 5e2f3779..86174972 100644 --- a/tests/BookStore.AppHost.Tests/TenantSecurityTests.cs +++ b/tests/BookStore.AppHost.Tests/TenantSecurityTests.cs @@ -1,6 +1,10 @@ +using System.IdentityModel.Tokens.Jwt; using System.Net; +using System.Security.Claims; using BookStore.AppHost.Tests.Helpers; using BookStore.Client; +using JasperFx; +using Microsoft.IdentityModel.Tokens; using Refit; namespace BookStore.AppHost.Tests; @@ -10,32 +14,32 @@ public class TenantSecurityTests [Test] public async Task Request_WithNoTenantIdClaim_ShouldBeForbidden() { - if (GlobalHooks.App == null) - { - throw new InvalidOperationException("App is not initialized"); - } + // Arrange: Register a real user so their sub resolves in the OnTokenValidated DB check. + // Then re-sign a new token using all the real claims EXCEPT tenant_id. + // This ensures JWT Bearer authentication succeeds and TenantSecurityMiddleware sees an + // authenticated user whose tenant_id claim is absent — triggering 403 Forbidden. + var (_, _, loginResponse, tenantId) = await AuthenticationHelpers.RegisterAndLoginUserAsync(); - var validToken = GlobalHooks.AdminAccessToken!; + var signingKey = new SymmetricSecurityKey( + System.Text.Encoding.UTF8.GetBytes("your-secret-key-must-be-at-least-32-characters-long-for-hs256")); - // Arrange - // Test 1: Valid token (tenant=Default/BookStore), Header=acme -> Should Fail - var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(validToken, "acme")); + var handler = new JwtSecurityTokenHandler(); + var realToken = handler.ReadJwtToken(loginResponse.AccessToken); + var claimsWithoutTenantId = realToken.Claims + .Where(c => c.Type != "tenant_id") + .ToList(); - // Act & Assert - var exception = await Assert.That(async () => await client.GetShoppingCartAsync()).Throws(); - _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); - } + var forgedToken = new JwtSecurityToken( + issuer: "BookStore.ApiService", + audience: "BookStore.Web", + claims: claimsWithoutTenantId, + expires: DateTime.UtcNow.AddHours(1), + signingCredentials: new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256)); - [Test] - public async Task Request_Anonymous_WithTenantHeader_ShouldBeForbidden() - { - if (GlobalHooks.App == null) - { - throw new InvalidOperationException("App is not initialized"); - } + var tokenWithoutTenantClaim = handler.WriteToken(forgedToken); - // Test 2: Anonymous user with X-Tenant-ID="acme" -> Should be Forbidden - var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient("acme")); + var client = RestService.For( + HttpClientHelpers.GetAuthenticatedClient(tokenWithoutTenantClaim, tenantId)); // Act & Assert var exception = await Assert.That(async () => await client.GetShoppingCartAsync()).Throws(); @@ -43,43 +47,21 @@ public async Task Request_Anonymous_WithTenantHeader_ShouldBeForbidden() } [Test] - public async Task Request_NoTenantClaim_ShouldBeForbidden() + public async Task Request_Anonymous_WithTenantHeader_ShouldBeForbidden() { if (GlobalHooks.App == null) { throw new InvalidOperationException("App is not initialized"); } - // Test 3: Same as Test 1 basically - Valid Token (Default), Header (acme) -> Mismatch -> Forbidden - var validToken = GlobalHooks.AdminAccessToken!; - var client = RestService.For(HttpClientHelpers.GetAuthenticatedClient(validToken, "acme")); + // Anonymous request targeting any non-default tenant should be Forbidden + var otherTenant = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(otherTenant); + + var client = RestService.For(HttpClientHelpers.GetUnauthenticatedClient(otherTenant)); // Act & Assert var exception = await Assert.That(async () => await client.GetShoppingCartAsync()).Throws(); _ = await Assert.That(exception!.StatusCode).IsEqualTo(HttpStatusCode.Forbidden); } - - [Test] - public async Task Admin_TenantList_RestrictedToDefaultTenant() - { - if (GlobalHooks.App == null) - { - throw new InvalidOperationException("App is not initialized"); - } - - if (GlobalHooks.AdminAccessToken == null) - { - throw new InvalidOperationException("Admin Access Token is null"); - } - - // 1. Success path: Default Tenant Admin (GlobalHooks.AdminAccessToken) accessing Default Tenant endpoint - var client = - RestService.For(HttpClientHelpers.GetAuthenticatedClient(GlobalHooks.AdminAccessToken)); - - // Act - var result = await client.GetAllTenantsAdminAsync(); - - // Assert - _ = await Assert.That(result).IsNotNull(); - } } diff --git a/tests/BookStore.AppHost.Tests/TenantUserIsolationTests.cs b/tests/BookStore.AppHost.Tests/TenantUserIsolationTests.cs index a8d5a5ee..8974036e 100644 --- a/tests/BookStore.AppHost.Tests/TenantUserIsolationTests.cs +++ b/tests/BookStore.AppHost.Tests/TenantUserIsolationTests.cs @@ -17,7 +17,8 @@ public class TenantUserIsolationTests public async Task RateBook_InSpecificTenant_ShouldUpdateRating() { // Arrange - Setup tenant and user - var tenantId = "acme"; + var tenantId = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantId); var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var loginRes = await AuthenticationHelpers.LoginAsAdminAsync(adminClient, tenantId); _ = await Assert.That(loginRes).IsNotNull(); @@ -29,7 +30,7 @@ public async Task RateBook_InSpecificTenant_ShouldUpdateRating() var createRequest = new CreateBookRequest { Id = Guid.CreateVersion7(), - Title = $"TenantBook-{Guid.NewGuid()}", + Title = $"TenantBook-{Guid.CreateVersion7()}", Isbn = "1234567890", Language = "en", Translations = @@ -66,7 +67,8 @@ public async Task RateBook_InSpecificTenant_ShouldUpdateRating() public async Task AddToFavorites_InSpecificTenant_ShouldUpdateFavorites() { // Arrange - var tenantId = "contoso"; + var tenantId = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantId); var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var loginRes = await AuthenticationHelpers.LoginAsAdminAsync(adminClient, tenantId); _ = await Assert.That(loginRes).IsNotNull(); @@ -77,7 +79,7 @@ public async Task AddToFavorites_InSpecificTenant_ShouldUpdateFavorites() var createRequest = new CreateBookRequest { Id = Guid.CreateVersion7(), - Title = $"FavBook-{Guid.NewGuid()}", + Title = $"FavBook-{Guid.CreateVersion7()}", Isbn = "1234567890", Language = "en", Translations = @@ -117,7 +119,8 @@ public async Task AddToFavorites_InSpecificTenant_ShouldUpdateFavorites() public async Task AddToCart_InSpecificTenant_ShouldPersistInTenant() { // Arrange - Setup tenant-specific context - var tenantId = "acme"; + var tenantId = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenantId); var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); var loginRes = await AuthenticationHelpers.LoginAsAdminAsync(adminClient, tenantId); _ = await Assert.That(loginRes).IsNotNull(); @@ -128,7 +131,7 @@ public async Task AddToCart_InSpecificTenant_ShouldPersistInTenant() var createRequest = new CreateBookRequest { Id = Guid.CreateVersion7(), - Title = $"CartBook-{Guid.NewGuid()}", + Title = $"CartBook-{Guid.CreateVersion7()}", Isbn = "1234567890", Language = "en", Translations = @@ -162,9 +165,11 @@ public async Task AddToCart_InSpecificTenant_ShouldPersistInTenant() [Test] public async Task UserData_ShouldBeIsolatedBetweenTenants() { - // Arrange - Create users in two different tenants - var tenant1 = "acme"; - var tenant2 = "contoso"; + // Arrange - Create users in two fresh isolated tenants + var tenant1 = FakeDataGenerators.GenerateFakeTenantId(); + var tenant2 = FakeDataGenerators.GenerateFakeTenantId(); + await DatabaseHelpers.CreateTenantViaApiAsync(tenant1); + await DatabaseHelpers.CreateTenantViaApiAsync(tenant2); var adminClient = await HttpClientHelpers.GetAuthenticatedClientAsync(); @@ -178,7 +183,7 @@ public async Task UserData_ShouldBeIsolatedBetweenTenants() var createRequest = new CreateBookRequest { Id = Guid.CreateVersion7(), - Title = $"IsoBook-{tid}-{Guid.NewGuid()}", + Title = $"IsoBook-{tid}-{Guid.CreateVersion7()}", Isbn = "1234567890", Language = "en", Translations = diff --git a/tests/BookStore.AppHost.Tests/UnverifiedAccountCleanupTests.cs b/tests/BookStore.AppHost.Tests/UnverifiedAccountCleanupTests.cs index 6d9363b0..ac383b42 100644 --- a/tests/BookStore.AppHost.Tests/UnverifiedAccountCleanupTests.cs +++ b/tests/BookStore.AppHost.Tests/UnverifiedAccountCleanupTests.cs @@ -28,9 +28,9 @@ public async Task CleanupHandler_ShouldDeleteStaleUnverifiedAccounts() opts.UseSystemTextJsonForSerialization(EnumStorage.AsString, Casing.CamelCase); }); - var staleUnverifiedEmail = $"stale_unverified_{Guid.NewGuid()}@example.com"; - var freshUnverifiedEmail = $"fresh_unverified_{Guid.NewGuid()}@example.com"; - var staleVerifiedEmail = $"stale_verified_{Guid.NewGuid()}@example.com"; + var staleUnverifiedEmail = $"stale_unverified_{Guid.CreateVersion7()}@example.com"; + var freshUnverifiedEmail = $"fresh_unverified_{Guid.CreateVersion7()}@example.com"; + var staleVerifiedEmail = $"stale_verified_{Guid.CreateVersion7()}@example.com"; await using (var session = store.LightweightSession()) { diff --git a/tests/BookStore.AppHost.Tests/UpdateTests.cs b/tests/BookStore.AppHost.Tests/UpdateTests.cs index b63f3954..a02e6e8a 100644 --- a/tests/BookStore.AppHost.Tests/UpdateTests.cs +++ b/tests/BookStore.AppHost.Tests/UpdateTests.cs @@ -21,7 +21,7 @@ public async Task UpdateAuthor_FullUpdate_ShouldReflectChanges() var updateRequest = new UpdateAuthorRequest { - Name = $"Updated Author {Guid.NewGuid()}", + Name = $"Updated Author {Guid.CreateVersion7()}", Translations = new Dictionary { ["en"] = new("Updated Biography EN"), diff --git a/tests/BookStore.Web.Tests/QueryInvalidationServiceTests.cs b/tests/BookStore.Web.Tests/QueryInvalidationServiceTests.cs index 60d7df53..d11b1c24 100644 --- a/tests/BookStore.Web.Tests/QueryInvalidationServiceTests.cs +++ b/tests/BookStore.Web.Tests/QueryInvalidationServiceTests.cs @@ -15,7 +15,7 @@ public class QueryInvalidationServiceTests [Test] public async Task ShouldInvalidate_ReturnsTrue_WhenKeyMatches() { - var notification = new BookCreatedNotification(Guid.NewGuid(), Guid.NewGuid(), "Title", DateTimeOffset.UtcNow); + var notification = new BookCreatedNotification(Guid.CreateVersion7(), Guid.CreateVersion7(), "Title", DateTimeOffset.UtcNow); var keys = new[] { "Books" }; var result = _sut.ShouldInvalidate(notification, keys); @@ -26,8 +26,8 @@ public async Task ShouldInvalidate_ReturnsTrue_WhenKeyMatches() [Test] public async Task ShouldInvalidate_ReturnsTrue_WhenEntityKeyMatches() { - var bookId = Guid.NewGuid(); - var notification = new BookUpdatedNotification(Guid.NewGuid(), bookId, "Title", DateTimeOffset.UtcNow); + var bookId = Guid.CreateVersion7(); + var notification = new BookUpdatedNotification(Guid.CreateVersion7(), bookId, "Title", DateTimeOffset.UtcNow); var keys = new[] { $"Book:{bookId}" }; var result = _sut.ShouldInvalidate(notification, keys); @@ -38,8 +38,8 @@ public async Task ShouldInvalidate_ReturnsTrue_WhenEntityKeyMatches() [Test] public async Task ShouldInvalidate_ReturnsFalse_WhenNoKeyMatches() { - var bookId = Guid.NewGuid(); - var notification = new BookUpdatedNotification(Guid.NewGuid(), bookId, "Title", DateTimeOffset.UtcNow); + var bookId = Guid.CreateVersion7(); + var notification = new BookUpdatedNotification(Guid.CreateVersion7(), bookId, "Title", DateTimeOffset.UtcNow); var keys = new[] { "Authors" }; var result = _sut.ShouldInvalidate(notification, keys); @@ -50,8 +50,8 @@ public async Task ShouldInvalidate_ReturnsFalse_WhenNoKeyMatches() [Test] public async Task ShouldInvalidate_UserVerified_MatchesUserKey() { - var userId = Guid.NewGuid(); - var notification = new UserVerifiedNotification(Guid.NewGuid(), userId, "email@example.com", DateTimeOffset.UtcNow); + var userId = Guid.CreateVersion7(); + var notification = new UserVerifiedNotification(Guid.CreateVersion7(), userId, "email@example.com", DateTimeOffset.UtcNow); var keys = new[] { $"User:{userId}" }; var result = _sut.ShouldInvalidate(notification, keys); @@ -123,7 +123,7 @@ object GetDefaultValue(Type type) if (type == typeof(Guid)) { - return Guid.NewGuid(); + return Guid.CreateVersion7(); } if (type == typeof(DateTimeOffset)) diff --git a/tests/BookStore.Web.Tests/Services/CatalogServiceTests.cs b/tests/BookStore.Web.Tests/Services/CatalogServiceTests.cs index d2b6ac3f..753c7f55 100644 --- a/tests/BookStore.Web.Tests/Services/CatalogServiceTests.cs +++ b/tests/BookStore.Web.Tests/Services/CatalogServiceTests.cs @@ -16,7 +16,7 @@ public class CatalogServiceTests CatalogService _sut = null!; BookDto CreateBookDto(Guid id = default, bool isFavorite = false, int userRating = 0) => new( - Id: id == default ? Guid.NewGuid() : id, + Id: id == default ? Guid.CreateVersion7() : id, Title: "Test Book", Isbn: "1234567890", Language: "en",