| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435 |
- using System.Reflection;
- using System.Text.RegularExpressions;
- using System.Runtime.CompilerServices;
- using Microsoft.AspNetCore.Mvc;
- using Admin.NET.Plugin.AiDOP.Controllers.S8;
- using Admin.NET.Plugin.AiDOP.Entity.S8;
- using Admin.NET.Plugin.AiDOP.Infrastructure;
- using Admin.NET.Plugin.AiDOP.Service.S8;
- using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S8;
- /// <summary>
- /// S8-RULE-GOVERNANCE-BATCH3:<b>业务侧根本不能创建 / 删除监控规则</b>。
- ///
- /// <para>本文件取代了原 <c>S8DatasetOnlyRuleCreationTests</c>。那个名字守护的是
- /// 「创建规则时只能选治理数据集」—— 一条已经过时的口径:现在不存在"创建规则"这件事,
- /// 继续用那个名字会让读者以为 Rule Builder 还在,只是被限制了选项。</para>
- ///
- /// <para>最终模型:</para>
- /// <code>
- /// 业务需求 → 研发实现 S8RuleDefinition → S8RuleCatalog → Release
- /// → S8RuleProvisioningService → 每租户一条 Runtime Policy(默认停用)
- /// </code>
- /// <para>用户能做的只有:调整已发布规则的运行参数、启停、预演、立即执行、暂停恢复。</para>
- ///
- /// <para>原文件中仍然有效的断言(Legacy 类型不存在 / 数据集端点只读 / Rule 01 形态可表达)
- /// 已逐条迁入本文件,未丢失覆盖。</para>
- ///
- /// <para>不接 DB、不接 DI:退役守卫在**任何 DB 访问之前**抛出,因此用
- /// <see cref="RuntimeHelpers.GetUninitializedObject"/> 绕开构造函数即可驱动。
- /// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 失败 ——
- /// 这正是我们要的信号:<b>守卫必须前置</b>。</para>
- /// </summary>
- public class S8RuleCreationRetiredTests
- {
- private static readonly Assembly PluginAssembly = typeof(AdoS8WatchRule).Assembly;
- private static S8WatchRuleService Svc() =>
- (S8WatchRuleService)RuntimeHelpers.GetUninitializedObject(typeof(S8WatchRuleService));
- private static readonly S8TrustedScope Scope = new(838257186181189L);
- private static AdoS8WatchRule Body() => new()
- {
- RuleCode = "RULE_A_USER_INVENTED",
- SceneCode = "S1",
- RuleType = "TIMEOUT",
- DatasetCode = "PURCHASE_DELIVERY",
- WatchObjectType = "ORDER",
- PollIntervalSeconds = 300
- };
- // ───────────────────────── T1:创建能力 ─────────────────────────
- [Fact]
- public void T1_CreateAsync_IsRetired_WithGuidanceMessage()
- {
- var ex = Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(Body(), Scope); });
- Assert.Equal(S8WriteRetiredException.WatchRuleCreateMessage, ex.Message);
- // 文案必须说清"新规则从哪来",否则用户只会以为接口坏了。
- Assert.Contains("系统版本定义", ex.Message);
- Assert.DoesNotContain("unsupported", ex.Message, StringComparison.OrdinalIgnoreCase);
- Assert.DoesNotContain("unknown", ex.Message, StringComparison.OrdinalIgnoreCase);
- // 与"整实体更新退役"是不同的事,文案不得混用。
- Assert.NotEqual(S8WriteRetiredException.WatchRuleUpdateMessage, ex.Message);
- }
- /// <summary>入参是否"合法"与能力是否存在无关:任何 body 都必须 410,且零 DB 访问。</summary>
- [Fact]
- public void T1_CreateAsync_IsRetired_RegardlessOfPayload()
- {
- Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(new AdoS8WatchRule(), Scope); });
- var wellFormed = Body();
- wellFormed.RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY"; // 连"合法"的编码也不行
- Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(wellFormed, Scope); });
- }
- [Fact]
- public void T1_CreateEndpoint_Returns410_AndTouchesNoScope()
- {
- var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "CreateAsync"));
- // 墓碑而非删 route:直接删会让遗留调用方拿到 405(该路径上还有 GET),
- // 而 405 说的是"方法不对",会让人以为换个动词就能建。
- Assert.Single(action.GetCustomAttributes<HttpPostAttribute>());
- Assert.NotEmpty(action.GetCustomAttributes<ObsoleteAttribute>());
- // 同步返回:能力已退役,不该为构造一个用不到的 scope 去 await 组织库查询。
- Assert.Equal(typeof(IActionResult), action.ReturnType);
- }
- // ───────────────────────── T2:删除能力 ─────────────────────────
- [Fact]
- public void T2_DeleteAsync_IsRetired_AndPointsToDisable()
- {
- var ex = Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().DeleteAsync(1329909460023L, Scope); });
- Assert.Equal(S8WriteRetiredException.WatchRuleDeleteMessage, ex.Message);
- Assert.Contains("停用", ex.Message); // 必须给出正确的替代动作
- }
- [Theory]
- [InlineData(0L)]
- [InlineData(-1L)]
- [InlineData(long.MaxValue)]
- public void T2_DeleteAsync_IsRetired_RegardlessOfId(long id)
- {
- // 任意 id(含越权 id)一律 410 而非 404:
- // 「这个能力没了」优先于「这条记录不属于你」,避免越权探测反推他租户数据是否存在。
- Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().DeleteAsync(id, Scope); });
- }
- [Fact]
- public void T2_DeleteEndpoint_Returns410()
- {
- var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "DeleteAsync"));
- Assert.Single(action.GetCustomAttributes<HttpDeleteAttribute>());
- Assert.NotEmpty(action.GetCustomAttributes<ObsoleteAttribute>());
- Assert.Equal(typeof(IActionResult), action.ReturnType);
- }
- // ───────────────────────── T3 / T4 / T14 / T15:Wizard 全链消失 ─────────────────────────
- /// <summary>
- /// 向导后端整体退役。它的唯一出口是"生成规则",而创建规则已经不存在,
- /// 草稿也就失去了全部意义 —— 不为"以后可能用"保留死代码。
- /// </summary>
- [Theory]
- [InlineData("Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigDraftsController")]
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.S8ConfigDraftService")]
- [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftCreateDto")]
- [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftUpdateDto")]
- [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftGenerateRuleDto")]
- [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftDetailDto")]
- public void T3_T4_WizardBackendType_NoLongerExists(string fullName)
- {
- Assert.Null(PluginAssembly.GetType(fullName, throwOnError: false));
- }
- /// <summary>T14:整个程序集里不得再有任何 wizard-drafts 路由。</summary>
- [Fact]
- public void T14_NoWizardDraftRouteRemains()
- {
- var offenders = PluginAssembly.GetTypes()
- .Where(t => typeof(ControllerBase).IsAssignableFrom(t))
- .SelectMany(t => t.GetCustomAttributes<RouteAttribute>().Select(r => r.Template ?? string.Empty))
- .Where(t => t.Contains("wizard", StringComparison.OrdinalIgnoreCase)
- || t.Contains("draft", StringComparison.OrdinalIgnoreCase))
- .ToArray();
- Assert.Empty(offenders);
- }
- /// <summary>
- /// T15:实体与表按既有口径 KEEP_TEMPORARILY(先退役 API、后物理清理)。
- ///
- /// <para><b>Batch 4 修正</b>:本用例原先断言「除实体自身外无任何类型消费它」,
- /// 那是基于 Batch 3 的一个错误结论 —— 当时审计认为草稿表只服务规则向导。
- /// 实际上主动提报页把 <c>mechanism = MANUAL_REPORT</c> 的行当作提报模板在用,
- /// Batch 3 把整个 Controller 删掉后该功能静默失效。</para>
- ///
- /// <para>现在的口径是:<b>只允许只读的模板服务消费它,且该服务不得有任何写能力</b>。
- /// 这比"零消费方"更准确,也仍然挡住"借草稿表复活建规则"。</para>
- /// </summary>
- [Fact]
- public void T15_ConfigDraftEntity_IsConsumedOnlyByReadOnlyTemplateService()
- {
- var entity = PluginAssembly.GetType("Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8ConfigDraft", throwOnError: false);
- Assert.NotNull(entity); // 表还在,等后续 schema cleanup
- var consumers = PluginAssembly.GetTypes()
- .Where(t => t != entity && !t.Name.Contains("Startup", StringComparison.Ordinal))
- // 排除编译器生成的 async 状态机:它们把外层 this 与局部变量提升成字段,
- // 因此会连带"持有"仓储类型,但那不是一个独立的消费方。
- .Where(t => !t.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false))
- .Where(t => t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
- .Any(f => f.FieldType.IsGenericType
- && f.FieldType.GetGenericArguments().Contains(entity)))
- .Select(t => t.Name)
- .ToArray();
- Assert.Equal(new[] { "S8ReportTemplateService" }, consumers);
- }
- /// <summary>模板服务必须是纯读:出现任何写动词都意味着建规则那条路可能被绕回来。</summary>
- [Fact]
- public void T15_ReportTemplateService_IsReadOnly()
- {
- var t = PluginAssembly.GetType("Admin.NET.Plugin.AiDOP.Service.S8.S8ReportTemplateService", throwOnError: true)!;
- var publicMethods = t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
- .Select(m => m.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
- Assert.Equal(new[] { "GetAsync", "ListAsync" }, publicMethods);
- foreach (var verb in new[] { "Create", "Update", "Delete", "Save", "Insert", "Generate", "Publish" })
- Assert.DoesNotContain(publicMethods, n => n.StartsWith(verb, StringComparison.Ordinal));
- }
- /// <summary>模板端点只有 GET,且用只读权限码而非规则配置权限码。</summary>
- [Fact]
- public void T15_ReportTemplateEndpoints_AreGetOnly()
- {
- var controller = PluginAssembly.GetType(
- "Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigReportTemplatesController", throwOnError: true)!;
- var actions = controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).ToArray();
- Assert.NotEmpty(actions);
- foreach (var m in actions)
- {
- Assert.NotEmpty(m.GetCustomAttributes<HttpGetAttribute>());
- Assert.Empty(m.GetCustomAttributes<HttpPostAttribute>());
- Assert.Empty(m.GetCustomAttributes<HttpPutAttribute>());
- Assert.Empty(m.GetCustomAttributes<HttpDeleteAttribute>());
- Assert.Empty(m.GetCustomAttributes<HttpPatchAttribute>());
- }
- var route = Assert.Single(controller.GetCustomAttributes<RouteAttribute>()).Template ?? string.Empty;
- Assert.DoesNotContain("draft", route, StringComparison.OrdinalIgnoreCase);
- Assert.DoesNotContain("wizard", route, StringComparison.OrdinalIgnoreCase);
- }
- // ───────────────────────── T5 / T13:其余入口 ─────────────────────────
- /// <summary>T5:CreateAsync 不再是业务生产写路径 —— 名字还在,但恒抛退役异常。</summary>
- [Fact]
- public void T5_CreateAsync_IsNotAProductionWritePath()
- {
- var m = typeof(S8WatchRuleService).GetMethod("CreateAsync",
- new[] { typeof(AdoS8WatchRule), typeof(S8TrustedScope) });
- Assert.NotNull(m);
- // 保留方法名而非删掉:断言"恒抛退役异常"比断言"名字不存在"更能防住有人换个名字复活它。
- Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(Body(), Scope); });
- // 无作用域重载不得存在。
- Assert.Null(typeof(S8WatchRuleService).GetMethod("CreateAsync", new[] { typeof(AdoS8WatchRule) }));
- }
- /// <summary>T13:TestAsync 已物理移除(能力被 GET /{id}/preview 完全覆盖)。</summary>
- [Fact]
- public void T13_TestEndpointAndServiceMethod_AreGone()
- {
- Assert.Null(typeof(S8WatchRuleService).GetMethods().FirstOrDefault(m => m.Name == "TestAsync"));
- Assert.Empty(Actions(typeof(AdoS8ConfigWatchRulesController), "TestAsync"));
- }
- // ───────────────────────── T6:唯一合法运行时创建源 ─────────────────────────
- /// <summary>
- /// T6:<b>Provisioning 是唯一合法的 runtime create source</b>。
- ///
- /// <para>注意这条守卫要表达的不是"任何 Insert 都非法" —— 那会误伤系统供给路径。
- /// 要表达的是:<c>用户/API 创建 = 禁止</c>,<c>代码供给 = 允许</c>。
- /// 因此用源码扫描找出所有对 <c>AdoS8WatchRule</c> 的 Insert 语句,
- /// 断言它们只出现在 <c>S8RuleProvisioningService</c> 里。</para>
- /// </summary>
- [Fact]
- public void T6_OnlyProvisioningInsertsWatchRules()
- {
- var root = PluginSourceRoot();
- var offenders = new List<string>();
- // 只认「声明为 SqlSugarRepository<AdoS8WatchRule> 的那个字段上的插入」。
- // 粗放地扫 AsInsertable 会误伤同一文件里对别的实体的写入 ——
- // S8WatchSchedulerService 就同时持有 watch rule 仓储(只读)与 detection state 仓储(要写)。
- var fieldDecl = new Regex(
- @"SqlSugarRepository<AdoS8WatchRule>\s+(_[A-Za-z0-9_]+)", RegexOptions.Compiled);
- foreach (var file in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
- {
- var name = Path.GetFileName(file);
- if (name == "S8RuleProvisioningService.cs") continue; // 唯一合法系统写路径
- var code = ExecutableLines(File.ReadAllText(file));
- var fields = fieldDecl.Matches(code).Select(m => m.Groups[1].Value).Distinct().ToArray();
- foreach (var line in code.Split('\n'))
- {
- var trimmed = line.Trim();
- // 形式一:显式泛型 Insertable<AdoS8WatchRule>
- if (trimmed.Contains("Insertable<AdoS8WatchRule>", StringComparison.Ordinal))
- offenders.Add($"{name}: {trimmed}");
- // 形式二:在 watch rule 仓储字段上调用 AsInsertable / InsertAsync
- foreach (var f in fields)
- {
- if (trimmed.Contains($"{f}.AsInsertable", StringComparison.Ordinal)
- || trimmed.Contains($"{f}.InsertAsync", StringComparison.Ordinal))
- offenders.Add($"{name}: {trimmed}");
- }
- }
- }
- Assert.True(offenders.Count == 0,
- "只有 S8RuleProvisioningService 允许创建监控规则运行策略行,以下位置出现了额外的写入:\n "
- + string.Join("\n ", offenders));
- }
- /// <summary>反向对照:守卫不是"恒为空"——供给服务里确实存在那唯一一处合法插入。</summary>
- [Fact]
- public void T6_ProvisioningItselfDoesInsert()
- {
- var code = File.ReadAllText(Path.Combine(PluginSourceRoot(), "Service", "S8", "S8RuleProvisioningService.cs"));
- Assert.Contains("_rep.AsInsertable(row)", code);
- }
- // ───────────────────────── 迁自原文件:仍然有效的断言 ─────────────────────────
- /// <summary>Legacy 取数 / 数据源相关类型全部不存在(与 S8LegacySymbolsRemovedTests 互补)。</summary>
- [Theory]
- [InlineData("Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8DataSource")]
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.S8DataSourceService")]
- [InlineData("Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigDataSourcesController")]
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8DataSourceRowLoader")]
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8SqlSugarScopeFactory")]
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8LegacySqlDataProvider")]
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8LegacySqlDataProvider")]
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8DataAccessMode")]
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationPolicy")]
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationOrigin")]
- // S8-RULE-GOVERNANCE-BATCH1:判定语义迁入代码定义后,三个 params 解析类一并删除。
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8TimeoutParams")]
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8ShortageParams")]
- [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8OutOfRangeParams")]
- public void LegacyDataAccessType_NoLongerExists(string fullName)
- {
- Assert.Null(PluginAssembly.GetType(fullName, throwOnError: false));
- }
- /// <summary>
- /// 取代数据源端点的是一个**只读**目录。二者性质不同,不是改名:
- /// 旧的是租户可写的物理连接配置,新的是平台级只读目录。
- /// </summary>
- [Fact]
- public void DatasetsController_IsReadOnly()
- {
- var actions = typeof(AdoS8ConfigDatasetsController)
- .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
- .ToArray();
- Assert.NotEmpty(actions);
- foreach (var m in actions)
- {
- Assert.Empty(m.GetCustomAttributes<HttpPostAttribute>());
- Assert.Empty(m.GetCustomAttributes<HttpPutAttribute>());
- Assert.Empty(m.GetCustomAttributes<HttpDeleteAttribute>());
- Assert.Empty(m.GetCustomAttributes<HttpPatchAttribute>());
- }
- }
- /// <summary>Rule 01 的数据集仍在目录内(供给出来的行必须能通过 Enable Gate 的目录检查)。</summary>
- [Fact]
- public void Rule01Dataset_RemainsDefinedInCatalog()
- {
- var catalog = new S8DatasetCatalog(new IS8DatasetDefinitionSource[]
- {
- new Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8BusinessDatasetDefinitions()
- });
- Assert.True(catalog.IsDefined("PURCHASE_DELIVERY"));
- }
- // ───────────────────────── 保留能力不得回归 ─────────────────────────
- /// <summary>
- /// T9–T11:退役不得误伤仍然合法的能力。
- /// 少一个都意味着用户连"调整已发布规则的运行策略"都做不到了。
- /// </summary>
- [Theory]
- [InlineData("UpdateParametersAsync")]
- [InlineData("EnableAsync")]
- [InlineData("DisableAsync")]
- [InlineData("UpdateScheduleAsync")]
- [InlineData("RunNowAsync")]
- [InlineData("PauseAsync")]
- [InlineData("ResumeAsync")]
- [InlineData("ListAsync")]
- public void SurvivingCapabilities_AreIntact(string method)
- {
- Assert.NotEmpty(typeof(S8WatchRuleService).GetMethods().Where(m => m.Name == method));
- }
- [Theory]
- [InlineData("PreviewAsync", "GET")]
- [InlineData("ProvisionAsync", "POST")]
- [InlineData("EnableAsync", "POST")]
- [InlineData("DisableAsync", "POST")]
- [InlineData("UpdateParamsAsync", "PUT")]
- public void SurvivingEndpoints_AreIntact(string action, string verb)
- {
- var m = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), action));
- var has = verb switch
- {
- "GET" => m.GetCustomAttributes<HttpGetAttribute>().Any(),
- "POST" => m.GetCustomAttributes<HttpPostAttribute>().Any(),
- "PUT" => m.GetCustomAttributes<HttpPutAttribute>().Any(),
- _ => false
- };
- Assert.True(has, $"{action} 必须仍是 {verb}");
- }
- private static IEnumerable<MethodInfo> Actions(Type controller, string name) =>
- controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
- .Where(m => m.Name == name);
- private static string PluginSourceRoot()
- {
- var dir = new DirectoryInfo(AppContext.BaseDirectory);
- while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
- dir = dir.Parent;
- Assert.NotNull(dir);
- return Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP");
- }
- /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
- private static string ExecutableLines(string code) =>
- string.Join('\n', code.Split('\n')
- .Where(l =>
- {
- var t = l.TrimStart();
- return !t.StartsWith("///", StringComparison.Ordinal)
- && !t.StartsWith("//", StringComparison.Ordinal)
- && !t.StartsWith("*", StringComparison.Ordinal);
- }));
- }
|