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; /// /// Enforces the #286 route convention: every API-controller action's effective route is /// versioned and absolute (^/api/v{n}/). 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. /// [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(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(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()) { 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); } }