S7TenantAuthorityContractTests.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
  3. /// <summary>
  4. /// S7-FQC-TENANT-AUTHORITY-1 源码契约测试:S7 成品检验审批权威**租户本地化**(仅未来新建)。
  5. ///
  6. /// 修复的根因(2026-09-14 审计 EVIDENCE COMPLETE):
  7. /// - <c>ROLE_S7_FQC_*</c> 三个 Code 全库各只有一行、固定挂默认租户(对照 ROLE_S6_IPQC_* 已各 10 份);
  8. /// - 所有 S7 定义(57 条,V1–V3 全版本)的 N2/N3 都写死默认租户物理 RoleId
  9. /// 1329915020002 / 1329915020003;
  10. /// - 各租户靠 9 条跨租户 SysUserRole 绑定借用,其中 4 条被 Running 实例实际依赖。
  11. ///
  12. /// 本批只关未来新建这条口子:租户本地角色 + 定义按租户播种 + N2/N3 改 RoleCode。
  13. /// **不迁** 7 条 Running 的 FlowJsonSnapshot、**不删**跨租户绑定、**不开** TenantStrictRoleBizTypes
  14. /// (顺序必须是 local roles → future definition → active migration → strict guard)。
  15. ///
  16. /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:seed 依赖 ISqlSugarClient,
  17. /// 裸 xUnit 进程无法实例化。真实效果由本批 Runtime R1–R10 覆盖。
  18. /// </summary>
  19. public class S7TenantAuthorityContractTests
  20. {
  21. private const string SupervisorCode = "ROLE_S7_FQC_SUPERVISOR";
  22. private const string QeCode = "ROLE_S7_FQC_QE";
  23. private const string InspectorCode = "ROLE_S7_FQC_INSPECTOR";
  24. private static string AuthoritySeed() => File.ReadAllText(FindFile(
  25. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "S7TenantAuthoritySeed.cs"));
  26. private static string FlowSeed() => File.ReadAllText(FindFile(
  27. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Infrastructure", "FqcInspBillFlowSeed.cs"));
  28. private static string FlowConst() => File.ReadAllText(FindFile(
  29. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "FinishedWarehouse", "FqcInspBillFlowBizHandler.cs"));
  30. private static string Engine() => File.ReadAllText(FindFile(
  31. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
  32. private static string FlowTaskService() => File.ReadAllText(FindFile(
  33. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowTask", "FlowTaskService.cs"));
  34. /// <summary>
  35. /// **去注释**后的 C# 源码视图。这些文件的注释里会成段引用被淘汰的写法
  36. /// (如「原写 RoleSupervisorId=1329915020002」「历史实例各用自己的 FlowJsonSnapshot」)来解释
  37. /// 为什么不再那么做;裸文本 DoesNotContain 会把说明文字当成违规实现命中。
  38. /// 断言「实现里不得出现 X」时一律用本视图;断言「注释里必须写明某约定」时用原文。
  39. /// </summary>
  40. private static string CsCode(string src)
  41. {
  42. var s = System.Text.RegularExpressions.Regex.Replace(src, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline);
  43. var kept = s.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal));
  44. return string.Join("\n", kept);
  45. }
  46. private static string FlowJsonBody() => Slice(CsCode(FlowSeed()), "private static string BuildFlowJson()", "private static ApprovalFlowEdgeItem BuildEdge");
  47. // ── §27-1 / §27-2:N2 / N3 权威必须是 RoleCode ──
  48. /// <summary>N2 检验主管节点必须用 RoleCode 常量,而不是任何数字 Id。</summary>
  49. [Fact]
  50. public void DefinitionN2_UsesSupervisorRoleCode()
  51. {
  52. var n2 = Slice(FlowJsonBody(), "NodeName = \"检验主管审核\"", "Text = new FlowTextItem");
  53. Assert.Contains("ApproverIds = FqcInspBillFlowConst.RoleSupervisorCode", n2);
  54. Assert.DoesNotContain("RoleSupervisorId", n2);
  55. Assert.DoesNotContain(".ToString()", n2);
  56. }
  57. /// <summary>N3 QE 处置节点同理。</summary>
  58. [Fact]
  59. public void DefinitionN3_UsesQeRoleCode()
  60. {
  61. var n3 = Slice(FlowJsonBody(), "NodeName = \"QE处置\"", "Text = new FlowTextItem");
  62. Assert.Contains("ApproverIds = FqcInspBillFlowConst.RoleQeCode", n3);
  63. Assert.DoesNotContain("RoleQeId", n3);
  64. Assert.DoesNotContain(".ToString()", n3);
  65. }
  66. /// <summary>RoleCode 常量的字面值必须与 DB 中既有 Code 完全一致(改字面量会让解析静默落空)。</summary>
  67. [Theory]
  68. [InlineData("RoleInspectorCode", InspectorCode)]
  69. [InlineData("RoleSupervisorCode", SupervisorCode)]
  70. [InlineData("RoleQeCode", QeCode)]
  71. public void RoleCodeConstants_MatchExistingDbCodes(string constName, string expected)
  72. {
  73. Assert.Contains($"public const string {constName} = \"{expected}\"", FlowConst());
  74. }
  75. // ── §27-3:定义里禁止出现默认租户物理 RoleId ──
  76. /// <summary>
  77. /// 三个 legacy 物理 RoleId 只能作为「默认租户既有固定 Id」出现在租户本地化种子里,
  78. /// **不得**出现在流程定义 JSON 的构造中。
  79. /// </summary>
  80. [Theory]
  81. [InlineData("1329915020001")]
  82. [InlineData("1329915020002")]
  83. [InlineData("1329915020003")]
  84. public void DefinitionJson_ContainsNoLegacyPhysicalRoleId(string legacyId)
  85. {
  86. Assert.DoesNotContain(legacyId, FlowJsonBody()); // FlowJsonBody 已是去注释视图
  87. }
  88. /// <summary>定义构造中不得引用任何 RoleId 常量(即使是本租户的,也只能用 Code)。</summary>
  89. [Theory]
  90. [InlineData("RoleInspectorId")]
  91. [InlineData("RoleSupervisorId")]
  92. [InlineData("RoleQeId")]
  93. public void DefinitionJson_ReferencesNoRoleIdConstant(string idConst)
  94. {
  95. Assert.DoesNotContain(idConst, FlowJsonBody());
  96. }
  97. /// <summary>legacy 固定 Id 仍必须保留在常量与种子里——默认租户的历史 FlowJson 与本地角色靠它对齐。</summary>
  98. [Fact]
  99. public void LegacyRoleIds_StillRetainedForDefaultTenantIdentity()
  100. {
  101. Assert.Contains("RoleSupervisorId = 1329915020002", FlowConst());
  102. Assert.Contains("LegacyDefaultTenantRoleId", AuthoritySeed());
  103. Assert.Contains("tenantId == SqlSugarConst.DefaultTenantId", AuthoritySeed());
  104. }
  105. // ── §27-4 / §27-5:角色供给是租户本地的,同 Code 可跨租户共存 ──
  106. /// <summary>查重键必须是 (TenantId, Code);用全局 Any(Code) 就是「全库单例」的成因。</summary>
  107. [Fact]
  108. public void RoleProvisioning_DedupesByTenantAndCode()
  109. {
  110. var body = Slice(AuthoritySeed(), "private static void EnsureTenantLocalRoles", "private static void CopyLegacyCrossTenantMembership");
  111. Assert.Contains("r.TenantId == tenantId && r.Code == spec.Code", body);
  112. Assert.Contains("TenantId = tenantId,", body);
  113. }
  114. /// <summary>
  115. /// 同一个 RoleCode 在不同租户必须是不同 RoleId:默认租户沿用固定 Id,其余走雪花号。
  116. /// 若两处都用固定 Id 就会主键冲突/退化回单例。
  117. /// </summary>
  118. [Fact]
  119. public void SameRoleCode_GetsDistinctRoleIdPerTenant()
  120. {
  121. var body = Slice(AuthoritySeed(), "private static void EnsureTenantLocalRoles", "private static void CopyLegacyCrossTenantMembership");
  122. Assert.Contains("? spec.LegacyDefaultTenantRoleId", body);
  123. Assert.Contains(": YitIdHelper.NextId()", body);
  124. }
  125. /// <summary>旧的全局 EnsureRole(单例成因)必须已从 S7 种子中移除。</summary>
  126. [Fact]
  127. public void LegacyGlobalEnsureRole_IsRemovedFromFlowSeed()
  128. {
  129. var s = CsCode(FlowSeed());
  130. Assert.Contains("S7TenantAuthoritySeed.EnsureTenantAuthority(db)", s);
  131. Assert.DoesNotContain("ClearFilter().Any(x => x.Code == code)", s);
  132. Assert.DoesNotContain("private static void EnsureRoles(", s);
  133. }
  134. /// <summary>租户清单取自 SysTenant 注册表,禁止硬编码具体租户 Id / UAT 账号。</summary>
  135. [Theory]
  136. [InlineData("797403760988229")]
  137. [InlineData("838257186181189")]
  138. [InlineData("838257212780613")]
  139. [InlineData("AIDOPDemo")]
  140. [InlineData("UATQualityA")]
  141. public void TenantAuthoritySeed_HardcodesNoTenantOrAccount(string forbidden)
  142. {
  143. Assert.DoesNotContain(forbidden, AuthoritySeed());
  144. Assert.DoesNotContain(forbidden, FlowSeed());
  145. }
  146. /// <summary>租户来源必须是 SysTenant,而不是 SysOrg(按 Org 播种正是 57 条定义与租户错配的成因)。</summary>
  147. [Fact]
  148. public void TenantSet_ComesFromSysTenantNotSysOrg()
  149. {
  150. Assert.Contains("db.Queryable<SysTenant>()", AuthoritySeed());
  151. var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
  152. Assert.Contains("S7TenantAuthoritySeed.GetActivatableTenantIds(db)", publish);
  153. Assert.DoesNotContain("db.Queryable<SysOrg>()", publish);
  154. Assert.DoesNotContain("foreach (var orgId in orgIds)", publish);
  155. }
  156. // ── §十一:定义租户必须等于目标租户 ──
  157. /// <summary>新定义的 TenantId 必须就是目标租户,不得再经 Org→Tenant 间接解析。</summary>
  158. [Fact]
  159. public void NewDefinition_TenantIdEqualsTargetTenant()
  160. {
  161. var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
  162. Assert.Contains("TenantId = tenantId,", publish);
  163. Assert.DoesNotContain("ApprovalFlowTenantSeed.ResolveTenantId", publish);
  164. Assert.Contains("x.TenantId == tenantId", publish);
  165. }
  166. // ── §十二:历史定义不得被改动 ──
  167. /// <summary>不得下架 / 改写 / 删除任何历史定义(原 v2→v3 升级会 SetColumns(IsPublished=false))。</summary>
  168. [Fact]
  169. public void HistoricalDefinitions_AreNeitherUnpublishedNorRewritten()
  170. {
  171. var s = CsCode(FlowSeed());
  172. Assert.DoesNotContain("x.IsPublished == false", s);
  173. Assert.DoesNotContain("Updateable<ApprovalFlowEntity>", s);
  174. Assert.DoesNotContain("Deleteable", s);
  175. }
  176. /// <summary>补发靠新版本号(既有最大 + 1),不原地改已被实例引用的定义。</summary>
  177. [Fact]
  178. public void NewDefinition_UsesIncrementedVersion()
  179. {
  180. var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
  181. Assert.Contains("Version = (existing?.Version ?? 0) + 1", publish);
  182. }
  183. /// <summary>幂等判据是「已含 RoleCode」——重复启动不会反复补发。</summary>
  184. [Fact]
  185. public void Seeding_IsIdempotentOnRoleCodePresence()
  186. {
  187. var publish = Slice(FlowSeed(), "private static void EnsurePublishedFlow", "private static long ResolveTenantOrgId");
  188. Assert.Contains("Contains(FqcInspBillFlowConst.RoleSupervisorCode)", publish);
  189. }
  190. // ── §27-6:N1 仍是 Initiator ──
  191. /// <summary>本批不得把 N1 改回 Inspector Role —— 业务设计是「认领人(发起人)」。</summary>
  192. [Fact]
  193. public void DefinitionN1_RemainsInitiator()
  194. {
  195. var n1 = Slice(FlowJsonBody(), "NodeName = \"检验员检验\"", "Text = new FlowTextItem");
  196. Assert.Contains("ApproverType = nameof(ApproverTypeEnum.Initiator)", n1);
  197. Assert.Contains("ApproverIds = \"\"", n1);
  198. }
  199. // ── §十四 / §十五:成员语义只复制既有事实,legacy 绑定保留 ──
  200. /// <summary>成员复制必须是 COPY 而非 MOVE:不得删除任何既有绑定。</summary>
  201. [Fact]
  202. public void MembershipCopy_NeverDeletesLegacyBinding()
  203. {
  204. Assert.DoesNotContain("Deleteable", CsCode(AuthoritySeed()));
  205. Assert.Contains("COPY 不是 MOVE", AuthoritySeed()); // 该约定必须写在注释里
  206. }
  207. /// <summary>只复制已存在的授权事实,不凭猜测新增业务成员。</summary>
  208. [Fact]
  209. public void MembershipCopy_DerivesOnlyFromExistingBindings()
  210. {
  211. var body = Slice(AuthoritySeed(), "private static void CopyLegacyCrossTenantMembership", "public static long? ResolveLocalRoleId");
  212. Assert.Contains("ur.RoleId == spec.LegacyDefaultTenantRoleId", body);
  213. Assert.Contains("u.TenantId != SqlSugarConst.DefaultTenantId", body);
  214. // SysUserRole 无唯一键,必须自己防重
  215. Assert.Contains("x.UserId == user.Id && x.RoleId == localRoleId.Value", body);
  216. }
  217. /// <summary>Activation Gate:三角色都有本地角色且各有同租户成员,才为该租户发布 RoleCode 定义。</summary>
  218. [Fact]
  219. public void ActivationGate_RequiresLocalRoleWithSameTenantMember()
  220. {
  221. var body = Slice(AuthoritySeed(), "public static List<long> GetActivatableTenantIds", "private static List<long> ResolveTenantIds");
  222. Assert.Contains("ResolveLocalRoleId(db, tenantId, spec.Code)", body);
  223. Assert.Contains("u.TenantId == tenantId", body);
  224. Assert.Contains("if (sameTenantMembers == 0) { allReady = false; break; }", body);
  225. }
  226. // ── §27-7 / §二十五:本批不得触碰历史实例 ──
  227. /// <summary>种子与租户权威基线都不得读写 ApprovalFlowInstance / FlowJsonSnapshot。</summary>
  228. [Theory]
  229. [InlineData("ApprovalFlowInstance")]
  230. [InlineData("FlowJsonSnapshot")]
  231. [InlineData("InitiatorId")]
  232. public void Seeding_NeverTouchesExistingInstances(string forbidden)
  233. {
  234. Assert.DoesNotContain(forbidden, CsCode(AuthoritySeed()));
  235. Assert.DoesNotContain(forbidden, CsCode(FlowSeed()));
  236. }
  237. // ── §二十八 / §二十九:回归与顺序 ──
  238. /// <summary>S7 通用审批旁路守卫必须仍然生效(上一批 CLOSED,不得被本批影响)。</summary>
  239. [Fact]
  240. public void GenericApprovalGuard_StillCoversS7()
  241. {
  242. var dict = Slice(FlowTaskService(), "DedicatedEntryOnlyBizTypes = new()", "};");
  243. Assert.Contains("[\"S7_FQC_INSPBILL\"]", dict);
  244. }
  245. /// <summary>
  246. /// §29:本批**不得**把 S7 加入 TenantStrictRoleBizTypes ——
  247. /// 7 条 Running 仍是 legacy 物理 RoleId 快照,先开守卫会当场把它们全部拦死。
  248. /// 正确顺序:local roles → future definition RoleCode → active migration → strict guard。
  249. /// </summary>
  250. [Fact]
  251. public void StrictRoleGuard_NotYetOpenedForS7()
  252. {
  253. var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
  254. Assert.DoesNotContain("S7_FQC_INSPBILL", set);
  255. Assert.Contains("\"IPQC_INSPECTION\"", set);
  256. Assert.Contains("\"S6_PROCESS_INSPECTION\"", set);
  257. }
  258. /// <summary>RoleCode 解析必须是租户内解析(这是 RoleCode 方案成立的前提)。</summary>
  259. [Fact]
  260. public void RoleCodeResolver_IsTenantScoped()
  261. {
  262. var body = Slice(Engine(), "if (codes.Count > 0)", "if (ids.Count == 0)");
  263. Assert.Contains("r.TenantId == effectiveTenantId", body);
  264. Assert.Contains("r.Status == StatusEnum.Enable", body);
  265. Assert.Contains("codes.Contains(r.Code)", body);
  266. }
  267. /// <summary>设计代次常量已升到 v4,且明确它不等于 ApprovalFlow.Version。</summary>
  268. [Fact]
  269. public void FlowDefinitionVersion_BumpedAndDocumented()
  270. {
  271. var s = FlowConst();
  272. Assert.Contains("public const int FlowDefinitionVersion = 4;", s);
  273. Assert.Contains("不等于", s);
  274. }
  275. private static string Slice(string src, string from, string to)
  276. {
  277. var a = src.IndexOf(from, StringComparison.Ordinal);
  278. Assert.True(a >= 0, $"未找到起点:{from}");
  279. var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
  280. return b > a ? src[a..b] : src[a..];
  281. }
  282. private static string FindFile(params string[] parts)
  283. {
  284. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  285. while (dir != null)
  286. {
  287. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  288. if (File.Exists(candidate)) return candidate;
  289. dir = dir.Parent;
  290. }
  291. throw new FileNotFoundException(string.Join("/", parts));
  292. }
  293. }