112 lines
3.8 KiB
C#
112 lines
3.8 KiB
C#
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);
|
|
}
|
|
}
|