S8LegacySqlGovernanceTests.cs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
  1. using Admin.NET.Plugin.AiDOP.Entity.S8;
  2. using Admin.NET.Plugin.AiDOP.Service.S8;
  3. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  4. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  5. using Microsoft.Extensions.Logging.Abstractions;
  6. using Xunit;
  7. namespace Admin.NET.Test.S8;
  8. /// <summary>
  9. /// S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:停止新增 SQL 技术债。
  10. ///
  11. /// 治理口径不是"禁止一切 LEGACY_SQL"——132 条历史规则仍需运行,向导也仍在用服务端生成的 SQL。
  12. /// 真正要掐断的是**调用方自带 expression** 这条路径:它既是技术债来源,也是注入面。
  13. /// 因此按创建来源区分:
  14. /// · EXTERNAL_API(裸 REST,调用方自带 SQL)→ 拒绝
  15. /// · WIZARD_GENERATED(服务端字典白名单生成)→ 暂时放行,随向导迁移后收口
  16. /// </summary>
  17. public class S8LegacySqlGovernanceTests
  18. {
  19. private static AdoS8WatchRule LegacyRule() => new()
  20. {
  21. RuleCode = "UT_NEW_LEGACY",
  22. RuleType = "TIMEOUT",
  23. DataAccessMode = null, // 等价 LEGACY_SQL
  24. Expression = "SELECT * FROM some_physical_table WHERE 1=1"
  25. };
  26. private static AdoS8WatchRule StandardRule() => new()
  27. {
  28. RuleCode = "UT_NEW_STANDARD",
  29. RuleType = "TIMEOUT",
  30. DataAccessMode = S8DataAccessMode.StandardDataset,
  31. DatasetCode = "ANY_DATASET"
  32. };
  33. // ============================================================
  34. // C. 新建 Legacy SQL 规则
  35. // ============================================================
  36. [Fact]
  37. public void ExternalApi_CreatingLegacySqlRule_IsRejected()
  38. {
  39. var ex = Assert.Throws<S8BizException>(() =>
  40. S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), S8RuleCreationOrigin.ExternalApi));
  41. Assert.Contains(S8RuleCreationPolicy.LegacySqlCreationDisabledReason, ex.Message);
  42. }
  43. [Fact]
  44. public void ExternalApi_CreatingLegacySqlRule_ExplicitMode_IsAlsoRejected()
  45. {
  46. // 显式写 LEGACY_SQL 与留空等价,不能靠显式声明绕过治理。
  47. var rule = LegacyRule();
  48. rule.DataAccessMode = S8DataAccessMode.LegacySql;
  49. Assert.Throws<S8BizException>(() =>
  50. S8RuleCreationPolicy.EnsureCreationAllowed(rule, S8RuleCreationOrigin.ExternalApi));
  51. }
  52. [Fact]
  53. public void WizardGenerated_LegacySqlRule_IsNowAlsoRejected()
  54. {
  55. // 语义变更(S8-LEGACY-RUNTIME-RETIREMENT-1):本用例原名
  56. // WizardGenerated_LegacySqlRule_StillAllowed —— 当时向导是唯一豁免通道,
  57. // 因为它是在产流程且 SQL 由服务端按字典白名单生成。
  58. //
  59. // 该豁免现已取消:没有治理 Dataset 就没有可运行 Rule,不再用 Legacy SQL 兜底。
  60. // 向导保留独立文案(提示"当前对象无治理数据集"),因为在向导里报
  61. // "不接受你自带的 SQL" 会让用户完全对不上号 —— 他并没有写过 SQL。
  62. var ex = Assert.Throws<S8BizException>(() =>
  63. S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), S8RuleCreationOrigin.WizardGenerated));
  64. Assert.Contains(S8RuleCreationPolicy.LegacySqlRetiredReason, ex.Message);
  65. }
  66. [Fact]
  67. public void StandardDatasetRule_AllowedFromAnyOrigin()
  68. {
  69. S8RuleCreationPolicy.EnsureCreationAllowed(StandardRule(), S8RuleCreationOrigin.ExternalApi);
  70. S8RuleCreationPolicy.EnsureCreationAllowed(StandardRule(), S8RuleCreationOrigin.WizardGenerated);
  71. }
  72. [Fact]
  73. public void UnknownOrigin_IsTreatedAsExternal_FailClosed()
  74. {
  75. // 未知来源按最严格处理:新增调用方不会因为忘记声明来源而意外获得放行。
  76. Assert.Throws<S8BizException>(() =>
  77. S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), "SOMETHING_NEW"));
  78. }
  79. // ============================================================
  80. // D. 历史 Legacy 规则的维护不受治理影响
  81. // ============================================================
  82. [Fact]
  83. public void ExistingLegacyRule_CanStillBeDisabled()
  84. {
  85. // 这条**必须**保持放行:若关停也被拦,数据集/数据源一出问题规则就再也关不掉,
  86. // 只能去改库。退役门禁的方向是单向的 —— 只拦「进入运行态」。
  87. var existing = LegacyRule();
  88. S8RuleCreationPolicy.EnsureMaintenanceAllowed(existing, enabling: false);
  89. }
  90. [Fact]
  91. public void ExistingLegacyRule_CannotBeReEnabled_AfterRuntimeRetirement()
  92. {
  93. // 语义变更(S8-LEGACY-RUNTIME-RETIREMENT-1):本用例原名
  94. // ExistingLegacyRule_CanStillBeReEnabled —— 上一阶段的治理目标是"不新增旧债",
  95. // 故存量规则重新启用是放行的。本阶段目标升级为"旧债不得运行",因此反转。
  96. var ex = Assert.Throws<S8BizException>(() =>
  97. S8RuleCreationPolicy.EnsureMaintenanceAllowed(LegacyRule(), enabling: true));
  98. Assert.Contains(S8RuleCreationPolicy.LegacySqlRetiredReason, ex.Message);
  99. }
  100. [Fact]
  101. public void ExplicitLegacyMode_CannotBeReEnabled_Either()
  102. {
  103. // NULL(历史现状)与显式 LEGACY_SQL 必须同样被拦,否则回填一次字面值就能绕过退役。
  104. var rule = LegacyRule();
  105. rule.DataAccessMode = S8DataAccessMode.LegacySql;
  106. Assert.Throws<S8BizException>(() =>
  107. S8RuleCreationPolicy.EnsureMaintenanceAllowed(rule, enabling: true));
  108. }
  109. [Fact]
  110. public void StandardDatasetRule_CanBeEnabled()
  111. {
  112. // 正向对照:退役门禁不能误伤 STANDARD_DATASET(Rule 01 走的正是这条路)。
  113. S8RuleCreationPolicy.EnsureMaintenanceAllowed(StandardRule(), enabling: true);
  114. }
  115. // ============================================================
  116. // E. 运行资格判定(调度器纵深防御所依赖的同一个谓词)
  117. // ============================================================
  118. [Theory]
  119. [InlineData(null)] // 132 条历史规则的现状
  120. [InlineData("")] // 空串
  121. [InlineData(" ")] // 空白
  122. [InlineData("LEGACY_SQL")] // 显式声明
  123. [InlineData("legacy_sql")] // 大小写变体
  124. [InlineData("SOMETHING_ELSE")] // 脏数据:既非 Legacy 也非 Standard
  125. public void IsRuntimeEligible_IsFalse_ForEverythingButStandardDataset(string? mode)
  126. {
  127. var rule = LegacyRule();
  128. rule.DataAccessMode = mode;
  129. Assert.False(S8RuleCreationPolicy.IsRuntimeEligible(rule));
  130. }
  131. [Theory]
  132. [InlineData("STANDARD_DATASET")]
  133. [InlineData("standard_dataset")] // Resolve 对模式值大小写不敏感
  134. public void IsRuntimeEligible_IsTrue_ForStandardDataset(string mode)
  135. {
  136. var rule = StandardRule();
  137. rule.DataAccessMode = mode;
  138. Assert.True(S8RuleCreationPolicy.IsRuntimeEligible(rule));
  139. }
  140. [Fact]
  141. public void IsRuntimeEligible_DoesNotThrow_OnDirtyModeValue()
  142. {
  143. // 调度器每个 tick 都会调它。脏数据必须返回 false 而不是抛异常中断整个 tick,
  144. // 否则一条坏行就能让全租户的监控停摆(S8DataAccessMode.Resolve 对未知值是抛错的)。
  145. var rule = LegacyRule();
  146. rule.DataAccessMode = "NOT_A_REAL_MODE";
  147. Assert.False(S8RuleCreationPolicy.IsRuntimeEligible(rule));
  148. }
  149. // ============================================================
  150. // B. STANDARD_DATASET 即使带恶意 SQL 也绝不执行
  151. // ============================================================
  152. private const string PoisonDataset = "FAKE_POISON_DATASET";
  153. private static S8DatasetCapabilities TimeoutCapable() => new()
  154. {
  155. SupportsTimeout = true,
  156. HasSourceObjectId = true,
  157. HasRelatedObjectCode = true,
  158. HasDueAt = true,
  159. HasStatus = true
  160. };
  161. private sealed class PoisonSource : IS8DatasetDefinitionSource
  162. {
  163. public IEnumerable<S8DatasetDefinition> GetDefinitions() => new[]
  164. {
  165. new S8DatasetDefinition
  166. {
  167. DatasetCode = PoisonDataset,
  168. DisplayName = "恶意SQL防护测试数据集",
  169. Kind = S8DatasetKind.Object,
  170. Capabilities = TimeoutCapable()
  171. }
  172. };
  173. }
  174. private sealed class EmptyProvider : IS8MonitoringDataProvider
  175. {
  176. public string DatasetCode => PoisonDataset;
  177. public S8DatasetCapabilities Capabilities => TimeoutCapable();
  178. public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
  179. {
  180. request.EnsureValid();
  181. return Task.FromResult(S8MonitoringRowSet.Empty);
  182. }
  183. }
  184. private sealed class SpyLegacy : IS8LegacySqlDataProvider
  185. {
  186. public int CallCount { get; private set; }
  187. public string LastExpression { get; private set; }
  188. public Task<S8MonitoringDataResult> LoadAsync(
  189. long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
  190. int timeoutSeconds, CancellationToken cancellationToken = default)
  191. {
  192. CallCount++;
  193. LastExpression = rule.Expression;
  194. return Task.FromResult(new S8MonitoringDataResult { RowSet = S8MonitoringRowSet.Empty });
  195. }
  196. }
  197. [Fact]
  198. public async Task StandardDataset_MaliciousExpression_IsNeverExecuted()
  199. {
  200. var legacy = new SpyLegacy();
  201. var gateway = new S8MonitoringDataGateway(
  202. legacy,
  203. new S8MonitoringDataProviderRegistry(new[] { new EmptyProvider() }),
  204. new S8DatasetCatalog(new[] { new PoisonSource() }),
  205. NullLogger<S8MonitoringDataGateway>.Instance);
  206. var rule = new AdoS8WatchRule
  207. {
  208. RuleCode = "UT_POISON",
  209. RuleType = "TIMEOUT",
  210. DataAccessMode = S8DataAccessMode.StandardDataset,
  211. DatasetCode = PoisonDataset,
  212. // 即使历史遗留字段里躺着可执行 SQL,STANDARD_DATASET 路径也绝不能碰它。
  213. Expression = "DROP TABLE ado_s8_exception; SELECT SHOULD_NEVER_RUN"
  214. };
  215. var result = await gateway.LoadAsync(1, 1, rule, "TIMEOUT", 60, 1000);
  216. Assert.Equal(S8DataAccessMode.StandardDataset, result.DataAccessMode);
  217. Assert.Equal(0, legacy.CallCount);
  218. Assert.Null(legacy.LastExpression);
  219. }
  220. }