Files
ersatztv/ErsatzTV.Tests/LegacyUiRedirectsTests.cs
T
timothyandClaude Opus 4.8 495e450e2c
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m41s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 5m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m11s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 6m48s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 3m42s
test(ui): extend redirect guard meta-test to Tier-2 pattern templates (#204 review)
Fork adversarial review nit: Map_Keys_Should_Not_Begin_With_Forbidden_Prefix
covered only Tier-1 Map keys, not the Tier-2 PatternRule templates. That guard
invariant is the load-bearing protection for the un-prefix-guarded /api|/artwork|
/docs|/openapi surface, so make it self-enforcing over ALL rules — a future
prefix-violating template now fails the test instead of slipping through.

Exposes internal LegacyUiRedirects.PatternTemplates (InternalsVisibleTo already
set for ErsatzTV.Tests); stores the raw template on PatternRule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 16:45:03 +02:00

232 lines
10 KiB
C#

using Microsoft.AspNetCore.Http;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests;
[TestFixture]
public class LegacyUiRedirectsTests
{
private static readonly string StartupSource = File.ReadAllText(FindStartupPath());
[Test]
public void Every_Mapping_Should_Resolve()
{
foreach ((string from, string to) in LegacyUiRedirects.Map)
{
LegacyUiRedirects.TryGetRedirect(new PathString(from), out string target).ShouldBeTrue();
target.ShouldBe(to);
}
}
[TestCase("/", "/app")]
[TestCase("/channels", "/app/channels")]
[TestCase("/channels/add", "/app/new-channel")]
[TestCase("/schedules", "/app/schedules")]
[TestCase("/playouts", "/app/playouts")]
[TestCase("/media/libraries", "/app/libraries")]
[TestCase("/settings/ffmpeg", "/app/settings/streaming")]
[TestCase("/settings/hdhr", "/app/settings/system")]
[TestCase("/settings/logging", "/app/settings/logging")]
[TestCase("/settings/playout", "/app/settings/playout")]
[TestCase("/settings/scanner", "/app/settings/scanner")]
[TestCase("/settings/ui", "/app/settings/general")]
[TestCase("/settings/xmltv", "/app/settings/xmltv")]
// (A) parameterless routes migrated in #204 (one spot-check per new group)
[TestCase("/channels/numbers", "/app/channels")]
[TestCase("/system/troubleshooting/block-playout", "/app/troubleshooting/blocks")]
[TestCase("/media/browser/images", "/app/media/images/browser")]
[TestCase("/schedules/add", "/app/schedules")]
// (B) ".../add" sub-actions
[TestCase("/playouts/add", "/app/playouts")]
[TestCase("/ffmpeg/add", "/app/ffmpeg-profiles")]
// (E-base) browse roots
[TestCase("/media/movies", "/app/media?kind=movies")]
[TestCase("/media/remote/streams", "/app/media?kind=remote-streams")]
public void Known_Route_Should_Redirect(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
// (C) id-substituting
[TestCase("/channels/5", "/app/edit-channel/5")]
[TestCase("/blocks/12", "/app/blocks/12")]
[TestCase("/deco-templates/3", "/app/deco-templates/3")]
[TestCase("/media/trakt/lists/11", "/app/trakt-lists/11")]
[TestCase("/media/movies/42", "/app/media/movies/42")]
[TestCase("/media/tv/shows/7", "/app/media/shows/7")]
[TestCase("/media/tv/seasons/8", "/app/media/seasons/8")]
[TestCase("/media/music/artists/9", "/app/media/artists/9")]
// (C2) mid-path
[TestCase("/playouts/9/templates", "/app/playouts/9/templates")]
[TestCase("/playouts/9/alternate-schedules", "/app/playouts/9/alternate-schedules")]
// (D) id-dropping
[TestCase("/schedules/4", "/app/schedules")]
[TestCase("/schedules/4/items", "/app/schedules")]
[TestCase("/playouts/classic/2", "/app/playouts")]
[TestCase("/playouts/block/2", "/app/playouts")]
[TestCase("/playouts/scripted/2", "/app/playouts")]
[TestCase("/playouts/sequential/2", "/app/playouts")]
[TestCase("/playouts/add/classic", "/app/playouts")]
[TestCase("/media/collections/8", "/app/collections")]
[TestCase("/media/collections/8/edit", "/app/collections")]
[TestCase("/media/multi-collections/8/edit", "/app/multi-collections")]
[TestCase("/media/rerun-collections/8/edit", "/app/rerun-collections")]
[TestCase("/media/filler/presets/8/edit", "/app/filler-presets")]
[TestCase("/ffmpeg/6", "/app/ffmpeg-profiles")]
[TestCase("/watermarks/6", "/app/watermarks")]
// (E-page) paging
[TestCase("/media/movies/page/3", "/app/media?kind=movies")]
[TestCase("/media/tv/shows/page/2", "/app/media?kind=shows")]
[TestCase("/media/tv/seasons/page/2", "/app/media?kind=seasons")]
[TestCase("/media/tv/episodes/page/2", "/app/media?kind=episodes")]
[TestCase("/media/music/artists/page/2", "/app/media?kind=artists")]
[TestCase("/media/music/videos/page/2", "/app/media?kind=music-videos")]
[TestCase("/media/music/songs/page/2", "/app/media?kind=songs")]
[TestCase("/media/other/videos/page/2", "/app/media?kind=other-videos")]
[TestCase("/media/remote/streams/page/2", "/app/media?kind=remote-streams")]
[TestCase("/media/images/page/2", "/app/media?kind=images")]
public void Pattern_Route_Should_Redirect(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[TestCase("/channels/", "/app/channels")]
[TestCase("/schedules/", "/app/schedules")]
[TestCase("/settings/ffmpeg/", "/app/settings/streaming")]
// trailing slash on parameterized/pattern routes
[TestCase("/channels/5/", "/app/edit-channel/5")]
[TestCase("/playouts/9/templates/", "/app/playouts/9/templates")]
[TestCase("/media/movies/", "/app/media?kind=movies")]
public void Trailing_Slash_Should_Match(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[Test]
public void Lookup_Should_Be_Case_Insensitive()
{
LegacyUiRedirects.TryGetRedirect(new PathString("/Channels"), out string target).ShouldBeTrue();
target.ShouldBe("/app/channels");
}
[TestCase("/Channels/5", "/app/edit-channel/5")]
[TestCase("/MEDIA/TV/SHOWS/7", "/app/media/shows/7")]
public void Pattern_Lookup_Should_Be_Case_Insensitive(string path, string expected)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeTrue();
target.ShouldBe(expected);
}
[TestCase("/system/health")] // Blazor home escape hatch
[TestCase("/media/sources/plex")] // media sources have no SPA UI (#202)
[TestCase("/media/sources/jellyfin")]
[TestCase("/api/foo")] // API surface
[TestCase("/artwork/1")] // artwork surface
[TestCase("/docs")] // docs surface
[TestCase("/openapi")] // openapi surface
[TestCase("/app")] // already the SPA
[TestCase("/app/channels")] // already the SPA
[TestCase("/iptv/channels.m3u")] // IPTV surface
[TestCase("/api/health")] // API surface
[TestCase("")] // empty
[TestCase("//")] // all-slash path must not collapse to root "/"
[TestCase("/channels//")] // double trailing slash is not normalized to a match
// strict id validation — these must all fall through
[TestCase("/channels/abc")]
[TestCase("/channels/0")]
[TestCase("/channels/-1")]
[TestCase("/channels/1.5")]
[TestCase("/channels/999999999999")]
[TestCase("/channels//5")] // empty segment: must not match /channels/{id}
[TestCase("/media/movies/page/abc")]
[TestCase("/media/movies/page/0")]
[TestCase("/playouts/templates")] // no id segment
[TestCase("/media/movies/page")] // 3-seg, non-numeric tail
public void Non_Migrated_Route_Should_Not_Redirect(string path)
{
LegacyUiRedirects.TryGetRedirect(new PathString(path), out string target).ShouldBeFalse();
target.ShouldBe(string.Empty);
}
[TestCase("/app/media?kind=movies", "?page=2", "/app/media?kind=movies&page=2")]
[TestCase("/app/media?kind=movies", "", "/app/media?kind=movies")]
[TestCase("/app/channels", "?x=1", "/app/channels?x=1")]
[TestCase("/app/channels", "", "/app/channels")]
[TestCase("/app/media?kind=movies", "?kind=shows", "/app/media?kind=movies&kind=shows")]
public void AppendQueryString_Should_Merge(string target, string query, string expected)
{
LegacyUiRedirects.AppendQueryString(target, new QueryString(query)).ShouldBe(expected);
}
[Test]
public void Map_Keys_Should_Not_Begin_With_Forbidden_Prefix()
{
string[] forbidden =
{
"/api", "/artwork", "/docs", "/openapi", "/iptv", "/app", "/media/sources"
};
foreach (string key in LegacyUiRedirects.Map.Keys)
{
foreach (string prefix in forbidden)
{
key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
.ShouldBeFalse($"Map key '{key}' must not begin with forbidden prefix '{prefix}'");
}
}
// The guard invariant is load-bearing for the un-prefix-guarded /api|/artwork|
// /docs|/openapi surface, so it must cover the Tier-2 pattern templates too —
// not only the Tier-1 Map keys — or a future prefix-violating rule would slip in.
foreach (string template in LegacyUiRedirects.PatternTemplates)
{
foreach (string prefix in forbidden)
{
template.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
.ShouldBeFalse($"Pattern template '{template}' must not begin with forbidden prefix '{prefix}'");
}
}
}
[Test]
public void Startup_Should_Redirect_Legacy_Routes_In_Blazor_Branch_Before_Routing()
{
// The redirect middleware must be wired inside the blazor branch and run
// before UseRouting so migrated routes never reach the Blazor fallback.
int redirectIndex = StartupSource.IndexOf("LegacyUiRedirects.TryGetRedirect", StringComparison.Ordinal);
redirectIndex.ShouldBeGreaterThan(-1);
// The Blazor branch's UseRouting call that follows the redirect middleware.
int routingIndex = StartupSource.IndexOf("blazor.UseRouting()", StringComparison.Ordinal);
routingIndex.ShouldBeGreaterThan(-1);
redirectIndex.ShouldBeLessThan(routingIndex);
// 302 (temporary), not a permanent redirect; incoming query merged via AppendQueryString.
StartupSource.ShouldContain("context.Request.PathBase + LegacyUiRedirects.AppendQueryString(target");
StartupSource.ShouldNotContain("RedirectPermanent(target");
}
private static string FindStartupPath()
{
DirectoryInfo? directory = new(TestContext.CurrentContext.TestDirectory);
while (directory is not null)
{
string candidate = Path.Combine(directory.FullName, "ErsatzTV", "Startup.cs");
if (File.Exists(candidate))
{
return candidate;
}
directory = directory.Parent;
}
throw new FileNotFoundException("Could not find ErsatzTV/Startup.cs");
}
}