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
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>
232 lines
10 KiB
C#
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");
|
|
}
|
|
}
|