Files
ersatztv/ErsatzTV.Tests/Controllers/AuthControllerTests.cs
T
timothyandClaude Opus 4.8 ec26e1be5b fix(api): #316 review — POST-ify graphics-elements refresh, LockedError→409, no-store machine-key
- GET /api/graphics-elements no longer side-effects; refresh moved to
  POST /api/graphics-elements/refresh (204), closing a CSRF vector on a GET.
- PrepareTroubleshootingPlaybackHandler now returns a typed LockedError from
  both atomic lock-acquire failures; ApiResults.ToErrorResult maps it to 409
  instead of falling through to 422, so a lock lost in the race between the
  controller's pre-check and the handler's atomic acquire still reports 409.
- AuthController.MachineKey sets Cache-Control: no-store + Pragma: no-cache
  on the 200 response carrying the master API key.
- Reworded the stale "subtitleId query parameter" endpoint description now
  that playback/start takes a JSON body.
- Regenerated openapi/v1.json + docs/endpoint-index.md; docs/api-conventions.md
  updated with the LockedError pattern (§3a) and the ToErrorResult table row.

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

138 lines
5.1 KiB
C#

using System.Collections.Generic;
using System.Security.Claims;
using ErsatzTV.Application.Auth;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Services;
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();
private static IApiKeyProvider ApiKeyProvider(string key = "the-machine-key")
{
var provider = Substitute.For<IApiKeyProvider>();
provider.ApiKey.Returns(key);
return provider;
}
[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), ApiKeyProvider());
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), ApiKeyProvider());
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), ApiKeyProvider())
{
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>());
}
[Test]
public void MachineKey_Returns_401_When_Anonymous()
{
var mediator = Substitute.For<IMediator>();
var controller = new AuthController(mediator, Config(envSeed: false), ApiKeyProvider())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
IActionResult result = controller.MachineKey();
var unauthorized = result.ShouldBeOfType<UnauthorizedObjectResult>();
unauthorized.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(401);
}
[Test]
public void MachineKey_Returns_The_Key_For_An_Authenticated_Session()
{
var mediator = Substitute.For<IMediator>();
var controller = new AuthController(mediator, Config(envSeed: false), ApiKeyProvider("the-machine-key"))
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin")], AuthConstants.CookieScheme))
}
}
};
IActionResult result = controller.MachineKey();
var ok = result.ShouldBeOfType<OkObjectResult>();
ok.Value.ShouldBeOfType<MachineKeyResponse>().ApiKey.ShouldBe("the-machine-key");
}
[Test]
public void MachineKey_Sets_CacheControl_NoStore_For_An_Authenticated_Session()
{
var mediator = Substitute.For<IMediator>();
var httpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(
new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin")], AuthConstants.CookieScheme))
};
var controller = new AuthController(mediator, Config(envSeed: false), ApiKeyProvider("the-machine-key"))
{
ControllerContext = new ControllerContext { HttpContext = httpContext }
};
controller.MachineKey();
httpContext.Response.Headers.CacheControl.ToString().ShouldBe("no-store");
httpContext.Response.Headers.Pragma.ToString().ShouldBe("no-cache");
}
}