using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow; /// /// S6 IPQC 过程检验单(IPQC_INSPECTION,源 qms_gcjyd)通用审批旁路封堵 · 源码契约测试。 /// /// 背景:`686824d5e` 只把 S6_PROCESS_INSPECTION(Phase1C 链)加入了守卫名单, /// 同名不同源的 IPQC_INSPECTION 一直在名单外。2026-09-14 实测:通用 /// flowTask/approve 真实把一条 IPQC N2 待办直接推进到 end。 /// /// 破坏面比 Phase1C 链更大——专用入口 IpqcInspectionDisposition.submit 在**同一事务内**: /// ① INSERT 处置记录 ado_s6_ipqc_quality_disposition; /// ② 把扩展表 ado_s6_ipqc_inspection_flow_state.business_status 推到 DISPOSED; /// ③ 才 _flowEngine.Approve 推进 N3 → end。 /// 走通用审批 ①② 都不会发生:流程显示已闭环,处置轨迹为空且业务状态永久停在旧值,事后无法补记。 /// 另:专用 supervisor-return 要求意见必填并经 ReturnToPrev 回 N1,通用 Reject 直接置实例终态。 /// /// 本文件与 分开: /// 后者还断言 Phase1C 链自己的 ReviewService / FlowConst / PendingList 形状, /// 改名或塞进去只会制造无必要 diff。两者共用同一套守卫机制,下方断言即冻结这一点。 /// /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:FlowTaskService 依赖 /// SqlSugarRepository<T>,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests)。 /// 真实阻断由本批 Runtime 矩阵 R1–R4 覆盖。 /// public class IpqcInspectionGenericApprovalGuardContractTests { private const string BizType = "IPQC_INSPECTION"; private const string Guard = "EnsureDedicatedEntryOnlyAsync"; private static string FlowTaskService() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs")); private static string FlowConst() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowConst.cs")); private static string FlowService() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowService.cs")); private static string DispositionService() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionDispositionService.cs")); // ── 守卫覆盖 ── /// IPQC_INSPECTION 必须在「只能走专用入口」名单里,且文案指向业务页。 [Fact] public void Guard_ListsIpqcInspectionAsDedicatedEntryOnly() { var s = FlowTaskService(); Assert.Contains($"[\"{BizType}\"]", s); Assert.Contains("过程检验单请在「过程检验」业务页完成检验、主管审核或质量处置", s); } /// BizType 字面量必须与运行态一致(实测 len=15,无大小写/下划线变体)。 [Fact] public void BizType_MatchesRuntimeLiteral() { Assert.Contains($"BizType = \"{BizType}\"", FlowConst()); Assert.Equal(15, BizType.Length); } /// 扩展名单时不得把既有条目挤掉——S5 与 Phase1C 链必须仍在。 [Theory] [InlineData("S5_IQC_INSPBILL")] [InlineData("S6_PROCESS_INSPECTION")] public void Guard_KeepsPreviouslyGuardedBizTypes(string bizType) { Assert.Contains($"[\"{bizType}\"]", FlowTaskService()); } /// 必须复用同一套守卫,不得为 IPQC 另起一份判定或散落 if。 [Fact] public void Guard_ReusesSharedMechanism_NotAParallelImplementation() { var s = FlowTaskService(); Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", s); Assert.DoesNotContain($"bizType == \"{BizType}\"", s); Assert.DoesNotContain($"BizType == \"{BizType}\"", s); } /// 必须精确匹配,不得退化成前缀/包含匹配把无关业务一起挡掉。 [Fact] public void Guard_UsesExactMatch_NotFuzzy() { var s = FlowTaskService(); Assert.DoesNotContain("StartsWith(\"IPQC", s); Assert.DoesNotContain("Contains(\"IPQC", s); Assert.DoesNotContain("Contains(\"INSPECTION\")", s); Assert.DoesNotContain("StartsWith(\"S6", s); } /// 四个会推进流程的通用端点都必须在调用引擎之前先过守卫(批量端点绕过单条方法体)。 [Theory] [InlineData("_engine.Approve(input.TaskId, input.Comment)")] [InlineData("_engine.Reject(input.TaskId, input.Comment)")] [InlineData("_engine.Approve(taskId, input.Comment)")] [InlineData("_engine.Reject(taskId, input.Comment)")] public void Guard_RunsBeforeEngineCall(string engineCall) { var s = FlowTaskService(); var engineAt = s.IndexOf(engineCall, StringComparison.Ordinal); Assert.True(engineAt > 0, $"未找到引擎调用点:{engineCall}"); Assert.True(s.LastIndexOf(Guard + "(", engineAt, StringComparison.Ordinal) > 0, $"引擎调用 {engineCall} 之前缺少 {Guard} 守卫"); } /// 权威 BizType 必须从库中的 instance 反查,不得取自请求体(否则可伪造绕过)。 [Fact] public void Guard_ReadsBizTypeFromDatabase() { var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n /// "); Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard); Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard); Assert.DoesNotContain("input.BizType", guard); } /// 不误伤:名单是精确字典查找,未把通用审批业务(如 CONTRACT_REVIEW)纳入。 [Theory] [InlineData("CONTRACT_REVIEW")] // 2026-09-14 移除 S7_FQC_INSPBILL:当时 S7 被列为 OUT OF SCOPE 故断言"未纳入", // 现已由 S7-FQC-GUARD-1 正式纳入守卫(见 S7FqcGenericApprovalGuardContractTests)。 [InlineData("SMART_OPS_IMPROVEMENT")] [InlineData("EXCEPTION_REPORT")] public void Guard_DoesNotBlockNonDedicatedBizTypes(string bizType) { var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};"); Assert.DoesNotContain(bizType, dict); } /// 对外文案不得泄漏 RoleId / TenantId / 流程定义 Id 等内部实现。 [Fact] public void Guard_MessageDoesNotLeakInternals() { var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};"); foreach (var leak in new[] { "TenantId", "RoleId", "FlowId", "DefinitionId" }) Assert.DoesNotContain($"{leak}=", dict); } // ── 专用入口必须仍然可用(否则封堵等于封死业务)── /// 阻断通用入口的前提是四个节点都有合法专用入口,且都直调引擎、不经 FlowTaskService。 [Theory] [InlineData("submit-inspection")] [InlineData("supervisor-approve")] [InlineData("supervisor-return")] public void DedicatedFlowEntries_Exist(string route) { Assert.Contains($"HttpPost(\"{route}\")", FlowService()); } /// N3 处置的专用入口在另一个 service。 [Fact] public void DedicatedDispositionEntry_Exists() { var s = DispositionService(); Assert.Contains("Route(\"api/IpqcInspectionDisposition\")", s); Assert.Contains("HttpPost(\"submit\")", s); } /// 专用入口不得改走通用 FlowTaskService,否则会被自己的守卫挡住。 [Fact] public void DedicatedEntries_CallEngineDirectly_NotFlowTaskService() { foreach (var src in new[] { FlowService(), DispositionService() }) { Assert.Contains("_flowEngine.", src); Assert.DoesNotContain("FlowTaskService", src); } } // ── 冻结「为什么必须封堵」的那条事务性质 ── /// 处置记录与扩展表状态必须与推进同处一个事务,且先写后推——这是禁止通用审批的根本理由。 [Fact] public void Disposition_WritesRecordAndStateInsideSameTransactionBeforeApprove() { var s = DispositionService(); var tranAt = s.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal); var insertAt = s.IndexOf("_db.Insertable(disposition)", StringComparison.Ordinal); var stateAt = s.IndexOf("UPDATE ado_s6_ipqc_inspection_flow_state", StringComparison.Ordinal); var approveAt = s.IndexOf("_flowEngine.Approve(", StringComparison.Ordinal); Assert.True(tranAt > 0, "缺少事务边界"); Assert.True(insertAt > tranAt, "处置记录 INSERT 必须在事务内"); Assert.True(stateAt > tranAt, "扩展表状态更新必须在事务内"); Assert.True(approveAt > insertAt && approveAt > stateAt, "必须先落业务再推进流程"); Assert.Contains("if (!tran.IsSuccess) throw tran.ErrorException;", s); } /// 专用退回要求意见必填并回退到 N1,与通用 Reject 的终态语义不同。 [Fact] public void SupervisorReturn_RequiresCommentAndGoesBackToN1() { var s = FlowService(); Assert.Contains("throw Oops.Oh(\"退回必须填写意见\")", s); Assert.Contains("_flowEngine.ReturnToPrev(", s); } private static string Slice(string src, string from, string to) { var a = src.IndexOf(from, StringComparison.Ordinal); Assert.True(a >= 0, $"未找到起点:{from}"); var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal); return b > a ? src[a..b] : src[a..]; } private static string FindFile(params string[] parts) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null) { var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray()); if (File.Exists(candidate)) return candidate; dir = dir.Parent; } throw new FileNotFoundException(string.Join("/", parts)); } }