S8SysUserOnlyIdentityTests.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. using System.Reflection;
  2. using Admin.NET.Core;
  3. using Admin.NET.Plugin.AiDOP.Controllers.S8;
  4. using Admin.NET.Plugin.AiDOP.Dto.S8;
  5. using Admin.NET.Plugin.AiDOP.Entity.S8;
  6. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  7. using Admin.NET.Plugin.AiDOP.Service.S8;
  8. using Xunit;
  9. namespace Admin.NET.Plugin.AiDOP.Tests.S8;
  10. /// <summary>
  11. /// S8-SYSUSER-ONLY-1:S8 的「人」统一为系统账号(<c>SysUser.Id</c>)。
  12. ///
  13. /// <para><b>本批要消灭的失败形态</b>(全部有真库取证):
  14. /// ① <c>assignee_id</c> 34 个非空值里 20 个是 EmployeeMaster.RecID、0 个是 SysUser.Id、
  15. /// 14 个两边都不存在 —— 同一列长期装着两种不同含义的数字;
  16. /// ② <c>timeline.operator_id</c> 按动作分裂:CLAIM 写 Employee.RecID,其余写 SysUser.Id,
  17. /// 于是「这一步是谁做的」在时间线上根本无法回答;
  18. /// ③ 所有「经 EmployeeMaster.SysUserId 换算」的路径(通知、审批流转办、工作台)
  19. /// 在 1105 名员工只有 8 个绑定账号的现实下几乎总是走「换不到 → 静默跳过」分支。</para>
  20. ///
  21. /// <para><b>本文件的断言以反向为主</b>:这类回归的共同特征是"加回一个换算就恢复原状、
  22. /// 而且全链路仍然报成功",正向断言抓不到。因此这里守的是
  23. /// <b>某些符号/类型/字段不得再出现</b>。</para>
  24. /// </summary>
  25. public class S8SysUserOnlyIdentityTests
  26. {
  27. private const BindingFlags Instance =
  28. BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
  29. private static readonly string PluginRoot =
  30. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  31. /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
  32. private static string CodeOnly(string relativePath)
  33. {
  34. var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
  35. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  36. return string.Join('\n', File.ReadAllLines(full)
  37. .Where(l =>
  38. {
  39. var t = l.TrimStart();
  40. return !t.StartsWith("///", StringComparison.Ordinal)
  41. && !t.StartsWith("//", StringComparison.Ordinal);
  42. }));
  43. }
  44. /// <summary>
  45. /// 读迁移脚本的<b>可执行部分</b>。与 <see cref="CodeOnly"/> 同一原则:
  46. /// 脚本注释里要说明「为什么不能用某种写法」,必然会复述那种写法本身,
  47. /// 不剥注释的话这条守卫会被自己的说明文字触发。
  48. /// </summary>
  49. private static string MigrationSqlCodeOnly()
  50. {
  51. var full = Path.GetFullPath(Path.Combine(
  52. PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.496.sql"));
  53. Assert.True(File.Exists(full), $"迁移脚本不存在:{full}");
  54. return string.Join('\n', File.ReadAllLines(full)
  55. .Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
  56. }
  57. private static string SugarColumnName(Type type, string property)
  58. {
  59. var prop = type.GetProperty(property);
  60. Assert.NotNull(prop);
  61. var attr = prop!.GetCustomAttributes()
  62. .Single(a => a.GetType().Name == "SugarColumn");
  63. return (string)attr.GetType().GetProperty("ColumnName")!.GetValue(attr)!;
  64. }
  65. // ═══════════════════════════════════════════════════════════════════════════════
  66. // T1–T5 · 四个身份字段全部是 SysUserId
  67. // ═══════════════════════════════════════════════════════════════════════════════
  68. /// <summary>
  69. /// T1 / T2:处理人字段名与物理列名都显式带 <c>user</c>。
  70. ///
  71. /// <para>断言列名而不只断言属性名:属性改了但物理列还叫 <c>assignee_id</c> 的话,
  72. /// 读库的人、写 SQL 的人、看 dump 的人依旧无从判断这里装的是哪种 ID。</para>
  73. /// </summary>
  74. [Fact]
  75. public void T1_T2_Assignee_IsExplicitlyUserId()
  76. {
  77. Assert.NotNull(typeof(AdoS8Exception).GetProperty("AssigneeUserId"));
  78. Assert.Null(typeof(AdoS8Exception).GetProperty("AssigneeId"));
  79. Assert.Equal("assignee_user_id", SugarColumnName(typeof(AdoS8Exception), "AssigneeUserId"));
  80. }
  81. /// <summary>T3:检验人同上。</summary>
  82. [Fact]
  83. public void T3_Verifier_IsExplicitlyUserId()
  84. {
  85. Assert.NotNull(typeof(AdoS8Exception).GetProperty("VerifierUserId"));
  86. Assert.Null(typeof(AdoS8Exception).GetProperty("VerifierId"));
  87. Assert.Equal("verifier_user_id", SugarColumnName(typeof(AdoS8Exception), "VerifierUserId"));
  88. }
  89. /// <summary>T4:提报人同上。</summary>
  90. [Fact]
  91. public void T4_Reporter_IsExplicitlyUserId()
  92. {
  93. Assert.NotNull(typeof(AdoS8Exception).GetProperty("ReporterUserId"));
  94. Assert.Null(typeof(AdoS8Exception).GetProperty("ReporterId"));
  95. Assert.Equal("reporter_user_id", SugarColumnName(typeof(AdoS8Exception), "ReporterUserId"));
  96. }
  97. /// <summary>
  98. /// T5:时间线操作人同上。这一条对应的是**实测已发生**的混装:
  99. /// CLAIM 19 行是 Employee.RecID,VERIFY_SUBMITTED 里 7 行 Employee / 8 行 SysUser。
  100. /// </summary>
  101. [Fact]
  102. public void T5_TimelineOperator_IsExplicitlyUserId()
  103. {
  104. Assert.NotNull(typeof(AdoS8ExceptionTimeline).GetProperty("OperatorUserId"));
  105. Assert.Null(typeof(AdoS8ExceptionTimeline).GetProperty("OperatorId"));
  106. Assert.Equal("operator_user_id", SugarColumnName(typeof(AdoS8ExceptionTimeline), "OperatorUserId"));
  107. }
  108. // ═══════════════════════════════════════════════════════════════════════════════
  109. // T6–T9 · 账号校验:租户唯一边界,工厂不参与
  110. // ═══════════════════════════════════════════════════════════════════════════════
  111. /// <summary>
  112. /// T6 / T7 / T8:校验判据是「主键 + 本租户 + 启用」三条,一条不少、一条不多。
  113. ///
  114. /// <para>用源码断言而非行为断言,是因为这三条的失效方式都是**放宽谓词**:
  115. /// 少一条 <c>TenantId ==</c> 就能把别家账号设成本租户的处理人,
  116. /// 少一条 <c>Status == Enable</c> 就能把已停用账号派单出去。
  117. /// 两者都不会报错,只会静默扩大可选集合。</para>
  118. /// </summary>
  119. [Fact]
  120. public void T6_T7_T8_UserValidation_RequiresTenantAndActive()
  121. {
  122. var code = CodeOnly("Infrastructure/S8/S8UserScopeValidator.cs");
  123. Assert.Contains("x.TenantId == tenantId && x.Status == StatusEnum.Enable", code);
  124. // 跨租户与不存在共用同一文案,避免账号探测。
  125. Assert.Contains("不存在或已停用", code);
  126. }
  127. /// <summary>
  128. /// T9:工厂与工厂号一律不参与人员判定。
  129. ///
  130. /// <para>真库取证:<c>factory_ref_id = 1000</c> 同时属于 824585161322565 与 797403760988229,
  131. /// 按工厂校验会把另一租户的人判为合法。这一条断言的正是「不许再把它加回来」。</para>
  132. /// </summary>
  133. [Fact]
  134. public void T9_UserValidation_HasNoFactoryPredicate()
  135. {
  136. var code = CodeOnly("Infrastructure/S8/S8UserScopeValidator.cs");
  137. Assert.DoesNotContain("FactoryId", code);
  138. Assert.DoesNotContain("FactoryRefId", code);
  139. }
  140. /// <summary>
  141. /// 账号合法性判定必须<b>只有一份实现</b>。
  142. ///
  143. /// <para>与 <c>IS8DepartmentScopeValidator</c> 同一动机:认领 / 转派 / 复检 / 通知
  144. /// 都要回答同一个问题,各写一套 SQL 迟早出现「转派检查通过、通知却发不出去」。</para>
  145. /// </summary>
  146. [Fact]
  147. public void UserValidity_HasSingleImplementation()
  148. {
  149. var impls = typeof(IS8UserScopeValidator).Assembly.GetTypes()
  150. .Where(t => t is { IsAbstract: false, IsInterface: false }
  151. && typeof(IS8UserScopeValidator).IsAssignableFrom(t))
  152. .ToList();
  153. Assert.Single(impls);
  154. Assert.Equal(typeof(S8UserScopeValidator), impls[0]);
  155. // 别处不得再手写「本租户 + 启用」的账号查询。
  156. foreach (var f in new[]
  157. {
  158. "Service/S8/S8TaskFlowService.cs",
  159. "Service/S8/S8ExceptionService.cs",
  160. "Service/S8/S8ManualReportService.cs",
  161. })
  162. {
  163. Assert.DoesNotContain("Status == StatusEnum.Enable", CodeOnly(f));
  164. }
  165. }
  166. // ═══════════════════════════════════════════════════════════════════════════════
  167. // T10–T12 · Employee 已彻底退出人员主链
  168. // ═══════════════════════════════════════════════════════════════════════════════
  169. /// <summary>
  170. /// T10 / T11 / T12 / T21:人员主链上不得再出现任何 Employee 换算。
  171. ///
  172. /// <para>这是本批最重要的一条。桥的危险不在于它错,而在于它**换不到时的行为是静默跳过** ——
  173. /// 通知不发、审批流不转办、工作台整块显示"未绑定员工",而调用方一律看到成功。</para>
  174. /// </summary>
  175. [Theory]
  176. [InlineData("Service/S8/S8TaskFlowService.cs")] // T10 Claim / T11 Transfer / T12 Verify
  177. [InlineData("Service/S8/S8ManualReportService.cs")] // 提报人
  178. [InlineData("Service/S8/S8ExceptionService.cs")] // 列表/详情水合
  179. [InlineData("Service/S8/ExceptionEscalationBizHandler.cs")] // 升级回调
  180. [InlineData("Service/S8/S8MasterDataAdapter.cs")] // 人员选择器
  181. [InlineData("Workbench/AidopWorkbenchService.cs")] // 我的待办
  182. public void T10_T11_T12_T21_NoEmployeeBridgeInPersonChain(string relativePath)
  183. {
  184. var code = CodeOnly(relativePath);
  185. Assert.DoesNotContain("AdoS0EmployeeMaster", code);
  186. Assert.DoesNotContain("EmployeeMaster", code);
  187. Assert.DoesNotContain("GetEmployeeSysUserId", code);
  188. Assert.DoesNotContain("SysUserId ==", code);
  189. }
  190. /// <summary>
  191. /// 工作台不得再因「未绑定员工」整块不可用。
  192. /// 该分支在 1105 : 8 的真实绑定率下,对绝大多数账号恒为真。
  193. /// </summary>
  194. [Fact]
  195. public void T21b_Workbench_HasNoEmployeeUnboundBranch()
  196. {
  197. var code = CodeOnly("Workbench/AidopWorkbenchService.cs");
  198. Assert.DoesNotContain("EMPLOYEE_UNBOUND", code);
  199. }
  200. /// <summary>
  201. /// T13:通知投递直接落到系统账号,不再有 employeeRecId 形参。
  202. ///
  203. /// <para>S8-NOTIFY-RECIPIENT-1 之后收件人不再写死在代码里:原 <c>NotifyUserAsync(exception, sysUserId, title)</c>
  204. /// 已由 <c>DispatchEventAsync(exception, eventCode, ...)</c> 取代,收件人由统一模型解析、
  205. /// 输出仍然只可能是 <c>SysUser.Id</c>。守卫对象随之改为「派发按事件走统一链路,
  206. /// 且 Employee 桥不得复活」—— 守的仍是同一件事:通知的收件人只有账号一种身份。</para>
  207. /// </summary>
  208. [Fact]
  209. public void T13_Notification_TakesSysUserIdDirectly()
  210. {
  211. var dispatch = typeof(S8TaskFlowService).GetMethod("DispatchEventAsync", Instance);
  212. Assert.NotNull(dispatch);
  213. Assert.Contains(dispatch!.GetParameters(), p => p.Name == "eventCode");
  214. // Employee 桥与写死收件人的旧方法都必须已消失
  215. Assert.Null(typeof(S8TaskFlowService).GetMethod("NotifyUserAsync", Instance));
  216. Assert.Null(typeof(S8TaskFlowService).GetMethod("NotifyAssigneeAsync", Instance));
  217. Assert.Null(typeof(S8TaskFlowService).GetMethod("GetEmployeeSysUserIdAsync", Instance));
  218. // 解析器输出的仍然只能是账号 Id
  219. var resolver = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
  220. Assert.Contains("List<long> UserIds", resolver);
  221. Assert.DoesNotContain("EmployeeMaster", resolver);
  222. }
  223. // ═══════════════════════════════════════════════════════════════════════════════
  224. // T14–T15 · First Claim Wins
  225. // ═══════════════════════════════════════════════════════════════════════════════
  226. /// <summary>
  227. /// T14 / T15:并发认领由**一条带前置条件的 UPDATE** 裁决,第二个认领者被拒。
  228. ///
  229. /// <para>断言的是"没有先读后写的窗口":谓词必须同时含
  230. /// <c>Status == fromStatus</c> 与 <c>AssigneeUserId == null</c>,且影响行数 0 时报错。
  231. /// 若改成 SELECT 判空 → UPDATE,两个人可以同时通过判空、同时写入,
  232. /// 最终 assignee 是后写的那个,而两个人都收到"认领成功"。</para>
  233. /// </summary>
  234. [Fact]
  235. public void T14_T15_Claim_IsAtomicFirstWins()
  236. {
  237. var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
  238. Assert.Contains("x.Status == fromStatus", code);
  239. Assert.Contains("x.AssigneeUserId == null", code);
  240. Assert.Contains("ExecuteCommandAsync()", code);
  241. Assert.Contains("affected == 0", code);
  242. Assert.Contains("该异常已被其他人员认领", code);
  243. }
  244. /// <summary>
  245. /// T14b:认领人只能是当前登录账号 —— 方法签名里<b>没有</b> assignee 形参。
  246. ///
  247. /// <para>旧签名收 <c>assigneeId</c>,意味着持 <c>ExceptionClaim</c>(弱权限)的人
  248. /// 能做 <c>ExceptionAssign</c>(强权限)才该做的事:替别人认领。</para>
  249. /// </summary>
  250. [Fact]
  251. public void T14b_Claim_TakesNoAssigneeFromClient()
  252. {
  253. var claim = typeof(S8TaskFlowService).GetMethod("ClaimAsync", Instance);
  254. Assert.NotNull(claim);
  255. Assert.DoesNotContain(claim!.GetParameters(),
  256. p => p.Name!.Contains("assignee", StringComparison.OrdinalIgnoreCase));
  257. // 请求体里也不能留一个"服务端会忽略"的字段。
  258. Assert.Null(typeof(AdoS8ClaimBody).GetProperty("AssigneeId"));
  259. Assert.Null(typeof(AdoS8ClaimBody).GetProperty("AssigneeUserId"));
  260. }
  261. // ═══════════════════════════════════════════════════════════════════════════════
  262. // T22 · Timeline 单一 ID 空间
  263. // ═══════════════════════════════════════════════════════════════════════════════
  264. /// <summary>
  265. /// T22:写时间线的形参叫 <c>operatorUserId</c>,且 CLAIM 写的是当前登录账号。
  266. ///
  267. /// <para>旧实现 CLAIM 传的是 <c>assigneeId</c>(Employee.RecID),
  268. /// 其余动作传 <c>currentUserId</c>(SysUser.Id)—— 混装由此产生。</para>
  269. /// </summary>
  270. [Fact]
  271. public void T22_Timeline_HasSingleIdSpace()
  272. {
  273. var insert = typeof(S8TaskFlowService).GetMethod("InsertTimelineAsync", Instance);
  274. Assert.NotNull(insert);
  275. Assert.Contains(insert!.GetParameters(), p => p.Name == "operatorUserId");
  276. Assert.DoesNotContain(insert.GetParameters(), p => p.Name == "operatorId");
  277. var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
  278. Assert.Contains("\"CLAIM\", \"认领\", fromStatus, \"ASSIGNED\", currentUserId", code);
  279. }
  280. // ═══════════════════════════════════════════════════════════════════════════════
  281. // T24 · RESET 白名单守卫
  282. // ═══════════════════════════════════════════════════════════════════════════════
  283. /// <summary>
  284. /// T16 / T17 / T24:迁移的 RESET 范围必须是**硬编码白名单**。
  285. ///
  286. /// <para>最危险的写法是按表名前缀做通配删除 —— 它会把<b>将来才新增的表</b>
  287. /// 也一并删掉,而写脚本的人当时根本看不到那些表。
  288. /// 同样危险的是整表清空 <c>ApprovalFlowInstance/Task</c>:这两张表由 S5/S6/S7 共用。</para>
  289. /// </summary>
  290. [Fact]
  291. public void T16_T17_T24_Migration_UsesExplicitResetWhitelist()
  292. {
  293. var sql = MigrationSqlCodeOnly();
  294. // ① 不得有任何通配删除
  295. Assert.DoesNotContain("LIKE 'ado_s8_%'", sql);
  296. Assert.DoesNotContain("LIKE \"ado_s8_%\"", sql);
  297. // ② 审批流必须按 BizType 定点删,不得整表清空
  298. Assert.Contains("'EXCEPTION_REPORT','EXCEPTION_ESCALATION','EXCEPTION_CLOSURE'", sql);
  299. Assert.DoesNotContain("DELETE FROM `ApprovalFlowInstance`;", sql);
  300. Assert.DoesNotContain("DELETE FROM `ApprovalFlowTask`;", sql);
  301. Assert.DoesNotContain("TRUNCATE", sql);
  302. // ③ 平台主数据与配置态一行都不许删;脚本自带断言兜底
  303. foreach (var master in new[] { "SysUser", "SysRole", "EmployeeMaster", "DepartmentMaster" })
  304. Assert.DoesNotContain($"DELETE FROM `{master}`", sql);
  305. foreach (var config in new[]
  306. {
  307. "ado_s8_exception_type", "ado_s8_scene_config",
  308. "ado_s8_notification_layer", "ado_s8_role_permission_config",
  309. "ado_s8_dashboard_cell_config", "ado_s8_watch_rule",
  310. })
  311. Assert.DoesNotContain($"DELETE FROM `{config}`", sql);
  312. Assert.Contains("s8_496_BLOCKED_platform_master_wiped", sql);
  313. Assert.Contains("s8_496_BLOCKED_product_config_wiped", sql);
  314. // ④ AutoVersionUpdate 对含该关键字的脚本一律拒绝执行,且它是**朴素子串匹配** ——
  315. // 注释里出现也照样拦。所以这一条必须看整个文件,不能用剥注释后的版本。
  316. Assert.DoesNotContain("DELIMITER", File.ReadAllText(Path.GetFullPath(Path.Combine(
  317. PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.496.sql"))));
  318. }
  319. /// <summary>
  320. /// T18 / T19:Provisioning 新建的规则行恒为停用,且不写任何人员字段。
  321. ///
  322. /// <para>reset 之后管理员必须重新配置再启用 —— 不得出现"清完库自己就跑起来了"。</para>
  323. /// </summary>
  324. [Fact]
  325. public void T18_T19_Provisioning_CreatesDisabledRuleWithoutPeople()
  326. {
  327. var code = CodeOnly("Service/S8/S8RuleProvisioningService.cs");
  328. Assert.Contains("Enabled = false", code);
  329. foreach (var forbidden in new[] { "AssigneeUserId", "VerifierUserId", "HandlerUser", "SysUser" })
  330. Assert.DoesNotContain(forbidden, code);
  331. }
  332. /// <summary>
  333. /// T20:迁移可重复执行。三种到场状态(只有旧列 / 新旧都有 / 只有新列)都要能收敛,
  334. /// 否则第二次启动会因 <c>ALTER</c> 报错而整个 AutoVersionUpdate 中断。
  335. /// </summary>
  336. [Fact]
  337. public void T20_Migration_IsIdempotent()
  338. {
  339. var sql = MigrationSqlCodeOnly();
  340. Assert.Contains("CREATE TABLE IF NOT EXISTS", sql);
  341. Assert.Contains("information_schema.COLUMNS", sql);
  342. // 三分支:改名 / 丢弃重复旧列 / 什么都不做
  343. Assert.Contains("CHANGE COLUMN `assignee_id` `assignee_user_id`", sql);
  344. Assert.Contains("DROP COLUMN `assignee_id`", sql);
  345. Assert.Contains("'DO 0'", sql);
  346. }
  347. // ═══════════════════════════════════════════════════════════════════════════════
  348. // 退役入口
  349. // ═══════════════════════════════════════════════════════════════════════════════
  350. /// <summary>
  351. /// 「操作员绑定」必须整体退役,且退役后的入口<b>不再具备写 EmployeeMaster 的能力</b>。
  352. ///
  353. /// <para>只把菜单藏起来是不够的:留着一个能写主数据的 API,
  354. /// 下一个人照样会顺着它把 Employee 绑定重新接回人员主链。</para>
  355. /// </summary>
  356. [Fact]
  357. public void OperatorBinding_IsFullyRetired()
  358. {
  359. Assert.Null(Type.GetType("Admin.NET.Plugin.AiDOP.Service.S8.S8OperatorBindingService, Admin.NET.Plugin.AiDOP"));
  360. Assert.False(File.Exists(Path.Combine(PluginRoot, "Service", "S8", "S8OperatorBindingService.cs")));
  361. var code = CodeOnly("Controllers/S8/AdoS8ConfigBindingsController.cs");
  362. Assert.Contains("Status410Gone", code);
  363. Assert.DoesNotContain("EmployeeMaster", code);
  364. Assert.DoesNotContain("S8OperatorBindingService", code);
  365. }
  366. /// <summary>
  367. /// 人员选择器只能列系统账号。
  368. /// 旧的 <c>employees</c> 端点保留但返回 410,好让旧客户端拿到明确原因而不是 404。
  369. /// </summary>
  370. [Fact]
  371. public void PersonPicker_ListsSysUsersOnly()
  372. {
  373. Assert.NotNull(typeof(S8MasterDataAdapter).GetMethod("GetOperatorUsersAsync"));
  374. Assert.Null(typeof(S8MasterDataAdapter).GetMethod("GetEmployeesAsync"));
  375. var code = CodeOnly("Controllers/S8/AdoS8MasterDataController.cs");
  376. Assert.Contains("HttpGet(\"users\")", code);
  377. Assert.Contains("Status410Gone", code);
  378. }
  379. /// <summary>
  380. /// T23:本批不含任何指向共享库的写操作。
  381. /// 迁移只在本地执行;共享 165 的执行时点由发布流程决定,不由代码触发。
  382. /// </summary>
  383. [Fact]
  384. public void T23_NoSharedDbHostAppearsInMigrationOrCode()
  385. {
  386. // T23 刻意读<b>整个文件含注释</b>:共享库主机名出现在注释里同样是泄漏。
  387. var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
  388. PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.496.sql")));
  389. Assert.DoesNotContain("123.60.180.165", sql);
  390. Assert.DoesNotContain("aidopdev", sql);
  391. }
  392. }