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>
108 lines
4.1 KiB
C#
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]
|
|
};
|
|
}
|