using System.Text.RegularExpressions;
using Admin.NET.Plugin.AiDOP.DataPlatform;
using Admin.NET.Plugin.AiDOP.Infrastructure;
using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SqlSugar;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
///
/// S5 库存多租户隔离实库集成测试(Domain 解析 + Live165 库位白名单)。
/// **门禁**:仅当环境变量 AIDOP_IT=1 时执行(无 DB 的普通 CI 跳过,不失败)。
/// 全程只读:不写 aidopdev,不写 165,不改任何主数据。
///
[Trait("Category", "Integration")]
public class InventoryTenantIsolationIntegrationTests
{
private const long UatTenant = 838257186181189L;
private const long AidopTenant = 797403760988229L;
private const long SecondDomainTenant = 824585161322565L;
/// 修复前 UAT 实测越权读到的他租户库位。
private static readonly string[] KnownForeignLocations = { "10000047", "10000054", "10000092" };
private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
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;
// 取活动(非注释)的 aidopdev 连接串——凭据来自项目配置,不写入测试源码
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-tenant-iso-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
});
}
private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db);
private static Live165InventorySource Live(ISqlSugarClient db) =>
new(new MdpSourceScopeFactory(db), db, Options.Create(new AidopInventoryOptions()));
private static InventoryBalanceReader Reader(ISqlSugarClient db) =>
new(Live(db), new StdInventorySource(db, Options.Create(new AidopInventoryOptions())),
Resolver(db), Options.Create(new AidopInventoryOptions()), NullLoggerFactory.Instance);
/// 直接从库里取当前租户的合法库位(测试基准,与被测代码各自独立成立)。
private static async Task> AllowedLocationsAsync(ISqlSugarClient db, long tenantId, string domain) =>
await db.Ado.SqlQueryAsync(
"""
SELECT DISTINCT Location FROM LocationMaster
WHERE tenant_id=@TenantId AND Domain=@Domain
AND IFNULL(Typed,'')<>'Supp' AND TRIM(Location)<>''
""",
new List { new("@TenantId", tenantId), new("@Domain", domain) });
private static InventoryBalanceQuery Q(long tenantId, string domain, Action tweak = null)
{
var o = new QueryOverrides();
tweak?.Invoke(o);
return new InventoryBalanceQuery
{
TenantId = tenantId, Domain = domain,
Location = o.Location, MaterialCode = o.MaterialCode, BatchNo = o.BatchNo,
Page = 1, PageSize = 200
};
}
private sealed class QueryOverrides
{
public string Location { get; set; }
public string MaterialCode { get; set; }
public string BatchNo { get; set; }
}
// ===== Case 1 / Case 8:Domain 按租户解析 =====
[Fact]
public async Task Case1_DomainIsResolvedPerTenant_NotGloballyFirstRow()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var r = Resolver(db);
var aidop = await r.ResolveDomainAsync("DOPDEMORQ_SQLSERVER", AidopTenant);
var second = await r.ResolveDomainAsync("DOPDEMORQ_SQLSERVER", SecondDomainTenant);
// 核心:两个租户不得解析到同一个 Domain(修复前都会拿到全局第一条)
Assert.NotEqual(aidop, second);
// 且各自等于该租户自己配置的 CompanyCode
Assert.Equal(await ConfiguredDomainAsync(db, AidopTenant), aidop);
Assert.Equal(await ConfiguredDomainAsync(db, SecondDomainTenant), second);
}
[Fact]
public async Task Case8_SecondDomainTenant_UsesItsOwnDomain()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", SecondDomainTenant);
var aidopDomain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", AidopTenant);
Assert.NotEqual(aidopDomain, domain); // 不得错走 AIDOP 租户的 Domain
Assert.Equal(await ConfiguredDomainAsync(db, SecondDomainTenant), domain);
}
[Fact]
public async Task Case2_TenantWithoutDomainMapping_FailsClosed_NoHardcodedFallback()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
// 动态挑一个「启用但既无 CompanyCode 也无库位主数据」的租户,避免硬编码租户号
var orphan = (await db.Ado.SqlQueryAsync(
"""
SELECT t.Id FROM SysTenant t
WHERE t.Status = 1
AND NOT EXISTS (SELECT 1 FROM GeneralizedCodeMaster g
WHERE g.tenant_id=t.Id AND g.FldName='SystemConfig' AND g.Val='CompanyCode'
AND IFNULL(g.Domain,'')<>'')
AND NOT EXISTS (SELECT 1 FROM LocationMaster l
WHERE l.tenant_id=t.Id AND IFNULL(l.Domain,'')<>'')
ORDER BY t.Id LIMIT 1
""")).FirstOrDefault();
Assert.True(orphan > 0, "库中应存在一个无 Domain 映射的启用租户以验证 fail closed");
var ex = await Assert.ThrowsAsync(
() => Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", orphan));
Assert.Contains("Domain", ex.Message);
Assert.DoesNotContain("8010", ex.Message); // 绝不能悄悄返回硬编码 Domain
}
[Fact]
public async Task Case2b_UatTenantResolvesDomain_FromItsOwnMasterData()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
// UAT 无 CompanyCode 行 → 走次口径(该租户自己的 LocationMaster 唯一 Domain)
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
var own = await db.Ado.SqlQueryAsync(
"SELECT DISTINCT Domain FROM LocationMaster WHERE tenant_id=@T AND IFNULL(Domain,'')<>''",
new List { new("@T", UatTenant) });
Assert.Single(own);
Assert.Equal(own[0].Trim(), domain);
}
// ===== Case 3 / Case 4:白名单构成 =====
[Fact]
public async Task Case3_UatWhitelist_MatchesLocationMasterAndIsNotEmpty()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
var expected = await AllowedLocationsAsync(db, UatTenant, domain);
Assert.NotEmpty(expected); // 数量以当前 DB 为准,不硬编码
var scope = TenantLocationScope.FromWhitelist(expected);
Assert.Equal(expected.Count, scope.Count);
}
[Fact]
public async Task Case4_SuppLocations_NeverEnterWhitelist()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", AidopTenant);
var supp = await db.Ado.SqlQueryAsync(
"SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>''",
new List { new("@T", AidopTenant), new("@D", domain) });
Assert.NotEmpty(supp); // 该租户确有 Supp 库位,测试才有意义
var scope = TenantLocationScope.FromWhitelist(await AllowedLocationsAsync(db, AidopTenant, domain));
Assert.All(supp, s => Assert.False(scope.Contains(s), $"Supp 库位 {s} 不得进入白名单"));
}
// ===== Case 5 / 6 / 10 / 11 / 12:Live165 实查隔离 =====
[Fact]
public async Task Case5_Uat_QueryingKnownForeignLocations_ReturnsZero()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
var live = Live(db);
foreach (var foreign in KnownForeignLocations)
{
var page = await live.QueryPageAsync(Q(UatTenant, domain, o => o.Location = foreign));
Assert.Equal(0, page.Total);
Assert.Empty(page.List);
}
}
[Fact]
public async Task Case6_Uat_QueryingOwnLocation_StillReturnsItsStock()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
var allowed = await AllowedLocationsAsync(db, UatTenant, domain);
var live = Live(db);
// 防「安全修复 = 全查空」:白名单内至少要有一个库位能查出实时库存
var hit = 0;
foreach (var loc in allowed)
{
var page = await live.QueryPageAsync(Q(UatTenant, domain, o => o.Location = loc));
if (page.Total > 0)
{
hit++;
Assert.All(page.List, r => Assert.True(
string.Equals(r.Location?.Trim(), loc.Trim(), StringComparison.OrdinalIgnoreCase),
$"返回行库位 {r.Location} 与请求库位 {loc} 不一致"));
}
}
Assert.True(hit > 0, "UAT 合法库位应至少有一个能查到实时库存,否则说明被安全修复误杀");
}
[Theory]
[InlineData(UatTenant)]
[InlineData(AidopTenant)]
public async Task Case7_10_11_AllLiveRowsStayInsideTenantWhitelist(long tenantId)
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", tenantId);
var scope = TenantLocationScope.FromWhitelist(await AllowedLocationsAsync(db, tenantId, domain));
var live = Live(db);
// 取该租户白名单内真实存在的物料/批次,作为 Material / Batch 筛选输入
var seed = (await live.QueryPageAsync(Q(tenantId, domain))).List.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.MaterialCode));
Assert.NotNull(seed);
var queries = new[]
{
Q(tenantId, domain), // 无 Location 筛选(全白名单)
Q(tenantId, domain, o => o.MaterialCode = seed.MaterialCode), // Case 10 物料筛选
Q(tenantId, domain, o => o.BatchNo = seed.BatchNo), // Case 11 批次筛选
};
foreach (var q in queries)
{
var page = await live.QueryPageAsync(q);
Assert.Equal(InventoryDataSources.Live165, page.DataSource);
Assert.All(page.List, r => Assert.True(
scope.Contains(r.Location),
$"越权:tenant={tenantId} 返回了白名单外库位 {r.Location}"));
}
}
[Fact]
public async Task Case12_UserSpecifiedForeignLocation_CannotBypassScope_LegalOneStillWorks()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
var allowed = await AllowedLocationsAsync(db, UatTenant, domain);
var scope = TenantLocationScope.FromWhitelist(allowed);
var live = Live(db);
// 他租户库位 → 0;本租户库位 → 结果全部落在该库位
Assert.Equal(0, (await live.QueryPageAsync(Q(UatTenant, domain, o => o.Location = KnownForeignLocations[0]))).Total);
var legal = allowed.First();
var page = await live.QueryPageAsync(Q(UatTenant, domain, o => o.Location = legal));
Assert.All(page.List, r => Assert.True(scope.Contains(r.Location)));
}
// ===== Case 9:空白名单 fail closed =====
[Fact]
public async Task Case9_EmptyWhitelist_ReturnsZeroRows_NeverFullDomain()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
// 构造一个在 LocationMaster 中无任何库位的租户号,Domain 仍用真实有数据的 8010
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", AidopTenant);
var noLocationTenant = (await db.Ado.SqlQueryAsync(
"SELECT Id FROM SysTenant WHERE Status=1 AND Id NOT IN (SELECT DISTINCT tenant_id FROM LocationMaster WHERE tenant_id IS NOT NULL) ORDER BY Id LIMIT 1"))
.FirstOrDefault();
Assert.True(noLocationTenant > 0);
var page = await Live(db).QueryPageAsync(Q(noLocationTenant, domain));
Assert.Equal(0, page.Total);
Assert.Empty(page.List);
Assert.Equal(0m, page.GrandTotalQtyOnHand);
}
// ===== Case 13:查询路由不变(无筛选仍走 STD)=====
[Fact]
public async Task Case13_NoSelectiveFilter_StillRoutesToStd()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
var page = await Reader(db).QueryPageAsync(Q(UatTenant, domain));
Assert.Equal(InventoryDataSources.Std, page.DataSource);
Assert.False(page.IsRealtime);
}
[Fact]
public async Task Case13b_SelectiveFilter_RoutesToLive165()
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
var allowed = await AllowedLocationsAsync(db, UatTenant, domain);
var page = await Reader(db).QueryPageAsync(Q(UatTenant, domain, o => o.Location = allowed.First()));
Assert.Equal(InventoryDataSources.Live165, page.DataSource);
}
// ===== 第十八节:安全不变量的运行时证据(同时打印验收所需数字)=====
[Theory]
[InlineData(UatTenant)]
[InlineData(AidopTenant)]
public async Task Evidence_LiveResultAlwaysSubsetOfTenantWhitelist(long tenantId)
{
if (!Enabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", tenantId);
var allowed = await AllowedLocationsAsync(db, tenantId, domain);
var scope = TenantLocationScope.FromWhitelist(allowed);
var live = Live(db);
// 全量翻页取回 Live 结果(不只看首页前几条)
var seen = new List();
for (var page = 1; page <= 50; page++)
{
var p = await live.QueryPageAsync(new InventoryBalanceQuery
{
TenantId = tenantId, Domain = domain, Page = page, PageSize = 200
});
seen.AddRange(p.List.Select(x => x.Location));
if (page * 200 >= p.Total) break;
}
var resultLocations = seen.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var violations = resultLocations.Where(x => !scope.Contains(x)).ToList();
_out.WriteLine($"tenant={tenantId} domain={domain}");
_out.WriteLine($" AllowedLocations = {scope.Count}");
_out.WriteLine($" LiveResultLocations= {resultLocations.Count}");
_out.WriteLine($" 越权 Location 数 = {violations.Count} [{string.Join(",", violations)}]");
foreach (var foreign in KnownForeignLocations)
{
var p = await live.QueryPageAsync(Q(tenantId, domain, o => o.Location = foreign));
_out.WriteLine($" 查询他租户库位 {foreign}: total={p.Total} dataSource={p.DataSource}");
}
var legal = allowed.FirstOrDefault(l =>
live.QueryPageAsync(Q(tenantId, domain, o => o.Location = l)).GetAwaiter().GetResult().Total > 0);
if (legal is not null)
{
var p = await live.QueryPageAsync(Q(tenantId, domain, o => o.Location = legal));
_out.WriteLine($" 查询本租户合法库位 {legal}: total={p.Total} dataSource={p.DataSource} realtime={p.IsRealtime}");
}
var noFilter = await Reader(db).QueryPageAsync(Q(tenantId, domain));
_out.WriteLine($" 无筛选路由 dataSource={noFilter.DataSource} realtime={noFilter.IsRealtime}");
Assert.Empty(violations);
}
private readonly Xunit.Abstractions.ITestOutputHelper _out;
public InventoryTenantIsolationIntegrationTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
private static async Task ConfiguredDomainAsync(ISqlSugarClient db, long tenantId) =>
(await db.Ado.SqlQueryAsync(
"""
SELECT DISTINCT Domain FROM GeneralizedCodeMaster
WHERE tenant_id=@T AND FldName='SystemConfig' AND Val='CompanyCode' AND IFNULL(Domain,'')<>''
""",
new List { new("@T", tenantId) })).Single().Trim();
}