Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m22s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Independent review (cold fork = MERGEABLE-WITH-NITS; Codex = BLOCKED, caught concurrency defects the fork missed). All actionable findings folded in: - HIGH (Codex) atomic first-claim-wins: ClaimLocalAdmin now writes the three credential rows in ONE transaction guarded by the unique ConfigElement.Key index (lost race -> DbUpdateException -> 409), so concurrent claims can't produce a mixed-state credential. - HIGH (Codex) consistent login snapshot: VerifyLocalAdminLogin reads hash+stamp in one query and drops rehash-on-verify, so a login racing a password change can't capture a stamp newer than the hash it verified (concurrent change -> old password fails, or the issued cookie carries the pre-change stamp -> revoked next request). - MEDIUM (Codex) env-seed migration race: LocalAdminSeedService is now a RunOnce BackgroundService that awaits SystemStartup.WaitForDatabase (the migrator is a BackgroundService; registration order didn't guarantee the schema) + try/catch. - MEDIUM (fork M1) ForwardedHeaders: reverted the strict-opt-in flip — it would regress /iptv M3U/XMLTV/HLS absolute-URL generation (Request.Scheme) behind a proxy without KnownProxies. Kept #285 behavior; KnownProxies still recommended. - LOW (Codex/fork) require X-CSRF on /api/auth/logout + /password (the [SkipApiAuthorization] surface isn't covered by the filter's CSRF check; closes forced-logout CSRF). - ChangeLocalAdminPassword also writes hash+stamp atomically. Input length caps on username/password. Deferred with a tracked gate: MEDIUM (Codex) side-effecting [RequiresAuthentication] GETs (troubleshoot playback/archive) aren't CSRF-covered -> #301, gates PR2 (latent in PR1: the SPA still uses the machine key). Verify: full ErsatzTV.Tests green (1501); no OpenAPI/generated drift. Docs updated (api-conventions §9, decisions.md). Refs #295 #301 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
123 lines
4.3 KiB
C#
123 lines
4.3 KiB
C#
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<string> StoredHash() =>
|
|
(await _configElementRepository.GetValue<string>(
|
|
ConfigElementKey.AuthLocalAdminPasswordHash,
|
|
CancellationToken.None)).IfNone("");
|
|
|
|
private async Task<string> StoredStamp() =>
|
|
(await _configElementRepository.GetValue<string>(
|
|
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<BaseError, LocalAdminPrincipal> 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<BaseError, LocalAdminPrincipal> 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<BaseError, LocalAdminPrincipal> 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<BaseError, LocalAdminPrincipal> result = await handler.Handle(
|
|
new ChangeLocalAdminPassword(Username, CurrentPassword, NewPassword),
|
|
CancellationToken.None);
|
|
|
|
result.IsLeft.ShouldBeTrue();
|
|
}
|
|
}
|