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",