using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow; /// /// S6-TENANT-AUTHORITY-1 源码契约测试:S6 两条 IPQC 审批链的审批权威必须租户本地化。 /// /// 背景(Evidence Audit 结论): /// Primary = SYSTEMIC TENANT CLONE DEFECT —— 三个 S6 角色全库单例挂默认租户, /// 流程定义把默认租户的**物理 RoleId** 写死进 FlowJson,各租户靠跨租户 /// SysUserRole 绑定借用;该绑定被删一行,整个租户的 N2 当场解析为 0 人。 /// Secondary = UAT ROLE DRIFT(引爆点,非根因) /// Contributing = Resolver Branch B FAIL-SILENT /// /// 目标模型:tenant-local Role + same-tenant membership + RoleCode authority /// + tenant-scoped Definition + S6 专属 fail-closed 守卫。 /// /// 本层沿用本仓对 FlowEngine / 种子的既有做法——源码契约断言而非行为级集成测试: /// 这些类型依赖 ISqlSugarClient / SqlSugarRepository<T>,裸 xUnit 进程无法实例化 /// (见 S8ExceptionFlowTenantContextContractTests 与 S5IqcGenericApprovalGuardContractTests 的同款说明)。 /// 真实解析行为由本批的 Runtime 矩阵 R1–R13 覆盖。 /// public class S6TenantAuthorityMigrationContractTests { private const string InspectorCode = "ROLE_S6_IPQC_INSPECTOR"; private const string SupervisorCode = "ROLE_S6_IPQC_SUPERVISOR"; private const string QeCode = "ROLE_S6_IPQC_QUALITY_ENGINEER"; private static string AuthoritySeed() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S6TenantAuthoritySeed.cs")); private static string IpqcSeed() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "IpqcInspectionFlowSeed.cs")); private static string ProcessSeed() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S6ProcessInspectionFlowSeed.cs")); // ───────────── 角色种子:tenant-local ───────────── /// 角色查重键必须是 (TenantId, Code);原实现的全局 Any(Code) 正是「全库单例」成因。 [Fact] public void RoleSeed_DedupesByTenantAndCode_NotGlobally() { var s = AuthoritySeed(); Assert.Contains("r.TenantId == tenantId && r.Code == spec.Code", s); // 不得退回「只要任何租户有这个 Code 就不再建」 Assert.DoesNotContain("Any(x => x.Code == code)", s); } /// 同 Code 在不同租户各一份是合法的:RoleId 必须逐租户新取号,不得全租户共用固定 Id。 [Fact] public void RoleSeed_AllocatesPerTenantRoleId_ExceptDefaultTenantLegacy() { var s = AuthoritySeed(); Assert.Contains("YitIdHelper.NextId()", s); // 默认租户沿用既有固定 Id(历史 FlowJson 与业务门禁仍按该 Id 判定,换号会打断它们) Assert.Contains("tenantId == SqlSugarConst.DefaultTenantId", s); Assert.Contains("spec.LegacyDefaultTenantRoleId", s); } /// 租户清单必须取自正式注册表 SysTenant,不得自行构造。 [Fact] public void RoleSeed_EnumeratesTenantsFromOfficialRegistry() { Assert.Contains("Queryable()", AuthoritySeed()); } /// 通用种子里禁止出现任何具体租户 Id 或 UAT 账号。 [Theory] [InlineData("838257186181189")] [InlineData("797403760988229")] [InlineData("UATAdminA")] [InlineData("UATQualityA")] [InlineData("UATQualityB")] public void Seeds_NeverHardcodeTenantOrUatAccount(string forbidden) { Assert.DoesNotContain(forbidden, AuthoritySeed()); Assert.DoesNotContain(forbidden, IpqcSeed()); Assert.DoesNotContain(forbidden, ProcessSeed()); } /// membership 迁移是 COPY 不是 MOVE:不得删除既有跨租户绑定。 [Fact] public void MembershipMigration_CopiesAndNeverDeletesLegacyBinding() { var s = AuthoritySeed(); Assert.Contains("CopyLegacyCrossTenantMembership", s); Assert.DoesNotContain("Deleteable", s); Assert.DoesNotContain("DeleteAsync", s); } /// SysUserRole 没有 (UserId, RoleId) 唯一约束,迁移必须自己防重。 [Fact] public void MembershipMigration_IsIdempotent() { var body = Slice(AuthoritySeed(), "private static void CopyLegacyCrossTenantMembership", "public static long? ResolveLocalRoleId"); Assert.Contains("Any(x => x.UserId == user.Id && x.RoleId == localRoleId.Value)", body); Assert.Contains("if (bound) continue;", body); } /// Activation Gate:成员必须与角色同租户才算 ready,只数绑定行会误判。 [Fact] public void ActivationGate_RequiresSameTenantMembersForAllThreeRoles() { var body = Slice(AuthoritySeed(), "public static List GetActivatableTenantIds", "private static List ResolveTenantIds"); Assert.Contains("u.TenantId == tenantId", body); Assert.Contains("if (sameTenantMembers == 0)", body); Assert.Contains("foreach (var spec in RoleSpecs)", body); } // ───────────── FlowJson:RoleCode 权威 ───────────── /// 两条 S6 链的 Role 节点一律用 RoleCode,不得再出现物理 RoleId。 [Theory] [InlineData("IpqcInspectionFlowConst.RoleInspectorId.ToString()")] [InlineData("IpqcInspectionFlowConst.RoleSupervisorId.ToString()")] [InlineData("IpqcInspectionFlowConst.RoleQualityEngineerId.ToString()")] public void IpqcFlowJson_NoLongerEmbedsPhysicalRoleId(string forbidden) { Assert.DoesNotContain(forbidden, IpqcSeed()); } /// S6_PROCESS_INSPECTION 同样不得再写物理 RoleId。 [Theory] [InlineData("RoleSupervisorId.ToString()")] [InlineData("RoleQualityEngineerId.ToString()")] public void ProcessFlowJson_NoLongerEmbedsPhysicalRoleId(string forbidden) { Assert.DoesNotContain(forbidden, ProcessSeed()); } /// IPQC 三个 Role 节点的 approverIds 必须是三个 RoleCode 常量。 [Theory] [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleInspectorCode")] [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleSupervisorCode")] [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleQualityEngineerCode")] public void IpqcFlowJson_UsesRoleCodeAuthority(string expected) { Assert.Contains(expected, IpqcSeed()); } /// S6_PROCESS_INSPECTION 的 N2/N3 必须是 RoleCode(N1 是 Initiator,不查角色)。 [Theory] [InlineData("ApproverIds = S6ProcessInspectionFlowConst.RoleSupervisorCode")] [InlineData("ApproverIds = S6ProcessInspectionFlowConst.RoleQualityEngineerCode")] public void ProcessFlowJson_UsesRoleCodeAuthority(string expected) { Assert.Contains(expected, ProcessSeed()); } /// 三个 RoleCode 字面量本身必须稳定,改动会让既有定义与新定义解析到不同角色。 [Fact] public void RoleCodes_AreStable() { var s = File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowConst.cs")); Assert.Contains($"RoleInspectorCode = \"{InspectorCode}\"", s); Assert.Contains($"RoleSupervisorCode = \"{SupervisorCode}\"", s); Assert.Contains($"RoleQualityEngineerCode = \"{QeCode}\"", s); } // ───────────── Definition:tenant-scoped ───────────── /// 定义副本维度必须是 Tenant 而不是 Org —— 引擎选定义时只看 TenantId、完全不看 OrgId。 [Theory] [InlineData("Ipqc")] [InlineData("Process")] public void Definitions_AreKeyedByTenantNotOrg(string which) { var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed(); var body = Slice(s, "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId"); Assert.Contains("GetActivatableTenantIds(db)", body); Assert.Contains("x.TenantId == tenantId", body); Assert.Contains("TenantId = tenantId,", body); // 不得再按 SysOrg 逐组织复制 Assert.DoesNotContain("Queryable()", body); } /// 新定义必须显式落 TenantId,不得再产生 TenantId 为空的「全局」定义。 [Theory] [InlineData("Ipqc")] [InlineData("Process")] public void Definitions_SetExplicitTenantId(string which) { var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed(); Assert.Contains("TenantId = tenantId,", s); Assert.DoesNotContain("TenantId = null", s); } /// 幂等:已是 RoleCode 版就跳过,不得每次启动都堆一版。 [Theory] [InlineData("Ipqc")] [InlineData("Process")] public void Definitions_AreIdempotentOnRoleCodeMarker(string which) { var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed(); Assert.Contains("RoleSupervisorCode))", s); Assert.Contains("continue;", s); } /// 本批纯增量:不得下架或删除历史定义(legacy 清理属后续独立批次)。 [Theory] [InlineData("Ipqc")] [InlineData("Process")] public void Definitions_NeverUnpublishOrDeleteLegacy(string which) { var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed(); Assert.DoesNotContain("x.IsPublished == false", s); Assert.DoesNotContain("Deleteable", 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)); } }