Cold review (no Critical/High). Folded: - Low: clamp JWT:BrowserTokenLifetimeMinutes to a 24h max so a seconds-vs-minutes typo can't mint a multi-year bearer token (non-positive/unparseable still falls back to 60 min). - Low: reset the SPA iptv-token cache on the preview panel's Retry and on each troubleshooting Play, so a stale token (key rotated) or a stale "JWT disabled" latch (backend reconfigured since page load) can't wedge a user-initiated retry. Deferred to #559 (tracked): redact access_token from Serilog request logs and set no-store on token-bearing /iptv manifests — pre-existing properties of the shared ?access_token= transport (Jellyfin/M3U already use it), now bounded by the 60-min lifetime; cross-cutting fixes beyond this feature's scope. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
81 lines
2.6 KiB
C#
81 lines
2.6 KiB
C#
using System.Collections.Generic;
|
|
using ErsatzTV;
|
|
using Microsoft.Extensions.Configuration;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests;
|
|
|
|
[TestFixture]
|
|
public class JwtHelperTests
|
|
{
|
|
private const string SigningKey = "this-is-a-sufficiently-long-signing-key-123456";
|
|
|
|
[TearDown]
|
|
public void ResetJwt() => JwtHelper.Init(new ConfigurationBuilder().Build());
|
|
|
|
private static IConfiguration Config(string? lifetimeMinutes) =>
|
|
new ConfigurationBuilder()
|
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
|
{
|
|
["JWT:IssuerSigningKey"] = SigningKey,
|
|
["JWT:BrowserTokenLifetimeMinutes"] = lifetimeMinutes
|
|
})
|
|
.Build();
|
|
|
|
[Test]
|
|
public void Init_Enables_When_Signing_Key_Present()
|
|
{
|
|
JwtHelper.Init(Config(null));
|
|
JwtHelper.IsEnabled.ShouldBeTrue();
|
|
}
|
|
|
|
[Test]
|
|
public void Browser_Token_Lifetime_Defaults_To_60_Minutes()
|
|
{
|
|
JwtHelper.Init(Config(null));
|
|
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(60));
|
|
}
|
|
|
|
[Test]
|
|
public void Browser_Token_Lifetime_Honors_Configured_Minutes()
|
|
{
|
|
JwtHelper.Init(Config("15"));
|
|
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(15));
|
|
}
|
|
|
|
[TestCase("0")]
|
|
[TestCase("-5")]
|
|
[TestCase("not-a-number")]
|
|
public void Browser_Token_Lifetime_Falls_Back_To_Default_On_Invalid(string value)
|
|
{
|
|
// A non-positive / unparseable value must not mint an already-expired token.
|
|
JwtHelper.Init(Config(value));
|
|
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(60));
|
|
}
|
|
|
|
[TestCase("1441")]
|
|
[TestCase("600000")]
|
|
[TestCase("2147483647")]
|
|
public void Browser_Token_Lifetime_Is_Clamped_To_The_Maximum(string value)
|
|
{
|
|
// A value above the documented max (24h) — including a seconds-vs-minutes typo — is clamped, never
|
|
// honored literally into a multi-year bearer token.
|
|
JwtHelper.Init(Config(value));
|
|
JwtHelper.BrowserTokenLifetime.ShouldBe(TimeSpan.FromMinutes(1440));
|
|
}
|
|
|
|
[Test]
|
|
public void GenerateBrowserToken_Expiry_Reflects_Configured_Lifetime()
|
|
{
|
|
JwtHelper.Init(Config("30"));
|
|
|
|
(string token, DateTimeOffset expiresAt) = JwtHelper.GenerateBrowserToken();
|
|
|
|
token.ShouldNotBeNullOrWhiteSpace();
|
|
// ~30 minutes out (allow a wide slack for slow CI without asserting an exact instant).
|
|
expiresAt.ShouldBeGreaterThan(DateTimeOffset.UtcNow.AddMinutes(29));
|
|
expiresAt.ShouldBeLessThan(DateTimeOffset.UtcNow.AddMinutes(31));
|
|
}
|
|
}
|