| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- using System.Reflection;
- using System.Text.RegularExpressions;
- using Microsoft.AspNetCore.Authorization;
- using Microsoft.AspNetCore.Mvc;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S0.Tenant;
- /// <summary>
- /// S0-P0-TENANT-ID-CLOSURE 防回归契约。
- ///
- /// 守住四条铁律,任一被破坏则 CI 直接失败:
- /// 1. S0 业务 Controller 不得带类级 [AllowAnonymous](白名单外一律失败);
- /// 2. 每个 S0 业务 action 必须显式解析租户(AdoS0TenantScope.TryResolveRequired);
- /// 3. 不得只用 Domain / DomainCode 做隔离而没有任何 tenant 条件;
- /// 4. 有 Create 的 Controller 必须显式 stamp 租户,不得依赖全局 AOP。
- ///
- /// 用源码静态扫描而非反射执行,避免测试需要真实 DB / HTTP 上下文。
- /// </summary>
- public class S0ControllerTenantClosureTests
- {
- /// <summary>
- /// 允许匿名的 S0 接口白名单(必须写明业务理由)。
- /// 当前为空:S0 全部业务接口都要求认证。
- /// 新增条目前必须在评审中给出明确业务理由,否则本测试即为拦截点。
- /// </summary>
- private static readonly HashSet<string> IntentionalAnonymousWhitelist = new(StringComparer.Ordinal)
- {
- // 例:("AdoS0XxxController", "对外健康探活,不返回任何业务数据")
- };
- /// <summary>
- /// 无数据库访问、因而不需要租户解析的 action 白名单(按 Controller 文件名.Action 精确登记)。
- /// 仅允许纯静态字典 / 静态模板;任何触达仓储的 action 都不得登记在此。
- /// </summary>
- private static readonly HashSet<string> NoDataAccessActions = new(StringComparer.Ordinal)
- {
- "AdoS0QualityDictionaryController.GetNodes", // 写死的前端菜单节点数组
- "AdoS0EmpWorkDutiesController.GetDutyOptions", // 代码内静态 DutyMap 枚举
- "AdoS0QmsSpecImportController.RawTemplate", // 静态 Excel 导入模板
- "AdoS0QmsSpecImportController.ProcessTemplate", // 静态 Excel 导入模板
- "AdoS0QmsSpecImportController.FqcTemplate", // 静态 Excel 导入模板
- "AdoS0QmsSpecImportController.OqcTemplate", // 静态 Excel 导入模板
- };
- private static string ControllersRoot()
- {
- var dir = new DirectoryInfo(AppContext.BaseDirectory);
- while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0")))
- dir = dir.Parent;
- Assert.NotNull(dir);
- return Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0");
- }
- public static IEnumerable<object[]> S0ControllerFiles()
- {
- foreach (var f in Directory.EnumerateFiles(ControllersRoot(), "*.cs", SearchOption.AllDirectories).OrderBy(x => x))
- yield return [Path.GetFileNameWithoutExtension(f), f];
- }
- /// <summary>类级 [AllowAnonymous]:白名单以外一律禁止。</summary>
- [Theory]
- [MemberData(nameof(S0ControllerFiles))]
- public void S0Controllers_MustNotBeAnonymous(string name, string path)
- {
- var src = File.ReadAllText(path);
- foreach (var cls in ClassLevelAnonymousClasses(src))
- {
- Assert.True(IntentionalAnonymousWhitelist.Contains(cls),
- $"{name}: 类 {cls} 带类级 [AllowAnonymous],S0 业务接口必须认证;"
- + "确需匿名请先在 IntentionalAnonymousWhitelist 登记并写明业务理由。");
- }
- }
- /// <summary>每个 action 必须显式解析租户;不接受仅靠全局 AOP。</summary>
- [Theory]
- [MemberData(nameof(S0ControllerFiles))]
- public void S0Controllers_EveryActionResolvesTenant(string name, string path)
- {
- var src = File.ReadAllText(path);
- // action = [Http*] 特性之后最近的方法名;扣除显式登记的无数据库访问 action
- var actionNames = Regex.Matches(
- src,
- @"\[Http(?:Get|Post|Put|Delete|Patch)[^\]]*\][\s\S]{0,400}?\b(?:public|internal)\s+(?:async\s+)?(?:Task<[^>]+>|IActionResult|Task)\s+(\w+)\s*\(")
- .Select(m => m.Groups[1].Value)
- .ToList();
- var exempt = actionNames.Count(a => NoDataAccessActions.Contains($"{name}.{a}"));
- var required = Regex.Matches(src, @"\[Http(?:Get|Post|Put|Delete|Patch)").Count - exempt;
- if (required <= 0) return;
- var guards = Regex.Matches(src, @"AdoS0TenantScope\.TryResolveRequired").Count;
- Assert.True(guards >= required,
- $"{name}: 需解析租户的 action 数={required}(已豁免 {exempt} 个静态接口)但 "
- + $"TryResolveRequired 次数={guards};每个 S0 业务 action 都必须显式解析租户后再访问数据。");
- }
- /// <summary>Domain / DomainCode 不得替代 tenant_id 承担隔离职责。</summary>
- [Theory]
- [MemberData(nameof(S0ControllerFiles))]
- public void S0Controllers_DomainMustNotReplaceTenant(string name, string path)
- {
- var src = File.ReadAllText(path);
- var domain = Regex.Matches(src, @"\.Domain\s*==|DomainCode\s*==").Count;
- if (domain == 0) return;
- var tenant = Regex.Matches(src, @"TenantId\s*==|ScopedTo\(|ScopedToByColumn\(|ByIdScoped").Count;
- Assert.True(tenant > 0,
- $"{name}: 出现 Domain/DomainCode 过滤但没有任何 tenant 条件;"
- + "tenant_id 是唯一租户安全边界,Domain 只能作为业务筛选。");
- }
- /// <summary>有写入的 Controller 必须显式 stamp 租户。</summary>
- [Theory]
- [MemberData(nameof(S0ControllerFiles))]
- public void S0Controllers_CreateMustStampTenant(string name, string path)
- {
- var src = File.ReadAllText(path);
- if (!src.Contains("[HttpPost]")) return;
- // 纯导入/模板类接口不落主表实体,由各自 factory 内部 stamp
- var stamps = Regex.Matches(src, @"TenantId\s*=\s*(tenantId|tid)\b").Count;
- Assert.True(stamps > 0,
- $"{name}: 存在 [HttpPost] 但未见任何显式 TenantId 赋值;"
- + "Create 的租户必须由后端可信上下文赋值,不得依赖全局 AOP 兜底。");
- }
- /// <summary>可信租户解析器本身不得被前端入参污染。</summary>
- [Fact]
- public void TenantScope_MustNotAcceptClientSuppliedTenant()
- {
- // ControllersRoot() = <plugin>/Controllers/S0 → 上溯两级取插件根
- var root = new DirectoryInfo(ControllersRoot()).Parent!.Parent!.FullName;
- var scope = Path.Combine(root, "Infrastructure", "AdoS0TenantScope.cs");
- Assert.True(File.Exists(scope), "AdoS0TenantScope.cs 不存在");
- var src = File.ReadAllText(scope);
- Assert.Contains("um.TenantId", src);
- Assert.DoesNotContain("FromQuery", src);
- Assert.DoesNotContain("FromBody", src);
- }
- private static IEnumerable<string> ClassLevelAnonymousClasses(string src)
- {
- foreach (Match m in Regex.Matches(src, @"^[ \t]*\[AllowAnonymous\][ \t]*\r?$", RegexOptions.Multiline))
- {
- var tail = src[m.Index..];
- var cls = Regex.Match(tail, @"public\s+(?:sealed\s+)?class\s+(\w+)");
- var http = tail.IndexOf("[Http", StringComparison.Ordinal);
- if (cls.Success && (http == -1 || cls.Index < http))
- yield return cls.Groups[1].Value;
- }
- }
- }
|