S8LegacyRuntimeRetirementTests.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215
  1. using System.Reflection;
  2. using System.Runtime.CompilerServices;
  3. using Admin.NET.Plugin.AiDOP.Service.S8;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  5. using Xunit;
  6. namespace Admin.NET.Test.S8;
  7. /// <summary>
  8. /// S8-LEGACY-RUNTIME-RETIREMENT-1:STANDARD_DATASET 是唯一允许运行的取数模式。
  9. ///
  10. /// <b>本文件锁的是「接线」,不是「策略」。</b>
  11. /// 策略本身(<c>IsRuntimeEligible</c> / <c>EnsureMaintenanceAllowed</c> 的判定语义)
  12. /// 在 <see cref="S8LegacySqlGovernanceTests"/> 中用普通单元测试全覆盖;
  13. /// 这里要回答的是另一个问题:**每一条进入运行态的通道,是否真的调用了那道门?**
  14. ///
  15. /// 为什么必须用 IL 而不是行为测试:<c>ResumeAsync</c> / <c>RunNowAsync</c> 的门禁位于
  16. /// <c>LoadScopedAsync</c> 之后(必须先确认规则归属本租户,才能对它下结论),
  17. /// 因此无法像 <c>S8DataSourceWriteRetiredTests</c> 那样用 null 仓储在 DB 访问前截住;
  18. /// 而调度器的三层防御分布在 SQL 谓词与运行期守卫中,端到端验证需要真库真租约。
  19. /// 折中方案是**在字节码层断言调用点存在** —— 有人删掉门禁时会立刻红,
  20. /// 而不是等到某次生产 tick 才发现 Legacy 规则又跑起来了。
  21. ///
  22. /// <b>探测器自身的可信度由反向用例保证</b>:<c>PauseAsync</c>(退出运行态,
  23. /// 按设计**不得**有门禁)必须被判定为「未调用」。若探测器坏成"永远返回 true",
  24. /// 那条反向用例会失败。没有这层自检,IL 断言只是安慰剂。
  25. /// </summary>
  26. public class S8LegacyRuntimeRetirementTests
  27. {
  28. // ============================================================
  29. // IL 调用点探测
  30. // ============================================================
  31. private const byte OpCall = 0x28; // call
  32. private const byte OpCallvirt = 0x6F; // callvirt
  33. /// <summary>
  34. /// 取方法真正承载 IL 的那个 body。async 方法本身只是启动器,
  35. /// 真实逻辑在编译器生成的状态机 <c>MoveNext</c> 里;直接读原方法会一无所获。
  36. /// </summary>
  37. private static MethodBase ResolveBodyCarrier(Type owner, string methodName)
  38. {
  39. var method = owner.GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static)
  40. ?? throw new InvalidOperationException($"找不到方法 {owner.Name}.{methodName}");
  41. var asyncAttr = method.GetCustomAttribute<AsyncStateMachineAttribute>();
  42. if (asyncAttr == null) return method;
  43. return asyncAttr.StateMachineType.GetMethod("MoveNext", BindingFlags.NonPublic | BindingFlags.Instance)
  44. ?? throw new InvalidOperationException($"{owner.Name}.{methodName} 的状态机没有 MoveNext");
  45. }
  46. /// <summary>
  47. /// 判断 <paramref name="methodName"/> 的 IL 中是否存在对 <paramref name="target"/> 的调用。
  48. ///
  49. /// 实现是「扫描 call / callvirt 操作码 + 其后 4 字节 metadata token」的朴素匹配,
  50. /// 不做完整 IL 游走。取舍说明:
  51. /// · **不可能漏报** —— 真有调用,这 5 个字节就一定按此形态出现;
  52. /// · 理论上可能误报(某个操作数的字节恰好等于该模式),概率可忽略,
  53. /// 且误报方向是"以为有门禁",会被上面的反向自检用例暴露。
  54. /// 调用方与被调方同属 Admin.NET.Plugin.AiDOP 程序集,因此 IL 中是 MethodDef token,
  55. /// 与 <c>MethodInfo.MetadataToken</c> 直接相等,无需跨模块解析。
  56. /// </summary>
  57. private static bool CallsMethod(Type owner, string methodName, MethodInfo target)
  58. {
  59. var il = ResolveBodyCarrier(owner, methodName).GetMethodBody()?.GetILAsByteArray()
  60. ?? throw new InvalidOperationException($"{owner.Name}.{methodName} 取不到 IL");
  61. Assert.NotEmpty(il);
  62. var token = target.MetadataToken;
  63. var b0 = (byte)(token & 0xFF);
  64. var b1 = (byte)((token >> 8) & 0xFF);
  65. var b2 = (byte)((token >> 16) & 0xFF);
  66. var b3 = (byte)((token >> 24) & 0xFF);
  67. for (var i = 0; i + 4 < il.Length; i++)
  68. {
  69. if (il[i] != OpCall && il[i] != OpCallvirt) continue;
  70. if (il[i + 1] == b0 && il[i + 2] == b1 && il[i + 3] == b2 && il[i + 4] == b3)
  71. return true;
  72. }
  73. return false;
  74. }
  75. private static MethodInfo Gate() =>
  76. typeof(S8RuleCreationPolicy).GetMethod(nameof(S8RuleCreationPolicy.EnsureMaintenanceAllowed))!;
  77. private static MethodInfo Eligibility() =>
  78. typeof(S8RuleCreationPolicy).GetMethod(nameof(S8RuleCreationPolicy.IsRuntimeEligible))!;
  79. // ============================================================
  80. // 探测器自检 —— 必须先证明它会说 "没有"
  81. // ============================================================
  82. /// <summary>
  83. /// PauseAsync 是**退出**运行态,按设计不得有退役门禁(否则数据集一出问题规则就关不掉)。
  84. /// 它同时充当探测器的阴性对照:这条用例失败 = 探测器失效,其余 IL 断言全部不可信。
  85. /// </summary>
  86. [Fact]
  87. public void Detector_ReportsAbsence_ForPauseAsync_WhichMustNotBeGated()
  88. {
  89. Assert.False(CallsMethod(typeof(S8WatchRuleService), "PauseAsync", Gate()));
  90. }
  91. // ============================================================
  92. // T3 / T4:Resume 与 RunNow 是独立入队通道,各自必须带门
  93. // ============================================================
  94. /// <summary>
  95. /// T3:ResumeAsync 会清 paused_until 并把 next_run_at 置为 NOW,
  96. /// 即"让调度器下一轮拾取它" —— 这就是进入运行态。
  97. /// 它不经过 ValidateForEnable,所以门禁必须显式挂在这里。
  98. /// </summary>
  99. [Fact]
  100. public void ResumeAsync_IsGuardedByRetirementGate()
  101. {
  102. Assert.True(CallsMethod(typeof(S8WatchRuleService), "ResumeAsync", Gate()),
  103. "ResumeAsync 必须调用 S8RuleCreationPolicy.EnsureMaintenanceAllowed,否则 Legacy 规则可经"
  104. + "「恢复」重新入队");
  105. }
  106. /// <summary>T4:RunNowAsync 同理 —— 直接把 next_run_at 置为 NOW 排队执行。</summary>
  107. [Fact]
  108. public void RunNowAsync_IsGuardedByRetirementGate()
  109. {
  110. Assert.True(CallsMethod(typeof(S8WatchRuleService), "RunNowAsync", Gate()),
  111. "RunNowAsync 必须调用 S8RuleCreationPolicy.EnsureMaintenanceAllowed");
  112. }
  113. // ============================================================
  114. // T1 / T2 / T5:Enable 通道(Create-and-enable 与 UpdateParams 的共同必经点)
  115. // ============================================================
  116. /// <summary>
  117. /// ValidateForEnable 是「Create 时带 enabled=true」与「UpdateParams 由停用切启用」
  118. /// 两条路径的唯一交汇处,门禁挂在这里即可覆盖二者。
  119. /// 判定语义(含 NULL 历史行、显式 LEGACY_SQL、STANDARD 放行)见
  120. /// S8WatchRuleDataAccessValidatorTests 与 S8LegacySqlGovernanceTests。
  121. /// </summary>
  122. [Fact]
  123. public void ValidateForEnable_IsGuardedByRetirementGate()
  124. {
  125. Assert.True(CallsMethod(typeof(S8WatchRuleDataAccessValidator), "ValidateForEnable", Gate()));
  126. }
  127. /// <summary>
  128. /// UpdateParamsAsync 是 enabled 的唯一切换入口,必须经由 ValidateForEnable 拿到门禁。
  129. /// 若将来有人在这里直连 Gate 而绕开 validator,本断言会红 —— 那也应当被复核。
  130. /// </summary>
  131. [Fact]
  132. public void EnabledToggle_StillRoutesThroughValidator()
  133. {
  134. var validateForEnable = typeof(S8WatchRuleDataAccessValidator)
  135. .GetMethod(nameof(S8WatchRuleDataAccessValidator.ValidateForEnable))!;
  136. Assert.True(CallsMethod(typeof(S8WatchRuleService), "UpdateParamsAsync", validateForEnable));
  137. }
  138. // ============================================================
  139. // T6:调度器纵深防御
  140. // ============================================================
  141. /// <summary>
  142. /// T6 第 3 层:即使有人绕过 SQL 过滤与抢锁复查(手工改库 + 直接构造 lease),
  143. /// RunSingleRuleAsync 也必须在触碰 evaluator 与 S8LegacySqlDataProvider **之前**停下。
  144. ///
  145. /// 前两层是 SQL 谓词(<c>data_access_mode = 'STANDARD_DATASET'</c>,
  146. /// 靠三值逻辑天然排除 NULL),位于表达式树内、无法用 IL 断言,
  147. /// 其正确性由 <see cref="PickReadyRules_FiltersByRuntimeEligibleMode"/> 的常量契约 +
  148. /// 阶段 2 的真库冒烟共同保证。
  149. /// </summary>
  150. [Fact]
  151. public void Scheduler_RunSingleRule_ChecksRuntimeEligibility()
  152. {
  153. Assert.True(CallsMethod(typeof(S8WatchSchedulerService), "RunSingleRuleAsync", Eligibility()),
  154. "RunSingleRuleAsync 必须调用 S8RuleCreationPolicy.IsRuntimeEligible,"
  155. + "否则手工改库置 enabled=1 的 Legacy 规则仍会被执行");
  156. }
  157. /// <summary>
  158. /// T7 的常量侧契约:调度器用于过滤的模式值必须就是 STANDARD_DATASET 本身,
  159. /// 不允许被改成别名或宽松匹配。
  160. /// 这条同时守住"STANDARD_DATASET 规则仍然有资格被拾取" —— 若有人把过滤值改错,
  161. /// 受害的不是 Legacy 而是 Rule 01。
  162. /// </summary>
  163. [Fact]
  164. public void PickReadyRules_FiltersByRuntimeEligibleMode()
  165. {
  166. Assert.Equal("STANDARD_DATASET", S8DataAccessMode.StandardDataset);
  167. // 与 SQL 谓词等价的语义复核:只有该值被判定为有运行资格。
  168. Assert.True(S8RuleCreationPolicy.IsRuntimeEligible(
  169. new Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8WatchRule
  170. {
  171. DataAccessMode = S8DataAccessMode.StandardDataset
  172. }));
  173. }
  174. // ============================================================
  175. // 历史兼容:解析语义不得被"顺手迁移"
  176. // ============================================================
  177. /// <summary>
  178. /// 硬约束:NULL / 空白仍解析为 LEGACY_SQL。
  179. /// 把历史行伪装成 STANDARD_DATASET 会让它们绕过 Enable Gate,
  180. /// 去引用一个根本不存在的 Provider —— 那不是迁移,是把问题推到运行期。
  181. /// </summary>
  182. [Theory]
  183. [InlineData(null)]
  184. [InlineData("")]
  185. [InlineData(" ")]
  186. public void HistoricalNullMode_StillResolvesToLegacy_NotStandard(string? raw)
  187. {
  188. Assert.Equal(S8DataAccessMode.LegacySql, S8DataAccessMode.Resolve(raw));
  189. }
  190. }