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