S8SchedulerScopeGuardTests.cs 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. using Admin.NET.Plugin.AiDOP.Infrastructure;
  2. using Admin.NET.Plugin.AiDOP.Service.S8;
  3. using System.Reflection;
  4. using Xunit;
  5. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  6. /// <summary>
  7. /// S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:Scheduler 多租户执行边界守卫。
  8. ///
  9. /// <para>背景(2026-09-02 实测事故):Watch Scheduler 是**平台后台任务**,
  10. /// <c>ListEnabledScopesAsync</c> 按设计遍历全部「有启用规则且 Status=1」的租户。
  11. /// 该遍历本身合法,但当时链路上有三个缺口,使得一次 tick 会跨租户串味:</para>
  12. /// <list type="number">
  13. /// <item>执行入口 <c>RunSingleRuleAsync</c> 只按 <c>lease.RuleId</c> 装载规则,不校验归属;</item>
  14. /// <item>自动建单算 SLA 的 <c>ResolveSlaDeadlineAsync</c> 用 <c>ClearFilter()</c> + 仅 TypeCode 等值
  15. /// + <c>ORDER BY FactoryId DESC</c>,在**全库**范围挑「factory_id 最大」的那行;</item>
  16. /// <item>跨租户扫描的超时升级 Job 按 TypeCode 建全局 map,会把 B 租户的
  17. /// <c>escalate_role_code</c> 用到 A 租户的异常上。</item>
  18. /// </list>
  19. ///
  20. /// <para>本测试锁住修复后的口径:**遍历可以跨租户,但每条规则的执行必须隔离在自己的
  21. /// trusted (TenantId, FactoryId) 里**,且下游按作用域取配置。断言走源码扫描,
  22. /// 因为这些约束落在方法体的查询谓词上,反射看不到。</para>
  23. /// </summary>
  24. public class S8SchedulerScopeGuardTests
  25. {
  26. private static readonly string PluginRoot =
  27. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  28. private const string SchedulerSrc = "Service/S8/S8WatchSchedulerService.cs";
  29. private const string ManualReportSrc = "Service/S8/S8ManualReportService.cs";
  30. private const string EscalationSrc = "Service/S8/S8TimeoutAutoEscalationService.cs";
  31. private static string ReadSource(string relativePath)
  32. {
  33. var full = Path.Combine(PluginRoot, relativePath);
  34. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  35. return File.ReadAllText(full);
  36. }
  37. /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
  38. private static string CodeOnly(string relativePath) =>
  39. string.Join('\n', ReadSource(relativePath)
  40. .Split('\n')
  41. .Select(l => l.Trim())
  42. .Where(l => !l.StartsWith("///", StringComparison.Ordinal)
  43. && !l.StartsWith("//", StringComparison.Ordinal)));
  44. /// <summary>
  45. /// 缺口 1:执行入口必须按本次 tick 的 scope 绑行,并对归属不符 fail-fast。
  46. ///
  47. /// <para><b>S8-TENANT-ONLY-BATCH5</b>:作用域收敛为租户。绑行与归属复核**必须仍在**
  48. /// —— 去掉的只是工厂那一维,不是这道防线本身。同时反向断言工厂谓词不得回归,
  49. /// 否则 Batch 2 供给出来的 factory_id=0 的规则会再次被静默排除在执行之外。</para>
  50. /// </summary>
  51. [Fact]
  52. public void RunSingleRule_BindsRuleToTenantScope_AndFailsFastOnMismatch()
  53. {
  54. var code = CodeOnly(SchedulerSrc);
  55. // 装载规则时必须带 TenantId 谓词,禁止裸 Id 装载。
  56. Assert.Contains("x.Id == lease.RuleId && x.TenantId == tenantId", code);
  57. // 归属复核 + 明确的失败原因码;不得静默继续执行。
  58. Assert.Contains("rule_scope_mismatch", code);
  59. Assert.Contains("rule.TenantId != tenantId", code);
  60. // 反向:工厂不得重新进入规则身份判定。
  61. Assert.DoesNotContain("x.FactoryId == factoryId", code);
  62. Assert.DoesNotContain("rule.FactoryId != factoryId", code);
  63. }
  64. /// <summary>
  65. /// 缺口 2:SLA 解析必须接收显式作用域。
  66. /// S8-TENANT-ONLY-BATCH6:作用域收敛为租户,factoryId 形参一并移除
  67. /// (工厂在真库横跨租户,从来不构成边界;见 S8TenantOnlyExceptionScopeTests)。
  68. /// 签名用反射钉住,避免有人新增无作用域重载。
  69. /// </summary>
  70. [Fact]
  71. public void ResolveSlaDeadline_RequiresExplicitTenant()
  72. {
  73. var methods = typeof(S8ManualReportService)
  74. .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
  75. .Where(m => m.Name == "ResolveSlaDeadlineAsync")
  76. .ToList();
  77. Assert.NotEmpty(methods);
  78. Assert.All(methods, m =>
  79. {
  80. var p = m.GetParameters();
  81. Assert.True(p.Length >= 1, "ResolveSlaDeadlineAsync 必须显式接收 tenantId");
  82. Assert.Equal(typeof(long), p[0].ParameterType);
  83. Assert.Equal("tenantId", p[0].Name);
  84. Assert.DoesNotContain(p, x =>
  85. x.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
  86. });
  87. }
  88. /// <summary>缺口 2:SLA 查询谓词本身必须带作用域,不能只靠调用方传参而查询里不用。</summary>
  89. [Fact]
  90. public void ResolveSlaDeadline_QueryCarriesScopePredicate()
  91. {
  92. var code = CodeOnly(ManualReportSrc);
  93. Assert.Contains("(t.TenantId == tenantId || t.TenantId == S8ConfigScope.GlobalTenantId)", code);
  94. // 工厂谓词不得复活:它会让本批之后(租户行 factory_id = 0)的覆盖行整体失配。
  95. Assert.DoesNotContain("t.FactoryId == factoryId", code);
  96. }
  97. /// <summary>
  98. /// 缺口 3:跨租户扫描的超时升级 Job,异常类型必须按**每条异常自身**的作用域解析,
  99. /// 不得再用「按 TypeCode 建全局 map + 取 factory_id 最大」的写法。
  100. /// </summary>
  101. [Fact]
  102. public void TimeoutAutoEscalation_ResolvesExceptionTypePerRowScope()
  103. {
  104. var code = CodeOnly(EscalationSrc);
  105. // 旧写法已移除:全局 typeMap 按 TypeCode 直接取用。
  106. Assert.DoesNotContain("typeMap.TryGetValue", code);
  107. // 新写法:逐异常按 TenantId 解析(S8-TENANT-ONLY-BATCH6:工厂维度已从判据移除)。
  108. Assert.Contains("ResolveTypeForScope", code);
  109. Assert.Contains("t.TenantId == tenantId || t.TenantId == S8ConfigScope.GlobalTenantId", code);
  110. Assert.Contains("ResolveTypeForScope(e.ExceptionTypeCode!, e.TenantId)", code);
  111. // 工厂判据不得复活:本批之后租户覆盖行的 factory_id 恒为 0,
  112. // 带工厂等值会让「本租户已自定义 escalate_role_code」的异常拿到平台默认值。
  113. Assert.DoesNotContain("t.FactoryId == factoryId", code);
  114. Assert.DoesNotContain("OrderByDescending(t => t.FactoryId)", code);
  115. }
  116. /// <summary>
  117. /// 遍历入口保持「平台后台任务」语义:按租户枚举 + 只取 Status=1 的租户。
  118. ///
  119. /// <para><b>S8-TENANT-ONLY-BATCH5</b>:遍历维度由 (tenant, factory) 改为 tenant。
  120. /// 特别断言 <c>AND r.factory_id &gt; 0</c> **不得回归** —— 它会让 Batch 2 供给出来的
  121. /// factory_id=0 的运行策略永远不被调度,且不报任何错:页面显示已启用,实际从不执行。</para>
  122. /// </summary>
  123. [Fact]
  124. public void EnabledScopeEnumeration_IsTenantOnly()
  125. {
  126. var code = CodeOnly(SchedulerSrc);
  127. Assert.Contains("SELECT DISTINCT r.tenant_id", code);
  128. Assert.Contains("INNER JOIN SysTenant t ON t.Id = r.tenant_id AND t.Status = 1", code);
  129. Assert.Contains("AND r.tenant_id > 0", code);
  130. Assert.DoesNotContain("r.factory_id AS FactoryId", code);
  131. Assert.DoesNotContain("AND r.factory_id > 0", code);
  132. }
  133. /// <summary>
  134. /// 下游写入口必须继续以**显式租户**承接,不允许退回从 rule / 客户端推导。
  135. ///
  136. /// <para><b>S8-TENANT-ONLY-BATCH5</b>:第二个 long 形参(factoryId)已移除。
  137. /// 这里改为断言"第一个形参是 tenantId 且形参里不再出现 factoryId" ——
  138. /// 显式作用域这条要求没有放松,只是维度少了一个。</para>
  139. /// </summary>
  140. [Theory]
  141. [InlineData(nameof(S8WatchSchedulerService.PickReadyRulesAsync))]
  142. [InlineData(nameof(S8WatchSchedulerService.RunSingleRuleAsync))]
  143. [InlineData(nameof(S8WatchSchedulerService.ResetExpiredLeasesAsync))]
  144. [InlineData(nameof(S8WatchSchedulerService.RunDispatchTickAsync))]
  145. [InlineData(nameof(S8WatchSchedulerService.OnRuleCompletedAsync))]
  146. public void DownstreamWriters_KeepExplicitTenantScope(string method)
  147. {
  148. var m = typeof(S8WatchSchedulerService).GetMethod(method);
  149. Assert.NotNull(m);
  150. var ps = m!.GetParameters();
  151. Assert.Equal("tenantId", ps[0].Name);
  152. Assert.Equal(typeof(long), ps[0].ParameterType);
  153. Assert.DoesNotContain(ps, p => p.Name == "factoryId");
  154. }
  155. /// <summary>
  156. /// S8-TENANT-ONLY-BATCH5:dedup_key 不得再含工厂段。
  157. ///
  158. /// <para>这条是本批最关键的防回归点:一旦有人把 <c>:F{factory}</c> 加回去,
  159. /// 历史异常与新命中会分裂成两套身份 —— 旧异常开始 recovery、新键再建一条重复单,
  160. /// 而且这个后果只会在生产跑一段时间之后才显形。</para>
  161. /// </summary>
  162. [Fact]
  163. public void DedupKey_CarriesNoFactorySegment()
  164. {
  165. foreach (var src in new[]
  166. {
  167. "Service/S8/Rules/S8TimeoutRuleEvaluator.cs",
  168. "Service/S8/Rules/S8ShortageRuleEvaluator.cs",
  169. "Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs",
  170. })
  171. {
  172. var code = CodeOnly(src);
  173. Assert.Contains("$\"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}\"", code);
  174. Assert.DoesNotContain(":F{factoryId}", code);
  175. }
  176. }
  177. }