using Admin.NET.Plugin.AiDOP.Infrastructure;
using Admin.NET.Plugin.AiDOP.Service.S8;
using System.Reflection;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-P0-1-SCHEDULER-TRUSTED-SCOPE-1:Scheduler 多租户执行边界守卫。
///
/// 背景(2026-09-02 实测事故):Watch Scheduler 是**平台后台任务**,
/// ListEnabledScopesAsync 按设计遍历全部「有启用规则且 Status=1」的租户。
/// 该遍历本身合法,但当时链路上有三个缺口,使得一次 tick 会跨租户串味:
///
/// - 执行入口 RunSingleRuleAsync 只按 lease.RuleId 装载规则,不校验归属;
/// - 自动建单算 SLA 的 ResolveSlaDeadlineAsync 用 ClearFilter() + 仅 TypeCode 等值
/// + ORDER BY FactoryId DESC,在**全库**范围挑「factory_id 最大」的那行;
/// - 跨租户扫描的超时升级 Job 按 TypeCode 建全局 map,会把 B 租户的
/// escalate_role_code 用到 A 租户的异常上。
///
///
/// 本测试锁住修复后的口径:**遍历可以跨租户,但每条规则的执行必须隔离在自己的
/// trusted (TenantId, FactoryId) 里**,且下游按作用域取配置。断言走源码扫描,
/// 因为这些约束落在方法体的查询谓词上,反射看不到。
///
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);
}
/// 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。
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)));
///
/// 缺口 1:执行入口必须按本次 tick 的 scope 绑行,并对归属不符 fail-fast。
///
/// S8-TENANT-ONLY-BATCH5:作用域收敛为租户。绑行与归属复核**必须仍在**
/// —— 去掉的只是工厂那一维,不是这道防线本身。同时反向断言工厂谓词不得回归,
/// 否则 Batch 2 供给出来的 factory_id=0 的规则会再次被静默排除在执行之外。
///
[Fact]
public void RunSingleRule_BindsRuleToTenantScope_AndFailsFastOnMismatch()
{
var code = CodeOnly(SchedulerSrc);
// 装载规则时必须带 TenantId 谓词,禁止裸 Id 装载。
Assert.Contains("x.Id == lease.RuleId && x.TenantId == tenantId", code);
// 归属复核 + 明确的失败原因码;不得静默继续执行。
Assert.Contains("rule_scope_mismatch", code);
Assert.Contains("rule.TenantId != tenantId", code);
// 反向:工厂不得重新进入规则身份判定。
Assert.DoesNotContain("x.FactoryId == factoryId", code);
Assert.DoesNotContain("rule.FactoryId != factoryId", code);
}
///
/// 缺口 2:SLA 解析必须接收显式作用域。
/// S8-TENANT-ONLY-BATCH6:作用域收敛为租户,factoryId 形参一并移除
/// (工厂在真库横跨租户,从来不构成边界;见 S8TenantOnlyExceptionScopeTests)。
/// 签名用反射钉住,避免有人新增无作用域重载。
///
[Fact]
public void ResolveSlaDeadline_RequiresExplicitTenant()
{
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 >= 1, "ResolveSlaDeadlineAsync 必须显式接收 tenantId");
Assert.Equal(typeof(long), p[0].ParameterType);
Assert.Equal("tenantId", p[0].Name);
Assert.DoesNotContain(p, x =>
x.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
});
}
/// 缺口 2:SLA 查询谓词本身必须带作用域,不能只靠调用方传参而查询里不用。
[Fact]
public void ResolveSlaDeadline_QueryCarriesScopePredicate()
{
var code = CodeOnly(ManualReportSrc);
Assert.Contains("(t.TenantId == tenantId || t.TenantId == S8ConfigScope.GlobalTenantId)", code);
// 工厂谓词不得复活:它会让本批之后(租户行 factory_id = 0)的覆盖行整体失配。
Assert.DoesNotContain("t.FactoryId == factoryId", code);
}
///
/// 缺口 3:跨租户扫描的超时升级 Job,异常类型必须按**每条异常自身**的作用域解析,
/// 不得再用「按 TypeCode 建全局 map + 取 factory_id 最大」的写法。
///
[Fact]
public void TimeoutAutoEscalation_ResolvesExceptionTypePerRowScope()
{
var code = CodeOnly(EscalationSrc);
// 旧写法已移除:全局 typeMap 按 TypeCode 直接取用。
Assert.DoesNotContain("typeMap.TryGetValue", code);
// S8-RESPONSIBILITY-POOL-1:升级 authority 从 exception_type.escalate_role_code
// 换成**该规则的升级账号池**。本守卫要保护的一直是「不得把 B 租户的配置用到 A 租户的异常上」——
// 现在这条越界路径从根上消失了:升级人取自 (tenant_id, rule_code) 的责任池,
// 而 tenant_id 就是异常自己的租户,不存在"跨租户解析"这个动作。
Assert.DoesNotContain("ResolveTypeForScope", code);
Assert.DoesNotContain("AdoS8ExceptionType", code);
Assert.DoesNotContain("EscalateRoleCode", code);
Assert.Contains("_pools.GetValidMemberIdsAsync(", code);
Assert.Contains("e.TenantId, e.SourceRuleCode!, S8ResponsibilityType.Escalation", code);
// 工厂判据不得复活。
Assert.DoesNotContain("t.FactoryId == factoryId", code);
Assert.DoesNotContain("OrderByDescending(t => t.FactoryId)", code);
}
///
/// 遍历入口保持「平台后台任务」语义:按租户枚举 + 只取 Status=1 的租户。
///
/// S8-TENANT-ONLY-BATCH5:遍历维度由 (tenant, factory) 改为 tenant。
/// 特别断言 AND r.factory_id > 0 **不得回归** —— 它会让 Batch 2 供给出来的
/// factory_id=0 的运行策略永远不被调度,且不报任何错:页面显示已启用,实际从不执行。
///
[Fact]
public void EnabledScopeEnumeration_IsTenantOnly()
{
var code = CodeOnly(SchedulerSrc);
Assert.Contains("SELECT DISTINCT r.tenant_id", 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.DoesNotContain("r.factory_id AS FactoryId", code);
Assert.DoesNotContain("AND r.factory_id > 0", code);
}
///
/// 下游写入口必须继续以**显式租户**承接,不允许退回从 rule / 客户端推导。
///
/// S8-TENANT-ONLY-BATCH5:第二个 long 形参(factoryId)已移除。
/// 这里改为断言"第一个形参是 tenantId 且形参里不再出现 factoryId" ——
/// 显式作用域这条要求没有放松,只是维度少了一个。
///
[Theory]
[InlineData(nameof(S8WatchSchedulerService.PickReadyRulesAsync))]
[InlineData(nameof(S8WatchSchedulerService.RunSingleRuleAsync))]
[InlineData(nameof(S8WatchSchedulerService.ResetExpiredLeasesAsync))]
[InlineData(nameof(S8WatchSchedulerService.RunDispatchTickAsync))]
[InlineData(nameof(S8WatchSchedulerService.OnRuleCompletedAsync))]
public void DownstreamWriters_KeepExplicitTenantScope(string method)
{
var m = typeof(S8WatchSchedulerService).GetMethod(method);
Assert.NotNull(m);
var ps = m!.GetParameters();
Assert.Equal("tenantId", ps[0].Name);
Assert.Equal(typeof(long), ps[0].ParameterType);
Assert.DoesNotContain(ps, p => p.Name == "factoryId");
}
///
/// S8-TENANT-ONLY-BATCH5:dedup_key 不得再含工厂段。
///
/// 这条是本批最关键的防回归点:一旦有人把 :F{factory} 加回去,
/// 历史异常与新命中会分裂成两套身份 —— 旧异常开始 recovery、新键再建一条重复单,
/// 而且这个后果只会在生产跑一段时间之后才显形。
///
[Fact]
public void DedupKey_CarriesNoFactorySegment()
{
foreach (var src in new[]
{
"Service/S8/Rules/S8TimeoutRuleEvaluator.cs",
"Service/S8/Rules/S8ShortageRuleEvaluator.cs",
"Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs",
})
{
var code = CodeOnly(src);
Assert.Contains("$\"T{tenantId}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}\"", code);
Assert.DoesNotContain(":F{factoryId}", code);
}
}
}