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/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/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..bb8f5ab
--- /dev/null
+++ b/tests/TodoApp.IntegrationTests/HostConfigurationTests.cs
@@ -0,0 +1,187 @@
+using System.Net;
+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;
+
+///
+/// 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();
+ }
+
+ [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);
+}
+
+///
+/// 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);
+
+ // 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);
+ }
+ }
+}
+
+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 68cf284..760a7e3 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 8ac6cfb..f4e0ae5 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, )",