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; /// /// 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 上下文。 /// public class S0ControllerTenantClosureTests { /// /// 允许匿名的 S0 接口白名单(必须写明业务理由)。 /// 当前为空:S0 全部业务接口都要求认证。 /// 新增条目前必须在评审中给出明确业务理由,否则本测试即为拦截点。 /// private static readonly HashSet IntentionalAnonymousWhitelist = new(StringComparer.Ordinal) { // 例:("AdoS0XxxController", "对外健康探活,不返回任何业务数据") }; /// /// 无数据库访问、因而不需要租户解析的 action 白名单(按 Controller 文件名.Action 精确登记)。 /// 仅允许纯静态字典 / 静态模板;任何触达仓储的 action 都不得登记在此。 /// private static readonly HashSet 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 S0ControllerFiles() { foreach (var f in Directory.EnumerateFiles(ControllersRoot(), "*.cs", SearchOption.AllDirectories).OrderBy(x => x)) yield return [Path.GetFileNameWithoutExtension(f), f]; } /// 类级 [AllowAnonymous]:白名单以外一律禁止。 [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 登记并写明业务理由。"); } } /// 每个 action 必须显式解析租户;不接受仅靠全局 AOP。 [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 都必须显式解析租户后再访问数据。"); } /// Domain / DomainCode 不得替代 tenant_id 承担隔离职责。 [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 只能作为业务筛选。"); } /// 有写入的 Controller 必须显式 stamp 租户。 [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 兜底。"); } /// /// 跨表关联必须 tenant-bound(S0-SOP-DOMAIN-DECOUPLING-FIX)。 /// Domain / DomainCode 是 legacy 兼容字段,同租户内不同表取值可能不一致, /// 用它做 join 条件既挡不住跨租户、又会让展示列解析不出来。 /// [Theory] [MemberData(nameof(S0ControllerFiles))] public void S0Controllers_JoinsMustBeTenantBound(string name, string path) { var src = File.ReadAllText(path); foreach (Match m in Regex.Matches(src, @"\.(?:Left|Inner|Right|Full)Join<[^>]+>\(\([^)]*\)\s*=>\s*(?[^\r\n]+)")) { var cond = m.Groups["cond"].Value; Assert.True(cond.Contains("TenantId", StringComparison.Ordinal), $"{name}: 关联条件缺少 TenantId —— {cond.Trim()};" + "S0 跨表关联必须以 tenant_id + 业务键为准。"); Assert.False(Regex.IsMatch(cond, @"\.Domain\s*==|\.DomainCode\s*=="), $"{name}: 关联条件里出现 Domain/DomainCode —— {cond.Trim()};" + "Domain 不得参与跨表关联,同租户内各表 Domain 取值可能不一致。"); } } /// /// SOP 文档列表的可见性只能由 tenant_id 决定,不得回退成 Domain 等值过滤。 /// [Fact] public void SopDocuments_ListMustNotGateOnDomain() { var path = Path.Combine(ControllersRoot(), "Manufacturing", "AdoS0MfgSopDocumentsController.cs"); Assert.True(File.Exists(path), "AdoS0MfgSopDocumentsController.cs 不存在"); var src = File.ReadAllText(path); Assert.DoesNotContain(".Where(x => x.Domain == domain)", src); Assert.DoesNotContain("q.Domain.Trim()", src); Assert.Contains("ScopedTo(tenantId)", src); // Query DTO 的 Domain 不得再是必填 var dto = Path.Combine(new DirectoryInfo(ControllersRoot()).Parent!.Parent!.FullName, "Dto", "S0", "Manufacturing", "AdoS0ManufacturingDtos.cs"); var dtoSrc = File.ReadAllText(dto); var block = Regex.Match(dtoSrc, @"class AdoS0MfgSopDocumentQueryDto[\s\S]*?\n\}").Value; Assert.NotEqual(string.Empty, block); // 只看实际代码,剔除注释行(注释里可能提到 [Required] 说明改动原因) var code = string.Join('\n', block.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal))); Assert.DoesNotContain("[Required", code); Assert.Contains("public string? Domain", code); } /// 可信租户解析器本身不得被前端入参污染。 [Fact] public void TenantScope_MustNotAcceptClientSuppliedTenant() { // ControllersRoot() = /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 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; } } }