From dc511296a4f92b8bdbcac7606b7917a2f16e7e79 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 15 Aug 2026 20:06:08 +0200 Subject: [PATCH] test(auth): prove email token lifecycle Reject verification-link replay and cover real Identity token expiry, replay, email change, and custom username preservation on SQLite. --- .../EmailOwnershipIntegrationTests.cs | 153 ++++++++++++++++++ JobTrackerApi/Controllers/AuthController.cs | 2 +- docs/audits/verification-log.md | 1 + docs/verification/sec-005b-email-ownership.md | 3 +- docs/work-programmes/master-work-plan.md | 2 +- 5 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 JobTrackerApi.Tests/EmailOwnershipIntegrationTests.cs diff --git a/JobTrackerApi.Tests/EmailOwnershipIntegrationTests.cs b/JobTrackerApi.Tests/EmailOwnershipIntegrationTests.cs new file mode 100644 index 0000000..9779ecf --- /dev/null +++ b/JobTrackerApi.Tests/EmailOwnershipIntegrationTests.cs @@ -0,0 +1,153 @@ +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class EmailOwnershipIntegrationTests +{ + [Fact] + public async Task Real_confirmation_token_is_single_use() + { + await using var fixture = await Fixture.CreateAsync(); + var user = await fixture.CreateUserAsync("person@example.test", "person@example.test", confirmed: false); + var token = await fixture.Users.GenerateEmailConfirmationTokenAsync(user); + var controller = fixture.Controller(); + + Assert.IsType(await controller.VerifyEmail(new AuthController.VerifyEmailRequest(user.Id, token))); + Assert.IsType(await controller.VerifyEmail(new AuthController.VerifyEmailRequest(user.Id, token))); + Assert.True((await fixture.Users.FindByIdAsync(user.Id))!.EmailConfirmed); + } + + [Fact] + public async Task Real_expired_confirmation_token_is_rejected() + { + await using var fixture = await Fixture.CreateAsync(TimeSpan.Zero); + var user = await fixture.CreateUserAsync("expired@example.test", "expired@example.test", confirmed: false); + var token = await fixture.Users.GenerateEmailConfirmationTokenAsync(user); + await Task.Delay(20); + + var result = await fixture.Controller().VerifyEmail(new AuthController.VerifyEmailRequest(user.Id, token)); + + Assert.IsType(result); + Assert.False((await fixture.Users.FindByIdAsync(user.Id))!.EmailConfirmed); + } + + [Fact] + public async Task Real_change_email_token_preserves_custom_username_and_cannot_be_replayed() + { + await using var fixture = await Fixture.CreateAsync(); + var user = await fixture.CreateUserAsync("old@example.test", "ada", confirmed: true); + user.PendingEmail = "new@example.test"; + user.PendingEmailRequestedAtUtc = DateTimeOffset.UtcNow; + Assert.True((await fixture.Users.UpdateAsync(user)).Succeeded); + var token = await fixture.Users.GenerateChangeEmailTokenAsync(user, user.PendingEmail); + var request = new AuthController.ConfirmEmailChangeRequest(user.Id, user.PendingEmail, token); + var controller = fixture.Controller(); + + Assert.IsType(await controller.ConfirmEmailChange(request, default)); + fixture.Db.ChangeTracker.Clear(); + var changed = Assert.IsType(await fixture.Users.FindByIdAsync(user.Id)); + Assert.Equal("new@example.test", changed.Email); + Assert.Equal("ada", changed.UserName); + Assert.Null(changed.PendingEmail); + Assert.IsType(await controller.ConfirmEmailChange(request, default)); + } + + private sealed class Fixture : IAsyncDisposable + { + private readonly SqliteConnection _connection; + private readonly ServiceProvider _provider; + private readonly Mock _email = new(); + public JobTrackerContext Db { get; } + public UserManager Users { get; } + + private Fixture(SqliteConnection connection, ServiceProvider provider, JobTrackerContext db, UserManager users) + { + _connection = connection; + _provider = provider; + Db = db; + Users = users; + } + + public static async Task CreateAsync(TimeSpan? tokenLifespan = null) + { + var connection = new SqliteConnection("Data Source=:memory:"); + await connection.OpenAsync(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddDataProtection(); + services.AddHttpContextAccessor(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddDbContext((_, options) => options.UseSqlite(connection)); + services.AddIdentityCore(options => options.User.RequireUniqueEmail = true) + .AddRoles() + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + if (tokenLifespan is not null) + services.Configure(options => options.TokenLifespan = tokenLifespan.Value); + var provider = services.BuildServiceProvider(); + var scope = provider.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.EnsureCreatedAsync(); + return new Fixture(connection, provider, db, scope.ServiceProvider.GetRequiredService>()) + { + _scope = scope, + }; + } + + private IServiceScope? _scope; + + public async Task CreateUserAsync(string email, string userName, bool confirmed) + { + var user = new ApplicationUser + { + Id = Guid.NewGuid().ToString("N"), UserName = userName, Email = email, EmailConfirmed = confirmed, + }; + var created = await Users.CreateAsync(user, "Password123!"); + Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(error => error.Description))); + return user; + } + + public AuthController Controller() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["App:PublicBaseUrl"] = "https://jobs.example.test", + }).Build(); + return new AuthController( + configuration, + Users, + Mock.Of(), + _email.Object, + Mock.Of(), + Mock.Of(), + NullLogger.Instance, + Mock.Of(), + Db) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }, + }; + } + + public async ValueTask DisposeAsync() + { + _scope?.Dispose(); + await _provider.DisposeAsync(); + await _connection.DisposeAsync(); + } + } +} diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index 0a6a155..a80da45 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -1019,7 +1019,7 @@ public sealed class AuthController : ControllerBase if (token.Length == 0) return BadRequest("Token is required."); var user = await _users.FindByIdAsync(userId); - if (user is null) return BadRequest("Invalid or expired link."); + if (user is null || user.EmailConfirmed) return BadRequest("Invalid or expired link."); var res = await _users.ConfirmEmailAsync(user, token); if (!res.Succeeded) diff --git a/docs/audits/verification-log.md b/docs/audits/verification-log.md index d2b644c..534f083 100644 --- a/docs/audits/verification-log.md +++ b/docs/audits/verification-log.md @@ -213,3 +213,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un | V-179 | Account-deletion real-SQLite failure/retry tests; sidecar token/cache tests; full backend; Compose validation | Repository root / `tools/summarizer` | Remove the live sidecar-cache and shared tombstone-path gaps without enabling deletion | PASS — lifecycle 6/6, backend 658/658, sidecar 23/23 and Compose config pass. Sidecar failure withholds completion/tombstone until retry; maintenance purge is token-protected; tombstones map to a separate named volume; activation defaults false | Synthetic rows/cache only; no production volume, deletion, restart, provider revocation, backup restore or retention decision | SEC-009 repository cache/storage boundary complete; production activation remains blocked | | V-180 | CV operation/store focused real-SQLite tests and full backend | Repository root | Keep dormant CV extraction history consistent with cancellation, deadline recovery and retry before worker claim | PASS — focused 17/17 and backend 660/660. Cancel sets the run terminal immediately, retry reopens it, deadline recovery fails it, and owner/task/subject predicates prevent unrelated updates | Synthetic rows only; no parser/model/MariaDB/production process interruption | AI-004 dormant-row consistency gap closed | | V-181 | AI usage meter/operation/workspace/export/deletion real-SQLite tests; EF model check; SQLite/MariaDB scripts; disposable SQLite backfill and fresh application startup; full backend | Repository root | Make Workspace and durable Strategy/CV usage owner-safe, idempotent and independent of deletable private history | PASS — focused 28/28 and backend 663/663; no pending model changes; both providers generate bounded additive DDL; SQLite backfills the synthetic legacy row exactly once; fresh runtime applies through `20260815175236_AddCrossFeatureAiUsage` and serves `/health` | Synthetic local rows only; no provider/model call, MariaDB server, production migration or worker activation. CV retains a conservative reservation and older synchronous AI paths are not yet universal | Main durable usage boundary implemented; remaining synchronous producers stay tracked under POL-001 | +| V-182 | Real ASP.NET Identity data-protection token integration on SQLite; focused auth tests; full backend | Repository root | Close SEC-005B expiry/replay/custom-username proof without SMTP or production | PASS — valid confirmation succeeds once, replay and zero-lifetime expiry return the same generic failure, a real change-email token preserves a custom username and cannot replay; focused 39/39 and backend 666/666 | Synthetic addresses and ephemeral local data-protection keys only; no email, browser, MariaDB or production call | SEC-005B local token-state gap closed | diff --git a/docs/verification/sec-005b-email-ownership.md b/docs/verification/sec-005b-email-ownership.md index dc77c51..4f4d641 100644 --- a/docs/verification/sec-005b-email-ownership.md +++ b/docs/verification/sec-005b-email-ownership.md @@ -12,6 +12,7 @@ Status: `IMPLEMENTED — NOT VERIFIED`. Backend, frontend component, build, migr - Local accounts request a new address with their current password. The active address remains unchanged and both current and proposed addresses receive non-secret notifications. - `PendingEmail`, `PendingEmailRequestedAtUtc`, and a rotated security stamp make replacement requests invalidate older Identity change-email tokens. - Confirmation accepts only the current pending address, uses `UserManager.ChangeEmailAsync`, updates username only when it still tracks the old email, clears pending state, and revokes all sessions/trusted devices. +- Registration verification links are single-use at the HTTP boundary; an already confirmed account receives the same generic invalid/expired response as an invalid token. - Cancellation requires the current password and clears pending state. - ASP.NET Identity default token providers are registered; data-protection keys already persist under `Data:Root/keys`. @@ -29,6 +30,7 @@ Status: `IMPLEMENTED — NOT VERIFIED`. Backend, frontend component, build, migr - no `Set-Cookie` header and zero client cookies; - immediate login returned 403 `email_not_verified` and still zero cookies. - No email was sent and no production service or database was contacted. +- Real ASP.NET Identity data-protection tokens against SQLite prove valid confirmation, replay rejection, expiry rejection, real change-email confirmation, replay rejection and custom-username preservation. Focused auth/token tests pass 39/39; full backend passes 666/666. ## Blocked or partial checks @@ -56,6 +58,5 @@ dotnet run --no-build --no-launch-profile --project JobTrackerApi/JobTrackerApi. ## Remaining acceptance checks - Real-browser registration, resend, verification, email request, cancellation and confirmation using a local email sink. -- Expired/replayed real Identity token integration check and custom-username preservation integration check. - Disposable MariaDB upgrade/rollback execution. - Production SMTP/canonical-origin and rolling-version smoke with synthetic addresses. diff --git a/docs/work-programmes/master-work-plan.md b/docs/work-programmes/master-work-plan.md index adafc6e..3e75fc6 100644 --- a/docs/work-programmes/master-work-plan.md +++ b/docs/work-programmes/master-work-plan.md @@ -200,7 +200,7 @@ This queue records the highest-value work that can proceed without production cr - **Blocker:** in-app browser localhost access was denied by its admin policy check; no safe SMTP sink, disposable MariaDB, or production environment is available. - **Evidence:** `docs/verification/sec-005b-email-ownership.md`; focused backend 35/35; full backend 501/501; frontend 151/151 and build; SQLite upgrade and dual-provider migration scripts; isolated API 202/no-cookie and 403/no-cookie checks. - **Commit:** none. -- **Remaining work:** real-browser desktop/mobile/keyboard flows with a local email sink; expired/replayed token and custom-username integration checks; disposable MariaDB migration execution; production SMTP/origin/version-skew verification before `DONE`. +- **Remaining work:** real-browser desktop/mobile/keyboard flows with a local email sink; disposable MariaDB migration execution; production SMTP/origin/version-skew verification before `DONE`. Real Identity valid/expired/replayed verification and custom-username email-change behavior pass locally (V-182). ### SEC-006 — Compatible document-parser dependency update