S6ProcessInspectionGenericApprovalGuardContractTests.cs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
  3. /// <summary>
  4. /// S6 过程检验单(S6_PROCESS_INSPECTION)通用审批旁路封堵 · 源码契约测试。
  5. ///
  6. /// 背景(本批取证):
  7. /// 守卫名单原先只含 S5,S6 落在名单外 ⇒ /api/flowTask/approve|reject|batchApprove|batchReject
  8. /// 可直接推进 S6 流程。其中 N3_QE_DISPOSITION 最严重——处置记录
  9. /// ado_s6_ipqc_quality_disposition 是由 S6ProcessInspectionReview.qe-disposition 在
  10. /// **同一事务内** 先 INSERT 再推进流程的;走通用审批会把流程推到 end 而永不写处置记录,
  11. /// 造成「流程已闭环但处置轨迹为空」且事后无法补记。
  12. /// 另:专用 supervisor-return 要求意见必填且 ReturnToPrev 回 N1,通用 Reject 直接置实例终态。
  13. ///
  14. /// 前端虽已对 S6 隐藏单条同意/拒绝按钮,但批量勾选列无 bizType 过滤,
  15. /// batchApprove/batchReject 仍可绕过 ⇒ 只能靠后端网关 fail-closed 才算闭环。
  16. ///
  17. /// 另一半同样重要:S6 专用链**本来就是原子的**(四个动作各自 UseTranAsync 且引擎写入
  18. /// 经 SqlSugar 单例连接并入同一事务),本批刻意**不改**它。下方的事务契约断言用于把这个
  19. /// 已验证的性质冻结住,防止日后被无意改坏。
  20. ///
  21. /// 沿用本仓对 FlowEngine / FlowTaskService 的既有做法——源码契约断言而非行为级集成测试:
  22. /// 该层依赖 SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests)。
  23. /// </summary>
  24. public class S6ProcessInspectionGenericApprovalGuardContractTests
  25. {
  26. private const string BizType = "S6_PROCESS_INSPECTION";
  27. private const string Guard = "EnsureDedicatedEntryOnlyAsync";
  28. private static string FlowTaskService() => File.ReadAllText(FindFile(
  29. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
  30. private static string ReviewService() => File.ReadAllText(FindFile(
  31. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessInspectionReviewService.cs"));
  32. private static string FlowConst() => File.ReadAllText(FindFile(
  33. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "S6ProcessInspectionFlowConst.cs"));
  34. private static string PendingList() => File.ReadAllText(FindFile(
  35. "Web", "src", "views", "approvalFlow", "center", "components", "PendingList.vue"));
  36. // ── 守卫 ──
  37. /// <summary>S6 必须在「只能走专用入口」名单里,且文案指向业务页而非泛化的 forbidden。</summary>
  38. [Fact]
  39. public void Guard_ListsS6AsDedicatedEntryOnly()
  40. {
  41. var s = FlowTaskService();
  42. Assert.Contains($"[\"{BizType}\"]", s);
  43. Assert.Contains("过程检验单请在", s);
  44. }
  45. /// <summary>S6 与 S5 共用同一个守卫,不得为 S6 另起一套判定逻辑。</summary>
  46. [Fact]
  47. public void Guard_ReusesSharedMechanism_NotAParallelImplementation()
  48. {
  49. var s = FlowTaskService();
  50. Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", s);
  51. // 名单是精确字典查找,不得退化成模糊匹配把无关业务一起挡掉
  52. Assert.DoesNotContain("StartsWith(\"S6", s);
  53. Assert.DoesNotContain("Contains(\"PROCESS\")", s);
  54. }
  55. /// <summary>四个会推进流程的通用端点都必须在调用引擎之前先过守卫(批量端点绕过单条方法体)。</summary>
  56. [Theory]
  57. [InlineData("_engine.Approve(input.TaskId, input.Comment)")]
  58. [InlineData("_engine.Reject(input.TaskId, input.Comment)")]
  59. [InlineData("_engine.Approve(taskId, input.Comment)")]
  60. [InlineData("_engine.Reject(taskId, input.Comment)")]
  61. public void Guard_RunsBeforeEngineCall(string engineCall)
  62. {
  63. var s = FlowTaskService();
  64. var engineAt = s.IndexOf(engineCall, StringComparison.Ordinal);
  65. Assert.True(engineAt > 0, $"未找到引擎调用点:{engineCall}");
  66. Assert.True(s.LastIndexOf(Guard + "(", engineAt, StringComparison.Ordinal) > 0,
  67. $"引擎调用 {engineCall} 之前缺少 {Guard} 守卫");
  68. }
  69. /// <summary>权威 BizType 必须来自库中的 instance,不得取自请求体(否则可伪造绕过)。</summary>
  70. [Fact]
  71. public void Guard_ReadsBizTypeFromDatabase()
  72. {
  73. var s = FlowTaskService();
  74. var guard = Slice(s, $"private async Task {Guard}(long taskId)", "}\n\n /// <summary>");
  75. Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard);
  76. Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard);
  77. Assert.DoesNotContain("input.BizType", guard);
  78. }
  79. // ── 节点码(连任务书都写错过,必须钉死)──
  80. /// <summary>S6 的处置节点是 N3_QE_DISPOSITION;N3_QUALITY_DISPOSITION 属另一条 IPQC_INSPECTION 链。</summary>
  81. [Fact]
  82. public void S6_UsesQeDispositionNode_NotQualityDisposition()
  83. {
  84. var s = FlowConst();
  85. Assert.Contains("NodeSubmit = \"N1_SUBMIT\"", s);
  86. Assert.Contains("NodeSupervisor = \"N2_SUP_REVIEW\"", s);
  87. Assert.Contains("NodeDisposition = \"N3_QE_DISPOSITION\"", s);
  88. Assert.DoesNotContain("N3_QUALITY_DISPOSITION", s);
  89. Assert.Contains($"BizType = \"{BizType}\"", s);
  90. }
  91. // ── 专用链的既有性质:本批不改,冻结防回归 ──
  92. /// <summary>四个流程动作必须各自包在 UseTranAsync 内,并对失败 rethrow。</summary>
  93. [Theory]
  94. [InlineData("SubmitReview")]
  95. [InlineData("SupervisorApprove")]
  96. [InlineData("SupervisorReturn")]
  97. [InlineData("QeDisposition")]
  98. public void DedicatedActions_StayTransactional(string method)
  99. {
  100. var s = ReviewService();
  101. var at = s.IndexOf($"public async Task<S6ReviewStateOutput> {method}(", StringComparison.Ordinal);
  102. Assert.True(at > 0, $"未找到方法 {method}");
  103. var body = s[at..];
  104. var tranAt = body.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal);
  105. var checkAt = body.IndexOf("if (!tran.IsSuccess) throw tran.ErrorException;", StringComparison.Ordinal);
  106. Assert.True(tranAt > 0 && tranAt < 4000, $"{method} 缺少 UseTranAsync 事务边界");
  107. Assert.True(checkAt > tranAt, $"{method} 缺少 tran.IsSuccess 失败 rethrow");
  108. }
  109. /// <summary>处置记录 INSERT 必须与流程推进同处一个事务,这是禁止通用审批的根本理由。</summary>
  110. [Fact]
  111. public void QeDisposition_InsertsDispositionInsideSameTransactionAsApprove()
  112. {
  113. var s = ReviewService();
  114. var body = Slice(s, "public async Task<S6ReviewStateOutput> QeDisposition(", "if (!tran.IsSuccess)");
  115. var tranAt = body.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal);
  116. var insertAt = body.IndexOf("_db.Insertable(new AdoS6IpqcQualityDisposition", StringComparison.Ordinal);
  117. var approveAt = body.IndexOf("_flowEngine.Approve(", StringComparison.Ordinal);
  118. Assert.True(tranAt > 0, "QeDisposition 缺少事务");
  119. Assert.True(insertAt > tranAt, "处置记录 INSERT 必须在事务内");
  120. Assert.True(approveAt > insertAt, "必须先写处置记录再推进流程");
  121. }
  122. /// <summary>处置动作不得改写质量结论(overall_result 只读,既有契约)。</summary>
  123. [Fact]
  124. public void QeDisposition_NeverOverwritesOverallResult()
  125. {
  126. Assert.DoesNotContain("SET overall_result", ReviewService());
  127. }
  128. // ── 前端:既有 fail-closed 不得回退 ──
  129. /// <summary>审批中心对 S6 仍只给跳转按钮,不得回退为通用同意/拒绝。</summary>
  130. [Fact]
  131. public void PendingList_KeepsS6DeepLinkOnly()
  132. {
  133. var s = PendingList();
  134. var branch = Slice(s, $"row.bizType === '{BizType}'", "</template>");
  135. Assert.Contains("goToS6(row.bizId)", branch);
  136. Assert.DoesNotContain("emit('approve'", branch);
  137. Assert.DoesNotContain("emit('reject'", branch);
  138. }
  139. private static string Slice(string src, string from, string to)
  140. {
  141. var a = src.IndexOf(from, StringComparison.Ordinal);
  142. Assert.True(a >= 0, $"未找到起点:{from}");
  143. var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
  144. return b > a ? src[a..b] : src[a..];
  145. }
  146. private static string FindFile(params string[] parts)
  147. {
  148. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  149. while (dir != null)
  150. {
  151. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  152. if (File.Exists(candidate)) return candidate;
  153. dir = dir.Parent;
  154. }
  155. throw new FileNotFoundException(string.Join("/", parts));
  156. }
  157. }