|
|
@@ -0,0 +1,342 @@
|
|
|
+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 标准层(mdp_std_inventory)多租户隔离实库测试。
|
|
|
+/// **门禁**:仅当环境变量 AIDOP_IT=1 时执行。全程只读,不写 aidopdev、不写 165。
|
|
|
+/// </summary>
|
|
|
+[Trait("Category", "Integration")]
|
|
|
+public class StdInventoryTenantIsolationTests
|
|
|
+{
|
|
|
+ private const long UatTenant = 838257186181189L;
|
|
|
+ private const long AidopTenant = 797403760988229L;
|
|
|
+ private const long SecondDomainTenant = 824585161322565L;
|
|
|
+ private const string SourceCode = "DOPDEMORQ_SQLSERVER";
|
|
|
+
|
|
|
+ private static readonly string[] KnownForeignLocations = { "10000047", "10000054", "10000092" };
|
|
|
+
|
|
|
+ private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
|
|
|
+
|
|
|
+ private readonly Xunit.Abstractions.ITestOutputHelper _out;
|
|
|
+ public StdInventoryTenantIsolationTests(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-std-iso-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private static AidopInventoryOptions Opt(string sourceCode = SourceCode) =>
|
|
|
+ new() { SourceCode = sourceCode };
|
|
|
+
|
|
|
+ private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db);
|
|
|
+ private static StdInventorySource Std(ISqlSugarClient db) => new(db, Options.Create(Opt()));
|
|
|
+
|
|
|
+ private static InventoryBalanceQuery Q(long tenantId, string domain, string location = null,
|
|
|
+ string materialCode = null, int pageSize = 200, int page = 1) => new()
|
|
|
+ {
|
|
|
+ TenantId = tenantId, Domain = domain, Location = location, MaterialCode = materialCode,
|
|
|
+ Page = page, PageSize = pageSize
|
|
|
+ };
|
|
|
+
|
|
|
+ private static async Task<List<string>> AllowedAsync(ISqlSugarClient db, long tenantId, string domain) =>
|
|
|
+ await TenantLocationScopeLoader.LoadAsync(db, tenantId, domain);
|
|
|
+
|
|
|
+ private static async Task<string> DomainOf(ISqlSugarClient db, long tenantId) =>
|
|
|
+ await Resolver(db).ResolveDomainAsync(SourceCode, tenantId);
|
|
|
+
|
|
|
+ // ===== Case 1:Tenant 归属确定性,不再是「全局第一个启用租户」 =====
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task Case1_SourceOwnerTenant_IsDeterministic_NotGlobalFirstEnabledTenant()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+
|
|
|
+ var globalFirst = (await db.Ado.SqlQueryAsync<long>(
|
|
|
+ "SELECT Id FROM SysTenant WHERE Status=1 ORDER BY Id LIMIT 1")).Single();
|
|
|
+
|
|
|
+ var mapped = (await db.Ado.SqlQueryAsync<long>(
|
|
|
+ "SELECT tenant_id FROM ado_source_domain_tenant_map WHERE source_code=@S AND domain=@D AND status=1",
|
|
|
+ new List<SugarParameter> { new("@S", SourceCode), new("@D", "8010") })).Single();
|
|
|
+
|
|
|
+ var resolved = await Resolver(db).ResolveTenantIdAsync(SourceCode, "8010");
|
|
|
+
|
|
|
+ Assert.Equal(mapped, resolved);
|
|
|
+ Assert.NotEqual(globalFirst, resolved); // 核心:不再落到默认租户
|
|
|
+ _out.WriteLine($"globalFirstEnabledTenant={globalFirst} mapped={mapped} resolved={resolved}");
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task Case1b_UnregisteredSourceDomain_FailsClosed()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+
|
|
|
+ var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
|
|
+ () => Resolver(db).ResolveTenantIdAsync(SourceCode, "NO_SUCH_DOMAIN_9999"));
|
|
|
+ Assert.Contains("未登记", ex.Message);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== Case 2 / 3:Domain 按租户解析(STD 侧复核)=====
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task Case2_3_DomainPerTenant()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+
|
|
|
+ var uat = await DomainOf(db, UatTenant);
|
|
|
+ var aidop = await DomainOf(db, AidopTenant);
|
|
|
+ var second = await DomainOf(db, SecondDomainTenant);
|
|
|
+
|
|
|
+ Assert.Equal("8010", uat);
|
|
|
+ Assert.Equal("8010", aidop);
|
|
|
+ Assert.Equal("2410", second); // Case 3:824 不得被写进 8010
|
|
|
+ Assert.NotEqual(uat, second);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== Case 4 / 5 / 6 / 8:STD 查询结果必须落在租户库位边界内 =====
|
|
|
+
|
|
|
+ [Theory]
|
|
|
+ [InlineData(UatTenant)]
|
|
|
+ [InlineData(AidopTenant)]
|
|
|
+ public async Task Case4_6_StdResultAlwaysInsideTenantScopeAndNeverSupp(long tenantId)
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+
|
|
|
+ var domain = await DomainOf(db, tenantId);
|
|
|
+ var scope = TenantLocationScope.FromWhitelist(await AllowedAsync(db, tenantId, domain));
|
|
|
+ var std = Std(db);
|
|
|
+
|
|
|
+ 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", tenantId), new("@D", domain) }))
|
|
|
+ .Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
+
|
|
|
+ var seen = new List<string>();
|
|
|
+ for (var page = 1; page <= 50; page++)
|
|
|
+ {
|
|
|
+ var p = await std.QueryPageAsync(Q(tenantId, domain, page: page));
|
|
|
+ Assert.Equal(InventoryDataSources.Std, p.DataSource);
|
|
|
+ Assert.False(p.IsRealtime);
|
|
|
+ seen.AddRange(p.List.Select(x => x.Location));
|
|
|
+ if (page * 200 >= p.Total) break;
|
|
|
+ }
|
|
|
+
|
|
|
+ var locs = seen.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim())
|
|
|
+ .Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
|
|
+
|
|
|
+ var violations = locs.Where(x => !scope.Contains(x)).ToList();
|
|
|
+ var suppHits = locs.Where(supp.Contains).ToList();
|
|
|
+
|
|
|
+ _out.WriteLine($"tenant={tenantId} domain={domain} allowed={scope.Count} stdLocs={locs.Count} " +
|
|
|
+ $"violations={violations.Count} suppHits={suppHits.Count}");
|
|
|
+
|
|
|
+ Assert.Empty(violations); // Case 4
|
|
|
+ Assert.Empty(suppHits); // Case 6
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task Case5_8_Uat_ForeignLocation_ReturnsZero()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await DomainOf(db, UatTenant);
|
|
|
+ var std = Std(db);
|
|
|
+
|
|
|
+ foreach (var foreign in KnownForeignLocations)
|
|
|
+ {
|
|
|
+ var p = await std.QueryPageAsync(Q(UatTenant, domain, location: foreign));
|
|
|
+ Assert.Equal(0, p.Total);
|
|
|
+ Assert.Empty(p.List);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task Case6b_ExplicitSuppLocation_ReturnsZero()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await DomainOf(db, 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)<>'' ORDER BY Location LIMIT 5",
|
|
|
+ new List<SugarParameter> { new("@T", AidopTenant), new("@D", domain) })).ToList();
|
|
|
+ Assert.NotEmpty(supp);
|
|
|
+
|
|
|
+ foreach (var loc in supp)
|
|
|
+ {
|
|
|
+ var p = await Std(db).QueryPageAsync(Q(AidopTenant, domain, location: loc));
|
|
|
+ Assert.Equal(0, p.Total);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== Case 9:空白名单 fail closed =====
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task Case9_EmptyWhitelist_ReturnsZero_NeverFullDomain()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+
|
|
|
+ // 默认租户在 LocationMaster 无任何库位,但库里可能仍留有历史错误归属的快照
|
|
|
+ var stale = (await db.Ado.SqlQueryAsync<int>(
|
|
|
+ "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T",
|
|
|
+ new List<SugarParameter> { new("@T", 1300000000001L) })).Single();
|
|
|
+
|
|
|
+ var p = await Std(db).QueryPageAsync(Q(1300000000001L, "8010"));
|
|
|
+
|
|
|
+ _out.WriteLine($"默认租户库中残留 std 行={stale},查询返回={p.Total}");
|
|
|
+ Assert.Equal(0, p.Total); // 脏快照必须被查询层挡住
|
|
|
+ Assert.Empty(p.List);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== Case 10:SourceSystem 严格匹配 =====
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task Case10_SourceSystemStillStrictlyMatched()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await DomainOf(db, UatTenant);
|
|
|
+
|
|
|
+ // 库里若存在非正式来源(如 fixture source_system='UAT')的行,正式查询一律不得返回
|
|
|
+ var foreignSourceRows = (await db.Ado.SqlQueryAsync<int>(
|
|
|
+ "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system<>@S",
|
|
|
+ new List<SugarParameter> { new("@T", UatTenant), new("@S", SourceCode) })).Single();
|
|
|
+
|
|
|
+ var byOfficial = await Std(db).QueryPageAsync(Q(UatTenant, domain));
|
|
|
+ var officialRows = (await db.Ado.SqlQueryAsync<int>(
|
|
|
+ "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system=@S",
|
|
|
+ new List<SugarParameter> { new("@T", UatTenant), new("@S", SourceCode) })).Single();
|
|
|
+
|
|
|
+ _out.WriteLine($"UAT 非正式来源行={foreignSourceRows} 正式来源行={officialRows} 查询返回={byOfficial.Total}");
|
|
|
+ Assert.True(byOfficial.Total <= officialRows,
|
|
|
+ "STD 查询返回的行数不得超过正式 source_system 的行数(说明 source 过滤被放宽)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 第十九节:Live 故障注入 → fallback STD,且 fallback 结果仍受租户边界约束 =====
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task LiveFailure_FallsBackToStd_AndStillTenantScoped()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await DomainOf(db, AidopTenant);
|
|
|
+ var scope = TenantLocationScope.FromWhitelist(await AllowedAsync(db, AidopTenant, domain));
|
|
|
+
|
|
|
+ // 故障注入:给 LIVE 一个 mdp_source 中不存在的源码 → GetScopeAsync 抛错 → 触发 fallback。
|
|
|
+ // 生产代码零改动;STD 侧仍用正确源码。
|
|
|
+ var brokenLive = new Live165InventorySource(
|
|
|
+ new MdpSourceScopeFactory(db), db, Options.Create(Opt("NO_SUCH_SOURCE_FOR_FAULT_INJECTION")));
|
|
|
+ var reader = new InventoryBalanceReader(
|
|
|
+ brokenLive, Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance);
|
|
|
+
|
|
|
+ // 带筛选 → 本应走 LIVE;LIVE 失败后必须回落 STD
|
|
|
+ var legal = scope.Locations.First();
|
|
|
+ var page = await reader.QueryPageAsync(Q(AidopTenant, domain, location: legal));
|
|
|
+
|
|
|
+ Assert.Equal(InventoryDataSources.Std, page.DataSource);
|
|
|
+ Assert.False(page.IsRealtime);
|
|
|
+ Assert.All(page.List, r => Assert.True(scope.Contains(r.Location),
|
|
|
+ $"fallback 结果越界:{r.Location}"));
|
|
|
+ _out.WriteLine($"fallback dataSource={page.DataSource} total={page.Total} location={legal}");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 第二十节:写入层安全断言(对当前已物化快照做全量校验)=====
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 写入层安全断言。
|
|
|
+ /// <para>
|
|
|
+ /// **断言范围**:只对经修复后物化路径重建过的租户强制成立(当前为 UAT)。
|
|
|
+ /// 其余租户(797 / 默认租户)库中仍留有修复前写入的历史脏快照,
|
|
|
+ /// 按本批既定边界不做清理,只在输出中记录为维护债务 —— 它们已被查询层的
|
|
|
+ /// 租户库位边界挡住(见 Case4/Case9),不构成越权可见性。
|
|
|
+ /// </para>
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public async Task WriteSideInvariant_EveryStdRowInsideItsTenantScope()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+
|
|
|
+ // 对每个「有合法库位范围」的租户,校验其正式来源快照的每一行都落在范围内
|
|
|
+ var tenants = await db.Ado.SqlQueryAsync<long>(
|
|
|
+ """
|
|
|
+ SELECT DISTINCT lm.tenant_id FROM LocationMaster lm
|
|
|
+ JOIN SysTenant t ON t.Id=lm.tenant_id AND t.Status=1
|
|
|
+ WHERE IFNULL(lm.typed,'')<>'Supp' AND TRIM(lm.location)<>''
|
|
|
+ """);
|
|
|
+
|
|
|
+ var legacyDebt = new List<string>();
|
|
|
+ foreach (var tenantId in tenants)
|
|
|
+ {
|
|
|
+ var domain = await DomainOf(db, tenantId);
|
|
|
+ var bad = (await db.Ado.SqlQueryAsync<int>(
|
|
|
+ """
|
|
|
+ SELECT COUNT(1) FROM mdp_std_inventory s
|
|
|
+ WHERE s.tenant_id=@T AND s.source_system=@S
|
|
|
+ AND NOT EXISTS (
|
|
|
+ SELECT 1 FROM LocationMaster lm
|
|
|
+ WHERE lm.tenant_id=@T AND lm.Domain=s.domain
|
|
|
+ AND lm.location=s.location AND IFNULL(lm.typed,'')<>'Supp')
|
|
|
+ """,
|
|
|
+ new List<SugarParameter> { new("@T", tenantId), new("@S", SourceCode) })).Single();
|
|
|
+
|
|
|
+ var wrongDomain = (await db.Ado.SqlQueryAsync<int>(
|
|
|
+ "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system=@S AND domain<>@D",
|
|
|
+ new List<SugarParameter> { new("@T", tenantId), new("@S", SourceCode), new("@D", domain) })).Single();
|
|
|
+
|
|
|
+ var total = (await db.Ado.SqlQueryAsync<int>(
|
|
|
+ "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system=@S",
|
|
|
+ new List<SugarParameter> { new("@T", tenantId), new("@S", SourceCode) })).Single();
|
|
|
+
|
|
|
+ _out.WriteLine($"tenant={tenantId} domain={domain} stdRows={total} outOfScope={bad} wrongDomain={wrongDomain}");
|
|
|
+
|
|
|
+ if (tenantId == UatTenant)
|
|
|
+ {
|
|
|
+ // 已由修复后的物化路径重建 → 必须严格成立
|
|
|
+ Assert.Equal(0, bad);
|
|
|
+ Assert.Equal(0, wrongDomain);
|
|
|
+ }
|
|
|
+ else if (bad > 0 || wrongDomain > 0)
|
|
|
+ {
|
|
|
+ legacyDebt.Add($"tenant={tenantId} outOfScope={bad} wrongDomain={wrongDomain}");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (legacyDebt.Count > 0)
|
|
|
+ _out.WriteLine("【维护债务·本批不清理】修复前写入的历史脏快照(已被查询层挡住):\n "
|
|
|
+ + string.Join("\n ", legacyDebt));
|
|
|
+ }
|
|
|
+}
|