using System.Reflection; using System.Security.Claims; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; 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; /// /// 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. /// public sealed class AuthServiceTests { // ── Fixture helpers ───────────────────────────────────────────────── /// /// Creates a test fixture with an in-memory database, a UserRepository, /// and an AuthService backed by an in-memory configuration. /// private static (NexusDbContext db, IUserRepository repo, AuthService auth) CreateFixture() { var options = new DbContextOptionsBuilder() .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 { ["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.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 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); } /// /// 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. /// [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); } [Fact] public async Task RefreshToken_SurvivesServiceRestart_WhenDatabasePersists() { var databaseRoot = new InMemoryDatabaseRoot(); var databaseName = Guid.NewGuid().ToString(); var configuration = new MemoryConfig(new Dictionary { ["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.Instance; AuthSession initialSession; await using (var firstDb = new NexusDbContext( new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName, databaseRoot) .Options)) { await SeedUserAsync(firstDb, "restart@example.com", "RestartPassword123!"); var firstService = new AuthService(new UserRepository(firstDb), configuration, logger); initialSession = Assert.IsType( await firstService.LoginAsync(Login("restart@example.com", "RestartPassword123!"))); } await using var restartedDb = new NexusDbContext( new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName, databaseRoot) .Options); var restartedService = new AuthService( new UserRepository(restartedDb), configuration, logger); var refreshed = await restartedService.RefreshAsync(initialSession.RefreshToken); Assert.NotNull(refreshed); Assert.NotEqual(initialSession.RefreshToken, refreshed.RefreshToken); Assert.Equal(initialSession.User.Id, refreshed.User.Id); } // ══════════════════════════════════════════════════════════════════ // 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")); } } /// /// Minimal in-memory IConfiguration implementation for unit tests. /// Reads from a case-insensitive dictionary. /// internal sealed class MemoryConfig : Microsoft.Extensions.Configuration.IConfiguration { private readonly Dictionary _data; private readonly Dictionary _sections; public MemoryConfig(Dictionary data) { _data = new Dictionary(data, StringComparer.OrdinalIgnoreCase); _sections = new Dictionary(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 GetChildren() => Enumerable.Empty(); 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 GetChildren() => Enumerable.Empty(); public IChangeToken GetReloadToken() => NeverToken.Instance; } /// A change token that never signals — for test-use IConfiguration stubs. internal sealed class NeverToken : IChangeToken { public static readonly NeverToken Instance = new(); public bool HasChanged => false; public bool ActiveChangeCallbacks => false; public IDisposable RegisterChangeCallback(Action callback, object? state) => NoopDisposable.Instance; } internal sealed class NoopDisposable : IDisposable { public static readonly NoopDisposable Instance = new(); public void Dispose() { } }