S0ControllerTenantClosureTests.cs 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  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>可信租户解析器本身不得被前端入参污染。</summary>
  115. [Fact]
  116. public void TenantScope_MustNotAcceptClientSuppliedTenant()
  117. {
  118. // ControllersRoot() = <plugin>/Controllers/S0 → 上溯两级取插件根
  119. var root = new DirectoryInfo(ControllersRoot()).Parent!.Parent!.FullName;
  120. var scope = Path.Combine(root, "Infrastructure", "AdoS0TenantScope.cs");
  121. Assert.True(File.Exists(scope), "AdoS0TenantScope.cs 不存在");
  122. var src = File.ReadAllText(scope);
  123. Assert.Contains("um.TenantId", src);
  124. Assert.DoesNotContain("FromQuery", src);
  125. Assert.DoesNotContain("FromBody", src);
  126. }
  127. private static IEnumerable<string> ClassLevelAnonymousClasses(string src)
  128. {
  129. foreach (Match m in Regex.Matches(src, @"^[ \t]*\[AllowAnonymous\][ \t]*\r?$", RegexOptions.Multiline))
  130. {
  131. var tail = src[m.Index..];
  132. var cls = Regex.Match(tail, @"public\s+(?:sealed\s+)?class\s+(\w+)");
  133. var http = tail.IndexOf("[Http", StringComparison.Ordinal);
  134. if (cls.Success && (http == -1 || cls.Index < http))
  135. yield return cls.Groups[1].Value;
  136. }
  137. }
  138. }