|
@@ -0,0 +1,215 @@
|
|
|
|
|
+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));
|
|
|
|
|
+ }
|
|
|
|
|
+}
|