using System.Text.RegularExpressions;
using SqlSugar;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S5.ProcurementExecution;
///
/// S5 供应商链 P0 租户安全 · 谓词语义验证(§14 C / D / E / F 的数据面部分)。
///
/// 门禁:仅当环境变量 AIDOP_IT=1 时执行。
/// 全程零写入:不 INSERT / UPDATE / DELETE 任何业务表,也不执行 Refresh。
/// 双租户样本用内联派生表(SELECT ... UNION ALL)构造,把**修复后的真实谓词**
/// 放到真实 MySQL 引擎上跑,验证「本租户算式只吃本租户行」。
///
///
/// 边界声明:本组验证的是 SQL 谓词语义,不是端到端接线。
/// 「谁调用、传的哪个 tenantId、是否匿名」由 的
/// 反射 / 源码断言覆盖。Refresh 属破坏性写路径,按批次纪律
/// RUNTIME DESTRUCTIVE PATH NOT EXECUTED。
///
///
[Trait("Category", "Integration")]
public class SupplierTenantSecurityDataTests
{
private const long TenantA = 838257186181189L; // UAT
private const long TenantB = 797403760988229L; // AIDOP
private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
private readonly Xunit.Abstractions.ITestOutputHelper _out;
public SupplierTenantSecurityDataTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
private static ISqlSugarClient? BuildAidopdev()
{
const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
if (!File.Exists(path)) return null;
var cs = File.ReadLines(path)
.Select(l => l.Trim())
.Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"")
&& l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
.Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
.FirstOrDefault(v => !string.IsNullOrEmpty(v));
if (string.IsNullOrEmpty(cs)) return null;
return new SqlSugarScope(new ConnectionConfig
{
ConfigId = "aidopdev-supplier-sec-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
});
}
/// 双租户 MissedPrint 样本:同一 (PurOrd, PurLine),A=10 / B=99。
private const string MissedPrintFixture = """
SELECT @A AS tenant_id, 'PO-DUP-1' AS PurOrd, 1 AS PurLine, 'I' AS Status, 10 AS Qty, 'SHD-DUP-1' AS ShipperNbr
UNION ALL SELECT @B, 'PO-DUP-1', 1, 'I', 99, 'SHD-DUP-1'
UNION ALL SELECT @B, 'PO-DUP-1', 1, 'I', 1, 'SHD-DUP-1'
""";
/// 双租户欠料中间表样本:同一 ItemNum,各 1 行。
private const string TotalFixture = """
SELECT @A AS tenant_id, 'ITEM-DUP' AS ItemNum, 0 AS zjsl, 0 AS locStock
UNION ALL SELECT @B, 'ITEM-DUP', 0, 0
""";
private static List Pars() => new()
{
new("@A", TenantA), new("@B", TenantB), new("@TenantId", TenantA),
};
// ===================== E:MissedPrint 在检数量聚合 =====================
///
/// §14 E:Tenant A 的 zjsl 必须只得到 10,不得得到 110(A 的 10 + B 的 99+1)。
/// 修复后的谓词是 m.tenant_id = p0.tenant_id。
///
[Fact]
public async Task E_MissedPrintAggregate_OnlySumsOwnTenant()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var scoped = await db!.Ado.SqlQuerySingleAsync(
$"""
SELECT (SELECT SUM(m.Qty) FROM ({MissedPrintFixture}) m
WHERE m.Status='I' AND m.PurOrd=p0.PurOrd AND m.PurLine=p0.Line
AND m.tenant_id = p0.tenant_id)
FROM (SELECT @TenantId AS tenant_id, 'PO-DUP-1' AS PurOrd, 1 AS Line) p0
""", Pars());
// 负向对照:旧写法(无 tenant 条件)会把 B 的 100 一起算进来
var unscoped = await db.Ado.SqlQuerySingleAsync(
$"""
SELECT (SELECT SUM(m.Qty) FROM ({MissedPrintFixture}) m
WHERE m.Status='I' AND m.PurOrd=p0.PurOrd AND m.PurLine=p0.Line)
FROM (SELECT @TenantId AS tenant_id, 'PO-DUP-1' AS PurOrd, 1 AS Line) p0
""", Pars());
_out.WriteLine($"修复后 zjsl={scoped};旧写法 zjsl={unscoped}");
Assert.Equal(110m, unscoped); // 负向对照成立,用例才有意义
Assert.Equal(10m, scoped);
}
// ===================== F:PurOrdWmsPush 箱码取数 =====================
///
/// §14 F:同一 ShipperNbr 双租户时,Tenant A 入队只能读到 A 自己的箱码。
///
[Fact]
public async Task F_BarcodeSelect_OnlyReadsOwnTenant()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var scoped = await db!.Ado.GetIntAsync(
$"""
SELECT COUNT(1) FROM ({MissedPrintFixture}) m
WHERE m.ShipperNbr='SHD-DUP-1' AND m.tenant_id = @TenantId
""", Pars());
var unscoped = await db.Ado.GetIntAsync(
$"""
SELECT COUNT(1) FROM ({MissedPrintFixture}) m
WHERE m.ShipperNbr='SHD-DUP-1'
""", Pars());
_out.WriteLine($"修复后箱码数={scoped};旧写法={unscoped}");
Assert.Equal(3, unscoped);
Assert.Equal(1, scoped);
}
// ===================== C / D:DELETE / UPDATE 影响面 =====================
///
/// §14 C:Tenant A 的清表只能命中 A。用 SELECT 复刻 DELETE 的 WHERE 求影响面,不执行 DELETE。
///
[Fact]
public async Task C_DeleteScope_TouchesOnlyOwnTenant()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var affected = await db!.Ado.SqlQueryAsync(
$"SELECT tenant_id FROM ({TotalFixture}) a WHERE a.tenant_id = @TenantId", Pars());
var affectedAll = await db.Ado.GetIntAsync(
$"SELECT COUNT(1) FROM ({TotalFixture}) a", Pars());
_out.WriteLine($"本租户命中={affected.Count},样本总行={affectedAll}");
Assert.Equal(2, affectedAll); // 样本确实含两个租户,用例才有意义
Assert.Single(affected);
Assert.Equal(TenantA, affected[0]);
Assert.DoesNotContain(TenantB, affected);
}
///
/// §14 D:同 ItemNum 的双租户数据,Tenant A 的 UPDATE 不得命中 B。
/// 复刻 Step 3 的 `JOIN ... ON a.ItemNum=b.ItemNum WHERE a.tenant_id=@TenantId` 影响面。
///
[Fact]
public async Task D_UpdateScope_TouchesOnlyOwnTenant()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
const string joinSrc = "SELECT 'ITEM-DUP' AS ItemNum, 7 AS zjsl";
var scoped = await db!.Ado.SqlQueryAsync(
$"""
SELECT a.tenant_id FROM ({TotalFixture}) a
JOIN ({joinSrc}) b ON a.ItemNum = b.ItemNum
WHERE a.tenant_id = @TenantId
""", Pars());
// 负向对照:旧写法只按 ItemNum 关联,会同时改到两个租户
var unscoped = await db.Ado.SqlQueryAsync(
$"""
SELECT a.tenant_id FROM ({TotalFixture}) a
JOIN ({joinSrc}) b ON a.ItemNum = b.ItemNum
""", Pars());
_out.WriteLine($"修复后命中租户={string.Join(",", scoped)};旧写法命中={string.Join(",", unscoped)}");
Assert.Equal(2, unscoped.Count);
Assert.Single(scoped);
Assert.Equal(TenantA, scoped[0]);
}
// ===================== 现网键碰撞现状(§12) =====================
///
/// §12:即使当前无跨租户重复也不能省 tenant 条件(业务键无数据库级唯一约束)。
/// 本用例只记录现状,不因为「今天不重复」而放松要求;若将来出现重复,日志里能直接看到。
///
[Fact]
public async Task BusinessKeys_HaveNoGlobalUniqueConstraint()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var dupMp = await db!.Ado.GetIntAsync(
"SELECT COUNT(1) FROM (SELECT PurOrd,PurLine FROM MissedPrint WHERE IFNULL(PurOrd,'')<>'' GROUP BY PurOrd,PurLine HAVING COUNT(DISTINCT tenant_id)>1) x");
var dupShipper = await db.Ado.GetIntAsync(
"SELECT COUNT(1) FROM (SELECT ShipperNbr FROM MissedPrint WHERE IFNULL(ShipperNbr,'')<>'' GROUP BY ShipperNbr HAVING COUNT(DISTINCT tenant_id)>1) x");
var dupItem = await db.Ado.GetIntAsync(
"SELECT COUNT(1) FROM (SELECT ItemNum FROM ItemMaster GROUP BY ItemNum HAVING COUNT(DISTINCT tenant_id)>1) x");
_out.WriteLine($"跨租户重复:MissedPrint(PurOrd,PurLine)={dupMp} ShipperNbr={dupShipper} ItemMaster.ItemNum={dupItem}");
// 这些业务键没有任何**不含 tenant_id 的**唯一索引 —— 即它们全局不唯一,
// 所以只按业务键关联必然可能跨租户串数,必须显式带 tenant 条件。
// (ItemMaster 有 uk_ItemMaster_factory_item = (tenant_id, factory_ref_id, ItemNum),
// 恰恰说明 ItemNum 只在租户内唯一。)
var globallyUniqueIdx = await db.Ado.SqlQueryAsync(
"""
SELECT CONCAT(s.TABLE_NAME, '.', s.INDEX_NAME)
FROM information_schema.STATISTICS s
WHERE s.TABLE_SCHEMA = DATABASE() AND s.NON_UNIQUE = 0
AND ((s.TABLE_NAME='MissedPrint' AND s.COLUMN_NAME IN ('ShipperNbr','PurOrd'))
OR (s.TABLE_NAME='ItemMaster' AND s.COLUMN_NAME = 'ItemNum')
OR (s.TABLE_NAME='WorkOrdMaster' AND s.COLUMN_NAME = 'WorkOrd'))
AND NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS t
WHERE t.TABLE_SCHEMA = s.TABLE_SCHEMA AND t.TABLE_NAME = s.TABLE_NAME
AND t.INDEX_NAME = s.INDEX_NAME AND t.COLUMN_NAME = 'tenant_id')
GROUP BY s.TABLE_NAME, s.INDEX_NAME
""");
_out.WriteLine($"不含 tenant_id 的唯一索引:{(globallyUniqueIdx.Count == 0 ? "无" : string.Join(",", globallyUniqueIdx))}");
Assert.Empty(globallyUniqueIdx);
// ItemNum 已经跨租户重名 —— 这条是「不能只按业务键关联」的现网证据
Assert.True(dupItem > 0, "ItemMaster.ItemNum 若已全局唯一,需重新评估本组结论");
}
// ===================== §17 回归:008 主链未被本批破坏 =====================
[Fact]
public async Task Regression_LabelQueryTenantSplit_Unchanged()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var total = await db!.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
var nullTenant = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id IS NULL");
var uat = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T",
new List { new("@T", TenantA) });
var aidop = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T",
new List { new("@T", TenantB) });
_out.WriteLine($"MissedPrint total={total} null={nullTenant} UAT={uat} 797={aidop}");
Assert.Equal(0, nullTenant);
Assert.Equal(total, uat + aidop);
}
}