S8RuleCreationRetiredTests.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. using System.Reflection;
  2. using System.Text.RegularExpressions;
  3. using System.Runtime.CompilerServices;
  4. using Microsoft.AspNetCore.Mvc;
  5. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  6. using Admin.NET.Plugin.AiDOP.Entity.S8;
  7. using Admin.NET.Plugin.AiDOP.Infrastructure;
  8. using Admin.NET.Plugin.AiDOP.Service.S8;
  9. using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
  10. using Xunit;
  11. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  12. /// <summary>
  13. /// S8-RULE-GOVERNANCE-BATCH3:<b>业务侧根本不能创建 / 删除监控规则</b>。
  14. ///
  15. /// <para>本文件取代了原 <c>S8DatasetOnlyRuleCreationTests</c>。那个名字守护的是
  16. /// 「创建规则时只能选治理数据集」—— 一条已经过时的口径:现在不存在"创建规则"这件事,
  17. /// 继续用那个名字会让读者以为 Rule Builder 还在,只是被限制了选项。</para>
  18. ///
  19. /// <para>最终模型:</para>
  20. /// <code>
  21. /// 业务需求 → 研发实现 S8RuleDefinition → S8RuleCatalog → Release
  22. /// → S8RuleProvisioningService → 每租户一条 Runtime Policy(默认停用)
  23. /// </code>
  24. /// <para>用户能做的只有:调整已发布规则的运行参数、启停、预演、立即执行、暂停恢复。</para>
  25. ///
  26. /// <para>原文件中仍然有效的断言(Legacy 类型不存在 / 数据集端点只读 / Rule 01 形态可表达)
  27. /// 已逐条迁入本文件,未丢失覆盖。</para>
  28. ///
  29. /// <para>不接 DB、不接 DI:退役守卫在**任何 DB 访问之前**抛出,因此用
  30. /// <see cref="RuntimeHelpers.GetUninitializedObject"/> 绕开构造函数即可驱动。
  31. /// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 失败 ——
  32. /// 这正是我们要的信号:<b>守卫必须前置</b>。</para>
  33. /// </summary>
  34. public class S8RuleCreationRetiredTests
  35. {
  36. private static readonly Assembly PluginAssembly = typeof(AdoS8WatchRule).Assembly;
  37. private static S8WatchRuleService Svc() =>
  38. (S8WatchRuleService)RuntimeHelpers.GetUninitializedObject(typeof(S8WatchRuleService));
  39. private static readonly S8TrustedScope Scope = new(838257186181189L, 838257186320453L);
  40. private static AdoS8WatchRule Body() => new()
  41. {
  42. RuleCode = "RULE_A_USER_INVENTED",
  43. SceneCode = "S1",
  44. RuleType = "TIMEOUT",
  45. DatasetCode = "PURCHASE_DELIVERY",
  46. WatchObjectType = "ORDER",
  47. PollIntervalSeconds = 300
  48. };
  49. // ───────────────────────── T1:创建能力 ─────────────────────────
  50. [Fact]
  51. public void T1_CreateAsync_IsRetired_WithGuidanceMessage()
  52. {
  53. var ex = Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(Body(), Scope); });
  54. Assert.Equal(S8WriteRetiredException.WatchRuleCreateMessage, ex.Message);
  55. // 文案必须说清"新规则从哪来",否则用户只会以为接口坏了。
  56. Assert.Contains("系统版本定义", ex.Message);
  57. Assert.DoesNotContain("unsupported", ex.Message, StringComparison.OrdinalIgnoreCase);
  58. Assert.DoesNotContain("unknown", ex.Message, StringComparison.OrdinalIgnoreCase);
  59. // 与"整实体更新退役"是不同的事,文案不得混用。
  60. Assert.NotEqual(S8WriteRetiredException.WatchRuleUpdateMessage, ex.Message);
  61. }
  62. /// <summary>入参是否"合法"与能力是否存在无关:任何 body 都必须 410,且零 DB 访问。</summary>
  63. [Fact]
  64. public void T1_CreateAsync_IsRetired_RegardlessOfPayload()
  65. {
  66. Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(new AdoS8WatchRule(), Scope); });
  67. var wellFormed = Body();
  68. wellFormed.RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY"; // 连"合法"的编码也不行
  69. Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(wellFormed, Scope); });
  70. }
  71. [Fact]
  72. public void T1_CreateEndpoint_Returns410_AndTouchesNoScope()
  73. {
  74. var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "CreateAsync"));
  75. // 墓碑而非删 route:直接删会让遗留调用方拿到 405(该路径上还有 GET),
  76. // 而 405 说的是"方法不对",会让人以为换个动词就能建。
  77. Assert.Single(action.GetCustomAttributes<HttpPostAttribute>());
  78. Assert.NotEmpty(action.GetCustomAttributes<ObsoleteAttribute>());
  79. // 同步返回:能力已退役,不该为构造一个用不到的 scope 去 await 组织库查询。
  80. Assert.Equal(typeof(IActionResult), action.ReturnType);
  81. }
  82. // ───────────────────────── T2:删除能力 ─────────────────────────
  83. [Fact]
  84. public void T2_DeleteAsync_IsRetired_AndPointsToDisable()
  85. {
  86. var ex = Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().DeleteAsync(1329909460023L, Scope); });
  87. Assert.Equal(S8WriteRetiredException.WatchRuleDeleteMessage, ex.Message);
  88. Assert.Contains("停用", ex.Message); // 必须给出正确的替代动作
  89. }
  90. [Theory]
  91. [InlineData(0L)]
  92. [InlineData(-1L)]
  93. [InlineData(long.MaxValue)]
  94. public void T2_DeleteAsync_IsRetired_RegardlessOfId(long id)
  95. {
  96. // 任意 id(含越权 id)一律 410 而非 404:
  97. // 「这个能力没了」优先于「这条记录不属于你」,避免越权探测反推他租户数据是否存在。
  98. Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().DeleteAsync(id, Scope); });
  99. }
  100. [Fact]
  101. public void T2_DeleteEndpoint_Returns410()
  102. {
  103. var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "DeleteAsync"));
  104. Assert.Single(action.GetCustomAttributes<HttpDeleteAttribute>());
  105. Assert.NotEmpty(action.GetCustomAttributes<ObsoleteAttribute>());
  106. Assert.Equal(typeof(IActionResult), action.ReturnType);
  107. }
  108. // ───────────────────────── T3 / T4 / T14 / T15:Wizard 全链消失 ─────────────────────────
  109. /// <summary>
  110. /// 向导后端整体退役。它的唯一出口是"生成规则",而创建规则已经不存在,
  111. /// 草稿也就失去了全部意义 —— 不为"以后可能用"保留死代码。
  112. /// </summary>
  113. [Theory]
  114. [InlineData("Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigDraftsController")]
  115. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.S8ConfigDraftService")]
  116. [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftCreateDto")]
  117. [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftUpdateDto")]
  118. [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftGenerateRuleDto")]
  119. [InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftDetailDto")]
  120. public void T3_T4_WizardBackendType_NoLongerExists(string fullName)
  121. {
  122. Assert.Null(PluginAssembly.GetType(fullName, throwOnError: false));
  123. }
  124. /// <summary>T14:整个程序集里不得再有任何 wizard-drafts 路由。</summary>
  125. [Fact]
  126. public void T14_NoWizardDraftRouteRemains()
  127. {
  128. var offenders = PluginAssembly.GetTypes()
  129. .Where(t => typeof(ControllerBase).IsAssignableFrom(t))
  130. .SelectMany(t => t.GetCustomAttributes<RouteAttribute>().Select(r => r.Template ?? string.Empty))
  131. .Where(t => t.Contains("wizard", StringComparison.OrdinalIgnoreCase)
  132. || t.Contains("draft", StringComparison.OrdinalIgnoreCase))
  133. .ToArray();
  134. Assert.Empty(offenders);
  135. }
  136. /// <summary>
  137. /// T15:实体与表按既有口径 KEEP_TEMPORARILY(先退役 API、后物理清理),
  138. /// 但**运行代码不得再读写它** —— 除实体自身外不应有任何类型引用它。
  139. /// </summary>
  140. [Fact]
  141. public void T15_ConfigDraftEntity_Survives_ButNoProductionCodeConsumesIt()
  142. {
  143. var entity = PluginAssembly.GetType("Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8ConfigDraft", throwOnError: false);
  144. Assert.NotNull(entity); // 表还在,等后续 schema cleanup
  145. var consumers = PluginAssembly.GetTypes()
  146. .Where(t => t != entity && !t.Name.Contains("Startup", StringComparison.Ordinal))
  147. .Where(t => t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
  148. .Any(f => f.FieldType.IsGenericType
  149. && f.FieldType.GetGenericArguments().Contains(entity)))
  150. .Select(t => t.FullName)
  151. .ToArray();
  152. Assert.Empty(consumers);
  153. }
  154. // ───────────────────────── T5 / T13:其余入口 ─────────────────────────
  155. /// <summary>T5:CreateAsync 不再是业务生产写路径 —— 名字还在,但恒抛退役异常。</summary>
  156. [Fact]
  157. public void T5_CreateAsync_IsNotAProductionWritePath()
  158. {
  159. var m = typeof(S8WatchRuleService).GetMethod("CreateAsync",
  160. new[] { typeof(AdoS8WatchRule), typeof(S8TrustedScope) });
  161. Assert.NotNull(m);
  162. // 保留方法名而非删掉:断言"恒抛退役异常"比断言"名字不存在"更能防住有人换个名字复活它。
  163. Assert.Throws<S8WriteRetiredException>(() => { _ = Svc().CreateAsync(Body(), Scope); });
  164. // 无作用域重载不得存在。
  165. Assert.Null(typeof(S8WatchRuleService).GetMethod("CreateAsync", new[] { typeof(AdoS8WatchRule) }));
  166. }
  167. /// <summary>T13:TestAsync 已物理移除(能力被 GET /{id}/preview 完全覆盖)。</summary>
  168. [Fact]
  169. public void T13_TestEndpointAndServiceMethod_AreGone()
  170. {
  171. Assert.Null(typeof(S8WatchRuleService).GetMethods().FirstOrDefault(m => m.Name == "TestAsync"));
  172. Assert.Empty(Actions(typeof(AdoS8ConfigWatchRulesController), "TestAsync"));
  173. }
  174. // ───────────────────────── T6:唯一合法运行时创建源 ─────────────────────────
  175. /// <summary>
  176. /// T6:<b>Provisioning 是唯一合法的 runtime create source</b>。
  177. ///
  178. /// <para>注意这条守卫要表达的不是"任何 Insert 都非法" —— 那会误伤系统供给路径。
  179. /// 要表达的是:<c>用户/API 创建 = 禁止</c>,<c>代码供给 = 允许</c>。
  180. /// 因此用源码扫描找出所有对 <c>AdoS8WatchRule</c> 的 Insert 语句,
  181. /// 断言它们只出现在 <c>S8RuleProvisioningService</c> 里。</para>
  182. /// </summary>
  183. [Fact]
  184. public void T6_OnlyProvisioningInsertsWatchRules()
  185. {
  186. var root = PluginSourceRoot();
  187. var offenders = new List<string>();
  188. // 只认「声明为 SqlSugarRepository<AdoS8WatchRule> 的那个字段上的插入」。
  189. // 粗放地扫 AsInsertable 会误伤同一文件里对别的实体的写入 ——
  190. // S8WatchSchedulerService 就同时持有 watch rule 仓储(只读)与 detection state 仓储(要写)。
  191. var fieldDecl = new Regex(
  192. @"SqlSugarRepository<AdoS8WatchRule>\s+(_[A-Za-z0-9_]+)", RegexOptions.Compiled);
  193. foreach (var file in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
  194. {
  195. var name = Path.GetFileName(file);
  196. if (name == "S8RuleProvisioningService.cs") continue; // 唯一合法系统写路径
  197. var code = ExecutableLines(File.ReadAllText(file));
  198. var fields = fieldDecl.Matches(code).Select(m => m.Groups[1].Value).Distinct().ToArray();
  199. foreach (var line in code.Split('\n'))
  200. {
  201. var trimmed = line.Trim();
  202. // 形式一:显式泛型 Insertable<AdoS8WatchRule>
  203. if (trimmed.Contains("Insertable<AdoS8WatchRule>", StringComparison.Ordinal))
  204. offenders.Add($"{name}: {trimmed}");
  205. // 形式二:在 watch rule 仓储字段上调用 AsInsertable / InsertAsync
  206. foreach (var f in fields)
  207. {
  208. if (trimmed.Contains($"{f}.AsInsertable", StringComparison.Ordinal)
  209. || trimmed.Contains($"{f}.InsertAsync", StringComparison.Ordinal))
  210. offenders.Add($"{name}: {trimmed}");
  211. }
  212. }
  213. }
  214. Assert.True(offenders.Count == 0,
  215. "只有 S8RuleProvisioningService 允许创建监控规则运行策略行,以下位置出现了额外的写入:\n "
  216. + string.Join("\n ", offenders));
  217. }
  218. /// <summary>反向对照:守卫不是"恒为空"——供给服务里确实存在那唯一一处合法插入。</summary>
  219. [Fact]
  220. public void T6_ProvisioningItselfDoesInsert()
  221. {
  222. var code = File.ReadAllText(Path.Combine(PluginSourceRoot(), "Service", "S8", "S8RuleProvisioningService.cs"));
  223. Assert.Contains("_rep.AsInsertable(row)", code);
  224. }
  225. // ───────────────────────── 迁自原文件:仍然有效的断言 ─────────────────────────
  226. /// <summary>Legacy 取数 / 数据源相关类型全部不存在(与 S8LegacySymbolsRemovedTests 互补)。</summary>
  227. [Theory]
  228. [InlineData("Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8DataSource")]
  229. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.S8DataSourceService")]
  230. [InlineData("Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigDataSourcesController")]
  231. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8DataSourceRowLoader")]
  232. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8SqlSugarScopeFactory")]
  233. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8LegacySqlDataProvider")]
  234. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8LegacySqlDataProvider")]
  235. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8DataAccessMode")]
  236. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationPolicy")]
  237. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationOrigin")]
  238. // S8-RULE-GOVERNANCE-BATCH1:判定语义迁入代码定义后,三个 params 解析类一并删除。
  239. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8TimeoutParams")]
  240. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8ShortageParams")]
  241. [InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8OutOfRangeParams")]
  242. public void LegacyDataAccessType_NoLongerExists(string fullName)
  243. {
  244. Assert.Null(PluginAssembly.GetType(fullName, throwOnError: false));
  245. }
  246. /// <summary>
  247. /// 取代数据源端点的是一个**只读**目录。二者性质不同,不是改名:
  248. /// 旧的是租户可写的物理连接配置,新的是平台级只读目录。
  249. /// </summary>
  250. [Fact]
  251. public void DatasetsController_IsReadOnly()
  252. {
  253. var actions = typeof(AdoS8ConfigDatasetsController)
  254. .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
  255. .ToArray();
  256. Assert.NotEmpty(actions);
  257. foreach (var m in actions)
  258. {
  259. Assert.Empty(m.GetCustomAttributes<HttpPostAttribute>());
  260. Assert.Empty(m.GetCustomAttributes<HttpPutAttribute>());
  261. Assert.Empty(m.GetCustomAttributes<HttpDeleteAttribute>());
  262. Assert.Empty(m.GetCustomAttributes<HttpPatchAttribute>());
  263. }
  264. }
  265. /// <summary>Rule 01 的数据集仍在目录内(供给出来的行必须能通过 Enable Gate 的目录检查)。</summary>
  266. [Fact]
  267. public void Rule01Dataset_RemainsDefinedInCatalog()
  268. {
  269. var catalog = new S8DatasetCatalog(new IS8DatasetDefinitionSource[]
  270. {
  271. new Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8BusinessDatasetDefinitions()
  272. });
  273. Assert.True(catalog.IsDefined("PURCHASE_DELIVERY"));
  274. }
  275. // ───────────────────────── 保留能力不得回归 ─────────────────────────
  276. /// <summary>
  277. /// T9–T11:退役不得误伤仍然合法的能力。
  278. /// 少一个都意味着用户连"调整已发布规则的运行策略"都做不到了。
  279. /// </summary>
  280. [Theory]
  281. [InlineData("UpdateParametersAsync")]
  282. [InlineData("EnableAsync")]
  283. [InlineData("DisableAsync")]
  284. [InlineData("UpdateScheduleAsync")]
  285. [InlineData("RunNowAsync")]
  286. [InlineData("PauseAsync")]
  287. [InlineData("ResumeAsync")]
  288. [InlineData("ListAsync")]
  289. public void SurvivingCapabilities_AreIntact(string method)
  290. {
  291. Assert.NotEmpty(typeof(S8WatchRuleService).GetMethods().Where(m => m.Name == method));
  292. }
  293. [Theory]
  294. [InlineData("PreviewAsync", "GET")]
  295. [InlineData("ProvisionAsync", "POST")]
  296. [InlineData("EnableAsync", "POST")]
  297. [InlineData("DisableAsync", "POST")]
  298. [InlineData("UpdateParamsAsync", "PUT")]
  299. public void SurvivingEndpoints_AreIntact(string action, string verb)
  300. {
  301. var m = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), action));
  302. var has = verb switch
  303. {
  304. "GET" => m.GetCustomAttributes<HttpGetAttribute>().Any(),
  305. "POST" => m.GetCustomAttributes<HttpPostAttribute>().Any(),
  306. "PUT" => m.GetCustomAttributes<HttpPutAttribute>().Any(),
  307. _ => false
  308. };
  309. Assert.True(has, $"{action} 必须仍是 {verb}");
  310. }
  311. private static IEnumerable<MethodInfo> Actions(Type controller, string name) =>
  312. controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
  313. .Where(m => m.Name == name);
  314. private static string PluginSourceRoot()
  315. {
  316. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  317. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
  318. dir = dir.Parent;
  319. Assert.NotNull(dir);
  320. return Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP");
  321. }
  322. /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
  323. private static string ExecutableLines(string code) =>
  324. string.Join('\n', code.Split('\n')
  325. .Where(l =>
  326. {
  327. var t = l.TrimStart();
  328. return !t.StartsWith("///", StringComparison.Ordinal)
  329. && !t.StartsWith("//", StringComparison.Ordinal)
  330. && !t.StartsWith("*", StringComparison.Ordinal);
  331. }));
  332. }