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(); } } }