|
|
@@ -125,6 +125,54 @@ public class S0ControllerTenantClosureTests
|
|
|
+ "Create 的租户必须由后端可信上下文赋值,不得依赖全局 AOP 兜底。");
|
|
|
}
|
|
|
|
|
|
+ /// <summary>
|
|
|
+ /// 跨表关联必须 tenant-bound(S0-SOP-DOMAIN-DECOUPLING-FIX)。
|
|
|
+ /// Domain / DomainCode 是 legacy 兼容字段,同租户内不同表取值可能不一致,
|
|
|
+ /// 用它做 join 条件既挡不住跨租户、又会让展示列解析不出来。
|
|
|
+ /// </summary>
|
|
|
+ [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*(?<cond>[^\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 取值可能不一致。");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// SOP 文档列表的可见性只能由 tenant_id 决定,不得回退成 Domain 等值过滤。
|
|
|
+ /// </summary>
|
|
|
+ [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);
|
|
|
+ }
|
|
|
+
|
|
|
/// <summary>可信租户解析器本身不得被前端入参污染。</summary>
|
|
|
[Fact]
|
|
|
public void TenantScope_MustNotAcceptClientSuppliedTenant()
|