using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow; /// /// Batch 5A 源码契约测试:S5_IQC_INSPBILL 的「通用审批旁路」必须双端封死。 /// /// 背景(Batch 5 实测):通用 /api/flowTask/approve 只经引擎 GetPendingTask 校验 /// 「任务存在 / Pending / 指派人==当前用户」,不校验业务类型与租户,也不在事务内, /// 曾以 HTTP 200 真实把 S5 检验单从 N2 推进到 N3,绕过 IqcInspBillFlowService 的 /// 租户解析 / 业务单归属 / 节点守卫 / UseTranAsync 四层 authority。 /// /// 仅隐藏前端按钮不足以闭环(S7/S6 即为反例:前端 fail-closed,后端仍可手工 HTTP 直达), /// 故后端网关层必须 fail-closed,前端仅作一致的引导。 /// /// 本层沿用本仓对 FlowEngine/FlowTaskService 的既有做法——源码契约断言而非行为级集成测试: /// FlowEngineService 全程依赖 SqlSugarRepository<T>,裸 xUnit 进程无法实例化 /// (详见 S8ExceptionFlowTenantContextContractTests 的说明)。 /// public class S5IqcGenericApprovalGuardContractTests { private const string BizType = "S5_IQC_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 PendingList() => File.ReadAllText(FindFile( "Web", "src", "views", "approvalFlow", "center", "components", "PendingList.vue")); /// 后端必须持有该 bizType 的拒绝名单,且给出引导到专用页面的文案。 [Fact] public void Backend_DeclaresDedicatedEntryOnlyBizType() { var s = FlowTaskService(); Assert.Contains("DedicatedEntryOnlyBizTypes", s); Assert.Contains($"[\"{BizType}\"]", s); Assert.Contains("来料检验单详情", s); } /// 权威业务类型必须从库中反查(task → instance),不得信任前端入参。 [Fact] public void Backend_ReadsBizTypeFromDatabase_NotFromRequest() { var s = FlowTaskService(); var guard = Slice(s, $"private async Task {Guard}(long taskId)", "}\n\n /// "); Assert.Contains("_taskRep.GetByIdAsync(taskId)", guard); Assert.Contains("_instanceRep.GetByIdAsync(task.InstanceId)", guard); Assert.Contains("instance.BizType", guard); Assert.Contains("Oops.Oh(hint)", guard); // TaskActionInput 只有 TaskId/Comment,不存在可被伪造的 bizType 入参 Assert.DoesNotContain("input.BizType", guard); } /// 四个会推进流程的通用端点都必须在调用引擎之前先过 guard。 [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 Backend_GuardRunsBeforeEngineCall(string engineCall) { var s = FlowTaskService(); var engineAt = s.IndexOf(engineCall, StringComparison.Ordinal); Assert.True(engineAt > 0, $"未找到引擎调用点:{engineCall}"); // 该引擎调用点之前最近的一次 guard 调用必须存在,且中间不得再夹一次引擎调用 var guardAt = s.LastIndexOf(Guard + "(", engineAt, StringComparison.Ordinal); Assert.True(guardAt > 0, $"引擎调用 {engineCall} 之前缺少 {Guard} 守卫"); } /// 批量端点绕过单条方法体,必须各自独立带 guard(否则批量可绕过)。 [Fact] public void Backend_BatchEndpointsAlsoGuarded() { var s = FlowTaskService(); var batchApprove = Slice(s, "public async Task BatchApprove", "public async Task BatchReject"); Assert.Contains(Guard, batchApprove); var batchReject = s[s.IndexOf("public async Task BatchReject", StringComparison.Ordinal)..]; Assert.Contains(Guard, batchReject); } /// guard 只针对名单内业务,不得误伤其它通用审批(名单必须是精确匹配的字典查找)。 [Fact] public void Backend_GuardIsAllowListedByExactBizType_NotBlanket() { var s = FlowTaskService(); Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType, out var hint)", s); // 不得出现「所有 Sx 业务一律拒绝」之类的宽泛匹配 Assert.DoesNotContain("StartsWith(\"S", s); Assert.DoesNotContain("Contains(\"_INSPBILL\")", s); } /// 前端待办列表必须为 S5 提供专用分支,不再落入通用 v-else 的同意/拒绝。 [Fact] public void Frontend_PendingList_HasDedicatedS5Branch() { var s = PendingList(); Assert.Contains($"row.bizType === '{BizType}'", s); Assert.Contains("goToIqc(row.bizId)", s); Assert.Contains("/aidop/s5/iqc/detail/${bizId}", s); } /// S5 三个节点码必须与后端常量一致;尤其 N3 是 SQE 而非 S7 的 QE。 [Fact] public void Frontend_UsesS5NodeCodes_NotFqcOnes() { var s = PendingList(); var branch = Slice(s, $"row.bizType === '{BizType}'", ""); Assert.Contains("'N1_INSPECT'", branch); Assert.Contains("'N2_SUP_REVIEW'", branch); Assert.Contains("'N3_SQE_DISPOSITION'", branch); Assert.DoesNotContain("'N3_QE_DISPOSITION'", branch); // 那是 S7 FQC 的节点码 } /// 未知节点必须 fail-closed(只读文案),不得回落到通用同意/拒绝。 [Fact] public void Frontend_UnknownNode_FailsClosed() { var s = PendingList(); var branch = Slice(s, $"row.bizType === '{BizType}'", ""); Assert.Contains("= 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)); } }