c3e0e6913b
Adds 4 tests in EnsureDatabaseSeedAuditTests: - EnsureDatabaseAsync_WithSeedAuditOwnerCreated_DoesNotResetOwnerPasswordHash - EnsureDatabaseAsync_NewDbContextAfterPasswordChange_PreservesChangedPassword - EnsureDatabaseAsync_WithSeedAuditButNoUsers_DoesNotReSeedOwner - EnsureDatabaseAsync_WithoutSeedAudit_WouldCreateOwner Verifies that once SeedAudit contains owner_created, subsequent EnsureDatabaseAsync calls (simulating pod restarts) do NOT reset the owner's password hash. Covers the owner_created SeedAudit guard.
233 lines
9.6 KiB
C#
233 lines
9.6 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Nexus.Api.Data;
|
|
using Nexus.Api.Services;
|
|
using Xunit;
|
|
|
|
namespace Nexus.Api.Tests;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
public sealed class EnsureDatabaseSeedAuditTests
|
|
{
|
|
private const string SeedKey = "owner_created";
|
|
|
|
/// <summary>
|
|
/// Creates an in-memory DbContext pre-seeded with an owner user and a SeedAudit row.
|
|
/// </summary>
|
|
private static async Task<(NexusDbContext db, NexusUser owner, string originalHash)> CreateSeededFixtureAsync()
|
|
{
|
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
|
.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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Simulates the restart guard: if SeedAudit contains owner_created,
|
|
/// the owner password hash must not be changed to a newly generated hash.
|
|
/// </summary>
|
|
[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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Simulates a full restart: creates a completely new DbContext (simulating a new pod),
|
|
/// and verifies the SeedAudit guard prevents owner re-seeding.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task EnsureDatabaseAsync_NewDbContextAfterPasswordChange_PreservesChangedPassword()
|
|
{
|
|
// Arrange — create and seed the first "instance"
|
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
|
.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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task EnsureDatabaseAsync_WithSeedAuditButNoUsers_DoesNotReSeedOwner()
|
|
{
|
|
// Arrange
|
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
|
.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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the baseline scenario: without SeedAudit, EnsureDatabaseAsync
|
|
/// would proceed to seed a new owner (this is the pre-guard behavior,
|
|
/// documented here for completeness).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task EnsureDatabaseAsync_WithoutSeedAudit_WouldCreateOwner()
|
|
{
|
|
// Arrange
|
|
var options = new DbContextOptionsBuilder<NexusDbContext>()
|
|
.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));
|
|
}
|
|
}
|