Files
ersatztv/ErsatzTV.Tests/Controllers/AuthControllerTests.cs
T
timothyandClaude Opus 4.8 e8c3481ea5
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 8s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m24s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 11m9s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(api): #295 PR1 — fold in fix-commit re-review (2nd Codex round)
Fix-commit re-review confirmed the 1st-round fixes resolved and caught a 2nd round:

- HIGH — env-seed vs. setup race: an attacker could claim admin in the startup
  window before LocalAdminSeedService runs, and the seed's insert would then be
  swallowed (attacker credential persists, defeating env recovery). Fixed
  structurally: the setup-claim endpoint is CLOSED (409) whenever
  Auth:LocalAdmin:Password is configured — the env seed owns the credential, so
  there's no claim to race (also strengthens the setup-claim TOFU posture).
  Config.setupRequired reflects it.
- LOW — a concurrent setup race-loser now returns 409 (not 422); ClaimLocalAdmin's
  DbUpdateException catch re-checks existence and rethrows genuine/transient DB
  errors instead of masking them as "already configured".
- MEDIUM (accepted, documented) — two simultaneous authenticated password changes
  are a non-serializable lost-update; accepted for a single-admin system
  (self-healing via re-login, implausible timing).

+3 AuthController tests (env-seed closes setup / setupRequired gating). Full
ErsatzTV.Tests green (1506); no generated drift. Docs updated.

Refs #295

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 16:52:23 +02:00

72 lines
2.7 KiB
C#

using System.Collections.Generic;
using ErsatzTV.Application.Auth;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
[TestFixture]
public class AuthControllerTests
{
private static IConfiguration Config(bool envSeed) =>
new ConfigurationBuilder()
.AddInMemoryCollection(
envSeed
? new Dictionary<string, string?> { ["Auth:LocalAdmin:Password"] = "seed-password" }
: new Dictionary<string, string?>())
.Build();
[Test]
public async Task Config_Reports_Setup_Not_Required_When_Env_Seed_Configured()
{
var mediator = Substitute.For<IMediator>();
mediator.Send(Arg.Any<IsLocalAdminConfigured>(), Arg.Any<CancellationToken>()).Returns(false);
var controller = new AuthController(mediator, Config(envSeed: true));
var result = await controller.Config(CancellationToken.None) as OkObjectResult;
var body = result!.Value.ShouldBeOfType<AuthConfigResponse>();
// Env seed owns the credential → the SPA must not offer the browser setup-claim.
body.SetupRequired.ShouldBeFalse();
}
[Test]
public async Task Config_Reports_Setup_Required_When_Unconfigured_And_No_Env_Seed()
{
var mediator = Substitute.For<IMediator>();
mediator.Send(Arg.Any<IsLocalAdminConfigured>(), Arg.Any<CancellationToken>()).Returns(false);
var controller = new AuthController(mediator, Config(envSeed: false));
var result = await controller.Config(CancellationToken.None) as OkObjectResult;
var body = result!.Value.ShouldBeOfType<AuthConfigResponse>();
body.SetupRequired.ShouldBeTrue();
}
[Test]
public async Task Setup_Is_Closed_With_409_When_Env_Seed_Configured()
{
var mediator = Substitute.For<IMediator>();
var controller = new AuthController(mediator, Config(envSeed: true))
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
var result = await controller.Setup(new SetupRequest("admin", "hunter2pw"), CancellationToken.None);
var problem = result.ShouldBeOfType<ConflictObjectResult>();
problem.StatusCode.ShouldBe(StatusCodes.Status409Conflict);
// The claim must never be attempted while the env seed owns the credential.
await mediator.DidNotReceive().Send(Arg.Any<ClaimLocalAdmin>(), Arg.Any<CancellationToken>());
}
}