w4c-workflows-api/w4c-workflows-api.Tests/RequireScopeAttributeTests.cs

112 lines
3.8 KiB
C#
Raw Permalink Normal View History

2026-09-13 16:28:47 +00:00
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;
/// <summary>
/// P1-14: an operator key with no recorded scopes must not silently pass every
/// scope check. It is denied when <c>Auth:EnforceScopes</c> 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.
/// </summary>
public class RequireScopeAttributeTests
{
private static ActionExecutingContext Context(
string? authKind,
IReadOnlyList<string>? 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<IFilterMetadata>(), new Dictionary<string, object?>(), 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<IFilterMetadata>(), 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<string>(), 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<string>(), 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);
}
}