|
|
@@ -0,0 +1,231 @@
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// 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 覆盖。
|
|
|
+/// </summary>
|
|
|
+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 ─────────────
|
|
|
+
|
|
|
+ /// <summary>角色查重键必须是 (TenantId, Code);原实现的全局 Any(Code) 正是「全库单例」成因。</summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>同 Code 在不同租户各一份是合法的:RoleId 必须逐租户新取号,不得全租户共用固定 Id。</summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>租户清单必须取自正式注册表 SysTenant,不得自行构造。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void RoleSeed_EnumeratesTenantsFromOfficialRegistry()
|
|
|
+ {
|
|
|
+ Assert.Contains("Queryable<SysTenant>()", AuthoritySeed());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>通用种子里禁止出现任何具体租户 Id 或 UAT 账号。</summary>
|
|
|
+ [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());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>membership 迁移是 COPY 不是 MOVE:不得删除既有跨租户绑定。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void MembershipMigration_CopiesAndNeverDeletesLegacyBinding()
|
|
|
+ {
|
|
|
+ var s = AuthoritySeed();
|
|
|
+ Assert.Contains("CopyLegacyCrossTenantMembership", s);
|
|
|
+ Assert.DoesNotContain("Deleteable<SysUserRole>", s);
|
|
|
+ Assert.DoesNotContain("DeleteAsync", s);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>SysUserRole 没有 (UserId, RoleId) 唯一约束,迁移必须自己防重。</summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>Activation Gate:成员必须与角色同租户才算 ready,只数绑定行会误判。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void ActivationGate_RequiresSameTenantMembersForAllThreeRoles()
|
|
|
+ {
|
|
|
+ var body = Slice(AuthoritySeed(), "public static List<long> GetActivatableTenantIds", "private static List<long> ResolveTenantIds");
|
|
|
+ Assert.Contains("u.TenantId == tenantId", body);
|
|
|
+ Assert.Contains("if (sameTenantMembers == 0)", body);
|
|
|
+ Assert.Contains("foreach (var spec in RoleSpecs)", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────── FlowJson:RoleCode 权威 ─────────────
|
|
|
+
|
|
|
+ /// <summary>两条 S6 链的 Role 节点一律用 RoleCode,不得再出现物理 RoleId。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("IpqcInspectionFlowConst.RoleInspectorId.ToString()")]
|
|
|
+ [InlineData("IpqcInspectionFlowConst.RoleSupervisorId.ToString()")]
|
|
|
+ [InlineData("IpqcInspectionFlowConst.RoleQualityEngineerId.ToString()")]
|
|
|
+ public void IpqcFlowJson_NoLongerEmbedsPhysicalRoleId(string forbidden)
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain(forbidden, IpqcSeed());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>S6_PROCESS_INSPECTION 同样不得再写物理 RoleId。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("RoleSupervisorId.ToString()")]
|
|
|
+ [InlineData("RoleQualityEngineerId.ToString()")]
|
|
|
+ public void ProcessFlowJson_NoLongerEmbedsPhysicalRoleId(string forbidden)
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain(forbidden, ProcessSeed());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>IPQC 三个 Role 节点的 approverIds 必须是三个 RoleCode 常量。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleInspectorCode")]
|
|
|
+ [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleSupervisorCode")]
|
|
|
+ [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleQualityEngineerCode")]
|
|
|
+ public void IpqcFlowJson_UsesRoleCodeAuthority(string expected)
|
|
|
+ {
|
|
|
+ Assert.Contains(expected, IpqcSeed());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>S6_PROCESS_INSPECTION 的 N2/N3 必须是 RoleCode(N1 是 Initiator,不查角色)。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("ApproverIds = S6ProcessInspectionFlowConst.RoleSupervisorCode")]
|
|
|
+ [InlineData("ApproverIds = S6ProcessInspectionFlowConst.RoleQualityEngineerCode")]
|
|
|
+ public void ProcessFlowJson_UsesRoleCodeAuthority(string expected)
|
|
|
+ {
|
|
|
+ Assert.Contains(expected, ProcessSeed());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>三个 RoleCode 字面量本身必须稳定,改动会让既有定义与新定义解析到不同角色。</summary>
|
|
|
+ [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 ─────────────
|
|
|
+
|
|
|
+ /// <summary>定义副本维度必须是 Tenant 而不是 Org —— 引擎选定义时只看 TenantId、完全不看 OrgId。</summary>
|
|
|
+ [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<SysOrg>()", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>新定义必须显式落 TenantId,不得再产生 TenantId 为空的「全局」定义。</summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>幂等:已是 RoleCode 版就跳过,不得每次启动都堆一版。</summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>本批纯增量:不得下架或删除历史定义(legacy 清理属后续独立批次)。</summary>
|
|
|
+ [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<ApprovalFlowEntity>", 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));
|
|
|
+ }
|
|
|
+}
|