SupplierTenantSecurityDataTests.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. using System.Text.RegularExpressions;
  2. using SqlSugar;
  3. using Xunit;
  4. namespace Admin.NET.Plugin.AiDOP.Tests.S5.ProcurementExecution;
  5. /// <summary>
  6. /// S5 供应商链 P0 租户安全 · 谓词语义验证(§14 C / D / E / F 的数据面部分)。
  7. /// <para>
  8. /// <b>门禁</b>:仅当环境变量 AIDOP_IT=1 时执行。
  9. /// <b>全程零写入</b>:不 INSERT / UPDATE / DELETE 任何业务表,也不执行 Refresh。
  10. /// 双租户样本用内联派生表(<c>SELECT ... UNION ALL</c>)构造,把**修复后的真实谓词**
  11. /// 放到真实 MySQL 引擎上跑,验证「本租户算式只吃本租户行」。
  12. /// </para>
  13. /// <para>
  14. /// 边界声明:本组验证的是 <b>SQL 谓词语义</b>,不是端到端接线。
  15. /// 「谁调用、传的哪个 tenantId、是否匿名」由 <see cref="SupplierTenantSecurityGuardTests"/> 的
  16. /// 反射 / 源码断言覆盖。Refresh 属破坏性写路径,按批次纪律
  17. /// <b>RUNTIME DESTRUCTIVE PATH NOT EXECUTED</b>。
  18. /// </para>
  19. /// </summary>
  20. [Trait("Category", "Integration")]
  21. public class SupplierTenantSecurityDataTests
  22. {
  23. private const long TenantA = 838257186181189L; // UAT
  24. private const long TenantB = 797403760988229L; // AIDOP
  25. private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
  26. private readonly Xunit.Abstractions.ITestOutputHelper _out;
  27. public SupplierTenantSecurityDataTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
  28. private static ISqlSugarClient? BuildAidopdev()
  29. {
  30. const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
  31. if (!File.Exists(path)) return null;
  32. var cs = File.ReadLines(path)
  33. .Select(l => l.Trim())
  34. .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"")
  35. && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
  36. .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
  37. .FirstOrDefault(v => !string.IsNullOrEmpty(v));
  38. if (string.IsNullOrEmpty(cs)) return null;
  39. return new SqlSugarScope(new ConnectionConfig
  40. {
  41. ConfigId = "aidopdev-supplier-sec-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
  42. });
  43. }
  44. /// <summary>双租户 MissedPrint 样本:同一 (PurOrd, PurLine),A=10 / B=99。</summary>
  45. private const string MissedPrintFixture = """
  46. SELECT @A AS tenant_id, 'PO-DUP-1' AS PurOrd, 1 AS PurLine, 'I' AS Status, 10 AS Qty, 'SHD-DUP-1' AS ShipperNbr
  47. UNION ALL SELECT @B, 'PO-DUP-1', 1, 'I', 99, 'SHD-DUP-1'
  48. UNION ALL SELECT @B, 'PO-DUP-1', 1, 'I', 1, 'SHD-DUP-1'
  49. """;
  50. /// <summary>双租户欠料中间表样本:同一 ItemNum,各 1 行。</summary>
  51. private const string TotalFixture = """
  52. SELECT @A AS tenant_id, 'ITEM-DUP' AS ItemNum, 0 AS zjsl, 0 AS locStock
  53. UNION ALL SELECT @B, 'ITEM-DUP', 0, 0
  54. """;
  55. private static List<SugarParameter> Pars() => new()
  56. {
  57. new("@A", TenantA), new("@B", TenantB), new("@TenantId", TenantA),
  58. };
  59. // ===================== E:MissedPrint 在检数量聚合 =====================
  60. /// <summary>
  61. /// §14 E:Tenant A 的 zjsl 必须只得到 10,不得得到 110(A 的 10 + B 的 99+1)。
  62. /// 修复后的谓词是 <c>m.tenant_id = p0.tenant_id</c>。
  63. /// </summary>
  64. [Fact]
  65. public async Task E_MissedPrintAggregate_OnlySumsOwnTenant()
  66. {
  67. if (!Enabled) return;
  68. var db = BuildAidopdev();
  69. Assert.NotNull(db);
  70. var scoped = await db!.Ado.SqlQuerySingleAsync<decimal?>(
  71. $"""
  72. SELECT (SELECT SUM(m.Qty) FROM ({MissedPrintFixture}) m
  73. WHERE m.Status='I' AND m.PurOrd=p0.PurOrd AND m.PurLine=p0.Line
  74. AND m.tenant_id = p0.tenant_id)
  75. FROM (SELECT @TenantId AS tenant_id, 'PO-DUP-1' AS PurOrd, 1 AS Line) p0
  76. """, Pars());
  77. // 负向对照:旧写法(无 tenant 条件)会把 B 的 100 一起算进来
  78. var unscoped = await db.Ado.SqlQuerySingleAsync<decimal?>(
  79. $"""
  80. SELECT (SELECT SUM(m.Qty) FROM ({MissedPrintFixture}) m
  81. WHERE m.Status='I' AND m.PurOrd=p0.PurOrd AND m.PurLine=p0.Line)
  82. FROM (SELECT @TenantId AS tenant_id, 'PO-DUP-1' AS PurOrd, 1 AS Line) p0
  83. """, Pars());
  84. _out.WriteLine($"修复后 zjsl={scoped};旧写法 zjsl={unscoped}");
  85. Assert.Equal(110m, unscoped); // 负向对照成立,用例才有意义
  86. Assert.Equal(10m, scoped);
  87. }
  88. // ===================== F:PurOrdWmsPush 箱码取数 =====================
  89. /// <summary>
  90. /// §14 F:同一 ShipperNbr 双租户时,Tenant A 入队只能读到 A 自己的箱码。
  91. /// </summary>
  92. [Fact]
  93. public async Task F_BarcodeSelect_OnlyReadsOwnTenant()
  94. {
  95. if (!Enabled) return;
  96. var db = BuildAidopdev();
  97. Assert.NotNull(db);
  98. var scoped = await db!.Ado.GetIntAsync(
  99. $"""
  100. SELECT COUNT(1) FROM ({MissedPrintFixture}) m
  101. WHERE m.ShipperNbr='SHD-DUP-1' AND m.tenant_id = @TenantId
  102. """, Pars());
  103. var unscoped = await db.Ado.GetIntAsync(
  104. $"""
  105. SELECT COUNT(1) FROM ({MissedPrintFixture}) m
  106. WHERE m.ShipperNbr='SHD-DUP-1'
  107. """, Pars());
  108. _out.WriteLine($"修复后箱码数={scoped};旧写法={unscoped}");
  109. Assert.Equal(3, unscoped);
  110. Assert.Equal(1, scoped);
  111. }
  112. // ===================== C / D:DELETE / UPDATE 影响面 =====================
  113. /// <summary>
  114. /// §14 C:Tenant A 的清表只能命中 A。用 SELECT 复刻 DELETE 的 WHERE 求影响面,不执行 DELETE。
  115. /// </summary>
  116. [Fact]
  117. public async Task C_DeleteScope_TouchesOnlyOwnTenant()
  118. {
  119. if (!Enabled) return;
  120. var db = BuildAidopdev();
  121. Assert.NotNull(db);
  122. var affected = await db!.Ado.SqlQueryAsync<long>(
  123. $"SELECT tenant_id FROM ({TotalFixture}) a WHERE a.tenant_id = @TenantId", Pars());
  124. var affectedAll = await db.Ado.GetIntAsync(
  125. $"SELECT COUNT(1) FROM ({TotalFixture}) a", Pars());
  126. _out.WriteLine($"本租户命中={affected.Count},样本总行={affectedAll}");
  127. Assert.Equal(2, affectedAll); // 样本确实含两个租户,用例才有意义
  128. Assert.Single(affected);
  129. Assert.Equal(TenantA, affected[0]);
  130. Assert.DoesNotContain(TenantB, affected);
  131. }
  132. /// <summary>
  133. /// §14 D:同 ItemNum 的双租户数据,Tenant A 的 UPDATE 不得命中 B。
  134. /// 复刻 Step 3 的 `JOIN ... ON a.ItemNum=b.ItemNum WHERE a.tenant_id=@TenantId` 影响面。
  135. /// </summary>
  136. [Fact]
  137. public async Task D_UpdateScope_TouchesOnlyOwnTenant()
  138. {
  139. if (!Enabled) return;
  140. var db = BuildAidopdev();
  141. Assert.NotNull(db);
  142. const string joinSrc = "SELECT 'ITEM-DUP' AS ItemNum, 7 AS zjsl";
  143. var scoped = await db!.Ado.SqlQueryAsync<long>(
  144. $"""
  145. SELECT a.tenant_id FROM ({TotalFixture}) a
  146. JOIN ({joinSrc}) b ON a.ItemNum = b.ItemNum
  147. WHERE a.tenant_id = @TenantId
  148. """, Pars());
  149. // 负向对照:旧写法只按 ItemNum 关联,会同时改到两个租户
  150. var unscoped = await db.Ado.SqlQueryAsync<long>(
  151. $"""
  152. SELECT a.tenant_id FROM ({TotalFixture}) a
  153. JOIN ({joinSrc}) b ON a.ItemNum = b.ItemNum
  154. """, Pars());
  155. _out.WriteLine($"修复后命中租户={string.Join(",", scoped)};旧写法命中={string.Join(",", unscoped)}");
  156. Assert.Equal(2, unscoped.Count);
  157. Assert.Single(scoped);
  158. Assert.Equal(TenantA, scoped[0]);
  159. }
  160. // ===================== 现网键碰撞现状(§12) =====================
  161. /// <summary>
  162. /// §12:即使当前无跨租户重复也不能省 tenant 条件(业务键无数据库级唯一约束)。
  163. /// 本用例只记录现状,不因为「今天不重复」而放松要求;若将来出现重复,日志里能直接看到。
  164. /// </summary>
  165. [Fact]
  166. public async Task BusinessKeys_HaveNoGlobalUniqueConstraint()
  167. {
  168. if (!Enabled) return;
  169. var db = BuildAidopdev();
  170. Assert.NotNull(db);
  171. var dupMp = await db!.Ado.GetIntAsync(
  172. "SELECT COUNT(1) FROM (SELECT PurOrd,PurLine FROM MissedPrint WHERE IFNULL(PurOrd,'')<>'' GROUP BY PurOrd,PurLine HAVING COUNT(DISTINCT tenant_id)>1) x");
  173. var dupShipper = await db.Ado.GetIntAsync(
  174. "SELECT COUNT(1) FROM (SELECT ShipperNbr FROM MissedPrint WHERE IFNULL(ShipperNbr,'')<>'' GROUP BY ShipperNbr HAVING COUNT(DISTINCT tenant_id)>1) x");
  175. var dupItem = await db.Ado.GetIntAsync(
  176. "SELECT COUNT(1) FROM (SELECT ItemNum FROM ItemMaster GROUP BY ItemNum HAVING COUNT(DISTINCT tenant_id)>1) x");
  177. _out.WriteLine($"跨租户重复:MissedPrint(PurOrd,PurLine)={dupMp} ShipperNbr={dupShipper} ItemMaster.ItemNum={dupItem}");
  178. // 这些业务键没有任何**不含 tenant_id 的**唯一索引 —— 即它们全局不唯一,
  179. // 所以只按业务键关联必然可能跨租户串数,必须显式带 tenant 条件。
  180. // (ItemMaster 有 uk_ItemMaster_factory_item = (tenant_id, factory_ref_id, ItemNum),
  181. // 恰恰说明 ItemNum 只在租户内唯一。)
  182. var globallyUniqueIdx = await db.Ado.SqlQueryAsync<string>(
  183. """
  184. SELECT CONCAT(s.TABLE_NAME, '.', s.INDEX_NAME)
  185. FROM information_schema.STATISTICS s
  186. WHERE s.TABLE_SCHEMA = DATABASE() AND s.NON_UNIQUE = 0
  187. AND ((s.TABLE_NAME='MissedPrint' AND s.COLUMN_NAME IN ('ShipperNbr','PurOrd'))
  188. OR (s.TABLE_NAME='ItemMaster' AND s.COLUMN_NAME = 'ItemNum')
  189. OR (s.TABLE_NAME='WorkOrdMaster' AND s.COLUMN_NAME = 'WorkOrd'))
  190. AND NOT EXISTS (
  191. SELECT 1 FROM information_schema.STATISTICS t
  192. WHERE t.TABLE_SCHEMA = s.TABLE_SCHEMA AND t.TABLE_NAME = s.TABLE_NAME
  193. AND t.INDEX_NAME = s.INDEX_NAME AND t.COLUMN_NAME = 'tenant_id')
  194. GROUP BY s.TABLE_NAME, s.INDEX_NAME
  195. """);
  196. _out.WriteLine($"不含 tenant_id 的唯一索引:{(globallyUniqueIdx.Count == 0 ? "无" : string.Join(",", globallyUniqueIdx))}");
  197. Assert.Empty(globallyUniqueIdx);
  198. // ItemNum 已经跨租户重名 —— 这条是「不能只按业务键关联」的现网证据
  199. Assert.True(dupItem > 0, "ItemMaster.ItemNum 若已全局唯一,需重新评估本组结论");
  200. }
  201. // ===================== §17 回归:008 主链未被本批破坏 =====================
  202. [Fact]
  203. public async Task Regression_LabelQueryTenantSplit_Unchanged()
  204. {
  205. if (!Enabled) return;
  206. var db = BuildAidopdev();
  207. Assert.NotNull(db);
  208. var total = await db!.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
  209. var nullTenant = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id IS NULL");
  210. var uat = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T",
  211. new List<SugarParameter> { new("@T", TenantA) });
  212. var aidop = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T",
  213. new List<SugarParameter> { new("@T", TenantB) });
  214. _out.WriteLine($"MissedPrint total={total} null={nullTenant} UAT={uat} 797={aidop}");
  215. Assert.Equal(0, nullTenant);
  216. Assert.Equal(total, uat + aidop);
  217. }
  218. }