using Admin.NET.Plugin.AiDOP.Const.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-UAT-APPROVAL-REPAIR-1:审核链修复。 /// /// 修的是一个完整的静默悬挂(本地取证,非推测):UAT 租户 3 条 /// PENDING_VERIFICATION 异常的 verifier_user_id 都是 UATQualityA, /// 而对应 EXCEPTION_CLOSURE 实例 Status=Running、tasks=0、 /// 异常上的 active_flow_instance_id 仍是 NULL —— 节点配的是别租户角色 Id, /// ResolveApprovers 返回 0 人 → ProcessNextNode 抛错 → /// OnFlowStarted 从未调用 → 实例永久悬挂,而 S8 侧显示一切正常。 /// public class S8ApprovalFlowRepairTests { private static readonly string PluginRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); private static string CodeOnly(string relativePath) { var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return string.Join('\n', File.ReadAllLines(full) .Where(l => { var t = l.TrimStart(); return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal); })); } // ══════════════════════════════════════════════════════════════════════ // 判据:什么算「已经租户安全」 // ══════════════════════════════════════════════════════════════════════ /// /// T5 / T6:判据是「该节点引用的每个对象是否都属于本租户」, /// 而不是「用了哪种 approverType 写法」。 /// /// 第一版按写法判(只认 RoleCode),把默认租户一条 /// SpecificUser 指向本租户账号的正确配置也改写了 —— /// 那是把「这种写法在别的租户下不安全」错当成「这个配置错了」。 /// [Theory] // 本租户 RoleCode → 安全 [InlineData("Role", "ROLE_QC", true)] [InlineData("Role", "ROLE_QC,ROLE_S8_CONFIG_ADMIN", true)] // 本租户 RoleId(数字)→ 也安全:判据是「引用对象归不归本租户」,不是「用了哪种写法」 [InlineData("Role", "9001", true)] // 跨租户数字 RoleId(正是坏掉的那个配置)→ 不安全 [InlineData("Role", "1300000000101,1300000000888", false)] // 混入跨租户对象 → 整体不安全 [InlineData("Role", "ROLE_QC,1300000000101", false)] [InlineData("Role", "ROLE_NOT_IN_TENANT", false)] // SpecificUser 指向**本租户**账号 → 安全(第一版把它误判为不安全,改坏了默认租户的正确配置) [InlineData("SpecificUser", "7001", true)] // SpecificUser 指向别租户账号 → 不安全(escalation 的现状) [InlineData("SpecificUser", "789819678195781", false)] // Department 同理 [InlineData("Department", "8001", true)] [InlineData("Department", "246", false)] // Initiator 由运行时按发起人解析,天然不跨租户 [InlineData("Initiator", null, true)] // 空配置 / 认不出的类型 → 不安全 [InlineData("Role", "", false)] [InlineData("Role", null, false)] [InlineData("WhoKnows", "1", false)] public void TenantSafetyPredicate_JudgesByReferencedObjectOwnership(string type, string? ids, bool expected) { var scope = new S8ApprovalFlowRepairService.S8TenantApproverScope( RoleCodes: new HashSet(StringComparer.Ordinal) { "ROLE_QC", "ROLE_S8_CONFIG_ADMIN" }, RoleIds: new HashSet { 9001 }, UserIds: new HashSet { 7001 }, OrgIds: new HashSet { 8001 }); Assert.Equal(expected, S8ApprovalFlowRepairService.IsAlreadyTenantSafe(type, ids, scope)); } /// /// T3:跨租户审批人配置必须被判为需要修复 —— 这正是 escalation 的现状 /// (三个节点全部 SpecificUser=789819678195781,Demo02 属默认租户)。 /// [Fact] public void T3_CrossTenantApprover_IsRejectedAsUnsafe() { var scope = new S8ApprovalFlowRepairService.S8TenantApproverScope( RoleCodes: new HashSet(StringComparer.Ordinal) { "ROLE_QC" }, RoleIds: new HashSet(), UserIds: new HashSet(), OrgIds: new HashSet()); Assert.False(S8ApprovalFlowRepairService.IsAlreadyTenantSafe("SpecificUser", "789819678195781", scope)); Assert.False(S8ApprovalFlowRepairService.IsAlreadyTenantSafe("Role", "1300000000101,1300000000888", scope)); } /// /// 对账服务写下的改动必须能按自己的留证撤回。 /// /// 判据本身也会出错:第一版把「SpecificUser 指向本租户账号」误判为不安全, /// 于是改坏了默认租户一条本来正确的配置。没有回退能力,一次判据失误就永久生效。 /// [Fact] public void Repair_CanUndoItsOwnWritesWhenPredicateChanges() { var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs"); Assert.Contains("RESTORED", code); Assert.Contains("s8_flow_repair_restored", code); Assert.Contains("priorLogs.TryGetValue((flow.Id, nodeName), out var prior)", code); // 只撤自己写的:当前值必须与留证里的 after 完全一致才回退 Assert.Contains("string.Equals(prior.AfterIds, beforeIds, StringComparison.Ordinal)", code); // 且回退目标必须按**现在的**判据是安全的 Assert.Contains("IsAlreadyTenantSafe(prior.BeforeType, prior.BeforeIds, tenantScope)", code); } // ══════════════════════════════════════════════════════════════════════ // 修复行为 // ══════════════════════════════════════════════════════════════════════ /// /// 派生依据必须是该租户自己的权限事实,不是硬编码角色名。 /// 升级流程没有专门角色时回落审核角色,且回落必须写进留证 —— /// 否则将来没人知道升级审批为什么是质检在审。 /// [Fact] public void Repair_DerivesRolesFromTenantOwnPermissionGrants() { var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs"); Assert.Contains("S8PermissionCatalog.VerificationApprove", code); Assert.Contains("S8PermissionCatalog.ExceptionUpgrade", code); Assert.Contains("r.TenantId.Value", code); Assert.Contains("回落使用审核角色", code); // 不得出现硬编码的具体角色名 foreach (var hardcoded in new[] { "\"ROLE_QC\"", "\"UAT_FUNCTION_USER\"", "\"ROLE_S8_CONFIG_ADMIN\"" }) Assert.DoesNotContain(hardcoded, code); } /// /// T4:推导不出角色时不猜 —— 保持原样并大声报出来。 /// /// 塞一个"看起来能用"的角色进去会让人以为审核链已经通了, /// 那比留着坏配置更危险。 /// [Fact] public void T4_NoDerivableRole_BlocksLoudlyInsteadOfGuessing() { var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs"); Assert.Contains("BLOCKED_NO_ROLE", code); Assert.Contains("s8_flow_repair_blocked", code); Assert.Contains("保持原样", code); } /// /// 只修坏的,不碰好的;判据本身保证幂等(修好后第二次运行命中「已 OK」而跳过)。 /// /// 每次启动无条件回写会覆盖管理员的手工配置 —— /// 那是比配置错误更难发现的故障。 /// [Fact] public void Repair_IsIdempotentAndDoesNotOverwriteGoodConfig() { var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs"); Assert.Contains("SKIPPED_ALREADY_OK", code); Assert.Contains("IsAlreadyTenantSafe(beforeType, beforeIds, tenantScope)", code); Assert.Contains("result.AlreadyOkCount++", code); } /// /// 修复只作用于 S8 自己的两条流程,绝不波及 S1/S5/S6/S7。 /// /// 真库实测 74 个 SpecificUser 节点里有大量跨租户配置 /// (CONTRACT_REVIEW / ORDER_CHANGE_REVIEW / MATERIAL_SHORTAGE 在三个租户里 /// 都指向租户 797 的账号)。本批不碰它们。 /// [Fact] public void Repair_ScopeIsLimitedToS8FlowsOnly() { var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs"); Assert.Contains("f.BizType == ClosureBizType || f.BizType == EscalationBizType", code); Assert.Equal("EXCEPTION_CLOSURE", S8ApprovalFlowRepairService.ClosureBizType); Assert.Equal("EXCEPTION_ESCALATION", S8ApprovalFlowRepairService.EscalationBizType); foreach (var other in new[] { "CONTRACT_REVIEW", "ORDER_CHANGE_REVIEW", "MATERIAL_SHORTAGE", "ORDER_REVIEW" }) Assert.DoesNotContain(other, code); } /// /// 不改 FlowEngine:给 SpecificUser / Department 补租户谓词会外溢到其它模块, /// 属另一个批次。本批以「只用 RoleCode 形态」绕开该缺陷。 /// [Fact] public void FlowEngine_IsNotModifiedByThisBatch() { var engine = Path.GetFullPath(Path.Combine( PluginRoot, "../Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs")); Assert.True(File.Exists(engine)); var src = File.ReadAllText(engine); // Role 的 code 分支本来就有租户校验 —— 这正是本批选择 RoleCode 形态的依据 Assert.Contains("r.TenantId == effectiveTenantId", src); // 本批没有往里加 S8 专属逻辑 Assert.DoesNotContain("S8-UAT-APPROVAL-REPAIR", src); } /// 修复必须留证:改前 / 改后 / 原因 / 结论,缺一不可。 [Fact] public void Repair_LeavesAuditTrail() { var t = typeof(AdoS8ApprovalFlowRepairLog); foreach (var p in new[] { "TenantId", "BizType", "NodeName", "BeforeType", "BeforeIds", "AfterType", "AfterIds", "Reason", "Outcome" }) Assert.NotNull(t.GetProperty(p)); var code = CodeOnly("Service/S8/S8ApprovalFlowRepairService.cs"); Assert.Contains("REPAIRED", code); } // ══════════════════════════════════════════════════════════════════════ // verifier 与审批任务的一致性(§10) // ══════════════════════════════════════════════════════════════════════ /// /// T7 / T10 / T11:检验人必须具备「审核」动作权限。 /// /// 这条校验保证「UI 能选到的人」⊆「审批任务的收件人」: /// 复核节点按「被授予 s8:verification:approve 的角色」派发, /// 而检验人也必须持有同一能力。少了它就会出现错位 —— /// 页面上选了甲,任务发给乙那一组,甲手里没有任务、乙又过不了 /// verifier_user_id 校验,两边互相卡住且都不报错。 /// [Fact] public void T7_T10_T11_Verifier_MustHoldVerifyPermission() { var code = CodeOnly("Service/S8/S8TaskFlowService.cs"); // S8-RESPONSIBILITY-POOL-1:判据本体迁到 IS8VerifierEligibility(候选列表与提交校验共用同一份), // 但**保护对象没变**:选出来的人必须真的能点「检验通过 / 检验退回」。 Assert.Contains("_verifierEligibility.CheckAsync(tenantId, verifier.UserId, e)", code); var eligibility = CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs"); Assert.Contains("S8ExceptionActionCode.Verify", eligibility); Assert.Contains("没有审核权限,不能作为检验人", eligibility); // verifier_user_id 仍然只装 SysUser.Id Assert.Contains("e.VerifierUserId = verifier.UserId;", code); Assert.DoesNotContain("EmployeeMaster", code.Replace("EmployeeMaster.Department", string.Empty)); } /// 前端候选集必须与后端校验同源,不能让用户选到一个提交就被拒的账号。 [Fact] public void VerifierPicker_UsesSameSourceAsBackendCheck() { var page = File.ReadAllText(Path.GetFullPath(Path.Combine( PluginRoot, "../../../Web/src/views/aidop/s8/exceptions/S8TaskDetailPage.vue"))); // S8-RESPONSIBILITY-POOL-1:候选来源从「全租户 ∩ 审核权限」收窄为 // 「该规则的复核池 ∩ 审核权限 ∩ 可用账号」,由后端一个端点给出。 // 本守卫保护的一直是「前端能选的 == 后端会放行的」,来源换了但要求没变 —— // 而且现在更强:候选与提交校验共用同一份 IS8VerifierEligibility 实现。 Assert.Contains("s8ExceptionApi.verifierCandidates(", page); Assert.DoesNotContain("s8ExceptionApi.users('EXCEPTION_VERIFY')", page); Assert.Contains("verifierUsers", page); // 没有可选复核人时必须显式说明原因,而不是给一个空下拉 Assert.Contains("该规则没有可用的复核人员", page); Assert.Contains("本租户没有任何账号具备「审核」权限", page); var eligibility = CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs"); Assert.Contains("ListCandidatesAsync", eligibility); Assert.Contains("CheckAsync", eligibility); Assert.Contains("S8ExceptionActionCode.Verify", eligibility); } /// T12 / T13:修复链路上没有 Employee、没有 Factory。 [Theory] [InlineData("Service/S8/S8ApprovalFlowRepairService.cs")] [InlineData("Service/S8/S8MasterDataAdapter.cs")] public void T12_T13_NoEmployeeNoFactory(string relativePath) { var code = CodeOnly(relativePath); Assert.DoesNotContain("AdoS0EmployeeMaster", code); Assert.DoesNotContain("FactoryRefId", code); } }