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