using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using Admin.NET.Plugin.AiDOP.Service.S8.Rules; using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Admin.NET.Test.S8; /// /// S8-LEGACY-SQL-RESIDUAL-CLEANUP-3:停止新增 SQL 技术债。 /// /// 治理口径不是"禁止一切 LEGACY_SQL"——132 条历史规则仍需运行,向导也仍在用服务端生成的 SQL。 /// 真正要掐断的是**调用方自带 expression** 这条路径:它既是技术债来源,也是注入面。 /// 因此按创建来源区分: /// · EXTERNAL_API(裸 REST,调用方自带 SQL)→ 拒绝 /// · WIZARD_GENERATED(服务端字典白名单生成)→ 暂时放行,随向导迁移后收口 /// public class S8LegacySqlGovernanceTests { private static AdoS8WatchRule LegacyRule() => new() { RuleCode = "UT_NEW_LEGACY", RuleType = "TIMEOUT", DataAccessMode = null, // 等价 LEGACY_SQL Expression = "SELECT * FROM some_physical_table WHERE 1=1" }; private static AdoS8WatchRule StandardRule() => new() { RuleCode = "UT_NEW_STANDARD", RuleType = "TIMEOUT", DataAccessMode = S8DataAccessMode.StandardDataset, DatasetCode = "ANY_DATASET" }; // ============================================================ // C. 新建 Legacy SQL 规则 // ============================================================ [Fact] public void ExternalApi_CreatingLegacySqlRule_IsRejected() { var ex = Assert.Throws(() => S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), S8RuleCreationOrigin.ExternalApi)); Assert.Contains(S8RuleCreationPolicy.LegacySqlCreationDisabledReason, ex.Message); } [Fact] public void ExternalApi_CreatingLegacySqlRule_ExplicitMode_IsAlsoRejected() { // 显式写 LEGACY_SQL 与留空等价,不能靠显式声明绕过治理。 var rule = LegacyRule(); rule.DataAccessMode = S8DataAccessMode.LegacySql; Assert.Throws(() => S8RuleCreationPolicy.EnsureCreationAllowed(rule, S8RuleCreationOrigin.ExternalApi)); } [Fact] public void WizardGenerated_LegacySqlRule_StillAllowed() { // 向导 SQL 由 S8ConfigDraftService 按字典白名单生成,非调用方自带; // 该流程仍在生产使用,本批不阻断(见报告 KNOWN REMAINING SQL DEBT)。 S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), S8RuleCreationOrigin.WizardGenerated); } [Fact] public void StandardDatasetRule_AllowedFromAnyOrigin() { S8RuleCreationPolicy.EnsureCreationAllowed(StandardRule(), S8RuleCreationOrigin.ExternalApi); S8RuleCreationPolicy.EnsureCreationAllowed(StandardRule(), S8RuleCreationOrigin.WizardGenerated); } [Fact] public void UnknownOrigin_IsTreatedAsExternal_FailClosed() { // 未知来源按最严格处理:新增调用方不会因为忘记声明来源而意外获得放行。 Assert.Throws(() => S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), "SOMETHING_NEW")); } // ============================================================ // D. 历史 Legacy 规则的维护不受治理影响 // ============================================================ [Fact] public void ExistingLegacyRule_CanStillBeDisabled() { // 治理只作用于"新建"。若关停也被拦,数据集/数据源一出问题规则就再也关不掉。 var existing = LegacyRule(); S8RuleCreationPolicy.EnsureMaintenanceAllowed(existing, enabling: false); } [Fact] public void ExistingLegacyRule_CanStillBeReEnabled() { // 历史规则重新启用不属于"新增技术债",不拦。 S8RuleCreationPolicy.EnsureMaintenanceAllowed(LegacyRule(), enabling: true); } // ============================================================ // B. STANDARD_DATASET 即使带恶意 SQL 也绝不执行 // ============================================================ private const string PoisonDataset = "FAKE_POISON_DATASET"; private static S8DatasetCapabilities TimeoutCapable() => new() { SupportsTimeout = true, HasSourceObjectId = true, HasRelatedObjectCode = true, HasDueAt = true, HasStatus = true }; private sealed class PoisonSource : IS8DatasetDefinitionSource { public IEnumerable GetDefinitions() => new[] { new S8DatasetDefinition { DatasetCode = PoisonDataset, DisplayName = "恶意SQL防护测试数据集", Kind = S8DatasetKind.Object, Capabilities = TimeoutCapable() } }; } private sealed class EmptyProvider : IS8MonitoringDataProvider { public string DatasetCode => PoisonDataset; public S8DatasetCapabilities Capabilities => TimeoutCapable(); public Task LoadAsync(S8MonitoringDataRequest request, CancellationToken cancellationToken = default) { request.EnsureValid(); return Task.FromResult(S8MonitoringRowSet.Empty); } } private sealed class SpyLegacy : IS8LegacySqlDataProvider { public int CallCount { get; private set; } public string LastExpression { get; private set; } public Task LoadAsync( long tenantId, long factoryId, AdoS8WatchRule rule, string ruleType, int timeoutSeconds, CancellationToken cancellationToken = default) { CallCount++; LastExpression = rule.Expression; return Task.FromResult(new S8MonitoringDataResult { RowSet = S8MonitoringRowSet.Empty }); } } [Fact] public async Task StandardDataset_MaliciousExpression_IsNeverExecuted() { var legacy = new SpyLegacy(); var gateway = new S8MonitoringDataGateway( legacy, new S8MonitoringDataProviderRegistry(new[] { new EmptyProvider() }), new S8DatasetCatalog(new[] { new PoisonSource() }), NullLogger.Instance); var rule = new AdoS8WatchRule { RuleCode = "UT_POISON", RuleType = "TIMEOUT", DataAccessMode = S8DataAccessMode.StandardDataset, DatasetCode = PoisonDataset, // 即使历史遗留字段里躺着可执行 SQL,STANDARD_DATASET 路径也绝不能碰它。 Expression = "DROP TABLE ado_s8_exception; SELECT SHOULD_NEVER_RUN" }; var result = await gateway.LoadAsync(1, 1, rule, "TIMEOUT", 60, 1000); Assert.Equal(S8DataAccessMode.StandardDataset, result.DataAccessMode); Assert.Equal(0, legacy.CallCount); Assert.Null(legacy.LastExpression); } }