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 ChangeLocalAdminPasswordHandlerTests { private InMemoryTvContext _db = null!; private IConfigElementRepository _configElementRepository = null!; private ILocalPasswordHasher _passwordHasher = null!; private const string Username = "Operator"; private const string CurrentPassword = "supersecret"; private const string NewPassword = "evenbettersecret"; [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 ChangeLocalAdminPasswordHandler MakeHandler() => new(_db.Factory, _passwordHasher); private async Task SeedAdmin() { var claim = new ClaimLocalAdminHandler(_db.Factory, _passwordHasher); (await claim.Handle(new ClaimLocalAdmin(Username, CurrentPassword), CancellationToken.None)) .IsRight.ShouldBeTrue(); } private async Task StoredHash() => (await _configElementRepository.GetValue( ConfigElementKey.AuthLocalAdminPasswordHash, CancellationToken.None)).IfNone(""); private async Task StoredStamp() => (await _configElementRepository.GetValue( ConfigElementKey.AuthSecurityStamp, CancellationToken.None)).IfNone(""); [Test] public async Task Handle_Should_Change_Password_And_Rotate_Stamp_On_Valid_Current_Password() { await SeedAdmin(); string originalStamp = await StoredStamp(); ChangeLocalAdminPasswordHandler handler = MakeHandler(); Either result = await handler.Handle( new ChangeLocalAdminPassword(Username, CurrentPassword, NewPassword), CancellationToken.None); result.IsRight.ShouldBeTrue(); string storedHash = await StoredHash(); _passwordHasher.Verify(storedHash, NewPassword).ShouldNotBe(LocalPasswordVerification.Failed); _passwordHasher.Verify(storedHash, CurrentPassword).ShouldBe(LocalPasswordVerification.Failed); string newStamp = await StoredStamp(); newStamp.ShouldNotBe(originalStamp); LocalAdminPrincipal principal = result.Match( Left: e => throw new ShouldAssertException(e.ToString()), Right: p => p); principal.SecurityStamp.ShouldBe(newStamp); } [Test] public async Task Handle_Should_Fail_On_Wrong_Current_Password_And_Change_Nothing() { await SeedAdmin(); string originalHash = await StoredHash(); string originalStamp = await StoredStamp(); ChangeLocalAdminPasswordHandler handler = MakeHandler(); Either result = await handler.Handle( new ChangeLocalAdminPassword(Username, "notthecurrentpassword", NewPassword), CancellationToken.None); result.IsLeft.ShouldBeTrue(); (await StoredHash()).ShouldBe(originalHash); (await StoredStamp()).ShouldBe(originalStamp); } [Test] public async Task Handle_Should_Reject_Short_New_Password() { await SeedAdmin(); string shortPassword = new('a', AuthConstants.MinPasswordLength - 1); ChangeLocalAdminPasswordHandler handler = MakeHandler(); Either result = await handler.Handle( new ChangeLocalAdminPassword(Username, CurrentPassword, shortPassword), CancellationToken.None); result.IsLeft.ShouldBeTrue(); } [Test] public async Task Handle_Should_Fail_On_Unconfigured_Db() { ChangeLocalAdminPasswordHandler handler = MakeHandler(); Either result = await handler.Handle( new ChangeLocalAdminPassword(Username, CurrentPassword, NewPassword), CancellationToken.None); result.IsLeft.ShouldBeTrue(); } }