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
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>
154 lines
7.4 KiB
C#
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);
|
|
}
|
|
}
|