using System.Reflection;
using System.Runtime.CompilerServices;
using Admin.NET.Plugin.AiDOP.Service.S8;
using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
using Xunit;
namespace Admin.NET.Test.S8;
///
/// S8-LEGACY-RUNTIME-RETIREMENT-1:STANDARD_DATASET 是唯一允许运行的取数模式。
///
/// 本文件锁的是「接线」,不是「策略」。
/// 策略本身(IsRuntimeEligible / EnsureMaintenanceAllowed 的判定语义)
/// 在 中用普通单元测试全覆盖;
/// 这里要回答的是另一个问题:**每一条进入运行态的通道,是否真的调用了那道门?**
///
/// 为什么必须用 IL 而不是行为测试:ResumeAsync / RunNowAsync 的门禁位于
/// LoadScopedAsync 之后(必须先确认规则归属本租户,才能对它下结论),
/// 因此无法像 S8DataSourceWriteRetiredTests 那样用 null 仓储在 DB 访问前截住;
/// 而调度器的三层防御分布在 SQL 谓词与运行期守卫中,端到端验证需要真库真租约。
/// 折中方案是**在字节码层断言调用点存在** —— 有人删掉门禁时会立刻红,
/// 而不是等到某次生产 tick 才发现 Legacy 规则又跑起来了。
///
/// 探测器自身的可信度由反向用例保证:PauseAsync(退出运行态,
/// 按设计**不得**有门禁)必须被判定为「未调用」。若探测器坏成"永远返回 true",
/// 那条反向用例会失败。没有这层自检,IL 断言只是安慰剂。
///
public class S8LegacyRuntimeRetirementTests
{
// ============================================================
// IL 调用点探测
// ============================================================
private const byte OpCall = 0x28; // call
private const byte OpCallvirt = 0x6F; // callvirt
///
/// 取方法真正承载 IL 的那个 body。async 方法本身只是启动器,
/// 真实逻辑在编译器生成的状态机 MoveNext 里;直接读原方法会一无所获。
///
private static MethodBase ResolveBodyCarrier(Type owner, string methodName)
{
var method = owner.GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
?? throw new InvalidOperationException($"找不到方法 {owner.Name}.{methodName}");
var asyncAttr = method.GetCustomAttribute();
if (asyncAttr == null) return method;
return asyncAttr.StateMachineType.GetMethod("MoveNext", BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException($"{owner.Name}.{methodName} 的状态机没有 MoveNext");
}
///
/// 判断 的 IL 中是否存在对 的调用。
///
/// 实现是「扫描 call / callvirt 操作码 + 其后 4 字节 metadata token」的朴素匹配,
/// 不做完整 IL 游走。取舍说明:
/// · **不可能漏报** —— 真有调用,这 5 个字节就一定按此形态出现;
/// · 理论上可能误报(某个操作数的字节恰好等于该模式),概率可忽略,
/// 且误报方向是"以为有门禁",会被上面的反向自检用例暴露。
/// 调用方与被调方同属 Admin.NET.Plugin.AiDOP 程序集,因此 IL 中是 MethodDef token,
/// 与 MethodInfo.MetadataToken 直接相等,无需跨模块解析。
///
private static bool CallsMethod(Type owner, string methodName, MethodInfo target)
{
var il = ResolveBodyCarrier(owner, methodName).GetMethodBody()?.GetILAsByteArray()
?? throw new InvalidOperationException($"{owner.Name}.{methodName} 取不到 IL");
Assert.NotEmpty(il);
var token = target.MetadataToken;
var b0 = (byte)(token & 0xFF);
var b1 = (byte)((token >> 8) & 0xFF);
var b2 = (byte)((token >> 16) & 0xFF);
var b3 = (byte)((token >> 24) & 0xFF);
for (var i = 0; i + 4 < il.Length; i++)
{
if (il[i] != OpCall && il[i] != OpCallvirt) continue;
if (il[i + 1] == b0 && il[i + 2] == b1 && il[i + 3] == b2 && il[i + 4] == b3)
return true;
}
return false;
}
private static MethodInfo Gate() =>
typeof(S8RuleCreationPolicy).GetMethod(nameof(S8RuleCreationPolicy.EnsureMaintenanceAllowed))!;
private static MethodInfo Eligibility() =>
typeof(S8RuleCreationPolicy).GetMethod(nameof(S8RuleCreationPolicy.IsRuntimeEligible))!;
// ============================================================
// 探测器自检 —— 必须先证明它会说 "没有"
// ============================================================
///
/// PauseAsync 是**退出**运行态,按设计不得有退役门禁(否则数据集一出问题规则就关不掉)。
/// 它同时充当探测器的阴性对照:这条用例失败 = 探测器失效,其余 IL 断言全部不可信。
///
[Fact]
public void Detector_ReportsAbsence_ForPauseAsync_WhichMustNotBeGated()
{
Assert.False(CallsMethod(typeof(S8WatchRuleService), "PauseAsync", Gate()));
}
// ============================================================
// T3 / T4:Resume 与 RunNow 是独立入队通道,各自必须带门
// ============================================================
///
/// T3:ResumeAsync 会清 paused_until 并把 next_run_at 置为 NOW,
/// 即"让调度器下一轮拾取它" —— 这就是进入运行态。
/// 它不经过 ValidateForEnable,所以门禁必须显式挂在这里。
///
[Fact]
public void ResumeAsync_IsGuardedByRetirementGate()
{
Assert.True(CallsMethod(typeof(S8WatchRuleService), "ResumeAsync", Gate()),
"ResumeAsync 必须调用 S8RuleCreationPolicy.EnsureMaintenanceAllowed,否则 Legacy 规则可经"
+ "「恢复」重新入队");
}
/// T4:RunNowAsync 同理 —— 直接把 next_run_at 置为 NOW 排队执行。
[Fact]
public void RunNowAsync_IsGuardedByRetirementGate()
{
Assert.True(CallsMethod(typeof(S8WatchRuleService), "RunNowAsync", Gate()),
"RunNowAsync 必须调用 S8RuleCreationPolicy.EnsureMaintenanceAllowed");
}
// ============================================================
// T1 / T2 / T5:Enable 通道(Create-and-enable 与 UpdateParams 的共同必经点)
// ============================================================
///
/// ValidateForEnable 是「Create 时带 enabled=true」与「UpdateParams 由停用切启用」
/// 两条路径的唯一交汇处,门禁挂在这里即可覆盖二者。
/// 判定语义(含 NULL 历史行、显式 LEGACY_SQL、STANDARD 放行)见
/// S8WatchRuleDataAccessValidatorTests 与 S8LegacySqlGovernanceTests。
///
[Fact]
public void ValidateForEnable_IsGuardedByRetirementGate()
{
Assert.True(CallsMethod(typeof(S8WatchRuleDataAccessValidator), "ValidateForEnable", Gate()));
}
///
/// UpdateParamsAsync 是 enabled 的唯一切换入口,必须经由 ValidateForEnable 拿到门禁。
/// 若将来有人在这里直连 Gate 而绕开 validator,本断言会红 —— 那也应当被复核。
///
[Fact]
public void EnabledToggle_StillRoutesThroughValidator()
{
var validateForEnable = typeof(S8WatchRuleDataAccessValidator)
.GetMethod(nameof(S8WatchRuleDataAccessValidator.ValidateForEnable))!;
Assert.True(CallsMethod(typeof(S8WatchRuleService), "UpdateParamsAsync", validateForEnable));
}
// ============================================================
// T6:调度器纵深防御
// ============================================================
///
/// T6 第 3 层:即使有人绕过 SQL 过滤与抢锁复查(手工改库 + 直接构造 lease),
/// RunSingleRuleAsync 也必须在触碰 evaluator 与 S8LegacySqlDataProvider **之前**停下。
///
/// 前两层是 SQL 谓词(data_access_mode = 'STANDARD_DATASET',
/// 靠三值逻辑天然排除 NULL),位于表达式树内、无法用 IL 断言,
/// 其正确性由 的常量契约 +
/// 阶段 2 的真库冒烟共同保证。
///
[Fact]
public void Scheduler_RunSingleRule_ChecksRuntimeEligibility()
{
Assert.True(CallsMethod(typeof(S8WatchSchedulerService), "RunSingleRuleAsync", Eligibility()),
"RunSingleRuleAsync 必须调用 S8RuleCreationPolicy.IsRuntimeEligible,"
+ "否则手工改库置 enabled=1 的 Legacy 规则仍会被执行");
}
///
/// T7 的常量侧契约:调度器用于过滤的模式值必须就是 STANDARD_DATASET 本身,
/// 不允许被改成别名或宽松匹配。
/// 这条同时守住"STANDARD_DATASET 规则仍然有资格被拾取" —— 若有人把过滤值改错,
/// 受害的不是 Legacy 而是 Rule 01。
///
[Fact]
public void PickReadyRules_FiltersByRuntimeEligibleMode()
{
Assert.Equal("STANDARD_DATASET", S8DataAccessMode.StandardDataset);
// 与 SQL 谓词等价的语义复核:只有该值被判定为有运行资格。
Assert.True(S8RuleCreationPolicy.IsRuntimeEligible(
new Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8WatchRule
{
DataAccessMode = S8DataAccessMode.StandardDataset
}));
}
// ============================================================
// 历史兼容:解析语义不得被"顺手迁移"
// ============================================================
///
/// 硬约束:NULL / 空白仍解析为 LEGACY_SQL。
/// 把历史行伪装成 STANDARD_DATASET 会让它们绕过 Enable Gate,
/// 去引用一个根本不存在的 Provider —— 那不是迁移,是把问题推到运行期。
///
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void HistoricalNullMode_StillResolvesToLegacy_NotStandard(string? raw)
{
Assert.Equal(S8DataAccessMode.LegacySql, S8DataAccessMode.Resolve(raw));
}
}