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