From 2dab4b522981791e2db605b95a0e0c6a62674b49 Mon Sep 17 00:00:00 2001 From: bgard68 Date: Tue, 25 Aug 2026 05:07:42 +0000 Subject: [PATCH 1/2] test: raise API coverage to 99.88% of lines and 100% of branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds coverage tooling and the tests to close the gaps it found. Baseline was 86.51% of lines and 64.69% of branches across 97 tests; it is now 99.88% and 100% across 289. Tooling - coverlet.collector on both test projects, configured by coverlet.runsettings at the repository root: Cobertura and coverlet JSON, only the four TodoApp.* assemblies, auto-properties skipped. - JSON as well as Cobertura because the two suites have to be merged. Cobertura records only a per-line branch percentage, so a branch covered by the unit suite and one covered by the integration suite both read "1 of 2" and a naive merge under-reports. The JSON keeps each branch as its own record with a hit count, so the merge is exact. Tests - Auth: the rejection paths (unknown, expired and replayed refresh tokens, deactivated accounts, logout against a token the caller does not own), the already-linked and orphaned Google sign-in branches, and the concurrent-insert race that has to surface as 409 rather than 500. - Domain: every entity guard, including that Revoke keeps the first reason and that Activate does not rotate the security stamp. - Todos and categories: ownership isolation, filters, search, ordering, and the concurrency conflict where the row is deleted between the read and the write. - Infrastructure: the Pwned Passwords range lookup against a stub handler (k-anonymity, match, threshold, malformed lines, and every fail-open path), the current-user claims reader, the hasher's malformed-hash paths, and the provider selection in the composition root. - WebApi: each arm of the exception-to-problem mapping, the refresh cookie and its CSRF check, the JWT signing-key guard, and the app hosted as Production and with no appsettings at all. Production changes, both to make untested behaviour testable - GoogleTokenValidator: the payload mapping is split into FromPayload so it can be tested without Google's live signing keys. No behaviour change. - Program.cs: the startup database initialisation and its background retry loop move to DatabaseStartup, which returns the retry task instead of discarding it internally. Program.cs still discards it, so startup does not wait; the retry delay and attempt count are now explicit at the call site. This is 31 lines of recovery logic that previously had no test at all. Two lines remain uncovered, both needing a live cloud service: the mapping after a successful Google token validation, and the closing brace of the Key Vault block, which is only reached when AddAzureKeyVault succeeds against a real vault. Both are documented in docs/development/testing.md §3.4. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019h7znwAftvs36vD4YwKMRf --- coverlet.runsettings | 24 + docs/development/testing.md | 30 +- .../Authentication/GoogleTokenValidator.cs | 17 +- src/TodoApp.WebApi/DatabaseStartup.cs | 86 +++ src/TodoApp.WebApi/Program.cs | 52 +- .../AuthenticationSetupTests.cs | 68 +++ .../DatabaseStartupTests.cs | 130 +++++ .../GlobalExceptionHandlerTests.cs | 138 +++++ .../HostConfigurationTests.cs | 171 ++++++ .../RefreshTokenCookieHelperTests.cs | 176 +++++++ .../TodoApp.IntegrationTests.csproj | 4 + .../packages.lock.json | 6 + .../Auth/AuthEdgeCaseTests.cs | 408 +++++++++++++++ .../Auth/GetCurrentUserTests.cs | 56 ++ .../Categories/CategoryHandlerTests.cs | 145 ++++++ .../Common/ExceptionTests.cs | 93 ++++ .../Common/InfrastructureRegistrationTests.cs | 94 ++++ .../Common/ValidatorTests.cs | 30 ++ .../Domain/EntityGuardTests.cs | 253 +++++++++ .../Security/CurrentUserServiceTests.cs | 86 +++ .../Security/GoogleTokenValidatorTests.cs | 76 +++ .../HibpBreachedPasswordCheckerTests.cs | 235 +++++++++ .../Security/PasswordHasherEdgeCaseTests.cs | 72 +++ tests/TodoApp.UnitTests/TestSupport/Fakes.cs | 41 ++ .../TestSupport/TestDatabase.cs | 13 + .../TodoApp.UnitTests.csproj | 4 + .../Todos/TodoHandlerTests.cs | 489 ++++++++++++++++++ tests/TodoApp.UnitTests/packages.lock.json | 6 + 28 files changed, 2952 insertions(+), 51 deletions(-) create mode 100644 coverlet.runsettings create mode 100644 src/TodoApp.WebApi/DatabaseStartup.cs create mode 100644 tests/TodoApp.IntegrationTests/AuthenticationSetupTests.cs create mode 100644 tests/TodoApp.IntegrationTests/DatabaseStartupTests.cs create mode 100644 tests/TodoApp.IntegrationTests/GlobalExceptionHandlerTests.cs create mode 100644 tests/TodoApp.IntegrationTests/HostConfigurationTests.cs create mode 100644 tests/TodoApp.IntegrationTests/RefreshTokenCookieHelperTests.cs create mode 100644 tests/TodoApp.UnitTests/Auth/AuthEdgeCaseTests.cs create mode 100644 tests/TodoApp.UnitTests/Auth/GetCurrentUserTests.cs create mode 100644 tests/TodoApp.UnitTests/Categories/CategoryHandlerTests.cs create mode 100644 tests/TodoApp.UnitTests/Common/ExceptionTests.cs create mode 100644 tests/TodoApp.UnitTests/Common/InfrastructureRegistrationTests.cs create mode 100644 tests/TodoApp.UnitTests/Common/ValidatorTests.cs create mode 100644 tests/TodoApp.UnitTests/Domain/EntityGuardTests.cs create mode 100644 tests/TodoApp.UnitTests/Security/CurrentUserServiceTests.cs create mode 100644 tests/TodoApp.UnitTests/Security/GoogleTokenValidatorTests.cs create mode 100644 tests/TodoApp.UnitTests/Security/HibpBreachedPasswordCheckerTests.cs create mode 100644 tests/TodoApp.UnitTests/Security/PasswordHasherEdgeCaseTests.cs create mode 100644 tests/TodoApp.UnitTests/Todos/TodoHandlerTests.cs diff --git a/coverlet.runsettings b/coverlet.runsettings new file mode 100644 index 0000000..7d30b35 --- /dev/null +++ b/coverlet.runsettings @@ -0,0 +1,24 @@ + + + + + + + + cobertura,json + + [TodoApp.Domain]*,[TodoApp.Application]*,[TodoApp.Infrastructure]*,[TodoApp.WebApi]* + Obsolete,GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute + true + false + false + + + + + diff --git a/docs/development/testing.md b/docs/development/testing.md index 953905c..df7b8ef 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -19,7 +19,7 @@ the companion [CI/CD pipeline testing guide](../deployment/pipeline.md). | Frontend | `frontend/src/**/*.test.{js,jsx}` | **Vitest** + React Testing Library | Pure helpers, a component, and the `useTodos` hook (optimistic move + error handling). | | API — unit | `tests/TodoApp.UnitTests` | **xUnit** + FluentAssertions | Domain invariants and CQRS handlers (ownership, concurrency, auth/refresh) against real EF Core over in-memory SQLite. | | API — integration | `tests/TodoApp.IntegrationTests` | **xUnit** + `WebApplicationFactory` | The whole API in-process, hit over HTTP end-to-end (register → authorize → call endpoints). | -| API — smoke | `scripts/todoapp-smoketest.ps1` | **PowerShell** (`Invoke-WebRequest`) | Every endpoint over HTTP against a *running* instance — a fast health / regression check (see §3.4). | +| API — smoke | `scripts/todoapp-smoketest.ps1` | **PowerShell** (`Invoke-WebRequest`) | Every endpoint over HTTP against a *running* instance — a fast health / regression check (see §3.5). | The guiding rule on both sides: **tests use the real thing wherever it's cheap.** The API tests run against a genuine EF Core context (in-memory SQLite, not the EF in-memory provider) so query @@ -306,7 +306,33 @@ dotnet test tests/TodoApp.UnitTests # unit only dotnet test tests/TodoApp.IntegrationTests # integration only ``` -### 3.4 End-to-end smoke test — `scripts/todoapp-smoketest.ps1` +### 3.4 Measuring coverage + +Both API test projects reference `coverlet.collector`, and `coverlet.runsettings` at the repository +root configures it: Cobertura **and** coverlet JSON output, only the four `TodoApp.*` assemblies in +scope, and auto-properties skipped (a getter nobody wrote is not a branch anybody needs tested). + +```bash +dotnet test TodoApp.sln --settings coverlet.runsettings +``` + +Each project writes `coverage.cobertura.xml` and `coverage.json` under its own `TestResults//`. + +**Merge the JSON, not the Cobertura, when combining the two projects.** Cobertura records only a +per-line branch *percentage*, which cannot be unioned across runs — a branch the unit suite covers +and a branch the integration suite covers still read as "1 of 2" in both files, so a naive merge +under-reports. Coverlet's JSON keeps every branch as its own record with a hit count, so summing +per `(file, class, method, line, ordinal)` is exact. + +Current state: **99.88% of lines and 100% of branches**. Two lines are not covered, both because +they need a live cloud service rather than because they lack a test: + +| Location | Why | +| -------- | --- | +| `GoogleTokenValidator.ValidateAsync` — the mapping after a successful validation | Verifying a real Google ID token requires Google's live signing keys. The mapping itself is split into `FromPayload` and is tested; the configuration guard and the malformed-token path are tested too. | +| `Program.cs` — the closing brace of the Key Vault block | Only reached when `AddAzureKeyVault` succeeds against a real vault. The opt-in branch is covered both ways, including that a malformed `KeyVault:Uri` stops startup. | + +### 3.5 End-to-end smoke test — `scripts/todoapp-smoketest.ps1` Beyond the automated suites, a PowerShell script hits **every** API endpoint over HTTP against a *running* instance and prints a pass/fail report — the fastest way to confirm the whole API behaves diff --git a/src/TodoApp.Infrastructure/Authentication/GoogleTokenValidator.cs b/src/TodoApp.Infrastructure/Authentication/GoogleTokenValidator.cs index 381be6b..9257efc 100644 --- a/src/TodoApp.Infrastructure/Authentication/GoogleTokenValidator.cs +++ b/src/TodoApp.Infrastructure/Authentication/GoogleTokenValidator.cs @@ -35,15 +35,22 @@ public GoogleTokenValidator(IOptions options) var payload = await GoogleJsonWebSignature.ValidateAsync(idToken, settings); - return new GoogleUserInfo( - payload.Subject, - payload.Email, - payload.EmailVerified, - payload.Name); + return FromPayload(payload); } catch (InvalidJwtException) { return null; } } + + /// + /// Maps an already-verified Google payload onto our own model. Split out from + /// so the mapping is testable without a Google-signed token — + /// validation itself needs Google's live signing keys and cannot run offline. + /// + public static GoogleUserInfo FromPayload(GoogleJsonWebSignature.Payload payload) => new( + payload.Subject, + payload.Email, + payload.EmailVerified, + payload.Name); } diff --git a/src/TodoApp.WebApi/DatabaseStartup.cs b/src/TodoApp.WebApi/DatabaseStartup.cs new file mode 100644 index 0000000..7ec96c9 --- /dev/null +++ b/src/TodoApp.WebApi/DatabaseStartup.cs @@ -0,0 +1,86 @@ +using TodoApp.Application.Common.Interfaces; +using TodoApp.Infrastructure.Persistence; + +namespace TodoApp.WebApi; + +/// +/// Creates and seeds the database at startup, without letting a cold or paused database stop the +/// app from starting. +/// +/// +/// Azure SQL serverless can be waking from auto-pause when the container comes up. Failing +/// startup there turns a slow database into a dead app, so an unreachable database is logged and +/// the initialization is retried off the startup path; requests in the meantime ride out the +/// wake-up via EF's EnableRetryOnFailure. +/// +public static class DatabaseStartup +{ + /// + /// Initializes the database, falling back to a background retry loop if the first attempt + /// fails. + /// + /// + /// null when the database was initialized immediately, otherwise the background retry + /// task. Production discards it — startup must not wait on it — but a test can await it. + /// + public static async Task InitializeAsync( + IServiceProvider services, + DemoSeedOptions demoSeed, + ILogger logger, + TimeSpan retryDelay, + int maxRetryAttempts) + { + try + { + await InitializeOnceAsync(services, demoSeed); + return null; + } + catch (Exception ex) + { + logger.LogWarning(ex, + "Database initialization was deferred at startup (database may be resuming). " + + "It will be retried in the background."); + + return Task.Run(() => RetryAsync( + services, demoSeed, logger, retryDelay, maxRetryAttempts)); + } + } + + private static async Task InitializeOnceAsync(IServiceProvider services, DemoSeedOptions demoSeed) + { + using var scope = services.CreateScope(); + var scoped = scope.ServiceProvider; + + await DbInitializer.InitializeAsync( + scoped.GetRequiredService(), + scoped.GetRequiredService(), + scoped.GetRequiredService(), + demoSeed); + } + + private static async Task RetryAsync( + IServiceProvider services, + DemoSeedOptions demoSeed, + ILogger logger, + TimeSpan retryDelay, + int maxRetryAttempts) + { + for (var attempt = 1; attempt <= maxRetryAttempts; attempt++) + { + await Task.Delay(retryDelay); + + try + { + await InitializeOnceAsync(services, demoSeed); + logger.LogInformation( + "Database initialization completed on background attempt {Attempt}.", attempt); + return; + } + catch (Exception retryEx) + { + logger.LogWarning(retryEx, + "Background database initialization attempt {Attempt} failed.", attempt); + } + } + } +} diff --git a/src/TodoApp.WebApi/Program.cs b/src/TodoApp.WebApi/Program.cs index 80fe1d1..945775f 100644 --- a/src/TodoApp.WebApi/Program.cs +++ b/src/TodoApp.WebApi/Program.cs @@ -181,50 +181,14 @@ await context.HttpContext.Response.WriteAsync( // Create and seed the database on startup — but never let a cold/paused database (e.g. Azure // SQL serverless waking from auto-pause) block the app from starting. If the DB is unreachable // here, we log and carry on; the schema/seed is retried in the background until it succeeds, and -// requests ride out the wake-up via EF's EnableRetryOnFailure. -using (var scope = app.Services.CreateScope()) -{ - var services = scope.ServiceProvider; - var startupLogger = services.GetRequiredService>(); - try - { - var context = services.GetRequiredService(); - var passwordHasher = services.GetRequiredService(); - var dateTime = services.GetRequiredService(); - await DbInitializer.InitializeAsync(context, passwordHasher, dateTime, demoSeed); - } - catch (Exception ex) - { - startupLogger.LogWarning(ex, - "Database initialization was deferred at startup (database may be resuming). " + - "It will be retried in the background."); - - // Retry the initialization off the startup path so the app can start serving immediately. - _ = Task.Run(async () => - { - for (var attempt = 1; attempt <= 10; attempt++) - { - await Task.Delay(TimeSpan.FromSeconds(15)); - try - { - using var retryScope = app.Services.CreateScope(); - var rs = retryScope.ServiceProvider; - await DbInitializer.InitializeAsync( - rs.GetRequiredService(), - rs.GetRequiredService(), - rs.GetRequiredService(), - demoSeed); - startupLogger.LogInformation("Database initialization completed on background attempt {Attempt}.", attempt); - break; - } - catch (Exception retryEx) - { - startupLogger.LogWarning(retryEx, "Background database initialization attempt {Attempt} failed.", attempt); - } - } - }); - } -} +// requests ride out the wake-up via EF's EnableRetryOnFailure. The returned background task is +// deliberately not awaited: startup must not wait on it. +_ = await DatabaseStartup.InitializeAsync( + app.Services, + demoSeed, + app.Services.GetRequiredService>(), + retryDelay: TimeSpan.FromSeconds(15), + maxRetryAttempts: 10); app.UseExceptionHandler(); diff --git a/tests/TodoApp.IntegrationTests/AuthenticationSetupTests.cs b/tests/TodoApp.IntegrationTests/AuthenticationSetupTests.cs new file mode 100644 index 0000000..8857131 --- /dev/null +++ b/tests/TodoApp.IntegrationTests/AuthenticationSetupTests.cs @@ -0,0 +1,68 @@ +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TodoApp.WebApi.Authentication; +using Xunit; + +namespace TodoApp.IntegrationTests; + +/// +/// The signing-key guard. A missing or too-short key must stop the app at startup rather than +/// let it run with authentication that cannot be trusted. +/// +public class AuthenticationSetupTests +{ + private static IServiceCollection Register(string? key) + { + var settings = new Dictionary { ["Jwt:Issuer"] = "TodoApp" }; + if (key is not null) + { + settings["Jwt:Key"] = key; + } + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + + return new ServiceCollection().AddJwtAuthentication(configuration); + } + + [Fact] + public void NoJwtSectionAtAll_StopsStartup() + { + var configuration = new ConfigurationBuilder().Build(); + + var act = () => new ServiceCollection().AddJwtAuthentication(configuration); + + act.Should().Throw().WithMessage("*at least 32 bytes*"); + } + + [Theory] + [InlineData(null)] // the section exists but carries no key + [InlineData("")] // present but empty + [InlineData(" ")] // whitespace + [InlineData("too-short")] // under 256 bits + [InlineData("31-bytes-is-still-one-too-few!!")] + public void AWeakOrMissingKey_StopsStartup(string? key) + { + var act = () => Register(key); + + act.Should().Throw().WithMessage("*at least 32 bytes*"); + } + + [Fact] + public void AKeyOfExactlyTheMinimumLengthIsAccepted() + { + var key = new string('k', 32); // 32 ASCII characters == 32 bytes == 256 bits + + var act = () => Register(key); + + act.Should().NotThrow(); + } + + [Fact] + public void AValidKeyRegistersAuthentication() + { + var services = Register("a-perfectly-adequate-signing-key-of-sufficient-length"); + + services.Should().NotBeEmpty(); + } +} diff --git a/tests/TodoApp.IntegrationTests/DatabaseStartupTests.cs b/tests/TodoApp.IntegrationTests/DatabaseStartupTests.cs new file mode 100644 index 0000000..4848fe1 --- /dev/null +++ b/tests/TodoApp.IntegrationTests/DatabaseStartupTests.cs @@ -0,0 +1,130 @@ +using FluentAssertions; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using TodoApp.Application.Common.Interfaces; +using TodoApp.Infrastructure.Authentication; +using TodoApp.Infrastructure.Persistence; +using TodoApp.Infrastructure.Time; +using TodoApp.WebApi; +using Xunit; + +namespace TodoApp.IntegrationTests; + +/// +/// Startup must survive a database that is not ready yet — an Azure SQL serverless instance +/// waking from auto-pause is the case this exists for. A failure here has to defer, not kill the +/// app, and the retry has to actually recover once the database comes back. +/// +public class DatabaseStartupTests : IDisposable +{ + private readonly SqliteConnection _connection = new("DataSource=:memory:"); + private readonly DatabaseSwitch _database = new(); + + public DatabaseStartupTests() => _connection.Open(); + + private ServiceProvider BuildServices() + { + var services = new ServiceCollection(); + services.AddSingleton(_database); + services.AddSingleton(); + services.AddSingleton(); + services.AddScoped(_ => + { + if (!_database.IsReachable) + { + throw new InvalidOperationException("the database is still waking up"); + } + + return new ApplicationDbContext( + new DbContextOptionsBuilder().UseSqlite(_connection).Options); + }); + + return services.BuildServiceProvider(); + } + + private bool SchemaExists() + { + using var command = _connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='Users';"; + return Convert.ToInt32(command.ExecuteScalar()) > 0; + } + + [Fact] + public async Task AReachableDatabaseIsInitializedInlineWithNoBackgroundWork() + { + using var services = BuildServices(); + + var background = await DatabaseStartup.InitializeAsync( + services, new DemoSeedOptions(), NullLogger.Instance, + retryDelay: TimeSpan.FromMilliseconds(10), maxRetryAttempts: 1); + + background.Should().BeNull(); // nothing was deferred + SchemaExists().Should().BeTrue(); + } + + [Fact] + public async Task TheDemoSeedIsAppliedWhenExplicitlyEnabled() + { + using var services = BuildServices(); + + await DatabaseStartup.InitializeAsync( + services, + new DemoSeedOptions { DemoUser = true, Email = "demo@todoapp.local", Password = "Password123!" }, + NullLogger.Instance, + retryDelay: TimeSpan.FromMilliseconds(10), + maxRetryAttempts: 1); + + using var scope = services.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + (await context.Users.AnyAsync(u => u.Email == "demo@todoapp.local")).Should().BeTrue(); + } + + [Fact] + public async Task AnUnreachableDatabaseDefersInsteadOfFailingStartup() + { + using var services = BuildServices(); + _database.IsReachable = false; + + var background = await DatabaseStartup.InitializeAsync( + services, new DemoSeedOptions(), NullLogger.Instance, + retryDelay: TimeSpan.FromMilliseconds(10), + maxRetryAttempts: 10); + + // Startup got past it: the caller holds a task, not an exception. + background.Should().NotBeNull(); + + _database.IsReachable = true; + await background!; + + SchemaExists().Should().BeTrue(); + } + + [Fact] + public async Task TheBackgroundRetryGivesUpQuietlyWhenTheDatabaseNeverComesBack() + { + using var services = BuildServices(); + _database.IsReachable = false; + + var background = await DatabaseStartup.InitializeAsync( + services, new DemoSeedOptions(), NullLogger.Instance, + retryDelay: TimeSpan.FromMilliseconds(1), + maxRetryAttempts: 2); + + // Exhausting the attempts must not throw on a background thread — that would take the + // process down rather than leave a logged, still-serving app. + var act = async () => await background!; + + await act.Should().NotThrowAsync(); + SchemaExists().Should().BeFalse(); + } + + public void Dispose() => _connection.Dispose(); + + /// Stands in for a database that is paused and later wakes up. + private sealed class DatabaseSwitch + { + public bool IsReachable { get; set; } = true; + } +} diff --git a/tests/TodoApp.IntegrationTests/GlobalExceptionHandlerTests.cs b/tests/TodoApp.IntegrationTests/GlobalExceptionHandlerTests.cs new file mode 100644 index 0000000..c1c5aee --- /dev/null +++ b/tests/TodoApp.IntegrationTests/GlobalExceptionHandlerTests.cs @@ -0,0 +1,138 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using TodoApp.Application.Common.Exceptions; +using TodoApp.WebApi; +using Xunit; + +namespace TodoApp.IntegrationTests; + +/// +/// Every application exception has to come out as an RFC 7807 problem with the right status. +/// This is the API's error contract, so each arm of the mapping is pinned down directly rather +/// than only through whichever endpoints happen to raise it. +/// +public class GlobalExceptionHandlerTests +{ + private static async Task<(bool handled, HttpContext context, ProblemDetails problem)> HandleAsync( + Exception exception) + { + var problemDetailsService = new CapturingProblemDetailsService(); + var handler = new GlobalExceptionHandler( + problemDetailsService, NullLogger.Instance); + + var context = new DefaultHttpContext(); + context.Request.Path = "/api/todos/1"; + + var handled = await handler.TryHandleAsync(context, exception, CancellationToken.None); + + problemDetailsService.Captured.Should().NotBeNull(); + return (handled, context, problemDetailsService.Captured!); + } + + [Fact] + public async Task ValidationFailures_Become400WithTheFieldErrors() + { + var errors = new[] + { + new FluentValidation.Results.ValidationFailure("Email", "Email is required.") + }; + + var (handled, context, problem) = await HandleAsync(new ValidationException(errors)); + + handled.Should().BeTrue(); + context.Response.StatusCode.Should().Be(StatusCodes.Status400BadRequest); + problem.Should().BeOfType() + .Which.Errors.Should().ContainKey("Email"); + } + + [Fact] + public async Task NotFound_Becomes404() + { + var (_, context, problem) = await HandleAsync(new NotFoundException("TodoItem", 1)); + + context.Response.StatusCode.Should().Be(StatusCodes.Status404NotFound); + problem.Title.Should().Be("Resource not found."); + problem.Detail.Should().Contain("TodoItem"); + } + + [Fact] + public async Task Unauthorized_Becomes401() + { + var (_, context, problem) = await HandleAsync(new UnauthorizedException("Invalid token.")); + + context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + problem.Title.Should().Be("Authentication failed."); + problem.Detail.Should().Be("Invalid token."); + } + + [Fact] + public async Task ForbiddenAccess_Becomes403() + { + var (_, context, problem) = await HandleAsync( + new ForbiddenAccessException("Not your session.")); + + context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + problem.Title.Should().Be("Access denied."); + problem.Detail.Should().Be("Not your session."); + } + + [Fact] + public async Task Conflict_Becomes409() + { + var (_, context, problem) = await HandleAsync(new ConflictException("Already exists.")); + + context.Response.StatusCode.Should().Be(StatusCodes.Status409Conflict); + problem.Title.Should().Be("Request conflict."); + } + + [Fact] + public async Task ConcurrencyConflict_Becomes409CarryingTheCurrentServerState() + { + var current = new { Id = 1, Title = "Server wins" }; + + var (_, context, problem) = await HandleAsync( + new ConcurrencyConflictException("Stale.", current)); + + context.Response.StatusCode.Should().Be(StatusCodes.Status409Conflict); + problem.Extensions.Should().ContainKey("current"); + problem.Extensions["current"].Should().BeSameAs(current); + } + + [Fact] + public async Task ConcurrencyConflict_WithNoServerState_OmitsTheExtension() + { + var (_, _, problem) = await HandleAsync(new ConcurrencyConflictException("Stale.", null)); + + problem.Extensions.Should().NotContainKey("current"); + } + + [Fact] + public async Task AnUnexpectedException_Becomes500WithoutLeakingDetail() + { + var (_, context, problem) = await HandleAsync( + new InvalidOperationException("connection string user=sa;password=hunter2")); + + context.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError); + problem.Title.Should().Be("An unexpected error occurred."); + problem.Detail.Should().BeNull(); // the internal message never reaches the client + } + + private sealed class CapturingProblemDetailsService : IProblemDetailsService + { + public ProblemDetails? Captured { get; private set; } + + public ValueTask TryWriteAsync(ProblemDetailsContext context) + { + Captured = context.ProblemDetails; + return ValueTask.FromResult(true); + } + + public ValueTask WriteAsync(ProblemDetailsContext context) + { + Captured = context.ProblemDetails; + return ValueTask.CompletedTask; + } + } +} diff --git a/tests/TodoApp.IntegrationTests/HostConfigurationTests.cs b/tests/TodoApp.IntegrationTests/HostConfigurationTests.cs new file mode 100644 index 0000000..18e7a99 --- /dev/null +++ b/tests/TodoApp.IntegrationTests/HostConfigurationTests.cs @@ -0,0 +1,171 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace TodoApp.IntegrationTests; + +/// +/// Hosts the API as a deployed instance would see it: the Production environment (no Swagger, +/// HSTS and HTTPS redirection on, a health-style root) rather than the Development defaults the +/// rest of the suite runs under. +/// +public sealed class ProductionWebApplicationFactory : CustomWebApplicationFactory +{ + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment(Environments.Production); + base.ConfigureWebHost(builder); + } +} + +public class ProductionHostTests : IClassFixture +{ + private readonly ProductionWebApplicationFactory _factory; + + public ProductionHostTests(ProductionWebApplicationFactory factory) => _factory = factory; + + [Fact] + public async Task TheRootReportsHealthInsteadOfRedirectingToSwagger() + { + var response = await _factory.CreateClient().GetAsync("/"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + var body = await response.Content.ReadFromJsonAsync(); + body!.Status.Should().Be("ok"); + } + + [Fact] + public async Task SwaggerIsNotServedOutsideDevelopment() + { + var response = await _factory.CreateClient().GetAsync("/swagger/index.html"); + + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task TheApiStillWorks() + { + var client = _factory.CreateClient(); + + var registered = await client.RegisterAsync(); + + registered.AccessToken.Should().NotBeNullOrWhiteSpace(); + } + + private sealed record StatusResponse(string Status); +} + +/// +/// Hosts the API with no appsettings file at all, so every configuration section falls back to +/// the defaults compiled into Program.cs. That fallback path is what a deployment gets when a +/// config file fails to ship, and it should still produce a working app. +/// +public sealed class BareConfigurationWebApplicationFactory : CustomWebApplicationFactory +{ + private readonly string _emptyContentRoot = + Directory.CreateTempSubdirectory("todoapp-bare-config-").FullName; + + // Nothing: the point of this host is that it reads no configuration of its own. + protected override IEnumerable> TestConfiguration => []; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment(Environments.Production); + builder.UseContentRoot(_emptyContentRoot); + base.ConfigureWebHost(builder); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + Directory.Delete(_emptyContentRoot, recursive: true); + } + } +} + +public class BareConfigurationHostTests : IClassFixture +{ + private readonly BareConfigurationWebApplicationFactory _factory; + + public BareConfigurationHostTests(BareConfigurationWebApplicationFactory factory) + => _factory = factory; + + [Fact] + public async Task TheAppStartsAndServesOnCompiledInDefaults() + { + var response = await _factory.CreateClient().GetAsync("/"); + + response.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task RegistrationWorksWithoutAnyConfiguredSections() + { + var client = _factory.CreateClient(); + + var registered = await client.RegisterAsync(); + + registered.AccessToken.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task NoDemoUserIsSeededWhenNothingAsksForOne() + { + var client = _factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/auth/login", + new { email = "demo@todoapp.local", password = "Password123!" }); + + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} + +/// +/// The optional Azure Key Vault configuration source. It is opt-in on KeyVault:Uri, and a +/// URI that is set but unusable has to stop startup — an app that quietly ignores a typo'd vault +/// would come up without the secrets it is supposed to be reading from it. +/// +public class KeyVaultConfigurationTests +{ + private sealed class KeyVaultFactory : CustomWebApplicationFactory + { + private readonly string _uri; + + public KeyVaultFactory(string uri) => _uri = uri; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseSetting("KeyVault:Uri", _uri); + base.ConfigureWebHost(builder); + } + } + + [Fact] + public void AMalformedVaultUriStopsStartup() + { + using var factory = new KeyVaultFactory("not a vault uri"); + + var act = () => factory.CreateClient(); + + act.Should().Throw(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task NoVaultUriMeansNoAzureCallAtAll(string uri) + { + using var factory = new KeyVaultFactory(uri); + + var response = await factory.CreateClient().GetAsync("/api/todos"); + + // The app started and served a request without ever reaching for a vault. + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} diff --git a/tests/TodoApp.IntegrationTests/RefreshTokenCookieHelperTests.cs b/tests/TodoApp.IntegrationTests/RefreshTokenCookieHelperTests.cs new file mode 100644 index 0000000..7e9b8d4 --- /dev/null +++ b/tests/TodoApp.IntegrationTests/RefreshTokenCookieHelperTests.cs @@ -0,0 +1,176 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using TodoApp.WebApi.Authentication; +using Xunit; + +namespace TodoApp.IntegrationTests; + +/// +/// The cookie helper decides where a refresh token may come from and whether the CSRF proof is +/// present. Both are security decisions, so every input shape is pinned down here. +/// +public class RefreshTokenCookieHelperTests +{ + private static HttpRequest RequestWith(string? cookieValue = null, string? csrfHeader = null) + { + var context = new DefaultHttpContext(); + + if (cookieValue is not null) + { + SetCookies(context, RefreshTokenCookie.Name, cookieValue); + } + + if (csrfHeader is not null) + { + context.Request.Headers[RefreshTokenCookie.CsrfHeaderName] = csrfHeader; + } + + return context.Request; + } + + // Installed as a feature rather than assigned to Request.Cookies: that setter re-serializes + // into a Cookie header and rejects a blank or whitespace value, which would make the + // "cookie present but blank" case — a real thing a browser can send — impossible to test. + private static void SetCookies(HttpContext context, string name, string value) => + context.Features.Set(new StubCookiesFeature(new StubCookies(name, value))); + + private sealed class StubCookiesFeature : IRequestCookiesFeature + { + public StubCookiesFeature(IRequestCookieCollection cookies) => Cookies = cookies; + + public IRequestCookieCollection Cookies { get; set; } + } + + private sealed class StubCookies : IRequestCookieCollection + { + private readonly Dictionary _cookies; + + public StubCookies(string name, string value) => + _cookies = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [name] = value + }; + + public string? this[string key] => _cookies.TryGetValue(key, out var v) ? v : null; + + public int Count => _cookies.Count; + + public ICollection Keys => _cookies.Keys; + + public bool ContainsKey(string key) => _cookies.ContainsKey(key); + + public bool TryGetValue(string key, [System.Diagnostics.CodeAnalysis.MaybeNullWhen(false)] out string value) + => _cookies.TryGetValue(key, out value!); + + public IEnumerator> GetEnumerator() => _cookies.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } + + [Fact] + public void Read_PrefersTheBodyValue() + { + RefreshTokenCookie.Read(RequestWith(cookieValue: "from-cookie"), "from-body") + .Should().Be("from-body"); + } + + [Fact] + public void Read_FallsBackToTheCookie() + { + RefreshTokenCookie.Read(RequestWith(cookieValue: "from-cookie"), fromBody: null) + .Should().Be("from-cookie"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Read_TreatsABlankBodyValueAsAbsent(string fromBody) + { + RefreshTokenCookie.Read(RequestWith(cookieValue: "from-cookie"), fromBody) + .Should().Be("from-cookie"); + } + + [Fact] + public void Read_WithNoCookieAndNoBody_IsNull() + { + RefreshTokenCookie.Read(RequestWith(), fromBody: null).Should().BeNull(); + } + + [Fact] + public void Read_WithADifferentCookieButNotOurs_IsNull() + { + var context = new DefaultHttpContext(); + SetCookies(context, "some_other_cookie", "value"); + + RefreshTokenCookie.Read(context.Request, fromBody: null).Should().BeNull(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Read_TreatsABlankCookieAsAbsent(string cookieValue) + { + RefreshTokenCookie.Read(RequestWith(cookieValue: cookieValue), fromBody: null) + .Should().BeNull(); + } + + [Fact] + public void Csrf_IsSatisfiedByABodySuppliedToken() + { + // Whoever set the body already had script execution on an allowed origin. + RefreshTokenCookie.CsrfSatisfied(RequestWith(), "from-body").Should().BeTrue(); + } + + [Fact] + public void Csrf_IsSatisfiedByThePresenceOfTheHeader() + { + // The header's presence is the proof, not its value: a cross-site form post cannot set it. + RefreshTokenCookie.CsrfSatisfied(RequestWith(csrfHeader: "1"), fromBody: null) + .Should().BeTrue(); + } + + [Fact] + public void Csrf_IsNotSatisfiedByACookieAlone() + { + RefreshTokenCookie.CsrfSatisfied(RequestWith(cookieValue: "from-cookie"), fromBody: null) + .Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Csrf_IsNotSatisfiedByABlankHeader(string header) + { + RefreshTokenCookie.CsrfSatisfied(RequestWith(csrfHeader: header), fromBody: null) + .Should().BeFalse(); + } + + [Fact] + public void Write_SetsAnHttpOnlyCrossSiteCookieScopedToTheAuthEndpoints() + { + var context = new DefaultHttpContext(); + var expiry = new DateTimeOffset(2026, 3, 1, 0, 0, 0, TimeSpan.Zero); + + RefreshTokenCookie.Write(context.Response, "the-token", expiry); + + var setCookie = context.Response.Headers.SetCookie.ToString(); + setCookie.Should().Contain($"{RefreshTokenCookie.Name}=the-token"); + setCookie.Should().Contain("httponly"); + setCookie.Should().Contain("secure"); + setCookie.Should().Contain("samesite=none"); + setCookie.Should().Contain("path=/api/auth"); + } + + [Fact] + public void Clear_ExpiresTheCookie() + { + var context = new DefaultHttpContext(); + + RefreshTokenCookie.Clear(context.Response); + + var setCookie = context.Response.Headers.SetCookie.ToString(); + setCookie.Should().Contain($"{RefreshTokenCookie.Name}="); + setCookie.Should().Contain("expires=Thu, 01 Jan 1970"); + } +} diff --git a/tests/TodoApp.IntegrationTests/TodoApp.IntegrationTests.csproj b/tests/TodoApp.IntegrationTests/TodoApp.IntegrationTests.csproj index 9ad4a57..12b807c 100644 --- a/tests/TodoApp.IntegrationTests/TodoApp.IntegrationTests.csproj +++ b/tests/TodoApp.IntegrationTests/TodoApp.IntegrationTests.csproj @@ -9,6 +9,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/tests/TodoApp.IntegrationTests/packages.lock.json b/tests/TodoApp.IntegrationTests/packages.lock.json index f6dc36e..0e9d48a 100644 --- a/tests/TodoApp.IntegrationTests/packages.lock.json +++ b/tests/TodoApp.IntegrationTests/packages.lock.json @@ -2,6 +2,12 @@ "version": 1, "dependencies": { "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "27jXSV/0DbVqF5jDrAxuQFZ9oaz6gmG03p8ttxAFk+X0M4woFYj7MoWDLCna5EGLb0CE6OE7X6ZH3Wt5smTtaA==" + }, "FluentAssertions": { "type": "Direct", "requested": "[6.12.2, )", diff --git a/tests/TodoApp.UnitTests/Auth/AuthEdgeCaseTests.cs b/tests/TodoApp.UnitTests/Auth/AuthEdgeCaseTests.cs new file mode 100644 index 0000000..acd253c --- /dev/null +++ b/tests/TodoApp.UnitTests/Auth/AuthEdgeCaseTests.cs @@ -0,0 +1,408 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using TodoApp.Application.Auth.Commands.GoogleSignIn; +using TodoApp.Application.Auth.Commands.Login; +using TodoApp.Application.Auth.Commands.RefreshToken; +using TodoApp.Application.Auth.Commands.Register; +using TodoApp.Application.Auth.Commands.RevokeAllTokens; +using TodoApp.Application.Auth.Commands.RevokeToken; +using TodoApp.Application.Common.Exceptions; +using TodoApp.Application.Common.Models; +using TodoApp.Domain.Entities; +using TodoApp.Infrastructure.Authentication; +using TodoApp.UnitTests.TestSupport; +using Xunit; +using DomainRefreshToken = TodoApp.Domain.Entities.RefreshToken; + +namespace TodoApp.UnitTests.Auth; + +/// +/// The rejection and recovery paths of the auth handlers: disabled accounts, replayed or +/// expired refresh tokens, and logout attempts against tokens the caller does not own. +/// +public class AuthEdgeCaseTests +{ + private readonly FakeJwtTokenService _jwt = new(); + private readonly FakeDateTimeProvider _clock = new(); + + private User SeedUser(TestDatabase db, string email = "edge@example.com") + { + var user = new User(email, "hash", _clock.UtcNow); + db.Context.Users.Add(user); + db.Context.SaveChanges(); + return user; + } + + private (string raw, DomainRefreshToken entity) AddToken( + TestDatabase db, int userId, DateTimeOffset? expiresAt = null) + { + var created = _jwt.CreateRefreshToken(); + var entity = new DomainRefreshToken( + userId, created.TokenHash, expiresAt ?? created.ExpiresAt, _clock.UtcNow); + db.Context.RefreshTokens.Add(entity); + db.Context.SaveChanges(); + return (created.RawToken, entity); + } + + // ---- Refresh ------------------------------------------------------------------- + + [Fact] + public async Task Refresh_WithUnknownToken_ThrowsUnauthorized() + { + using var db = new TestDatabase(); + var handler = new RefreshTokenCommandHandler(db.NewContext(), _jwt, _clock); + + var act = () => handler.Handle( + new RefreshTokenCommand { RefreshToken = "never-issued" }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Refresh_WhenTheOwningUserIsGone_ThrowsUnauthorized() + { + using var db = new TestDatabase(); + db.DisableForeignKeyEnforcement(); + var (raw, _) = AddToken(db, userId: 9999); // no matching Users row + var handler = new RefreshTokenCommandHandler(db.NewContext(), _jwt, _clock); + + var act = () => handler.Handle( + new RefreshTokenCommand { RefreshToken = raw }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Refresh_ForADeactivatedUser_ThrowsUnauthorized() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var (raw, _) = AddToken(db, user.Id); + + user.Deactivate(_clock.UtcNow); + db.Context.SaveChanges(); + + var handler = new RefreshTokenCommandHandler(db.NewContext(), _jwt, _clock); + var act = () => handler.Handle( + new RefreshTokenCommand { RefreshToken = raw }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Refresh_WithExpiredToken_RevokesItSoAReplayIsDetectable() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var (raw, entity) = AddToken(db, user.Id, expiresAt: _clock.UtcNow.AddMinutes(-1)); + + var handler = new RefreshTokenCommandHandler(db.NewContext(), _jwt, _clock); + var act = () => handler.Handle( + new RefreshTokenCommand { RefreshToken = raw }, CancellationToken.None); + + await act.Should().ThrowAsync(); + + // Left active, an expired row would never trip the reuse detection (review finding L7). + using var read = db.NewContext(); + var stored = await read.RefreshTokens.SingleAsync(t => t.Id == entity.Id); + stored.RevokedAt.Should().NotBeNull(); + stored.RevokedReason.Should().Be("Expired"); + } + + // ---- Revoke (logout) ----------------------------------------------------------- + + [Fact] + public async Task Revoke_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new RevokeTokenCommandHandler( + db.NewContext(), _jwt, new FakeCurrentUserService(), _clock); + + var act = () => handler.Handle( + new RevokeTokenCommand { RefreshToken = "anything" }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task Revoke_WithBlankToken_IsANoOp(string token) + { + using var db = new TestDatabase(); + var user = SeedUser(db); + AddToken(db, user.Id); + + var handler = new RevokeTokenCommandHandler( + db.NewContext(), _jwt, new FakeCurrentUserService { UserId = user.Id }, _clock); + + await handler.Handle(new RevokeTokenCommand { RefreshToken = token }, CancellationToken.None); + + using var read = db.NewContext(); + (await read.RefreshTokens.CountAsync(t => t.RevokedAt == null)).Should().Be(1); + } + + [Fact] + public async Task Revoke_WithAnUnknownToken_IsASilentNoOp() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + + var handler = new RevokeTokenCommandHandler( + db.NewContext(), _jwt, new FakeCurrentUserService { UserId = user.Id }, _clock); + + // Must not reveal whether the token exists. + await handler.Handle( + new RevokeTokenCommand { RefreshToken = "never-issued" }, CancellationToken.None); + } + + [Fact] + public async Task Revoke_DoesNotTouchAnotherUsersToken() + { + using var db = new TestDatabase(); + var me = SeedUser(db); + var other = SeedUser(db, "other@example.com"); + var (theirRaw, theirToken) = AddToken(db, other.Id); + + var handler = new RevokeTokenCommandHandler( + db.NewContext(), _jwt, new FakeCurrentUserService { UserId = me.Id }, _clock); + + await handler.Handle(new RevokeTokenCommand { RefreshToken = theirRaw }, CancellationToken.None); + + using var read = db.NewContext(); + (await read.RefreshTokens.SingleAsync(t => t.Id == theirToken.Id)).RevokedAt.Should().BeNull(); + } + + [Fact] + public async Task Revoke_RevokesTheCallersOwnActiveToken() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var (raw, entity) = AddToken(db, user.Id); + + var handler = new RevokeTokenCommandHandler( + db.NewContext(), _jwt, new FakeCurrentUserService { UserId = user.Id }, _clock); + + await handler.Handle(new RevokeTokenCommand { RefreshToken = raw }, CancellationToken.None); + + using var read = db.NewContext(); + var stored = await read.RefreshTokens.SingleAsync(t => t.Id == entity.Id); + stored.RevokedReason.Should().Be("Logout"); + } + + [Fact] + public async Task Revoke_OnAnAlreadyRevokedToken_LeavesTheOriginalReason() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var (raw, entity) = AddToken(db, user.Id); + entity.Revoke("Rotated", _clock.UtcNow); + db.Context.SaveChanges(); + + var handler = new RevokeTokenCommandHandler( + db.NewContext(), _jwt, new FakeCurrentUserService { UserId = user.Id }, _clock); + + await handler.Handle(new RevokeTokenCommand { RefreshToken = raw }, CancellationToken.None); + + using var read = db.NewContext(); + (await read.RefreshTokens.SingleAsync(t => t.Id == entity.Id)) + .RevokedReason.Should().Be("Rotated"); + } + + // ---- Login --------------------------------------------------------------------- + + [Fact] + public async Task Login_ForADeactivatedAccount_ThrowsUnauthorized() + { + using var db = new TestDatabase(); + var hasher = new PasswordHasher(); + var user = new User("disabled@example.com", hasher.Hash("Password1"), _clock.UtcNow); + user.Deactivate(_clock.UtcNow); + db.Context.Users.Add(user); + db.Context.SaveChanges(); + + var handler = new LoginCommandHandler(db.NewContext(), hasher, _jwt, _clock); + var act = () => handler.Handle( + new LoginCommand { Email = "disabled@example.com", Password = "Password1" }, + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("This account has been disabled."); + } + + [Fact] + public async Task Register_WhenTheInsertIsRejected_ThrowsConflictRatherThan500() + { + using var db = new TestDatabase(); + + // The pre-check passes, then a concurrent request takes the email before this one saves. + // A pending row the database will reject reproduces the same DbUpdateException. + var context = db.NewContext(); + context.ExternalLogins.Add(new ExternalLogin(9999, "Google", "sub-poison", _clock.UtcNow)); + + var handler = new RegisterCommandHandler( + context, new PasswordHasher(), _jwt, _clock, new FakeBreachedPasswordChecker()); + + var act = () => handler.Handle( + new RegisterCommand { Email = "racer@example.com", Password = "Password1" }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + // ---- Revoke all ---------------------------------------------------------------- + + [Fact] + public async Task RevokeAll_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new RevokeAllTokensCommandHandler( + db.NewContext(), new FakeCurrentUserService(), _clock); + + var act = () => handler.Handle(new RevokeAllTokensCommand(), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RevokeAll_ForAUserThatDoesNotExist_ThrowsNotFound() + { + using var db = new TestDatabase(); + var admin = SeedUser(db, "admin@example.com"); + + var handler = new RevokeAllTokensCommandHandler( + db.NewContext(), + new FakeCurrentUserService { UserId = admin.Id, Role = "Admin" }, + _clock); + + var act = () => handler.Handle( + new RevokeAllTokensCommand { UserId = 4242 }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task RevokeAll_AsAdmin_KillsAnotherUsersSessions() + { + using var db = new TestDatabase(); + var admin = SeedUser(db, "admin@example.com"); + var target = SeedUser(db, "target@example.com"); + AddToken(db, target.Id); + var originalStamp = target.SecurityStamp; + + var handler = new RevokeAllTokensCommandHandler( + db.NewContext(), + new FakeCurrentUserService { UserId = admin.Id, Role = "Admin" }, + _clock); + + await handler.Handle(new RevokeAllTokensCommand { UserId = target.Id }, CancellationToken.None); + + using var read = db.NewContext(); + (await read.RefreshTokens.CountAsync(t => t.UserId == target.Id && t.RevokedAt == null)) + .Should().Be(0); + (await read.Users.SingleAsync(u => u.Id == target.Id)) + .SecurityStamp.Should().NotBe(originalStamp); + } + + // ---- Google sign-in ------------------------------------------------------------ + + [Fact] + public async Task Google_ForADeactivatedAccount_ThrowsUnauthorized() + { + using var db = new TestDatabase(); + var user = new User("disabled@example.com", "hash", _clock.UtcNow); + user.Deactivate(_clock.UtcNow); + db.Context.Users.Add(user); + db.Context.SaveChanges(); + + var handler = new GoogleSignInCommandHandler( + db.NewContext(), + new FakeGoogleTokenValidator + { + Result = new GoogleUserInfo("sub-disabled", "disabled@example.com", true, null) + }, + _jwt, + _clock); + + var act = () => handler.Handle(new GoogleSignInCommand { IdToken = "t" }, CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("This account has been disabled."); + } + + [Fact] + public async Task Google_WithAnAlreadyLinkedAccount_SignsInWithoutRelinking() + { + using var db = new TestDatabase(); + var user = SeedUser(db, "linked@example.com"); + db.Context.ExternalLogins.Add(new ExternalLogin(user.Id, "Google", "sub-linked", _clock.UtcNow)); + db.Context.SaveChanges(); + + var handler = new GoogleSignInCommandHandler( + db.NewContext(), + new FakeGoogleTokenValidator + { + Result = new GoogleUserInfo("sub-linked", "linked@example.com", true, null) + }, + _jwt, + _clock); + + var response = await handler.Handle( + new GoogleSignInCommand { IdToken = "t" }, CancellationToken.None); + + response.User.Id.Should().Be(user.Id); + + using var read = db.NewContext(); + (await read.ExternalLogins.CountAsync()).Should().Be(1); + (await read.Users.CountAsync()).Should().Be(1); + } + + [Fact] + public async Task Google_WhenTheLinkedUserRowIsGone_ThrowsUnauthorized() + { + using var db = new TestDatabase(); + db.DisableForeignKeyEnforcement(); + // An orphaned link: the ExternalLogin row survives but its user does not. + db.Context.ExternalLogins.Add(new ExternalLogin(9999, "Google", "sub-orphan", _clock.UtcNow)); + db.Context.SaveChanges(); + + var handler = new GoogleSignInCommandHandler( + db.NewContext(), + new FakeGoogleTokenValidator + { + Result = new GoogleUserInfo("sub-orphan", "orphan@example.com", true, null) + }, + _jwt, + _clock); + + var act = () => handler.Handle(new GoogleSignInCommand { IdToken = "t" }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Google_WhenTheInsertIsRejected_ThrowsConflictRatherThan500() + { + using var db = new TestDatabase(); + + // Stand in for the concurrent first-time sign-in that grabs the same (Provider, + // ProviderKey) or Email a moment before this one saves: a pending row the database + // will reject, so the handler's SaveChanges raises DbUpdateException exactly as the + // unique-index violation would. + var context = db.NewContext(); + context.ExternalLogins.Add(new ExternalLogin(9999, "Google", "sub-poison", _clock.UtcNow)); + + var handler = new GoogleSignInCommandHandler( + context, + new FakeGoogleTokenValidator + { + Result = new GoogleUserInfo("sub-race", "racer@example.com", true, null) + }, + _jwt, + _clock); + + var act = () => handler.Handle(new GoogleSignInCommand { IdToken = "t" }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/TodoApp.UnitTests/Auth/GetCurrentUserTests.cs b/tests/TodoApp.UnitTests/Auth/GetCurrentUserTests.cs new file mode 100644 index 0000000..16179de --- /dev/null +++ b/tests/TodoApp.UnitTests/Auth/GetCurrentUserTests.cs @@ -0,0 +1,56 @@ +using FluentAssertions; +using TodoApp.Application.Auth.Queries.GetCurrentUser; +using TodoApp.Application.Common.Exceptions; +using TodoApp.Domain.Entities; +using TodoApp.Domain.Enums; +using TodoApp.UnitTests.TestSupport; +using Xunit; + +namespace TodoApp.UnitTests.Auth; + +public class GetCurrentUserTests +{ + private readonly FakeDateTimeProvider _clock = new(); + + [Fact] + public async Task Handle_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new GetCurrentUserQueryHandler(db.NewContext(), new FakeCurrentUserService()); + + var act = () => handler.Handle(new GetCurrentUserQuery(), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_WhenTheUserRowIsGone_Throws() + { + using var db = new TestDatabase(); + // A token can outlive the row it names — deleting the account must not 500. + var handler = new GetCurrentUserQueryHandler( + db.NewContext(), new FakeCurrentUserService { UserId = 4242 }); + + var act = () => handler.Handle(new GetCurrentUserQuery(), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Handle_ReturnsTheCallersOwnProfile() + { + using var db = new TestDatabase(); + var user = new User("Me@Example.com", "hash", _clock.UtcNow, UserRole.Admin); + db.Context.Users.Add(user); + db.Context.SaveChanges(); + + var handler = new GetCurrentUserQueryHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }); + + var dto = await handler.Handle(new GetCurrentUserQuery(), CancellationToken.None); + + dto.Id.Should().Be(user.Id); + dto.Email.Should().Be("me@example.com"); + dto.Role.Should().Be(nameof(UserRole.Admin)); + } +} diff --git a/tests/TodoApp.UnitTests/Categories/CategoryHandlerTests.cs b/tests/TodoApp.UnitTests/Categories/CategoryHandlerTests.cs new file mode 100644 index 0000000..b0b8b65 --- /dev/null +++ b/tests/TodoApp.UnitTests/Categories/CategoryHandlerTests.cs @@ -0,0 +1,145 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using TodoApp.Application.Categories.Commands.CreateCategory; +using TodoApp.Application.Categories.Commands.DeleteCategory; +using TodoApp.Application.Categories.Commands.UpdateCategory; +using TodoApp.Application.Categories.Queries.GetCategories; +using TodoApp.Application.Common.Exceptions; +using TodoApp.Domain.Entities; +using TodoApp.UnitTests.TestSupport; +using Xunit; + +namespace TodoApp.UnitTests.Categories; + +public class CategoryHandlerTests +{ + private readonly FakeDateTimeProvider _clock = new(); + + private User SeedUser(TestDatabase db, string email = "cat@example.com") + { + var user = new User(email, "hash", _clock.UtcNow); + db.Context.Users.Add(user); + db.Context.SaveChanges(); + return user; + } + + private Category SeedCategory(TestDatabase db, int userId, string name = "Work") + { + var category = new Category(userId, name, "#fff", _clock.UtcNow); + db.Context.Categories.Add(category); + db.Context.SaveChanges(); + return category; + } + + // ---- Update -------------------------------------------------------------------- + + [Fact] + public async Task Update_RenamesAndRecolors() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var category = SeedCategory(db, user.Id); + + var handler = new UpdateCategoryCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }, _clock); + + var dto = await handler.Handle( + new UpdateCategoryCommand { Id = category.Id, Name = "Studies", Color = "#123456" }, + CancellationToken.None); + + dto.Name.Should().Be("Studies"); + dto.Color.Should().Be("#123456"); + + using var read = db.NewContext(); + (await read.Categories.SingleAsync(c => c.Id == category.Id)).Name.Should().Be("Studies"); + } + + [Fact] + public async Task Update_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new UpdateCategoryCommandHandler( + db.NewContext(), new FakeCurrentUserService(), _clock); + + var act = () => handler.Handle( + new UpdateCategoryCommand { Id = 1, Name = "X", Color = "#fff" }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Update_OfAnotherUsersCategory_ThrowsNotFound() + { + using var db = new TestDatabase(); + var me = SeedUser(db); + var other = SeedUser(db, "other@example.com"); + var theirs = SeedCategory(db, other.Id); + + var handler = new UpdateCategoryCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = me.Id }, _clock); + + var act = () => handler.Handle( + new UpdateCategoryCommand { Id = theirs.Id, Name = "Mine now", Color = "#fff" }, + CancellationToken.None); + + // Not Forbidden: the caller must not learn that someone else's category exists. + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Update_ToADuplicateName_ThrowsConflict() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + SeedCategory(db, user.Id, "Work"); + var personal = SeedCategory(db, user.Id, "Personal"); + + var handler = new UpdateCategoryCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }, _clock); + + var act = () => handler.Handle( + new UpdateCategoryCommand { Id = personal.Id, Name = "Work", Color = "#fff" }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + // ---- Create / Delete / Query --------------------------------------------------- + + [Fact] + public async Task Create_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new CreateCategoryCommandHandler( + db.NewContext(), new FakeCurrentUserService(), _clock); + + var act = () => handler.Handle( + new CreateCategoryCommand { Name = "X", Color = "#fff" }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Delete_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new DeleteCategoryCommandHandler( + db.NewContext(), new FakeCurrentUserService()); + + var act = () => handler.Handle(new DeleteCategoryCommand(1), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetCategories_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new GetCategoriesQueryHandler( + db.NewContext(), new FakeCurrentUserService()); + + var act = () => handler.Handle(new GetCategoriesQuery(), CancellationToken.None); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/TodoApp.UnitTests/Common/ExceptionTests.cs b/tests/TodoApp.UnitTests/Common/ExceptionTests.cs new file mode 100644 index 0000000..46fa43d --- /dev/null +++ b/tests/TodoApp.UnitTests/Common/ExceptionTests.cs @@ -0,0 +1,93 @@ +using FluentAssertions; +using FluentValidation.Results; +using TodoApp.Application.Common.Exceptions; +using Xunit; + +namespace TodoApp.UnitTests.Common; + +/// +/// The exception types are the API's error contract — each one maps to a status code in +/// GlobalExceptionHandler, so their messages and payloads are worth pinning down. +/// +public class ExceptionTests +{ + [Fact] + public void NotFound_FromANameAndKey_ReadsAsASentence() + { + new NotFoundException("TodoItem", 7).Message + .Should().Be("Entity \"TodoItem\" (7) was not found."); + } + + [Fact] + public void NotFound_FromAMessage_UsesItVerbatim() + { + new NotFoundException("That board is gone.").Message.Should().Be("That board is gone."); + } + + [Fact] + public void Unauthorized_HasADefaultMessage() + { + new UnauthorizedException().Message.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void Unauthorized_KeepsAnExplicitMessage() + { + new UnauthorizedException("Invalid refresh token.").Message + .Should().Be("Invalid refresh token."); + } + + [Fact] + public void Forbidden_HasADefaultMessage() + { + new ForbiddenAccessException().Message.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void Conflict_KeepsItsMessage() + { + new ConflictException("Already exists.").Message.Should().Be("Already exists."); + } + + [Fact] + public void ConcurrencyConflict_CarriesTheCurrentServerState() + { + var current = new { Id = 1 }; + + var exception = new ConcurrencyConflictException("Stale.", current); + + exception.Message.Should().Be("Stale."); + exception.CurrentValue.Should().BeSameAs(current); + } + + [Fact] + public void ConcurrencyConflict_MayCarryNothing() + { + new ConcurrencyConflictException("Stale.", null).CurrentValue.Should().BeNull(); + } + + [Fact] + public void Validation_WithNoFailures_HasNoErrors() + { + var exception = new ValidationException(); + + exception.Errors.Should().BeEmpty(); + exception.Message.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void Validation_GroupsFailuresByPropertyAndDropsDuplicates() + { + var exception = new ValidationException( + [ + new ValidationFailure("Email", "Email is required."), + new ValidationFailure("Password", "Too short."), + new ValidationFailure("Password", "Too common."), + new ValidationFailure("Password", "Too short.") + ]); + + exception.Errors.Should().HaveCount(2); + exception.Errors["Email"].Should().Equal("Email is required."); + exception.Errors["Password"].Should().Equal("Too short.", "Too common."); + } +} diff --git a/tests/TodoApp.UnitTests/Common/InfrastructureRegistrationTests.cs b/tests/TodoApp.UnitTests/Common/InfrastructureRegistrationTests.cs new file mode 100644 index 0000000..9a6af2f --- /dev/null +++ b/tests/TodoApp.UnitTests/Common/InfrastructureRegistrationTests.cs @@ -0,0 +1,94 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TodoApp.Application.Common.Interfaces; +using TodoApp.Infrastructure; +using TodoApp.Infrastructure.Persistence; +using Xunit; + +namespace TodoApp.UnitTests.Common; + +/// +/// The composition root picks the database provider from configuration, so the same build runs +/// on SQLite locally and Azure SQL in production. Getting that wrong is a deploy-time failure, +/// which is exactly when it is most expensive to find. +/// +public class InfrastructureRegistrationTests +{ + private static ServiceProvider Build(params (string Key, string Value)[] settings) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(settings.Select(s => new KeyValuePair(s.Key, s.Value))) + .Build(); + + return new ServiceCollection() + .AddLogging() + .AddInfrastructure(configuration) + .BuildServiceProvider(); + } + + [Fact] + public void DefaultsToSqlite() + { + using var provider = Build(); + using var scope = provider.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + context.Database.ProviderName.Should().Be("Microsoft.EntityFrameworkCore.Sqlite"); + } + + [Fact] + public void UsesTheConfiguredSqliteConnectionString() + { + using var provider = Build(("ConnectionStrings:DefaultConnection", "Data Source=custom.db")); + using var scope = provider.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + context.Database.GetConnectionString().Should().Be("Data Source=custom.db"); + } + + [Fact] + public void SqlServerProviderIsSelectedByConfiguration() + { + using var provider = Build( + ("Database:Provider", "SqlServer"), + ("ConnectionStrings:DefaultConnection", "Server=localhost;Database=Todo;Integrated Security=true;")); + using var scope = provider.CreateScope(); + + var context = scope.ServiceProvider.GetRequiredService(); + + // Registration only — nothing here opens a connection. + context.Database.ProviderName.Should().Be("Microsoft.EntityFrameworkCore.SqlServer"); + } + + [Fact] + public void SqlServerWithoutAConnectionString_FailsLoudly() + { + using var provider = Build(("Database:Provider", "SqlServer")); + using var scope = provider.CreateScope(); + + var act = () => scope.ServiceProvider.GetRequiredService(); + + act.Should().Throw() + .WithMessage("*ConnectionStrings:DefaultConnection*"); + } + + [Fact] + public void RegistersTheApplicationLayerAbstractions() + { + using var provider = Build(); + using var scope = provider.CreateScope(); + var services = scope.ServiceProvider; + + services.GetRequiredService().Should().BeOfType(); + services.GetRequiredService().Should().NotBeNull(); + services.GetRequiredService().Should().NotBeNull(); + services.GetRequiredService().Should().NotBeNull(); + services.GetRequiredService().Should().NotBeNull(); + services.GetRequiredService().Should().NotBeNull(); + services.GetRequiredService().Should().NotBeNull(); + } +} diff --git a/tests/TodoApp.UnitTests/Common/ValidatorTests.cs b/tests/TodoApp.UnitTests/Common/ValidatorTests.cs new file mode 100644 index 0000000..9961208 --- /dev/null +++ b/tests/TodoApp.UnitTests/Common/ValidatorTests.cs @@ -0,0 +1,30 @@ +using FluentAssertions; +using TodoApp.Application.Auth.Commands.GoogleSignIn; +using Xunit; + +namespace TodoApp.UnitTests.Common; + +public class ValidatorTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + public void GoogleSignIn_RequiresAnIdToken(string idToken) + { + var result = new GoogleSignInCommandValidator() + .Validate(new GoogleSignInCommand { IdToken = idToken }); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle() + .Which.ErrorMessage.Should().Be("A Google ID token is required."); + } + + [Fact] + public void GoogleSignIn_AcceptsANonEmptyToken() + { + var result = new GoogleSignInCommandValidator() + .Validate(new GoogleSignInCommand { IdToken = "header.payload.signature" }); + + result.IsValid.Should().BeTrue(); + } +} diff --git a/tests/TodoApp.UnitTests/Domain/EntityGuardTests.cs b/tests/TodoApp.UnitTests/Domain/EntityGuardTests.cs new file mode 100644 index 0000000..b80eebf --- /dev/null +++ b/tests/TodoApp.UnitTests/Domain/EntityGuardTests.cs @@ -0,0 +1,253 @@ +using FluentAssertions; +using TodoApp.Domain.Entities; +using Xunit; + +namespace TodoApp.UnitTests.Domain; + +/// +/// The entity invariants: every constructor and mutator rejects the states the rest of the +/// system assumes cannot exist. +/// +public class EntityGuardTests +{ + private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + // ---- Category ------------------------------------------------------------------ + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Category_RequiresAnOwner(int userId) + { + var act = () => new Category(userId, "Work", "#fff", Now); + + act.Should().Throw().WithParameterName("userId"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Category_RequiresAName(string name) + { + var act = () => new Category(1, name, "#fff", Now); + + act.Should().Throw().WithParameterName("name"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Category_RequiresAColor(string color) + { + var act = () => new Category(1, "Work", color, Now); + + act.Should().Throw().WithParameterName("color"); + } + + [Fact] + public void Category_TrimsNameAndColor() + { + var category = new Category(1, " Work ", " #fff ", Now); + + category.Name.Should().Be("Work"); + category.Color.Should().Be("#fff"); + category.UserId.Should().Be(1); + category.CreatedAt.Should().Be(Now); + } + + [Fact] + public void Category_Update_ReplacesNameAndColorAndStampsUpdatedAt() + { + var category = new Category(1, "Work", "#fff", Now); + var later = Now.AddHours(1); + + category.Update(" Studies ", " #000 ", later); + + category.Name.Should().Be("Studies"); + category.Color.Should().Be("#000"); + category.UpdatedAt.Should().Be(later); + } + + [Fact] + public void Category_Update_StillRejectsABlankName() + { + var category = new Category(1, "Work", "#fff", Now); + + var act = () => category.Update(" ", "#000", Now); + + act.Should().Throw().WithParameterName("name"); + } + + [Fact] + public void Category_Update_StillRejectsABlankColor() + { + var category = new Category(1, "Work", "#fff", Now); + + var act = () => category.Update("Studies", " ", Now); + + act.Should().Throw().WithParameterName("color"); + } + + [Fact] + public void Category_DefaultsFor_SeedsTheStarterSet() + { + var defaults = Category.DefaultsFor(7, Now).ToList(); + + defaults.Should().HaveCount(5); + defaults.Should().OnlyContain(c => c.UserId == 7 && c.CreatedAt == Now); + defaults.Select(c => c.Name).Should() + .BeEquivalentTo(["Work", "Personal", "Errands", "Study", "Other"]); + } + + // ---- ExternalLogin ------------------------------------------------------------- + + [Theory] + [InlineData(0)] + [InlineData(-5)] + public void ExternalLogin_RequiresAUser(int userId) + { + var act = () => new ExternalLogin(userId, "Google", "sub", Now); + + act.Should().Throw().WithParameterName("userId"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ExternalLogin_RequiresAProvider(string provider) + { + var act = () => new ExternalLogin(1, provider, "sub", Now); + + act.Should().Throw().WithParameterName("provider"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ExternalLogin_RequiresAProviderKey(string providerKey) + { + var act = () => new ExternalLogin(1, "Google", providerKey, Now); + + act.Should().Throw().WithParameterName("providerKey"); + } + + [Fact] + public void ExternalLogin_StoresTheProviderIdentity() + { + var login = new ExternalLogin(3, "Google", "sub-9", Now); + + login.UserId.Should().Be(3); + login.Provider.Should().Be("Google"); + login.ProviderKey.Should().Be("sub-9"); + login.CreatedAt.Should().Be(Now); + } + + // ---- RefreshToken -------------------------------------------------------------- + + [Fact] + public void RefreshToken_RequiresAHash() + { + var act = () => new RefreshToken(1, null!, Now.AddDays(1), Now); + + act.Should().Throw().WithParameterName("tokenHash"); + } + + [Fact] + public void RefreshToken_Revoke_IsIdempotentAndKeepsTheFirstReason() + { + var token = new RefreshToken(1, "hash", Now.AddDays(1), Now); + + token.Revoke("Rotated", Now, "next-hash"); + token.Revoke("Logout", Now.AddHours(1), "other-hash"); + + token.RevokedReason.Should().Be("Rotated"); + token.RevokedAt.Should().Be(Now); + token.ReplacedByTokenHash.Should().Be("next-hash"); + } + + // ---- User ---------------------------------------------------------------------- + + [Fact] + public void User_RequiresAPasswordHash() + { + var act = () => new User("a@b.com", null!, Now); + + act.Should().Throw().WithParameterName("passwordHash"); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void User_NormalizeEmail_RejectsABlankAddress(string? email) + { + var act = () => User.NormalizeEmail(email!); + + act.Should().Throw().WithParameterName("email"); + } + + [Fact] + public void User_NormalizeEmail_TrimsAndLowercases() + { + User.NormalizeEmail(" Mixed.Case@Example.COM ").Should().Be("mixed.case@example.com"); + } + + [Fact] + public void User_SetPassword_RotatesTheSecurityStamp() + { + var user = new User("a@b.com", "old", Now); + var stamp = user.SecurityStamp; + + user.SetPassword("new", Now.AddMinutes(5)); + + user.PasswordHash.Should().Be("new"); + user.SecurityStamp.Should().NotBe(stamp); // every outstanding access token dies + user.UpdatedAt.Should().Be(Now.AddMinutes(5)); + } + + [Fact] + public void User_SetPassword_RejectsNull() + { + var user = new User("a@b.com", "old", Now); + + var act = () => user.SetPassword(null!, Now); + + act.Should().Throw().WithParameterName("passwordHash"); + } + + [Fact] + public void User_UpgradePasswordHash_RejectsNull() + { + var user = new User("a@b.com", "old", Now); + + var act = () => user.UpgradePasswordHash(null!, Now); + + act.Should().Throw().WithParameterName("passwordHash"); + } + + [Fact] + public void User_Activate_RestoresAccessWithoutRotatingTheStamp() + { + var user = new User("a@b.com", "hash", Now); + user.Deactivate(Now.AddMinutes(1)); + var stampWhileDisabled = user.SecurityStamp; + + user.Activate(Now.AddMinutes(2)); + + user.IsActive.Should().BeTrue(); + user.SecurityStamp.Should().Be(stampWhileDisabled); + user.UpdatedAt.Should().Be(Now.AddMinutes(2)); + } + + [Fact] + public void User_CreateExternal_HasNoLocalPassword() + { + var user = User.CreateExternal(" Ext@Example.com ", Now); + + user.Email.Should().Be("ext@example.com"); + user.PasswordHash.Should().BeNull(); + user.HasPassword.Should().BeFalse(); + user.IsActive.Should().BeTrue(); + user.CreatedAt.Should().Be(Now); + } +} diff --git a/tests/TodoApp.UnitTests/Security/CurrentUserServiceTests.cs b/tests/TodoApp.UnitTests/Security/CurrentUserServiceTests.cs new file mode 100644 index 0000000..3048de7 --- /dev/null +++ b/tests/TodoApp.UnitTests/Security/CurrentUserServiceTests.cs @@ -0,0 +1,86 @@ +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using TodoApp.Infrastructure.Authentication; +using Xunit; + +namespace TodoApp.UnitTests.Security; + +/// +/// Reading the caller's identity off the request. Inbound claim mapping is off, so the raw JWT +/// claim names ("sub", "email", "role") are what the service must look for. +/// +public class CurrentUserServiceTests +{ + private static CurrentUserService For(ClaimsPrincipal? principal) + { + var accessor = new HttpContextAccessor(); + + if (principal is not null) + { + accessor.HttpContext = new DefaultHttpContext { User = principal }; + } + + return new CurrentUserService(accessor); + } + + private static ClaimsPrincipal Authenticated(params Claim[] claims) => + new(new ClaimsIdentity(claims, authenticationType: "Test", nameType: "sub", roleType: "role")); + + [Fact] + public void WithNoHttpContext_NobodyIsSignedIn() + { + var service = For(null); + + service.UserId.Should().BeNull(); + service.Email.Should().BeNull(); + service.IsAuthenticated.Should().BeFalse(); + service.IsInRole("Admin").Should().BeFalse(); + } + + [Fact] + public void WithAnAnonymousPrincipal_NobodyIsSignedIn() + { + var service = For(new ClaimsPrincipal(new ClaimsIdentity())); + + service.UserId.Should().BeNull(); + service.Email.Should().BeNull(); + service.IsAuthenticated.Should().BeFalse(); + service.IsInRole("Admin").Should().BeFalse(); + } + + [Fact] + public void ReadsTheIdentityFromTheRawJwtClaims() + { + var service = For(Authenticated( + new Claim("sub", "42"), + new Claim("email", "me@example.com"), + new Claim("role", "Admin"))); + + service.UserId.Should().Be(42); + service.Email.Should().Be("me@example.com"); + service.IsAuthenticated.Should().BeTrue(); + service.IsInRole("Admin").Should().BeTrue(); + service.IsInRole("User").Should().BeFalse(); + } + + [Fact] + public void WithAPrincipalThatCarriesNoIdentity_NobodyIsSignedIn() + { + // ClaimsPrincipal.Identity is null when no identity has been added at all — a shape the + // null-conditional chain in IsAuthenticated has to survive. + var service = For(new ClaimsPrincipal()); + + service.IsAuthenticated.Should().BeFalse(); + service.UserId.Should().BeNull(); + } + + [Fact] + public void ANonNumericSubjectIsNotAUserId() + { + var service = For(Authenticated(new Claim("sub", "not-a-number"))); + + service.UserId.Should().BeNull(); + service.IsAuthenticated.Should().BeTrue(); // still signed in, just not identifiable + } +} diff --git a/tests/TodoApp.UnitTests/Security/GoogleTokenValidatorTests.cs b/tests/TodoApp.UnitTests/Security/GoogleTokenValidatorTests.cs new file mode 100644 index 0000000..6ac22ab --- /dev/null +++ b/tests/TodoApp.UnitTests/Security/GoogleTokenValidatorTests.cs @@ -0,0 +1,76 @@ +using FluentAssertions; +using Google.Apis.Auth; +using Microsoft.Extensions.Options; +using TodoApp.Infrastructure.Authentication; +using Xunit; + +namespace TodoApp.UnitTests.Security; + +/// +/// The offline half of Google token validation: the configuration guard, the rejection of a +/// token that never parses, and the payload mapping. Verifying a real signature needs Google's +/// live signing keys, so that step is not exercised here. +/// +public class GoogleTokenValidatorTests +{ + private static GoogleTokenValidator Create(string? clientId) => + new(Options.Create(new GoogleAuthSettings { ClientId = clientId! })); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public async Task WithoutAConfiguredClientId_FailsLoudly(string? clientId) + { + var act = () => Create(clientId).ValidateAsync("any-token", CancellationToken.None); + + // A misconfigured deployment must not silently reject every Google sign-in as invalid. + await act.Should().ThrowAsync() + .WithMessage("*Authentication:Google:ClientId*"); + } + + // An empty token is not covered here: it never reaches this class, because + // GoogleSignInCommandValidator rejects it before the handler runs. + [Theory] + [InlineData("not-a-jwt")] + [InlineData("only.two")] + public async Task AnUnparseableToken_IsRejectedAsNull(string idToken) + { + var result = await Create("client-id.apps.googleusercontent.com") + .ValidateAsync(idToken, CancellationToken.None); + + result.Should().BeNull(); + } + + [Fact] + public void FromPayload_CarriesTheIdentityWeActuallyUse() + { + var info = GoogleTokenValidator.FromPayload(new GoogleJsonWebSignature.Payload + { + Subject = "sub-123", + Email = "person@example.com", + EmailVerified = true, + Name = "A Person" + }); + + info.Subject.Should().Be("sub-123"); + info.Email.Should().Be("person@example.com"); + info.EmailVerified.Should().BeTrue(); + info.Name.Should().Be("A Person"); + } + + [Fact] + public void FromPayload_KeepsAnUnverifiedEmailFlagged() + { + var info = GoogleTokenValidator.FromPayload(new GoogleJsonWebSignature.Payload + { + Subject = "sub-456", + Email = "unverified@example.com", + EmailVerified = false, + Name = null + }); + + info.EmailVerified.Should().BeFalse(); + info.Name.Should().BeNull(); + } +} diff --git a/tests/TodoApp.UnitTests/Security/HibpBreachedPasswordCheckerTests.cs b/tests/TodoApp.UnitTests/Security/HibpBreachedPasswordCheckerTests.cs new file mode 100644 index 0000000..52d91f3 --- /dev/null +++ b/tests/TodoApp.UnitTests/Security/HibpBreachedPasswordCheckerTests.cs @@ -0,0 +1,235 @@ +using System.Net; +using System.Security.Cryptography; +using System.Text; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using TodoApp.Infrastructure.Authentication; +using Xunit; + +namespace TodoApp.UnitTests.Security; + +/// +/// The Pwned Passwords range lookup. No test touches the network: a stub handler stands in for +/// the API so both the match logic and the fail-open behavior are exercised deterministically. +/// +public class HibpBreachedPasswordCheckerTests +{ + private const string Password = "correct horse battery staple"; + + private static (string prefix, string suffix) Sha1Of(string password) + { + var hex = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(password))); + return (hex[..5], hex[5..]); + } + + private static HibpBreachedPasswordChecker Create( + StubHandler handler, bool enabled = true, int minimumOccurrences = 1) + { + var options = Options.Create(new PasswordBreachCheckOptions + { + Enabled = enabled, + TimeoutSeconds = 5, + MinimumOccurrences = minimumOccurrences + }); + + return new HibpBreachedPasswordChecker( + new StubHttpClientFactory(handler), + options, + NullLogger.Instance); + } + + [Fact] + public async Task Disabled_ChecksNothing() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("must not be called")); + + var breached = await Create(handler, enabled: false) + .IsBreachedAsync(Password, CancellationToken.None); + + breached.Should().BeFalse(); + handler.Calls.Should().Be(0); + } + + [Fact] + public async Task AnEmptyPassword_ChecksNothing() + { + var handler = new StubHandler(_ => throw new InvalidOperationException("must not be called")); + + var breached = await Create(handler).IsBreachedAsync("", CancellationToken.None); + + breached.Should().BeFalse(); + handler.Calls.Should().Be(0); + } + + [Fact] + public async Task SendsOnlyTheFirstFiveHexCharactersOfTheHash() + { + var (prefix, _) = Sha1Of(Password); + string? requested = null; + var handler = new StubHandler(request => + { + requested = request.RequestUri!.ToString(); + return Respond(HttpStatusCode.OK, "0000000000000000000000000000000000000:1"); + }); + + await Create(handler).IsBreachedAsync(Password, CancellationToken.None); + + // k-anonymity: the suffix must never leave the process. + requested.Should().EndWith($"range/{prefix}"); + var (_, suffix) = Sha1Of(Password); + requested.Should().NotContain(suffix); + } + + [Fact] + public async Task AMatchingSuffix_IsBreached() + { + var (_, suffix) = Sha1Of(Password); + var handler = new StubHandler(_ => Respond(HttpStatusCode.OK, + $"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:9\n{suffix}:42")); + + var breached = await Create(handler).IsBreachedAsync(Password, CancellationToken.None); + + breached.Should().BeTrue(); + } + + [Fact] + public async Task AMatchingSuffixIsCaseInsensitive() + { + var (_, suffix) = Sha1Of(Password); + var handler = new StubHandler(_ => Respond(HttpStatusCode.OK, $"{suffix.ToLowerInvariant()}:3")); + + var breached = await Create(handler).IsBreachedAsync(Password, CancellationToken.None); + + breached.Should().BeTrue(); + } + + [Fact] + public async Task NoMatchingSuffix_IsClean() + { + var handler = new StubHandler(_ => Respond(HttpStatusCode.OK, + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA:9\nBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB:1")); + + var breached = await Create(handler).IsBreachedAsync(Password, CancellationToken.None); + + breached.Should().BeFalse(); + } + + [Fact] + public async Task AnEmptyRangeResponse_IsClean() + { + var handler = new StubHandler(_ => Respond(HttpStatusCode.OK, "")); + + var breached = await Create(handler).IsBreachedAsync(Password, CancellationToken.None); + + breached.Should().BeFalse(); + } + + [Theory] + [InlineData("no-colon-at-all")] + [InlineData(":leading-colon")] + public async Task MalformedLinesAreSkipped(string line) + { + var (_, suffix) = Sha1Of(Password); + var handler = new StubHandler(_ => Respond(HttpStatusCode.OK, $"{line}\n{suffix}:5")); + + var breached = await Create(handler).IsBreachedAsync(Password, CancellationToken.None); + + breached.Should().BeTrue(); // the good line after the junk still matches + } + + [Fact] + public async Task AnUnparseableCountIsTreatedAsASingleSighting() + { + var (_, suffix) = Sha1Of(Password); + var handler = new StubHandler(_ => Respond(HttpStatusCode.OK, $"{suffix}:not-a-number")); + + var breached = await Create(handler, minimumOccurrences: 2) + .IsBreachedAsync(Password, CancellationToken.None); + + breached.Should().BeFalse(); // 1 sighting < the threshold of 2 + } + + [Fact] + public async Task ACountBelowTheThresholdIsAllowed() + { + var (_, suffix) = Sha1Of(Password); + var handler = new StubHandler(_ => Respond(HttpStatusCode.OK, $"{suffix}:3")); + + var breached = await Create(handler, minimumOccurrences: 10) + .IsBreachedAsync(Password, CancellationToken.None); + + breached.Should().BeFalse(); + } + + [Fact] + public async Task ANonSuccessStatus_FailsOpen() + { + var handler = new StubHandler(_ => Respond(HttpStatusCode.TooManyRequests, "")); + + var breached = await Create(handler).IsBreachedAsync(Password, CancellationToken.None); + + breached.Should().BeFalse(); + } + + [Fact] + public async Task ATransportFailure_FailsOpen() + { + var handler = new StubHandler(_ => throw new HttpRequestException("dns is having a day")); + + var breached = await Create(handler).IsBreachedAsync(Password, CancellationToken.None); + + breached.Should().BeFalse(); + } + + [Fact] + public async Task ATimeout_FailsOpen() + { + var handler = new StubHandler(_ => throw new TaskCanceledException("timed out")); + + var breached = await Create(handler).IsBreachedAsync(Password, CancellationToken.None); + + breached.Should().BeFalse(); + } + + [Fact] + public async Task CallerCancellation_FailsOpen() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var handler = new StubHandler(_ => throw new OperationCanceledException()); + + var breached = await Create(handler).IsBreachedAsync(Password, cts.Token); + + breached.Should().BeFalse(); + } + + private static HttpResponseMessage Respond(HttpStatusCode status, string body) => + new(status) { Content = new StringContent(body) }; + + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func _respond; + + public StubHandler(Func respond) => _respond = respond; + + public int Calls { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + Calls++; + return Task.FromResult(_respond(request)); + } + } + + private sealed class StubHttpClientFactory : IHttpClientFactory + { + private readonly StubHandler _handler; + + public StubHttpClientFactory(StubHandler handler) => _handler = handler; + + public HttpClient CreateClient(string name) => + new(_handler, disposeHandler: false) { BaseAddress = new Uri("https://api.pwnedpasswords.com/") }; + } +} diff --git a/tests/TodoApp.UnitTests/Security/PasswordHasherEdgeCaseTests.cs b/tests/TodoApp.UnitTests/Security/PasswordHasherEdgeCaseTests.cs new file mode 100644 index 0000000..541548e --- /dev/null +++ b/tests/TodoApp.UnitTests/Security/PasswordHasherEdgeCaseTests.cs @@ -0,0 +1,72 @@ +using FluentAssertions; +using TodoApp.Infrastructure.Authentication; +using Xunit; + +namespace TodoApp.UnitTests.Security; + +/// +/// The hasher's rejection paths. A malformed stored hash must fail verification rather than +/// throw, so a corrupted row cannot turn a failed login into a 500. +/// +public class PasswordHasherEdgeCaseTests +{ + private readonly PasswordHasher _hasher = new(); + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Hashing_RequiresAPassword(string? password) + { + var act = () => _hasher.Hash(password!); + + act.Should().Throw().WithParameterName("password"); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void VerifyingAgainstABlankHash_IsFalse(string? hash) + { + _hasher.Verify(hash!, "Password1").Should().BeFalse(); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void VerifyingABlankPassword_IsFalse(string? password) + { + _hasher.Verify(_hasher.Hash("Password1"), password!).Should().BeFalse(); + } + + [Theory] + [InlineData("not-a-hash")] // no separators + [InlineData("600000.onlytwoparts")] // too few parts + [InlineData("notanumber.c2FsdA==.a2V5")] // iteration count is not an integer + [InlineData("0.c2FsdA==.a2V5")] // zero iterations + [InlineData("-1.c2FsdA==.a2V5")] // negative iterations + [InlineData("600000.not!base64.a2V5")] // salt is not base64 + [InlineData("600000.c2FsdA==.not!base64")] // key is not base64 + [InlineData("600000..")] // empty salt and key + public void AMalformedHash_FailsVerificationWithoutThrowing(string hash) + { + _hasher.Verify(hash, "Password1").Should().BeFalse(); + } + + [Theory] + [InlineData("not-a-hash")] + [InlineData("600000.not!base64.a2V5")] + [InlineData("600000..")] + public void AMalformedHash_IsFlaggedForRehash(string hash) + { + // Unparseable means the next successful sign-in should replace it. + _hasher.NeedsRehash(hash).Should().BeTrue(); + } + + [Fact] + public void TheWrongPasswordDoesNotVerify() + { + var hash = _hasher.Hash("Password1"); + + _hasher.Verify(hash, "Password2").Should().BeFalse(); + } +} diff --git a/tests/TodoApp.UnitTests/TestSupport/Fakes.cs b/tests/TodoApp.UnitTests/TestSupport/Fakes.cs index 4ffdf70..1300f93 100644 --- a/tests/TodoApp.UnitTests/TestSupport/Fakes.cs +++ b/tests/TodoApp.UnitTests/TestSupport/Fakes.cs @@ -1,6 +1,8 @@ +using Microsoft.EntityFrameworkCore; using TodoApp.Application.Common.Interfaces; using TodoApp.Application.Common.Models; using TodoApp.Domain.Entities; +using TodoApp.Infrastructure.Persistence; namespace TodoApp.UnitTests.TestSupport; @@ -76,3 +78,42 @@ public Task IsBreachedAsync(string password, CancellationToken cancellatio return Task.FromResult(Breached); } } + +/// +/// Wraps a real context and runs a hook immediately before its first SaveChanges, so a test can +/// stage the other half of a race — another actor updating or deleting the row between this +/// handler's read and its write. Only the first save is intercepted; later saves pass straight +/// through, so a handler that saves twice still behaves normally after the conflict. +/// +public sealed class RacingDbContext : IApplicationDbContext +{ + private readonly ApplicationDbContext _inner; + private Action? _beforeFirstSave; + + public RacingDbContext(ApplicationDbContext inner, Action beforeFirstSave) + { + _inner = inner; + _beforeFirstSave = beforeFirstSave; + } + + public DbSet TodoItems => _inner.TodoItems; + + public DbSet Categories => _inner.Categories; + + public DbSet Users => _inner.Users; + + public DbSet RefreshTokens => _inner.RefreshTokens; + + public DbSet ExternalLogins => _inner.ExternalLogins; + + public Task SaveChangesAsync(CancellationToken cancellationToken) + { + var hook = _beforeFirstSave; + _beforeFirstSave = null; + hook?.Invoke(); + return _inner.SaveChangesAsync(cancellationToken); + } + + public void SetOriginalConcurrencyToken(TodoItem entity, Guid token) + => _inner.SetOriginalConcurrencyToken(entity, token); +} diff --git a/tests/TodoApp.UnitTests/TestSupport/TestDatabase.cs b/tests/TodoApp.UnitTests/TestSupport/TestDatabase.cs index 0bf5cce..09bf1f3 100644 --- a/tests/TodoApp.UnitTests/TestSupport/TestDatabase.cs +++ b/tests/TodoApp.UnitTests/TestSupport/TestDatabase.cs @@ -33,6 +33,19 @@ public ApplicationDbContext NewContext() return new ApplicationDbContext(options); } + /// + /// Turns SQLite foreign-key enforcement off for the shared connection, so a test can seed a + /// deliberately orphaned row (a token or external login whose user no longer exists) and + /// exercise a handler's defensive "the row is gone" branch. Cascade deletes make that state + /// unreachable through the normal API, but the branch still has to hold. + /// + public void DisableForeignKeyEnforcement() + { + using var command = _connection.CreateCommand(); + command.CommandText = "PRAGMA foreign_keys = OFF;"; + command.ExecuteNonQuery(); + } + public void Dispose() { Context.Dispose(); diff --git a/tests/TodoApp.UnitTests/TodoApp.UnitTests.csproj b/tests/TodoApp.UnitTests/TodoApp.UnitTests.csproj index 5171aee..b746018 100644 --- a/tests/TodoApp.UnitTests/TodoApp.UnitTests.csproj +++ b/tests/TodoApp.UnitTests/TodoApp.UnitTests.csproj @@ -9,6 +9,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/tests/TodoApp.UnitTests/Todos/TodoHandlerTests.cs b/tests/TodoApp.UnitTests/Todos/TodoHandlerTests.cs new file mode 100644 index 0000000..8b9ee04 --- /dev/null +++ b/tests/TodoApp.UnitTests/Todos/TodoHandlerTests.cs @@ -0,0 +1,489 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using TodoApp.Application.Common.Exceptions; +using TodoApp.Application.Todos.Commands.ChangeStatus; +using TodoApp.Application.Todos.Commands.CreateTodo; +using TodoApp.Application.Todos.Commands.DeleteTodo; +using TodoApp.Application.Todos.Commands.UpdateTodo; +using TodoApp.Application.Todos.Dtos; +using TodoApp.Application.Todos.Queries.GetTodoById; +using TodoApp.Application.Todos.Queries.GetTodos; +using TodoApp.Domain.Entities; +using TodoApp.Domain.Enums; +using TodoApp.UnitTests.TestSupport; +using Xunit; + +namespace TodoApp.UnitTests.Todos; + +public class TodoHandlerTests +{ + private readonly FakeDateTimeProvider _clock = new(); + + private User SeedUser(TestDatabase db, string email = "todo@example.com") + { + var user = new User(email, "hash", _clock.UtcNow); + db.Context.Users.Add(user); + db.Context.SaveChanges(); + return user; + } + + private TodoItem SeedTodo( + TestDatabase db, + int userId, + string title = "Task", + string? description = null, + Priority priority = Priority.Medium, + DateTimeOffset? dueDate = null, + TodoStatus status = TodoStatus.ToDo) + { + var todo = new TodoItem(userId, title, description, priority, null, dueDate, _clock.UtcNow); + if (status != TodoStatus.ToDo) + { + todo.MoveTo(status, _clock.UtcNow); + } + + db.Context.TodoItems.Add(todo); + db.Context.SaveChanges(); + return todo; + } + + // ---- Delete -------------------------------------------------------------------- + + [Fact] + public async Task Delete_RemovesTheCallersOwnTask() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var todo = SeedTodo(db, user.Id); + + var handler = new DeleteTodoCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }); + + await handler.Handle(new DeleteTodoCommand(todo.Id), CancellationToken.None); + + using var read = db.NewContext(); + (await read.TodoItems.CountAsync()).Should().Be(0); + } + + [Fact] + public async Task Delete_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new DeleteTodoCommandHandler(db.NewContext(), new FakeCurrentUserService()); + + var act = () => handler.Handle(new DeleteTodoCommand(1), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Delete_OfAnotherUsersTask_ThrowsNotFound() + { + using var db = new TestDatabase(); + var me = SeedUser(db); + var other = SeedUser(db, "other@example.com"); + var theirs = SeedTodo(db, other.Id); + + var handler = new DeleteTodoCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = me.Id }); + + var act = () => handler.Handle(new DeleteTodoCommand(theirs.Id), CancellationToken.None); + + await act.Should().ThrowAsync(); + + using var read = db.NewContext(); + (await read.TodoItems.CountAsync()).Should().Be(1); + } + + // ---- Change status ------------------------------------------------------------- + + [Fact] + public async Task ChangeStatus_MovesTheTaskToTheNewLane() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var todo = SeedTodo(db, user.Id); + + var handler = new ChangeTodoStatusCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }, _clock); + + var dto = await handler.Handle( + new ChangeTodoStatusCommand { Id = todo.Id, Status = TodoStatus.Done }, + CancellationToken.None); + + dto.Status.Should().Be(TodoStatus.Done); + } + + [Fact] + public async Task ChangeStatus_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new ChangeTodoStatusCommandHandler( + db.NewContext(), new FakeCurrentUserService(), _clock); + + var act = () => handler.Handle( + new ChangeTodoStatusCommand { Id = 1, Status = TodoStatus.Done }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ChangeStatus_ForAMissingTask_ThrowsNotFound() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + + var handler = new ChangeTodoStatusCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }, _clock); + + var act = () => handler.Handle( + new ChangeTodoStatusCommand { Id = 404, Status = TodoStatus.Done }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ChangeStatus_WithTheCurrentToken_Succeeds() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var todo = SeedTodo(db, user.Id); + + var handler = new ChangeTodoStatusCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }, _clock); + + var dto = await handler.Handle( + new ChangeTodoStatusCommand + { + Id = todo.Id, + Status = TodoStatus.InProgress, + ConcurrencyToken = todo.ConcurrencyToken + }, + CancellationToken.None); + + dto.Status.Should().Be(TodoStatus.InProgress); + } + + [Fact] + public async Task ChangeStatus_WithAnEmptyToken_SkipsTheConcurrencyCheck() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var todo = SeedTodo(db, user.Id); + + var handler = new ChangeTodoStatusCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }, _clock); + + // Guid.Empty means "the client has no token", not "the client saw an empty token". + var dto = await handler.Handle( + new ChangeTodoStatusCommand + { + Id = todo.Id, + Status = TodoStatus.InProgress, + ConcurrencyToken = Guid.Empty + }, + CancellationToken.None); + + dto.Status.Should().Be(TodoStatus.InProgress); + } + + [Fact] + public async Task ChangeStatus_WithAStaleToken_ThrowsConflictCarryingTheCurrentValue() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var todo = SeedTodo(db, user.Id); + + var handler = new ChangeTodoStatusCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }, _clock); + + var act = () => handler.Handle( + new ChangeTodoStatusCommand + { + Id = todo.Id, + Status = TodoStatus.Done, + ConcurrencyToken = Guid.NewGuid() + }, + CancellationToken.None); + + var conflict = (await act.Should().ThrowAsync()).Which; + conflict.CurrentValue.Should().BeOfType(); + } + + [Fact] + public async Task ChangeStatus_WhenTheRowWasDeletedMeanwhile_ConflictCarriesNoCurrentValue() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var todo = SeedTodo(db, user.Id); + + // The competing delete lands after this handler has read the row but before it writes, + // so the UPDATE matches nothing and there is no server state left to hand back. + var context = new RacingDbContext(db.NewContext(), () => + { + using var other = db.NewContext(); + other.TodoItems.Remove(other.TodoItems.Single(t => t.Id == todo.Id)); + other.SaveChanges(); + }); + + var handler = new ChangeTodoStatusCommandHandler( + context, new FakeCurrentUserService { UserId = user.Id }, _clock); + + var act = () => handler.Handle( + new ChangeTodoStatusCommand + { + Id = todo.Id, + Status = TodoStatus.Done, + ConcurrencyToken = todo.ConcurrencyToken + }, + CancellationToken.None); + + var conflict = (await act.Should().ThrowAsync()).Which; + conflict.CurrentValue.Should().BeNull(); + } + + // ---- Update -------------------------------------------------------------------- + + [Fact] + public async Task Update_WithAnUnknownCategory_ThrowsNotFound() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var todo = SeedTodo(db, user.Id); + + var handler = new UpdateTodoCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }, _clock); + + var act = () => handler.Handle( + new UpdateTodoCommand { Id = todo.Id, Title = "Changed", CategoryId = 404 }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Update_WithAnotherUsersCategory_ThrowsNotFound() + { + using var db = new TestDatabase(); + var me = SeedUser(db); + var other = SeedUser(db, "other@example.com"); + var theirCategory = new Category(other.Id, "Theirs", "#fff", _clock.UtcNow); + db.Context.Categories.Add(theirCategory); + db.Context.SaveChanges(); + var todo = SeedTodo(db, me.Id); + + var handler = new UpdateTodoCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = me.Id }, _clock); + + var act = () => handler.Handle( + new UpdateTodoCommand { Id = todo.Id, Title = "Changed", CategoryId = theirCategory.Id }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Update_WithTheCallersOwnCategory_Succeeds() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var category = new Category(user.Id, "Work", "#fff", _clock.UtcNow); + db.Context.Categories.Add(category); + db.Context.SaveChanges(); + var todo = SeedTodo(db, user.Id); + + var handler = new UpdateTodoCommandHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }, _clock); + + var dto = await handler.Handle( + new UpdateTodoCommand { Id = todo.Id, Title = "Changed", CategoryId = category.Id }, + CancellationToken.None); + + dto.CategoryId.Should().Be(category.Id); + } + + [Fact] + public async Task Update_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new UpdateTodoCommandHandler( + db.NewContext(), new FakeCurrentUserService(), _clock); + + var act = () => handler.Handle( + new UpdateTodoCommand { Id = 1, Title = "X" }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Update_WhenTheRowWasDeletedMeanwhile_ConflictCarriesNoCurrentValue() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + var todo = SeedTodo(db, user.Id); + + var context = new RacingDbContext(db.NewContext(), () => + { + using var other = db.NewContext(); + other.TodoItems.Remove(other.TodoItems.Single(t => t.Id == todo.Id)); + other.SaveChanges(); + }); + + var handler = new UpdateTodoCommandHandler( + context, new FakeCurrentUserService { UserId = user.Id }, _clock); + + var act = () => handler.Handle( + new UpdateTodoCommand + { + Id = todo.Id, + Title = "Changed", + ConcurrencyToken = todo.ConcurrencyToken + }, + CancellationToken.None); + + var conflict = (await act.Should().ThrowAsync()).Which; + conflict.CurrentValue.Should().BeNull(); + } + + // ---- Queries ------------------------------------------------------------------- + + [Fact] + public async Task GetTodos_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new GetTodosQueryHandler(db.NewContext(), new FakeCurrentUserService()); + + var act = () => handler.Handle(new GetTodosQuery(), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task GetTodos_ActiveFilter_ExcludesDone() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + SeedTodo(db, user.Id, "Open"); + SeedTodo(db, user.Id, "Finished", status: TodoStatus.Done); + + var handler = new GetTodosQueryHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }); + + var results = await handler.Handle( + new GetTodosQuery { Filter = TodoFilter.Active }, CancellationToken.None); + + results.Select(t => t.Title).Should().Equal("Open"); + } + + [Fact] + public async Task GetTodos_CompletedFilter_KeepsOnlyDone() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + SeedTodo(db, user.Id, "Open"); + SeedTodo(db, user.Id, "Finished", status: TodoStatus.Done); + + var handler = new GetTodosQueryHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }); + + var results = await handler.Handle( + new GetTodosQuery { Filter = TodoFilter.Completed }, CancellationToken.None); + + results.Select(t => t.Title).Should().Equal("Finished"); + } + + [Fact] + public async Task GetTodos_SearchMatchesTitleOrDescription() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + SeedTodo(db, user.Id, "Buy milk"); + SeedTodo(db, user.Id, "Errand", description: "pick up milk on the way"); + SeedTodo(db, user.Id, "Unrelated", description: "nothing to see"); + + var handler = new GetTodosQueryHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }); + + var results = await handler.Handle( + new GetTodosQuery { Search = " milk " }, CancellationToken.None); + + results.Select(t => t.Title).Should().BeEquivalentTo(["Buy milk", "Errand"]); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task GetTodos_BlankSearch_IsIgnored(string search) + { + using var db = new TestDatabase(); + var user = SeedUser(db); + SeedTodo(db, user.Id, "One"); + SeedTodo(db, user.Id, "Two"); + + var handler = new GetTodosQueryHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }); + + var results = await handler.Handle( + new GetTodosQuery { Search = search }, CancellationToken.None); + + results.Should().HaveCount(2); + } + + [Fact] + public async Task GetTodos_OrdersByPriorityThenDueDateWithUndatedLast() + { + using var db = new TestDatabase(); + var user = SeedUser(db); + SeedTodo(db, user.Id, "Low", priority: Priority.Low); + SeedTodo(db, user.Id, "High undated", priority: Priority.High); + SeedTodo(db, user.Id, "High soon", priority: Priority.High, dueDate: _clock.UtcNow.AddDays(1)); + + var handler = new GetTodosQueryHandler( + db.NewContext(), new FakeCurrentUserService { UserId = user.Id }); + + var results = await handler.Handle(new GetTodosQuery(), CancellationToken.None); + + results.Select(t => t.Title).Should().Equal("High soon", "High undated", "Low"); + } + + [Fact] + public async Task GetTodos_ExcludesOtherUsersTasks() + { + using var db = new TestDatabase(); + var me = SeedUser(db); + var other = SeedUser(db, "other@example.com"); + SeedTodo(db, me.Id, "Mine"); + SeedTodo(db, other.Id, "Theirs"); + + var handler = new GetTodosQueryHandler( + db.NewContext(), new FakeCurrentUserService { UserId = me.Id }); + + var results = await handler.Handle(new GetTodosQuery(), CancellationToken.None); + + results.Select(t => t.Title).Should().Equal("Mine"); + } + + [Fact] + public async Task GetTodoById_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new GetTodoByIdQueryHandler(db.NewContext(), new FakeCurrentUserService()); + + var act = () => handler.Handle(new GetTodoByIdQuery(1), CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task Create_WithoutAuthenticatedUser_Throws() + { + using var db = new TestDatabase(); + var handler = new CreateTodoCommandHandler( + db.NewContext(), new FakeCurrentUserService(), _clock); + + var act = () => handler.Handle( + new CreateTodoCommand { Title = "X" }, CancellationToken.None); + + await act.Should().ThrowAsync(); + } +} diff --git a/tests/TodoApp.UnitTests/packages.lock.json b/tests/TodoApp.UnitTests/packages.lock.json index 2d3263c..1be6138 100644 --- a/tests/TodoApp.UnitTests/packages.lock.json +++ b/tests/TodoApp.UnitTests/packages.lock.json @@ -2,6 +2,12 @@ "version": 1, "dependencies": { "net10.0": { + "coverlet.collector": { + "type": "Direct", + "requested": "[10.0.1, )", + "resolved": "10.0.1", + "contentHash": "27jXSV/0DbVqF5jDrAxuQFZ9oaz6gmG03p8ttxAFk+X0M4woFYj7MoWDLCna5EGLb0CE6OE7X6ZH3Wt5smTtaA==" + }, "FluentAssertions": { "type": "Direct", "requested": "[6.12.2, )", From 86966a4f5bd21083828dadb84b220d2608694e19 Mon Sep 17 00:00:00 2001 From: bgard68 Date: Tue, 25 Aug 2026 05:41:13 +0000 Subject: [PATCH 2/2] test: stop the integration host calling Have I Been Pwned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures the CI runner exposed and this machine could not. The breach check is on in appsettings.json and off only in appsettings.Development.json. The rest of the suite runs as Development and so inherited "off"; the new Production and bare-configuration hosts did not, and started calling the real Pwned Passwords service. "Password1" is in the corpus, so registration returned 400 on a runner with connectivity and passed on this sandbox, where the request fails open. Turning it off in the base factory makes the guarantee explicit for every environment a factory might host, and a new test asserts it rather than leaving it to be rediscovered. The bare-configuration factory also deleted its temporary content root in Dispose, which runs on both the sync and async teardown paths — the second delete threw out of test-class cleanup. It now checks the directory is still there. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019h7znwAftvs36vD4YwKMRf --- .../CustomWebApplicationFactory.cs | 8 ++++++++ .../HostConfigurationTests.cs | 18 +++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/TodoApp.IntegrationTests/CustomWebApplicationFactory.cs b/tests/TodoApp.IntegrationTests/CustomWebApplicationFactory.cs index 4d491cb..8afa5cf 100644 --- a/tests/TodoApp.IntegrationTests/CustomWebApplicationFactory.cs +++ b/tests/TodoApp.IntegrationTests/CustomWebApplicationFactory.cs @@ -38,11 +38,19 @@ public CustomWebApplicationFactory() /// clear of what the suite generates (the whole run shares one client-IP partition), and demo /// seeding is forced off so tests exercise the production default. Override to vary either. /// + /// + /// The breach check is off explicitly rather than by inheriting it from + /// appsettings.Development.json, so a factory that hosts a different environment does not + /// silently start calling Have I Been Pwned. That reaches the real service, which makes the + /// suite depend on the network and on whether a test password is in the corpus — "Password1" + /// is, so registration 400s on a machine with connectivity and passes on one without. + /// protected virtual IEnumerable> TestConfiguration => [ new("RateLimiting:Auth:PermitLimit", "10000"), new("RateLimiting:Global:PermitLimit", "10000"), new("Seed:DemoUser", "false"), + new("PasswordBreachCheck:Enabled", "false"), ]; protected override void ConfigureWebHost(IWebHostBuilder builder) diff --git a/tests/TodoApp.IntegrationTests/HostConfigurationTests.cs b/tests/TodoApp.IntegrationTests/HostConfigurationTests.cs index 18e7a99..bb8f5ab 100644 --- a/tests/TodoApp.IntegrationTests/HostConfigurationTests.cs +++ b/tests/TodoApp.IntegrationTests/HostConfigurationTests.cs @@ -2,7 +2,10 @@ using System.Net.Http.Json; using FluentAssertions; using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; +using TodoApp.Infrastructure.Authentication; using Xunit; namespace TodoApp.IntegrationTests; @@ -55,6 +58,17 @@ public async Task TheApiStillWorks() registered.AccessToken.Should().NotBeNullOrWhiteSpace(); } + [Fact] + public void TheTestHostNeverReachesForThePwnedPasswordsService() + { + var options = _factory.Services.GetRequiredService>(); + + // appsettings.json turns the check on for deployed environments. A test host that inherits + // that starts calling a third party, and the suite's outcome then depends on connectivity + // and on whether its passwords are in the corpus. This is the guard on that. + options.Value.Enabled.Should().BeFalse(); + } + private sealed record StatusResponse(string Status); } @@ -82,7 +96,9 @@ protected override void Dispose(bool disposing) { base.Dispose(disposing); - if (disposing) + // Dispose runs on both the sync and async teardown paths, so this is reached twice and a + // second delete would throw out of test-class cleanup. + if (disposing && Directory.Exists(_emptyContentRoot)) { Directory.Delete(_emptyContentRoot, recursive: true); }