using System.Reflection; using Admin.NET.Core; using Admin.NET.Plugin.AiDOP.Controllers.S8; using Admin.NET.Plugin.AiDOP.Dto.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-SYSUSER-ONLY-1:S8 的「人」统一为系统账号(SysUser.Id)。 /// /// 本批要消灭的失败形态(全部有真库取证): /// ① assignee_id 34 个非空值里 20 个是 EmployeeMaster.RecID、0 个是 SysUser.Id、 /// 14 个两边都不存在 —— 同一列长期装着两种不同含义的数字; /// ② timeline.operator_id 按动作分裂:CLAIM 写 Employee.RecID,其余写 SysUser.Id, /// 于是「这一步是谁做的」在时间线上根本无法回答; /// ③ 所有「经 EmployeeMaster.SysUserId 换算」的路径(通知、审批流转办、工作台) /// 在 1105 名员工只有 8 个绑定账号的现实下几乎总是走「换不到 → 静默跳过」分支。 /// /// 本文件的断言以反向为主:这类回归的共同特征是"加回一个换算就恢复原状、 /// 而且全链路仍然报成功",正向断言抓不到。因此这里守的是 /// 某些符号/类型/字段不得再出现 /// public class S8SysUserOnlyIdentityTests { private const BindingFlags Instance = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; private static readonly string PluginRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); /// 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。 private static string CodeOnly(string relativePath) { var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}"); return string.Join('\n', File.ReadAllLines(full) .Where(l => { var t = l.TrimStart(); return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal); })); } /// /// 读迁移脚本的可执行部分。与 同一原则: /// 脚本注释里要说明「为什么不能用某种写法」,必然会复述那种写法本身, /// 不剥注释的话这条守卫会被自己的说明文字触发。 /// private static string MigrationSqlCodeOnly() { var full = Path.GetFullPath(Path.Combine( PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.496.sql")); Assert.True(File.Exists(full), $"迁移脚本不存在:{full}"); return string.Join('\n', File.ReadAllLines(full) .Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal))); } private static string SugarColumnName(Type type, string property) { var prop = type.GetProperty(property); Assert.NotNull(prop); var attr = prop!.GetCustomAttributes() .Single(a => a.GetType().Name == "SugarColumn"); return (string)attr.GetType().GetProperty("ColumnName")!.GetValue(attr)!; } // ═══════════════════════════════════════════════════════════════════════════════ // T1–T5 · 四个身份字段全部是 SysUserId // ═══════════════════════════════════════════════════════════════════════════════ /// /// T1 / T2:处理人字段名与物理列名都显式带 user。 /// /// 断言列名而不只断言属性名:属性改了但物理列还叫 assignee_id 的话, /// 读库的人、写 SQL 的人、看 dump 的人依旧无从判断这里装的是哪种 ID。 /// [Fact] public void T1_T2_Assignee_IsExplicitlyUserId() { Assert.NotNull(typeof(AdoS8Exception).GetProperty("AssigneeUserId")); Assert.Null(typeof(AdoS8Exception).GetProperty("AssigneeId")); Assert.Equal("assignee_user_id", SugarColumnName(typeof(AdoS8Exception), "AssigneeUserId")); } /// T3:检验人同上。 [Fact] public void T3_Verifier_IsExplicitlyUserId() { Assert.NotNull(typeof(AdoS8Exception).GetProperty("VerifierUserId")); Assert.Null(typeof(AdoS8Exception).GetProperty("VerifierId")); Assert.Equal("verifier_user_id", SugarColumnName(typeof(AdoS8Exception), "VerifierUserId")); } /// T4:提报人同上。 [Fact] public void T4_Reporter_IsExplicitlyUserId() { Assert.NotNull(typeof(AdoS8Exception).GetProperty("ReporterUserId")); Assert.Null(typeof(AdoS8Exception).GetProperty("ReporterId")); Assert.Equal("reporter_user_id", SugarColumnName(typeof(AdoS8Exception), "ReporterUserId")); } /// /// T5:时间线操作人同上。这一条对应的是**实测已发生**的混装: /// CLAIM 19 行是 Employee.RecID,VERIFY_SUBMITTED 里 7 行 Employee / 8 行 SysUser。 /// [Fact] public void T5_TimelineOperator_IsExplicitlyUserId() { Assert.NotNull(typeof(AdoS8ExceptionTimeline).GetProperty("OperatorUserId")); Assert.Null(typeof(AdoS8ExceptionTimeline).GetProperty("OperatorId")); Assert.Equal("operator_user_id", SugarColumnName(typeof(AdoS8ExceptionTimeline), "OperatorUserId")); } // ═══════════════════════════════════════════════════════════════════════════════ // T6–T9 · 账号校验:租户唯一边界,工厂不参与 // ═══════════════════════════════════════════════════════════════════════════════ /// /// T6 / T7 / T8:校验判据是「主键 + 本租户 + 启用」三条,一条不少、一条不多。 /// /// 用源码断言而非行为断言,是因为这三条的失效方式都是**放宽谓词**: /// 少一条 TenantId == 就能把别家账号设成本租户的处理人, /// 少一条 Status == Enable 就能把已停用账号派单出去。 /// 两者都不会报错,只会静默扩大可选集合。 /// [Fact] public void T6_T7_T8_UserValidation_RequiresTenantAndActive() { var code = CodeOnly("Infrastructure/S8/S8UserScopeValidator.cs"); Assert.Contains("x.TenantId == tenantId && x.Status == StatusEnum.Enable", code); // 跨租户与不存在共用同一文案,避免账号探测。 Assert.Contains("不存在或已停用", code); } /// /// T9:工厂与工厂号一律不参与人员判定。 /// /// 真库取证:factory_ref_id = 1000 同时属于 824585161322565 与 797403760988229, /// 按工厂校验会把另一租户的人判为合法。这一条断言的正是「不许再把它加回来」。 /// [Fact] public void T9_UserValidation_HasNoFactoryPredicate() { var code = CodeOnly("Infrastructure/S8/S8UserScopeValidator.cs"); Assert.DoesNotContain("FactoryId", code); Assert.DoesNotContain("FactoryRefId", code); } /// /// 账号合法性判定必须只有一份实现。 /// /// IS8DepartmentScopeValidator 同一动机:认领 / 转派 / 复检 / 通知 /// 都要回答同一个问题,各写一套 SQL 迟早出现「转派检查通过、通知却发不出去」。 /// [Fact] public void UserValidity_HasSingleImplementation() { var impls = typeof(IS8UserScopeValidator).Assembly.GetTypes() .Where(t => t is { IsAbstract: false, IsInterface: false } && typeof(IS8UserScopeValidator).IsAssignableFrom(t)) .ToList(); Assert.Single(impls); Assert.Equal(typeof(S8UserScopeValidator), impls[0]); // 别处不得再手写「本租户 + 启用」的账号查询。 foreach (var f in new[] { "Service/S8/S8TaskFlowService.cs", "Service/S8/S8ExceptionService.cs", "Service/S8/S8ManualReportService.cs", }) { Assert.DoesNotContain("Status == StatusEnum.Enable", CodeOnly(f)); } } // ═══════════════════════════════════════════════════════════════════════════════ // T10–T12 · Employee 已彻底退出人员主链 // ═══════════════════════════════════════════════════════════════════════════════ /// /// T10 / T11 / T12 / T21:人员主链上不得再出现任何 Employee 换算。 /// /// 这是本批最重要的一条。桥的危险不在于它错,而在于它**换不到时的行为是静默跳过** —— /// 通知不发、审批流不转办、工作台整块显示"未绑定员工",而调用方一律看到成功。 /// [Theory] [InlineData("Service/S8/S8TaskFlowService.cs")] // T10 Claim / T11 Transfer / T12 Verify [InlineData("Service/S8/S8ManualReportService.cs")] // 提报人 [InlineData("Service/S8/S8ExceptionService.cs")] // 列表/详情水合 [InlineData("Service/S8/ExceptionEscalationBizHandler.cs")] // 升级回调 [InlineData("Service/S8/S8MasterDataAdapter.cs")] // 人员选择器 [InlineData("Workbench/AidopWorkbenchService.cs")] // 我的待办 public void T10_T11_T12_T21_NoEmployeeBridgeInPersonChain(string relativePath) { var code = CodeOnly(relativePath); Assert.DoesNotContain("AdoS0EmployeeMaster", code); Assert.DoesNotContain("EmployeeMaster", code); Assert.DoesNotContain("GetEmployeeSysUserId", code); Assert.DoesNotContain("SysUserId ==", code); } /// /// 工作台不得再因「未绑定员工」整块不可用。 /// 该分支在 1105 : 8 的真实绑定率下,对绝大多数账号恒为真。 /// [Fact] public void T21b_Workbench_HasNoEmployeeUnboundBranch() { var code = CodeOnly("Workbench/AidopWorkbenchService.cs"); Assert.DoesNotContain("EMPLOYEE_UNBOUND", code); } /// /// T13:通知投递直接收账号 Id,不再有 employeeRecId 形参。 /// [Fact] public void T13_Notification_TakesSysUserIdDirectly() { var notify = typeof(S8TaskFlowService).GetMethod("NotifyUserAsync", Instance); Assert.NotNull(notify); Assert.Contains(notify!.GetParameters(), p => p.Name == "sysUserId"); Assert.Null(typeof(S8TaskFlowService).GetMethod("NotifyAssigneeAsync", Instance)); Assert.Null(typeof(S8TaskFlowService).GetMethod("GetEmployeeSysUserIdAsync", Instance)); } // ═══════════════════════════════════════════════════════════════════════════════ // T14–T15 · First Claim Wins // ═══════════════════════════════════════════════════════════════════════════════ /// /// T14 / T15:并发认领由**一条带前置条件的 UPDATE** 裁决,第二个认领者被拒。 /// /// 断言的是"没有先读后写的窗口":谓词必须同时含 /// Status == fromStatusAssigneeUserId == null,且影响行数 0 时报错。 /// 若改成 SELECT 判空 → UPDATE,两个人可以同时通过判空、同时写入, /// 最终 assignee 是后写的那个,而两个人都收到"认领成功"。 /// [Fact] public void T14_T15_Claim_IsAtomicFirstWins() { var code = CodeOnly("Service/S8/S8TaskFlowService.cs"); Assert.Contains("x.Status == fromStatus", code); Assert.Contains("x.AssigneeUserId == null", code); Assert.Contains("ExecuteCommandAsync()", code); Assert.Contains("affected == 0", code); Assert.Contains("该异常已被其他人员认领", code); } /// /// T14b:认领人只能是当前登录账号 —— 方法签名里没有 assignee 形参。 /// /// 旧签名收 assigneeId,意味着持 ExceptionClaim(弱权限)的人 /// 能做 ExceptionAssign(强权限)才该做的事:替别人认领。 /// [Fact] public void T14b_Claim_TakesNoAssigneeFromClient() { var claim = typeof(S8TaskFlowService).GetMethod("ClaimAsync", Instance); Assert.NotNull(claim); Assert.DoesNotContain(claim!.GetParameters(), p => p.Name!.Contains("assignee", StringComparison.OrdinalIgnoreCase)); // 请求体里也不能留一个"服务端会忽略"的字段。 Assert.Null(typeof(AdoS8ClaimBody).GetProperty("AssigneeId")); Assert.Null(typeof(AdoS8ClaimBody).GetProperty("AssigneeUserId")); } // ═══════════════════════════════════════════════════════════════════════════════ // T22 · Timeline 单一 ID 空间 // ═══════════════════════════════════════════════════════════════════════════════ /// /// T22:写时间线的形参叫 operatorUserId,且 CLAIM 写的是当前登录账号。 /// /// 旧实现 CLAIM 传的是 assigneeId(Employee.RecID), /// 其余动作传 currentUserId(SysUser.Id)—— 混装由此产生。 /// [Fact] public void T22_Timeline_HasSingleIdSpace() { var insert = typeof(S8TaskFlowService).GetMethod("InsertTimelineAsync", Instance); Assert.NotNull(insert); Assert.Contains(insert!.GetParameters(), p => p.Name == "operatorUserId"); Assert.DoesNotContain(insert.GetParameters(), p => p.Name == "operatorId"); var code = CodeOnly("Service/S8/S8TaskFlowService.cs"); Assert.Contains("\"CLAIM\", \"认领\", fromStatus, \"ASSIGNED\", currentUserId", code); } // ═══════════════════════════════════════════════════════════════════════════════ // T24 · RESET 白名单守卫 // ═══════════════════════════════════════════════════════════════════════════════ /// /// T16 / T17 / T24:迁移的 RESET 范围必须是**硬编码白名单**。 /// /// 最危险的写法是按表名前缀做通配删除 —— 它会把将来才新增的表 /// 也一并删掉,而写脚本的人当时根本看不到那些表。 /// 同样危险的是整表清空 ApprovalFlowInstance/Task:这两张表由 S5/S6/S7 共用。 /// [Fact] public void T16_T17_T24_Migration_UsesExplicitResetWhitelist() { var sql = MigrationSqlCodeOnly(); // ① 不得有任何通配删除 Assert.DoesNotContain("LIKE 'ado_s8_%'", sql); Assert.DoesNotContain("LIKE \"ado_s8_%\"", sql); // ② 审批流必须按 BizType 定点删,不得整表清空 Assert.Contains("'EXCEPTION_REPORT','EXCEPTION_ESCALATION','EXCEPTION_CLOSURE'", sql); Assert.DoesNotContain("DELETE FROM `ApprovalFlowInstance`;", sql); Assert.DoesNotContain("DELETE FROM `ApprovalFlowTask`;", sql); Assert.DoesNotContain("TRUNCATE", sql); // ③ 平台主数据与配置态一行都不许删;脚本自带断言兜底 foreach (var master in new[] { "SysUser", "SysRole", "EmployeeMaster", "DepartmentMaster" }) Assert.DoesNotContain($"DELETE FROM `{master}`", sql); foreach (var config in new[] { "ado_s8_exception_type", "ado_s8_scene_config", "ado_s8_notification_layer", "ado_s8_role_permission_config", "ado_s8_dashboard_cell_config", "ado_s8_watch_rule", }) Assert.DoesNotContain($"DELETE FROM `{config}`", sql); Assert.Contains("s8_496_BLOCKED_platform_master_wiped", sql); Assert.Contains("s8_496_BLOCKED_product_config_wiped", sql); // ④ AutoVersionUpdate 对含该关键字的脚本一律拒绝执行,且它是**朴素子串匹配** —— // 注释里出现也照样拦。所以这一条必须看整个文件,不能用剥注释后的版本。 Assert.DoesNotContain("DELIMITER", File.ReadAllText(Path.GetFullPath(Path.Combine( PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.496.sql")))); } /// /// T18 / T19:Provisioning 新建的规则行恒为停用,且不写任何人员字段。 /// /// reset 之后管理员必须重新配置再启用 —— 不得出现"清完库自己就跑起来了"。 /// [Fact] public void T18_T19_Provisioning_CreatesDisabledRuleWithoutPeople() { var code = CodeOnly("Service/S8/S8RuleProvisioningService.cs"); Assert.Contains("Enabled = false", code); foreach (var forbidden in new[] { "AssigneeUserId", "VerifierUserId", "HandlerUser", "SysUser" }) Assert.DoesNotContain(forbidden, code); } /// /// T20:迁移可重复执行。三种到场状态(只有旧列 / 新旧都有 / 只有新列)都要能收敛, /// 否则第二次启动会因 ALTER 报错而整个 AutoVersionUpdate 中断。 /// [Fact] public void T20_Migration_IsIdempotent() { var sql = MigrationSqlCodeOnly(); Assert.Contains("CREATE TABLE IF NOT EXISTS", sql); Assert.Contains("information_schema.COLUMNS", sql); // 三分支:改名 / 丢弃重复旧列 / 什么都不做 Assert.Contains("CHANGE COLUMN `assignee_id` `assignee_user_id`", sql); Assert.Contains("DROP COLUMN `assignee_id`", sql); Assert.Contains("'DO 0'", sql); } // ═══════════════════════════════════════════════════════════════════════════════ // 退役入口 // ═══════════════════════════════════════════════════════════════════════════════ /// /// 「操作员绑定」必须整体退役,且退役后的入口不再具备写 EmployeeMaster 的能力。 /// /// 只把菜单藏起来是不够的:留着一个能写主数据的 API, /// 下一个人照样会顺着它把 Employee 绑定重新接回人员主链。 /// [Fact] public void OperatorBinding_IsFullyRetired() { Assert.Null(Type.GetType("Admin.NET.Plugin.AiDOP.Service.S8.S8OperatorBindingService, Admin.NET.Plugin.AiDOP")); Assert.False(File.Exists(Path.Combine(PluginRoot, "Service", "S8", "S8OperatorBindingService.cs"))); var code = CodeOnly("Controllers/S8/AdoS8ConfigBindingsController.cs"); Assert.Contains("Status410Gone", code); Assert.DoesNotContain("EmployeeMaster", code); Assert.DoesNotContain("S8OperatorBindingService", code); } /// /// 人员选择器只能列系统账号。 /// 旧的 employees 端点保留但返回 410,好让旧客户端拿到明确原因而不是 404。 /// [Fact] public void PersonPicker_ListsSysUsersOnly() { Assert.NotNull(typeof(S8MasterDataAdapter).GetMethod("GetOperatorUsersAsync")); Assert.Null(typeof(S8MasterDataAdapter).GetMethod("GetEmployeesAsync")); var code = CodeOnly("Controllers/S8/AdoS8MasterDataController.cs"); Assert.Contains("HttpGet(\"users\")", code); Assert.Contains("Status410Gone", code); } /// /// T23:本批不含任何指向共享库的写操作。 /// 迁移只在本地执行;共享 165 的执行时点由发布流程决定,不由代码触发。 /// [Fact] public void T23_NoSharedDbHostAppearsInMigrationOrCode() { // T23 刻意读整个文件含注释:共享库主机名出现在注释里同样是泄漏。 var sql = File.ReadAllText(Path.GetFullPath(Path.Combine( PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.496.sql"))); Assert.DoesNotContain("123.60.180.165", sql); Assert.DoesNotContain("aidopdev", sql); } }