Files
ersatztv/ErsatzTV.Tests/Controllers/ApiRouteVersioningTests.cs
T
timothyandClaude Opus 4.8 682dceec8f
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 9s
Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 9s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m5s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 2m6s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m11s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m2s
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 / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 3m49s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 7m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 5m43s
fix(api): #286 review — allowlist non-/api routes in the versioning test; base-url-aware deprecation Link
Cold-fork + Codex review of PR #326:
- ApiRouteVersioningTests: iterate IRouteTemplateProvider (covers a
  template-less [HttpGet] paired with an action-level [Route]) and assert
  any non-/api route against an explicit KnownNonApiRoutes allowlist
  instead of silently skipping — an accidental absolute non-/api route
  (which would also escape ApiAuthorizationFilter's /api-scoped gate) now
  fails the test.
- ApiVersionRewriteMiddleware: root the deprecation Link at Request.PathBase
  so it stays correct under ETV_BASE_URL (</etv/docs>, not host-root </docs>).

refs #286 #197

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 00:40:58 +02:00

154 lines
7.4 KiB
C#

using System.Reflection;
using System.Text.RegularExpressions;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Middleware;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Controllers;
/// <summary>
/// Enforces the #286 route convention: every API-controller action's effective route is
/// versioned and absolute (<c>^/api/v{n}/</c>). This is the standing net for the leading-slash +
/// versioning standardization — a new controller that drifts (a relative or unversioned route)
/// fails CI here, the "fix it while you're in the file" gate the format rules use.
/// </summary>
[TestFixture]
public class ApiRouteVersioningTests
{
private static readonly Regex VersionedAbsolute = new(@"^/api/v\d+/", RegexOptions.Compiled);
// Routes on an API controller that deliberately live OUTSIDE /api because they are browser-navigation
// endpoints, not part of the JSON API surface (api-conventions §9). This is an explicit allowlist, NOT
// a blanket skip: an accidental non-/api absolute route (e.g. a stray [HttpPost("/channels")]) must fail
// here, because it would also escape ApiAuthorizationFilter's /api-scoped gate → an unauthenticated
// mutation. The only intentional entry today is AuthController's OIDC challenge.
private static readonly string[] KnownNonApiRoutes = ["/auth/oidc/login"];
[Test]
public void Every_Api_Controller_Action_Route_Should_Be_Versioned_And_Absolute()
{
// Same reflective scan as ApiControllerSecurityTests: [ApiController] concrete classes in the
// Controllers.Api namespace, so a new controller is covered automatically (no hand list).
Type[] apiControllers = typeof(CollectionController)
.Assembly
.GetTypes()
.Where(t => t.Namespace == typeof(CollectionController).Namespace)
.Where(t => t is { IsClass: true, IsAbstract: false })
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
.ToArray();
apiControllers.Length.ShouldBeGreaterThanOrEqualTo(20);
var assertedRoutes = 0;
foreach (Type controllerType in apiControllers)
{
// A class-level [Route] prefix is allowed (the shared-{id} pattern used by Scanner/Scripted);
// the effective route is what must be versioned + absolute.
string controllerTemplate = controllerType
.GetCustomAttributes<RouteAttribute>(inherit: true)
.Select(r => r.Template)
.FirstOrDefault();
foreach (MethodInfo action in controllerType
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
// IRouteTemplateProvider covers BOTH [HttpGet("...")] (HttpMethodAttribute) and a bare
// action-level [Route("...")] — so a template-less [HttpGet] paired with [Route("...")]
// can't slip an unversioned route past this net (cold-review nit, #326).
foreach (IRouteTemplateProvider routeProvider in action
.GetCustomAttributes(inherit: true)
.OfType<IRouteTemplateProvider>())
{
string? effective = CombineRoute(controllerTemplate, routeProvider.Template);
if (effective is null)
{
// No route on the controller or the action → not a routable API endpoint; skip.
continue;
}
// A route outside /api must be a KNOWN, intentional browser-nav endpoint — never a
// silent skip (see KnownNonApiRoutes above for why: it would also escape the /api-scoped
// auth filter). A new one fails here until it's explicitly allowlisted or versioned.
if (!effective.StartsWith("/api/", StringComparison.OrdinalIgnoreCase) &&
!effective.Equals("/api", StringComparison.OrdinalIgnoreCase))
{
KnownNonApiRoutes.ShouldContain(
effective,
$"{controllerType.Name}.{action.Name} route '{effective}' is neither versioned " +
"(/api/v1/…) nor a known non-/api browser-nav endpoint — version it, or add it to " +
"KnownNonApiRoutes if it is intentionally outside the JSON API surface.");
continue;
}
VersionedAbsolute.IsMatch(effective).ShouldBeTrue(
$"{controllerType.Name}.{action.Name} route '{effective}' must be versioned + absolute (^/api/v{{n}}/)");
assertedRoutes++;
}
}
}
// Non-vacuous guard: the whole surface is ~250 routes, so a scan that asserted almost nothing
// (a reflection regression) would otherwise pass green.
assertedRoutes.ShouldBeGreaterThanOrEqualTo(150);
}
// Mirror ASP.NET Core's attribute-route combination: an action template starting with '/' or '~/'
// is absolute (ignores the controller prefix); otherwise it is appended to the controller prefix.
private static string? CombineRoute(string? controllerTemplate, string? actionTemplate)
{
if (!string.IsNullOrEmpty(actionTemplate) &&
(actionTemplate.StartsWith('/') || actionTemplate.StartsWith("~/")))
{
return actionTemplate.TrimStart('~');
}
if (!string.IsNullOrEmpty(controllerTemplate))
{
string prefix = "/" + controllerTemplate.Trim('/');
return string.IsNullOrEmpty(actionTemplate) ? prefix : prefix + "/" + actionTemplate.TrimStart('/');
}
if (string.IsNullOrEmpty(actionTemplate))
{
return null;
}
return "/" + actionTemplate.TrimStart('/');
}
}
[TestFixture]
public class ApiVersionRewriteMiddlewareTests
{
[TestCase("/api/channels", "/api/v1/channels")]
[TestCase("/api/channels/5", "/api/v1/channels/5")]
[TestCase("/api/scan/00000000-0000-0000-0000-000000000000/progress",
"/api/v1/scan/00000000-0000-0000-0000-000000000000/progress")]
[TestCase("/api/version", "/api/v1/version")] // 'version' must not be mistaken for a version token
[TestCase("/API/Channels", "/api/v1/Channels")] // case-insensitive on the /api segment
public void Should_Rewrite_Legacy_Unversioned_Api_Path(string input, string expected)
{
ApiVersionRewriteMiddleware.TryRewriteLegacyApiPath(new PathString(input), out PathString rewritten)
.ShouldBeTrue();
rewritten.Value.ShouldBe(expected);
}
[TestCase("/api/v1/channels")] // already versioned
[TestCase("/api/v2/channels")] // a future version is passed through, never forced back to v1
[TestCase("/iptv/channels.m3u")] // non-/api
[TestCase("/app/")]
[TestCase("/artwork/posters/1.jpg")]
[TestCase("/api")] // no trailing segment
[TestCase("")]
public void Should_Not_Rewrite(string input)
{
ApiVersionRewriteMiddleware.TryRewriteLegacyApiPath(new PathString(input), out PathString rewritten)
.ShouldBeFalse();
rewritten.Value.ShouldBe(new PathString(input).Value);
}
}