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)); } }