S0ControllerTenantClosureTests.cs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. using System.Reflection;
  2. using System.Text.RegularExpressions;
  3. using Microsoft.AspNetCore.Authorization;
  4. using Microsoft.AspNetCore.Mvc;
  5. using Xunit;
  6. namespace Admin.NET.Plugin.AiDOP.Tests.S0.Tenant;
  7. /// <summary>
  8. /// S0-P0-TENANT-ID-CLOSURE 防回归契约。
  9. ///
  10. /// 守住四条铁律,任一被破坏则 CI 直接失败:
  11. /// 1. S0 业务 Controller 不得带类级 [AllowAnonymous](白名单外一律失败);
  12. /// 2. 每个 S0 业务 action 必须显式解析租户(AdoS0TenantScope.TryResolveRequired);
  13. /// 3. 不得只用 Domain / DomainCode 做隔离而没有任何 tenant 条件;
  14. /// 4. 有 Create 的 Controller 必须显式 stamp 租户,不得依赖全局 AOP。
  15. ///
  16. /// 用源码静态扫描而非反射执行,避免测试需要真实 DB / HTTP 上下文。
  17. /// </summary>
  18. public class S0ControllerTenantClosureTests
  19. {
  20. /// <summary>
  21. /// 允许匿名的 S0 接口白名单(必须写明业务理由)。
  22. /// 当前为空:S0 全部业务接口都要求认证。
  23. /// 新增条目前必须在评审中给出明确业务理由,否则本测试即为拦截点。
  24. /// </summary>
  25. private static readonly HashSet<string> IntentionalAnonymousWhitelist = new(StringComparer.Ordinal)
  26. {
  27. // 例:("AdoS0XxxController", "对外健康探活,不返回任何业务数据")
  28. };
  29. /// <summary>
  30. /// 无数据库访问、因而不需要租户解析的 action 白名单(按 Controller 文件名.Action 精确登记)。
  31. /// 仅允许纯静态字典 / 静态模板;任何触达仓储的 action 都不得登记在此。
  32. /// </summary>
  33. private static readonly HashSet<string> NoDataAccessActions = new(StringComparer.Ordinal)
  34. {
  35. "AdoS0QualityDictionaryController.GetNodes", // 写死的前端菜单节点数组
  36. "AdoS0EmpWorkDutiesController.GetDutyOptions", // 代码内静态 DutyMap 枚举
  37. "AdoS0QmsSpecImportController.RawTemplate", // 静态 Excel 导入模板
  38. "AdoS0QmsSpecImportController.ProcessTemplate", // 静态 Excel 导入模板
  39. "AdoS0QmsSpecImportController.FqcTemplate", // 静态 Excel 导入模板
  40. "AdoS0QmsSpecImportController.OqcTemplate", // 静态 Excel 导入模板
  41. };
  42. private static string ControllersRoot()
  43. {
  44. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  45. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0")))
  46. dir = dir.Parent;
  47. Assert.NotNull(dir);
  48. return Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0");
  49. }
  50. public static IEnumerable<object[]> S0ControllerFiles()
  51. {
  52. foreach (var f in Directory.EnumerateFiles(ControllersRoot(), "*.cs", SearchOption.AllDirectories).OrderBy(x => x))
  53. yield return [Path.GetFileNameWithoutExtension(f), f];
  54. }
  55. /// <summary>类级 [AllowAnonymous]:白名单以外一律禁止。</summary>
  56. [Theory]
  57. [MemberData(nameof(S0ControllerFiles))]
  58. public void S0Controllers_MustNotBeAnonymous(string name, string path)
  59. {
  60. var src = File.ReadAllText(path);
  61. foreach (var cls in ClassLevelAnonymousClasses(src))
  62. {
  63. Assert.True(IntentionalAnonymousWhitelist.Contains(cls),
  64. $"{name}: 类 {cls} 带类级 [AllowAnonymous],S0 业务接口必须认证;"
  65. + "确需匿名请先在 IntentionalAnonymousWhitelist 登记并写明业务理由。");
  66. }
  67. }
  68. /// <summary>每个 action 必须显式解析租户;不接受仅靠全局 AOP。</summary>
  69. [Theory]
  70. [MemberData(nameof(S0ControllerFiles))]
  71. public void S0Controllers_EveryActionResolvesTenant(string name, string path)
  72. {
  73. var src = File.ReadAllText(path);
  74. // action = [Http*] 特性之后最近的方法名;扣除显式登记的无数据库访问 action
  75. var actionNames = Regex.Matches(
  76. src,
  77. @"\[Http(?:Get|Post|Put|Delete|Patch)[^\]]*\][\s\S]{0,400}?\b(?:public|internal)\s+(?:async\s+)?(?:Task<[^>]+>|IActionResult|Task)\s+(\w+)\s*\(")
  78. .Select(m => m.Groups[1].Value)
  79. .ToList();
  80. var exempt = actionNames.Count(a => NoDataAccessActions.Contains($"{name}.{a}"));
  81. var required = Regex.Matches(src, @"\[Http(?:Get|Post|Put|Delete|Patch)").Count - exempt;
  82. if (required <= 0) return;
  83. var guards = Regex.Matches(src, @"AdoS0TenantScope\.TryResolveRequired").Count;
  84. Assert.True(guards >= required,
  85. $"{name}: 需解析租户的 action 数={required}(已豁免 {exempt} 个静态接口)但 "
  86. + $"TryResolveRequired 次数={guards};每个 S0 业务 action 都必须显式解析租户后再访问数据。");
  87. }
  88. /// <summary>Domain / DomainCode 不得替代 tenant_id 承担隔离职责。</summary>
  89. [Theory]
  90. [MemberData(nameof(S0ControllerFiles))]
  91. public void S0Controllers_DomainMustNotReplaceTenant(string name, string path)
  92. {
  93. var src = File.ReadAllText(path);
  94. var domain = Regex.Matches(src, @"\.Domain\s*==|DomainCode\s*==").Count;
  95. if (domain == 0) return;
  96. var tenant = Regex.Matches(src, @"TenantId\s*==|ScopedTo\(|ScopedToByColumn\(|ByIdScoped").Count;
  97. Assert.True(tenant > 0,
  98. $"{name}: 出现 Domain/DomainCode 过滤但没有任何 tenant 条件;"
  99. + "tenant_id 是唯一租户安全边界,Domain 只能作为业务筛选。");
  100. }
  101. /// <summary>有写入的 Controller 必须显式 stamp 租户。</summary>
  102. [Theory]
  103. [MemberData(nameof(S0ControllerFiles))]
  104. public void S0Controllers_CreateMustStampTenant(string name, string path)
  105. {
  106. var src = File.ReadAllText(path);
  107. if (!src.Contains("[HttpPost]")) return;
  108. // 纯导入/模板类接口不落主表实体,由各自 factory 内部 stamp
  109. var stamps = Regex.Matches(src, @"TenantId\s*=\s*(tenantId|tid)\b").Count;
  110. Assert.True(stamps > 0,
  111. $"{name}: 存在 [HttpPost] 但未见任何显式 TenantId 赋值;"
  112. + "Create 的租户必须由后端可信上下文赋值,不得依赖全局 AOP 兜底。");
  113. }
  114. /// <summary>
  115. /// 跨表关联必须 tenant-bound(S0-SOP-DOMAIN-DECOUPLING-FIX)。
  116. /// Domain / DomainCode 是 legacy 兼容字段,同租户内不同表取值可能不一致,
  117. /// 用它做 join 条件既挡不住跨租户、又会让展示列解析不出来。
  118. /// </summary>
  119. [Theory]
  120. [MemberData(nameof(S0ControllerFiles))]
  121. public void S0Controllers_JoinsMustBeTenantBound(string name, string path)
  122. {
  123. var src = File.ReadAllText(path);
  124. foreach (Match m in Regex.Matches(src, @"\.(?:Left|Inner|Right|Full)Join<[^>]+>\(\([^)]*\)\s*=>\s*(?<cond>[^\r\n]+)"))
  125. {
  126. var cond = m.Groups["cond"].Value;
  127. Assert.True(cond.Contains("TenantId", StringComparison.Ordinal),
  128. $"{name}: 关联条件缺少 TenantId —— {cond.Trim()};"
  129. + "S0 跨表关联必须以 tenant_id + 业务键为准。");
  130. Assert.False(Regex.IsMatch(cond, @"\.Domain\s*==|\.DomainCode\s*=="),
  131. $"{name}: 关联条件里出现 Domain/DomainCode —— {cond.Trim()};"
  132. + "Domain 不得参与跨表关联,同租户内各表 Domain 取值可能不一致。");
  133. }
  134. }
  135. /// <summary>
  136. /// SOP 文档列表的可见性只能由 tenant_id 决定,不得回退成 Domain 等值过滤。
  137. /// </summary>
  138. [Fact]
  139. public void SopDocuments_ListMustNotGateOnDomain()
  140. {
  141. var path = Path.Combine(ControllersRoot(), "Manufacturing", "AdoS0MfgSopDocumentsController.cs");
  142. Assert.True(File.Exists(path), "AdoS0MfgSopDocumentsController.cs 不存在");
  143. var src = File.ReadAllText(path);
  144. Assert.DoesNotContain(".Where(x => x.Domain == domain)", src);
  145. Assert.DoesNotContain("q.Domain.Trim()", src);
  146. Assert.Contains("ScopedTo(tenantId)", src);
  147. // Query DTO 的 Domain 不得再是必填
  148. var dto = Path.Combine(new DirectoryInfo(ControllersRoot()).Parent!.Parent!.FullName,
  149. "Dto", "S0", "Manufacturing", "AdoS0ManufacturingDtos.cs");
  150. var dtoSrc = File.ReadAllText(dto);
  151. var block = Regex.Match(dtoSrc, @"class AdoS0MfgSopDocumentQueryDto[\s\S]*?\n\}").Value;
  152. Assert.NotEqual(string.Empty, block);
  153. // 只看实际代码,剔除注释行(注释里可能提到 [Required] 说明改动原因)
  154. var code = string.Join('\n', block.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal)));
  155. Assert.DoesNotContain("[Required", code);
  156. Assert.Contains("public string? Domain", code);
  157. }
  158. /// <summary>可信租户解析器本身不得被前端入参污染。</summary>
  159. [Fact]
  160. public void TenantScope_MustNotAcceptClientSuppliedTenant()
  161. {
  162. // ControllersRoot() = <plugin>/Controllers/S0 → 上溯两级取插件根
  163. var root = new DirectoryInfo(ControllersRoot()).Parent!.Parent!.FullName;
  164. var scope = Path.Combine(root, "Infrastructure", "AdoS0TenantScope.cs");
  165. Assert.True(File.Exists(scope), "AdoS0TenantScope.cs 不存在");
  166. var src = File.ReadAllText(scope);
  167. Assert.Contains("um.TenantId", src);
  168. Assert.DoesNotContain("FromQuery", src);
  169. Assert.DoesNotContain("FromBody", src);
  170. }
  171. private static IEnumerable<string> ClassLevelAnonymousClasses(string src)
  172. {
  173. foreach (Match m in Regex.Matches(src, @"^[ \t]*\[AllowAnonymous\][ \t]*\r?$", RegexOptions.Multiline))
  174. {
  175. var tail = src[m.Index..];
  176. var cls = Regex.Match(tail, @"public\s+(?:sealed\s+)?class\s+(\w+)");
  177. var http = tail.IndexOf("[Http", StringComparison.Ordinal);
  178. if (cls.Success && (http == -1 || cls.Index < http))
  179. yield return cls.Groups[1].Value;
  180. }
  181. }
  182. }