| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167 |
- 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;
- /// <summary>
- /// S5 MissedPrint 租户归属 · 源码级守卫。
- /// <para>
- /// 纯反射 + 源码文本断言,不连库、不起服务,普通 CI 也会执行(无 AIDOP_IT 门禁)。
- /// 数据面断言在 <see cref="InventoryDataPagesScopeTests"/>(需 AIDOP_IT=1)。
- /// </para>
- /// <para>
- /// 背景:MissedPrint 由 SupplierShipmentService「生成标签」写入,历史 INSERT 未写 tenant_id,
- /// 库中 152 行全为 NULL;同时该服务类级带 <c>[AllowAnonymous]</c> 且用
- /// <c>AidopTenantHelper.Resolve</c>(会读 query 参数 tenantId 并有默认租户兜底),
- /// 匿名 GET 传 <c>?tenantId=</c> 即可读任意租户数据。本组把修复后的边界固化下来。
- /// </para>
- /// </summary>
- 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<AllowAnonymousAttribute>(inherit: true));
- var offenders = serviceType
- .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
- .Where(m => m.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true) != null)
- .Select(m => m.Name)
- .ToList();
- Assert.True(offenders.Count == 0, $"以下端点仍标注了 AllowAnonymous:{string.Join(", ", offenders)}");
- }
- /// <summary>
- /// AidopTenantHelper.Resolve 会读 query 参数并有默认租户兜底(其自身注释即写明不得用于业务读写),
- /// 发货单服务必须一律走 AidopTenantScope.ResolveOrThrow(仅认信 JWT、无兜底)。
- /// </summary>
- [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);
- }
- /// <summary>
- /// 生成标签的取数与关联必须限定租户:scm_shbq 的行、以及 PurOrdDetail / ItemMaster 的富化关联。
- /// ItemMaster 的 ItemNum 跨租户重名,不限定会放大插入行数。
- /// </summary>
- [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);
- }
- /// <summary>
- /// 按业务键定位的写语句必须叠加租户:shdh / shddh 在库中无唯一索引。
- /// </summary>
- [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);
- }
- /// <summary>
- /// 作废旧标签的 UPDATE 必须按租户,而不是仅按 Domain —— 797 与 UAT 的 Domain 同为 8010。
- /// </summary>
- [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);
- }
- /// <summary>
- /// 下拉选项同样是数据出口,不得跨租户抖出别人的库位。
- /// </summary>
- [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);
- }
- }
|