using System.Reflection; using System.Text.RegularExpressions; using Microsoft.AspNetCore.Authorization; using Admin.NET.Plugin.AiDOP.DataPlatform.Wms; using Admin.NET.Plugin.AiDOP.ProcurementExecution; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.ProcurementExecution; /// /// S5 供应商链 P0 租户安全守卫(§14 A/B/C/D/E/F 的静态部分)。 /// /// 纯反射 + 源码文本断言,不连库、不起服务、不执行任何写路径,普通 CI 也会执行(无 AIDOP_IT 门禁)。 /// 数据面断言见 SupplierTenantSecurityDataTests(需 AIDOP_IT=1,全程只读)。 /// /// /// 背景(实测):SupplierDeliveryManagementServiceSupplierShortageKanbanService /// 原为类级 [AllowAnonymous] + AidopTenantHelper.Resolve(App.HttpContext) /// (解析链含 query 参数 tenantId 与 DefaultTenantId 兜底)。匿名 GET /// supplier-delivery/list?tenantId=<任意租户> 返回该租户业务数据(797→94 条、UAT→7 条); /// 而 supplier-shortage-kanban/refresh 的两条 DELETE 当时不带 WHERE,会清空全部租户的欠料汇总。 /// /// public class SupplierTenantSecurityGuardTests { private static readonly string PluginRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); private const string DeliverySrc = "ProcurementExecution/SupplierDeliveryManagementService.cs"; private const string ShortageSrc = "ProcurementExecution/SupplierShortageKanbanService.cs"; private const string PushSrc = "DataPlatform/Wms/PurOrdWmsPushService.cs"; private static string ReadSource(string relativePath) { var full = Path.Combine(PluginRoot, relativePath); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return File.ReadAllText(full); } /// 只取可执行代码行:类注释里为留档会提到旧写法的名字,不应算违规。 private static List CodeLines(string relativePath) => ReadSource(relativePath) .Split('\n') .Select(l => l.Trim()) .Where(l => !l.StartsWith("///", StringComparison.Ordinal) && !l.StartsWith("//", StringComparison.Ordinal)) .ToList(); // ===================== A / B:认证与 Tenant 来源 ===================== [Theory] [InlineData(typeof(SupplierDeliveryManagementService))] [InlineData(typeof(SupplierShortageKanbanService))] [InlineData(typeof(PurOrdWmsPushService))] public void Service_MustNotBeMarkedAllowAnonymous(Type serviceType) { Assert.Null(serviceType.GetCustomAttribute(inherit: true)); } [Theory] [InlineData(typeof(SupplierDeliveryManagementService))] [InlineData(typeof(SupplierShortageKanbanService))] [InlineData(typeof(PurOrdWmsPushService))] public void NoEndpoint_MayBeMarkedAllowAnonymous(Type serviceType) { var offenders = serviceType .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) .Where(m => m.GetCustomAttribute(inherit: true) != null) .Select(m => m.Name) .ToList(); Assert.True(offenders.Count == 0, $"以下端点仍标注了 AllowAnonymous:{string.Join(", ", offenders)}"); } /// /// AidopTenantHelper.Resolve 会读 query 参数 tenantId 且有 DefaultTenantId 兜底 /// (其自身注释即写明不得用于业务读写),三个服务必须一律走 AidopTenantScope.ResolveOrThrow。 /// [Theory] [InlineData(DeliverySrc)] [InlineData(ShortageSrc)] [InlineData(PushSrc)] public void Service_MustNotUseUnsafeTenantHelper(string relativePath) { var offenders = CodeLines(relativePath) .Where(l => l.Contains("AidopTenantHelper", StringComparison.Ordinal)) .ToList(); Assert.True(offenders.Count == 0, $"{relativePath} 仍在用不安全的租户解析:{string.Join(" | ", offenders)}"); } /// 每个公开端点方法体内都必须出现可信租户解析,不允许有"忘了加"的端点。 [Theory] [InlineData(DeliverySrc, 5)] [InlineData(ShortageSrc, 4)] public void EveryEndpoint_ResolvesTenantFromTrustedContext(string relativePath, int expectedCount) { var count = CodeLines(relativePath) .Count(l => l.Contains("AidopTenantScope.ResolveOrThrow(_userManager)", StringComparison.Ordinal)); Assert.Equal(expectedCount, count); } /// /// 请求体里的 TenantId 只能当断言用,不得据其切租户。 /// [Fact] public void PurOrdWmsPush_MustDeriveTenantFromJwt_NotFromRequestBody() { var lines = CodeLines(PushSrc); Assert.Contains(lines, l => l.Contains("var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);", StringComparison.Ordinal)); Assert.Contains(lines, l => l.Contains("input.TenantId != tenantId", StringComparison.Ordinal)); // 不得再把请求体的 TenantId 直接传进业务链 Assert.DoesNotContain(lines, l => l.Contains("EnqueueShipmentChainAsync(input.TenantId", StringComparison.Ordinal)); Assert.DoesNotContain(lines, l => l.Contains("EnqueuePurchaseOrderAsync(input.TenantId", StringComparison.Ordinal)); } // ===================== C:DELETE tenant scope ===================== /// /// Refresh 里绝不允许出现无 WHERE 的整表 DELETE / TRUNCATE: /// 原写法 `DELETE FROM WorkOrdDetailTotal` 会清空全部租户,随后只重建被指定的那个租户。 /// [Fact] public void ShortageRefresh_MustNotContainUnscopedDeleteOrTruncate() { var src = ReadSource(ShortageSrc); foreach (var table in new[] { "WorkOrdDetailTotal", "WorkOrdDetailTotalKB" }) { Assert.Contains($"DELETE FROM {table} WHERE tenant_id = @TenantId", src, StringComparison.Ordinal); } // 注释里会留档旧写法的关键词,因此只扫可执行代码 var code = string.Join("\n", CodeLines(ShortageSrc)); // 任何 "DELETE FROM <表>" 后面若没跟 WHERE 就是整表删 // \b 必不可少:没有它,\w+ 会回溯成更短的前缀让负向先行断言假性通过 var unscoped = Regex.Matches(code, @"DELETE\s+FROM\s+\w+\b(?!\s+WHERE)", RegexOptions.IgnoreCase) .Select(m => m.Value.Trim()) .ToList(); Assert.True(unscoped.Count == 0, $"存在无 WHERE 的整表 DELETE:{string.Join(" | ", unscoped)}"); // TRUNCATE 天然无法带条件,一律不允许 Assert.DoesNotContain("TRUNCATE", code, StringComparison.OrdinalIgnoreCase); // DROP 会打断并发的另一个租户 Assert.DoesNotContain("DROP TABLE", code, StringComparison.OrdinalIgnoreCase); } // ===================== D:UPDATE tenant scope ===================== /// /// 每条 `UPDATE WorkOrdDetailTotal*` 的目标侧都必须带 tenant_id; /// 只靠 ItemNum / WorkOrd / OrdDate 关联是不够的(这些业务键无全局唯一约束)。 /// [Fact] public void ShortageRefresh_EveryUpdate_TargetsOwnTenantOnly() { var src = ReadSource(ShortageSrc); var offenders = new List(); foreach (Match m in Regex.Matches(src, @"UPDATE\s+WorkOrdDetailTotal(KB)?\b", RegexOptions.IgnoreCase)) { // 取该 UPDATE 语句块:到下一个 `"""` 结束符为止 var end = src.IndexOf("\"\"\"", m.Index, StringComparison.Ordinal); var stmt = end > m.Index ? src[m.Index..end] : src[m.Index..]; var hasTenant = stmt.Contains("tenant_id = @TenantId", StringComparison.Ordinal) || stmt.Contains("tenant_id = p0.tenant_id", StringComparison.Ordinal); if (!hasTenant) offenders.Add(stmt.Split('\n')[0].Trim() + " …"); } Assert.True(offenders.Count == 0, $"以下 UPDATE 目标侧缺租户条件:{string.Join(" | ", offenders)}"); } // ===================== E:MissedPrint 聚合 tenant scope ===================== /// /// 在检数量 zjsl 只能统计当前租户的 MissedPrint。 /// 同一语句内的 s0(scm_shdzb)/ Do0(PurOrdRctDetail)本就带 tenant_id = p0.tenant_id, /// 独 i0(MissedPrint)漏掉 —— 本用例把这条口径钉死。 /// [Fact] public void ShortageRefresh_MissedPrintAggregate_MustBeTenantScoped() { var src = ReadSource(ShortageSrc); var i = src.IndexOf("FROM MissedPrint m", StringComparison.Ordinal); Assert.True(i >= 0, "找不到 MissedPrint 聚合锚点"); var end = src.IndexOf(") i0 ON TRUE", i, StringComparison.Ordinal); Assert.True(end > i, "找不到 i0 LATERAL 结束锚点"); var stmt = src[i..end]; Assert.Contains("m.tenant_id = p0.tenant_id", stmt, StringComparison.Ordinal); // 且 p0 侧必须被钉到当前租户,否则上面那条等式没有意义 Assert.Contains("p0.tenant_id = @TenantId", src, StringComparison.Ordinal); } // ===================== F:PurOrdWmsPush 的 MissedPrint 取数 ===================== /// /// 送货单号 ShipperNbr 在库中无唯一索引,不能只靠它收敛箱码; /// 该方法本就有可信的 tenantId 传入(TryEnqueueShipmentChainSafeAsync(tenantId, shddh)),必须用上。 /// [Fact] public void PurOrdWmsPush_BarcodeSelect_MustBeTenantScoped() { var src = ReadSource(PushSrc); var i = src.IndexOf("FROM MissedPrint", StringComparison.Ordinal); Assert.True(i >= 0, "找不到 MissedPrint 取数锚点"); var end = src.IndexOf("ORDER BY ShipperLine", i, StringComparison.Ordinal); Assert.True(end > i, "找不到结束锚点"); var stmt = src[i..end]; Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal); // 原有业务语义不得被削弱 Assert.Contains("IFNULL(Status, '') = 'U'", stmt, StringComparison.Ordinal); Assert.Contains("NOT LIKE '作废%'", stmt, StringComparison.Ordinal); } // ===================== §9 Domain resolver 归口 ===================== /// /// 欠料看板不得再用「全局取第一条 CompanyCode」+ 硬编码回落,必须按当前租户解析且 fail closed。 /// [Fact] public void ShortageRefresh_DomainResolver_MustBeTenantScopedAndFailClosed() { var lines = CodeLines(ShortageSrc); Assert.Contains(lines, l => l.Contains("_domainTenant.ResolveDomainAsync(", StringComparison.Ordinal)); var offenders = lines .Where(l => l.Contains("FldName='SystemConfig'", StringComparison.Ordinal) || l.Contains("\"1001\"", StringComparison.Ordinal) || l.Contains("\"8010\"", StringComparison.Ordinal)) .ToList(); Assert.True(offenders.Count == 0, $"仍存在 global-first / 硬编码 Domain 回落:{string.Join(" | ", offenders)}"); } // ===================== 回归:上一批已收口的邻居 ===================== [Theory] [InlineData(typeof(SupplierShipmentService))] public void PreviouslyClosedSiblings_MustStayClosed(Type serviceType) { Assert.Null(serviceType.GetCustomAttribute(inherit: true)); } }