S8LegacySqlGovernanceTests.cs 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  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_StillAllowed()
  54. {
  55. // 向导 SQL 由 S8ConfigDraftService 按字典白名单生成,非调用方自带;
  56. // 该流程仍在生产使用,本批不阻断(见报告 KNOWN REMAINING SQL DEBT)。
  57. S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), S8RuleCreationOrigin.WizardGenerated);
  58. }
  59. [Fact]
  60. public void StandardDatasetRule_AllowedFromAnyOrigin()
  61. {
  62. S8RuleCreationPolicy.EnsureCreationAllowed(StandardRule(), S8RuleCreationOrigin.ExternalApi);
  63. S8RuleCreationPolicy.EnsureCreationAllowed(StandardRule(), S8RuleCreationOrigin.WizardGenerated);
  64. }
  65. [Fact]
  66. public void UnknownOrigin_IsTreatedAsExternal_FailClosed()
  67. {
  68. // 未知来源按最严格处理:新增调用方不会因为忘记声明来源而意外获得放行。
  69. Assert.Throws<S8BizException>(() =>
  70. S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), "SOMETHING_NEW"));
  71. }
  72. // ============================================================
  73. // D. 历史 Legacy 规则的维护不受治理影响
  74. // ============================================================
  75. [Fact]
  76. public void ExistingLegacyRule_CanStillBeDisabled()
  77. {
  78. // 治理只作用于"新建"。若关停也被拦,数据集/数据源一出问题规则就再也关不掉。
  79. var existing = LegacyRule();
  80. S8RuleCreationPolicy.EnsureMaintenanceAllowed(existing, enabling: false);
  81. }
  82. [Fact]
  83. public void ExistingLegacyRule_CanStillBeReEnabled()
  84. {
  85. // 历史规则重新启用不属于"新增技术债",不拦。
  86. S8RuleCreationPolicy.EnsureMaintenanceAllowed(LegacyRule(), enabling: true);
  87. }
  88. // ============================================================
  89. // B. STANDARD_DATASET 即使带恶意 SQL 也绝不执行
  90. // ============================================================
  91. private const string PoisonDataset = "FAKE_POISON_DATASET";
  92. private static S8DatasetCapabilities TimeoutCapable() => new()
  93. {
  94. SupportsTimeout = true,
  95. HasSourceObjectId = true,
  96. HasRelatedObjectCode = true,
  97. HasDueAt = true,
  98. HasStatus = true
  99. };
  100. private sealed class PoisonSource : IS8DatasetDefinitionSource
  101. {
  102. public IEnumerable<S8DatasetDefinition> GetDefinitions() => new[]
  103. {
  104. new S8DatasetDefinition
  105. {
  106. DatasetCode = PoisonDataset,
  107. DisplayName = "恶意SQL防护测试数据集",
  108. Kind = S8DatasetKind.Object,
  109. Capabilities = TimeoutCapable()
  110. }
  111. };
  112. }
  113. private sealed class EmptyProvider : IS8MonitoringDataProvider
  114. {
  115. public string DatasetCode => PoisonDataset;
  116. public S8DatasetCapabilities Capabilities => TimeoutCapable();
  117. public Task<S8MonitoringRowSet> LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default)
  118. {
  119. request.EnsureValid();
  120. return Task.FromResult(S8MonitoringRowSet.Empty);
  121. }
  122. }
  123. private sealed class SpyLegacy : IS8LegacySqlDataProvider
  124. {
  125. public int CallCount { get; private set; }
  126. public string LastExpression { get; private set; }
  127. public Task<S8MonitoringDataResult> LoadAsync(
  128. long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType,
  129. int timeoutSeconds, CancellationToken cancellationToken = default)
  130. {
  131. CallCount++;
  132. LastExpression = rule.Expression;
  133. return Task.FromResult(new S8MonitoringDataResult { RowSet = S8MonitoringRowSet.Empty });
  134. }
  135. }
  136. [Fact]
  137. public async Task StandardDataset_MaliciousExpression_IsNeverExecuted()
  138. {
  139. var legacy = new SpyLegacy();
  140. var gateway = new S8MonitoringDataGateway(
  141. legacy,
  142. new S8MonitoringDataProviderRegistry(new[] { new EmptyProvider() }),
  143. new S8DatasetCatalog(new[] { new PoisonSource() }),
  144. NullLogger<S8MonitoringDataGateway>.Instance);
  145. var rule = new AdoS8WatchRule
  146. {
  147. RuleCode = "UT_POISON",
  148. RuleType = "TIMEOUT",
  149. DataAccessMode = S8DataAccessMode.StandardDataset,
  150. DatasetCode = PoisonDataset,
  151. // 即使历史遗留字段里躺着可执行 SQL,STANDARD_DATASET 路径也绝不能碰它。
  152. Expression = "DROP TABLE ado_s8_exception; SELECT SHOULD_NEVER_RUN"
  153. };
  154. var result = await gateway.LoadAsync(1, 1, rule, "TIMEOUT", 60, 1000);
  155. Assert.Equal(S8DataAccessMode.StandardDataset, result.DataAccessMode);
  156. Assert.Equal(0, legacy.CallCount);
  157. Assert.Null(legacy.LastExpression);
  158. }
  159. }