using Admin.NET.Plugin.AiDOP.Const.S8; using Admin.NET.Plugin.AiDOP.Controllers.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Routing; using System.Reflection; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-P0-4-API-AUTHORIZATION-1:S8 接口鉴权守卫。 /// /// 修复前实测(业务账号 UATExceptionA,2026-09-02): /// PUT /config/watch-rules/{id}/paramsPOST /config/operator-bindings /// 都直达业务校验返回 400 而非 403 —— 鉴权层根本没拦。 /// 根因是平台 JwtHandler 用「路由路径逐字转权限名」匹配, /// 而 S8 是带路径参数的 RESTful 路由(…/exceptions/{id}/claim…:1329909430019:claim), /// 静态权限码永远不可能相等;又因末行「未登记路由默认放行」,全部 S8 接口变成登录即可调用。 /// /// 本测试全部走**反射元数据**(不是字符串 grep),逐个枚举 S8 Controller 的 Action, /// 保证:① 每个 Action 都声明了能力码;② 能力码在正式目录内; /// ③ 写动作不得落到只读能力上;④ 各类动作落到正确的能力分组; /// ⑤ 不回退到已死的 ado_s8_role_permission_config.permission_codes /// public class S8AuthorizationGuardTests { private static readonly Assembly PluginAssembly = typeof(AdoS8ExceptionsController).Assembly; /// S8 命名空间下的全部 Controller。 public static IEnumerable S8Controllers => PluginAssembly .GetTypes() .Where(t => t.Namespace == typeof(AdoS8ExceptionsController).Namespace && typeof(ControllerBase).IsAssignableFrom(t) && !t.IsAbstract) .OrderBy(t => t.Name); public static TheoryData ControllerData() { var data = new TheoryData(); foreach (var t in S8Controllers) data.Add(t); return data; } /// /// S8-ACTION-PERMISSION-1:一个 Action 现在可能挂两种门禁之一 —— /// 配置面仍用能力码 , /// 异常单动作改用 (租户内 Action↔Role 判定)。 /// 两者互斥:同一个 Action 不允许同时挂,否则同一问题两个 authority。 /// private sealed record ActionInfo( Type Controller, MethodInfo Method, string Verb, string Template, string? Permission, string? ExceptionAction) { /// 是否已被任一门禁保护。未保护 = 退回平台「未登记路由默认放行」。 public bool Guarded => !string.IsNullOrWhiteSpace(Permission) || !string.IsNullOrWhiteSpace(ExceptionAction); } private static IEnumerable AllActions() { foreach (var c in S8Controllers) { foreach (var m in c.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)) { var http = m.GetCustomAttributes().OfType().FirstOrDefault(); if (http == null) continue; var verb = http.HttpMethods.FirstOrDefault() ?? "GET"; var template = (http as IRouteTemplateProvider)?.Template ?? string.Empty; var perm = m.GetCustomAttribute()?.Code; var act = m.GetCustomAttribute()?.Code; yield return new ActionInfo(c, m, verb.ToUpperInvariant(), template, perm, act); } } } private static bool IsReadCapability(string code) => code is S8PermissionCatalog.ExceptionRead or S8PermissionCatalog.ConfigRead or S8PermissionCatalog.DashboardRead; // ───────────────────────── ① 全量覆盖 ───────────────────────── /// S8 Controller 必须被枚举到(防止命名空间搬家后本测试静默空跑)。 [Fact] public void AllS8ControllersAreDiscovered() { var controllers = S8Controllers.ToList(); // 下限随功能删除同步下调:S8-STANDARD-DATASET-HARD-CUTOVER-1 物理删除了 // AdoS8ConfigDataSourcesController 与 AdoS8ConfigAlertRulesController(25 → 23)。 // 这条断言防的是「反射枚举失效导致后面所有授权检查空跑」,不是控制器数量本身, // 故取当前实际数量作下限即可。 Assert.True(controllers.Count >= 23, $"仅发现 {controllers.Count} 个 S8 Controller,疑似枚举失效"); Assert.Contains(controllers, t => t == typeof(AdoS8ExceptionsController)); Assert.Contains(controllers, t => t == typeof(AdoS8WatchDebugController)); } /// 每一个 Action 都必须有确定的授权要求,一个都不许漏。 [Fact] public void EveryS8Action_DeclaresAPermission() { var missing = AllActions() .Where(a => !a.Guarded) .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb} {a.Template}]") .ToList(); Assert.True(missing.Count == 0, "以下 S8 Action 未声明 [S8Permission] 或 [S8ExceptionAction],将退回平台『未登记路由默认放行』:\n" + string.Join('\n', missing)); // S8-ACTION-PERMISSION-1:两种门禁互斥。同时挂 = 同一问题两个 authority,判据迟早分叉。 var doubled = AllActions() .Where(a => !string.IsNullOrWhiteSpace(a.Permission) && !string.IsNullOrWhiteSpace(a.ExceptionAction)) .Select(a => $"{a.Controller.Name}.{a.Method.Name}") .ToList(); Assert.True(doubled.Count == 0, "以下 Action 同时挂了两种门禁:\n" + string.Join('\n', doubled)); } /// 声明的权限码必须在正式目录内,杜绝写错字符串导致永远匹配不到。 [Fact] public void EveryDeclaredPermission_ExistsInCatalog() { var known = S8PermissionCatalog.All.Select(x => x.Code).ToHashSet(StringComparer.Ordinal); var unknown = AllActions() .Where(a => a.Permission != null && !known.Contains(a.Permission)) .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}") .Distinct().ToList(); Assert.True(unknown.Count == 0, "以下权限码不在 S8PermissionCatalog 内:\n" + string.Join('\n', unknown)); var unknownActions = AllActions() .Where(a => a.ExceptionAction != null && !S8ExceptionActionCatalog.IsKnown(a.ExceptionAction)) .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.ExceptionAction}") .Distinct().ToList(); Assert.True(unknownActions.Count == 0, "以下动作码不在 S8ExceptionActionCatalog 内:\n" + string.Join('\n', unknownActions)); } // ───────────────────────── ② 写动作不得落到只读能力 ───────────────────────── /// /// 所有 mutation(非 GET)Action 都不得使用只读能力码, /// 否则「有查看权限的人」就能改配置 / 改业务状态。 /// [Fact] public void MutationActions_DoNotUseReadOnlyCapabilities() { var offenders = AllActions() .Where(a => a.Verb != "GET" && ((a.Permission != null && IsReadCapability(a.Permission)) // 新门禁同理:写动作不得落在"查看异常"上, // 否则有查看权限的人就能改业务状态。 || a.ExceptionAction == S8ExceptionActionCode.View || a.ExceptionAction == S8ExceptionActionCode.ViewAll)) .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb}] → {a.Permission ?? a.ExceptionAction}") .ToList(); Assert.True(offenders.Count == 0, "以下写动作落在只读能力上:\n" + string.Join('\n', offenders)); } // ───────────────────────── ③ 分组正确性 ───────────────────────── /// 配置类 Controller 的写动作必须落在 config 组,不得使用 operator / verification 能力。 [Theory] [InlineData(typeof(AdoS8ConfigWatchRulesController), S8PermissionCatalog.ConfigWatchRule)] // S8-STANDARD-DATASET-HARD-CUTOVER-1:AdoS8ConfigDataSourcesController 已物理删除。 [InlineData(typeof(AdoS8ConfigExceptionTypesController), S8PermissionCatalog.ConfigExceptionType)] [InlineData(typeof(AdoS8ConfigNotificationLayersController), S8PermissionCatalog.ConfigNotification)] [InlineData(typeof(AdoS8ConfigScenesController), S8PermissionCatalog.ConfigScene)] [InlineData(typeof(AdoS8ConfigKpiTargetsController), S8PermissionCatalog.ConfigKpi)] [InlineData(typeof(AdoS8ConfigDashboardCellsController), S8PermissionCatalog.ConfigDashboard)] [InlineData(typeof(AdoS8ConfigBindingsController), S8PermissionCatalog.ConfigOperatorBind)] [InlineData(typeof(AdoS8ConfigRolesController), S8PermissionCatalog.ConfigRoleWrite)] public void ConfigController_MutationsUseItsOwnConfigCapability(Type controller, string expected) { var mutations = AllActions().Where(a => a.Controller == controller && a.Verb != "GET").ToList(); Assert.NotEmpty(mutations); Assert.All(mutations, a => Assert.Equal(expected, a.Permission)); } /// 配置类 Controller 一律不得出现 operator / verification 能力码。 [Fact] public void ConfigControllers_NeverUseOperatorOrQualityCapabilities() { var operatorOrQuality = new[] { S8PermissionCatalog.ExceptionClaim, S8PermissionCatalog.ExceptionStart, S8PermissionCatalog.ExceptionAssign, S8PermissionCatalog.ExceptionUpgrade, S8PermissionCatalog.ExceptionReject, S8PermissionCatalog.ExceptionClose, S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationApprove, S8PermissionCatalog.VerificationReject, }.ToHashSet(StringComparer.Ordinal); var offenders = AllActions() .Where(a => a.Controller.Name.Contains("Config", StringComparison.Ordinal) && a.Permission != null && operatorOrQuality.Contains(a.Permission)) .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}") .ToList(); Assert.True(offenders.Count == 0, string.Join('\n', offenders)); } /// 复检通过 / 退回必须是 quality 能力;提交复检、认领、开始处理必须是 operator 能力。 [Theory] // S8-ACTION-PERMISSION-1:映射对象由能力码改为**动作码**。 // 旧能力码在本批之后只剩一个用途:首次 Provisioning 从「本租户原本谁能做」推导默认授权; // 运行期判定改由 S8ExceptionActionAttribute + 租户内 Action↔Role 回答, // 原因是旧链路不校验角色的租户(实测 UATAdminA 经别家租户角色拿到 assign)。 // 守卫对象随之改变,守的仍是同一件事:这个路由挂对了门禁。 [InlineData("approve-verification", S8ExceptionActionCode.Verify)] [InlineData("reject-verification", S8ExceptionActionCode.Verify)] [InlineData("submit-verification", S8ExceptionActionCode.SubmitVerify)] [InlineData("claim", S8ExceptionActionCode.Claim)] [InlineData("start-progress", S8ExceptionActionCode.Start)] [InlineData("transfer", S8ExceptionActionCode.Transfer)] [InlineData("upgrade", S8ExceptionActionCode.Upgrade)] [InlineData("comment", S8ExceptionActionCode.Comment)] public void ExceptionAction_MapsToExpectedActionCode(string routeFragment, string expected) { var action = AllActions().SingleOrDefault(a => a.Controller == typeof(AdoS8ExceptionsController) && a.Verb != "GET" && a.Template.EndsWith(routeFragment, StringComparison.Ordinal)); Assert.NotNull(action); Assert.Equal(expected, action!.ExceptionAction); } /// 「检验通过 / 退回」绝不能与「提交复检」共用能力码,否则处理人可自检自过。 [Fact] public void QualityApproval_IsSeparatedFromOperatorSubmission() { Assert.NotEqual(S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationApprove); Assert.NotEqual(S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationReject); } // ───────────────────────── ④ 调试接口 ───────────────────────── /// /// 调试 / 运维接口必须要求独立的 debug 能力,且不得复用任何业务能力码。 /// 该接口历史上是「任意登录用户可调用」,且能对**任意 tenantId/factoryId** 触发自动建单主链。 /// [Fact] public void DebugEndpoints_RequireDedicatedDebugCapability() { var actions = AllActions().Where(a => a.Controller == typeof(AdoS8WatchDebugController)).ToList(); Assert.NotEmpty(actions); Assert.All(actions, a => Assert.Equal(S8PermissionCatalog.DebugRun, a.Permission)); // debug 能力码不得被任何业务接口复用。 var leaked = AllActions() .Where(a => a.Permission == S8PermissionCatalog.DebugRun && a.Controller != typeof(AdoS8WatchDebugController)) .Select(a => a.Controller.Name + "." + a.Method.Name) .ToList(); Assert.True(leaked.Count == 0, string.Join('\n', leaked)); } // ───────────────────────── ⑤ 不回退到死表 ───────────────────────── /// /// 授权判定必须走平台 SysMenu 按钮权限(SysMenuService.GetOwnBtnPermList), /// 不得把已确认 0 消费方的 ado_s8_role_permission_config.permission_codes 接回运行链。 /// [Fact] public void AuthorizationDoesNotRevivePermissionCodesTable() { var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); var src = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8PermissionAttribute.cs")); Assert.Contains("GetOwnBtnPermList", src); // S8-ACTION-PERMISSION-1:新授权链路一并纳入本守卫。新表 ado_s8_exception_action_role // 取代了那张 0 消费方的死表,绝不能有人顺手把 permission_codes 接回运行链。 foreach (var f in new[] { "Infrastructure/S8/S8PermissionAttribute.cs", "Infrastructure/S8/S8ExceptionActionAuthorizer.cs", "Infrastructure/S8/S8ExceptionActionAttribute.cs", "Infrastructure/S8/S8TenantRoleResolver.cs", }) { var one = File.ReadAllText(Path.Combine(root, f)); // 判据必须精确到「那张死表的实体 / 属性 / 列」。 // 裸子串 "PermissionCodes" 会误伤合法方法名(如按租户解析能力码的 // GetPermissionCodesAsync),把一条真守卫变成噪音,最后被人整条删掉。 Assert.DoesNotContain("AdoS8RolePermissionConfig", one); Assert.DoesNotContain(".PermissionCodes", one); Assert.DoesNotContain("permission_codes", one); } } /// 权限门必须 fail-closed:拿不到权限服务 / 抛异常时一律 403,绝不放行。 [Fact] public void PermissionGate_IsFailClosed() { var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); var src = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8PermissionAttribute.cs")); Assert.Contains("Status403Forbidden", src); Assert.Contains("catch", src); // 空码构造必须抛,防止 [S8Permission("")] 变成静默放行。 Assert.Throws(() => new S8PermissionAttribute("")); // S8-ACTION-PERMISSION-1:新门禁同一条要求,且未知动作码同样在构造期就炸。 var actionSrc = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8ExceptionActionAttribute.cs")); Assert.Contains("Status403Forbidden", actionSrc); Assert.Contains("catch", actionSrc); Assert.Throws(() => new S8ExceptionActionAttribute("")); Assert.Throws(() => new S8ExceptionActionAttribute("EXCEPTION_NOT_REAL")); } /// /// 目录内每个**未废弃**能力码都应至少被一个 Action 使用,避免目录与实现漂移出孤儿码。 /// (s8:exception:close 已标 deprecated:S8 无独立关闭接口,关闭由 approve-verification /// 经状态机达成;既有 SysMenu 行与授权保持不动,故不从目录删除。) /// [Fact] public void CatalogHasNoOrphanCapability() { var used = AllActions().Where(a => a.Permission != null).Select(a => a.Permission!).ToHashSet(StringComparer.Ordinal); // S8-ACTION-PERMISSION-1:归属现在有两种 —— ① 仍被某个 Action 直接使用(配置面); // ② 被 S8ExceptionActionCatalog 引为某动作的 LegacyPermissionCode。后者不是孤儿, // 而是首次 Provisioning 推导默认授权的输入;删掉它会让「本租户原本谁能做这件事」失传。 var legacyMapped = S8ExceptionActionCatalog.All .SelectMany(d => new[] { d.LegacyPermissionCode }.Concat(d.LegacyPermissionAliases)) .Where(c => !string.IsNullOrWhiteSpace(c)) .Select(c => c!) .ToHashSet(StringComparer.Ordinal); var orphans = S8PermissionCatalog.All .Where(x => !x.Deprecated) .Select(x => x.Code) .Where(c => !used.Contains(c) && !legacyMapped.Contains(c)) .ToList(); Assert.True(orphans.Count == 0, "以下能力码既无 Action 使用、也未被动作目录引为 Legacy 推导输入:\n" + string.Join('\n', orphans)); } /// 已废弃能力码不得被任何 Action 引用(否则等于用一个没人维护的码当门禁)。 [Fact] public void DeprecatedCapabilitiesAreNotReferencedByAnyAction() { var deprecated = S8PermissionCatalog.All.Where(x => x.Deprecated).Select(x => x.Code) .ToHashSet(StringComparer.Ordinal); Assert.NotEmpty(deprecated); // 防止标记体系被误删后本测试空跑 var offenders = AllActions() .Where(a => a.Permission != null && deprecated.Contains(a.Permission)) .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}") .ToList(); Assert.True(offenders.Count == 0, string.Join('\n', offenders)); } }