InventoryTenantIsolationIntegrationTests.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  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 库存多租户隔离实库集成测试(Domain 解析 + Live165 库位白名单)。
  12. /// **门禁**:仅当环境变量 AIDOP_IT=1 时执行(无 DB 的普通 CI 跳过,不失败)。
  13. /// 全程只读:不写 aidopdev,不写 165,不改任何主数据。
  14. /// </summary>
  15. [Trait("Category", "Integration")]
  16. public class InventoryTenantIsolationIntegrationTests
  17. {
  18. private const long UatTenant = 838257186181189L;
  19. private const long AidopTenant = 797403760988229L;
  20. private const long SecondDomainTenant = 824585161322565L;
  21. /// <summary>修复前 UAT 实测越权读到的他租户库位。</summary>
  22. private static readonly string[] KnownForeignLocations = { "10000047", "10000054", "10000092" };
  23. private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
  24. private static ISqlSugarClient BuildAidopdev()
  25. {
  26. const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
  27. if (!File.Exists(path)) return null;
  28. // 取活动(非注释)的 aidopdev 连接串——凭据来自项目配置,不写入测试源码
  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-tenant-iso-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
  38. });
  39. }
  40. private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db);
  41. private static Live165InventorySource Live(ISqlSugarClient db) =>
  42. new(new MdpSourceScopeFactory(db), db, Options.Create(new AidopInventoryOptions()));
  43. private static InventoryBalanceReader Reader(ISqlSugarClient db) =>
  44. new(Live(db), new StdInventorySource(db, Options.Create(new AidopInventoryOptions())),
  45. Resolver(db), Options.Create(new AidopInventoryOptions()), NullLoggerFactory.Instance);
  46. /// <summary>直接从库里取当前租户的合法库位(测试基准,与被测代码各自独立成立)。</summary>
  47. private static async Task<List<string>> AllowedLocationsAsync(ISqlSugarClient db, long tenantId, string domain) =>
  48. await db.Ado.SqlQueryAsync<string>(
  49. """
  50. SELECT DISTINCT Location FROM LocationMaster
  51. WHERE tenant_id=@TenantId AND Domain=@Domain
  52. AND IFNULL(Typed,'')<>'Supp' AND TRIM(Location)<>''
  53. """,
  54. new List<SugarParameter> { new("@TenantId", tenantId), new("@Domain", domain) });
  55. private static InventoryBalanceQuery Q(long tenantId, string domain, Action<QueryOverrides> tweak = null)
  56. {
  57. var o = new QueryOverrides();
  58. tweak?.Invoke(o);
  59. return new InventoryBalanceQuery
  60. {
  61. TenantId = tenantId, Domain = domain,
  62. Location = o.Location, MaterialCode = o.MaterialCode, BatchNo = o.BatchNo,
  63. Page = 1, PageSize = 200
  64. };
  65. }
  66. private sealed class QueryOverrides
  67. {
  68. public string Location { get; set; }
  69. public string MaterialCode { get; set; }
  70. public string BatchNo { get; set; }
  71. }
  72. // ===== Case 1 / Case 8:Domain 按租户解析 =====
  73. [Fact]
  74. public async Task Case1_DomainIsResolvedPerTenant_NotGloballyFirstRow()
  75. {
  76. if (!Enabled) return;
  77. var db = BuildAidopdev();
  78. Assert.NotNull(db);
  79. var r = Resolver(db);
  80. var aidop = await r.ResolveDomainAsync("DOPDEMORQ_SQLSERVER", AidopTenant);
  81. var second = await r.ResolveDomainAsync("DOPDEMORQ_SQLSERVER", SecondDomainTenant);
  82. // 核心:两个租户不得解析到同一个 Domain(修复前都会拿到全局第一条)
  83. Assert.NotEqual(aidop, second);
  84. // 且各自等于该租户自己配置的 CompanyCode
  85. Assert.Equal(await ConfiguredDomainAsync(db, AidopTenant), aidop);
  86. Assert.Equal(await ConfiguredDomainAsync(db, SecondDomainTenant), second);
  87. }
  88. [Fact]
  89. public async Task Case8_SecondDomainTenant_UsesItsOwnDomain()
  90. {
  91. if (!Enabled) return;
  92. var db = BuildAidopdev();
  93. Assert.NotNull(db);
  94. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", SecondDomainTenant);
  95. var aidopDomain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", AidopTenant);
  96. Assert.NotEqual(aidopDomain, domain); // 不得错走 AIDOP 租户的 Domain
  97. Assert.Equal(await ConfiguredDomainAsync(db, SecondDomainTenant), domain);
  98. }
  99. [Fact]
  100. public async Task Case2_TenantWithoutDomainMapping_FailsClosed_NoHardcodedFallback()
  101. {
  102. if (!Enabled) return;
  103. var db = BuildAidopdev();
  104. Assert.NotNull(db);
  105. // 动态挑一个「启用但既无 CompanyCode 也无库位主数据」的租户,避免硬编码租户号
  106. var orphan = (await db.Ado.SqlQueryAsync<long>(
  107. """
  108. SELECT t.Id FROM SysTenant t
  109. WHERE t.Status = 1
  110. AND NOT EXISTS (SELECT 1 FROM GeneralizedCodeMaster g
  111. WHERE g.tenant_id=t.Id AND g.FldName='SystemConfig' AND g.Val='CompanyCode'
  112. AND IFNULL(g.Domain,'')<>'')
  113. AND NOT EXISTS (SELECT 1 FROM LocationMaster l
  114. WHERE l.tenant_id=t.Id AND IFNULL(l.Domain,'')<>'')
  115. ORDER BY t.Id LIMIT 1
  116. """)).FirstOrDefault();
  117. Assert.True(orphan > 0, "库中应存在一个无 Domain 映射的启用租户以验证 fail closed");
  118. var ex = await Assert.ThrowsAsync<InvalidOperationException>(
  119. () => Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", orphan));
  120. Assert.Contains("Domain", ex.Message);
  121. Assert.DoesNotContain("8010", ex.Message); // 绝不能悄悄返回硬编码 Domain
  122. }
  123. [Fact]
  124. public async Task Case2b_UatTenantResolvesDomain_FromItsOwnMasterData()
  125. {
  126. if (!Enabled) return;
  127. var db = BuildAidopdev();
  128. Assert.NotNull(db);
  129. // UAT 无 CompanyCode 行 → 走次口径(该租户自己的 LocationMaster 唯一 Domain)
  130. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
  131. var own = await db.Ado.SqlQueryAsync<string>(
  132. "SELECT DISTINCT Domain FROM LocationMaster WHERE tenant_id=@T AND IFNULL(Domain,'')<>''",
  133. new List<SugarParameter> { new("@T", UatTenant) });
  134. Assert.Single(own);
  135. Assert.Equal(own[0].Trim(), domain);
  136. }
  137. // ===== Case 3 / Case 4:白名单构成 =====
  138. [Fact]
  139. public async Task Case3_UatWhitelist_MatchesLocationMasterAndIsNotEmpty()
  140. {
  141. if (!Enabled) return;
  142. var db = BuildAidopdev();
  143. Assert.NotNull(db);
  144. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
  145. var expected = await AllowedLocationsAsync(db, UatTenant, domain);
  146. Assert.NotEmpty(expected); // 数量以当前 DB 为准,不硬编码
  147. var scope = TenantLocationScope.FromWhitelist(expected);
  148. Assert.Equal(expected.Count, scope.Count);
  149. }
  150. [Fact]
  151. public async Task Case4_SuppLocations_NeverEnterWhitelist()
  152. {
  153. if (!Enabled) return;
  154. var db = BuildAidopdev();
  155. Assert.NotNull(db);
  156. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", AidopTenant);
  157. var supp = await db.Ado.SqlQueryAsync<string>(
  158. "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>''",
  159. new List<SugarParameter> { new("@T", AidopTenant), new("@D", domain) });
  160. Assert.NotEmpty(supp); // 该租户确有 Supp 库位,测试才有意义
  161. var scope = TenantLocationScope.FromWhitelist(await AllowedLocationsAsync(db, AidopTenant, domain));
  162. Assert.All(supp, s => Assert.False(scope.Contains(s), $"Supp 库位 {s} 不得进入白名单"));
  163. }
  164. // ===== Case 5 / 6 / 10 / 11 / 12:Live165 实查隔离 =====
  165. [Fact]
  166. public async Task Case5_Uat_QueryingKnownForeignLocations_ReturnsZero()
  167. {
  168. if (!Enabled) return;
  169. var db = BuildAidopdev();
  170. Assert.NotNull(db);
  171. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
  172. var live = Live(db);
  173. foreach (var foreign in KnownForeignLocations)
  174. {
  175. var page = await live.QueryPageAsync(Q(UatTenant, domain, o => o.Location = foreign));
  176. Assert.Equal(0, page.Total);
  177. Assert.Empty(page.List);
  178. }
  179. }
  180. [Fact]
  181. public async Task Case6_Uat_QueryingOwnLocation_StillReturnsItsStock()
  182. {
  183. if (!Enabled) return;
  184. var db = BuildAidopdev();
  185. Assert.NotNull(db);
  186. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
  187. var allowed = await AllowedLocationsAsync(db, UatTenant, domain);
  188. var live = Live(db);
  189. // 防「安全修复 = 全查空」:白名单内至少要有一个库位能查出实时库存
  190. var hit = 0;
  191. foreach (var loc in allowed)
  192. {
  193. var page = await live.QueryPageAsync(Q(UatTenant, domain, o => o.Location = loc));
  194. if (page.Total > 0)
  195. {
  196. hit++;
  197. Assert.All(page.List, r => Assert.True(
  198. string.Equals(r.Location?.Trim(), loc.Trim(), StringComparison.OrdinalIgnoreCase),
  199. $"返回行库位 {r.Location} 与请求库位 {loc} 不一致"));
  200. }
  201. }
  202. Assert.True(hit > 0, "UAT 合法库位应至少有一个能查到实时库存,否则说明被安全修复误杀");
  203. }
  204. [Theory]
  205. [InlineData(UatTenant)]
  206. [InlineData(AidopTenant)]
  207. public async Task Case7_10_11_AllLiveRowsStayInsideTenantWhitelist(long tenantId)
  208. {
  209. if (!Enabled) return;
  210. var db = BuildAidopdev();
  211. Assert.NotNull(db);
  212. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", tenantId);
  213. var scope = TenantLocationScope.FromWhitelist(await AllowedLocationsAsync(db, tenantId, domain));
  214. var live = Live(db);
  215. // 取该租户白名单内真实存在的物料/批次,作为 Material / Batch 筛选输入
  216. var seed = (await live.QueryPageAsync(Q(tenantId, domain))).List.FirstOrDefault(x => !string.IsNullOrWhiteSpace(x.MaterialCode));
  217. Assert.NotNull(seed);
  218. var queries = new[]
  219. {
  220. Q(tenantId, domain), // 无 Location 筛选(全白名单)
  221. Q(tenantId, domain, o => o.MaterialCode = seed.MaterialCode), // Case 10 物料筛选
  222. Q(tenantId, domain, o => o.BatchNo = seed.BatchNo), // Case 11 批次筛选
  223. };
  224. foreach (var q in queries)
  225. {
  226. var page = await live.QueryPageAsync(q);
  227. Assert.Equal(InventoryDataSources.Live165, page.DataSource);
  228. Assert.All(page.List, r => Assert.True(
  229. scope.Contains(r.Location),
  230. $"越权:tenant={tenantId} 返回了白名单外库位 {r.Location}"));
  231. }
  232. }
  233. [Fact]
  234. public async Task Case12_UserSpecifiedForeignLocation_CannotBypassScope_LegalOneStillWorks()
  235. {
  236. if (!Enabled) return;
  237. var db = BuildAidopdev();
  238. Assert.NotNull(db);
  239. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
  240. var allowed = await AllowedLocationsAsync(db, UatTenant, domain);
  241. var scope = TenantLocationScope.FromWhitelist(allowed);
  242. var live = Live(db);
  243. // 他租户库位 → 0;本租户库位 → 结果全部落在该库位
  244. Assert.Equal(0, (await live.QueryPageAsync(Q(UatTenant, domain, o => o.Location = KnownForeignLocations[0]))).Total);
  245. var legal = allowed.First();
  246. var page = await live.QueryPageAsync(Q(UatTenant, domain, o => o.Location = legal));
  247. Assert.All(page.List, r => Assert.True(scope.Contains(r.Location)));
  248. }
  249. // ===== Case 9:空白名单 fail closed =====
  250. [Fact]
  251. public async Task Case9_EmptyWhitelist_ReturnsZeroRows_NeverFullDomain()
  252. {
  253. if (!Enabled) return;
  254. var db = BuildAidopdev();
  255. Assert.NotNull(db);
  256. // 构造一个在 LocationMaster 中无任何库位的租户号,Domain 仍用真实有数据的 8010
  257. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", AidopTenant);
  258. var noLocationTenant = (await db.Ado.SqlQueryAsync<long>(
  259. "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"))
  260. .FirstOrDefault();
  261. Assert.True(noLocationTenant > 0);
  262. var page = await Live(db).QueryPageAsync(Q(noLocationTenant, domain));
  263. Assert.Equal(0, page.Total);
  264. Assert.Empty(page.List);
  265. Assert.Equal(0m, page.GrandTotalQtyOnHand);
  266. }
  267. // ===== Case 13:查询路由不变(无筛选仍走 STD)=====
  268. [Fact]
  269. public async Task Case13_NoSelectiveFilter_StillRoutesToStd()
  270. {
  271. if (!Enabled) return;
  272. var db = BuildAidopdev();
  273. Assert.NotNull(db);
  274. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
  275. var page = await Reader(db).QueryPageAsync(Q(UatTenant, domain));
  276. Assert.Equal(InventoryDataSources.Std, page.DataSource);
  277. Assert.False(page.IsRealtime);
  278. }
  279. [Fact]
  280. public async Task Case13b_SelectiveFilter_RoutesToLive165()
  281. {
  282. if (!Enabled) return;
  283. var db = BuildAidopdev();
  284. Assert.NotNull(db);
  285. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", UatTenant);
  286. var allowed = await AllowedLocationsAsync(db, UatTenant, domain);
  287. var page = await Reader(db).QueryPageAsync(Q(UatTenant, domain, o => o.Location = allowed.First()));
  288. Assert.Equal(InventoryDataSources.Live165, page.DataSource);
  289. }
  290. // ===== 第十八节:安全不变量的运行时证据(同时打印验收所需数字)=====
  291. [Theory]
  292. [InlineData(UatTenant)]
  293. [InlineData(AidopTenant)]
  294. public async Task Evidence_LiveResultAlwaysSubsetOfTenantWhitelist(long tenantId)
  295. {
  296. if (!Enabled) return;
  297. var db = BuildAidopdev();
  298. Assert.NotNull(db);
  299. var domain = await Resolver(db).ResolveDomainAsync("DOPDEMORQ_SQLSERVER", tenantId);
  300. var allowed = await AllowedLocationsAsync(db, tenantId, domain);
  301. var scope = TenantLocationScope.FromWhitelist(allowed);
  302. var live = Live(db);
  303. // 全量翻页取回 Live 结果(不只看首页前几条)
  304. var seen = new List<string>();
  305. for (var page = 1; page <= 50; page++)
  306. {
  307. var p = await live.QueryPageAsync(new InventoryBalanceQuery
  308. {
  309. TenantId = tenantId, Domain = domain, Page = page, PageSize = 200
  310. });
  311. seen.AddRange(p.List.Select(x => x.Location));
  312. if (page * 200 >= p.Total) break;
  313. }
  314. var resultLocations = seen.Where(x => !string.IsNullOrWhiteSpace(x))
  315. .Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
  316. var violations = resultLocations.Where(x => !scope.Contains(x)).ToList();
  317. _out.WriteLine($"tenant={tenantId} domain={domain}");
  318. _out.WriteLine($" AllowedLocations = {scope.Count}");
  319. _out.WriteLine($" LiveResultLocations= {resultLocations.Count}");
  320. _out.WriteLine($" 越权 Location 数 = {violations.Count} [{string.Join(",", violations)}]");
  321. foreach (var foreign in KnownForeignLocations)
  322. {
  323. var p = await live.QueryPageAsync(Q(tenantId, domain, o => o.Location = foreign));
  324. _out.WriteLine($" 查询他租户库位 {foreign}: total={p.Total} dataSource={p.DataSource}");
  325. }
  326. var legal = allowed.FirstOrDefault(l =>
  327. live.QueryPageAsync(Q(tenantId, domain, o => o.Location = l)).GetAwaiter().GetResult().Total > 0);
  328. if (legal is not null)
  329. {
  330. var p = await live.QueryPageAsync(Q(tenantId, domain, o => o.Location = legal));
  331. _out.WriteLine($" 查询本租户合法库位 {legal}: total={p.Total} dataSource={p.DataSource} realtime={p.IsRealtime}");
  332. }
  333. var noFilter = await Reader(db).QueryPageAsync(Q(tenantId, domain));
  334. _out.WriteLine($" 无筛选路由 dataSource={noFilter.DataSource} realtime={noFilter.IsRealtime}");
  335. Assert.Empty(violations);
  336. }
  337. private readonly Xunit.Abstractions.ITestOutputHelper _out;
  338. public InventoryTenantIsolationIntegrationTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
  339. private static async Task<string> ConfiguredDomainAsync(ISqlSugarClient db, long tenantId) =>
  340. (await db.Ado.SqlQueryAsync<string>(
  341. """
  342. SELECT DISTINCT Domain FROM GeneralizedCodeMaster
  343. WHERE tenant_id=@T AND FldName='SystemConfig' AND Val='CompanyCode' AND IFNULL(Domain,'')<>''
  344. """,
  345. new List<SugarParameter> { new("@T", tenantId) })).Single().Trim();
  346. }