S8MasterDataScopeGuardTests.cs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using Admin.NET.Plugin.AiDOP.Service.S8;
  4. using System.Reflection;
  5. using Xunit;
  6. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  7. /// <summary>
  8. /// S8-P0-3-MASTERDATA-TRUSTED-SCOPE-1:S8 主数据 / 操作员绑定的跨租户越权守卫。
  9. ///
  10. /// <para>背景(2026-09-02 以普通租户账号 UATExceptionA 实测,HTTP 200):</para>
  11. /// <list type="bullet">
  12. /// <item><c>GET /api/aidop/s8/config/operator-bindings</c>(不带任何参数)→ 500 行,
  13. /// 按工厂分布 {1000: 498, 1329900200002: 2},**全部属于其它租户,本租户 0 行**;</item>
  14. /// <item><c>GET /api/aidop/s8/config/operator-bindings?factoryRefId=1000</c> → 500 条外租户员工;</item>
  15. /// <item><c>GET /api/aidop/s8/master-data/employees?factoryRefId=1000</c> → 500 条外租户员工工号 + 真实姓名。</item>
  16. /// </list>
  17. ///
  18. /// <para>根因:两个控制器都未接入 <see cref="S8TrustedScopeResolver"/>(KNOWN-ISSUES I-001 的 6 个之二),
  19. /// 服务层用 <c>ClearFilter()</c> + **客户端传入的** <c>factoryRefId</c> 作边界,
  20. /// 且 <c>ListAsync</c> 的该谓词还是 <c>WhereIF(factoryRefId.HasValue, …)</c> —— 可选,不传即无边界。</para>
  21. ///
  22. /// <para>本测试锁住修复后的口径:作用域只能来自服务端认证身份;客户端 factoryRefId / tenantId
  23. /// 不得出现在任何 Action 或 Service 签名上,杜绝「以后有人又把它接回去」。</para>
  24. /// </summary>
  25. public class S8MasterDataScopeGuardTests
  26. {
  27. private static readonly string PluginRoot =
  28. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  29. private const string MasterDataSrc = "Service/S8/S8MasterDataAdapter.cs";
  30. private static string ReadSource(string relativePath)
  31. {
  32. var full = Path.Combine(PluginRoot, relativePath);
  33. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  34. return File.ReadAllText(full);
  35. }
  36. /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
  37. private static List<string> CodeLines(string relativePath) =>
  38. ReadSource(relativePath)
  39. .Split('\n')
  40. .Select(l => l.Trim())
  41. .Where(l => !l.StartsWith("///", StringComparison.Ordinal)
  42. && !l.StartsWith("//", StringComparison.Ordinal))
  43. .ToList();
  44. private static IEnumerable<MethodInfo> ActionsOf(Type controller) =>
  45. controller.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly);
  46. // ───────────────────────── 控制器层 ─────────────────────────
  47. /// <summary>
  48. /// 仍在服务的控制器必须从可信作用域取租户。
  49. ///
  50. /// <para><c>AdoS8ConfigBindingsController</c> 已整体退役(全部 Action 返回 410),
  51. /// 不再注入任何服务、也不再解析作用域 —— 一个不查库、不写库的墓碑入口
  52. /// 要作用域解析器没有意义。它的守卫改由 <see cref="OperatorBinding_IsRetired"/> 承担。</para>
  53. /// </summary>
  54. [Theory]
  55. [InlineData(typeof(AdoS8MasterDataController))]
  56. public void Controller_InjectsTrustedScopeResolver(Type controller)
  57. {
  58. var ctor = controller.GetConstructors().Single();
  59. Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(S8TrustedScopeResolver));
  60. }
  61. /// <summary>客户端不得再通过 query / route 指定作用域:这两个名字必须从 Action 签名彻底消失。</summary>
  62. [Theory]
  63. [InlineData(typeof(AdoS8ConfigBindingsController))]
  64. [InlineData(typeof(AdoS8MasterDataController))]
  65. public void ControllerActions_DoNotAcceptClientSuppliedScope(Type controller)
  66. {
  67. foreach (var action in ActionsOf(controller))
  68. {
  69. foreach (var p in action.GetParameters())
  70. {
  71. Assert.False(
  72. string.Equals(p.Name, "factoryRefId", StringComparison.OrdinalIgnoreCase)
  73. || string.Equals(p.Name, "factoryId", StringComparison.OrdinalIgnoreCase)
  74. || string.Equals(p.Name, "tenantId", StringComparison.OrdinalIgnoreCase),
  75. $"{controller.Name}.{action.Name} 仍接受客户端作用域参数 {p.Name}");
  76. }
  77. }
  78. }
  79. // ───────────────────────── 服务层签名 ─────────────────────────
  80. /// <summary>五个入口必须以 S8TrustedScope 作首参,且不得再出现 factoryRefId 形参。</summary>
  81. [Theory]
  82. [InlineData(typeof(S8MasterDataAdapter), "GetDepartmentsAsync")]
  83. // S8-SYSUSER-ONLY-1:GetEmployeesAsync → GetOperatorUsersAsync(员工下拉换成账号下拉);
  84. // S8OperatorBindingService 四个入口随该服务一并退役,见 OperatorBinding_IsRetired。
  85. [InlineData(typeof(S8MasterDataAdapter), "GetOperatorUsersAsync")]
  86. [InlineData(typeof(S8MasterDataAdapter), "GetLinesAsync")]
  87. public void ServiceEntryPoints_TakeTrustedScopeAndRejectClientFactory(Type service, string method)
  88. {
  89. var m = service.GetMethod(method, BindingFlags.Instance | BindingFlags.Public);
  90. Assert.NotNull(m);
  91. var ps = m!.GetParameters();
  92. Assert.NotEmpty(ps);
  93. Assert.Equal(typeof(S8TrustedScope), ps[0].ParameterType);
  94. Assert.DoesNotContain(ps, p =>
  95. string.Equals(p.Name, "factoryRefId", StringComparison.OrdinalIgnoreCase)
  96. || string.Equals(p.Name, "factoryId", StringComparison.OrdinalIgnoreCase)
  97. || string.Equals(p.Name, "tenantId", StringComparison.OrdinalIgnoreCase));
  98. }
  99. // ───────────────────────── 服务层查询谓词 ─────────────────────────
  100. /// <summary>
  101. /// S8-SYSUSER-ONLY-1:绑定服务整体退役,原先三条「ClearFilter 必须收口」「SysUser 查询必须带租户」
  102. /// 「绑定/解绑必须按作用域绑行」的断言合并为一条 —— <b>这个写 EmployeeMaster 的写路径不得复活</b>。
  103. ///
  104. /// <para>那三条守的是同一件事:一个能把任意员工绑成本租户处理人的写入口,必须有租户边界。
  105. /// 现在这个入口本身不存在了,守卫的对象也就从「边界写对没有」变成「入口有没有被引回来」。</para>
  106. /// </summary>
  107. [Fact]
  108. public void OperatorBinding_IsRetired()
  109. {
  110. Assert.False(File.Exists(Path.Combine(PluginRoot, "Service", "S8", "S8OperatorBindingService.cs")),
  111. "S8OperatorBindingService 已退役,不得复活");
  112. var src = string.Join('\n', CodeLines("Controllers/S8/AdoS8ConfigBindingsController.cs"));
  113. Assert.Contains("Status410Gone", src);
  114. // 退役后的入口不应该还保有写主数据的能力。
  115. Assert.DoesNotContain("EmployeeMaster", src);
  116. Assert.DoesNotContain("_empRep", src);
  117. }
  118. /// <summary>主数据三个入口的边界必须来自 scope 的租户,不得来自入参、更不得退回工厂。</summary>
  119. [Fact]
  120. public void MasterData_ScopeComesFromTrustedScopeOnly()
  121. {
  122. var src = string.Join('\n', CodeLines(MasterDataSrc));
  123. Assert.Contains("x.TenantId == scope.TenantId", src);
  124. Assert.Contains("u.TenantId == scope.TenantId", src);
  125. Assert.DoesNotContain("x.FactoryRefId == scope.FactoryId", src);
  126. // 客户端参数写法必须消失。
  127. Assert.DoesNotContain("factoryRefId!.Value", src);
  128. Assert.DoesNotContain("factoryRefId.HasValue", src);
  129. }
  130. }