using ErsatzTV.Application.Auth; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Infrastructure.Data.Repositories; using ErsatzTV.Tests.Support; using LanguageExt; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Auth; [TestFixture] public class SeedLocalAdminFromEnvironmentHandlerTests { private InMemoryTvContext _db = null!; private IConfigElementRepository _configElementRepository = null!; private ILocalPasswordHasher _passwordHasher = null!; [SetUp] public async Task SetUp() { _db = await InMemoryTvContext.CreateAsync(); _configElementRepository = new ConfigElementRepository(_db.Factory); _passwordHasher = new LocalPasswordHasher(); } [TearDown] public async Task TearDown() => await _db.DisposeAsync(); private SeedLocalAdminFromEnvironmentHandler MakeHandler() => new(_db.Factory, _passwordHasher); private async Task StoredValue(ConfigElementKey key) => (await _configElementRepository.GetValue(key, CancellationToken.None)).IfNone(""); [Test] public async Task Handle_Should_Seed_Username_Hash_And_Stamp_On_Fresh_Db() { SeedLocalAdminFromEnvironmentHandler handler = MakeHandler(); Either result = await handler.Handle( new SeedLocalAdminFromEnvironment("Operator", "supersecret"), CancellationToken.None); result.IsRight.ShouldBeTrue(); (await StoredValue(ConfigElementKey.AuthLocalAdminUsername)).ShouldBe("Operator"); (await StoredValue(ConfigElementKey.AuthSecurityStamp)).ShouldNotBeNullOrEmpty(); _passwordHasher.Verify(await StoredValue(ConfigElementKey.AuthLocalAdminPasswordHash), "supersecret") .ShouldNotBe(LocalPasswordVerification.Failed); } [Test] public async Task Handle_Should_Overwrite_Existing_Credential_And_Rotate_Stamp() { var claim = new ClaimLocalAdminHandler(_db.Factory, _passwordHasher); (await claim.Handle(new ClaimLocalAdmin("Original", "originalsecret"), CancellationToken.None)) .IsRight.ShouldBeTrue(); string originalStamp = await StoredValue(ConfigElementKey.AuthSecurityStamp); SeedLocalAdminFromEnvironmentHandler handler = MakeHandler(); Either result = await handler.Handle( new SeedLocalAdminFromEnvironment("Replacement", "replacementsecret"), CancellationToken.None); result.IsRight.ShouldBeTrue(); (await StoredValue(ConfigElementKey.AuthLocalAdminUsername)).ShouldBe("Replacement"); (await StoredValue(ConfigElementKey.AuthSecurityStamp)).ShouldNotBe(originalStamp); string storedHash = await StoredValue(ConfigElementKey.AuthLocalAdminPasswordHash); _passwordHasher.Verify(storedHash, "replacementsecret").ShouldNotBe(LocalPasswordVerification.Failed); _passwordHasher.Verify(storedHash, "originalsecret").ShouldBe(LocalPasswordVerification.Failed); } [Test] public async Task Handle_Should_Default_Empty_Username_To_Admin() { SeedLocalAdminFromEnvironmentHandler handler = MakeHandler(); Either result = await handler.Handle( new SeedLocalAdminFromEnvironment(" ", "supersecret"), CancellationToken.None); result.IsRight.ShouldBeTrue(); (await StoredValue(ConfigElementKey.AuthLocalAdminUsername)).ShouldBe("admin"); } [Test] public async Task Handle_Should_Reject_Over_Long_Username() { // LocalAdminHelpers.MaxUsernameLength (256) is internal; use the documented bound directly. string longUsername = new('u', 256 + 1); SeedLocalAdminFromEnvironmentHandler handler = MakeHandler(); Either result = await handler.Handle( new SeedLocalAdminFromEnvironment(longUsername, "supersecret"), CancellationToken.None); result.IsLeft.ShouldBeTrue(); (await _configElementRepository.GetConfigElement( ConfigElementKey.AuthLocalAdminUsername, CancellationToken.None)).IsNone.ShouldBeTrue(); } [Test] public async Task Handle_Should_Reject_Short_Password() { string shortPassword = new('a', AuthConstants.MinPasswordLength - 1); SeedLocalAdminFromEnvironmentHandler handler = MakeHandler(); Either result = await handler.Handle( new SeedLocalAdminFromEnvironment("Operator", shortPassword), CancellationToken.None); result.IsLeft.ShouldBeTrue(); (await _configElementRepository.GetConfigElement( ConfigElementKey.AuthLocalAdminPasswordHash, CancellationToken.None)).IsNone.ShouldBeTrue(); } }