S8DebugTenantGuardTests.cs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. using Admin.NET.Plugin.AiDOP.Const.S8;
  2. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  5. using System.Reflection;
  6. using System.Text.RegularExpressions;
  7. using Xunit;
  8. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  9. /// <summary>
  10. /// S8-DEBUG-TENANT-GUARD-1:<see cref="AdoS8WatchDebugController"/> 的租户信任边界。
  11. ///
  12. /// <para><b>修复前的事实</b>(2026-09-20 只读审计):本 Controller 是 S8 中唯一把 query 上的
  13. /// <c>tenantId</c> 当执行上下文的入口。<c>run-once</c> 把它直接传给
  14. /// <c>S8WatchSchedulerService.CreateExceptionsAsync(tenantId)</c>,可在<b>任意租户</b>建单,
  15. /// 并从出参读回该租户的 <c>createdExceptionId</c> / <c>relatedObjectCode</c> / <c>matchedExceptionId</c>;
  16. /// <c>test-cleanup</c> 可软删任意租户的 TEST_G09_ 数据。唯一的阻挡是
  17. /// <c>WatchScheduler:DebugEndpointEnabled</c> —— 一个<b>功能开关</b>,不是信任边界。</para>
  18. ///
  19. /// <para><b>为什么是源码契约测试而不是运行时测试</b>:本 Controller 依赖
  20. /// <c>S8WatchSchedulerService</c>(需要 SqlSugar 实库)与 <c>S8TrustedScopeResolver</c>(需要
  21. /// 认证后的 <c>UserManager</c>),在进程内无法构造 —— 与
  22. /// <c>S8ExceptionFlowTenantContextContractTests</c> 同一处境,沿用同一手法:
  23. /// <b>反射元数据 + 去注释后的源码契约</b>。本测试<b>不</b>断言运行时会返回 403/404,
  24. /// 它断言的是「代码里不存在那条跨租户通路」。</para>
  25. ///
  26. /// <para>断言一律在<b>剥离注释后</b>的源码上进行:否则文档里提到 <c>tenantId</c> 就会把
  27. /// 守卫变成噪音,或在有人改文案时假失败。</para>
  28. /// </summary>
  29. public class S8DebugTenantGuardTests
  30. {
  31. private static string PluginRoot => Path.GetFullPath(
  32. Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  33. private static string ReadCode(string relativePath)
  34. {
  35. var raw = File.ReadAllText(Path.Combine(PluginRoot, relativePath));
  36. // 先删块注释,再删行注释(含 /// 文档注释)。字符串字面量里不含 "//",本文件范围内安全。
  37. var noBlock = Regex.Replace(raw, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
  38. return Regex.Replace(noBlock, @"^\s*//.*$", string.Empty, RegexOptions.Multiline);
  39. }
  40. private static string DebugControllerCode => ReadCode("Controllers/S8/AdoS8WatchDebugController.cs");
  41. private static int Count(string haystack, string needle)
  42. {
  43. var n = 0;
  44. for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0;
  45. i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal)) n++;
  46. return n;
  47. }
  48. private static IEnumerable<MethodInfo> DebugActions => typeof(AdoS8WatchDebugController)
  49. .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
  50. .Where(m => !m.IsSpecialName);
  51. // ───────────────── ① 当前租户可正常执行 ─────────────────
  52. /// <summary>
  53. /// 两个 Action 都必须从可信作用域取租户并据此执行。
  54. /// 这既是「当前租户可用」的正面断言,也是租户来源的唯一合法形态。
  55. /// </summary>
  56. [Fact]
  57. public void CurrentTenant_DebugExecution_UsesResolvedScope()
  58. {
  59. var code = DebugControllerCode;
  60. Assert.Equal(2, Count(code, "_scope.ResolveAsync()"));
  61. Assert.Contains("CreateExceptionsAsync(scope.TenantId)", code);
  62. Assert.Contains("SoftDeleteTestPrefixAsync(scope.TenantId)", code);
  63. }
  64. /// <summary>可信作用域解析器必须由构造函数注入 —— 反射元数据,不可被改文案绕过。</summary>
  65. [Fact]
  66. public void Controller_InjectsTrustedScopeResolver()
  67. {
  68. var ctor = Assert.Single(typeof(AdoS8WatchDebugController).GetConstructors());
  69. Assert.Contains(ctor.GetParameters(), p => p.ParameterType == typeof(S8TrustedScopeResolver));
  70. }
  71. // ───────────────── ② 客户端传入的 tenantId 不得切换执行上下文 ─────────────────
  72. /// <summary>
  73. /// query 上的 tenantId / factoryId 只能被显式丢弃,绝不能流入任何 service 调用。
  74. /// </summary>
  75. [Fact]
  76. public void ClientSuppliedOtherTenant_CannotSwitchTenant()
  77. {
  78. var code = DebugControllerCode;
  79. // 兼容形参必须被显式丢弃(run-once 的 tenantId+factoryId、test-cleanup 的 tenantId)。
  80. Assert.Equal(2, Count(code, "_ = tenantId;"));
  81. Assert.Contains("_ = factoryId;", code);
  82. // 修复前的两条跨租户通路,必须不复存在。
  83. Assert.DoesNotContain("CreateExceptionsAsync(tenantId)", code);
  84. Assert.DoesNotContain("SoftDeleteTestPrefixAsync(tenantId", code);
  85. // 不得以任何形式把 query 参数重新当作租户使用。
  86. Assert.DoesNotContain("tenantId.Value", code);
  87. Assert.DoesNotContain("tenantId ??", code);
  88. }
  89. /// <summary>
  90. /// ③/④ 合并断言:读回(run-once 出参)与写入(建单 / 软删)都只能发生在已解析租户上。
  91. /// 两个 service 调用是本 Controller 仅有的数据出入口,全部以 scope.TenantId 为准,
  92. /// 因此不存在「读到别家租户 exception」或「在别家租户建单」的通路。
  93. /// </summary>
  94. [Fact]
  95. public void DebugReadAndWrite_AreConfinedToResolvedTenant()
  96. {
  97. var code = DebugControllerCode;
  98. var serviceCalls = new[] { "CreateExceptionsAsync(", "SoftDeleteTestPrefixAsync(" };
  99. foreach (var call in serviceCalls)
  100. {
  101. Assert.Equal(1, Count(code, call));
  102. var idx = code.IndexOf(call, StringComparison.Ordinal);
  103. var arg = code[(idx + call.Length)..code.IndexOf(')', idx)];
  104. Assert.Equal("scope.TenantId", arg.Trim());
  105. }
  106. }
  107. // ───────────────── ⑤ 未启用时的拒绝语义 ─────────────────
  108. /// <summary>
  109. /// DebugEndpointEnabled=false 时两个 Action 都必须 404,且该门必须在
  110. /// 作用域解析与任何 service 调用<b>之前</b>返回(DB 零触达)。
  111. /// </summary>
  112. [Fact]
  113. public void DebugDisabled_ReturnsNotFound_BeforeAnyWork()
  114. {
  115. var code = DebugControllerCode;
  116. const string gate = "if (!_debugEndpointEnabled) return NotFound();";
  117. Assert.Equal(2, Count(code, gate));
  118. // 逐个 Action 校验顺序:第 n 道门必须早于第 n 次作用域解析。
  119. var gateIdx = -1;
  120. var scopeIdx = -1;
  121. for (var i = 0; i < 2; i++)
  122. {
  123. gateIdx = code.IndexOf(gate, gateIdx + 1, StringComparison.Ordinal);
  124. scopeIdx = code.IndexOf("_scope.ResolveAsync()", scopeIdx + 1, StringComparison.Ordinal);
  125. Assert.True(gateIdx >= 0 && scopeIdx >= 0 && gateIdx < scopeIdx,
  126. $"第 {i + 1} 个 Action 的 DebugEndpointEnabled 门禁必须早于作用域解析。");
  127. }
  128. // 默认值必须是 false(fail-closed):缺配置时不得默认开启。
  129. Assert.Contains("GetValue(\"WatchScheduler:DebugEndpointEnabled\", false)", code);
  130. }
  131. // ───────────────── ⑥ 超管:沿用平台既有策略,不自行放权 ─────────────────
  132. /// <summary>
  133. /// 本 Controller 不得出现任何超管特判 —— 调试入口不是新开全租户权限的地方。
  134. /// 超管口径完全由 <see cref="AidopTenantScope"/> 决定。
  135. /// </summary>
  136. [Fact]
  137. public void SuperAdmin_GetsNoCrossTenantDebugPrivilegeHere()
  138. {
  139. var code = DebugControllerCode;
  140. Assert.DoesNotContain("SuperAdmin", code);
  141. Assert.DoesNotContain("ClearFilter", code);
  142. Assert.DoesNotContain("IgnoreQueryFilters", code);
  143. }
  144. /// <summary>
  145. /// 平台既有超管策略必须仍然是「未选目标租户 → 直接拒绝」(保守拒绝,不默认全租户)。
  146. /// 本批不改这条策略,只确认调试入口继承的是它。
  147. /// </summary>
  148. [Fact]
  149. public void TenantAuthority_StillRejectsSuperAdminWithoutTargetTenant()
  150. {
  151. var authority = ReadCode("Infrastructure/AidopTenantScope.cs");
  152. Assert.Contains("userManager.SuperAdmin && tid == MainTenantId", authority);
  153. Assert.Contains("throw Oops.Oh", authority);
  154. Assert.Contains("if (tid <= 0)", authority);
  155. }
  156. // ───────────────── 单一 authority:不得新增第二套租户解析 ─────────────────
  157. /// <summary>
  158. /// 调试入口必须复用 S8 统一的租户 authority,不得自建第二套
  159. /// (直接读 UserManager / 从配置或请求头取租户都算)。
  160. /// </summary>
  161. [Fact]
  162. public void DebugEndpoint_DoesNotIntroduceSecondTenantAuthority()
  163. {
  164. var code = DebugControllerCode;
  165. Assert.Contains("S8TrustedScopeResolver", code);
  166. Assert.DoesNotContain("UserManager", code);
  167. Assert.DoesNotContain("HttpContext", code);
  168. Assert.DoesNotContain("Request.Headers", code);
  169. }
  170. /// <summary>两个 Action 仍须挂 s8:debug:run 能力门(本批不得顺手放松授权)。</summary>
  171. [Fact]
  172. public void DebugActions_StillRequireDebugRunPermission()
  173. {
  174. var actions = DebugActions.ToList();
  175. Assert.Equal(2, actions.Count);
  176. Assert.All(actions, m =>
  177. {
  178. var attr = m.GetCustomAttribute<S8PermissionAttribute>();
  179. Assert.NotNull(attr);
  180. Assert.Equal(S8PermissionCatalog.DebugRun, attr!.Code);
  181. });
  182. }
  183. }