using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow; /// /// S7 成品检验单(S7_FQC_INSPBILL,源 qms_qcpp_inspbill)通用审批旁路封堵 · 源码契约测试(S7-FQC-GUARD-1)。 /// /// 背景:守卫名单此前只有 S5_IQC_INSPBILL / S6_PROCESS_INSPECTION / IPQC_INSPECTION, /// S7 一直在名单外(早前批次显式记为 OUT OF SCOPE)。 /// /// 为什么 S7 的绕过面是**全部**业务语义:本链的 三个回调 /// (OnFlowStarted / OnNodeCompleted / OnFlowCompleted)**全是 no-op**, /// 业务写入一律由 FqcInspBillFlowService 在引擎之外、同事务内完成。于是通用审批: /// ① N1 approve → submit-result 从未写 pd/hgsl/bhgsl/clfs 就推到 N2;更严重的是 GW_RESULT /// 网关按 pd 算 disposition_required,pd 为 null 时算 0 → 不合格单静默绕过 N3 QE 处置直接结束; /// ② N2 approve → 跳过 TryEnqueueFqcPassOutboxAsync,合格出站 Outbox 永不入队; /// ③ N2 reject → FlowEngine.Reject 把实例置 Rejected 终态并取消所有待办,而专用 supervisor-reject /// 是 ReturnToPrev 回 N1 + 同事务复位 qms_fqcbj.FINSPECTSTATUS + 同步 mdp_std_fqc_task; /// ④ N3 approve → 推到 end 而 ado_s7_fqc_qe_disposition 永不写入,处置轨迹为空且无法补记。 /// /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:FlowTaskService 依赖 /// SqlSugarRepository<T>,裸 xUnit 进程无法实例化。真实阻断由本批 Runtime R1–R4 覆盖。 /// public class S7FqcGenericApprovalGuardContractTests { private const string BizType = "S7_FQC_INSPBILL"; 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", "FinishedWarehouse", "FqcInspBillFlowBizHandler.cs")); private static string FlowService() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowService.cs")); private static string Dict() => Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};"); // ── 纳入守卫 ── /// S7 真实 BizType 必须在名单里,且与常量定义一致(不允许两处写不同字面量)。 [Fact] public void S7BizType_IsInDedicatedEntryGuard() { Assert.Contains($"[\"{BizType}\"]", Dict()); Assert.Contains($"public const string BizType = \"{BizType}\"", FlowConst()); } /// §15 Regression Freeze:新增 S7 不得挤掉既有三条。 [Theory] [InlineData("S5_IQC_INSPBILL")] [InlineData("S6_PROCESS_INSPECTION")] [InlineData("IPQC_INSPECTION")] public void ExistingGuardedBizTypes_ArePreserved(string bizType) { Assert.Contains($"[\"{bizType}\"]", Dict()); } /// 名单恰好四条 —— 防止本批顺手扩大守卫范围。 [Fact] public void Guard_CoversExactlyFourBizTypes() { Assert.Equal(4, CountOf(Dict(), "\"] = \"")); } // ── 四个通用入口全覆盖(§14)── /// approve / reject / batchApprove / batchReject 四个入口都必须先过同一守卫。 [Theory] [InlineData("_engine.Approve(input.TaskId, input.Comment)")] [InlineData("_engine.Reject(input.TaskId, input.Comment)")] public void SingleActions_GuardPrecedesEngineCall(string engineCall) { var s = FlowTaskService(); var at = s.IndexOf(engineCall, StringComparison.Ordinal); Assert.True(at > 0, $"未找到引擎调用点:{engineCall}"); Assert.True(s.LastIndexOf(Guard + "(", at, StringComparison.Ordinal) > 0, $"引擎调用 {engineCall} 之前缺少 {Guard} 守卫"); } /// /// 批量入口的循环体各自持有守卫。批量端点不会走单项方法体, /// 少一处就等于批量仍可绕过(§21 / §22)。 /// [Fact] public void BatchActions_EachLoopBodyCallsGuard() { var s = FlowTaskService(); Assert.Equal(4, CountOf(s, Guard + "(taskId)") + CountOf(s, Guard + "(input.TaskId)")); foreach (var batch in new[] { "BatchApprove", "BatchReject" }) { var body = Slice(s, $"public async Task {batch}(", "return result;"); Assert.Contains(Guard + "(taskId)", body); } } // ── §11 Guard 必须早于任何 mutation ── /// 守卫是动作方法的第一条语句,因此 fail 时任务状态/实例/日志/下一节点都还没动。 [Theory] [InlineData("public async Task Approve(TaskActionInput input)")] [InlineData("public async Task Reject(TaskActionInput input)")] public void Guard_IsFirstStatementOfAction(string signature) { var body = Slice(FlowTaskService(), signature, "}"); var lines = body.Split('\n').Select(l => l.Trim()).Where(l => l != "" && l != "{" && !l.StartsWith("//", StringComparison.Ordinal)).ToList(); var first = lines.FirstOrDefault(l => !l.StartsWith("public ", StringComparison.Ordinal)); Assert.Equal($"await {Guard}(input.TaskId);", first); } /// 守卫自身只读不写:不得在判定过程里改任何实体。 [Fact] public void Guard_PerformsNoMutation() { var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n /// "); foreach (var write in new[] { "AsUpdateable", "InsertAsync", "DeleteAsync", "ExecuteCommandAsync", "UseTranAsync" }) Assert.DoesNotContain(write, guard); } // ── §10 精确匹配 ── /// 必须是精确字典查表,禁止 Contains / StartsWith 之类模糊匹配。 [Fact] public void Guard_UsesExactDictionaryLookup() { var guard = Slice(FlowTaskService(), $"private async Task {Guard}(long taskId)", "}\n\n /// "); Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", guard); foreach (var fuzzy in new[] { ".Contains(", ".StartsWith(", ".EndsWith(", "ToUpper", "ToLower" }) Assert.DoesNotContain(fuzzy, guard); } /// §16 Control:明确允许通用审批的 BizType 不得被纳入。 [Theory] [InlineData("CONTRACT_REVIEW")] [InlineData("ORDER_REVIEW")] [InlineData("SMART_OPS_IMPROVEMENT")] [InlineData("EXCEPTION_REPORT")] [InlineData("MATERIAL_SHORTAGE")] public void Guard_DoesNotBlockGenericApprovalBizTypes(string bizType) { Assert.DoesNotContain(bizType, Dict()); } /// §9 禁止散落 if:不得在四个动作里各写一遍 S7 的判断。 [Fact] public void NoScatteredPerBizTypeIfStatements() { var s = FlowTaskService(); // BizType 字面量只应出现在名单里(1 次),不应出现在任何 if 条件中 Assert.Equal(1, CountOf(s, $"\"{BizType}\"")); Assert.DoesNotContain($"== \"{BizType}\"", s); } // ── §12 权威来源与不泄漏 ── /// 权威 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); } /// §13 对外文案不得泄漏 RoleId / TenantId / 流程定义 Id 等内部实现。 [Fact] public void GuardMessage_DoesNotLeakInternals() { var dict = Dict(); foreach (var leak in new[] { "TenantId", "RoleId", "FlowId", "DefinitionId", "InstanceId" }) Assert.DoesNotContain($"{leak}=", dict); } /// 文案必须指向专用业务页,且不是裸技术异常。 [Fact] public void GuardMessage_PointsToDedicatedPage() { var line = Dict().Split('\n').Single(l => l.Contains($"[\"{BizType}\"]", StringComparison.Ordinal)); Assert.Contains("FQC检验单详情", line); Assert.Contains("不支持在审批中心直接同意/拒绝", line); foreach (var bad in new[] { "BizType not allowed", "NullReference", "500" }) Assert.DoesNotContain(bad, line); } // ── 封堵的前提:专用入口必须仍然存在且直调引擎 ── /// 四个专用入口都必须在(否则封堵等于封死业务)。 [Theory] [InlineData("submit-result")] [InlineData("supervisor-approve")] [InlineData("supervisor-reject")] [InlineData("qe-submit-disposition")] public void DedicatedEntries_StillExist(string route) { Assert.Contains($"[HttpPost(\"{route}\")]", FlowService()); } /// 专用入口直调 FlowEngine,不经 FlowTaskService —— 故守卫不会误伤正常业务路径。 [Fact] public void DedicatedEntries_CallEngineDirectly_NotViaFlowTaskService() { var s = FlowService(); Assert.Contains("_flowEngine.Approve(", s); Assert.Contains("_flowEngine.ReturnToPrev(", s); Assert.DoesNotContain("FlowTaskService", s); Assert.DoesNotContain("flowTask/approve", s); } /// /// 冻结"为什么必须封堵"的两条根据: /// ① Handler 回调全 no-op(业务副作用不在引擎里);② 专用 reject 走 ReturnToPrev 而非置终态。 /// 任一条将来被改动,本守卫的理由需要重新评估,测试在此提醒。 /// [Fact] public void BypassRationale_HandlerCallbacksAreNoOp() { var h = FlowConst(); foreach (var cb in new[] { "OnFlowStarted", "OnNodeCompleted", "OnFlowCompleted" }) { var line = h.Split('\n').Single(l => l.Contains($"public Task {cb}(", StringComparison.Ordinal)); Assert.Contains("Task.CompletedTask", line); } } /// 专用 supervisor-reject 的业务副作用必须仍在(它们正是通用 Reject 绕过的东西)。 [Fact] public void BypassRationale_DedicatedRejectHasBusinessSideEffects() { var body = Slice(FlowService(), "public async Task SupervisorReject(", "public async Task QeSubmitDisposition("); Assert.Contains("退回必须填写意见", body); Assert.Contains("_flowEngine.ReturnToPrev(", body); Assert.Contains("UPDATE qms_fqcbj SET FINSPECTSTATUS", body); Assert.Contains("SyncBillToStdSafeAsync", body); } private static int CountOf(string src, string needle) { int n = 0, i = 0; while ((i = src.IndexOf(needle, i, StringComparison.Ordinal)) >= 0) { n++; i += needle.Length; } return n; } 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)); } }