fix(api): #286 review — allowlist non-/api routes in the versioning test; base-url-aware deprecation Link
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>
This commit was merged in pull request #326.
This commit is contained in:
2026-07-13 00:40:58 +02:00
co-authored by Claude Opus 4.8
parent ef2bd65c27
commit 682dceec8f
2 changed files with 25 additions and 9 deletions
@@ -21,6 +21,13 @@ 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()
{
@@ -49,24 +56,31 @@ public class ApiRouteVersioningTests
foreach (MethodInfo action in controllerType
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
foreach (HttpMethodAttribute httpAttribute in action
.GetCustomAttributes<HttpMethodAttribute>(inherit: true))
// 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, httpAttribute.Template);
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 few actions on API controllers deliberately live OUTSIDE /api because they are
// browser-navigation endpoints, not part of the JSON API surface — e.g.
// AuthController's GET /auth/oidc/login OIDC challenge (api-conventions §9). The
// versioning convention only governs the /api surface; a non-/api nav route is a
// different surface and must NOT be forced to /api/v1.
// 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;
}
@@ -37,7 +37,9 @@ public sealed class ApiVersionRewriteMiddleware
{
context.Request.Path = rewritten;
context.Response.Headers["Deprecation"] = "true";
context.Response.Headers["Link"] = "</docs>; rel=\"deprecation\"";
// Root the docs link at the request PathBase so it stays correct under a reverse-proxy
// base URL (ETV_BASE_URL): empty PathBase → </docs>, PathBase "/etv" → </etv/docs>.
context.Response.Headers["Link"] = $"<{context.Request.PathBase}/docs>; rel=\"deprecation\"";
if (!string.IsNullOrWhiteSpace(_sunset))
{
context.Response.Headers["Sunset"] = _sunset;