Files
ersatztv/ErsatzTV.Tests/Controllers/AuthControllerTests.cs
T
timothyandClaude Opus 4.8 f8ae4d62ab fix(552): mint a short-lived JWT so the SPA reaches /iptv/* under JWT auth
Under a JWT-enabled deployment (JWT:IssuerSigningKey set), /iptv/* is gated by
ConditionalIptvAuthorizeFilter and the "jwt" scheme does not accept the SPA's
ctv-session cookie, and nothing minted a JWT for the browser. So the #60 channel
preview was declared Unavailable and could not run at all.

Add GET /api/v1/auth/iptv-token (session-gated, on the [IgnoreApi] AuthController):
mints a short-lived global token via JwtHelper.GenerateBrowserToken (60 min default,
JWT:BrowserTokenLifetimeMinutes override), 204 when JWT is disabled. The SPA's new
withIptvToken(url) helper appends it as ?access_token= to the manifest URL (a no-op
when JWT is off), used by the channel-preview panel and the troubleshooting screen.
Mapper.GetPreview drops its iptvJwtEnabled -> Unavailable guard; preview is now
JWT-agnostic.

Live-E2E under JWT: /iptv manifest 401s without a token and passes with a valid one
(garbage token -> 401); token endpoint 401s anonymous, mints with a session.

Honest finding: the issue's point 2 (troubleshooting screen broken under JWT) does
not reproduce -- its live.m3u8 is static-served (UseStaticFiles at /iptv/session),
outside the JWT filter, so it was never gated. The withIptvToken call there is a
harmless defensive no-op.

Docs: security.iptv-browser-token (api-auth-security.md), amended
api.channel-preview-capability, spa-conventions §5b. No OpenAPI change (IgnoreApi +
unchanged Preview schema).

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

233 lines
9.0 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");
}
[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<IMediator>(), Config(envSeed: false), ApiKeyProvider())
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
};
IActionResult result = controller.IptvToken();
result.ShouldBeOfType<UnauthorizedObjectResult>().Value.ShouldBeOfType<ProblemDetails>().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<IMediator>());
IActionResult result = controller.IptvToken();
result.ShouldBeOfType<NoContentResult>();
}
[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<IMediator>());
var ok = controller.IptvToken().ShouldBeOfType<OkObjectResult>();
var body = ok.Value.ShouldBeOfType<IptvTokenResponse>();
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<IMediator>());
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<string, string?>
{
["JWT:IssuerSigningKey"] = signingKey,
["JWT:BrowserTokenLifetimeMinutes"] = lifetimeMinutes
})
.Build();
}