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"); } [TearDown] public void ResetJwt() => // JwtHelper is static global state; leave it disabled so tests don't leak JWT config into each other. JwtHelper.Init(new ConfigurationBuilder().Build()); private static AuthController AuthenticatedController(IMediator mediator) => new(mediator, Config(envSeed: false), ApiKeyProvider()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal( new ClaimsIdentity([new Claim(ClaimTypes.Name, "admin")], AuthConstants.CookieScheme)) } } }; [Test] public void IptvToken_Returns_401_When_Anonymous() { JwtHelper.Init(JwtConfig("this-is-a-sufficiently-long-signing-key-123456")); var controller = new AuthController(Substitute.For(), Config(envSeed: false), ApiKeyProvider()) { ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } }; IActionResult result = controller.IptvToken(); result.ShouldBeOfType().Value.ShouldBeOfType().Status.ShouldBe(401); } [Test] public void IptvToken_Returns_204_When_Jwt_Disabled() { // JWT off (TearDown default): /iptv/* is open, so there is nothing to mint — the SPA plays the plain URL. AuthController controller = AuthenticatedController(Substitute.For()); IActionResult result = controller.IptvToken(); result.ShouldBeOfType(); } [Test] public void IptvToken_Mints_A_Verifiable_Token_When_Jwt_Enabled() { const string SigningKey = "this-is-a-sufficiently-long-signing-key-123456"; JwtHelper.Init(JwtConfig(SigningKey)); AuthController controller = AuthenticatedController(Substitute.For()); var ok = controller.IptvToken().ShouldBeOfType(); var body = ok.Value.ShouldBeOfType(); body.Token.ShouldNotBeNullOrWhiteSpace(); body.ExpiresAt.ShouldBeGreaterThan(DateTimeOffset.UtcNow); // The minted token must validate against the same key/params the "jwt" scheme enforces at runtime // (signature + lifetime; issuer/audience are not validated). var handler = new System.IdentityModel.Tokens.Jwt.JwtSecurityTokenHandler(); Should.NotThrow(() => handler.ValidateToken( body.Token, new Microsoft.IdentityModel.Tokens.TokenValidationParameters { ValidateIssuer = false, ValidateAudience = false, ValidateIssuerSigningKey = true, IssuerSigningKey = new Microsoft.IdentityModel.Tokens.SymmetricSecurityKey( System.Text.Encoding.ASCII.GetBytes(SigningKey)), ValidateLifetime = true }, out _)); } [Test] public void IptvToken_Sets_CacheControl_NoStore_When_Jwt_Enabled() { JwtHelper.Init(JwtConfig("this-is-a-sufficiently-long-signing-key-123456")); AuthController controller = AuthenticatedController(Substitute.For()); controller.IptvToken(); HttpResponse response = controller.ControllerContext.HttpContext.Response; response.Headers.CacheControl.ToString().ShouldBe("no-store"); response.Headers.Pragma.ToString().ShouldBe("no-cache"); } private static IConfiguration JwtConfig(string signingKey, string? lifetimeMinutes = null) => new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { ["JWT:IssuerSigningKey"] = signingKey, ["JWT:BrowserTokenLifetimeMinutes"] = lifetimeMinutes }) .Build(); }