using Microsoft.EntityFrameworkCore; using Nexus.Api.Data; using Nexus.Api.Services; using Xunit; namespace Nexus.Api.Tests; /// /// Regression tests for the SeedAudit-based owner-seed guard in EnsureDatabaseAsync. /// Verifies that once SeedAudit contains "owner_created", subsequent calls to /// EnsureDatabaseAsync (simulating pod restarts) do NOT reset the owner's password hash. /// public sealed class EnsureDatabaseSeedAuditTests { private const string SeedKey = "owner_created"; /// /// Creates an in-memory DbContext pre-seeded with an owner user and a SeedAudit row. /// private static async Task<(NexusDbContext db, NexusUser owner, string originalHash)> CreateSeededFixtureAsync() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; var db = new NexusDbContext(options); const string originalPassword = "InitialOwnerPassword123!"; var originalHash = PasswordSecurity.Hash(originalPassword); var owner = new NexusUser { Email = "owner@nexus.internal", NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"), DisplayName = "Nexus Owner", PasswordHash = originalHash, Role = "owner" }; db.Users.Add(owner); db.SeedAudits.Add(new SeedAudit { Key = SeedKey }); await db.SaveChangesAsync(); return (db, owner, originalHash); } /// /// Simulates the restart guard: if SeedAudit contains owner_created, /// the owner password hash must not be changed to a newly generated hash. /// [Fact] public async Task EnsureDatabaseAsync_WithSeedAuditOwnerCreated_DoesNotResetOwnerPasswordHash() { // Arrange — seed the DB with an owner and a SeedAudit row var (db, owner, originalHash) = await CreateSeededFixtureAsync(); // Sanity check: password hash starts as expected Assert.Equal(originalHash, owner.PasswordHash); Assert.True(PasswordSecurity.Verify("InitialOwnerPassword123!", owner.PasswordHash, out _)); // Act — simulate a password change by the user const string newPassword = "ChangedOwnerPassword456!"; owner.PasswordHash = PasswordSecurity.Hash(newPassword); await db.SaveChangesAsync(); // Detach and re-read to confirm the change persisted db.ChangeTracker.Clear(); var afterChange = await db.Users.FirstAsync(u => u.Id == owner.Id); Assert.NotEqual(originalHash, afterChange.PasswordHash); Assert.True(PasswordSecurity.Verify(newPassword, afterChange.PasswordHash, out _)); Assert.False(PasswordSecurity.Verify("InitialOwnerPassword123!", afterChange.PasswordHash, out _)); // Act — simulate EnsureDatabaseAsync on restart: // It checks SeedAudit first; if owner_created exists, it returns immediately. var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey); Assert.True(alreadySeeded, "SeedAudit should contain owner_created after initial seed"); if (alreadySeeded) { // EnsureDatabaseAsync returns here — owner is NOT touched } // Assert — after the "restart", the password hash must still be the changed one db.ChangeTracker.Clear(); var afterRestart = await db.Users.FirstAsync(u => u.Id == owner.Id); Assert.Equal(afterChange.PasswordHash, afterRestart.PasswordHash); Assert.NotEqual(originalHash, afterRestart.PasswordHash); Assert.True(PasswordSecurity.Verify(newPassword, afterRestart.PasswordHash, out _), "Changed password must still work after simulated restart"); Assert.False(PasswordSecurity.Verify("InitialOwnerPassword123!", afterRestart.PasswordHash, out _), "Original seed password must NOT work after simulated restart"); } /// /// Simulates a full restart: creates a completely new DbContext (simulating a new pod), /// and verifies the SeedAudit guard prevents owner re-seeding. /// [Fact] public async Task EnsureDatabaseAsync_NewDbContextAfterPasswordChange_PreservesChangedPassword() { // Arrange — create and seed the first "instance" var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; Guid ownerId; string changedHash; // First "pod run": seed owner + SeedAudit, then change password await using (var db1 = new NexusDbContext(options)) { const string initialPassword = "SeedPassword123!"; var owner = new NexusUser { Email = "owner@nexus.internal", NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"), DisplayName = "Nexus Owner", PasswordHash = PasswordSecurity.Hash(initialPassword), Role = "owner" }; db1.Users.Add(owner); db1.SeedAudits.Add(new SeedAudit { Key = SeedKey }); await db1.SaveChangesAsync(); ownerId = owner.Id; // Password change const string newPassword = "NewSecurePassword789!"; owner.PasswordHash = PasswordSecurity.Hash(newPassword); await db1.SaveChangesAsync(); changedHash = owner.PasswordHash; } // Act — second "pod run": new DbContext, simulate EnsureDatabaseAsync await using (var db2 = new NexusDbContext(options)) { var alreadySeeded = await db2.SeedAudits.AnyAsync(s => s.Key == SeedKey); Assert.True(alreadySeeded, "SeedAudit must persist across DbContext instances"); // EnsureDatabaseAsync would return here because alreadySeeded is true // No user creation or password reset happens var owner = await db2.Users.FirstAsync(u => u.Id == ownerId); Assert.Equal(changedHash, owner.PasswordHash); Assert.True(PasswordSecurity.Verify("NewSecurePassword789!", owner.PasswordHash, out _), "Changed password must survive a full simulated restart (new DbContext)"); Assert.False(PasswordSecurity.Verify("SeedPassword123!", owner.PasswordHash, out _), "Seed password must NOT work after restart"); } } /// /// Verifies that if all users are deleted but SeedAudit still has owner_created, /// a restart will NOT re-create the owner (the SeedAudit guard is the single /// source of truth — preventing password drift even if the user table is wiped). /// [Fact] public async Task EnsureDatabaseAsync_WithSeedAuditButNoUsers_DoesNotReSeedOwner() { // Arrange var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var db = new NexusDbContext(options); // SeedAudit exists from a prior run db.SeedAudits.Add(new SeedAudit { Key = SeedKey }); await db.SaveChangesAsync(); // Users table is empty (simulating a wiped DB or fresh volume with existing SeedAudit) var hasUsers = await db.Users.AnyAsync(); Assert.False(hasUsers); // Act — simulate restart: SeedAudit check var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey); Assert.True(alreadySeeded); // EnsureDatabaseAsync returns early because alreadySeeded is true if (alreadySeeded) { // No owner is created } // Assert — owner was NOT created (SeedAudit prevents re-seed) hasUsers = await db.Users.AnyAsync(); Assert.False(hasUsers, "SeedAudit should prevent owner re-creation even when Users table is empty"); } /// /// Verifies the baseline scenario: without SeedAudit, EnsureDatabaseAsync /// would proceed to seed a new owner (this is the pre-guard behavior, /// documented here for completeness). /// [Fact] public async Task EnsureDatabaseAsync_WithoutSeedAudit_WouldCreateOwner() { // Arrange var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; await using var db = new NexusDbContext(options); // No SeedAudit, no users — this is a fresh DB var alreadySeeded = await db.SeedAudits.AnyAsync(s => s.Key == SeedKey); Assert.False(alreadySeeded); var hasUsers = await db.Users.AnyAsync(); Assert.False(hasUsers); // Act — simulate the seed path (what EnsureDatabaseAsync would do when !alreadySeeded && !hasUsers) if (!alreadySeeded && !hasUsers) { // This is what EnsureDatabaseAsync would do: create owner + seed audit db.Users.Add(new NexusUser { Email = "owner@nexus.internal", NormalizedEmail = AuthService.NormalizeEmail("owner@nexus.internal"), DisplayName = "Nexus Owner", PasswordHash = PasswordSecurity.Hash("GeneratedTempPassword"), Role = "owner" }); db.SeedAudits.Add(new SeedAudit { Key = SeedKey }); await db.SaveChangesAsync(); } // Assert — owner now exists hasUsers = await db.Users.AnyAsync(); Assert.True(hasUsers); Assert.True(await db.SeedAudits.AnyAsync(s => s.Key == SeedKey)); } }