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.
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user