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 { ["Auth:LocalAdmin:Password"] = "seed-password" } : new Dictionary()) .Build(); private static IApiKeyProvider ApiKeyProvider(string key = "the-machine-key") { var provider = Substitute.For(); provider.ApiKey.Returns(key); return provider; } [Test] public async Task Config_Reports_Setup_Not_Required_When_Env_Seed_Configured() { var mediator = Substitute.For(); mediator.Send(Arg.Any(), Arg.Any()).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(); // 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(); mediator.Send(Arg.Any(), Arg.Any()).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(); body.SetupRequired.ShouldBeTrue(); } [Test] public async Task Setup_Is_Closed_With_409_When_Env_Seed_Configured() { var mediator = Substitute.For(); 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(); problem.StatusCode.ShouldBe(StatusCodes.Status409Conflict); // The claim must never be attempted while the env seed owns the credential. await mediator.DidNotReceive().Send(Arg.Any(), Arg.Any()); } [Test] public void MachineKey_Returns_401_When_Anonymous() { var mediator = Substitute.For(); var controller = new AuthController(mediator, Config(envSeed: false), ApiKeyProvider()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; IActionResult result = controller.MachineKey(); var unauthorized = result.ShouldBeOfType(); unauthorized.Value.ShouldBeOfType().Status.ShouldBe(401); } [Test] public void MachineKey_Returns_The_Key_For_An_Authenticated_Session() { var mediator = Substitute.For(); 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(); ok.Value.ShouldBeOfType().ApiKey.ShouldBe("the-machine-key"); } [Test] public void MachineKey_Sets_CacheControl_NoStore_For_An_Authenticated_Session() { var mediator = Substitute.For(); 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"); } }