| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243 |
- using System.Reflection;
- using System.Text.RegularExpressions;
- using Microsoft.AspNetCore.Authorization;
- using Admin.NET.Plugin.AiDOP.DataPlatform.Wms;
- using Admin.NET.Plugin.AiDOP.ProcurementExecution;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S5.ProcurementExecution;
- /// <summary>
- /// S5 供应商链 P0 租户安全守卫(§14 A/B/C/D/E/F 的静态部分)。
- /// <para>
- /// 纯反射 + 源码文本断言,不连库、不起服务、不执行任何写路径,普通 CI 也会执行(无 AIDOP_IT 门禁)。
- /// 数据面断言见 <c>SupplierTenantSecurityDataTests</c>(需 AIDOP_IT=1,全程只读)。
- /// </para>
- /// <para>
- /// 背景(实测):<c>SupplierDeliveryManagementService</c> 与 <c>SupplierShortageKanbanService</c>
- /// 原为类级 <c>[AllowAnonymous]</c> + <c>AidopTenantHelper.Resolve(App.HttpContext)</c>
- /// (解析链含 query 参数 tenantId 与 DefaultTenantId 兜底)。匿名 GET
- /// <c>supplier-delivery/list?tenantId=<任意租户></c> 返回该租户业务数据(797→94 条、UAT→7 条);
- /// 而 <c>supplier-shortage-kanban/refresh</c> 的两条 DELETE 当时不带 WHERE,会清空全部租户的欠料汇总。
- /// </para>
- /// </summary>
- public class SupplierTenantSecurityGuardTests
- {
- private static readonly string PluginRoot =
- Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
- private const string DeliverySrc = "ProcurementExecution/SupplierDeliveryManagementService.cs";
- private const string ShortageSrc = "ProcurementExecution/SupplierShortageKanbanService.cs";
- private const string PushSrc = "DataPlatform/Wms/PurOrdWmsPushService.cs";
- private static string ReadSource(string relativePath)
- {
- var full = Path.Combine(PluginRoot, relativePath);
- Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
- return File.ReadAllText(full);
- }
- /// <summary>只取可执行代码行:类注释里为留档会提到旧写法的名字,不应算违规。</summary>
- private static List<string> CodeLines(string relativePath) =>
- ReadSource(relativePath)
- .Split('\n')
- .Select(l => l.Trim())
- .Where(l => !l.StartsWith("///", StringComparison.Ordinal) && !l.StartsWith("//", StringComparison.Ordinal))
- .ToList();
- // ===================== A / B:认证与 Tenant 来源 =====================
- [Theory]
- [InlineData(typeof(SupplierDeliveryManagementService))]
- [InlineData(typeof(SupplierShortageKanbanService))]
- [InlineData(typeof(PurOrdWmsPushService))]
- public void Service_MustNotBeMarkedAllowAnonymous(Type serviceType)
- {
- Assert.Null(serviceType.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true));
- }
- [Theory]
- [InlineData(typeof(SupplierDeliveryManagementService))]
- [InlineData(typeof(SupplierShortageKanbanService))]
- [InlineData(typeof(PurOrdWmsPushService))]
- public void NoEndpoint_MayBeMarkedAllowAnonymous(Type serviceType)
- {
- var offenders = serviceType
- .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
- .Where(m => m.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true) != null)
- .Select(m => m.Name)
- .ToList();
- Assert.True(offenders.Count == 0, $"以下端点仍标注了 AllowAnonymous:{string.Join(", ", offenders)}");
- }
- /// <summary>
- /// AidopTenantHelper.Resolve 会读 query 参数 tenantId 且有 DefaultTenantId 兜底
- /// (其自身注释即写明不得用于业务读写),三个服务必须一律走 AidopTenantScope.ResolveOrThrow。
- /// </summary>
- [Theory]
- [InlineData(DeliverySrc)]
- [InlineData(ShortageSrc)]
- [InlineData(PushSrc)]
- public void Service_MustNotUseUnsafeTenantHelper(string relativePath)
- {
- var offenders = CodeLines(relativePath)
- .Where(l => l.Contains("AidopTenantHelper", StringComparison.Ordinal))
- .ToList();
- Assert.True(offenders.Count == 0, $"{relativePath} 仍在用不安全的租户解析:{string.Join(" | ", offenders)}");
- }
- /// <summary>每个公开端点方法体内都必须出现可信租户解析,不允许有"忘了加"的端点。</summary>
- [Theory]
- [InlineData(DeliverySrc, 5)]
- [InlineData(ShortageSrc, 4)]
- public void EveryEndpoint_ResolvesTenantFromTrustedContext(string relativePath, int expectedCount)
- {
- var count = CodeLines(relativePath)
- .Count(l => l.Contains("AidopTenantScope.ResolveOrThrow(_userManager)", StringComparison.Ordinal));
- Assert.Equal(expectedCount, count);
- }
- /// <summary>
- /// 请求体里的 TenantId 只能当断言用,不得据其切租户。
- /// </summary>
- [Fact]
- public void PurOrdWmsPush_MustDeriveTenantFromJwt_NotFromRequestBody()
- {
- var lines = CodeLines(PushSrc);
- Assert.Contains(lines, l => l.Contains("var tenantId = AidopTenantScope.ResolveOrThrow(_userManager);", StringComparison.Ordinal));
- Assert.Contains(lines, l => l.Contains("input.TenantId != tenantId", StringComparison.Ordinal));
- // 不得再把请求体的 TenantId 直接传进业务链
- Assert.DoesNotContain(lines, l => l.Contains("EnqueueShipmentChainAsync(input.TenantId", StringComparison.Ordinal));
- Assert.DoesNotContain(lines, l => l.Contains("EnqueuePurchaseOrderAsync(input.TenantId", StringComparison.Ordinal));
- }
- // ===================== C:DELETE tenant scope =====================
- /// <summary>
- /// Refresh 里绝不允许出现无 WHERE 的整表 DELETE / TRUNCATE:
- /// 原写法 `DELETE FROM WorkOrdDetailTotal` 会清空全部租户,随后只重建被指定的那个租户。
- /// </summary>
- [Fact]
- public void ShortageRefresh_MustNotContainUnscopedDeleteOrTruncate()
- {
- var src = ReadSource(ShortageSrc);
- foreach (var table in new[] { "WorkOrdDetailTotal", "WorkOrdDetailTotalKB" })
- {
- Assert.Contains($"DELETE FROM {table} WHERE tenant_id = @TenantId", src, StringComparison.Ordinal);
- }
- // 注释里会留档旧写法的关键词,因此只扫可执行代码
- var code = string.Join("\n", CodeLines(ShortageSrc));
- // 任何 "DELETE FROM <表>" 后面若没跟 WHERE 就是整表删
- // \b 必不可少:没有它,\w+ 会回溯成更短的前缀让负向先行断言假性通过
- var unscoped = Regex.Matches(code, @"DELETE\s+FROM\s+\w+\b(?!\s+WHERE)", RegexOptions.IgnoreCase)
- .Select(m => m.Value.Trim())
- .ToList();
- Assert.True(unscoped.Count == 0, $"存在无 WHERE 的整表 DELETE:{string.Join(" | ", unscoped)}");
- // TRUNCATE 天然无法带条件,一律不允许
- Assert.DoesNotContain("TRUNCATE", code, StringComparison.OrdinalIgnoreCase);
- // DROP 会打断并发的另一个租户
- Assert.DoesNotContain("DROP TABLE", code, StringComparison.OrdinalIgnoreCase);
- }
- // ===================== D:UPDATE tenant scope =====================
- /// <summary>
- /// 每条 `UPDATE WorkOrdDetailTotal*` 的目标侧都必须带 tenant_id;
- /// 只靠 ItemNum / WorkOrd / OrdDate 关联是不够的(这些业务键无全局唯一约束)。
- /// </summary>
- [Fact]
- public void ShortageRefresh_EveryUpdate_TargetsOwnTenantOnly()
- {
- var src = ReadSource(ShortageSrc);
- var offenders = new List<string>();
- foreach (Match m in Regex.Matches(src, @"UPDATE\s+WorkOrdDetailTotal(KB)?\b", RegexOptions.IgnoreCase))
- {
- // 取该 UPDATE 语句块:到下一个 `"""` 结束符为止
- var end = src.IndexOf("\"\"\"", m.Index, StringComparison.Ordinal);
- var stmt = end > m.Index ? src[m.Index..end] : src[m.Index..];
- var hasTenant = stmt.Contains("tenant_id = @TenantId", StringComparison.Ordinal)
- || stmt.Contains("tenant_id = p0.tenant_id", StringComparison.Ordinal);
- if (!hasTenant)
- offenders.Add(stmt.Split('\n')[0].Trim() + " …");
- }
- Assert.True(offenders.Count == 0, $"以下 UPDATE 目标侧缺租户条件:{string.Join(" | ", offenders)}");
- }
- // ===================== E:MissedPrint 聚合 tenant scope =====================
- /// <summary>
- /// 在检数量 zjsl 只能统计当前租户的 MissedPrint。
- /// 同一语句内的 s0(scm_shdzb)/ Do0(PurOrdRctDetail)本就带 tenant_id = p0.tenant_id,
- /// 独 i0(MissedPrint)漏掉 —— 本用例把这条口径钉死。
- /// </summary>
- [Fact]
- public void ShortageRefresh_MissedPrintAggregate_MustBeTenantScoped()
- {
- var src = ReadSource(ShortageSrc);
- var i = src.IndexOf("FROM MissedPrint m", StringComparison.Ordinal);
- Assert.True(i >= 0, "找不到 MissedPrint 聚合锚点");
- var end = src.IndexOf(") i0 ON TRUE", i, StringComparison.Ordinal);
- Assert.True(end > i, "找不到 i0 LATERAL 结束锚点");
- var stmt = src[i..end];
- Assert.Contains("m.tenant_id = p0.tenant_id", stmt, StringComparison.Ordinal);
- // 且 p0 侧必须被钉到当前租户,否则上面那条等式没有意义
- Assert.Contains("p0.tenant_id = @TenantId", src, StringComparison.Ordinal);
- }
- // ===================== F:PurOrdWmsPush 的 MissedPrint 取数 =====================
- /// <summary>
- /// 送货单号 ShipperNbr 在库中无唯一索引,不能只靠它收敛箱码;
- /// 该方法本就有可信的 tenantId 传入(TryEnqueueShipmentChainSafeAsync(tenantId, shddh)),必须用上。
- /// </summary>
- [Fact]
- public void PurOrdWmsPush_BarcodeSelect_MustBeTenantScoped()
- {
- var src = ReadSource(PushSrc);
- var i = src.IndexOf("FROM MissedPrint", StringComparison.Ordinal);
- Assert.True(i >= 0, "找不到 MissedPrint 取数锚点");
- var end = src.IndexOf("ORDER BY ShipperLine", i, StringComparison.Ordinal);
- Assert.True(end > i, "找不到结束锚点");
- var stmt = src[i..end];
- Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal);
- // 原有业务语义不得被削弱
- Assert.Contains("IFNULL(Status, '') = 'U'", stmt, StringComparison.Ordinal);
- Assert.Contains("NOT LIKE '作废%'", stmt, StringComparison.Ordinal);
- }
- // ===================== §9 Domain resolver 归口 =====================
- /// <summary>
- /// 欠料看板不得再用「全局取第一条 CompanyCode」+ 硬编码回落,必须按当前租户解析且 fail closed。
- /// </summary>
- [Fact]
- public void ShortageRefresh_DomainResolver_MustBeTenantScopedAndFailClosed()
- {
- var lines = CodeLines(ShortageSrc);
- Assert.Contains(lines, l => l.Contains("_domainTenant.ResolveDomainAsync(", StringComparison.Ordinal));
- var offenders = lines
- .Where(l => l.Contains("FldName='SystemConfig'", StringComparison.Ordinal)
- || l.Contains("\"1001\"", StringComparison.Ordinal)
- || l.Contains("\"8010\"", StringComparison.Ordinal))
- .ToList();
- Assert.True(offenders.Count == 0, $"仍存在 global-first / 硬编码 Domain 回落:{string.Join(" | ", offenders)}");
- }
- // ===================== 回归:上一批已收口的邻居 =====================
- [Theory]
- [InlineData(typeof(SupplierShipmentService))]
- public void PreviouslyClosedSiblings_MustStayClosed(Type serviceType)
- {
- Assert.Null(serviceType.GetCustomAttribute<AllowAnonymousAttribute>(inherit: true));
- }
- }
|