Files
ersatztv/ErsatzTV.Tests/JwtHelperTests.cs
T
timothyandClaude Opus 4.8 60a0c50578 fix(552): fold #552 security-review findings
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>
2026-07-22 17:34:01 +02:00

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));
}
}