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_IsNowAlsoRejected()
{
// 语义变更(S8-LEGACY-RUNTIME-RETIREMENT-1):本用例原名
// WizardGenerated_LegacySqlRule_StillAllowed —— 当时向导是唯一豁免通道,
// 因为它是在产流程且 SQL 由服务端按字典白名单生成。
//
// 该豁免现已取消:没有治理 Dataset 就没有可运行 Rule,不再用 Legacy SQL 兜底。
// 向导保留独立文案(提示"当前对象无治理数据集"),因为在向导里报
// "不接受你自带的 SQL" 会让用户完全对不上号 —— 他并没有写过 SQL。
var ex = Assert.Throws(() =>
S8RuleCreationPolicy.EnsureCreationAllowed(LegacyRule(), S8RuleCreationOrigin.WizardGenerated));
Assert.Contains(S8RuleCreationPolicy.LegacySqlRetiredReason, ex.Message);
}
[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_CannotBeReEnabled_AfterRuntimeRetirement()
{
// 语义变更(S8-LEGACY-RUNTIME-RETIREMENT-1):本用例原名
// ExistingLegacyRule_CanStillBeReEnabled —— 上一阶段的治理目标是"不新增旧债",
// 故存量规则重新启用是放行的。本阶段目标升级为"旧债不得运行",因此反转。
var ex = Assert.Throws(() =>
S8RuleCreationPolicy.EnsureMaintenanceAllowed(LegacyRule(), enabling: true));
Assert.Contains(S8RuleCreationPolicy.LegacySqlRetiredReason, ex.Message);
}
[Fact]
public void ExplicitLegacyMode_CannotBeReEnabled_Either()
{
// NULL(历史现状)与显式 LEGACY_SQL 必须同样被拦,否则回填一次字面值就能绕过退役。
var rule = LegacyRule();
rule.DataAccessMode = S8DataAccessMode.LegacySql;
Assert.Throws(() =>
S8RuleCreationPolicy.EnsureMaintenanceAllowed(rule, enabling: true));
}
[Fact]
public void StandardDatasetRule_CanBeEnabled()
{
// 正向对照:退役门禁不能误伤 STANDARD_DATASET(Rule 01 走的正是这条路)。
S8RuleCreationPolicy.EnsureMaintenanceAllowed(StandardRule(), enabling: true);
}
// ============================================================
// E. 运行资格判定(调度器纵深防御所依赖的同一个谓词)
// ============================================================
[Theory]
[InlineData(null)] // 132 条历史规则的现状
[InlineData("")] // 空串
[InlineData(" ")] // 空白
[InlineData("LEGACY_SQL")] // 显式声明
[InlineData("legacy_sql")] // 大小写变体
[InlineData("SOMETHING_ELSE")] // 脏数据:既非 Legacy 也非 Standard
public void IsRuntimeEligible_IsFalse_ForEverythingButStandardDataset(string? mode)
{
var rule = LegacyRule();
rule.DataAccessMode = mode;
Assert.False(S8RuleCreationPolicy.IsRuntimeEligible(rule));
}
[Theory]
[InlineData("STANDARD_DATASET")]
[InlineData("standard_dataset")] // Resolve 对模式值大小写不敏感
public void IsRuntimeEligible_IsTrue_ForStandardDataset(string mode)
{
var rule = StandardRule();
rule.DataAccessMode = mode;
Assert.True(S8RuleCreationPolicy.IsRuntimeEligible(rule));
}
[Fact]
public void IsRuntimeEligible_DoesNotThrow_OnDirtyModeValue()
{
// 调度器每个 tick 都会调它。脏数据必须返回 false 而不是抛异常中断整个 tick,
// 否则一条坏行就能让全租户的监控停摆(S8DataAccessMode.Resolve 对未知值是抛错的)。
var rule = LegacyRule();
rule.DataAccessMode = "NOT_A_REAL_MODE";
Assert.False(S8RuleCreationPolicy.IsRuntimeEligible(rule));
}
// ============================================================
// 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);
}
}