fix: harden owner bootstrap and auth persistence
This commit is contained in:
@@ -0,0 +1,397 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.DTOs;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace Nexus.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for AuthService login, change-password, admin-reset, and related flows.
|
||||
/// These are unit-level tests using an in-memory EF Core database so no
|
||||
/// external PostgreSQL instance is needed.
|
||||
/// </summary>
|
||||
public sealed class AuthServiceTests
|
||||
{
|
||||
// ── Fixture helpers ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a test fixture with an in-memory database, a UserRepository,
|
||||
/// and an AuthService backed by an in-memory configuration.
|
||||
/// </summary>
|
||||
private static (NexusDbContext db, IUserRepository repo, AuthService auth) CreateFixture()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
var db = new NexusDbContext(options);
|
||||
var repo = new UserRepository(db);
|
||||
|
||||
// In-memory config with minimum required JWT settings
|
||||
var config = new MemoryConfig(new Dictionary<string, string?>
|
||||
{
|
||||
["Jwt:Key"] = "this-is-a-test-key-that-is-at-least-32-bytes-long!",
|
||||
["Jwt:Issuer"] = "nexus-test",
|
||||
["Jwt:Audience"] = "nexus-test-web",
|
||||
});
|
||||
|
||||
var logger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AuthService>.Instance;
|
||||
var auth = new AuthService(repo, config, logger);
|
||||
return (db, repo, auth);
|
||||
}
|
||||
|
||||
private static LoginRequest Login(string email, string password)
|
||||
=> new() { Email = email, Password = password };
|
||||
|
||||
private static async Task<NexusUser> SeedUserAsync(NexusDbContext db, string email, string password, string role = "user")
|
||||
{
|
||||
var user = new NexusUser
|
||||
{
|
||||
Email = email,
|
||||
NormalizedEmail = AuthService.NormalizeEmail(email),
|
||||
DisplayName = email.Split('@')[0],
|
||||
PasswordHash = PasswordSecurity.Hash(password),
|
||||
Role = role
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
return user;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// Password Security Unit Tests
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void Hash_And_Verify_RoundTrip_Succeeds()
|
||||
{
|
||||
const string password = "MyTestPassword123!";
|
||||
var hash = PasswordSecurity.Hash(password);
|
||||
Assert.NotNull(hash);
|
||||
Assert.StartsWith("v1.", hash);
|
||||
|
||||
var ok = PasswordSecurity.Verify(password, hash, out var needsUpgrade);
|
||||
Assert.True(ok);
|
||||
Assert.False(needsUpgrade);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_WrongPassword_Fails()
|
||||
{
|
||||
var hash = PasswordSecurity.Hash("CorrectPassword123!");
|
||||
Assert.False(PasswordSecurity.Verify("WrongPassword456!", hash, out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_EmptyHash_ReturnsFalse()
|
||||
{
|
||||
Assert.False(PasswordSecurity.Verify("password", "", out _));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Verify_LegacySha256_PassesAndFlagsUpgrade()
|
||||
{
|
||||
const string password = "OldFormatPassword123!";
|
||||
var legacyHash = Convert.ToHexString(
|
||||
System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(password)));
|
||||
|
||||
var ok = PasswordSecurity.Verify(password, legacyHash, out var needsUpgrade);
|
||||
Assert.True(ok);
|
||||
Assert.True(needsUpgrade);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// Login Tests
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Login_WithValidCredentials_Succeeds()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
const string password = "ValidPassword123!";
|
||||
await SeedUserAsync(db, "test@example.com", password);
|
||||
|
||||
var session = await auth.LoginAsync(Login("test@example.com", password));
|
||||
Assert.NotNull(session);
|
||||
Assert.Equal("test", session.User.DisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_WithWrongPassword_ReturnsNull()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
await SeedUserAsync(db, "test@example.com", "CorrectPassword123!");
|
||||
|
||||
Assert.Null(await auth.LoginAsync(Login("test@example.com", "WrongPassword456!")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_WithNonexistentEmail_ReturnsNull()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
Assert.Null(await auth.LoginAsync(Login("nobody@example.com", "SomePassword123!")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_UpdatesLastLoginAt()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
const string password = "TestPassword123!";
|
||||
var user = await SeedUserAsync(db, "test@example.com", password);
|
||||
|
||||
var beforeLogin = user.LastLoginAt;
|
||||
await Task.Delay(10);
|
||||
|
||||
Assert.NotNull(await auth.LoginAsync(Login("test@example.com", password)));
|
||||
|
||||
var updated = await repo.GetByIdAsync(user.Id);
|
||||
Assert.NotNull(updated!.LastLoginAt);
|
||||
Assert.True(updated.LastLoginAt > beforeLogin || beforeLogin is null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that LoginAsync persists a password hash upgrade AND login
|
||||
/// timestamps even when there are NO expired refresh tokens. Previously
|
||||
/// the code relied on RemoveExpiredTokensAsync calling SaveChangesAsync,
|
||||
/// but that only happens when oldTokens.Count > 0.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Login_WithLegacyHash_UpgradesAndPersistsWithoutExpiredTokens()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
const string password = "LegacyUpgradePassword123!";
|
||||
|
||||
var legacyHash = Convert.ToHexString(
|
||||
System.Security.Cryptography.SHA256.HashData(
|
||||
System.Text.Encoding.UTF8.GetBytes(password)));
|
||||
|
||||
var user = new NexusUser
|
||||
{
|
||||
Email = "legacy@example.com",
|
||||
NormalizedEmail = AuthService.NormalizeEmail("legacy@example.com"),
|
||||
DisplayName = "Legacy",
|
||||
PasswordHash = legacyHash,
|
||||
Role = "user"
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Login triggers hash upgrade
|
||||
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
|
||||
|
||||
var updated = await repo.GetByIdAsync(user.Id);
|
||||
Assert.NotNull(updated);
|
||||
Assert.StartsWith("v1.", updated.PasswordHash);
|
||||
Assert.NotEqual(legacyHash, updated.PasswordHash);
|
||||
|
||||
// Second login with the upgraded hash should also work
|
||||
Assert.NotNull(await auth.LoginAsync(Login("legacy@example.com", password)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_WithExistingHash_DoesNotChangeHash()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
const string password = "StablePassword123!";
|
||||
var user = await SeedUserAsync(db, "stable@example.com", password);
|
||||
|
||||
var originalHash = user.PasswordHash;
|
||||
Assert.NotNull(await auth.LoginAsync(Login("stable@example.com", password)));
|
||||
|
||||
var updated = await repo.GetByIdAsync(user.Id);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal(originalHash, updated.PasswordHash);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// Change Password Tests
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_WithCorrectCurrentPassword_Succeeds()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
const string oldPw = "OldPassword123!";
|
||||
const string newPw = "NewPassword456!";
|
||||
var user = await SeedUserAsync(db, "changepw@example.com", oldPw);
|
||||
|
||||
var result = await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
|
||||
{
|
||||
CurrentPassword = oldPw,
|
||||
NewPassword = newPw
|
||||
});
|
||||
Assert.True(result);
|
||||
|
||||
Assert.Null(await auth.LoginAsync(Login("changepw@example.com", oldPw)));
|
||||
Assert.NotNull(await auth.LoginAsync(Login("changepw@example.com", newPw)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ChangePassword_WithWrongCurrentPassword_Fails()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
var user = await SeedUserAsync(db, "wrongpw@example.com", "ActualPassword123!");
|
||||
|
||||
Assert.False(await auth.ChangePasswordAsync(user.Id, new ChangePasswordRequest
|
||||
{
|
||||
CurrentPassword = "WrongPassword456!",
|
||||
NewPassword = "NewPassword789!"
|
||||
}));
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// Admin Reset Password Tests
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task AdminResetPassword_WithValidToken_Succeeds()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-admin-token-123");
|
||||
|
||||
const string oldPw = "OldPassword123!";
|
||||
const string newPw = "NewAdminPassword456!";
|
||||
await SeedUserAsync(db, "adminreset@example.com", oldPw);
|
||||
|
||||
Assert.True(await auth.AdminResetPasswordAsync("adminreset@example.com", newPw, "test-admin-token-123"));
|
||||
Assert.Null(await auth.LoginAsync(Login("adminreset@example.com", oldPw)));
|
||||
Assert.NotNull(await auth.LoginAsync(Login("adminreset@example.com", newPw)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminResetPassword_WithInvalidToken_Fails()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
Environment.SetEnvironmentVariable("Admin__ResetToken", "real-token-xyz");
|
||||
await SeedUserAsync(db, "badreset@example.com", "OriginalPassword123!");
|
||||
|
||||
Assert.False(await auth.AdminResetPasswordAsync("badreset@example.com", "NewPassword456!", "wrong-token"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminResetPassword_NonexistentUser_Fails()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
|
||||
Assert.False(await auth.AdminResetPasswordAsync("nobody@example.com", "NewPassword456!", "test-token"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AdminResetPassword_ShortPassword_Fails()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
Environment.SetEnvironmentVariable("Admin__ResetToken", "test-token");
|
||||
Assert.False(await auth.AdminResetPasswordAsync("test@example.com", "short", "test-token"));
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// Profile Update Tests
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateProfile_ChangesDisplayName()
|
||||
{
|
||||
var (db, repo, auth) = CreateFixture();
|
||||
const string password = "Password123!";
|
||||
var user = await SeedUserAsync(db, "profile@example.com", password);
|
||||
|
||||
var updated = await auth.UpdateProfileAsync(user.Id, new UpdateProfileRequest
|
||||
{
|
||||
DisplayName = "New Name"
|
||||
});
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("New Name", updated.DisplayName);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// NormalizeEmail
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
|
||||
[Fact]
|
||||
public void NormalizeEmail_TrimsAndUppercases()
|
||||
{
|
||||
Assert.Equal("TEST@EXAMPLE.COM", AuthService.NormalizeEmail(" test@Example.com "));
|
||||
Assert.Equal("A@B.COM", AuthService.NormalizeEmail("a@b.com"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal in-memory IConfiguration implementation for unit tests.
|
||||
/// Reads from a case-insensitive dictionary.
|
||||
/// </summary>
|
||||
internal sealed class MemoryConfig : Microsoft.Extensions.Configuration.IConfiguration
|
||||
{
|
||||
private readonly Dictionary<string, string?> _data;
|
||||
private readonly Dictionary<string, MemoryConfigSection> _sections;
|
||||
|
||||
public MemoryConfig(Dictionary<string, string?> data)
|
||||
{
|
||||
_data = new Dictionary<string, string?>(data, StringComparer.OrdinalIgnoreCase);
|
||||
_sections = new Dictionary<string, MemoryConfigSection>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public string? this[string key]
|
||||
{
|
||||
get => _data.TryGetValue(key, out var val) ? val : null;
|
||||
set => _data[key] = value ?? string.Empty;
|
||||
}
|
||||
|
||||
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
|
||||
{
|
||||
if (!_sections.TryGetValue(key, out var section))
|
||||
{
|
||||
section = new MemoryConfigSection(key, this);
|
||||
_sections[key] = section;
|
||||
}
|
||||
return section;
|
||||
}
|
||||
|
||||
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
|
||||
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
|
||||
|
||||
public IChangeToken GetReloadToken()
|
||||
=> NeverToken.Instance;
|
||||
}
|
||||
|
||||
internal sealed class MemoryConfigSection(string path, MemoryConfig root) : Microsoft.Extensions.Configuration.IConfigurationSection
|
||||
{
|
||||
public string Key => path.Split(':').Last();
|
||||
public string Path => path;
|
||||
public string? Value { get => root[path]; set => root[path] = value; }
|
||||
|
||||
public string? this[string key]
|
||||
{
|
||||
get => root[$"{path}:{key}"];
|
||||
set => root[$"{path}:{key}"] = value;
|
||||
}
|
||||
|
||||
public Microsoft.Extensions.Configuration.IConfigurationSection GetSection(string key)
|
||||
=> root.GetSection($"{path}:{key}");
|
||||
|
||||
public IEnumerable<Microsoft.Extensions.Configuration.IConfigurationSection> GetChildren()
|
||||
=> Enumerable.Empty<Microsoft.Extensions.Configuration.IConfigurationSection>();
|
||||
|
||||
public IChangeToken GetReloadToken()
|
||||
=> NeverToken.Instance;
|
||||
}
|
||||
|
||||
/// <summary>A change token that never signals — for test-use IConfiguration stubs.</summary>
|
||||
internal sealed class NeverToken : IChangeToken
|
||||
{
|
||||
public static readonly NeverToken Instance = new();
|
||||
public bool HasChanged => false;
|
||||
public bool ActiveChangeCallbacks => false;
|
||||
public IDisposable RegisterChangeCallback(Action<object?> callback, object? state) => NoopDisposable.Instance;
|
||||
}
|
||||
|
||||
internal sealed class NoopDisposable : IDisposable
|
||||
{
|
||||
public static readonly NoopDisposable Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
|
||||
Reference in New Issue
Block a user