118 lines
4.4 KiB
C#
118 lines
4.4 KiB
C#
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<string, string?>
|
|
{
|
|
["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<AppDbContext>()
|
|
.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<InvalidOperationException>(() => service.RegisterAsync(
|
|
new RegisterDto("second@example.com", "another-correct-password")));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task LoginRejectsWrongPasswordAndReusesNoLegacyHashScheme()
|
|
{
|
|
var options = new DbContextOptionsBuilder<AppDbContext>()
|
|
.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<UnauthorizedAccessException>(() => service.LoginAsync(
|
|
new LoginDto("admin@example.com", "wrong-password-value")));
|
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => 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<AppDbContext>()
|
|
.UseSqlite($"Data Source={dbPath};Pooling=False")
|
|
.Options;
|
|
|
|
try
|
|
{
|
|
await using (var setup = new AppDbContext(options))
|
|
{
|
|
await setup.Database.EnsureCreatedAsync();
|
|
}
|
|
|
|
async Task<bool> 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);
|
|
}
|
|
}
|
|
}
|