| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231 |
- 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));
- }
- }
|