dc511296a4
Reject verification-link replay and cover real Identity token expiry, replay, email change, and custom username preservation on SQLite.
154 lines
6.7 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|