S5IqcGenericApprovalGuardContractTests.cs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
  3. /// <summary>
  4. /// Batch 5A 源码契约测试:S5_IQC_INSPBILL 的「通用审批旁路」必须双端封死。
  5. ///
  6. /// 背景(Batch 5 实测):通用 /api/flowTask/approve 只经引擎 GetPendingTask 校验
  7. /// 「任务存在 / Pending / 指派人==当前用户」,不校验业务类型与租户,也不在事务内,
  8. /// 曾以 HTTP 200 真实把 S5 检验单从 N2 推进到 N3,绕过 IqcInspBillFlowService 的
  9. /// 租户解析 / 业务单归属 / 节点守卫 / UseTranAsync 四层 authority。
  10. ///
  11. /// 仅隐藏前端按钮不足以闭环(S7/S6 即为反例:前端 fail-closed,后端仍可手工 HTTP 直达),
  12. /// 故后端网关层必须 fail-closed,前端仅作一致的引导。
  13. ///
  14. /// 本层沿用本仓对 FlowEngine/FlowTaskService 的既有做法——源码契约断言而非行为级集成测试:
  15. /// FlowEngineService 全程依赖 SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化
  16. /// (详见 S8ExceptionFlowTenantContextContractTests 的说明)。
  17. /// </summary>
  18. public class S5IqcGenericApprovalGuardContractTests
  19. {
  20. private const string BizType = "S5_IQC_INSPBILL";
  21. private const string Guard = "EnsureDedicatedEntryOnlyAsync";
  22. private static string FlowTaskService() => File.ReadAllText(FindFile(
  23. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
  24. private static string PendingList() => File.ReadAllText(FindFile(
  25. "Web", "src", "views", "approvalFlow", "center", "components", "PendingList.vue"));
  26. /// <summary>后端必须持有该 bizType 的拒绝名单,且给出引导到专用页面的文案。</summary>
  27. [Fact]
  28. public void Backend_DeclaresDedicatedEntryOnlyBizType()
  29. {
  30. var s = FlowTaskService();
  31. Assert.Contains("DedicatedEntryOnlyBizTypes", s);
  32. Assert.Contains($"[\"{BizType}\"]", s);
  33. Assert.Contains("来料检验单详情", s);
  34. }
  35. /// <summary>权威业务类型必须从库中反查(task → instance),不得信任前端入参。</summary>
  36. [Fact]
  37. public void Backend_ReadsBizTypeFromDatabase_NotFromRequest()
  38. {
  39. var s = FlowTaskService();
  40. var guard = Slice(s, $"private async Task {Guard}(long taskId)", "}\n\n /// <summary>");
  41. Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard);
  42. Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard);
  43. Assert.Contains("instance.BizType", guard);
  44. // 2026-09-18 Policy Consolidation:字典值由 string 升级为 DedicatedEntryPolicy
  45. // (Message + BlockFlowMutation 同属一条记录,消除第二份 BizType 名单),
  46. // 故抛错取 hint.Message。业务契约不变——文案仍来自字典查表、仍不取自入参。
  47. Assert.Contains("Oops.Oh(hint.Message)", guard);
  48. // TaskActionInput 只有 TaskId/Comment,不存在可被伪造的 bizType 入参
  49. Assert.DoesNotContain("input.BizType", guard);
  50. }
  51. /// <summary>四个会推进流程的通用端点都必须在调用引擎之前先过 guard。</summary>
  52. [Theory]
  53. [InlineData("_engine.Approve(input.TaskId, input.Comment)")]
  54. [InlineData("_engine.Reject(input.TaskId, input.Comment)")]
  55. [InlineData("_engine.Approve(taskId, input.Comment)")]
  56. [InlineData("_engine.Reject(taskId, input.Comment)")]
  57. public void Backend_GuardRunsBeforeEngineCall(string engineCall)
  58. {
  59. var s = FlowTaskService();
  60. var engineAt = s.IndexOf(engineCall, StringComparison.Ordinal);
  61. Assert.True(engineAt > 0, $"未找到引擎调用点:{engineCall}");
  62. // 该引擎调用点之前最近的一次 guard 调用必须存在,且中间不得再夹一次引擎调用
  63. var guardAt = s.LastIndexOf(Guard + "(", engineAt, StringComparison.Ordinal);
  64. Assert.True(guardAt > 0, $"引擎调用 {engineCall} 之前缺少 {Guard} 守卫");
  65. }
  66. /// <summary>批量端点绕过单条方法体,必须各自独立带 guard(否则批量可绕过)。</summary>
  67. [Fact]
  68. public void Backend_BatchEndpointsAlsoGuarded()
  69. {
  70. var s = FlowTaskService();
  71. var batchApprove = Slice(s, "public async Task<BatchActionResult> BatchApprove", "public async Task<BatchActionResult> BatchReject");
  72. Assert.Contains(Guard, batchApprove);
  73. var batchReject = s[s.IndexOf("public async Task<BatchActionResult> BatchReject", StringComparison.Ordinal)..];
  74. Assert.Contains(Guard, batchReject);
  75. }
  76. /// <summary>guard 只针对名单内业务,不得误伤其它通用审批(名单必须是精确匹配的字典查找)。</summary>
  77. [Fact]
  78. public void Backend_GuardIsAllowListedByExactBizType_NotBlanket()
  79. {
  80. var s = FlowTaskService();
  81. Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", s);
  82. // 不得出现「所有 Sx 业务一律拒绝」之类的宽泛匹配
  83. Assert.DoesNotContain("StartsWith(\"S", s);
  84. Assert.DoesNotContain("Contains(\"_INSPBILL\")", s);
  85. }
  86. /// <summary>前端待办列表必须为 S5 提供专用分支,不再落入通用 v-else 的同意/拒绝。</summary>
  87. [Fact]
  88. public void Frontend_PendingList_HasDedicatedS5Branch()
  89. {
  90. var s = PendingList();
  91. Assert.Contains($"row.bizType === '{BizType}'", s);
  92. Assert.Contains("goToIqc(row.bizId)", s);
  93. Assert.Contains("/aidop/s5/iqc/detail/${bizId}", s);
  94. }
  95. /// <summary>S5 三个节点码必须与后端常量一致;尤其 N3 是 SQE 而非 S7 的 QE。</summary>
  96. [Fact]
  97. public void Frontend_UsesS5NodeCodes_NotFqcOnes()
  98. {
  99. var s = PendingList();
  100. var branch = Slice(s, $"row.bizType === '{BizType}'", "</template>");
  101. Assert.Contains("'N1_INSPECT'", branch);
  102. Assert.Contains("'N2_SUP_REVIEW'", branch);
  103. Assert.Contains("'N3_SQE_DISPOSITION'", branch);
  104. Assert.DoesNotContain("'N3_QE_DISPOSITION'", branch); // 那是 S7 FQC 的节点码
  105. }
  106. /// <summary>未知节点必须 fail-closed(只读文案),不得回落到通用同意/拒绝。</summary>
  107. [Fact]
  108. public void Frontend_UnknownNode_FailsClosed()
  109. {
  110. var s = PendingList();
  111. var branch = Slice(s, $"row.bizType === '{BizType}'", "</template>");
  112. Assert.Contains("<el-text v-else", branch);
  113. Assert.DoesNotContain("emit('approve'", branch);
  114. Assert.DoesNotContain("emit('reject'", branch);
  115. }
  116. private static string Slice(string src, string from, string to)
  117. {
  118. var a = src.IndexOf(from, StringComparison.Ordinal);
  119. Assert.True(a >= 0, $"未找到起点:{from}");
  120. var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
  121. return b > a ? src[a..b] : src[a..];
  122. }
  123. private static string FindFile(params string[] parts)
  124. {
  125. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  126. while (dir != null)
  127. {
  128. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  129. if (File.Exists(candidate)) return candidate;
  130. dir = dir.Parent;
  131. }
  132. throw new FileNotFoundException(string.Join("/", parts));
  133. }
  134. }