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:通知投递直接落到系统账号,不再有 employeeRecId 形参。
///
/// S8-NOTIFY-RECIPIENT-1 之后收件人不再写死在代码里:原 NotifyUserAsync(exception, sysUserId, title)
/// 已由 DispatchEventAsync(exception, eventCode, ...) 取代,收件人由统一模型解析、
/// 输出仍然只可能是 SysUser.Id。守卫对象随之改为「派发按事件走统一链路,
/// 且 Employee 桥不得复活」—— 守的仍是同一件事:通知的收件人只有账号一种身份。
///
[Fact]
public void T13_Notification_TakesSysUserIdDirectly()
{
var dispatch = typeof(S8TaskFlowService).GetMethod("DispatchEventAsync", Instance);
Assert.NotNull(dispatch);
Assert.Contains(dispatch!.GetParameters(), p => p.Name == "eventCode");
// Employee 桥与写死收件人的旧方法都必须已消失
Assert.Null(typeof(S8TaskFlowService).GetMethod("NotifyUserAsync", Instance));
Assert.Null(typeof(S8TaskFlowService).GetMethod("NotifyAssigneeAsync", Instance));
Assert.Null(typeof(S8TaskFlowService).GetMethod("GetEmployeeSysUserIdAsync", Instance));
// 解析器输出的仍然只能是账号 Id
var resolver = CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
Assert.Contains("List UserIds", resolver);
Assert.DoesNotContain("EmployeeMaster", resolver);
}
// ═══════════════════════════════════════════════════════════════════════════════
// T14–T15 · First Claim Wins
// ═══════════════════════════════════════════════════════════════════════════════
///
/// T14 / T15:并发认领由**一条带前置条件的 UPDATE** 裁决,第二个认领者被拒。
///
/// 断言的是"没有先读后写的窗口":谓词必须同时含
/// Status == fromStatus 与 AssigneeUserId == 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);
}
}