SupplierTenantSecurityGuardTests.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. using System.Reflection;
  2. using System.Text.RegularExpressions;
  3. using Microsoft.AspNetCore.Authorization;
  4. using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
  5. using Admin.NET.Plugin.AiDOP.ProcurementExecution;
  6. using Xunit;
  7. namespace Admin.NET.Plugin.AiDOP.Tests.S5.ProcurementExecution;
  8. /// <summary>
  9. /// S5 供应商链 P0 租户安全守卫(§14 A/B/C/D/E/F 的静态部分)。
  10. /// <para>
  11. /// 纯反射 + 源码文本断言,不连库、不起服务、不执行任何写路径,普通 CI 也会执行(无 AIDOP_IT 门禁)。
  12. /// 数据面断言见 <c>SupplierTenantSecurityDataTests</c>(需 AIDOP_IT=1,全程只读)。
  13. /// </para>
  14. /// <para>
  15. /// 背景(实测):<c>SupplierDeliveryManagementService</c> 与 <c>SupplierShortageKanbanService</c>
  16. /// 原为类级 <c>[AllowAnonymous]</c> + <c>AidopTenantHelper.Resolve(App.HttpContext)</c>
  17. /// (解析链含 query 参数 tenantId 与 DefaultTenantId 兜底)。匿名 GET
  18. /// <c>supplier-delivery/list?tenantId=&lt;任意租户&gt;</c> 返回该租户业务数据(797→94 条、UAT→7 条);
  19. /// 而 <c>supplier-shortage-kanban/refresh</c> 的两条 DELETE 当时不带 WHERE,会清空全部租户的欠料汇总。
  20. /// </para>
  21. /// </summary>
  22. public class SupplierTenantSecurityGuardTests
  23. {
  24. private static readonly string PluginRoot =
  25. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  26. private const string DeliverySrc = "ProcurementExecution/SupplierDeliveryManagementService.cs";
  27. private const string ShortageSrc = "ProcurementExecution/SupplierShortageKanbanService.cs";
  28. private const string PushSrc = "DataPlatform/Wms/PurOrdWmsPushService.cs";
  29. private static string ReadSource(string relativePath)
  30. {
  31. var full = Path.Combine(PluginRoot, relativePath);
  32. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  33. return File.ReadAllText(full);
  34. }
  35. /// <summary>只取可执行代码行:类注释里为留档会提到旧写法的名字,不应算违规。</summary>
  36. private static List<string> CodeLines(string relativePath) =>
  37. ReadSource(relativePath)
  38. .Split('\n')
  39. .Select(l => l.Trim())
  40. .Where(l => !l.StartsWith("///", StringComparison.Ordinal) && !l.StartsWith("//", StringComparison.Ordinal))
  41. .ToList();
  42. // ===================== A / B:认证与 Tenant 来源 =====================
  43. [Theory]
  44. [InlineData(typeof(SupplierDeliveryManagementService))]
  45. [InlineData(typeof(SupplierShortageKanbanService))]
  46. [InlineData(typeof(PurOrdWmsPushService))]
  47. public void Service_MustNotBeMarkedAllowAnonymous(Type serviceType)
  48. {
  49. Assert.Null(serviceType.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true));
  50. }
  51. [Theory]
  52. [InlineData(typeof(SupplierDeliveryManagementService))]
  53. [InlineData(typeof(SupplierShortageKanbanService))]
  54. [InlineData(typeof(PurOrdWmsPushService))]
  55. public void NoEndpoint_MayBeMarkedAllowAnonymous(Type serviceType)
  56. {
  57. var offenders = serviceType
  58. .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
  59. .Where(m => m.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true) != null)
  60. .Select(m => m.Name)
  61. .ToList();
  62. Assert.True(offenders.Count == 0, $"以下端点仍标注了 AllowAnonymous:{string.Join(", ", offenders)}");
  63. }
  64. /// <summary>
  65. /// AidopTenantHelper.Resolve 会读 query 参数 tenantId 且有 DefaultTenantId 兜底
  66. /// (其自身注释即写明不得用于业务读写),三个服务必须一律走 AidopTenantScope.ResolveOrThrow。
  67. /// </summary>
  68. [Theory]
  69. [InlineData(DeliverySrc)]
  70. [InlineData(ShortageSrc)]
  71. [InlineData(PushSrc)]
  72. public void Service_MustNotUseUnsafeTenantHelper(string relativePath)
  73. {
  74. var offenders = CodeLines(relativePath)
  75. .Where(l => l.Contains("AidopTenantHelper", StringComparison.Ordinal))
  76. .ToList();
  77. Assert.True(offenders.Count == 0, $"{relativePath} 仍在用不安全的租户解析:{string.Join(" | ", offenders)}");
  78. }
  79. /// <summary>每个公开端点方法体内都必须出现可信租户解析,不允许有"忘了加"的端点。</summary>
  80. [Theory]
  81. [InlineData(DeliverySrc, 5)]
  82. [InlineData(ShortageSrc, 4)]
  83. public void EveryEndpoint_ResolvesTenantFromTrustedContext(string relativePath, int expectedCount)
  84. {
  85. var count = CodeLines(relativePath)
  86. .Count(l => l.Contains("AidopTenantScope.ResolveOrThrow(_userManager)", StringComparison.Ordinal));
  87. Assert.Equal(expectedCount, count);
  88. }
  89. /// <summary>
  90. /// 请求体里的 TenantId 只能当断言用,不得据其切租户。
  91. /// </summary>
  92. [Fact]
  93. public void PurOrdWmsPush_MustDeriveTenantFromJwt_NotFromRequestBody()
  94. {
  95. var lines = CodeLines(PushSrc);
  96. Assert.Contains(lines, l => l.Contains("var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);", StringComparison.Ordinal));
  97. Assert.Contains(lines, l => l.Contains("input.TenantId != tenantId", StringComparison.Ordinal));
  98. // 不得再把请求体的 TenantId 直接传进业务链
  99. Assert.DoesNotContain(lines, l => l.Contains("EnqueueShipmentChainAsync(input.TenantId", StringComparison.Ordinal));
  100. Assert.DoesNotContain(lines, l => l.Contains("EnqueuePurchaseOrderAsync(input.TenantId", StringComparison.Ordinal));
  101. }
  102. // ===================== C:DELETE tenant scope =====================
  103. /// <summary>
  104. /// Refresh 里绝不允许出现无 WHERE 的整表 DELETE / TRUNCATE:
  105. /// 原写法 `DELETE FROM WorkOrdDetailTotal` 会清空全部租户,随后只重建被指定的那个租户。
  106. /// </summary>
  107. [Fact]
  108. public void ShortageRefresh_MustNotContainUnscopedDeleteOrTruncate()
  109. {
  110. var src = ReadSource(ShortageSrc);
  111. foreach (var table in new[] { "WorkOrdDetailTotal", "WorkOrdDetailTotalKB" })
  112. {
  113. Assert.Contains($"DELETE FROM {table} WHERE tenant_id = @TenantId", src, StringComparison.Ordinal);
  114. }
  115. // 注释里会留档旧写法的关键词,因此只扫可执行代码
  116. var code = string.Join("\n", CodeLines(ShortageSrc));
  117. // 任何 "DELETE FROM <表>" 后面若没跟 WHERE 就是整表删
  118. // \b 必不可少:没有它,\w+ 会回溯成更短的前缀让负向先行断言假性通过
  119. var unscoped = Regex.Matches(code, @"DELETE\s+FROM\s+\w+\b(?!\s+WHERE)", RegexOptions.IgnoreCase)
  120. .Select(m => m.Value.Trim())
  121. .ToList();
  122. Assert.True(unscoped.Count == 0, $"存在无 WHERE 的整表 DELETE:{string.Join(" | ", unscoped)}");
  123. // TRUNCATE 天然无法带条件,一律不允许
  124. Assert.DoesNotContain("TRUNCATE", code, StringComparison.OrdinalIgnoreCase);
  125. // DROP 会打断并发的另一个租户
  126. Assert.DoesNotContain("DROP TABLE", code, StringComparison.OrdinalIgnoreCase);
  127. }
  128. // ===================== D:UPDATE tenant scope =====================
  129. /// <summary>
  130. /// 每条 `UPDATE WorkOrdDetailTotal*` 的目标侧都必须带 tenant_id;
  131. /// 只靠 ItemNum / WorkOrd / OrdDate 关联是不够的(这些业务键无全局唯一约束)。
  132. /// </summary>
  133. [Fact]
  134. public void ShortageRefresh_EveryUpdate_TargetsOwnTenantOnly()
  135. {
  136. var src = ReadSource(ShortageSrc);
  137. var offenders = new List<string>();
  138. foreach (Match m in Regex.Matches(src, @"UPDATE\s+WorkOrdDetailTotal(KB)?\b", RegexOptions.IgnoreCase))
  139. {
  140. // 取该 UPDATE 语句块:到下一个 `"""` 结束符为止
  141. var end = src.IndexOf("\"\"\"", m.Index, StringComparison.Ordinal);
  142. var stmt = end > m.Index ? src[m.Index..end] : src[m.Index..];
  143. var hasTenant = stmt.Contains("tenant_id = @TenantId", StringComparison.Ordinal)
  144. || stmt.Contains("tenant_id = p0.tenant_id", StringComparison.Ordinal);
  145. if (!hasTenant)
  146. offenders.Add(stmt.Split('\n')[0].Trim() + " …");
  147. }
  148. Assert.True(offenders.Count == 0, $"以下 UPDATE 目标侧缺租户条件:{string.Join(" | ", offenders)}");
  149. }
  150. // ===================== E:MissedPrint 聚合 tenant scope =====================
  151. /// <summary>
  152. /// 在检数量 zjsl 只能统计当前租户的 MissedPrint。
  153. /// 同一语句内的 s0(scm_shdzb)/ Do0(PurOrdRctDetail)本就带 tenant_id = p0.tenant_id,
  154. /// 独 i0(MissedPrint)漏掉 —— 本用例把这条口径钉死。
  155. /// </summary>
  156. [Fact]
  157. public void ShortageRefresh_MissedPrintAggregate_MustBeTenantScoped()
  158. {
  159. var src = ReadSource(ShortageSrc);
  160. var i = src.IndexOf("FROM MissedPrint m", StringComparison.Ordinal);
  161. Assert.True(i >= 0, "找不到 MissedPrint 聚合锚点");
  162. var end = src.IndexOf(") i0 ON TRUE", i, StringComparison.Ordinal);
  163. Assert.True(end > i, "找不到 i0 LATERAL 结束锚点");
  164. var stmt = src[i..end];
  165. Assert.Contains("m.tenant_id = p0.tenant_id", stmt, StringComparison.Ordinal);
  166. // 且 p0 侧必须被钉到当前租户,否则上面那条等式没有意义
  167. Assert.Contains("p0.tenant_id = @TenantId", src, StringComparison.Ordinal);
  168. }
  169. // ===================== F:PurOrdWmsPush 的 MissedPrint 取数 =====================
  170. /// <summary>
  171. /// 送货单号 ShipperNbr 在库中无唯一索引,不能只靠它收敛箱码;
  172. /// 该方法本就有可信的 tenantId 传入(TryEnqueueShipmentChainSafeAsync(tenantId, shddh)),必须用上。
  173. /// </summary>
  174. [Fact]
  175. public void PurOrdWmsPush_BarcodeSelect_MustBeTenantScoped()
  176. {
  177. var src = ReadSource(PushSrc);
  178. var i = src.IndexOf("FROM MissedPrint", StringComparison.Ordinal);
  179. Assert.True(i >= 0, "找不到 MissedPrint 取数锚点");
  180. var end = src.IndexOf("ORDER BY ShipperLine", i, StringComparison.Ordinal);
  181. Assert.True(end > i, "找不到结束锚点");
  182. var stmt = src[i..end];
  183. Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal);
  184. // 原有业务语义不得被削弱
  185. Assert.Contains("IFNULL(Status, '') = 'U'", stmt, StringComparison.Ordinal);
  186. Assert.Contains("NOT LIKE '作废%'", stmt, StringComparison.Ordinal);
  187. }
  188. // ===================== §9 Domain resolver 归口 =====================
  189. /// <summary>
  190. /// 欠料看板不得再用「全局取第一条 CompanyCode」+ 硬编码回落,必须按当前租户解析且 fail closed。
  191. /// </summary>
  192. [Fact]
  193. public void ShortageRefresh_DomainResolver_MustBeTenantScopedAndFailClosed()
  194. {
  195. var lines = CodeLines(ShortageSrc);
  196. Assert.Contains(lines, l => l.Contains("_domainTenant.ResolveDomainAsync(", StringComparison.Ordinal));
  197. var offenders = lines
  198. .Where(l => l.Contains("FldName='SystemConfig'", StringComparison.Ordinal)
  199. || l.Contains("\"1001\"", StringComparison.Ordinal)
  200. || l.Contains("\"8010\"", StringComparison.Ordinal))
  201. .ToList();
  202. Assert.True(offenders.Count == 0, $"仍存在 global-first / 硬编码 Domain 回落:{string.Join(" | ", offenders)}");
  203. }
  204. // ===================== 回归:上一批已收口的邻居 =====================
  205. [Theory]
  206. [InlineData(typeof(SupplierShipmentService))]
  207. public void PreviouslyClosedSiblings_MustStayClosed(Type serviceType)
  208. {
  209. Assert.Null(serviceType.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true));
  210. }
  211. }