using D3ROVoice.Api.Data; using D3ROVoice.Api.Dtos; using D3ROVoice.Api.Services; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Xunit; namespace D3ROVoice.Api.Tests; public sealed class AuthSecurityTests { private static IConfiguration TestConfiguration() => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["JWT_SECRET"] = "test-only-jwt-secret-0123456789-abcdef", ["JWT_ISSUER"] = "https://issuer.test", ["JWT_AUDIENCE"] = "d3ro-admin-test" }) .Build(); [Fact] public async Task BootstrapStoresPbkdf2HashAndRejectsASecondAdministrator() { var options = new DbContextOptionsBuilder() .UseSqlite("Data Source=:memory:") .Options; await using var db = new AppDbContext(options); await db.Database.OpenConnectionAsync(); await db.Database.EnsureCreatedAsync(); var service = new AuthService(db, TestConfiguration()); var created = await service.RegisterAsync( new RegisterDto("ADMIN@EXAMPLE.COM", "correct-horse-battery-staple")); var stored = await db.Users.SingleAsync(); Assert.Equal("admin@example.com", stored.Email); Assert.Equal("SuperAdmin", stored.Role); Assert.StartsWith("AQAAAA", stored.PasswordHash); Assert.DoesNotContain("correct-horse", stored.PasswordHash, StringComparison.Ordinal); Assert.Equal("SuperAdmin", created.Role); Assert.True(created.ExpiresAt > DateTime.UtcNow.AddHours(7)); await Assert.ThrowsAsync(() => service.RegisterAsync( new RegisterDto("second@example.com", "another-correct-password"))); } [Fact] public async Task LoginRejectsWrongPasswordAndReusesNoLegacyHashScheme() { var options = new DbContextOptionsBuilder() .UseSqlite("Data Source=:memory:") .Options; await using var db = new AppDbContext(options); await db.Database.OpenConnectionAsync(); await db.Database.EnsureCreatedAsync(); var service = new AuthService(db, TestConfiguration()); await service.RegisterAsync( new RegisterDto("admin@example.com", "correct-horse-battery-staple")); await Assert.ThrowsAsync(() => service.LoginAsync( new LoginDto("admin@example.com", "wrong-password-value"))); await Assert.ThrowsAsync(() => service.LoginAsync( new LoginDto("missing@example.com", "wrong-password-value"))); var login = await service.LoginAsync( new LoginDto("ADMIN@example.com", "correct-horse-battery-staple")); Assert.Equal("admin@example.com", login.Email); Assert.False(string.IsNullOrWhiteSpace(login.Token)); } [Fact] public async Task ConcurrentBootstrapAllowsExactlyOneAdministrator() { var dbPath = Path.Combine( Path.GetTempPath(), $"d3ro-auth-security-{Guid.NewGuid():N}.db"); var options = new DbContextOptionsBuilder() .UseSqlite($"Data Source={dbPath};Pooling=False") .Options; try { await using (var setup = new AppDbContext(options)) { await setup.Database.EnsureCreatedAsync(); } async Task TryBootstrapAsync(int index) { await using var db = new AppDbContext(options); var service = new AuthService(db, TestConfiguration()); try { await service.RegisterAsync(new RegisterDto( $"admin-{index}@example.com", $"correct-horse-battery-{index}-staple")); return true; } catch (InvalidOperationException) { return false; } } var outcomes = await Task.WhenAll(TryBootstrapAsync(1), TryBootstrapAsync(2)); Assert.Single(outcomes, result => result); await using (var verification = new AppDbContext(options)) { Assert.Equal(1, await verification.Users.CountAsync(user => user.IsActive)); } } finally { if (File.Exists(dbPath)) File.Delete(dbPath); } } }