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);
}
}