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-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>
72 lines
2.7 KiB
C#
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>());
|
|
}
|
|
}
|