IpqcInspectionGenericApprovalGuardContractTests.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
  3. /// <summary>
  4. /// S6 IPQC 过程检验单(<c>IPQC_INSPECTION</c>,源 qms_gcjyd)通用审批旁路封堵 · 源码契约测试。
  5. ///
  6. /// 背景:`686824d5e` 只把 <c>S6_PROCESS_INSPECTION</c>(Phase1C 链)加入了守卫名单,
  7. /// 同名不同源的 <c>IPQC_INSPECTION</c> 一直在名单外。2026-09-14 实测:通用
  8. /// <c>flowTask/approve</c> 真实把一条 IPQC N2 待办直接推进到 end。
  9. ///
  10. /// 破坏面比 Phase1C 链更大——专用入口 <c>IpqcInspectionDisposition.submit</c> 在**同一事务内**:
  11. /// ① INSERT 处置记录 <c>ado_s6_ipqc_quality_disposition</c>;
  12. /// ② 把扩展表 <c>ado_s6_ipqc_inspection_flow_state.business_status</c> 推到 DISPOSED;
  13. /// ③ 才 <c>_flowEngine.Approve</c> 推进 N3 → end。
  14. /// 走通用审批 ①② 都不会发生:流程显示已闭环,处置轨迹为空且业务状态永久停在旧值,事后无法补记。
  15. /// 另:专用 <c>supervisor-return</c> 要求意见必填并经 ReturnToPrev 回 N1,通用 Reject 直接置实例终态。
  16. ///
  17. /// 本文件与 <see cref="S6ProcessInspectionGenericApprovalGuardContractTests"/> 分开:
  18. /// 后者还断言 Phase1C 链自己的 ReviewService / FlowConst / PendingList 形状,
  19. /// 改名或塞进去只会制造无必要 diff。两者共用同一套守卫机制,下方断言即冻结这一点。
  20. ///
  21. /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:FlowTaskService 依赖
  22. /// SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests)。
  23. /// 真实阻断由本批 Runtime 矩阵 R1–R4 覆盖。
  24. /// </summary>
  25. public class IpqcInspectionGenericApprovalGuardContractTests
  26. {
  27. private const string BizType = "IPQC_INSPECTION";
  28. private const string Guard = "EnsureDedicatedEntryOnlyAsync";
  29. private static string FlowTaskService() => File.ReadAllText(FindFile(
  30. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
  31. private static string FlowConst() => File.ReadAllText(FindFile(
  32. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowConst.cs"));
  33. private static string FlowService() => File.ReadAllText(FindFile(
  34. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowService.cs"));
  35. private static string DispositionService() => File.ReadAllText(FindFile(
  36. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionDispositionService.cs"));
  37. // ── 守卫覆盖 ──
  38. /// <summary>IPQC_INSPECTION 必须在「只能走专用入口」名单里,且文案指向业务页。</summary>
  39. [Fact]
  40. public void Guard_ListsIpqcInspectionAsDedicatedEntryOnly()
  41. {
  42. var s = FlowTaskService();
  43. Assert.Contains($"[\"{BizType}\"]", s);
  44. Assert.Contains("过程检验单请在「过程检验」业务页完成检验、主管审核或质量处置", s);
  45. }
  46. /// <summary>BizType 字面量必须与运行态一致(实测 len=15,无大小写/下划线变体)。</summary>
  47. [Fact]
  48. public void BizType_MatchesRuntimeLiteral()
  49. {
  50. Assert.Contains($"BizType = \"{BizType}\"", FlowConst());
  51. Assert.Equal(15, BizType.Length);
  52. }
  53. /// <summary>扩展名单时不得把既有条目挤掉——S5 与 Phase1C 链必须仍在。</summary>
  54. [Theory]
  55. [InlineData("S5_IQC_INSPBILL")]
  56. [InlineData("S6_PROCESS_INSPECTION")]
  57. public void Guard_KeepsPreviouslyGuardedBizTypes(string bizType)
  58. {
  59. Assert.Contains($"[\"{bizType}\"]", FlowTaskService());
  60. }
  61. /// <summary>必须复用同一套守卫,不得为 IPQC 另起一份判定或散落 if。</summary>
  62. [Fact]
  63. public void Guard_ReusesSharedMechanism_NotAParallelImplementation()
  64. {
  65. var s = FlowTaskService();
  66. Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", s);
  67. Assert.DoesNotContain($"bizType == \"{BizType}\"", s);
  68. Assert.DoesNotContain($"BizType == \"{BizType}\"", s);
  69. }
  70. /// <summary>必须精确匹配,不得退化成前缀/包含匹配把无关业务一起挡掉。</summary>
  71. [Fact]
  72. public void Guard_UsesExactMatch_NotFuzzy()
  73. {
  74. var s = FlowTaskService();
  75. Assert.DoesNotContain("StartsWith(\"IPQC", s);
  76. Assert.DoesNotContain("Contains(\"IPQC", s);
  77. Assert.DoesNotContain("Contains(\"INSPECTION\")", s);
  78. Assert.DoesNotContain("StartsWith(\"S6", s);
  79. }
  80. /// <summary>四个会推进流程的通用端点都必须在调用引擎之前先过守卫(批量端点绕过单条方法体)。</summary>
  81. [Theory]
  82. [InlineData("_engine.Approve(input.TaskId, input.Comment)")]
  83. [InlineData("_engine.Reject(input.TaskId, input.Comment)")]
  84. [InlineData("_engine.Approve(taskId, input.Comment)")]
  85. [InlineData("_engine.Reject(taskId, input.Comment)")]
  86. public void Guard_RunsBeforeEngineCall(string engineCall)
  87. {
  88. var s = FlowTaskService();
  89. var engineAt = s.IndexOf(engineCall, StringComparison.Ordinal);
  90. Assert.True(engineAt > 0, $"未找到引擎调用点:{engineCall}");
  91. Assert.True(s.LastIndexOf(Guard + "(", engineAt, StringComparison.Ordinal) > 0,
  92. $"引擎调用 {engineCall} 之前缺少 {Guard} 守卫");
  93. }
  94. /// <summary>权威 BizType 必须从库中的 instance 反查,不得取自请求体(否则可伪造绕过)。</summary>
  95. [Fact]
  96. public void Guard_ReadsBizTypeFromDatabase()
  97. {
  98. var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n /// <summary>");
  99. Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard);
  100. Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard);
  101. Assert.DoesNotContain("input.BizType", guard);
  102. }
  103. /// <summary>不误伤:名单是精确字典查找,未把通用审批业务(如 CONTRACT_REVIEW)纳入。</summary>
  104. [Theory]
  105. [InlineData("CONTRACT_REVIEW")]
  106. // 2026-09-14 移除 S7_FQC_INSPBILL:当时 S7 被列为 OUT OF SCOPE 故断言"未纳入",
  107. // 现已由 S7-FQC-GUARD-1 正式纳入守卫(见 S7FqcGenericApprovalGuardContractTests)。
  108. [InlineData("SMART_OPS_IMPROVEMENT")]
  109. [InlineData("EXCEPTION_REPORT")]
  110. public void Guard_DoesNotBlockNonDedicatedBizTypes(string bizType)
  111. {
  112. var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
  113. Assert.DoesNotContain(bizType, dict);
  114. }
  115. /// <summary>对外文案不得泄漏 RoleId / TenantId / 流程定义 Id 等内部实现。</summary>
  116. [Fact]
  117. public void Guard_MessageDoesNotLeakInternals()
  118. {
  119. var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
  120. foreach (var leak in new[] { "TenantId", "RoleId", "FlowId", "DefinitionId" })
  121. Assert.DoesNotContain($"{leak}=", dict);
  122. }
  123. // ── 专用入口必须仍然可用(否则封堵等于封死业务)──
  124. /// <summary>阻断通用入口的前提是四个节点都有合法专用入口,且都直调引擎、不经 FlowTaskService。</summary>
  125. [Theory]
  126. [InlineData("submit-inspection")]
  127. [InlineData("supervisor-approve")]
  128. [InlineData("supervisor-return")]
  129. public void DedicatedFlowEntries_Exist(string route)
  130. {
  131. Assert.Contains($"HttpPost(\"{route}\")", FlowService());
  132. }
  133. /// <summary>N3 处置的专用入口在另一个 service。</summary>
  134. [Fact]
  135. public void DedicatedDispositionEntry_Exists()
  136. {
  137. var s = DispositionService();
  138. Assert.Contains("Route(\"api/IpqcInspectionDisposition\")", s);
  139. Assert.Contains("HttpPost(\"submit\")", s);
  140. }
  141. /// <summary>专用入口不得改走通用 FlowTaskService,否则会被自己的守卫挡住。</summary>
  142. [Fact]
  143. public void DedicatedEntries_CallEngineDirectly_NotFlowTaskService()
  144. {
  145. foreach (var src in new[] { FlowService(), DispositionService() })
  146. {
  147. Assert.Contains("_flowEngine.", src);
  148. Assert.DoesNotContain("FlowTaskService", src);
  149. }
  150. }
  151. // ── 冻结「为什么必须封堵」的那条事务性质 ──
  152. /// <summary>处置记录与扩展表状态必须与推进同处一个事务,且先写后推——这是禁止通用审批的根本理由。</summary>
  153. [Fact]
  154. public void Disposition_WritesRecordAndStateInsideSameTransactionBeforeApprove()
  155. {
  156. var s = DispositionService();
  157. var tranAt = s.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal);
  158. var insertAt = s.IndexOf("_db.Insertable(disposition)", StringComparison.Ordinal);
  159. var stateAt = s.IndexOf("UPDATE ado_s6_ipqc_inspection_flow_state", StringComparison.Ordinal);
  160. var approveAt = s.IndexOf("_flowEngine.Approve(", StringComparison.Ordinal);
  161. Assert.True(tranAt > 0, "缺少事务边界");
  162. Assert.True(insertAt > tranAt, "处置记录 INSERT 必须在事务内");
  163. Assert.True(stateAt > tranAt, "扩展表状态更新必须在事务内");
  164. Assert.True(approveAt > insertAt && approveAt > stateAt, "必须先落业务再推进流程");
  165. Assert.Contains("if (!tran.IsSuccess) throw tran.ErrorException;", s);
  166. }
  167. /// <summary>专用退回要求意见必填并回退到 N1,与通用 Reject 的终态语义不同。</summary>
  168. [Fact]
  169. public void SupervisorReturn_RequiresCommentAndGoesBackToN1()
  170. {
  171. var s = FlowService();
  172. Assert.Contains("throw Oops.Oh(\"退回必须填写意见\")", s);
  173. Assert.Contains("_flowEngine.ReturnToPrev(", s);
  174. }
  175. private static string Slice(string src, string from, string to)
  176. {
  177. var a = src.IndexOf(from, StringComparison.Ordinal);
  178. Assert.True(a >= 0, $"未找到起点:{from}");
  179. var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
  180. return b > a ? src[a..b] : src[a..];
  181. }
  182. private static string FindFile(params string[] parts)
  183. {
  184. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  185. while (dir != null)
  186. {
  187. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  188. if (File.Exists(candidate)) return candidate;
  189. dir = dir.Parent;
  190. }
  191. throw new FileNotFoundException(string.Join("/", parts));
  192. }
  193. }