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,全程只读)。
///
///
/// 背景(实测):SupplierDeliveryManagementService 与 SupplierShortageKanbanService
/// 原为类级 [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));
}
}