S7FqcGenericApprovalGuardContractTests.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
  3. /// <summary>
  4. /// S7 成品检验单(<c>S7_FQC_INSPBILL</c>,源 qms_qcpp_inspbill)通用审批旁路封堵 · 源码契约测试(S7-FQC-GUARD-1)。
  5. ///
  6. /// 背景:守卫名单此前只有 S5_IQC_INSPBILL / S6_PROCESS_INSPECTION / IPQC_INSPECTION,
  7. /// S7 一直在名单外(早前批次显式记为 OUT OF SCOPE)。
  8. ///
  9. /// 为什么 S7 的绕过面是**全部**业务语义:本链的 <see cref="FqcInspBillFlowBizHandler"/> 三个回调
  10. /// (OnFlowStarted / OnNodeCompleted / OnFlowCompleted)**全是 no-op**,
  11. /// 业务写入一律由 <c>FqcInspBillFlowService</c> 在引擎之外、同事务内完成。于是通用审批:
  12. /// ① N1 approve → submit-result 从未写 pd/hgsl/bhgsl/clfs 就推到 N2;更严重的是 GW_RESULT
  13. /// 网关按 pd 算 disposition_required,pd 为 null 时算 0 → 不合格单静默绕过 N3 QE 处置直接结束;
  14. /// ② N2 approve → 跳过 TryEnqueueFqcPassOutboxAsync,合格出站 Outbox 永不入队;
  15. /// ③ N2 reject → FlowEngine.Reject 把实例置 Rejected 终态并取消所有待办,而专用 supervisor-reject
  16. /// 是 ReturnToPrev 回 N1 + 同事务复位 qms_fqcbj.FINSPECTSTATUS + 同步 mdp_std_fqc_task;
  17. /// ④ N3 approve → 推到 end 而 ado_s7_fqc_qe_disposition 永不写入,处置轨迹为空且无法补记。
  18. ///
  19. /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:FlowTaskService 依赖
  20. /// SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化。真实阻断由本批 Runtime R1–R4 覆盖。
  21. /// </summary>
  22. public class S7FqcGenericApprovalGuardContractTests
  23. {
  24. private const string BizType = "S7_FQC_INSPBILL";
  25. private const string Guard = "EnsureDedicatedEntryOnlyAsync";
  26. private static string FlowTaskService() => File.ReadAllText(FindFile(
  27. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
  28. private static string FlowConst() => File.ReadAllText(FindFile(
  29. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowBizHandler.cs"));
  30. private static string FlowService() => File.ReadAllText(FindFile(
  31. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowService.cs"));
  32. private static string Dict() => Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
  33. // ── 纳入守卫 ──
  34. /// <summary>S7 真实 BizType 必须在名单里,且与常量定义一致(不允许两处写不同字面量)。</summary>
  35. [Fact]
  36. public void S7BizType_IsInDedicatedEntryGuard()
  37. {
  38. Assert.Contains($"[\"{BizType}\"]", Dict());
  39. Assert.Contains($"public const string BizType = \"{BizType}\"", FlowConst());
  40. }
  41. /// <summary>§15 Regression Freeze:新增 S7 不得挤掉既有三条。</summary>
  42. [Theory]
  43. [InlineData("S5_IQC_INSPBILL")]
  44. [InlineData("S6_PROCESS_INSPECTION")]
  45. [InlineData("IPQC_INSPECTION")]
  46. public void ExistingGuardedBizTypes_ArePreserved(string bizType)
  47. {
  48. Assert.Contains($"[\"{bizType}\"]", Dict());
  49. }
  50. /// <summary>名单恰好四条 —— 防止顺手扩大守卫范围(T3)。</summary>
  51. [Fact]
  52. public void Guard_CoversExactlyFourBizTypes()
  53. {
  54. Assert.Equal(4, CountOf(Dict(), "\"] = new("));
  55. }
  56. // ── 四个通用入口全覆盖(§14)──
  57. /// <summary>approve / reject / batchApprove / batchReject 四个入口都必须先过同一守卫。</summary>
  58. [Theory]
  59. [InlineData("_engine.Approve(input.TaskId, input.Comment)")]
  60. [InlineData("_engine.Reject(input.TaskId, input.Comment)")]
  61. public void SingleActions_GuardPrecedesEngineCall(string engineCall)
  62. {
  63. var s = FlowTaskService();
  64. var at = s.IndexOf(engineCall, StringComparison.Ordinal);
  65. Assert.True(at > 0, $"未找到引擎调用点:{engineCall}");
  66. Assert.True(s.LastIndexOf(Guard + "(", at, StringComparison.Ordinal) > 0,
  67. $"引擎调用 {engineCall} 之前缺少 {Guard} 守卫");
  68. }
  69. /// <summary>
  70. /// 批量入口的循环体各自持有守卫。批量端点不会走单项方法体,
  71. /// 少一处就等于批量仍可绕过(§21 / §22)。
  72. /// </summary>
  73. [Fact]
  74. public void BatchActions_EachLoopBodyCallsGuard()
  75. {
  76. var s = FlowTaskService();
  77. Assert.Equal(4, CountOf(s, Guard + "(taskId)") + CountOf(s, Guard + "(input.TaskId)"));
  78. foreach (var batch in new[] { "BatchApprove", "BatchReject" })
  79. {
  80. var body = Slice(s, $"public async Task<BatchActionResult> {batch}(", "return result;");
  81. Assert.Contains(Guard + "(taskId)", body);
  82. }
  83. }
  84. // ── §11 Guard 必须早于任何 mutation ──
  85. /// <summary>守卫是动作方法的第一条语句,因此 fail 时任务状态/实例/日志/下一节点都还没动。</summary>
  86. [Theory]
  87. [InlineData("public async Task Approve(TaskActionInput input)")]
  88. [InlineData("public async Task Reject(TaskActionInput input)")]
  89. public void Guard_IsFirstStatementOfAction(string signature)
  90. {
  91. var body = Slice(FlowTaskService(), signature, "}");
  92. var lines = body.Split('\n').Select(l => l.Trim()).Where(l => l != "" && l != "{" && !l.StartsWith("//", StringComparison.Ordinal)).ToList();
  93. var first = lines.FirstOrDefault(l => !l.StartsWith("public ", StringComparison.Ordinal));
  94. Assert.Equal($"await {Guard}(input.TaskId);", first);
  95. }
  96. /// <summary>守卫自身只读不写:不得在判定过程里改任何实体。</summary>
  97. [Fact]
  98. public void Guard_PerformsNoMutation()
  99. {
  100. var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n /// <summary>");
  101. foreach (var write in new[] { "AsUpdateable", "InsertAsync", "DeleteAsync", "ExecuteCommandAsync", "UseTranAsync" })
  102. Assert.DoesNotContain(write, guard);
  103. }
  104. // ── §10 精确匹配 ──
  105. /// <summary>必须是精确字典查表,禁止 Contains / StartsWith 之类模糊匹配。</summary>
  106. [Fact]
  107. public void Guard_UsesExactDictionaryLookup()
  108. {
  109. var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n /// <summary>");
  110. Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", guard);
  111. foreach (var fuzzy in new[] { ".Contains(", ".StartsWith(", ".EndsWith(", "ToUpper", "ToLower" })
  112. Assert.DoesNotContain(fuzzy, guard);
  113. }
  114. /// <summary>§16 Control:明确允许通用审批的 BizType 不得被纳入。</summary>
  115. [Theory]
  116. [InlineData("CONTRACT_REVIEW")]
  117. [InlineData("ORDER_REVIEW")]
  118. [InlineData("SMART_OPS_IMPROVEMENT")]
  119. [InlineData("EXCEPTION_REPORT")]
  120. [InlineData("MATERIAL_SHORTAGE")]
  121. public void Guard_DoesNotBlockGenericApprovalBizTypes(string bizType)
  122. {
  123. Assert.DoesNotContain(bizType, Dict());
  124. }
  125. /// <summary>
  126. /// T10 / §9 禁止散落 if:不得在动作方法里各写一遍 S7 的判断。
  127. ///
  128. /// <para>Policy Consolidation 之后,S7 字面量在整个文件中**只应出现一次**——
  129. /// 即统一 policy 字典的键。封堵范围由该条记录的 <c>BlockFlowMutation</c> 字段表达,
  130. /// 不再有第二处成员声明。</para>
  131. /// </summary>
  132. [Fact]
  133. public void NoScatteredPerBizTypeIfStatements()
  134. {
  135. var s = FlowTaskService();
  136. Assert.Equal(1, CountOf(s, $"\"{BizType}\""));
  137. Assert.DoesNotContain($"== \"{BizType}\"", s);
  138. Assert.DoesNotContain($"case \"{BizType}\"", s);
  139. }
  140. // ── §12 权威来源与不泄漏 ──
  141. /// <summary>权威 BizType 必须从库中 instance 反查,不得取自请求体(否则可伪造绕过)。</summary>
  142. [Fact]
  143. public void Guard_ReadsBizTypeFromDatabase()
  144. {
  145. var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n /// <summary>");
  146. Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard);
  147. Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard);
  148. Assert.DoesNotContain("input.BizType", guard);
  149. }
  150. /// <summary>§13 对外文案不得泄漏 RoleId / TenantId / 流程定义 Id 等内部实现。</summary>
  151. [Fact]
  152. public void GuardMessage_DoesNotLeakInternals()
  153. {
  154. var dict = Dict();
  155. foreach (var leak in new[] { "TenantId", "RoleId", "FlowId", "DefinitionId", "InstanceId" })
  156. Assert.DoesNotContain($"{leak}=", dict);
  157. }
  158. /// <summary>文案必须指向专用业务页,且不是裸技术异常。</summary>
  159. [Fact]
  160. public void GuardMessage_PointsToDedicatedPage()
  161. {
  162. var line = Dict().Split('\n').Single(l => l.Contains($"[\"{BizType}\"]", StringComparison.Ordinal));
  163. Assert.Contains("FQC检验单详情", line);
  164. Assert.Contains("不支持在审批中心直接同意/拒绝", line);
  165. foreach (var bad in new[] { "BizType not allowed", "NullReference", "500" })
  166. Assert.DoesNotContain(bad, line);
  167. }
  168. // ── 封堵的前提:专用入口必须仍然存在且直调引擎 ──
  169. /// <summary>四个专用入口都必须在(否则封堵等于封死业务)。</summary>
  170. [Theory]
  171. [InlineData("submit-result")]
  172. [InlineData("supervisor-approve")]
  173. [InlineData("supervisor-reject")]
  174. [InlineData("qe-submit-disposition")]
  175. public void DedicatedEntries_StillExist(string route)
  176. {
  177. Assert.Contains($"[HttpPost(\"{route}\")]", FlowService());
  178. }
  179. /// <summary>专用入口直调 FlowEngine,不经 FlowTaskService —— 故守卫不会误伤正常业务路径。</summary>
  180. [Fact]
  181. public void DedicatedEntries_CallEngineDirectly_NotViaFlowTaskService()
  182. {
  183. var s = FlowService();
  184. Assert.Contains("_flowEngine.Approve(", s);
  185. Assert.Contains("_flowEngine.ReturnToPrev(", s);
  186. Assert.DoesNotContain("FlowTaskService", s);
  187. Assert.DoesNotContain("flowTask/approve", s);
  188. }
  189. /// <summary>
  190. /// 冻结"为什么必须封堵"的两条根据:
  191. /// ① Handler 回调全 no-op(业务副作用不在引擎里);② 专用 reject 走 ReturnToPrev 而非置终态。
  192. /// 任一条将来被改动,本守卫的理由需要重新评估,测试在此提醒。
  193. /// </summary>
  194. [Fact]
  195. public void BypassRationale_HandlerCallbacksAreNoOp()
  196. {
  197. var h = FlowConst();
  198. foreach (var cb in new[] { "OnFlowStarted", "OnNodeCompleted", "OnFlowCompleted" })
  199. {
  200. var line = h.Split('\n').Single(l => l.Contains($"public Task {cb}(", StringComparison.Ordinal));
  201. Assert.Contains("Task.CompletedTask", line);
  202. }
  203. }
  204. /// <summary>专用 supervisor-reject 的业务副作用必须仍在(它们正是通用 Reject 绕过的东西)。</summary>
  205. [Fact]
  206. public void BypassRationale_DedicatedRejectHasBusinessSideEffects()
  207. {
  208. var body = Slice(FlowService(), "public async Task<FqcFlowStateOutput> SupervisorReject(", "public async Task<FqcFlowStateOutput> QeSubmitDisposition(");
  209. Assert.Contains("退回必须填写意见", body);
  210. Assert.Contains("_flowEngine.ReturnToPrev(", body);
  211. Assert.Contains("UPDATE qms_fqcbj SET FINSPECTSTATUS", body);
  212. Assert.Contains("SyncBillToStdSafeAsync", body);
  213. }
  214. // ══════════════════════════════════════════════════════════════
  215. // 变更型通用入口封堵(S7-FQC-GENERIC-FLOW-MUTATION-GUARD-1)
  216. //
  217. // 上一批 Evidence Audit 确认:DedicatedEntryOnly 守卫此前只挂在
  218. // approve / reject / batchApprove / batchReject 四处,而 ReturnToPrev /
  219. // Withdraw / Transfer / AddSign 四个**会改流程状态**的入口全部直通引擎、零守卫。
  220. // 本区块冻结这四处的封堵,并冻结「不得顺手扩大到其它 BizType」。
  221. // ══════════════════════════════════════════════════════════════
  222. private const string MutationGuardByTask = "EnsureDedicatedFlowMutationOnlyByTaskAsync";
  223. private const string MutationGuardByInstance = "EnsureDedicatedFlowMutationOnlyByInstanceAsync";
  224. /// <summary>取某 BizType 在统一 policy 字典里的整条记录(跨行,含 BlockFlowMutation 字段)。</summary>
  225. private static string PolicyOf(string bizType) => Slice(Dict(), $"[\"{bizType}\"] = new(", "),");
  226. /// <summary>
  227. /// T1–T4:四个变更型入口各自的守卫必须是方法的**第一条语句**。
  228. ///
  229. /// <para>为什么必须是第一条:引擎侧这四个动作都没有 <c>[UnitOfWork]</c>,
  230. /// 一旦进入就按「取消同节点待办 → 改 task 状态 → 改 CurrentNodeId → 建新任务」
  231. /// 逐步提交,中途抛错无法回滚。守卫晚一步就不再是 ZERO MUTATION。</para>
  232. /// </summary>
  233. [Theory]
  234. [InlineData("public async Task ReturnToPrev(TaskActionInput input)", MutationGuardByTask, "input.TaskId")]
  235. [InlineData("public async Task Transfer(TaskTransferInput input)", MutationGuardByTask, "input.TaskId")]
  236. [InlineData("public async Task AddSign(TaskAddSignInput input)", MutationGuardByTask, "input.TaskId")]
  237. [InlineData("public async Task Withdraw(TaskWithdrawInput input)", MutationGuardByInstance, "input.InstanceId")]
  238. public void FlowMutationEntries_GuardIsFirstStatement(string signature, string guard, string arg)
  239. {
  240. var body = Slice(FlowTaskService(), signature, "}");
  241. var first = body.Split('\n').Select(l => l.Trim())
  242. .Where(l => l != "" && l != "{" && !l.StartsWith("//", StringComparison.Ordinal)
  243. && !l.StartsWith("public ", StringComparison.Ordinal))
  244. .FirstOrDefault();
  245. Assert.Equal($"await {guard}({arg});", first);
  246. }
  247. /// <summary>T5:守卫调用必须严格早于对应的 FlowEngine mutation 调用。</summary>
  248. [Theory]
  249. [InlineData("public async Task ReturnToPrev(TaskActionInput input)", "_engine.ReturnToPrev(")]
  250. [InlineData("public async Task Transfer(TaskTransferInput input)", "_engine.Transfer(")]
  251. [InlineData("public async Task AddSign(TaskAddSignInput input)", "_engine.AddSign(")]
  252. [InlineData("public async Task Withdraw(TaskWithdrawInput input)", "_engine.Withdraw(")]
  253. public void FlowMutationEntries_GuardPrecedesEngineCall(string signature, string engineCall)
  254. {
  255. var body = Slice(FlowTaskService(), signature, "}");
  256. var guardAt = body.IndexOf("EnsureDedicatedFlowMutationOnly", StringComparison.Ordinal);
  257. var engineAt = body.IndexOf(engineCall, StringComparison.Ordinal);
  258. Assert.True(guardAt >= 0, $"{signature} 缺少变更型守卫");
  259. Assert.True(engineAt > 0, $"未找到引擎调用 {engineCall}");
  260. Assert.True(guardAt < engineAt, $"{signature} 的守卫晚于 {engineCall},无法保证 ZERO MUTATION");
  261. }
  262. /// <summary>
  263. /// T1:只允许存在**一份** BizType policy authority source ——
  264. /// 恰好一个 Dictionary、零个旁路 HashSet。
  265. /// </summary>
  266. [Fact]
  267. public void Policy_HasExactlyOneAuthoritySource()
  268. {
  269. var s = FlowTaskService();
  270. Assert.Equal(1, CountOf(s, "private static readonly Dictionary"));
  271. Assert.Equal(0, CountOf(s, "private static readonly HashSet"));
  272. Assert.Contains("Dictionary<string, DedicatedEntryPolicy> DedicatedEntryOnlyBizTypes = new()", s);
  273. }
  274. /// <summary>
  275. /// T2:第二套名单必须已彻底删除 —— 连同任何「换个名字的同款集合」。
  276. /// </summary>
  277. [Theory]
  278. [InlineData("FlowMutationDedicatedOnlyBizTypes")]
  279. [InlineData("FlowMutationBizTypes")]
  280. [InlineData("S7FlowMutationBizTypes")]
  281. [InlineData("ShouldBlockS7")]
  282. [InlineData("IsS7")]
  283. public void Policy_SecondListIsRemoved(string forbidden)
  284. {
  285. Assert.DoesNotContain(forbidden, FlowTaskService());
  286. }
  287. /// <summary>T4:S7 的封堵范围延伸到变更型入口。</summary>
  288. [Fact]
  289. public void Policy_S7BlocksFlowMutation()
  290. {
  291. Assert.Contains("BlockFlowMutation: true", PolicyOf(BizType));
  292. }
  293. /// <summary>
  294. /// T5:其余三条只封堵同意/拒绝,变更型入口保持既有行为 ——
  295. /// 本次 consolidation 不得改变 S5 / S6 / IPQC 的任何行为。
  296. /// </summary>
  297. [Theory]
  298. [InlineData("S5_IQC_INSPBILL")]
  299. [InlineData("S6_PROCESS_INSPECTION")]
  300. [InlineData("IPQC_INSPECTION")]
  301. public void Policy_OtherDedicatedBizTypesDoNotBlockFlowMutation(string bizType)
  302. {
  303. Assert.Contains("BlockFlowMutation: false", PolicyOf(bizType));
  304. }
  305. /// <summary>
  306. /// T4/T5 续:恰好一条 <c>true</c>、三条 <c>false</c> ——
  307. /// 防止后续顺手把别的 BizType 翻成 true。
  308. /// </summary>
  309. [Fact]
  310. public void Policy_ExactlyOneBizTypeBlocksFlowMutation()
  311. {
  312. var dict = Dict();
  313. Assert.Equal(1, CountOf(dict, "BlockFlowMutation: true"));
  314. Assert.Equal(3, CountOf(dict, "BlockFlowMutation: false"));
  315. }
  316. /// <summary>
  317. /// 判定必须走显式字段,禁止从文案内容 / 字典顺序 / BizType 字面量前缀反推(§4)。
  318. /// </summary>
  319. [Fact]
  320. public void Policy_DecisionUsesExplicitFlagOnly()
  321. {
  322. var byInstance = Slice(FlowTaskService(), $"private async Task {MutationGuardByInstance}(long", "}\n\n /// <summary>");
  323. Assert.Contains("policy.BlockFlowMutation", byInstance);
  324. foreach (var inferred in new[] { "Message.Contains", "StartsWith", "EndsWith", "IndexOf", "ElementAt", "First()" })
  325. Assert.DoesNotContain(inferred, byInstance);
  326. }
  327. /// <summary>T7:原 approve/reject/batch 守卫必须仍然调用**原**守卫,未被新守卫替换。</summary>
  328. [Theory]
  329. [InlineData("public async Task Approve(TaskActionInput input)")]
  330. [InlineData("public async Task Reject(TaskActionInput input)")]
  331. public void ApproveReject_StillUseOriginalGuard(string signature)
  332. {
  333. var body = Slice(FlowTaskService(), signature, "}");
  334. Assert.Contains($"{Guard}(input.TaskId)", body);
  335. Assert.DoesNotContain("EnsureDedicatedFlowMutationOnly", body);
  336. }
  337. /// <summary>
  338. /// T8:Escalate / Urge 本批明确 OUT OF SCOPE,不得被误加守卫。
  339. /// Escalate 当前因 S7 定义未配 manual escalation 而在变更前即抛错;
  340. /// Urge 不改流程状态,其权限问题属 ApprovalFlow 通用治理。
  341. /// </summary>
  342. [Theory]
  343. [InlineData("public async Task Escalate(TaskActionInput input)", "_engine.Escalate(")]
  344. [InlineData("public async Task Urge(TaskUrgeInput input)", "_engine.Urge(")]
  345. public void EscalateAndUrge_RemainUntouched(string signature, string engineCall)
  346. {
  347. var body = Slice(FlowTaskService(), signature, "}");
  348. Assert.Contains(engineCall, body);
  349. Assert.DoesNotContain("EnsureDedicatedFlowMutationOnly", body);
  350. Assert.DoesNotContain(Guard + "(", body);
  351. }
  352. /// <summary>两个新守卫自身只读——判定过程不得改任何实体(ZERO MUTATION 的静态前提)。</summary>
  353. [Theory]
  354. [InlineData(MutationGuardByTask)]
  355. [InlineData(MutationGuardByInstance)]
  356. public void FlowMutationGuards_PerformNoMutation(string guard)
  357. {
  358. var body = Slice(FlowTaskService(), $"private async Task {guard}(long", "}\n\n /// <summary>");
  359. foreach (var write in new[] { "AsUpdateable", "InsertAsync", "DeleteAsync", "ExecuteCommandAsync", "UseTranAsync" })
  360. Assert.DoesNotContain(write, body);
  361. }
  362. /// <summary>新守卫的权威 BizType 同样必须来自库,且复用同一字典(不得自带文案体系)。</summary>
  363. [Fact]
  364. public void FlowMutationGuard_ReadsBizTypeFromDatabaseAndReusesDict()
  365. {
  366. var byInstance = Slice(FlowTaskService(), $"private async Task {MutationGuardByInstance}(long", "}\n\n /// <summary>");
  367. Assert.Contains("_instanceRep.GetByIdAsync(instanceId)", byInstance);
  368. Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType", byInstance);
  369. Assert.DoesNotContain("input.BizType", byInstance);
  370. var byTask = Slice(FlowTaskService(), $"private async Task {MutationGuardByTask}(long", "}\n\n /// <summary>");
  371. Assert.Contains("_taskRep.GetByIdAsync(taskId)", byTask);
  372. Assert.Contains($"{MutationGuardByInstance}(task.InstanceId)", byTask);
  373. }
  374. /// <summary>S7 文案必须覆盖本批新增的四个动作,否则用户点「转办」却看到「不支持同意/拒绝」。</summary>
  375. [Fact]
  376. public void GuardMessage_CoversFlowMutationActions()
  377. {
  378. var line = Dict().Split('\n').Single(l => l.Contains($"[\"{BizType}\"]", StringComparison.Ordinal));
  379. foreach (var act in new[] { "退回", "撤回", "转办", "加签" })
  380. Assert.Contains(act, line);
  381. }
  382. /// <summary>
  383. /// 封堵成立的前提:S7 合法退回能力仍在专用入口上。
  384. /// 通用 ReturnToPrev 被封后,N2→N1 必须仍可经 supervisor-reject 完成。
  385. /// </summary>
  386. [Fact]
  387. public void FlowMutationBlock_LeavesDedicatedRejectAsTheOnlyReturnPath()
  388. {
  389. var s = FlowService();
  390. Assert.Contains("[HttpPost(\"supervisor-reject\")]", s);
  391. Assert.Contains("_flowEngine.ReturnToPrev(", s);
  392. // 专用入口直调引擎,不经 FlowTaskService,故不会被本批守卫拦住
  393. Assert.DoesNotContain("FlowTaskService", s);
  394. }
  395. private static int CountOf(string src, string needle)
  396. {
  397. int n = 0, i = 0;
  398. while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; }
  399. return n;
  400. }
  401. private static string Slice(string src, string from, string to)
  402. {
  403. var a = src.IndexOf(from, StringComparison.Ordinal);
  404. Assert.True(a >= 0, $"未找到起点:{from}");
  405. var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
  406. return b > a ? src[a..b] : src[a..];
  407. }
  408. private static string FindFile(params string[] parts)
  409. {
  410. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  411. while (dir != null)
  412. {
  413. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  414. if (File.Exists(candidate)) return candidate;
  415. dir = dir.Parent;
  416. }
  417. throw new FileNotFoundException(string.Join("/", parts));
  418. }
  419. }