S6TenantAuthorityMigrationContractTests.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
  3. /// <summary>
  4. /// S6-TENANT-AUTHORITY-1 源码契约测试:S6 两条 IPQC 审批链的审批权威必须租户本地化。
  5. ///
  6. /// 背景(Evidence Audit 结论):
  7. /// Primary = SYSTEMIC TENANT CLONE DEFECT —— 三个 S6 角色全库单例挂默认租户,
  8. /// 流程定义把默认租户的**物理 RoleId** 写死进 FlowJson,各租户靠跨租户
  9. /// SysUserRole 绑定借用;该绑定被删一行,整个租户的 N2 当场解析为 0 人。
  10. /// Secondary = UAT ROLE DRIFT(引爆点,非根因)
  11. /// Contributing = Resolver Branch B FAIL-SILENT
  12. ///
  13. /// 目标模型:tenant-local Role + same-tenant membership + RoleCode authority
  14. /// + tenant-scoped Definition + S6 专属 fail-closed 守卫。
  15. ///
  16. /// 本层沿用本仓对 FlowEngine / 种子的既有做法——源码契约断言而非行为级集成测试:
  17. /// 这些类型依赖 ISqlSugarClient / SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化
  18. /// (见 S8ExceptionFlowTenantContextContractTests 与 S5IqcGenericApprovalGuardContractTests 的同款说明)。
  19. /// 真实解析行为由本批的 Runtime 矩阵 R1–R13 覆盖。
  20. /// </summary>
  21. public class S6TenantAuthorityMigrationContractTests
  22. {
  23. private const string InspectorCode = "ROLE_S6_IPQC_INSPECTOR";
  24. private const string SupervisorCode = "ROLE_S6_IPQC_SUPERVISOR";
  25. private const string QeCode = "ROLE_S6_IPQC_QUALITY_ENGINEER";
  26. private static string AuthoritySeed() => File.ReadAllText(FindFile(
  27. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S6TenantAuthoritySeed.cs"));
  28. private static string IpqcSeed() => File.ReadAllText(FindFile(
  29. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "IpqcInspectionFlowSeed.cs"));
  30. private static string ProcessSeed() => File.ReadAllText(FindFile(
  31. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S6ProcessInspectionFlowSeed.cs"));
  32. // ───────────── 角色种子:tenant-local ─────────────
  33. /// <summary>角色查重键必须是 (TenantId, Code);原实现的全局 Any(Code) 正是「全库单例」成因。</summary>
  34. [Fact]
  35. public void RoleSeed_DedupesByTenantAndCode_NotGlobally()
  36. {
  37. var s = AuthoritySeed();
  38. Assert.Contains("r.TenantId == tenantId && r.Code == spec.Code", s);
  39. // 不得退回「只要任何租户有这个 Code 就不再建」
  40. Assert.DoesNotContain("Any(x => x.Code == code)", s);
  41. }
  42. /// <summary>同 Code 在不同租户各一份是合法的:RoleId 必须逐租户新取号,不得全租户共用固定 Id。</summary>
  43. [Fact]
  44. public void RoleSeed_AllocatesPerTenantRoleId_ExceptDefaultTenantLegacy()
  45. {
  46. var s = AuthoritySeed();
  47. Assert.Contains("YitIdHelper.NextId()", s);
  48. // 默认租户沿用既有固定 Id(历史 FlowJson 与业务门禁仍按该 Id 判定,换号会打断它们)
  49. Assert.Contains("tenantId == SqlSugarConst.DefaultTenantId", s);
  50. Assert.Contains("spec.LegacyDefaultTenantRoleId", s);
  51. }
  52. /// <summary>租户清单必须取自正式注册表 SysTenant,不得自行构造。</summary>
  53. [Fact]
  54. public void RoleSeed_EnumeratesTenantsFromOfficialRegistry()
  55. {
  56. Assert.Contains("Queryable<SysTenant>()", AuthoritySeed());
  57. }
  58. /// <summary>通用种子里禁止出现任何具体租户 Id 或 UAT 账号。</summary>
  59. [Theory]
  60. [InlineData("838257186181189")]
  61. [InlineData("797403760988229")]
  62. [InlineData("UATAdminA")]
  63. [InlineData("UATQualityA")]
  64. [InlineData("UATQualityB")]
  65. public void Seeds_NeverHardcodeTenantOrUatAccount(string forbidden)
  66. {
  67. Assert.DoesNotContain(forbidden, AuthoritySeed());
  68. Assert.DoesNotContain(forbidden, IpqcSeed());
  69. Assert.DoesNotContain(forbidden, ProcessSeed());
  70. }
  71. /// <summary>membership 迁移是 COPY 不是 MOVE:不得删除既有跨租户绑定。</summary>
  72. [Fact]
  73. public void MembershipMigration_CopiesAndNeverDeletesLegacyBinding()
  74. {
  75. var s = AuthoritySeed();
  76. Assert.Contains("CopyLegacyCrossTenantMembership", s);
  77. Assert.DoesNotContain("Deleteable<SysUserRole>", s);
  78. Assert.DoesNotContain("DeleteAsync", s);
  79. }
  80. /// <summary>SysUserRole 没有 (UserId, RoleId) 唯一约束,迁移必须自己防重。</summary>
  81. [Fact]
  82. public void MembershipMigration_IsIdempotent()
  83. {
  84. var body = Slice(AuthoritySeed(), "private static void CopyLegacyCrossTenantMembership", "public static long? ResolveLocalRoleId");
  85. Assert.Contains("Any(x => x.UserId == user.Id && x.RoleId == localRoleId.Value)", body);
  86. Assert.Contains("if (bound) continue;", body);
  87. }
  88. /// <summary>Activation Gate:成员必须与角色同租户才算 ready,只数绑定行会误判。</summary>
  89. [Fact]
  90. public void ActivationGate_RequiresSameTenantMembersForAllThreeRoles()
  91. {
  92. var body = Slice(AuthoritySeed(), "public static List<long> GetActivatableTenantIds", "private static List<long> ResolveTenantIds");
  93. Assert.Contains("u.TenantId == tenantId", body);
  94. Assert.Contains("if (sameTenantMembers == 0)", body);
  95. Assert.Contains("foreach (var spec in RoleSpecs)", body);
  96. }
  97. // ───────────── FlowJson:RoleCode 权威 ─────────────
  98. /// <summary>两条 S6 链的 Role 节点一律用 RoleCode,不得再出现物理 RoleId。</summary>
  99. [Theory]
  100. [InlineData("IpqcInspectionFlowConst.RoleInspectorId.ToString()")]
  101. [InlineData("IpqcInspectionFlowConst.RoleSupervisorId.ToString()")]
  102. [InlineData("IpqcInspectionFlowConst.RoleQualityEngineerId.ToString()")]
  103. public void IpqcFlowJson_NoLongerEmbedsPhysicalRoleId(string forbidden)
  104. {
  105. Assert.DoesNotContain(forbidden, IpqcSeed());
  106. }
  107. /// <summary>S6_PROCESS_INSPECTION 同样不得再写物理 RoleId。</summary>
  108. [Theory]
  109. [InlineData("RoleSupervisorId.ToString()")]
  110. [InlineData("RoleQualityEngineerId.ToString()")]
  111. public void ProcessFlowJson_NoLongerEmbedsPhysicalRoleId(string forbidden)
  112. {
  113. Assert.DoesNotContain(forbidden, ProcessSeed());
  114. }
  115. /// <summary>IPQC 三个 Role 节点的 approverIds 必须是三个 RoleCode 常量。</summary>
  116. [Theory]
  117. [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleInspectorCode")]
  118. [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleSupervisorCode")]
  119. [InlineData("ApproverIds = IpqcInspectionFlowConst.RoleQualityEngineerCode")]
  120. public void IpqcFlowJson_UsesRoleCodeAuthority(string expected)
  121. {
  122. Assert.Contains(expected, IpqcSeed());
  123. }
  124. /// <summary>S6_PROCESS_INSPECTION 的 N2/N3 必须是 RoleCode(N1 是 Initiator,不查角色)。</summary>
  125. [Theory]
  126. [InlineData("ApproverIds = S6ProcessInspectionFlowConst.RoleSupervisorCode")]
  127. [InlineData("ApproverIds = S6ProcessInspectionFlowConst.RoleQualityEngineerCode")]
  128. public void ProcessFlowJson_UsesRoleCodeAuthority(string expected)
  129. {
  130. Assert.Contains(expected, ProcessSeed());
  131. }
  132. /// <summary>三个 RoleCode 字面量本身必须稳定,改动会让既有定义与新定义解析到不同角色。</summary>
  133. [Fact]
  134. public void RoleCodes_AreStable()
  135. {
  136. var s = File.ReadAllText(FindFile(
  137. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowConst.cs"));
  138. Assert.Contains($"RoleInspectorCode = \"{InspectorCode}\"", s);
  139. Assert.Contains($"RoleSupervisorCode = \"{SupervisorCode}\"", s);
  140. Assert.Contains($"RoleQualityEngineerCode = \"{QeCode}\"", s);
  141. }
  142. // ───────────── Definition:tenant-scoped ─────────────
  143. /// <summary>定义副本维度必须是 Tenant 而不是 Org —— 引擎选定义时只看 TenantId、完全不看 OrgId。</summary>
  144. [Theory]
  145. [InlineData("Ipqc")]
  146. [InlineData("Process")]
  147. public void Definitions_AreKeyedByTenantNotOrg(string which)
  148. {
  149. var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
  150. var body = Slice(s, "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
  151. Assert.Contains("GetActivatableTenantIds(db)", body);
  152. Assert.Contains("x.TenantId == tenantId", body);
  153. Assert.Contains("TenantId = tenantId,", body);
  154. // 不得再按 SysOrg 逐组织复制
  155. Assert.DoesNotContain("Queryable<SysOrg>()", body);
  156. }
  157. /// <summary>新定义必须显式落 TenantId,不得再产生 TenantId 为空的「全局」定义。</summary>
  158. [Theory]
  159. [InlineData("Ipqc")]
  160. [InlineData("Process")]
  161. public void Definitions_SetExplicitTenantId(string which)
  162. {
  163. var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
  164. Assert.Contains("TenantId = tenantId,", s);
  165. Assert.DoesNotContain("TenantId = null", s);
  166. }
  167. /// <summary>幂等:已是 RoleCode 版就跳过,不得每次启动都堆一版。</summary>
  168. [Theory]
  169. [InlineData("Ipqc")]
  170. [InlineData("Process")]
  171. public void Definitions_AreIdempotentOnRoleCodeMarker(string which)
  172. {
  173. var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
  174. Assert.Contains("RoleSupervisorCode))", s);
  175. Assert.Contains("continue;", s);
  176. }
  177. /// <summary>本批纯增量:不得下架或删除历史定义(legacy 清理属后续独立批次)。</summary>
  178. [Theory]
  179. [InlineData("Ipqc")]
  180. [InlineData("Process")]
  181. public void Definitions_NeverUnpublishOrDeleteLegacy(string which)
  182. {
  183. var s = which == "Ipqc" ? IpqcSeed() : ProcessSeed();
  184. Assert.DoesNotContain("x.IsPublished == false", s);
  185. Assert.DoesNotContain("Deleteable<ApprovalFlowEntity>", s);
  186. }
  187. private static string Slice(string src, string from, string to)
  188. {
  189. var a = src.IndexOf(from, StringComparison.Ordinal);
  190. Assert.True(a >= 0, $"未找到起点:{from}");
  191. var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
  192. return b > a ? src[a..b] : src[a..];
  193. }
  194. private static string FindFile(params string[] parts)
  195. {
  196. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  197. while (dir != null)
  198. {
  199. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  200. if (File.Exists(candidate)) return candidate;
  201. dir = dir.Parent;
  202. }
  203. throw new FileNotFoundException(string.Join("/", parts));
  204. }
  205. }