using System.Reflection; using Microsoft.AspNetCore.Authorization; using Admin.NET.Plugin.AiDOP.MaterialWarehouse; using Admin.NET.Plugin.AiDOP.ProcurementExecution; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// /// S5 MissedPrint 租户归属 · 源码级守卫。 /// /// 纯反射 + 源码文本断言,不连库、不起服务,普通 CI 也会执行(无 AIDOP_IT 门禁)。 /// 数据面断言在 (需 AIDOP_IT=1)。 /// /// /// 背景:MissedPrint 由 SupplierShipmentService「生成标签」写入,历史 INSERT 未写 tenant_id, /// 库中 152 行全为 NULL;同时该服务类级带 [AllowAnonymous] 且用 /// AidopTenantHelper.Resolve(会读 query 参数 tenantId 并有默认租户兜底), /// 匿名 GET 传 ?tenantId= 即可读任意租户数据。本组把修复后的边界固化下来。 /// /// public class MissedPrintTenantOwnershipGuardTests { private static readonly string PluginRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); private static string ReadSource(string relativePath) { var full = Path.Combine(PluginRoot, relativePath); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return File.ReadAllText(full); } // ---------- A:发货单服务的认证与租户解析边界 ---------- [Theory] [InlineData(typeof(SupplierShipmentService))] [InlineData(typeof(LabelQueryService))] [InlineData(typeof(StocktakeResultService))] public void ShipmentAndLabelServices_MustNotBeAnonymous(Type serviceType) { Assert.Null(serviceType.GetCustomAttribute(inherit: true)); 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 参数并有默认租户兜底(其自身注释即写明不得用于业务读写), /// 发货单服务必须一律走 AidopTenantScope.ResolveOrThrow(仅认信 JWT、无兜底)。 /// [Fact] public void SupplierShipmentService_MustNotUseUnsafeTenantHelper() { var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs"); // 只看可执行代码:类注释里为了留档会提到旧写法的名字,不应算违规 var codeLines = src.Split('\n') .Select(l => l.Trim()) .Where(l => !l.StartsWith("///", StringComparison.Ordinal) && !l.StartsWith("//", StringComparison.Ordinal)) .ToList(); var offenders = codeLines.Where(l => l.Contains("AidopTenantHelper.Resolve", StringComparison.Ordinal)).ToList(); Assert.True(offenders.Count == 0, $"仍在用不安全的租户解析:{string.Join(" | ", offenders)}"); Assert.Contains(codeLines, l => l.Contains("AidopTenantScope.ResolveOrThrow(_userManager)", StringComparison.Ordinal)); } // ---------- B:写路径必须写 / 限定 tenant_id ---------- [Fact] public void MissedPrintInsert_MustWriteTenantId() { var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs"); var insert = Slice(src, "INSERT INTO MissedPrint", "FROM scm_shbq s"); Assert.Contains("tenant_id", insert, StringComparison.Ordinal); Assert.Contains("@TenantId AS tenant_id", insert, StringComparison.Ordinal); } /// /// 生成标签的取数与关联必须限定租户:scm_shbq 的行、以及 PurOrdDetail / ItemMaster 的富化关联。 /// ItemMaster 的 ItemNum 跨租户重名,不限定会放大插入行数。 /// [Fact] public void MissedPrintInsertSource_MustBeTenantScoped() { var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs"); var select = Slice(src, "FROM scm_shbq s", "\"\"\""); Assert.Contains("i.tenant_id = @TenantId", select, StringComparison.Ordinal); Assert.Contains("p.tenant_id = @TenantId", select, StringComparison.Ordinal); Assert.Contains("s.tenant_id = @TenantId", select, StringComparison.Ordinal); } /// /// 按业务键定位的写语句必须叠加租户:shdh / shddh 在库中无唯一索引。 /// [Theory] [InlineData("DELETE FROM scm_shbq WHERE shdh=@shdh AND tenant_id=@TenantId")] [InlineData("UPDATE scm_shd SET shzt='待收', state=2, dycs=0 WHERE shddh=@shddh AND tenant_id=@TenantId")] public void BusinessKeyWrites_MustCarryTenantPredicate(string expected) { var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs"); Assert.Contains(expected, src, StringComparison.Ordinal); } /// /// 作废旧标签的 UPDATE 必须按租户,而不是仅按 Domain —— 797 与 UAT 的 Domain 同为 8010。 /// [Fact] public void MissedPrintVoidUpdate_MustBeTenantScoped() { var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs"); var stmt = Slice(src, "UPDATE MissedPrint SET", "Status = 'U';"); Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal); } // ---------- C:热监听回写路径 ---------- [Fact] public void HotWatch_MissedPrintUpdate_MustBeTenantScoped() { var src = ReadSource("DataPlatform/HotWatch/MdpHotWatchService.cs"); var stmt = Slice(src, "UPDATE MissedPrint", "\"\"\""); Assert.Contains("IFNULL(tenant_id, 0) = @TenantId", stmt, StringComparison.Ordinal); // 租户不可解析时必须跳过,而不是退化成按 Domain 全域改写 Assert.Contains("跳过 MissedPrint 箱码回写", src, StringComparison.Ordinal); } // ---------- D:只读列表的租户过滤 ---------- [Fact] public void LabelQuery_008_MustFilterByTenant() { var src = ReadSource("MaterialWarehouse/LabelQueryService.cs"); Assert.Contains("\"m.tenant_id = @TenantId\"", src, StringComparison.Ordinal); } [Fact] public void StocktakeResult_013_MustFilterByTenant() { var src = ReadSource("MaterialWarehouse/StocktakeResultService.cs"); Assert.Contains("\"m.tenant_id = @TenantId\"", src, StringComparison.Ordinal); } /// /// 下拉选项同样是数据出口,不得跨租户抖出别人的库位。 /// [Fact] public void LabelQuery_LocationOptions_MustFilterByTenant() { var src = ReadSource("MaterialWarehouse/LabelQueryService.cs"); var stmt = Slice(src, "SELECT DISTINCT Location AS Val", "ORDER BY Location"); Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal); } private static string Slice(string src, string from, string to) { var i = src.IndexOf(from, StringComparison.Ordinal); Assert.True(i >= 0, $"源码中找不到锚点:{from}"); var j = src.IndexOf(to, i + from.Length, StringComparison.Ordinal); Assert.True(j >= 0, $"源码中找不到结束锚点:{to}"); return src.Substring(i, j - i); } }