using System.Security.Claims; using Admin.NET.Core; using Admin.NET.Core.Service; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Xunit; namespace Admin.NET.Test.Security; public class CoreRbacContractTests { [Theory] [InlineData("/aidop/s8/dashboard", "/aidop/s8/dashboard")] [InlineData(" /aidop/s8/dashboard/ ", "/aidop/s8/dashboard")] [InlineData("/", "/")] public void NormalizeMenuPath_AcceptsOnlyCanonicalPcPaths(string input, string expected) { Assert.Equal(expected, SysMenuService.NormalizeMenuPath(input)); } [Theory] [InlineData(null)] [InlineData("")] [InlineData("aidop/s8/dashboard")] [InlineData("//external.example/path")] [InlineData("/aidop/s8/dashboard?tenant=other")] [InlineData("/aidop/s8/dashboard#fragment")] public void NormalizeMenuPath_RejectsAmbiguousOrExternalPaths(string input) { Assert.Null(SysMenuService.NormalizeMenuPath(input)); } [Fact] public async Task Attribute_AllowsAuthenticatedUser_WhenServiceGrantsPath() { var context = CreateContext(new StubMenuAccessService(true), authenticated: true); await new RequirePcMenuPathAttribute("/aidop/s8/dashboard").OnAuthorizationAsync(context); Assert.Null(context.Result); } [Fact] public async Task Attribute_AllowsWhenAnyConfiguredPathIsGranted() { var context = CreateContext( new StubMenuAccessService(grantedPath: "/aidop/s7/fqc/result-list"), authenticated: true); await new RequirePcMenuPathAttribute( "/aidop/s7/fqc/task-list", "/aidop/s7/fqc/result-list").OnAuthorizationAsync(context); Assert.Null(context.Result); } [Fact] public async Task Attribute_Returns401_WhenAuthenticationIsMissing() { var context = CreateContext(new StubMenuAccessService(true), authenticated: false); await new RequirePcMenuPathAttribute("/aidop/s8/dashboard").OnAuthorizationAsync(context); Assert.IsType(context.Result); } [Fact] public async Task Attribute_FailsClosed_WhenServiceDeniesIsMissingOrThrows() { foreach (var accessService in new ICurrentUserMenuAccessService[] { new StubMenuAccessService(false), new StubMenuAccessService(exception: new InvalidOperationException("db unavailable")), null }) { var context = CreateContext(accessService, authenticated: true); await new RequirePcMenuPathAttribute("/aidop/s8/dashboard").OnAuthorizationAsync(context); var result = Assert.IsType(context.Result); Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode); } } [Fact] public void Attribute_RejectsEmptyContract() { Assert.Throws(() => new RequirePcMenuPathAttribute(" ")); } private static AuthorizationFilterContext CreateContext( ICurrentUserMenuAccessService accessService, bool authenticated) { var services = new ServiceCollection(); if (accessService != null) services.AddSingleton(accessService); var httpContext = new DefaultHttpContext { RequestServices = services.BuildServiceProvider(), User = new ClaimsPrincipal(new ClaimsIdentity( new[] { new Claim(ClaimTypes.NameIdentifier, "1") }, authenticated ? "test" : null)) }; var actionContext = new ActionContext( httpContext, new RouteData(), new ActionDescriptor(), new ModelStateDictionary()); return new AuthorizationFilterContext(actionContext, new List()); } private sealed class StubMenuAccessService : ICurrentUserMenuAccessService { private readonly bool _result; private readonly Exception _exception; private readonly string _grantedPath; public StubMenuAccessService( bool result = false, Exception exception = null, string grantedPath = null) { _result = result; _exception = exception; _grantedPath = grantedPath; } public Task HasMenuPathAsync(string menuPath) => _exception == null ? Task.FromResult(_result || string.Equals(_grantedPath, menuPath, StringComparison.Ordinal)) : Task.FromException(_exception); } }