S8ApprovalFlowRepairTests.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. using Admin.NET.Plugin.AiDOP.Const.S8;
  2. using Admin.NET.Plugin.AiDOP.Entity.S8;
  3. using Admin.NET.Plugin.AiDOP.Service.S8;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  6. /// <summary>
  7. /// S8-UAT-APPROVAL-REPAIR-1:审核链修复。
  8. ///
  9. /// <para><b>修的是一个完整的静默悬挂</b>(本地取证,非推测):UAT 租户 3 条
  10. /// <c>PENDING_VERIFICATION</c> 异常的 <c>verifier_user_id</c> 都是 UATQualityA,
  11. /// 而对应 <c>EXCEPTION_CLOSURE</c> 实例 <b>Status=Running、tasks=0</b>、
  12. /// 异常上的 <c>active_flow_instance_id</c> 仍是 NULL —— 节点配的是别租户角色 Id,
  13. /// <c>ResolveApprovers</c> 返回 0 人 → <c>ProcessNextNode</c> 抛错 →
  14. /// <c>OnFlowStarted</c> 从未调用 → 实例永久悬挂,而 S8 侧显示一切正常。</para>
  15. /// </summary>
  16. public class S8ApprovalFlowRepairTests
  17. {
  18. private static readonly string PluginRoot =
  19. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  20. private static string CodeOnly(string relativePath)
  21. {
  22. var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
  23. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  24. return string.Join('\n', File.ReadAllLines(full)
  25. .Where(l =>
  26. {
  27. var t = l.TrimStart();
  28. return !t.StartsWith("///", StringComparison.Ordinal)
  29. && !t.StartsWith("//", StringComparison.Ordinal);
  30. }));
  31. }
  32. // ══════════════════════════════════════════════════════════════════════
  33. // 判据:什么算「已经租户安全」
  34. // ══════════════════════════════════════════════════════════════════════
  35. /// <summary>
  36. /// T5 / T6:判据是<b>「该节点引用的每个对象是否都属于本租户」</b>,
  37. /// 而不是「用了哪种 approverType 写法」。
  38. ///
  39. /// <para>第一版按写法判(只认 RoleCode),把默认租户一条
  40. /// <c>SpecificUser</c> 指向<b>本租户账号</b>的正确配置也改写了 ——
  41. /// 那是把「这种写法在别的租户下不安全」错当成「这个配置错了」。</para>
  42. /// </summary>
  43. [Theory]
  44. // 本租户 RoleCode → 安全
  45. [InlineData("Role", "ROLE_QC", true)]
  46. [InlineData("Role", "ROLE_QC,ROLE_S8_CONFIG_ADMIN", true)]
  47. // 本租户 RoleId(数字)→ 也安全:判据是「引用对象归不归本租户」,不是「用了哪种写法」
  48. [InlineData("Role", "9001", true)]
  49. // 跨租户数字 RoleId(正是坏掉的那个配置)→ 不安全
  50. [InlineData("Role", "1300000000101,1300000000888", false)]
  51. // 混入跨租户对象 → 整体不安全
  52. [InlineData("Role", "ROLE_QC,1300000000101", false)]
  53. [InlineData("Role", "ROLE_NOT_IN_TENANT", false)]
  54. // SpecificUser 指向**本租户**账号 → 安全(第一版把它误判为不安全,改坏了默认租户的正确配置)
  55. [InlineData("SpecificUser", "7001", true)]
  56. // SpecificUser 指向别租户账号 → 不安全(escalation 的现状)
  57. [InlineData("SpecificUser", "789819678195781", false)]
  58. // Department 同理
  59. [InlineData("Department", "8001", true)]
  60. [InlineData("Department", "246", false)]
  61. // Initiator 由运行时按发起人解析,天然不跨租户
  62. [InlineData("Initiator", null, true)]
  63. // 空配置 / 认不出的类型 → 不安全
  64. [InlineData("Role", "", false)]
  65. [InlineData("Role", null, false)]
  66. [InlineData("WhoKnows", "1", false)]
  67. public void TenantSafetyPredicate_JudgesByReferencedObjectOwnership(string type, string? ids, bool expected)
  68. {
  69. var scope = new S8ApprovalFlowRepairService.S8TenantApproverScope(
  70. RoleCodes: new HashSet<string>(StringComparer.Ordinal) { "ROLE_QC", "ROLE_S8_CONFIG_ADMIN" },
  71. RoleIds: new HashSet<long> { 9001 },
  72. UserIds: new HashSet<long> { 7001 },
  73. OrgIds: new HashSet<long> { 8001 });
  74. Assert.Equal(expected, S8ApprovalFlowRepairService.IsAlreadyTenantSafe(type, ids, scope));
  75. }
  76. /// <summary>
  77. /// T3:跨租户审批人配置必须被判为需要修复 —— 这正是 escalation 的现状
  78. /// (三个节点全部 <c>SpecificUser=789819678195781</c>,Demo02 属默认租户)。
  79. /// </summary>
  80. [Fact]
  81. public void T3_CrossTenantApprover_IsRejectedAsUnsafe()
  82. {
  83. var scope = new S8ApprovalFlowRepairService.S8TenantApproverScope(
  84. RoleCodes: new HashSet<string>(StringComparer.Ordinal) { "ROLE_QC" },
  85. RoleIds: new HashSet<long>(), UserIds: new HashSet<long>(), OrgIds: new HashSet<long>());
  86. Assert.False(S8ApprovalFlowRepairService.IsAlreadyTenantSafe("SpecificUser", "789819678195781", scope));
  87. Assert.False(S8ApprovalFlowRepairService.IsAlreadyTenantSafe("Role", "1300000000101,1300000000888", scope));
  88. }
  89. /// <summary>
  90. /// 对账服务写下的改动必须能按自己的留证撤回。
  91. ///
  92. /// <para>判据本身也会出错:第一版把「SpecificUser 指向本租户账号」误判为不安全,
  93. /// 于是改坏了默认租户一条本来正确的配置。没有回退能力,一次判据失误就永久生效。</para>
  94. /// </summary>
  95. [Fact]
  96. public void Repair_CanUndoItsOwnWritesWhenPredicateChanges()
  97. {
  98. var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
  99. Assert.Contains("RESTORED", code);
  100. Assert.Contains("s8_flow_repair_restored", code);
  101. Assert.Contains("priorLogs.TryGetValue((flow.Id, nodeName), out var prior)", code);
  102. // 只撤自己写的:当前值必须与留证里的 after 完全一致才回退
  103. Assert.Contains("string.Equals(prior.AfterIds, beforeIds, StringComparison.Ordinal)", code);
  104. // 且回退目标必须按**现在的**判据是安全的
  105. Assert.Contains("IsAlreadyTenantSafe(prior.BeforeType, prior.BeforeIds, tenantScope)", code);
  106. }
  107. // ══════════════════════════════════════════════════════════════════════
  108. // 修复行为
  109. // ══════════════════════════════════════════════════════════════════════
  110. /// <summary>
  111. /// 派生依据必须是<b>该租户自己的权限事实</b>,不是硬编码角色名。
  112. /// 升级流程没有专门角色时回落审核角色,且回落必须写进留证 ——
  113. /// 否则将来没人知道升级审批为什么是质检在审。
  114. /// </summary>
  115. [Fact]
  116. public void Repair_DerivesRolesFromTenantOwnPermissionGrants()
  117. {
  118. var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
  119. Assert.Contains("S8PermissionCatalog.VerificationApprove", code);
  120. Assert.Contains("S8PermissionCatalog.ExceptionUpgrade", code);
  121. Assert.Contains("r.TenantId.Value", code);
  122. Assert.Contains("回落使用审核角色", code);
  123. // 不得出现硬编码的具体角色名
  124. foreach (var hardcoded in new[] { "\"ROLE_QC\"", "\"UAT_FUNCTION_USER\"", "\"ROLE_S8_CONFIG_ADMIN\"" })
  125. Assert.DoesNotContain(hardcoded, code);
  126. }
  127. /// <summary>
  128. /// T4:推导不出角色时<b>不猜</b> —— 保持原样并大声报出来。
  129. ///
  130. /// <para>塞一个"看起来能用"的角色进去会让人以为审核链已经通了,
  131. /// 那比留着坏配置更危险。</para>
  132. /// </summary>
  133. [Fact]
  134. public void T4_NoDerivableRole_BlocksLoudlyInsteadOfGuessing()
  135. {
  136. var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
  137. Assert.Contains("BLOCKED_NO_ROLE", code);
  138. Assert.Contains("s8_flow_repair_blocked", code);
  139. Assert.Contains("保持原样", code);
  140. }
  141. /// <summary>
  142. /// 只修坏的,不碰好的;判据本身保证幂等(修好后第二次运行命中「已 OK」而跳过)。
  143. ///
  144. /// <para>每次启动无条件回写会覆盖管理员的手工配置 ——
  145. /// 那是比配置错误更难发现的故障。</para>
  146. /// </summary>
  147. [Fact]
  148. public void Repair_IsIdempotentAndDoesNotOverwriteGoodConfig()
  149. {
  150. var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
  151. Assert.Contains("SKIPPED_ALREADY_OK", code);
  152. Assert.Contains("IsAlreadyTenantSafe(beforeType, beforeIds, tenantScope)", code);
  153. Assert.Contains("result.AlreadyOkCount++", code);
  154. }
  155. /// <summary>
  156. /// 修复<b>只作用于 S8 自己的两条流程</b>,绝不波及 S1/S5/S6/S7。
  157. ///
  158. /// <para>真库实测 74 个 SpecificUser 节点里有大量跨租户配置
  159. /// (CONTRACT_REVIEW / ORDER_CHANGE_REVIEW / MATERIAL_SHORTAGE 在三个租户里
  160. /// 都指向租户 797 的账号)。本批不碰它们。</para>
  161. /// </summary>
  162. [Fact]
  163. public void Repair_ScopeIsLimitedToS8FlowsOnly()
  164. {
  165. var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
  166. Assert.Contains("f.BizType == ClosureBizType || f.BizType == EscalationBizType", code);
  167. Assert.Equal("EXCEPTION_CLOSURE", S8ApprovalFlowRepairService.ClosureBizType);
  168. Assert.Equal("EXCEPTION_ESCALATION", S8ApprovalFlowRepairService.EscalationBizType);
  169. foreach (var other in new[] { "CONTRACT_REVIEW", "ORDER_CHANGE_REVIEW", "MATERIAL_SHORTAGE", "ORDER_REVIEW" })
  170. Assert.DoesNotContain(other, code);
  171. }
  172. /// <summary>
  173. /// <b>不改 FlowEngine</b>:给 SpecificUser / Department 补租户谓词会外溢到其它模块,
  174. /// 属另一个批次。本批以「只用 RoleCode 形态」绕开该缺陷。
  175. /// </summary>
  176. [Fact]
  177. public void FlowEngine_IsNotModifiedByThisBatch()
  178. {
  179. var engine = Path.GetFullPath(Path.Combine(
  180. PluginRoot, "../Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs"));
  181. Assert.True(File.Exists(engine));
  182. var src = File.ReadAllText(engine);
  183. // Role 的 code 分支本来就有租户校验 —— 这正是本批选择 RoleCode 形态的依据
  184. Assert.Contains("r.TenantId == effectiveTenantId", src);
  185. // 本批没有往里加 S8 专属逻辑
  186. Assert.DoesNotContain("S8-UAT-APPROVAL-REPAIR", src);
  187. }
  188. /// <summary>修复必须留证:改前 / 改后 / 原因 / 结论,缺一不可。</summary>
  189. [Fact]
  190. public void Repair_LeavesAuditTrail()
  191. {
  192. var t = typeof(AdoS8ApprovalFlowRepairLog);
  193. foreach (var p in new[] { "TenantId", "BizType", "NodeName", "BeforeType", "BeforeIds", "AfterType", "AfterIds", "Reason", "Outcome" })
  194. Assert.NotNull(t.GetProperty(p));
  195. var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs");
  196. Assert.Contains("REPAIRED", code);
  197. }
  198. // ══════════════════════════════════════════════════════════════════════
  199. // verifier 与审批任务的一致性(§10)
  200. // ══════════════════════════════════════════════════════════════════════
  201. /// <summary>
  202. /// T7 / T10 / T11:检验人必须具备「审核」动作权限。
  203. ///
  204. /// <para>这条校验保证「UI 能选到的人」⊆「审批任务的收件人」:
  205. /// 复核节点按「被授予 s8:verification:approve 的角色」派发,
  206. /// 而检验人也必须持有同一能力。少了它就会出现错位 ——
  207. /// 页面上选了甲,任务发给乙那一组,甲手里没有任务、乙又过不了
  208. /// verifier_user_id 校验,两边互相卡住且都不报错。</para>
  209. /// </summary>
  210. [Fact]
  211. public void T7_T10_T11_Verifier_MustHoldVerifyPermission()
  212. {
  213. var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
  214. // S8-RESPONSIBILITY-POOL-1:判据本体迁到 IS8VerifierEligibility(候选列表与提交校验共用同一份),
  215. // 但**保护对象没变**:选出来的人必须真的能点「检验通过 / 检验退回」。
  216. Assert.Contains("_verifierEligibility.CheckAsync(tenantId, verifier.UserId, e)", code);
  217. var eligibility = CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs");
  218. Assert.Contains("S8ExceptionActionCode.Verify", eligibility);
  219. Assert.Contains("没有审核权限,不能作为检验人", eligibility);
  220. // verifier_user_id 仍然只装 SysUser.Id
  221. Assert.Contains("e.VerifierUserId = verifier.UserId;", code);
  222. Assert.DoesNotContain("EmployeeMaster", code.Replace("EmployeeMaster.Department", string.Empty));
  223. }
  224. /// <summary>前端候选集必须与后端校验同源,不能让用户选到一个提交就被拒的账号。</summary>
  225. [Fact]
  226. public void VerifierPicker_UsesSameSourceAsBackendCheck()
  227. {
  228. var page = File.ReadAllText(Path.GetFullPath(Path.Combine(
  229. PluginRoot, "../../../Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue")));
  230. // S8-RESPONSIBILITY-POOL-1:候选来源从「全租户 ∩ 审核权限」收窄为
  231. // 「该规则的复核池 ∩ 审核权限 ∩ 可用账号」,由后端一个端点给出。
  232. // 本守卫保护的一直是「前端能选的 == 后端会放行的」,来源换了但要求没变 ——
  233. // 而且现在更强:候选与提交校验共用同一份 IS8VerifierEligibility 实现。
  234. Assert.Contains("s8ExceptionApi.verifierCandidates(", page);
  235. Assert.DoesNotContain("s8ExceptionApi.users('EXCEPTION_VERIFY')", page);
  236. Assert.Contains("verifierUsers", page);
  237. // 没有可选复核人时必须显式说明原因,而不是给一个空下拉
  238. Assert.Contains("该规则没有可用的复核人员", page);
  239. Assert.Contains("本租户没有任何账号具备「审核」权限", page);
  240. var eligibility = CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs");
  241. Assert.Contains("ListCandidatesAsync", eligibility);
  242. Assert.Contains("CheckAsync", eligibility);
  243. Assert.Contains("S8ExceptionActionCode.Verify", eligibility);
  244. }
  245. /// <summary>T12 / T13:修复链路上没有 Employee、没有 Factory。</summary>
  246. [Theory]
  247. [InlineData("Service/S8/S8ApprovalFlowRepairService.cs")]
  248. [InlineData("Service/S8/S8MasterDataAdapter.cs")]
  249. public void T12_T13_NoEmployeeNoFactory(string relativePath)
  250. {
  251. var code = CodeOnly(relativePath);
  252. Assert.DoesNotContain("AdoS0EmployeeMaster", code);
  253. Assert.DoesNotContain("FactoryRefId", code);
  254. }
  255. }