- 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>
138 lines
5.1 KiB
C#
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");
|
|
}
|
|
}
|