| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415 |
- 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;
- /// <summary>
- /// S5 库存多租户隔离实库集成测试(Domain 解析 + Live165 库位白名单)。
- /// **门禁**:仅当环境变量 AIDOP_IT=1 时执行(无 DB 的普通 CI 跳过,不失败)。
- /// 全程只读:不写 aidopdev,不写 165,不改任何主数据。
- /// </summary>
- [Trait("Category", "Integration")]
- public class InventoryTenantIsolationIntegrationTests
- {
- private const long UatTenant = 838257186181189L;
- private const long AidopTenant = 797403760988229L;
- private const long SecondDomainTenant = 824585161322565L;
- /// <summary>修复前 UAT 实测越权读到的他租户库位。</summary>
- 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);
- /// <summary>直接从库里取当前租户的合法库位(测试基准,与被测代码各自独立成立)。</summary>
- private static async Task<List<string>> AllowedLocationsAsync(ISqlSugarClient db, long tenantId, string domain) =>
- await db.Ado.SqlQueryAsync<string>(
- """
- SELECT DISTINCT Location FROM LocationMaster
- WHERE tenant_id=@TenantId AND Domain=@Domain
- AND IFNULL(Typed,'')<>'Supp' AND TRIM(Location)<>''
- """,
- new List<SugarParameter> { new("@TenantId", tenantId), new("@Domain", domain) });
- private static InventoryBalanceQuery Q(long tenantId, string domain, Action<QueryOverrides> 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<long>(
- """
- 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<InvalidOperationException>(
- () => 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<string>(
- "SELECT DISTINCT Domain FROM LocationMaster WHERE tenant_id=@T AND IFNULL(Domain,'')<>''",
- new List<SugarParameter> { 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<string>(
- "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>''",
- new List<SugarParameter> { 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<long>(
- "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<string>();
- 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<string> ConfiguredDomainAsync(ISqlSugarClient db, long tenantId) =>
- (await db.Ado.SqlQueryAsync<string>(
- """
- SELECT DISTINCT Domain FROM GeneralizedCodeMaster
- WHERE tenant_id=@T AND FldName='SystemConfig' AND Val='CompanyCode' AND IFNULL(Domain,'')<>''
- """,
- new List<SugarParameter> { new("@T", tenantId) })).Single().Trim();
- }
|