feat: complete release readiness work #28
@@ -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<NoContentResult>(await controller.VerifyEmail(new AuthController.VerifyEmailRequest(user.Id, token)));
|
||||
Assert.IsType<BadRequestObjectResult>(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<BadRequestObjectResult>(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<NoContentResult>(await controller.ConfirmEmailChange(request, default));
|
||||
fixture.Db.ChangeTracker.Clear();
|
||||
var changed = Assert.IsType<ApplicationUser>(await fixture.Users.FindByIdAsync(user.Id));
|
||||
Assert.Equal("new@example.test", changed.Email);
|
||||
Assert.Equal("ada", changed.UserName);
|
||||
Assert.Null(changed.PendingEmail);
|
||||
Assert.IsType<BadRequestObjectResult>(await controller.ConfirmEmailChange(request, default));
|
||||
}
|
||||
|
||||
private sealed class Fixture : IAsyncDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly ServiceProvider _provider;
|
||||
private readonly Mock<IAppEmailSender> _email = new();
|
||||
public JobTrackerContext Db { get; }
|
||||
public UserManager<ApplicationUser> Users { get; }
|
||||
|
||||
private Fixture(SqliteConnection connection, ServiceProvider provider, JobTrackerContext db, UserManager<ApplicationUser> users)
|
||||
{
|
||||
_connection = connection;
|
||||
_provider = provider;
|
||||
Db = db;
|
||||
Users = users;
|
||||
}
|
||||
|
||||
public static async Task<Fixture> 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<CurrentUserService>();
|
||||
services.AddScoped<ICurrentUserService>(provider => provider.GetRequiredService<CurrentUserService>());
|
||||
services.AddDbContext<JobTrackerContext>((_, options) => options.UseSqlite(connection));
|
||||
services.AddIdentityCore<ApplicationUser>(options => options.User.RequireUniqueEmail = true)
|
||||
.AddRoles<IdentityRole>()
|
||||
.AddEntityFrameworkStores<JobTrackerContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
if (tokenLifespan is not null)
|
||||
services.Configure<DataProtectionTokenProviderOptions>(options => options.TokenLifespan = tokenLifespan.Value);
|
||||
var provider = services.BuildServiceProvider();
|
||||
var scope = provider.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
return new Fixture(connection, provider, db, scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>())
|
||||
{
|
||||
_scope = scope,
|
||||
};
|
||||
}
|
||||
|
||||
private IServiceScope? _scope;
|
||||
|
||||
public async Task<ApplicationUser> 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<string, string?>
|
||||
{
|
||||
["App:PublicBaseUrl"] = "https://jobs.example.test",
|
||||
}).Build();
|
||||
return new AuthController(
|
||||
configuration,
|
||||
Users,
|
||||
Mock.Of<ITokenService>(),
|
||||
_email.Object,
|
||||
Mock.Of<IGoogleTokenValidator>(),
|
||||
Mock.Of<IMicrosoftTokenValidator>(),
|
||||
NullLogger<AuthController>.Instance,
|
||||
Mock.Of<ITwoFactorPendingTokenService>(),
|
||||
Db)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
|
||||
};
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_scope?.Dispose();
|
||||
await _provider.DisposeAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user