S6TenantAuthorityGuardContractTests.cs 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.ApprovalFlow;
  3. /// <summary>
  4. /// S6-TENANT-AUTHORITY-1 源码契约测试(Phase E · Resolver 守卫)。
  5. ///
  6. /// 与 <see cref="S6TenantAuthorityMigrationContractTests"/> 分文件:迁移(角色 / 成员 / 定义)
  7. /// 与守卫是**两个可独立发布的阶段** —— 守卫必须在迁移完成并通过 Runtime 验证之后才允许启用,
  8. /// 否则会把仍靠 legacy 跨租户 RoleId 工作的租户当场打成 0 审批人。拆开后两个提交各自自洽。
  9. ///
  10. /// 守卫的范围铁律:只覆盖 IPQC_INSPECTION / S6_PROCESS_INSPECTION。
  11. /// S5_IQC_INSPBILL / S7_FQC_INSPBILL 等同样写死了默认租户物理 RoleId,但它们当前正靠跨租户
  12. /// SysUserRole 绑定工作(实测 28 个节点组合),本批不在迁移范围,无条件启用会直接打断它们。
  13. ///
  14. /// 沿用本仓既有做法——源码契约断言而非行为级集成测试:FlowEngineService 依赖
  15. /// SqlSugarRepository&lt;T&gt;,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests)。
  16. /// </summary>
  17. public class S6TenantAuthorityGuardContractTests
  18. {
  19. private static string Engine() => File.ReadAllText(FindFile(
  20. "server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
  21. // ───────────── Resolver 守卫:S6 专属 fail-closed ─────────────
  22. /// <summary>守卫必须只覆盖两条 S6 链。</summary>
  23. [Fact]
  24. public void Guard_ScopedToTheTwoS6BizTypes()
  25. {
  26. var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
  27. Assert.Contains("\"IPQC_INSPECTION\"", set);
  28. Assert.Contains("\"S6_PROCESS_INSPECTION\"", set);
  29. }
  30. /// <summary>S5 / S7 不得被本批守卫波及——它们仍持默认租户物理 RoleId 且当前正靠它工作。</summary>
  31. [Theory]
  32. [InlineData("S5_IQC_INSPBILL")]
  33. [InlineData("S7_FQC_INSPBILL")]
  34. [InlineData("SMART_OPS_IMPROVEMENT")]
  35. [InlineData("EXCEPTION_REPORT")]
  36. public void Guard_DoesNotAffectOtherBizTypes(string bizType)
  37. {
  38. var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
  39. Assert.DoesNotContain(bizType, set);
  40. }
  41. /// <summary>守卫必须是精确白名单查表,不得退化成前缀/模糊匹配把无关业务一起挡掉。</summary>
  42. [Fact]
  43. public void Guard_UsesExactAllowList_NotFuzzyMatch()
  44. {
  45. var s = Engine();
  46. Assert.Contains("TenantStrictRoleBizTypes.Contains(bizType)", s);
  47. Assert.DoesNotContain("BizType.StartsWith(\"S6", s);
  48. Assert.DoesNotContain("BizType.Contains(\"IPQC", s);
  49. }
  50. /// <summary>守卫必须在 Code 解析之前跑,针对的是 FlowJson 里写死的数字 RoleId。</summary>
  51. [Fact]
  52. public void Guard_RunsBeforeRoleCodeResolution()
  53. {
  54. var s = Engine();
  55. var guardAt = s.IndexOf("await EnsureRoleAuthorityTenantScopedAsync(ids, effectiveTenantId, authorityContext);", StringComparison.Ordinal);
  56. var codeAt = s.IndexOf("codeRoleIds", StringComparison.Ordinal);
  57. Assert.True(guardAt > 0, "未找到守卫调用点");
  58. Assert.True(codeAt > guardAt, "守卫必须先于 RoleCode 解析执行");
  59. }
  60. /// <summary>跨租户 RoleId 必须 fail-closed;且刻意不做 auto-heal(不按 Code 隐式替换)。</summary>
  61. [Fact]
  62. public void Guard_FailsClosedOnCrossTenantRole_AndDoesNotAutoHeal()
  63. {
  64. var body = Slice(Engine(),
  65. "private async Task EnsureRoleAuthorityTenantScopedAsync",
  66. "/// <summary>\n /// S8-EXCEPTION-FLOW-TENANT-CONTEXT-1");
  67. Assert.Contains("r.TenantId != effectiveTenantId", body);
  68. Assert.Contains("throw Oops.Oh(", body);
  69. // auto-heal 会让坏定义继续成功运行、永远没人去修
  70. Assert.DoesNotContain("codes.Contains(r.Code)", body);
  71. Assert.DoesNotContain("roleIds.Add(", body);
  72. }
  73. /// <summary>三类失败必须可区分,且日志带全部定位字段。</summary>
  74. [Theory]
  75. [InlineData("CROSS_TENANT_ROLE_REFERENCE")]
  76. [InlineData("INVALID_ROLE")]
  77. [InlineData("LOCAL_ROLE_HAS_NO_MEMBER")]
  78. public void Guard_ClassifiesFailures(string classification)
  79. {
  80. Assert.Contains(classification, Engine());
  81. }
  82. /// <summary>日志必须能定位到具体定义与节点。</summary>
  83. [Fact]
  84. public void Guard_LogsDiagnosticFields()
  85. {
  86. var s = Engine();
  87. foreach (var field in new[] { "BizType={BizType}", "FlowId={FlowId}", "NodeId={NodeId}", "RoleRef={RoleRef}", "EffectiveTenantId={TenantId}" })
  88. Assert.Contains(field, s);
  89. }
  90. /// <summary>Branch B 必须保留——历史定义仍可能存 RoleId,本批不是删除 RoleId 支持。</summary>
  91. [Fact]
  92. public void Guard_KeepsRoleIdBranch()
  93. {
  94. var s = Engine();
  95. Assert.Contains("var userIds = await _userRoleRep.AsQueryable()", s);
  96. Assert.Contains("ids.Contains(ur.RoleId)", s);
  97. }
  98. /// <summary>RoleCode 分支(目标路径)必须始终按当前租户 + 启用状态解析,本批不得改动它。</summary>
  99. [Fact]
  100. public void RoleCodeBranch_StaysTenantScoped()
  101. {
  102. var s = Engine();
  103. Assert.Contains("r.TenantId == effectiveTenantId", s);
  104. Assert.Contains("r.Status == StatusEnum.Enable", s);
  105. Assert.Contains("codes.Contains(r.Code)", s);
  106. }
  107. /// <summary>成员过滤这道租户收口不得被移除——它是跨租户泄漏的最后一道闸。</summary>
  108. [Fact]
  109. public void MemberResolution_KeepsTenantScope()
  110. {
  111. Assert.Contains("u.TenantId == effectiveTenantId", Engine());
  112. }
  113. private static string Slice(string src, string from, string to)
  114. {
  115. var a = src.IndexOf(from, StringComparison.Ordinal);
  116. Assert.True(a >= 0, $"未找到起点:{from}");
  117. var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
  118. return b > a ? src[a..b] : src[a..];
  119. }
  120. private static string FindFile(params string[] parts)
  121. {
  122. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  123. while (dir != null)
  124. {
  125. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  126. if (File.Exists(candidate)) return candidate;
  127. dir = dir.Parent;
  128. }
  129. throw new FileNotFoundException(string.Join("/", parts));
  130. }
  131. }