using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Routing; using w4c_workflows.Filters; using Xunit; namespace w4c_workflows.Tests; /// /// P1-14: an operator key with no recorded scopes must not silently pass every /// scope check. It is denied when Auth:EnforceScopes is on (the migration /// switch) and stays allowed otherwise so legacy/seed keys keep working. A key /// that does carry scopes must match exactly, and JWT/render-token surfaces skip /// the operator-key check entirely. /// public class RequireScopeAttributeTests { private static ActionExecutingContext Context( string? authKind, IReadOnlyList? scopes, bool enforceScopes) { var http = new DefaultHttpContext(); if (authKind != null) http.Items["AuthKind"] = authKind; if (scopes != null) http.Items["Scopes"] = scopes; http.Items["EnforceScopes"] = enforceScopes; var actionContext = new ActionContext(http, new RouteData(), new ActionDescriptor()); return new ActionExecutingContext( actionContext, new List(), new Dictionary(), controller: null!); } private static async Task<(bool NextCalled, ActionExecutingContext Context)> InvokeAsync( ActionExecutingContext context, string requiredScope) { var called = false; var filter = new RequireScopeAttribute(requiredScope); ActionExecutedContext Next() { called = true; return new ActionExecutedContext( context, new List(), controller: null!); } await filter.OnActionExecutionAsync(context, () => Task.FromResult(Next())); return (called, context); } private static int? ForbiddenStatus(ActionExecutingContext context) => (context.Result as ObjectResult)?.StatusCode; [Fact] public async Task Matching_scope_allows_the_action() { var (called, context) = await InvokeAsync(Context("operator", new[] { "read" }, false), "read"); Assert.True(called); Assert.Null(context.Result); } [Fact] public async Task Non_matching_scope_is_forbidden() { var (called, context) = await InvokeAsync(Context("operator", new[] { "read" }, false), "manage"); Assert.False(called); Assert.Equal(StatusCodes.Status403Forbidden, ForbiddenStatus(context)); } [Fact] public async Task Empty_scope_set_is_denied_when_enforcement_is_on() { var (called, context) = await InvokeAsync(Context("operator", Array.Empty(), true), "manage"); Assert.False(called); Assert.Equal(StatusCodes.Status403Forbidden, ForbiddenStatus(context)); } [Fact] public async Task Empty_scope_set_legacy_key_still_passes_without_enforcement() { var (called, context) = await InvokeAsync(Context("operator", Array.Empty(), false), "manage"); Assert.True(called); Assert.Null(context.Result); } [Fact] public async Task Missing_scope_item_is_denied_when_enforcement_is_on() { var (called, context) = await InvokeAsync(Context("operator", scopes: null, enforceScopes: true), "manage"); Assert.False(called); Assert.Equal(StatusCodes.Status403Forbidden, ForbiddenStatus(context)); } [Theory] [InlineData("jwt")] [InlineData("render-token")] public async Task Non_operator_key_surfaces_skip_the_scope_check(string authKind) { var (called, context) = await InvokeAsync(Context(authKind, scopes: null, enforceScopes: true), "manage"); Assert.True(called); Assert.Null(context.Result); } }