StdInventoryTenantIsolationTests.cs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. using System.Text.RegularExpressions;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  5. using Microsoft.Extensions.Logging.Abstractions;
  6. using Microsoft.Extensions.Options;
  7. using SqlSugar;
  8. using Xunit;
  9. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  10. /// <summary>
  11. /// S5 标准层(mdp_std_inventory)多租户隔离实库测试。
  12. /// **门禁**:仅当环境变量 AIDOP_IT=1 时执行。全程只读,不写 aidopdev、不写 165。
  13. /// </summary>
  14. [Trait("Category", "Integration")]
  15. public class StdInventoryTenantIsolationTests
  16. {
  17. private const long UatTenant = 838257186181189L;
  18. private const long AidopTenant = 797403760988229L;
  19. private const long SecondDomainTenant = 824585161322565L;
  20. private const string SourceCode = "DOPDEMORQ_SQLSERVER";
  21. private static readonly string[] KnownForeignLocations = { "10000047", "10000054", "10000092" };
  22. private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
  23. private readonly Xunit.Abstractions.ITestOutputHelper _out;
  24. public StdInventoryTenantIsolationTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
  25. private static ISqlSugarClient BuildAidopdev()
  26. {
  27. const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
  28. if (!File.Exists(path)) return null;
  29. var cs = File.ReadLines(path)
  30. .Select(l => l.Trim())
  31. .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
  32. .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
  33. .FirstOrDefault(v => !string.IsNullOrEmpty(v));
  34. if (string.IsNullOrEmpty(cs)) return null;
  35. return new SqlSugarScope(new ConnectionConfig
  36. {
  37. ConfigId = "aidopdev-std-iso-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
  38. });
  39. }
  40. private static AidopInventoryOptions Opt(string sourceCode = SourceCode) =>
  41. new() { SourceCode = sourceCode };
  42. private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db);
  43. private static StdInventorySource Std(ISqlSugarClient db) => new(db, Options.Create(Opt()));
  44. private static InventoryBalanceQuery Q(long tenantId, string domain, string location = null,
  45. string materialCode = null, int pageSize = 200, int page = 1) => new()
  46. {
  47. TenantId = tenantId, Domain = domain, Location = location, MaterialCode = materialCode,
  48. Page = page, PageSize = pageSize
  49. };
  50. private static async Task<List<string>> AllowedAsync(ISqlSugarClient db, long tenantId, string domain) =>
  51. await TenantLocationScopeLoader.LoadAsync(db, tenantId, domain);
  52. private static async Task<string> DomainOf(ISqlSugarClient db, long tenantId) =>
  53. await Resolver(db).ResolveDomainAsync(SourceCode, tenantId);
  54. // ===== Case 1:Tenant 归属确定性,不再是「全局第一个启用租户」 =====
  55. [Fact]
  56. public async Task Case1_SourceOwnerTenant_IsDeterministic_NotGlobalFirstEnabledTenant()
  57. {
  58. if (!Enabled) return;
  59. var db = BuildAidopdev();
  60. Assert.NotNull(db);
  61. var globalFirst = (await db.Ado.SqlQueryAsync<long>(
  62. "SELECT Id FROM SysTenant WHERE Status=1 ORDER BY Id LIMIT 1")).Single();
  63. var mapped = (await db.Ado.SqlQueryAsync<long>(
  64. "SELECT tenant_id FROM ado_source_domain_tenant_map WHERE source_code=@S AND domain=@D AND status=1",
  65. new List<SugarParameter> { new("@S", SourceCode), new("@D", "8010") })).Single();
  66. var resolved = await Resolver(db).ResolveTenantIdAsync(SourceCode, "8010");
  67. Assert.Equal(mapped, resolved);
  68. Assert.NotEqual(globalFirst, resolved); // 核心:不再落到默认租户
  69. _out.WriteLine($"globalFirstEnabledTenant={globalFirst} mapped={mapped} resolved={resolved}");
  70. }
  71. [Fact]
  72. public async Task Case1b_UnregisteredSourceDomain_FailsClosed()
  73. {
  74. if (!Enabled) return;
  75. var db = BuildAidopdev();
  76. Assert.NotNull(db);
  77. var ex = await Assert.ThrowsAsync<InvalidOperationException>(
  78. () => Resolver(db).ResolveTenantIdAsync(SourceCode, "NO_SUCH_DOMAIN_9999"));
  79. Assert.Contains("未登记", ex.Message);
  80. }
  81. // ===== Case 2 / 3:Domain 按租户解析(STD 侧复核)=====
  82. [Fact]
  83. public async Task Case2_3_DomainPerTenant()
  84. {
  85. if (!Enabled) return;
  86. var db = BuildAidopdev();
  87. Assert.NotNull(db);
  88. var uat = await DomainOf(db, UatTenant);
  89. var aidop = await DomainOf(db, AidopTenant);
  90. var second = await DomainOf(db, SecondDomainTenant);
  91. Assert.Equal("8010", uat);
  92. Assert.Equal("8010", aidop);
  93. Assert.Equal("2410", second); // Case 3:824 不得被写进 8010
  94. Assert.NotEqual(uat, second);
  95. }
  96. // ===== Case 4 / 5 / 6 / 8:STD 查询结果必须落在租户库位边界内 =====
  97. [Theory]
  98. [InlineData(UatTenant)]
  99. [InlineData(AidopTenant)]
  100. public async Task Case4_6_StdResultAlwaysInsideTenantScopeAndNeverSupp(long tenantId)
  101. {
  102. if (!Enabled) return;
  103. var db = BuildAidopdev();
  104. Assert.NotNull(db);
  105. var domain = await DomainOf(db, tenantId);
  106. var scope = TenantLocationScope.FromWhitelist(await AllowedAsync(db, tenantId, domain));
  107. var std = Std(db);
  108. var supp = (await db.Ado.SqlQueryAsync<string>(
  109. "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>''",
  110. new List<SugarParameter> { new("@T", tenantId), new("@D", domain) }))
  111. .Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
  112. var seen = new List<string>();
  113. for (var page = 1; page <= 50; page++)
  114. {
  115. var p = await std.QueryPageAsync(Q(tenantId, domain, page: page));
  116. Assert.Equal(InventoryDataSources.Std, p.DataSource);
  117. Assert.False(p.IsRealtime);
  118. seen.AddRange(p.List.Select(x => x.Location));
  119. if (page * 200 >= p.Total) break;
  120. }
  121. var locs = seen.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim())
  122. .Distinct(StringComparer.OrdinalIgnoreCase).ToList();
  123. var violations = locs.Where(x => !scope.Contains(x)).ToList();
  124. var suppHits = locs.Where(supp.Contains).ToList();
  125. _out.WriteLine($"tenant={tenantId} domain={domain} allowed={scope.Count} stdLocs={locs.Count} " +
  126. $"violations={violations.Count} suppHits={suppHits.Count}");
  127. Assert.Empty(violations); // Case 4
  128. Assert.Empty(suppHits); // Case 6
  129. }
  130. [Fact]
  131. public async Task Case5_8_Uat_ForeignLocation_ReturnsZero()
  132. {
  133. if (!Enabled) return;
  134. var db = BuildAidopdev();
  135. Assert.NotNull(db);
  136. var domain = await DomainOf(db, UatTenant);
  137. var std = Std(db);
  138. foreach (var foreign in KnownForeignLocations)
  139. {
  140. var p = await std.QueryPageAsync(Q(UatTenant, domain, location: foreign));
  141. Assert.Equal(0, p.Total);
  142. Assert.Empty(p.List);
  143. }
  144. }
  145. [Fact]
  146. public async Task Case6b_ExplicitSuppLocation_ReturnsZero()
  147. {
  148. if (!Enabled) return;
  149. var db = BuildAidopdev();
  150. Assert.NotNull(db);
  151. var domain = await DomainOf(db, AidopTenant);
  152. var supp = (await db.Ado.SqlQueryAsync<string>(
  153. "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>'' ORDER BY Location LIMIT 5",
  154. new List<SugarParameter> { new("@T", AidopTenant), new("@D", domain) })).ToList();
  155. Assert.NotEmpty(supp);
  156. foreach (var loc in supp)
  157. {
  158. var p = await Std(db).QueryPageAsync(Q(AidopTenant, domain, location: loc));
  159. Assert.Equal(0, p.Total);
  160. }
  161. }
  162. // ===== Case 9:空白名单 fail closed =====
  163. [Fact]
  164. public async Task Case9_EmptyWhitelist_ReturnsZero_NeverFullDomain()
  165. {
  166. if (!Enabled) return;
  167. var db = BuildAidopdev();
  168. Assert.NotNull(db);
  169. // 默认租户在 LocationMaster 无任何库位,但库里可能仍留有历史错误归属的快照
  170. var stale = (await db.Ado.SqlQueryAsync<int>(
  171. "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T",
  172. new List<SugarParameter> { new("@T", 1300000000001L) })).Single();
  173. var p = await Std(db).QueryPageAsync(Q(1300000000001L, "8010"));
  174. _out.WriteLine($"默认租户库中残留 std 行={stale},查询返回={p.Total}");
  175. Assert.Equal(0, p.Total); // 脏快照必须被查询层挡住
  176. Assert.Empty(p.List);
  177. }
  178. // ===== Case 10:SourceSystem 严格匹配 =====
  179. [Fact]
  180. public async Task Case10_SourceSystemStillStrictlyMatched()
  181. {
  182. if (!Enabled) return;
  183. var db = BuildAidopdev();
  184. Assert.NotNull(db);
  185. var domain = await DomainOf(db, UatTenant);
  186. // 库里若存在非正式来源(如 fixture source_system='UAT')的行,正式查询一律不得返回
  187. var foreignSourceRows = (await db.Ado.SqlQueryAsync<int>(
  188. "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system<>@S",
  189. new List<SugarParameter> { new("@T", UatTenant), new("@S", SourceCode) })).Single();
  190. var byOfficial = await Std(db).QueryPageAsync(Q(UatTenant, domain));
  191. var officialRows = (await db.Ado.SqlQueryAsync<int>(
  192. "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system=@S",
  193. new List<SugarParameter> { new("@T", UatTenant), new("@S", SourceCode) })).Single();
  194. _out.WriteLine($"UAT 非正式来源行={foreignSourceRows} 正式来源行={officialRows} 查询返回={byOfficial.Total}");
  195. Assert.True(byOfficial.Total <= officialRows,
  196. "STD 查询返回的行数不得超过正式 source_system 的行数(说明 source 过滤被放宽)");
  197. }
  198. // ===== 第十九节:Live 故障注入 → fallback STD,且 fallback 结果仍受租户边界约束 =====
  199. [Fact]
  200. public async Task LiveFailure_FallsBackToStd_AndStillTenantScoped()
  201. {
  202. if (!Enabled) return;
  203. var db = BuildAidopdev();
  204. Assert.NotNull(db);
  205. var domain = await DomainOf(db, AidopTenant);
  206. var scope = TenantLocationScope.FromWhitelist(await AllowedAsync(db, AidopTenant, domain));
  207. // 故障注入:给 LIVE 一个 mdp_source 中不存在的源码 → GetScopeAsync 抛错 → 触发 fallback。
  208. // 生产代码零改动;STD 侧仍用正确源码。
  209. var brokenLive = new Live165InventorySource(
  210. new MdpSourceScopeFactory(db), db, Options.Create(Opt("NO_SUCH_SOURCE_FOR_FAULT_INJECTION")));
  211. var reader = new InventoryBalanceReader(
  212. brokenLive, Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance);
  213. // 带筛选 → 本应走 LIVE;LIVE 失败后必须回落 STD
  214. var legal = scope.Locations.First();
  215. var page = await reader.QueryPageAsync(Q(AidopTenant, domain, location: legal));
  216. Assert.Equal(InventoryDataSources.Std, page.DataSource);
  217. Assert.False(page.IsRealtime);
  218. Assert.All(page.List, r => Assert.True(scope.Contains(r.Location),
  219. $"fallback 结果越界:{r.Location}"));
  220. _out.WriteLine($"fallback dataSource={page.DataSource} total={page.Total} location={legal}");
  221. }
  222. // ===== 第二十节:写入层安全断言(对当前已物化快照做全量校验)=====
  223. /// <summary>
  224. /// 写入层安全断言。
  225. /// <para>
  226. /// **断言范围**:只对经修复后物化路径重建过的租户强制成立(当前为 UAT)。
  227. /// 其余租户(797 / 默认租户)库中仍留有修复前写入的历史脏快照,
  228. /// 按本批既定边界不做清理,只在输出中记录为维护债务 —— 它们已被查询层的
  229. /// 租户库位边界挡住(见 Case4/Case9),不构成越权可见性。
  230. /// </para>
  231. /// </summary>
  232. [Fact]
  233. public async Task WriteSideInvariant_EveryStdRowInsideItsTenantScope()
  234. {
  235. if (!Enabled) return;
  236. var db = BuildAidopdev();
  237. Assert.NotNull(db);
  238. // 对每个「有合法库位范围」的租户,校验其正式来源快照的每一行都落在范围内
  239. var tenants = await db.Ado.SqlQueryAsync<long>(
  240. """
  241. SELECT DISTINCT lm.tenant_id FROM LocationMaster lm
  242. JOIN SysTenant t ON t.Id=lm.tenant_id AND t.Status=1
  243. WHERE IFNULL(lm.typed,'')<>'Supp' AND TRIM(lm.location)<>''
  244. """);
  245. var legacyDebt = new List<string>();
  246. foreach (var tenantId in tenants)
  247. {
  248. var domain = await DomainOf(db, tenantId);
  249. var bad = (await db.Ado.SqlQueryAsync<int>(
  250. """
  251. SELECT COUNT(1) FROM mdp_std_inventory s
  252. WHERE s.tenant_id=@T AND s.source_system=@S
  253. AND NOT EXISTS (
  254. SELECT 1 FROM LocationMaster lm
  255. WHERE lm.tenant_id=@T AND lm.Domain=s.domain
  256. AND lm.location=s.location AND IFNULL(lm.typed,'')<>'Supp')
  257. """,
  258. new List<SugarParameter> { new("@T", tenantId), new("@S", SourceCode) })).Single();
  259. var wrongDomain = (await db.Ado.SqlQueryAsync<int>(
  260. "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system=@S AND domain<>@D",
  261. new List<SugarParameter> { new("@T", tenantId), new("@S", SourceCode), new("@D", domain) })).Single();
  262. var total = (await db.Ado.SqlQueryAsync<int>(
  263. "SELECT COUNT(1) FROM mdp_std_inventory WHERE tenant_id=@T AND source_system=@S",
  264. new List<SugarParameter> { new("@T", tenantId), new("@S", SourceCode) })).Single();
  265. _out.WriteLine($"tenant={tenantId} domain={domain} stdRows={total} outOfScope={bad} wrongDomain={wrongDomain}");
  266. if (tenantId == UatTenant)
  267. {
  268. // 已由修复后的物化路径重建 → 必须严格成立
  269. Assert.Equal(0, bad);
  270. Assert.Equal(0, wrongDomain);
  271. }
  272. else if (bad > 0 || wrongDomain > 0)
  273. {
  274. legacyDebt.Add($"tenant={tenantId} outOfScope={bad} wrongDomain={wrongDomain}");
  275. }
  276. }
  277. if (legacyDebt.Count > 0)
  278. _out.WriteLine("【维护债务·本批不清理】修复前写入的历史脏快照(已被查询层挡住):\n "
  279. + string.Join("\n ", legacyDebt));
  280. }
  281. }