Files
ersatztv/ErsatzTV.Tests/Application/Channels/GetAllChannelsForApiHandlerTests.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

108 lines
4.1 KiB
C#

using ErsatzTV.Application.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetAllChannelsForApiHandlerTests
{
[Test]
public async Task Should_Project_Management_Table_Fields()
{
IChannelRepository repository = Substitute.For<IChannelRepository>();
repository.GetAll(Arg.Any<CancellationToken>())
.Returns([
new Channel(Guid.NewGuid())
{
Id = 7,
Number = "7.1",
SortNumber = 7.1,
Name = "Retro Cartoons",
Group = "Kids",
Categories = "animation",
FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" },
PreferredAudioLanguageCode = "eng",
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
IsEnabled = false,
ShowInEpg = false
}
]);
var handler = new GetAllChannelsForApiHandler(repository);
List<ChannelResponseModel> result = await handler.Handle(
new GetAllChannelsForApi(),
CancellationToken.None);
ChannelResponseModel channel = result.ShouldHaveSingleItem();
channel.Id.ShouldBe(7);
channel.Number.ShouldBe("7.1");
channel.SortNumber.ShouldBe(7.1);
channel.Name.ShouldBe("Retro Cartoons");
channel.Group.ShouldBe("Kids");
channel.Categories.ShouldBe("animation");
channel.FFmpegProfile.ShouldBe("HLS 720p");
channel.Language.ShouldBe("eng");
channel.StreamingMode.ShouldBe("HLS Segmenter");
channel.IsEnabled.ShouldBeFalse();
channel.ShowInEpg.ShouldBeFalse();
// No logo artwork -> null so the SPA renders its generated initials fallback.
channel.Logo.ShouldBeNull();
}
[Test]
public async Task Should_Root_Uploaded_Logo_Url()
{
IChannelRepository repository = Substitute.For<IChannelRepository>();
repository.GetAll(Arg.Any<CancellationToken>())
.Returns([NewChannel(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "abc123.png" })]);
var handler = new GetAllChannelsForApiHandler(repository);
List<ChannelResponseModel> result = await handler.Handle(
new GetAllChannelsForApi(),
CancellationToken.None);
// Uploaded logos are addressed as "iptv/logos/{file}"; the browse DTO roots it (leading slash) so
// the SPA's <img src> resolves against the site root regardless of the current SPA route.
result.ShouldHaveSingleItem().Logo.ShouldBe("/iptv/logos/abc123.png");
}
[Test]
public async Task Should_Pass_Through_External_Logo_Url()
{
IChannelRepository repository = Substitute.For<IChannelRepository>();
repository.GetAll(Arg.Any<CancellationToken>())
.Returns([NewChannel(new Artwork { ArtworkKind = ArtworkKind.Logo, Path = "https://example.com/logo.png" })]);
var handler = new GetAllChannelsForApiHandler(repository);
List<ChannelResponseModel> result = await handler.Handle(
new GetAllChannelsForApi(),
CancellationToken.None);
// An absolute external URL is directly usable and must pass through unchanged (no leading slash added).
result.ShouldHaveSingleItem().Logo.ShouldBe("https://example.com/logo.png");
}
private static Channel NewChannel(params Artwork[] artwork) =>
new(Guid.NewGuid())
{
Id = 7,
Number = "7.1",
SortNumber = 7.1,
Name = "Retro Cartoons",
Group = "Kids",
Categories = "animation",
FFmpegProfile = new FFmpegProfile { Name = "HLS 720p" },
PreferredAudioLanguageCode = "eng",
StreamingMode = StreamingMode.HttpLiveStreamingSegmenter,
IsEnabled = false,
ShowInEpg = false,
Artwork = [.. artwork]
};
}