Files
jobtrackingapp/JobTrackerApi.Tests/EmailOwnershipIntegrationTests.cs
T
cesnimda dc511296a4
CI and Deploy / test (pull_request) Successful in 5m18s
CI and Deploy / deploy (pull_request) Has been skipped
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.
2026-08-15 20:06:08 +02:00

154 lines
6.7 KiB
C#

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