| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284 |
- 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;
- /// <summary>
- /// S8-UAT-APPROVAL-REPAIR-1:审核链修复。
- ///
- /// <para><b>修的是一个完整的静默悬挂</b>(本地取证,非推测):UAT 租户 3 条
- /// <c>PENDING_VERIFICATION</c> 异常的 <c>verifier_user_id</c> 都是 UATQualityA,
- /// 而对应 <c>EXCEPTION_CLOSURE</c> 实例 <b>Status=Running、tasks=0</b>、
- /// 异常上的 <c>active_flow_instance_id</c> 仍是 NULL —— 节点配的是别租户角色 Id,
- /// <c>ResolveApprovers</c> 返回 0 人 → <c>ProcessNextNode</c> 抛错 →
- /// <c>OnFlowStarted</c> 从未调用 → 实例永久悬挂,而 S8 侧显示一切正常。</para>
- /// </summary>
- 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);
- }));
- }
- // ══════════════════════════════════════════════════════════════════════
- // 判据:什么算「已经租户安全」
- // ══════════════════════════════════════════════════════════════════════
- /// <summary>
- /// T5 / T6:判据是<b>「该节点引用的每个对象是否都属于本租户」</b>,
- /// 而不是「用了哪种 approverType 写法」。
- ///
- /// <para>第一版按写法判(只认 RoleCode),把默认租户一条
- /// <c>SpecificUser</c> 指向<b>本租户账号</b>的正确配置也改写了 ——
- /// 那是把「这种写法在别的租户下不安全」错当成「这个配置错了」。</para>
- /// </summary>
- [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<string>(StringComparer.Ordinal) { "ROLE_QC", "ROLE_S8_CONFIG_ADMIN" },
- RoleIds: new HashSet<long> { 9001 },
- UserIds: new HashSet<long> { 7001 },
- OrgIds: new HashSet<long> { 8001 });
- Assert.Equal(expected, S8ApprovalFlowRepairService.IsAlreadyTenantSafe(type, ids, scope));
- }
- /// <summary>
- /// T3:跨租户审批人配置必须被判为需要修复 —— 这正是 escalation 的现状
- /// (三个节点全部 <c>SpecificUser=789819678195781</c>,Demo02 属默认租户)。
- /// </summary>
- [Fact]
- public void T3_CrossTenantApprover_IsRejectedAsUnsafe()
- {
- var scope = new S8ApprovalFlowRepairService.S8TenantApproverScope(
- RoleCodes: new HashSet<string>(StringComparer.Ordinal) { "ROLE_QC" },
- RoleIds: new HashSet<long>(), UserIds: new HashSet<long>(), OrgIds: new HashSet<long>());
- Assert.False(S8ApprovalFlowRepairService.IsAlreadyTenantSafe("SpecificUser", "789819678195781", scope));
- Assert.False(S8ApprovalFlowRepairService.IsAlreadyTenantSafe("Role", "1300000000101,1300000000888", scope));
- }
- /// <summary>
- /// 对账服务写下的改动必须能按自己的留证撤回。
- ///
- /// <para>判据本身也会出错:第一版把「SpecificUser 指向本租户账号」误判为不安全,
- /// 于是改坏了默认租户一条本来正确的配置。没有回退能力,一次判据失误就永久生效。</para>
- /// </summary>
- [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);
- }
- // ══════════════════════════════════════════════════════════════════════
- // 修复行为
- // ══════════════════════════════════════════════════════════════════════
- /// <summary>
- /// 派生依据必须是<b>该租户自己的权限事实</b>,不是硬编码角色名。
- /// 升级流程没有专门角色时回落审核角色,且回落必须写进留证 ——
- /// 否则将来没人知道升级审批为什么是质检在审。
- /// </summary>
- [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);
- }
- /// <summary>
- /// T4:推导不出角色时<b>不猜</b> —— 保持原样并大声报出来。
- ///
- /// <para>塞一个"看起来能用"的角色进去会让人以为审核链已经通了,
- /// 那比留着坏配置更危险。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 只修坏的,不碰好的;判据本身保证幂等(修好后第二次运行命中「已 OK」而跳过)。
- ///
- /// <para>每次启动无条件回写会覆盖管理员的手工配置 ——
- /// 那是比配置错误更难发现的故障。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// 修复<b>只作用于 S8 自己的两条流程</b>,绝不波及 S1/S5/S6/S7。
- ///
- /// <para>真库实测 74 个 SpecificUser 节点里有大量跨租户配置
- /// (CONTRACT_REVIEW / ORDER_CHANGE_REVIEW / MATERIAL_SHORTAGE 在三个租户里
- /// 都指向租户 797 的账号)。本批不碰它们。</para>
- /// </summary>
- [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);
- }
- /// <summary>
- /// <b>不改 FlowEngine</b>:给 SpecificUser / Department 补租户谓词会外溢到其它模块,
- /// 属另一个批次。本批以「只用 RoleCode 形态」绕开该缺陷。
- /// </summary>
- [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);
- }
- /// <summary>修复必须留证:改前 / 改后 / 原因 / 结论,缺一不可。</summary>
- [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)
- // ══════════════════════════════════════════════════════════════════════
- /// <summary>
- /// T7 / T10 / T11:检验人必须具备「审核」动作权限。
- ///
- /// <para>这条校验保证「UI 能选到的人」⊆「审批任务的收件人」:
- /// 复核节点按「被授予 s8:verification:approve 的角色」派发,
- /// 而检验人也必须持有同一能力。少了它就会出现错位 ——
- /// 页面上选了甲,任务发给乙那一组,甲手里没有任务、乙又过不了
- /// verifier_user_id 校验,两边互相卡住且都不报错。</para>
- /// </summary>
- [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));
- }
- /// <summary>前端候选集必须与后端校验同源,不能让用户选到一个提交就被拒的账号。</summary>
- [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);
- }
- /// <summary>T12 / T13:修复链路上没有 Employee、没有 Factory。</summary>
- [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);
- }
- }
|