S8AuthorizationGuardTests.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. using Admin.NET.Plugin.AiDOP.Const.S8;
  2. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  4. using Microsoft.AspNetCore.Mvc;
  5. using Microsoft.AspNetCore.Mvc.Filters;
  6. using Microsoft.AspNetCore.Mvc.Routing;
  7. using System.Reflection;
  8. using Xunit;
  9. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  10. /// <summary>
  11. /// S8-P0-4-API-AUTHORIZATION-1:S8 接口鉴权守卫。
  12. ///
  13. /// <para>修复前实测(业务账号 <c>UATExceptionA</c>,2026-09-02):
  14. /// <c>PUT /config/watch-rules/{id}/params</c> 与 <c>POST /config/operator-bindings</c>
  15. /// 都直达业务校验返回 <b>400</b> 而非 403 —— 鉴权层根本没拦。
  16. /// 根因是平台 <c>JwtHandler</c> 用「路由路径逐字转权限名」匹配,
  17. /// 而 S8 是带路径参数的 RESTful 路由(<c>…/exceptions/{id}/claim</c> → <c>…:1329909430019:claim</c>),
  18. /// 静态权限码永远不可能相等;又因末行「未登记路由默认放行」,全部 S8 接口变成登录即可调用。</para>
  19. ///
  20. /// <para>本测试全部走**反射元数据**(不是字符串 grep),逐个枚举 S8 Controller 的 Action,
  21. /// 保证:① 每个 Action 都声明了能力码;② 能力码在正式目录内;
  22. /// ③ 写动作不得落到只读能力上;④ 各类动作落到正确的能力分组;
  23. /// ⑤ 不回退到已死的 <c>ado_s8_role_permission_config.permission_codes</c>。</para>
  24. /// </summary>
  25. public class S8AuthorizationGuardTests
  26. {
  27. private static readonly Assembly PluginAssembly = typeof(AdoS8ExceptionsController).Assembly;
  28. /// <summary>S8 命名空间下的全部 Controller。</summary>
  29. public static IEnumerable<Type> S8Controllers => PluginAssembly
  30. .GetTypes()
  31. .Where(t => t.Namespace == typeof(AdoS8ExceptionsController).Namespace
  32. && typeof(ControllerBase).IsAssignableFrom(t)
  33. && !t.IsAbstract)
  34. .OrderBy(t => t.Name);
  35. public static TheoryData<Type> ControllerData()
  36. {
  37. var data = new TheoryData<Type>();
  38. foreach (var t in S8Controllers) data.Add(t);
  39. return data;
  40. }
  41. /// <summary>
  42. /// S8-ACTION-PERMISSION-1:一个 Action 现在可能挂两种门禁之一 ——
  43. /// 配置面仍用能力码 <see cref="S8PermissionAttribute"/>,
  44. /// 异常单动作改用 <see cref="S8ExceptionActionAttribute"/>(租户内 Action↔Role 判定)。
  45. /// 两者<b>互斥</b>:同一个 Action 不允许同时挂,否则同一问题两个 authority。
  46. /// </summary>
  47. private sealed record ActionInfo(
  48. Type Controller, MethodInfo Method, string Verb, string Template,
  49. string? Permission, string? ExceptionAction)
  50. {
  51. /// <summary>是否已被任一门禁保护。未保护 = 退回平台「未登记路由默认放行」。</summary>
  52. public bool Guarded => !string.IsNullOrWhiteSpace(Permission) || !string.IsNullOrWhiteSpace(ExceptionAction);
  53. }
  54. private static IEnumerable<ActionInfo> AllActions()
  55. {
  56. foreach (var c in S8Controllers)
  57. {
  58. foreach (var m in c.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly))
  59. {
  60. var http = m.GetCustomAttributes().OfType<IActionHttpMethodProvider>().FirstOrDefault();
  61. if (http == null) continue;
  62. var verb = http.HttpMethods.FirstOrDefault() ?? "GET";
  63. var template = (http as IRouteTemplateProvider)?.Template ?? string.Empty;
  64. var perm = m.GetCustomAttribute<S8PermissionAttribute>()?.Code;
  65. var act = m.GetCustomAttribute<S8ExceptionActionAttribute>()?.Code;
  66. yield return new ActionInfo(c, m, verb.ToUpperInvariant(), template, perm, act);
  67. }
  68. }
  69. }
  70. private static bool IsReadCapability(string code) =>
  71. code is S8PermissionCatalog.ExceptionRead
  72. or S8PermissionCatalog.ConfigRead
  73. or S8PermissionCatalog.DashboardRead;
  74. // ───────────────────────── ① 全量覆盖 ─────────────────────────
  75. /// <summary>S8 Controller 必须被枚举到(防止命名空间搬家后本测试静默空跑)。</summary>
  76. [Fact]
  77. public void AllS8ControllersAreDiscovered()
  78. {
  79. var controllers = S8Controllers.ToList();
  80. // 下限随功能删除同步下调:S8-STANDARD-DATASET-HARD-CUTOVER-1 物理删除了
  81. // AdoS8ConfigDataSourcesController 与 AdoS8ConfigAlertRulesController(25 → 23)。
  82. // 这条断言防的是「反射枚举失效导致后面所有授权检查空跑」,不是控制器数量本身,
  83. // 故取当前实际数量作下限即可。
  84. Assert.True(controllers.Count >= 23, $"仅发现 {controllers.Count} 个 S8 Controller,疑似枚举失效");
  85. Assert.Contains(controllers, t => t == typeof(AdoS8ExceptionsController));
  86. Assert.Contains(controllers, t => t == typeof(AdoS8WatchDebugController));
  87. }
  88. /// <summary>每一个 Action 都必须有确定的授权要求,一个都不许漏。</summary>
  89. [Fact]
  90. public void EveryS8Action_DeclaresAPermission()
  91. {
  92. var missing = AllActions()
  93. .Where(a => !a.Guarded)
  94. .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb} {a.Template}]")
  95. .ToList();
  96. Assert.True(missing.Count == 0,
  97. "以下 S8 Action 未声明 [S8Permission] 或 [S8ExceptionAction],将退回平台『未登记路由默认放行』:\n"
  98. + string.Join('\n', missing));
  99. // S8-ACTION-PERMISSION-1:两种门禁互斥。同时挂 = 同一问题两个 authority,判据迟早分叉。
  100. var doubled = AllActions()
  101. .Where(a => !string.IsNullOrWhiteSpace(a.Permission) && !string.IsNullOrWhiteSpace(a.ExceptionAction))
  102. .Select(a => $"{a.Controller.Name}.{a.Method.Name}")
  103. .ToList();
  104. Assert.True(doubled.Count == 0, "以下 Action 同时挂了两种门禁:\n" + string.Join('\n', doubled));
  105. }
  106. /// <summary>声明的权限码必须在正式目录内,杜绝写错字符串导致永远匹配不到。</summary>
  107. [Fact]
  108. public void EveryDeclaredPermission_ExistsInCatalog()
  109. {
  110. var known = S8PermissionCatalog.All.Select(x => x.Code).ToHashSet(StringComparer.Ordinal);
  111. var unknown = AllActions()
  112. .Where(a => a.Permission != null && !known.Contains(a.Permission))
  113. .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}")
  114. .Distinct().ToList();
  115. Assert.True(unknown.Count == 0, "以下权限码不在 S8PermissionCatalog 内:\n" + string.Join('\n', unknown));
  116. var unknownActions = AllActions()
  117. .Where(a => a.ExceptionAction != null && !S8ExceptionActionCatalog.IsKnown(a.ExceptionAction))
  118. .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.ExceptionAction}")
  119. .Distinct().ToList();
  120. Assert.True(unknownActions.Count == 0,
  121. "以下动作码不在 S8ExceptionActionCatalog 内:\n" + string.Join('\n', unknownActions));
  122. }
  123. // ───────────────────────── ② 写动作不得落到只读能力 ─────────────────────────
  124. /// <summary>
  125. /// 所有 mutation(非 GET)Action 都不得使用只读能力码,
  126. /// 否则「有查看权限的人」就能改配置 / 改业务状态。
  127. /// </summary>
  128. [Fact]
  129. public void MutationActions_DoNotUseReadOnlyCapabilities()
  130. {
  131. var offenders = AllActions()
  132. .Where(a => a.Verb != "GET"
  133. && ((a.Permission != null && IsReadCapability(a.Permission))
  134. // 新门禁同理:写动作不得落在"查看异常"上,
  135. // 否则有查看权限的人就能改业务状态。
  136. || a.ExceptionAction == S8ExceptionActionCode.View
  137. || a.ExceptionAction == S8ExceptionActionCode.ViewAll))
  138. .Select(a => $"{a.Controller.Name}.{a.Method.Name} [{a.Verb}] → {a.Permission ?? a.ExceptionAction}")
  139. .ToList();
  140. Assert.True(offenders.Count == 0, "以下写动作落在只读能力上:\n" + string.Join('\n', offenders));
  141. }
  142. // ───────────────────────── ③ 分组正确性 ─────────────────────────
  143. /// <summary>配置类 Controller 的写动作必须落在 config 组,不得使用 operator / verification 能力。</summary>
  144. [Theory]
  145. [InlineData(typeof(AdoS8ConfigWatchRulesController), S8PermissionCatalog.ConfigWatchRule)]
  146. // S8-STANDARD-DATASET-HARD-CUTOVER-1:AdoS8ConfigDataSourcesController 已物理删除。
  147. [InlineData(typeof(AdoS8ConfigExceptionTypesController), S8PermissionCatalog.ConfigExceptionType)]
  148. [InlineData(typeof(AdoS8ConfigNotificationLayersController), S8PermissionCatalog.ConfigNotification)]
  149. [InlineData(typeof(AdoS8ConfigScenesController), S8PermissionCatalog.ConfigScene)]
  150. [InlineData(typeof(AdoS8ConfigKpiTargetsController), S8PermissionCatalog.ConfigKpi)]
  151. [InlineData(typeof(AdoS8ConfigDashboardCellsController), S8PermissionCatalog.ConfigDashboard)]
  152. [InlineData(typeof(AdoS8ConfigBindingsController), S8PermissionCatalog.ConfigOperatorBind)]
  153. [InlineData(typeof(AdoS8ConfigRolesController), S8PermissionCatalog.ConfigRoleWrite)]
  154. public void ConfigController_MutationsUseItsOwnConfigCapability(Type controller, string expected)
  155. {
  156. var mutations = AllActions().Where(a => a.Controller == controller && a.Verb != "GET").ToList();
  157. Assert.NotEmpty(mutations);
  158. Assert.All(mutations, a => Assert.Equal(expected, a.Permission));
  159. }
  160. /// <summary>配置类 Controller 一律不得出现 operator / verification 能力码。</summary>
  161. [Fact]
  162. public void ConfigControllers_NeverUseOperatorOrQualityCapabilities()
  163. {
  164. var operatorOrQuality = new[]
  165. {
  166. S8PermissionCatalog.ExceptionClaim, S8PermissionCatalog.ExceptionStart,
  167. S8PermissionCatalog.ExceptionAssign, S8PermissionCatalog.ExceptionUpgrade,
  168. S8PermissionCatalog.ExceptionReject, S8PermissionCatalog.ExceptionClose,
  169. S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationApprove,
  170. S8PermissionCatalog.VerificationReject,
  171. }.ToHashSet(StringComparer.Ordinal);
  172. var offenders = AllActions()
  173. .Where(a => a.Controller.Name.Contains("Config", StringComparison.Ordinal)
  174. && a.Permission != null && operatorOrQuality.Contains(a.Permission))
  175. .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}")
  176. .ToList();
  177. Assert.True(offenders.Count == 0, string.Join('\n', offenders));
  178. }
  179. /// <summary>复检通过 / 退回必须是 quality 能力;提交复检、认领、开始处理必须是 operator 能力。</summary>
  180. [Theory]
  181. // S8-ACTION-PERMISSION-1:映射对象由能力码改为**动作码**。
  182. // 旧能力码在本批之后只剩一个用途:首次 Provisioning 从「本租户原本谁能做」推导默认授权;
  183. // 运行期判定改由 S8ExceptionActionAttribute + 租户内 Action↔Role 回答,
  184. // 原因是旧链路不校验角色的租户(实测 UATAdminA 经别家租户角色拿到 assign)。
  185. // 守卫对象随之改变,守的仍是同一件事:这个路由挂对了门禁。
  186. [InlineData("approve-verification", S8ExceptionActionCode.Verify)]
  187. [InlineData("reject-verification", S8ExceptionActionCode.Verify)]
  188. [InlineData("submit-verification", S8ExceptionActionCode.SubmitVerify)]
  189. [InlineData("claim", S8ExceptionActionCode.Claim)]
  190. [InlineData("start-progress", S8ExceptionActionCode.Start)]
  191. [InlineData("transfer", S8ExceptionActionCode.Transfer)]
  192. [InlineData("upgrade", S8ExceptionActionCode.Upgrade)]
  193. [InlineData("comment", S8ExceptionActionCode.Comment)]
  194. public void ExceptionAction_MapsToExpectedActionCode(string routeFragment, string expected)
  195. {
  196. var action = AllActions().SingleOrDefault(a =>
  197. a.Controller == typeof(AdoS8ExceptionsController)
  198. && a.Verb != "GET"
  199. && a.Template.EndsWith(routeFragment, StringComparison.Ordinal));
  200. Assert.NotNull(action);
  201. Assert.Equal(expected, action!.ExceptionAction);
  202. }
  203. /// <summary>「检验通过 / 退回」绝不能与「提交复检」共用能力码,否则处理人可自检自过。</summary>
  204. [Fact]
  205. public void QualityApproval_IsSeparatedFromOperatorSubmission()
  206. {
  207. Assert.NotEqual(S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationApprove);
  208. Assert.NotEqual(S8PermissionCatalog.VerificationSubmit, S8PermissionCatalog.VerificationReject);
  209. }
  210. // ───────────────────────── ④ 调试接口 ─────────────────────────
  211. /// <summary>
  212. /// 调试 / 运维接口必须要求独立的 debug 能力,且不得复用任何业务能力码。
  213. /// 该接口历史上是「任意登录用户可调用」,且能对**任意 tenantId/factoryId** 触发自动建单主链。
  214. /// </summary>
  215. [Fact]
  216. public void DebugEndpoints_RequireDedicatedDebugCapability()
  217. {
  218. var actions = AllActions().Where(a => a.Controller == typeof(AdoS8WatchDebugController)).ToList();
  219. Assert.NotEmpty(actions);
  220. Assert.All(actions, a => Assert.Equal(S8PermissionCatalog.DebugRun, a.Permission));
  221. // debug 能力码不得被任何业务接口复用。
  222. var leaked = AllActions()
  223. .Where(a => a.Permission == S8PermissionCatalog.DebugRun
  224. && a.Controller != typeof(AdoS8WatchDebugController))
  225. .Select(a => a.Controller.Name + "." + a.Method.Name)
  226. .ToList();
  227. Assert.True(leaked.Count == 0, string.Join('\n', leaked));
  228. }
  229. // ───────────────────────── ⑤ 不回退到死表 ─────────────────────────
  230. /// <summary>
  231. /// 授权判定必须走平台 SysMenu 按钮权限(<c>SysMenuService.GetOwnBtnPermList</c>),
  232. /// 不得把已确认 0 消费方的 <c>ado_s8_role_permission_config.permission_codes</c> 接回运行链。
  233. /// </summary>
  234. [Fact]
  235. public void AuthorizationDoesNotRevivePermissionCodesTable()
  236. {
  237. var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  238. var src = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8PermissionAttribute.cs"));
  239. Assert.Contains("GetOwnBtnPermList", src);
  240. // S8-ACTION-PERMISSION-1:新授权链路一并纳入本守卫。新表 ado_s8_exception_action_role
  241. // 取代了那张 0 消费方的死表,绝不能有人顺手把 permission_codes 接回运行链。
  242. foreach (var f in new[]
  243. {
  244. "Infrastructure/S8/S8PermissionAttribute.cs",
  245. "Infrastructure/S8/S8ExceptionActionAuthorizer.cs",
  246. "Infrastructure/S8/S8ExceptionActionAttribute.cs",
  247. "Infrastructure/S8/S8TenantRoleResolver.cs",
  248. })
  249. {
  250. var one = File.ReadAllText(Path.Combine(root, f));
  251. // 判据必须精确到「那张死表的实体 / 属性 / 列」。
  252. // 裸子串 "PermissionCodes" 会误伤合法方法名(如按租户解析能力码的
  253. // GetPermissionCodesAsync),把一条真守卫变成噪音,最后被人整条删掉。
  254. Assert.DoesNotContain("AdoS8RolePermissionConfig", one);
  255. Assert.DoesNotContain(".PermissionCodes", one);
  256. Assert.DoesNotContain("permission_codes", one);
  257. }
  258. }
  259. /// <summary>权限门必须 fail-closed:拿不到权限服务 / 抛异常时一律 403,绝不放行。</summary>
  260. [Fact]
  261. public void PermissionGate_IsFailClosed()
  262. {
  263. var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  264. var src = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8PermissionAttribute.cs"));
  265. Assert.Contains("Status403Forbidden", src);
  266. Assert.Contains("catch", src);
  267. // 空码构造必须抛,防止 [S8Permission("")] 变成静默放行。
  268. Assert.Throws<ArgumentException>(() => new S8PermissionAttribute(""));
  269. // S8-ACTION-PERMISSION-1:新门禁同一条要求,且未知动作码同样在构造期就炸。
  270. var actionSrc = File.ReadAllText(Path.Combine(root, "Infrastructure/S8/S8ExceptionActionAttribute.cs"));
  271. Assert.Contains("Status403Forbidden", actionSrc);
  272. Assert.Contains("catch", actionSrc);
  273. Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute(""));
  274. Assert.Throws<ArgumentException>(() => new S8ExceptionActionAttribute("EXCEPTION_NOT_REAL"));
  275. }
  276. /// <summary>
  277. /// 目录内每个**未废弃**能力码都应至少被一个 Action 使用,避免目录与实现漂移出孤儿码。
  278. /// (<c>s8:exception:close</c> 已标 deprecated:S8 无独立关闭接口,关闭由 approve-verification
  279. /// 经状态机达成;既有 SysMenu 行与授权保持不动,故不从目录删除。)
  280. /// </summary>
  281. [Fact]
  282. public void CatalogHasNoOrphanCapability()
  283. {
  284. var used = AllActions().Where(a => a.Permission != null).Select(a => a.Permission!).ToHashSet(StringComparer.Ordinal);
  285. // S8-ACTION-PERMISSION-1:归属现在有两种 —— ① 仍被某个 Action 直接使用(配置面);
  286. // ② 被 S8ExceptionActionCatalog 引为某动作的 LegacyPermissionCode。后者不是孤儿,
  287. // 而是首次 Provisioning 推导默认授权的输入;删掉它会让「本租户原本谁能做这件事」失传。
  288. var legacyMapped = S8ExceptionActionCatalog.All
  289. .SelectMany(d => new[] { d.LegacyPermissionCode }.Concat(d.LegacyPermissionAliases))
  290. .Where(c => !string.IsNullOrWhiteSpace(c))
  291. .Select(c => c!)
  292. .ToHashSet(StringComparer.Ordinal);
  293. var orphans = S8PermissionCatalog.All
  294. .Where(x => !x.Deprecated)
  295. .Select(x => x.Code)
  296. .Where(c => !used.Contains(c) && !legacyMapped.Contains(c))
  297. .ToList();
  298. Assert.True(orphans.Count == 0,
  299. "以下能力码既无 Action 使用、也未被动作目录引为 Legacy 推导输入:\n" + string.Join('\n', orphans));
  300. }
  301. /// <summary>已废弃能力码不得被任何 Action 引用(否则等于用一个没人维护的码当门禁)。</summary>
  302. [Fact]
  303. public void DeprecatedCapabilitiesAreNotReferencedByAnyAction()
  304. {
  305. var deprecated = S8PermissionCatalog.All.Where(x => x.Deprecated).Select(x => x.Code)
  306. .ToHashSet(StringComparer.Ordinal);
  307. Assert.NotEmpty(deprecated); // 防止标记体系被误删后本测试空跑
  308. var offenders = AllActions()
  309. .Where(a => a.Permission != null && deprecated.Contains(a.Permission))
  310. .Select(a => $"{a.Controller.Name}.{a.Method.Name} → {a.Permission}")
  311. .ToList();
  312. Assert.True(offenders.Count == 0, string.Join('\n', offenders));
  313. }
  314. }