|
|
@@ -0,0 +1,147 @@
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8;
|
|
|
+using System.Reflection;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:Scheduler 多租户执行边界守卫。
|
|
|
+///
|
|
|
+/// <para>背景(2026-09-02 实测事故):Watch Scheduler 是**平台后台任务**,
|
|
|
+/// <c>ListEnabledScopesAsync</c> 按设计遍历全部「有启用规则且 Status=1」的租户。
|
|
|
+/// 该遍历本身合法,但当时链路上有三个缺口,使得一次 tick 会跨租户串味:</para>
|
|
|
+/// <list type="number">
|
|
|
+/// <item>执行入口 <c>RunSingleRuleAsync</c> 只按 <c>lease.RuleId</c> 装载规则,不校验归属;</item>
|
|
|
+/// <item>自动建单算 SLA 的 <c>ResolveSlaDeadlineAsync</c> 用 <c>ClearFilter()</c> + 仅 TypeCode 等值
|
|
|
+/// + <c>ORDER BY FactoryId DESC</c>,在**全库**范围挑「factory_id 最大」的那行;</item>
|
|
|
+/// <item>跨租户扫描的超时升级 Job 按 TypeCode 建全局 map,会把 B 租户的
|
|
|
+/// <c>escalate_role_code</c> 用到 A 租户的异常上。</item>
|
|
|
+/// </list>
|
|
|
+///
|
|
|
+/// <para>本测试锁住修复后的口径:**遍历可以跨租户,但每条规则的执行必须隔离在自己的
|
|
|
+/// trusted (TenantId, FactoryId) 里**,且下游按作用域取配置。断言走源码扫描,
|
|
|
+/// 因为这些约束落在方法体的查询谓词上,反射看不到。</para>
|
|
|
+/// </summary>
|
|
|
+public class S8SchedulerScopeGuardTests
|
|
|
+{
|
|
|
+ private static readonly string PluginRoot =
|
|
|
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
|
|
|
+
|
|
|
+ private const string SchedulerSrc = "Service/S8/S8WatchSchedulerService.cs";
|
|
|
+ private const string ManualReportSrc = "Service/S8/S8ManualReportService.cs";
|
|
|
+ private const string EscalationSrc = "Service/S8/S8TimeoutAutoEscalationService.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 string CodeOnly(string relativePath) =>
|
|
|
+ string.Join('\n', ReadSource(relativePath)
|
|
|
+ .Split('\n')
|
|
|
+ .Select(l => l.Trim())
|
|
|
+ .Where(l => !l.StartsWith("///", StringComparison.Ordinal)
|
|
|
+ && !l.StartsWith("//", StringComparison.Ordinal)));
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 缺口 1:执行入口必须按本次 tick 的 scope 绑行,并对归属不符 fail-fast。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void RunSingleRule_BindsRuleToTickScope_AndFailsFastOnMismatch()
|
|
|
+ {
|
|
|
+ var code = CodeOnly(SchedulerSrc);
|
|
|
+
|
|
|
+ // 装载规则时必须同时带 TenantId / FactoryId 谓词,禁止裸 Id 装载。
|
|
|
+ Assert.Contains("x.Id == lease.RuleId && x.TenantId == tenantId && x.FactoryId == factoryId", code);
|
|
|
+
|
|
|
+ // 归属复核 + 明确的失败原因码;不得静默继续执行。
|
|
|
+ Assert.Contains("rule_scope_mismatch", code);
|
|
|
+ Assert.Contains("rule.TenantId != tenantId || rule.FactoryId != factoryId", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 缺口 2:SLA 解析必须接收显式作用域,且查询带 (租户命中 OR 0) AND (工厂命中 OR 0)。
|
|
|
+ /// 签名用反射钉住,避免有人新增无作用域重载。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void ResolveSlaDeadline_RequiresExplicitTenantAndFactory()
|
|
|
+ {
|
|
|
+ var methods = typeof(S8ManualReportService)
|
|
|
+ .GetMethods(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
|
|
|
+ .Where(m => m.Name == "ResolveSlaDeadlineAsync")
|
|
|
+ .ToList();
|
|
|
+
|
|
|
+ Assert.NotEmpty(methods);
|
|
|
+ Assert.All(methods, m =>
|
|
|
+ {
|
|
|
+ var p = m.GetParameters();
|
|
|
+ Assert.True(p.Length >= 2, "ResolveSlaDeadlineAsync 必须显式接收 tenantId / factoryId");
|
|
|
+ Assert.Equal(typeof(long), p[0].ParameterType);
|
|
|
+ Assert.Equal(typeof(long), p[1].ParameterType);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>缺口 2:SLA 查询谓词本身必须带作用域,不能只靠调用方传参而查询里不用。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void ResolveSlaDeadline_QueryCarriesScopePredicate()
|
|
|
+ {
|
|
|
+ var code = CodeOnly(ManualReportSrc);
|
|
|
+ Assert.Contains("(t.TenantId == tenantId || t.TenantId == 0)", code);
|
|
|
+ Assert.Contains("(t.FactoryId == factoryId || t.FactoryId == 0)", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 缺口 3:跨租户扫描的超时升级 Job,异常类型必须按**每条异常自身**的作用域解析,
|
|
|
+ /// 不得再用「按 TypeCode 建全局 map + 取 factory_id 最大」的写法。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void TimeoutAutoEscalation_ResolvesExceptionTypePerRowScope()
|
|
|
+ {
|
|
|
+ var code = CodeOnly(EscalationSrc);
|
|
|
+
|
|
|
+ // 旧写法已移除:全局 typeMap 按 TypeCode 直接取用。
|
|
|
+ Assert.DoesNotContain("typeMap.TryGetValue", code);
|
|
|
+
|
|
|
+ // 新写法:逐异常按 (TenantId, FactoryId) 解析。
|
|
|
+ Assert.Contains("ResolveTypeForScope", code);
|
|
|
+ Assert.Contains("t.TenantId == tenantId || t.TenantId == 0", code);
|
|
|
+ Assert.Contains("t.FactoryId == factoryId || t.FactoryId == 0", code);
|
|
|
+ Assert.Contains("ResolveTypeForScope(e.ExceptionTypeCode!, e.TenantId, e.FactoryId)", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 遍历入口本身保持「平台后台任务」语义:按租户枚举 + 只取 Status=1 的租户,
|
|
|
+ /// 但每个 scope 必须带 tenant_id / factory_id 两个维度(不能只按租户)。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void EnabledScopeEnumeration_IsTenantAndFactoryPaired()
|
|
|
+ {
|
|
|
+ var code = CodeOnly(SchedulerSrc);
|
|
|
+ Assert.Contains("SELECT DISTINCT r.tenant_id AS TenantId, r.factory_id AS FactoryId", code);
|
|
|
+ Assert.Contains("INNER JOIN SysTenant t ON t.Id = r.tenant_id AND t.Status = 1", code);
|
|
|
+ Assert.Contains("AND r.tenant_id > 0", code);
|
|
|
+ Assert.Contains("AND r.factory_id > 0", code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 下游写入口必须继续以显式 (tenantId, factoryId) 承接,不允许退回从 rule / 客户端推导。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void DownstreamWriters_KeepExplicitScopeParameters()
|
|
|
+ {
|
|
|
+ var pick = typeof(S8WatchSchedulerService).GetMethod(
|
|
|
+ nameof(S8WatchSchedulerService.PickReadyRulesAsync));
|
|
|
+ Assert.NotNull(pick);
|
|
|
+ Assert.Equal(typeof(long), pick!.GetParameters()[0].ParameterType);
|
|
|
+ Assert.Equal(typeof(long), pick.GetParameters()[1].ParameterType);
|
|
|
+
|
|
|
+ var run = typeof(S8WatchSchedulerService).GetMethod(
|
|
|
+ nameof(S8WatchSchedulerService.RunSingleRuleAsync));
|
|
|
+ Assert.NotNull(run);
|
|
|
+ Assert.Equal(typeof(long), run!.GetParameters()[0].ParameterType);
|
|
|
+ Assert.Equal(typeof(long), run.GetParameters()[1].ParameterType);
|
|
|
+ }
|
|
|
+}
|