Files
ersatztv/ErsatzTV.Tests/Application/Auth/ClaimLocalAdminHandlerTests.cs
T
timothyandClaude Opus 4.8 6ac5150fd0
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
fix(api): #295 PR1 — fold in cold-fork + Codex review findings
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>
2026-07-12 16:40:25 +02:00

143 lines
5.1 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 ClaimLocalAdminHandlerTests
{
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 ClaimLocalAdminHandler MakeHandler() =>
new(_db.Factory, _passwordHasher);
[Test]
public async Task Handle_Should_Claim_Fresh_Admin_And_Persist_All_Config_Elements()
{
ClaimLocalAdminHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ClaimLocalAdmin("Operator", "supersecret"),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
LocalAdminPrincipal principal = result.Match(
Left: e => throw new ShouldAssertException(e.ToString()),
Right: p => p);
principal.Username.ShouldBe("Operator");
principal.SecurityStamp.ShouldNotBeNullOrEmpty();
Option<string> storedUser = await _configElementRepository.GetValue<string>(
ConfigElementKey.AuthLocalAdminUsername,
CancellationToken.None);
storedUser.IfNone("").ShouldBe("Operator");
Option<string> storedStamp = await _configElementRepository.GetValue<string>(
ConfigElementKey.AuthSecurityStamp,
CancellationToken.None);
storedStamp.IfNone("").ShouldBe(principal.SecurityStamp);
Option<string> storedHash = await _configElementRepository.GetValue<string>(
ConfigElementKey.AuthLocalAdminPasswordHash,
CancellationToken.None);
storedHash.IsSome.ShouldBeTrue();
_passwordHasher.Verify(storedHash.IfNone(""), "supersecret")
.ShouldNotBe(LocalPasswordVerification.Failed);
}
[Test]
public async Task Handle_Should_Refuse_Second_Claim_When_Admin_Already_Configured()
{
ClaimLocalAdminHandler handler = MakeHandler();
(await handler.Handle(new ClaimLocalAdmin("First", "supersecret"), CancellationToken.None))
.IsRight.ShouldBeTrue();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ClaimLocalAdmin("Second", "anothersecret"),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
// The original credential is untouched.
Option<string> storedUser = await _configElementRepository.GetValue<string>(
ConfigElementKey.AuthLocalAdminUsername,
CancellationToken.None);
storedUser.IfNone("").ShouldBe("First");
}
[Test]
public async Task Handle_Should_Reject_Whitespace_Username_And_Persist_Nothing()
{
ClaimLocalAdminHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ClaimLocalAdmin(" ", "supersecret"),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
await AssertNothingPersisted();
}
[Test]
public async Task Handle_Should_Reject_Short_Password_And_Persist_Nothing()
{
string shortPassword = new('a', AuthConstants.MinPasswordLength - 1);
ClaimLocalAdminHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ClaimLocalAdmin("Operator", shortPassword),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
await AssertNothingPersisted();
}
[Test]
public async Task Handle_Should_Reject_Over_Long_Password_And_Persist_Nothing()
{
// LocalAdminHelpers.MaxPasswordLength (1024) is internal; use the documented bound directly.
string longPassword = new('a', 1024 + 1);
ClaimLocalAdminHandler handler = MakeHandler();
Either<BaseError, LocalAdminPrincipal> result = await handler.Handle(
new ClaimLocalAdmin("Operator", longPassword),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
await AssertNothingPersisted();
}
private async Task AssertNothingPersisted()
{
(await _configElementRepository.GetConfigElement(
ConfigElementKey.AuthLocalAdminUsername,
CancellationToken.None)).IsNone.ShouldBeTrue();
(await _configElementRepository.GetConfigElement(
ConfigElementKey.AuthLocalAdminPasswordHash,
CancellationToken.None)).IsNone.ShouldBeTrue();
(await _configElementRepository.GetConfigElement(
ConfigElementKey.AuthSecurityStamp,
CancellationToken.None)).IsNone.ShouldBeTrue();
}
}