MissedPrintTenantOwnershipGuardTests.cs 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. using System.Reflection;
  2. using Microsoft.AspNetCore.Authorization;
  3. using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  4. using Admin.NET.Plugin.AiDOP.ProcurementExecution;
  5. using Xunit;
  6. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  7. /// <summary>
  8. /// S5 MissedPrint 租户归属 · 源码级守卫。
  9. /// <para>
  10. /// 纯反射 + 源码文本断言,不连库、不起服务,普通 CI 也会执行(无 AIDOP_IT 门禁)。
  11. /// 数据面断言在 <see cref="InventoryDataPagesScopeTests"/>(需 AIDOP_IT=1)。
  12. /// </para>
  13. /// <para>
  14. /// 背景:MissedPrint 由 SupplierShipmentService「生成标签」写入,历史 INSERT 未写 tenant_id,
  15. /// 库中 152 行全为 NULL;同时该服务类级带 <c>[AllowAnonymous]</c> 且用
  16. /// <c>AidopTenantHelper.Resolve</c>(会读 query 参数 tenantId 并有默认租户兜底),
  17. /// 匿名 GET 传 <c>?tenantId=</c> 即可读任意租户数据。本组把修复后的边界固化下来。
  18. /// </para>
  19. /// </summary>
  20. public class MissedPrintTenantOwnershipGuardTests
  21. {
  22. private static readonly string PluginRoot =
  23. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  24. private static string ReadSource(string relativePath)
  25. {
  26. var full = Path.Combine(PluginRoot, relativePath);
  27. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  28. return File.ReadAllText(full);
  29. }
  30. // ---------- A:发货单服务的认证与租户解析边界 ----------
  31. [Theory]
  32. [InlineData(typeof(SupplierShipmentService))]
  33. [InlineData(typeof(LabelQueryService))]
  34. [InlineData(typeof(StocktakeResultService))]
  35. public void ShipmentAndLabelServices_MustNotBeAnonymous(Type serviceType)
  36. {
  37. Assert.Null(serviceType.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true));
  38. var offenders = serviceType
  39. .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
  40. .Where(m => m.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true) != null)
  41. .Select(m => m.Name)
  42. .ToList();
  43. Assert.True(offenders.Count == 0, $"以下端点仍标注了 AllowAnonymous:{string.Join(", ", offenders)}");
  44. }
  45. /// <summary>
  46. /// AidopTenantHelper.Resolve 会读 query 参数并有默认租户兜底(其自身注释即写明不得用于业务读写),
  47. /// 发货单服务必须一律走 AidopTenantScope.ResolveOrThrow(仅认信 JWT、无兜底)。
  48. /// </summary>
  49. [Fact]
  50. public void SupplierShipmentService_MustNotUseUnsafeTenantHelper()
  51. {
  52. var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs");
  53. // 只看可执行代码:类注释里为了留档会提到旧写法的名字,不应算违规
  54. var codeLines = src.Split('\n')
  55. .Select(l => l.Trim())
  56. .Where(l => !l.StartsWith("///", StringComparison.Ordinal) && !l.StartsWith("//", StringComparison.Ordinal))
  57. .ToList();
  58. var offenders = codeLines.Where(l => l.Contains("AidopTenantHelper.Resolve", StringComparison.Ordinal)).ToList();
  59. Assert.True(offenders.Count == 0, $"仍在用不安全的租户解析:{string.Join(" | ", offenders)}");
  60. Assert.Contains(codeLines, l => l.Contains("AidopTenantScope.ResolveOrThrow(_userManager)", StringComparison.Ordinal));
  61. }
  62. // ---------- B:写路径必须写 / 限定 tenant_id ----------
  63. [Fact]
  64. public void MissedPrintInsert_MustWriteTenantId()
  65. {
  66. var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs");
  67. var insert = Slice(src, "INSERT INTO MissedPrint", "FROM scm_shbq s");
  68. Assert.Contains("tenant_id", insert, StringComparison.Ordinal);
  69. Assert.Contains("@TenantId AS tenant_id", insert, StringComparison.Ordinal);
  70. }
  71. /// <summary>
  72. /// 生成标签的取数与关联必须限定租户:scm_shbq 的行、以及 PurOrdDetail / ItemMaster 的富化关联。
  73. /// ItemMaster 的 ItemNum 跨租户重名,不限定会放大插入行数。
  74. /// </summary>
  75. [Fact]
  76. public void MissedPrintInsertSource_MustBeTenantScoped()
  77. {
  78. var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs");
  79. var select = Slice(src, "FROM scm_shbq s", "\"\"\"");
  80. Assert.Contains("i.tenant_id = @TenantId", select, StringComparison.Ordinal);
  81. Assert.Contains("p.tenant_id = @TenantId", select, StringComparison.Ordinal);
  82. Assert.Contains("s.tenant_id = @TenantId", select, StringComparison.Ordinal);
  83. }
  84. /// <summary>
  85. /// 按业务键定位的写语句必须叠加租户:shdh / shddh 在库中无唯一索引。
  86. /// </summary>
  87. [Theory]
  88. [InlineData("DELETE FROM scm_shbq WHERE shdh=@shdh AND tenant_id=@TenantId")]
  89. [InlineData("UPDATE scm_shd SET shzt='待收', state=2, dycs=0 WHERE shddh=@shddh AND tenant_id=@TenantId")]
  90. public void BusinessKeyWrites_MustCarryTenantPredicate(string expected)
  91. {
  92. var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs");
  93. Assert.Contains(expected, src, StringComparison.Ordinal);
  94. }
  95. /// <summary>
  96. /// 作废旧标签的 UPDATE 必须按租户,而不是仅按 Domain —— 797 与 UAT 的 Domain 同为 8010。
  97. /// </summary>
  98. [Fact]
  99. public void MissedPrintVoidUpdate_MustBeTenantScoped()
  100. {
  101. var src = ReadSource("ProcurementExecution/SupplierShipmentService.cs");
  102. var stmt = Slice(src, "UPDATE MissedPrint SET", "Status = 'U';");
  103. Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal);
  104. }
  105. // ---------- C:热监听回写路径 ----------
  106. [Fact]
  107. public void HotWatch_MissedPrintUpdate_MustBeTenantScoped()
  108. {
  109. var src = ReadSource("DataPlatform/HotWatch/MdpHotWatchService.cs");
  110. var stmt = Slice(src, "UPDATE MissedPrint", "\"\"\"");
  111. Assert.Contains("IFNULL(tenant_id, 0) = @TenantId", stmt, StringComparison.Ordinal);
  112. // 租户不可解析时必须跳过,而不是退化成按 Domain 全域改写
  113. Assert.Contains("跳过 MissedPrint 箱码回写", src, StringComparison.Ordinal);
  114. }
  115. // ---------- D:只读列表的租户过滤 ----------
  116. [Fact]
  117. public void LabelQuery_008_MustFilterByTenant()
  118. {
  119. var src = ReadSource("MaterialWarehouse/LabelQueryService.cs");
  120. Assert.Contains("\"m.tenant_id = @TenantId\"", src, StringComparison.Ordinal);
  121. }
  122. [Fact]
  123. public void StocktakeResult_013_MustFilterByTenant()
  124. {
  125. var src = ReadSource("MaterialWarehouse/StocktakeResultService.cs");
  126. Assert.Contains("\"m.tenant_id = @TenantId\"", src, StringComparison.Ordinal);
  127. }
  128. /// <summary>
  129. /// 下拉选项同样是数据出口,不得跨租户抖出别人的库位。
  130. /// </summary>
  131. [Fact]
  132. public void LabelQuery_LocationOptions_MustFilterByTenant()
  133. {
  134. var src = ReadSource("MaterialWarehouse/LabelQueryService.cs");
  135. var stmt = Slice(src, "SELECT DISTINCT Location AS Val", "ORDER BY Location");
  136. Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal);
  137. }
  138. private static string Slice(string src, string from, string to)
  139. {
  140. var i = src.IndexOf(from, StringComparison.Ordinal);
  141. Assert.True(i >= 0, $"源码中找不到锚点:{from}");
  142. var j = src.IndexOf(to, i + from.Length, StringComparison.Ordinal);
  143. Assert.True(j >= 0, $"源码中找不到结束锚点:{to}");
  144. return src.Substring(i, j - i);
  145. }
  146. }