| @@ -0,0 +1,135 @@ | |||||
| { | |||||
| "time": "2026-09-06T03:48:33.309Z", | |||||
| "checks": [ | |||||
| { | |||||
| "name": "root 使用管理员登录入口进入内部维护角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通角色目录不显示内部维护角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "管理员角色采用固定的受限授权", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "管理员授权已持久化,不动态获得全权限", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "admin 无法通过接口修改固定管理员授权", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "root 无法通过接口修改固定管理员授权", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "admin 账号已取消 is_protected", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "用户目录不显示 root 维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通账号按 ID 查询 root 受保护", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通账号按 ID 停用 root 受保护", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通账号按 ID 重置密码 root 受保护", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通账号按 ID 删除 root 受保护", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "管理员无法访问维护审计", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通审计不混入 root 登录与操作记录", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "root 活动保留在可追溯的内部审计", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "目录提供模型独立动作与查看依赖", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "已移除旧的共用内容写权限", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "创建可配置的reader测试角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "保存reader细分授权", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "创建可配置的editor测试角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "保存editor细分授权", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "建立隔离的多角色测试账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "多角色测试账号正常登录", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "接口切换到reader角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "reader当前身份独立生效", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "reader拥有模型查看权限", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "只有查看权限时,直接新建接口返回403", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "接口切换到editor角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "editor当前身份独立生效", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "editor拥有模型查看权限", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "自定义非教员角色获得新增权限即可创建模型", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通管理员模型查看与创建权限相互独立", | |||||
| "result": "PASS" | |||||
| } | |||||
| ], | |||||
| "catalogCount": 138, | |||||
| "adminPermissionCount": 106 | |||||
| } | |||||
| @@ -0,0 +1,41 @@ | |||||
| { | |||||
| "time": "2026-09-06T12:08:36.512028", | |||||
| "secretScan": "PASS", | |||||
| "secretScanScope": "Changed textual artifacts; identical existing source and Playwright library substrings excluded by surrounding-context comparison.", | |||||
| "unchangedBaselineFiles": [ | |||||
| "unreal_tran_api/database/unreal_tran_full.sql", | |||||
| "unreal_tran_api/ut_auth/src/main/resources/application.yml", | |||||
| "unreal_tran_api/wiki/11-内容制作模块.md", | |||||
| "unreal_tran_web/src/views/system/UsersView.vue", | |||||
| "ute2e/reports/role-permissions-20260906/playwright/index.html" | |||||
| ], | |||||
| "changedFileCounts": { | |||||
| "unreal_tran_api": 83, | |||||
| "unreal_tran_web": 68, | |||||
| "ute2e": 58 | |||||
| }, | |||||
| "backendTests": { | |||||
| "ut_auth": { | |||||
| "tests": 112, | |||||
| "failures": 0, | |||||
| "errors": 0, | |||||
| "skipped": 0 | |||||
| }, | |||||
| "ut_tran": { | |||||
| "tests": 340, | |||||
| "failures": 0, | |||||
| "errors": 0, | |||||
| "skipped": 0 | |||||
| } | |||||
| }, | |||||
| "deployedArtifacts": { | |||||
| "auth": { | |||||
| "sha256": "98aaeedfe29c8e75e99ef9421a3982ae52ab357eb8592c68a30dfc54e633ff85", | |||||
| "runningCopyMatches": true | |||||
| }, | |||||
| "tran": { | |||||
| "sha256": "d66a38602e32c0db0c2680905f6a6461d9cd09ab12f4ba1481c87f99f583e828", | |||||
| "runningCopyMatches": true | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,139 @@ | |||||
| # 权限调整设计与验收约定 | |||||
| 日期:2026-09-06。本文按“资料 → 产品原型 → 设计 → 开发 → 测试”记录本轮授权范围、实现依据和验收边界。文中的迁移后角色矩阵是根据旧环境快照与 036 迁移规则推导的目标值;真实 API、角色切换 E2E、截图及最终构建结果由本目录的最终测试报告补充,不能把设计目标当作已通过的实测结果。 | |||||
| ## 1. 资料:先确认旧系统实际允许什么 | |||||
| 依据: | |||||
| - 用户本轮确认的权限方案:管理员保留固定的实际管理权限;内部维护身份独立保护;页面与操作按权限判定;教学、资源和内容动作细分;头像菜单支持切换已分配的工作身份。 | |||||
| - 旧环境脱敏快照:[role-permissions-audit-20260906.json](data/role-permissions-audit-20260906.json)。采集时间为 2026-09-06 03:08:01 UTC,授权模式为 `SINGLE_ACTIVE`;交付副本仅含角色与权限,不含用户个人资料或凭据。 | |||||
| - 动作合同:[granular-permissions-contract.json](data/granular-permissions-contract.json)。列出本轮新增的 87 个动作码、所属页面、旧码来源和迁移条件。 | |||||
| - 迁移文件:[20260906_036_role_permissions_maintenance.sql](../../../unreal_tran_api/database/20260906_036_role_permissions_maintenance.sql)。以显式清单固定管理员授权,迁移普通角色的已有能力,增加维护审计范围。 | |||||
| - 服务端授权说明:[08-角色授权模式.md](../../../unreal_tran_api/wiki/08-角色授权模式.md)。页面权限、动作权限、数据范围、会话身份及维护账号约束分别生效。 | |||||
| 旧快照中的角色状态: | |||||
| | 当前角色 | 旧授权记录 | 旧数据范围 | 实际含义 | | |||||
| | --- | --- | --- | --- | | |||||
| | 管理员 `admin` | 动态取得全部 65 个启用叶子权限,旧 `superAdmin=true` | `ALL` | 权限目录看似“全部”,但内容写 API 和前端另有教员身份门槛;因此模型、场景等实际仍只读。不能把“目录全选”当作其真实业务写能力。 | | |||||
| | 教员 `teacher` | 显式 33 项,含 3 个共用内容写权限、教学管理与运行权限 | `SYSTEM` | 可制作内容、组织教学;资源库和维修知识主要有页面权限,未持有对应旧 `manage` 权限。 | | |||||
| | 学员 `student` | 显式 14 项,含教学领取、提交、运行及知识页面 | `SELF` | 参与本人或本人队组被分配的教学,不具有内容制作、教学管理和系统管理权限。 | | |||||
| 旧数据说明了两处需要修正的表达:一是管理员不能再显示成动态全权限后又在页面按角色名拦住;二是角色名不能替代一项可以独立授予或撤销的业务能力。迁移以旧实际能力为边界,不借权限拆分给教员补齐资源管理功能,也不给管理员补齐内容制作功能。 | |||||
| ## 2. 产品原型:保持已有入口,明确能看与能做 | |||||
| 登录页仍为教员、学员、系统管理员三个等宽身份选项。此选择用于请求服务端确认会话身份,不是页面授权结果。普通界面不增加“内部维护”登录选项或维护人员条目。 | |||||
| 列表、工作区和系统配置页遵守同一规则:有页面权限才进入;进入后,新建、编辑、删除、发布、审核分别显示或启用;权限不足时仍可查看已授权的数据。缺少编辑权限的三维工作区保留浏览、相机查看和已有的只读预览能力,阻止编辑字段、导入上传、拖动修改与自动保存。 | |||||
| 头像菜单和个人中心共用身份切换流程,只列出当前用户已分配且有效的角色;`SINGLE_ACTIVE` 且有效角色多于一个时可切换。`UNION` 沿用权限并集,不显示会改变授权的“切换身份”入口。 | |||||
| OFD 默认工作台仍是已有本地原型入口。本轮将其假定教员、无条件允许操作的上下文替换为真实会话与细分权限,并阻止无权限本地写入。原型没有的审批业务不会因为新增 `content.ofd.review` 就被临时拼出来;正式 OFD 接口与保留的正式工作流组件按已有审批状态机控制批准、驳回。 | |||||
| ## 3. 设计:能力、身份与数据范围分开 | |||||
| ### 3.1 授权判定 | |||||
| 有效操作必须同时满足:已认证会话、当前有效授权集合、对应页面、具体动作、服务端数据范围,以及原有项目状态和版本保护。前端只负责可用性提示,服务端仍校验每个实际请求。 | |||||
| `SINGLE_ACTIVE` 只使用当前活动角色的权限和数据范围;一个账号同时持有管理员、教员时,管理员身份不会自动合并教员的内容写权限。`UNION` 按服务端计算的有效角色并集处理。页面不得再通过 `roleCode === teacher/admin` 赋予通用编辑能力。 | |||||
| 学员本人、分组、队伍、岗位、正式考核投影、设备事件来源等仍属于业务约束;不能因页面改看权限而放开这些条件。版本号、固定发布依赖、数据范围和考核答案隔离继续由服务端判断。 | |||||
| ### 3.2 迁移后角色能力矩阵 | |||||
| 下表对应“旧快照 + 036 首次迁移”,不是对所有同名角色强行恢复一套默认权限。可配置角色后续被撤销的细分权限,不应因重复执行迁移而重新获得。 | |||||
| | 能力 | 管理员固定角色 | 教员迁移结果 | 学员迁移结果 | | |||||
| | --- | --- | --- | --- | | |||||
| | 工作总览 | 可进入 | 可进入 | 可进入本人视图 | | |||||
| | 模型、场景、训练编排、OFD 内容页面 | 保留查看,默认不含内容写 | 保留四类页面与制作能力 | 默认无内容制作页面 | | |||||
| | 内容新建、复制、编辑、上传、删除、发布 | 固定清单不授予 | 由原共用内容写权限展开为四类独立动作 | 不授予 | | |||||
| | OFD 批准、驳回 | 固定清单不授予 | 从原有内容修改能力迁移为 `content.ofd.review`,仍须对应审批状态和实际正式入口 | 不授予 | | |||||
| | 虚拟、实装、对抗、考试任务管理 | 保留新建、修改、发布、撤回、归档、评审、终止等管理动作 | 保留既有管理能力,按对应页面分别拆分 | 不授予管理动作 | | |||||
| | 教学领取、提交、正式考试执行 | 不因原动态全权限继续保留学员专属动作 | 不因教员身份新增学员专属动作 | 按四类教学页面分别取得领取、提交;正式考试执行使用独立 `teaching.exams.execute` | | |||||
| | 原有虚拟、实装、对抗与沉浸运行权限 | 管理固定清单不包含学员运行能力 | 保留原有 execute 权限,实际运行还须满足任务、参与人和考核约束 | 保留原有 execute 权限,限定本人/本人队组 | | |||||
| | 对抗配置、故障与干预 | 保留配置;故障增改删、干预独立授权 | 按原有配置/管理权限展开 | 不授予管理或注入能力 | | |||||
| | 指导书教学使用 | 反馈处理和统计 | 反馈处理和统计 | 学习进度、提交反馈 | | |||||
| | 资源管理、维修知识 | 保留管理能力并拆分增改删、发布和分类动作 | 保留原页面;因未有旧 manage,不自动新增资源/知识增改删 | 保留知识页面,不新增知识写权限 | | |||||
| | 数据处理 | 保留管理、测试、控制、校验、处理等已有能力 | 保留原页面与 ingest;不自动授予管道管理能力 | 默认不授予 | | |||||
| | AI 模型管理 | 保留对应管理、测试 | 旧快照无该页及管理授权,迁移不增加 | 不授予 | | |||||
| | 辅助保障 | 保留监控、干预 | 保留原监控、干预 | 不授予 | | |||||
| | 系统用户、部门、角色、权限、配置等 | 保留明确管理动作;普通 API 不能改管理员固定角色本身 | 旧快照未授予,不自动增加 | 不授予 | | |||||
| | 数据范围 | 保留 `ALL`,不代表内容写或维护身份 | 保留 `SYSTEM` | 保留 `SELF` | | |||||
| 管理员是固定的普通管理角色,不再依赖动态全选。其显式清单不随以后新增权限自动扩张;普通角色权限接口也不能修改该固定清单。它仍可在已有系统管理授权内管理可配置角色。一个账号被合法分配其他角色后,使用该角色需要按授权模式重新计算能力,这与修改管理员固定模板是两件事。 | |||||
| 内部维护角色与普通管理员分开:仅后端同时确认受保护维护账号标记和有效内部角色分配,才赋予内部维护能力。用户名、前端标记或单独的角色关联都不能提权。维护角色动态取得启用的页面/动作权限;分组节点不成为可授予的实际权限。 | |||||
| ### 3.3 内容四类操作与真实动作对应 | |||||
| | 页面及服务端类型 | 新建/复制 | 编辑/保存/上传/封面/恢复草稿 | 删除工程 | 发布/撤回发布 | 批准/驳回 | | |||||
| | --- | --- | --- | --- | --- | --- | | |||||
| | 模型制作 `MODEL` | `content.model.create` | `content.model.update` | `content.model.delete` | `content.model.publish` | 无独立模型审核码 | | |||||
| | 场景制作 `SCENE` | `content.scene.create` | `content.scene.update` | `content.scene.delete` | `content.scene.publish` | 无独立场景审核码 | | |||||
| | 训练编排 `TRAINING` | `content.training.create` | `content.training.update` | `content.training.delete` | `content.training.publish` | 无独立训练审核码 | | |||||
| | 作业指导书 `GUIDE` | `content.ofd.create` | `content.ofd.update` | `content.ofd.delete` | `content.ofd.publish` | `content.ofd.review` | | |||||
| `GUIDE` 的页面后缀使用 `ofd`,不是另造 `content.guide.*`。提交审核、撤回送审、修订归入 `update`;批准、驳回归入 OFD `review`;撤回已发布版本归入 `publish`。这些动作仍受原状态机约束,权限不会凭空增加生命周期转移。 | |||||
| 上述删除权限指删除内容工程。编辑工程内部的模型节点、场景对象、训练步骤或文档块,属于修改工程文档,仍使用该类内容的 `update`。封面截图和模型二进制上传同属编辑,不新增独立“封面权限”。 | |||||
| “发布已保存版本”只需要发布动作;“编辑后保存并发布”必须同时拥有编辑与发布。不能为了让发布按钮可用而暗中开放更新接口。场景、训练、模型宿主在只有发布权限时跳过保存,直接提交已加载的服务端版本号。OFD 正式发布还须存在与当前内容指纹匹配的两份交付物;只有发布权限的用户可以复用已保存的 OFD/离线包,缺失或过期时提示编辑者处理,不代为上传,也不发布旧内容的交付物。 | |||||
| ### 3.4 内部维护与审计隔离 | |||||
| 维护账号和内部角色不出现在普通用户列表、人员目录、角色列表、统计及角色分配入口;普通用户 API 不能对其修改、停用、删除、重置密码或改角色。此保护来自后端身份合同,不靠前端隐藏。 | |||||
| 系统、内容、资源智能审计增加写入时的 `visibility_scope` 快照,区分 `BUSINESS` 与 `INTERNAL`。维护账号产生的内部审计不会因后来切换活动角色而变为普通业务审计;读取内部审计仍要求当前有效内部维护角色。普通查询和导出不应通过详情 ID、筛选条件或其它目录绕过隔离。 | |||||
| 036 先做结构与授权迁移,不在 SQL 写入账号凭据,也不提前解除旧管理员保护。维护恢复能力通过一次性私密配置初始化;验证凭据、内部角色及有效授权后,才交接普通管理员的保护状态。报告、截图、源码及 DDL 均不记录密码、令牌或个人资料。 | |||||
| ### 3.5 角色切换与未保存内容 | |||||
| 切换前依次调用所有已注册工作区的离开确认,包括仍被缓存的后台编辑器。保存失败、模型/场景正在绑定或用户关闭确认框时,不发送角色切换请求。 | |||||
| 单个编辑器选择“放弃修改”不会立即删除恢复数据或清除脏标记;后续另一个编辑器取消、或角色切换接口失败时,仍保留原工作区状态。只有所有确认完成且服务端切换成功后,才标记身份提交成功并整页进入工作总览;新启动重新取得 `/auth/me` 和菜单,释放旧身份的编辑器、缓存和在途上下文。成功切换后避免 `beforeunload` 再提示或把已放弃的模型草稿写回恢复区。 | |||||
| ## 4. 开发:本轮落地范围 | |||||
| - 内容权限映射集中在 [contentPermissions.ts](../../../unreal_tran_web/src/utils/contentPermissions.ts),`canUsePermission` 只检查服务端返回的权限集合。旧共用动作码停用,不以“新码或旧码任选其一”的方式绕过细分授权。 | |||||
| - 内容列表按类型分别控制新建、复制、编辑、恢复、删除和发布。保留的正式工作台按具体生命周期动作授权。 | |||||
| - 场景和训练旧编辑器接入宿主能力,增加只读字段/按钮和直接回调保护;训练高级编排不再在只读会话中写本地工程缓存。发布失败继续阻断后续动作,不清除未保存内容,不取消已有版本锁或固定场景依赖。 | |||||
| - 模型 iframe 在同源宿主增加权限桥接,控制编辑面、变换控件、保存、发布、自动保存与恢复写入;不改旧仓库或已生成的静态 bundle。只读查看旧工程可做内存归一化,不触发自动迁移 PUT,不恢复未授权的本地编辑副本。 | |||||
| - OFD 本地运行时使用实际会话身份;本地创建、保存、删除、恢复、生命周期与资源编辑均有能力检查。向导把初始文档随新建一起保存,避免只有新建权限的用户先创建空工程后再因无修改权限失败。 | |||||
| - 头像入口与个人中心共享 [useRoleSwitch.ts](../../../unreal_tran_web/src/composables/useRoleSwitch.ts),工作区通过 [workspaceLeaveGuards.ts](../../../unreal_tran_web/src/utils/workspaceLeaveGuards.ts) 注册确认。页面标签存储按用户和活动角色隔离。 | |||||
| - 教学任务以已保存的任务类型/渠道选择动作码;正式考试走 `teaching.exams.*`。资源智能将旧 manage 拆为对应页面的真实动作,保留已有专用 ingest、execute、configure 等操作合同。 | |||||
| ## 5. 测试:区分已完成检查与待实测结果 | |||||
| 本文件形成时,内容范围已执行: | |||||
| | 检查 | 命令或证据 | 结果 | | |||||
| | --- | --- | --- | | |||||
| | 前端类型与 SFC 检查 | `pnpm exec vue-tsc --noEmit` | 通过;不等同于完整生产构建 | | |||||
| | 内容权限、OFD 本地写隔离、模型宿主与交付物复用 | `node --test tools/content-permissions.test.mjs` | 10/10 通过 | | |||||
| | 训练封面、异步保存、绑定并发、只读/发布分权 | `node --test tools/training-cover.test.mjs` | 19/19 通过 | | |||||
| | 场景封面、版本冲突、离开确认、只读/发布分权 | `node --test tools/scene-cover-api.test.mjs` | 28/28 通过,含后补的 8 项加载/上传/保存/发布并发离开回归 | | |||||
| | 上述三个 Node 测试文件此前联合执行 | `node --test --test-reporter=spec tools/content-permissions.test.mjs tools/training-cover.test.mjs tools/scene-cover-api.test.mjs` | 当时 49/49 通过;后补的 8 项已在场景测试文件独立复跑通过 | | |||||
| 真实联调报告还需要记录以下独立验收项的实际结果,未取得证据前不得填为“通过”: | |||||
| 1. 036 执行及维护恢复交接后,分别读取三个普通角色的实际 `/auth/me`、页面目录与数据范围;对照矩阵检查没有跨页扩大授权。 | |||||
| 2. 管理员内容列表只读、教员原有内容制作可用、学员无内容制作入口;自定义角色只授予某一内容类型的某个动作时,其余类型/动作均被 UI 和 API 拦截。 | |||||
| 3. 新建、复制、编辑、上传封面、恢复版本、删除、发布及 OFD 审核逐项验证;发布权限不能替代更新,更新不能替代删除/发布/审核。 | |||||
| 4. 通过真实三维编辑器确认只读画面仍能加载模型或场景,禁止编辑、上传与自动写;不能只根据接口成功或 DOM 对象数量判断视觉正常。 | |||||
| 5. 在有未保存修改时,从头像菜单切换角色:取消无切换请求;保存失败保留内容;多工作区前一项放弃、后一项取消不丢草稿;成功切换后重新获取身份与菜单,不显示旧角色页面缓存。 | |||||
| 6. 验证维护账号/角色不可经普通目录、详情、统计、人员下拉与修改接口泄漏或被维护;普通审计查询/导出不包含内部审计。 | |||||
| 7. 验证教学管理动作与学员动作分离,正式考试、岗位、队组、数据范围及固定快照约束仍有效。 | |||||
| 8. 完整 `pnpm run build`、真实 API 自动化、E2E 截图和必要的失败 trace 由最终报告统一记录。测试产生的临时角色、权限调整、工程和审计数据应说明保留或清理情况。 | |||||
| ## 6. 明确边界 | |||||
| 本轮是权限判定和工作区适配,不新增内容制作业务、教学流程或 OFD 审批产品功能。OFD 默认原型仍使用本地存储,前端能力约束不能被描述成完整服务端隔离、跨设备协作或数据范围安全;本轮没有将该本地业务整体迁移至内容 API。 | |||||
| 普通管理员保留配置其它角色的能力,但其固定角色本身没有内容制作权限。教员原来未获授予的资源/知识管理动作,不因角色名称自动补授。学员教学执行仍须任务已分配且符合本人/队组/岗位要求。存在权限不代表可以绕过发布前置检查、已发布内容不可变约束或乐观锁。 | |||||
| 本资料不包含维护凭据、用户个人资料、访问令牌或数据库备份内容;也不把尚在执行的 API/E2E 项目记为已完成。 | |||||
| @@ -0,0 +1,15 @@ | |||||
| { | |||||
| "time": "2026-09-06T04:07:09.247Z", | |||||
| "projects": [ | |||||
| "165" | |||||
| ], | |||||
| "users": [ | |||||
| "100" | |||||
| ], | |||||
| "roles": [ | |||||
| "20", | |||||
| "21" | |||||
| ], | |||||
| "status": "PASS", | |||||
| "postDeleteReads": "All 4 isolated fixture records return HTTP 404" | |||||
| } | |||||
| @@ -0,0 +1,483 @@ | |||||
| { | |||||
| "capturedAt": "2026-09-06T03:08:01.311Z", | |||||
| "authorizationMode": "SINGLE_ACTIVE", | |||||
| "permissions": [ | |||||
| { | |||||
| "code": "dashboard.view", | |||||
| "label": "工作总览", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "content.model", | |||||
| "label": "模型制作", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "content.scene", | |||||
| "label": "场景制作", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "content.training", | |||||
| "label": "训练编排", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "content.ofd", | |||||
| "label": "作业指导", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.virtual", | |||||
| "label": "虚拟仿真", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.physical", | |||||
| "label": "实装实训", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.confrontation", | |||||
| "label": "对抗训练", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.guides", | |||||
| "label": "作业指导书", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.immersive", | |||||
| "label": "沉浸交互", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.exams", | |||||
| "label": "考试测评", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.analytics", | |||||
| "label": "数据中心", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.resources", | |||||
| "label": "资源管理", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.knowledge", | |||||
| "label": "维修知识", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.data-processing", | |||||
| "label": "数据处理", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.ai-models", | |||||
| "label": "智能模型管理", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.assistance", | |||||
| "label": "智能辅助", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.users", | |||||
| "label": "用户管理", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.departments", | |||||
| "label": "部门管理", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.roles", | |||||
| "label": "角色管理", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.menus", | |||||
| "label": "菜单管理", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.permissions", | |||||
| "label": "权限管理", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.audit", | |||||
| "label": "审计日志", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.interfaces", | |||||
| "label": "接口配置", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.config", | |||||
| "label": "系统配置", | |||||
| "type": "PAGE", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "content.create", | |||||
| "label": "新建内容", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "content.update", | |||||
| "label": "编辑内容", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "content.publish", | |||||
| "label": "发布内容", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.tasks.create", | |||||
| "label": "创建任务", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.tasks.publish", | |||||
| "label": "发布任务", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.tasks.withdraw", | |||||
| "label": "撤回任务", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.tasks.review", | |||||
| "label": "评定成绩", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.tasks.archive", | |||||
| "label": "归档任务", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.tasks.accept", | |||||
| "label": "接收任务", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.tasks.submit", | |||||
| "label": "提交结果", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.virtual.execute", | |||||
| "label": "执行虚拟仿真", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.physical.execute", | |||||
| "label": "执行实装实训", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.confrontation.configure", | |||||
| "label": "配置对抗故障", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.confrontation.execute", | |||||
| "label": "执行对抗训练", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.immersive.execute", | |||||
| "label": "执行沉浸训练", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "teaching.physical.ingest", | |||||
| "label": "接入实装事件", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.data-processing.manage", | |||||
| "label": "管理数据处理", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.ai-models.manage", | |||||
| "label": "管理AI配置", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.resources.manage", | |||||
| "label": "维护资源目录", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.knowledge.manage", | |||||
| "label": "管理维修知识", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.knowledge.reindex", | |||||
| "label": "重建知识索引", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.data-processing.ingest", | |||||
| "label": "接入设备数据", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.ai-models.test", | |||||
| "label": "测试智能编排", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.assistance.monitor", | |||||
| "label": "处置辅助预警", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "integration.assistance.intervene", | |||||
| "label": "实施教学干预", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.users.create", | |||||
| "label": "新增用户", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.users.update", | |||||
| "label": "编辑用户", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.users.delete", | |||||
| "label": "删除用户", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.users.reset-password", | |||||
| "label": "重置密码", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.departments.create", | |||||
| "label": "新增部门", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.departments.update", | |||||
| "label": "编辑部门", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.departments.delete", | |||||
| "label": "删除部门", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.roles.create", | |||||
| "label": "新增角色", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.roles.update", | |||||
| "label": "编辑角色", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.roles.delete", | |||||
| "label": "删除角色", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.permissions.update", | |||||
| "label": "配置权限", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.audit.export", | |||||
| "label": "导出审计日志", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.interfaces.update", | |||||
| "label": "修改服务配置", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.menus.update", | |||||
| "label": "修改菜单配置", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| }, | |||||
| { | |||||
| "code": "system.config.update", | |||||
| "label": "修改系统配置", | |||||
| "type": "ACTION", | |||||
| "enabled": true | |||||
| } | |||||
| ], | |||||
| "roles": [ | |||||
| { | |||||
| "id": "1", | |||||
| "code": "admin", | |||||
| "name": "管理员", | |||||
| "enabled": true, | |||||
| "builtIn": true, | |||||
| "superAdmin": true, | |||||
| "dataScope": "ALL", | |||||
| "userCount": 2, | |||||
| "dynamicAll": true, | |||||
| "permissionCodes": [] | |||||
| }, | |||||
| { | |||||
| "id": "2", | |||||
| "code": "teacher", | |||||
| "name": "教员", | |||||
| "enabled": true, | |||||
| "builtIn": true, | |||||
| "superAdmin": false, | |||||
| "dataScope": "SYSTEM", | |||||
| "userCount": 8, | |||||
| "dynamicAll": false, | |||||
| "permissionCodes": [ | |||||
| "teaching.physical.ingest", | |||||
| "teaching.virtual", | |||||
| "teaching.physical", | |||||
| "teaching.confrontation", | |||||
| "teaching.exams", | |||||
| "content.create", | |||||
| "content.update", | |||||
| "content.publish", | |||||
| "teaching.tasks.create", | |||||
| "teaching.tasks.publish", | |||||
| "teaching.tasks.withdraw", | |||||
| "teaching.tasks.review", | |||||
| "teaching.tasks.archive", | |||||
| "teaching.virtual.execute", | |||||
| "teaching.physical.execute", | |||||
| "teaching.confrontation.configure", | |||||
| "integration.assistance", | |||||
| "teaching.confrontation.execute", | |||||
| "teaching.immersive.execute", | |||||
| "dashboard.view", | |||||
| "content.model", | |||||
| "content.scene", | |||||
| "content.training", | |||||
| "content.ofd", | |||||
| "teaching.guides", | |||||
| "teaching.immersive", | |||||
| "teaching.analytics", | |||||
| "integration.resources", | |||||
| "integration.knowledge", | |||||
| "integration.data-processing", | |||||
| "integration.data-processing.ingest", | |||||
| "integration.assistance.monitor", | |||||
| "integration.assistance.intervene" | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "id": "3", | |||||
| "code": "student", | |||||
| "name": "学员", | |||||
| "enabled": true, | |||||
| "builtIn": true, | |||||
| "superAdmin": false, | |||||
| "dataScope": "SELF", | |||||
| "userCount": 14, | |||||
| "dynamicAll": false, | |||||
| "permissionCodes": [ | |||||
| "teaching.virtual", | |||||
| "teaching.physical", | |||||
| "teaching.confrontation", | |||||
| "teaching.exams", | |||||
| "teaching.tasks.accept", | |||||
| "teaching.tasks.submit", | |||||
| "teaching.virtual.execute", | |||||
| "teaching.physical.execute", | |||||
| "teaching.confrontation.execute", | |||||
| "teaching.immersive.execute", | |||||
| "dashboard.view", | |||||
| "teaching.guides", | |||||
| "teaching.immersive", | |||||
| "integration.knowledge" | |||||
| ] | |||||
| } | |||||
| ] | |||||
| } | |||||
| @@ -0,0 +1,174 @@ | |||||
| { | |||||
| "startedAt": "2026-09-06T03:47:57.270Z", | |||||
| "mode": "LIVE_API", | |||||
| "checks": [ | |||||
| { | |||||
| "name": "维护账号使用已分配的内部角色,普通管理员不带维护标记", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "admin 的普通角色目录和分页都隐藏内部角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "admin 不能按已知内部角色 ID 读取/详情", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "admin 不能按已知内部角色 ID 读取/users", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "admin 不能按已知内部角色 ID 读取/permissions", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "root 的普通角色目录和分页都隐藏内部角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "root 不能按已知内部角色 ID 读取/详情", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "root 不能按已知内部角色 ID 读取/users", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "root 不能按已知内部角色 ID 读取/permissions", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "角色 admin 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "角色 administrative 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "角色 teacher 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "角色 student 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "角色 group_leader 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "教学人员搜索不暴露维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "教学人员按 ID 解析拒绝维护账号", | |||||
| "result": "PASS", | |||||
| "status": 400 | |||||
| }, | |||||
| { | |||||
| "name": "公开登录候选列表不暴露维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "admin 无法通过普通编辑更改固定管理员数据范围", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "admin 无法停用固定管理员角色", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "root 无法通过普通编辑更改固定管理员数据范围", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "root 无法停用固定管理员角色", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "固定管理员角色状态、范围、版本未被边界请求改变", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通管理员不能读取维护审计/列表", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "普通管理员不能读取维护审计/stats", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "普通管理员不能读取维护审计/export", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "已知维护账号错误密码和未知账号均返回统一认证失败", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "维护登录失败在普通列表不可见且内部审计保留真实 USER 目标", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "未知身份失败仍可在普通安全审计中查到", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通审计导出不包含维护记录,维护导出保留该记录", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通审计统计只计算 BUSINESS,维护统计只计算 INTERNAL", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通用户和角色统计排除内部账号及内部角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "首页人数不计维护账号,最近动态不显示维护失败日志", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "数据库只读核验:已知维护失败 INTERNAL,未知失败 BUSINESS", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "维护再次登录后普通 admin 仍未被重新保护", | |||||
| "result": "PASS" | |||||
| } | |||||
| ], | |||||
| "limitations": [ | |||||
| "首阶段不依赖隔离账号,内部角色赋值拒绝及测试账号版本保持由完整运行补测。" | |||||
| ], | |||||
| "databaseEvidence": { | |||||
| "ordinaryUserCount": 23, | |||||
| "ordinaryRoleCount": 7, | |||||
| "businessAuditCount": 254, | |||||
| "internalAuditCount": 8, | |||||
| "knownFailureScope": "INTERNAL", | |||||
| "unknownFailureScope": "BUSINESS", | |||||
| "knownFailurePreservesTarget": true | |||||
| }, | |||||
| "bootstrapReview": { | |||||
| "result": "PASS", | |||||
| "note": "初始化无密码配置时立即返回;已有凭据不覆盖;成功时只将旧普通管理员保护置0,没有将其恢复为1的路径。主代理已移除一次性环境变量并重启Auth,本脚本在该服务上核验admin保护仍为false。" | |||||
| }, | |||||
| "status": "PASS_WITH_LIMITATIONS", | |||||
| "finishedAt": "2026-09-06T03:48:35.682Z" | |||||
| } | |||||
| @@ -0,0 +1,194 @@ | |||||
| { | |||||
| "startedAt": "2026-09-06T03:49:40.457Z", | |||||
| "mode": "LIVE_API", | |||||
| "checks": [ | |||||
| { | |||||
| "name": "维护账号使用已分配的内部角色,普通管理员不带维护标记", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "admin 的普通角色目录和分页都隐藏内部角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "admin 不能按已知内部角色 ID 读取/详情", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "admin 不能按已知内部角色 ID 读取/users", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "admin 不能按已知内部角色 ID 读取/permissions", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "root 的普通角色目录和分页都隐藏内部角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "root 不能按已知内部角色 ID 读取/详情", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "root 不能按已知内部角色 ID 读取/users", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "root 不能按已知内部角色 ID 读取/permissions", | |||||
| "result": "PASS", | |||||
| "status": 404 | |||||
| }, | |||||
| { | |||||
| "name": "角色 admin 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "角色 administrative 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "角色 teacher 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "角色 student 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "角色 group_leader 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "角色 rv_reader_1e43e04e 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "角色 rv_editor_1e43e04e 的关联用户排除维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "教学人员搜索不暴露维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "教学人员按 ID 解析拒绝维护账号", | |||||
| "result": "PASS", | |||||
| "status": 400 | |||||
| }, | |||||
| { | |||||
| "name": "公开登录候选列表不暴露维护账号", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "admin 无法通过普通用户接口给测试账号授予内部角色", | |||||
| "result": "PASS", | |||||
| "status": 400 | |||||
| }, | |||||
| { | |||||
| "name": "root 无法通过普通用户接口给测试账号授予内部角色", | |||||
| "result": "PASS", | |||||
| "status": 400 | |||||
| }, | |||||
| { | |||||
| "name": "拒绝内部角色分配后测试账号原角色和版本保持不变", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "admin 无法通过普通编辑更改固定管理员数据范围", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "admin 无法停用固定管理员角色", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "root 无法通过普通编辑更改固定管理员数据范围", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "root 无法停用固定管理员角色", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "固定管理员角色状态、范围、版本未被边界请求改变", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通管理员不能读取维护审计/列表", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "普通管理员不能读取维护审计/stats", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "普通管理员不能读取维护审计/export", | |||||
| "result": "PASS", | |||||
| "status": 403 | |||||
| }, | |||||
| { | |||||
| "name": "已知维护账号错误密码和未知账号均返回统一认证失败", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "维护登录失败在普通列表不可见且内部审计保留真实 USER 目标", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "未知身份失败仍可在普通安全审计中查到", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通审计导出不包含维护记录,维护导出保留该记录", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通审计统计只计算 BUSINESS,维护统计只计算 INTERNAL", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "普通用户和角色统计排除内部账号及内部角色", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "首页人数不计维护账号,最近动态不显示维护失败日志", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "数据库只读核验:已知维护失败 INTERNAL,未知失败 BUSINESS", | |||||
| "result": "PASS" | |||||
| }, | |||||
| { | |||||
| "name": "维护再次登录后普通 admin 仍未被重新保护", | |||||
| "result": "PASS" | |||||
| } | |||||
| ], | |||||
| "limitations": [], | |||||
| "databaseEvidence": { | |||||
| "ordinaryUserCount": 23, | |||||
| "ordinaryRoleCount": 7, | |||||
| "businessAuditCount": 260, | |||||
| "internalAuditCount": 14, | |||||
| "knownFailureScope": "INTERNAL", | |||||
| "unknownFailureScope": "BUSINESS", | |||||
| "knownFailurePreservesTarget": true | |||||
| }, | |||||
| "bootstrapReview": { | |||||
| "result": "PASS", | |||||
| "note": "初始化无密码配置时立即返回;已有凭据不覆盖;成功时只将旧普通管理员保护置0,没有将其恢复为1的路径。主代理已移除一次性环境变量并重启Auth,本脚本在该服务上核验admin保护仍为false。" | |||||
| }, | |||||
| "status": "PASS", | |||||
| "finishedAt": "2026-09-06T03:49:58.984Z" | |||||
| } | |||||
| @@ -0,0 +1,541 @@ | |||||
| { | |||||
| "config": { | |||||
| "configFile": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\role-permissions-live.config.ts", | |||||
| "rootDir": "E:/work/edit_3d/unreal_tran/ute2e/tests", | |||||
| "forbidOnly": false, | |||||
| "fullyParallel": false, | |||||
| "globalSetup": null, | |||||
| "globalTeardown": null, | |||||
| "globalTimeout": 0, | |||||
| "grep": {}, | |||||
| "grepInvert": null, | |||||
| "maxFailures": 0, | |||||
| "metadata": { | |||||
| "actualWorkers": 1 | |||||
| }, | |||||
| "preserveOutput": "always", | |||||
| "reporter": [ | |||||
| [ | |||||
| "list", | |||||
| null | |||||
| ], | |||||
| [ | |||||
| "html", | |||||
| { | |||||
| "outputFolder": "reports/role-permissions-20260906/playwright", | |||||
| "open": "never" | |||||
| } | |||||
| ], | |||||
| [ | |||||
| "json", | |||||
| { | |||||
| "outputFile": "reports/role-permissions-20260906/results.json" | |||||
| } | |||||
| ] | |||||
| ], | |||||
| "reportSlowTests": { | |||||
| "max": 5, | |||||
| "threshold": 300000 | |||||
| }, | |||||
| "quiet": false, | |||||
| "projects": [ | |||||
| { | |||||
| "outputDir": "E:/work/edit_3d/unreal_tran/ute2e/reports/role-permissions-20260906/test-results", | |||||
| "repeatEach": 1, | |||||
| "retries": 0, | |||||
| "metadata": { | |||||
| "actualWorkers": 1 | |||||
| }, | |||||
| "id": "", | |||||
| "name": "", | |||||
| "testDir": "E:/work/edit_3d/unreal_tran/ute2e/tests", | |||||
| "testIgnore": [], | |||||
| "testMatch": [ | |||||
| "role-permissions-live.spec.ts", | |||||
| "role-permissions-races.spec.ts" | |||||
| ], | |||||
| "timeout": 90000 | |||||
| } | |||||
| ], | |||||
| "shard": null, | |||||
| "updateSnapshots": "missing", | |||||
| "updateSourceMethod": "patch", | |||||
| "version": "1.55.0", | |||||
| "workers": 1, | |||||
| "webServer": null | |||||
| }, | |||||
| "suites": [ | |||||
| { | |||||
| "title": "role-permissions-live.spec.ts", | |||||
| "file": "role-permissions-live.spec.ts", | |||||
| "column": 0, | |||||
| "line": 0, | |||||
| "specs": [ | |||||
| { | |||||
| "title": "管理员授权固定,权限树细分到页面操作", | |||||
| "ok": true, | |||||
| "tags": [], | |||||
| "tests": [ | |||||
| { | |||||
| "timeout": 90000, | |||||
| "annotations": [], | |||||
| "expectedStatus": "passed", | |||||
| "projectId": "", | |||||
| "projectName": "", | |||||
| "results": [ | |||||
| { | |||||
| "workerIndex": 0, | |||||
| "parallelIndex": 0, | |||||
| "status": "passed", | |||||
| "duration": 4890, | |||||
| "errors": [], | |||||
| "stdout": [], | |||||
| "stderr": [], | |||||
| "retry": 0, | |||||
| "startTime": "2026-09-06T04:01:44.991Z", | |||||
| "annotations": [], | |||||
| "attachments": [ | |||||
| { | |||||
| "name": "01-admin-fixed-permissions", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-管理员授权固定,权限树细分到页面操作\\attachments\\01-admin-fixed-permissions-a7dbf6b3e095f684c40ab84f44679efd6782130c.png" | |||||
| }, | |||||
| { | |||||
| "name": "sanitized-api-failures", | |||||
| "contentType": "application/json", | |||||
| "body": "W3sicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsInN0YXR1cyI6NDA0fSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifV0=" | |||||
| } | |||||
| ] | |||||
| } | |||||
| ], | |||||
| "status": "expected" | |||||
| } | |||||
| ], | |||||
| "id": "6358900fc7e9a59113e1-9dcc2bf9f0cc9a70dee9", | |||||
| "file": "role-permissions-live.spec.ts", | |||||
| "line": 61, | |||||
| "column": 1 | |||||
| }, | |||||
| { | |||||
| "title": "权限依赖:勾选操作自动补齐查看,取消查看需要确认", | |||||
| "ok": true, | |||||
| "tags": [], | |||||
| "tests": [ | |||||
| { | |||||
| "timeout": 90000, | |||||
| "annotations": [], | |||||
| "expectedStatus": "passed", | |||||
| "projectId": "", | |||||
| "projectName": "", | |||||
| "results": [ | |||||
| { | |||||
| "workerIndex": 0, | |||||
| "parallelIndex": 0, | |||||
| "status": "passed", | |||||
| "duration": 11397, | |||||
| "errors": [], | |||||
| "stdout": [], | |||||
| "stderr": [], | |||||
| "retry": 0, | |||||
| "startTime": "2026-09-06T04:01:50.388Z", | |||||
| "annotations": [], | |||||
| "attachments": [ | |||||
| { | |||||
| "name": "02-action-adds-page-permission", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-权限依赖:勾选操作自动补齐查看,取消查看需要确认\\attachments\\02-action-adds-page-permission-7711c2cf7fb63f1e3f92afd44a723fc1e6e47df8.png" | |||||
| }, | |||||
| { | |||||
| "name": "03-removing-page-confirms-dependent-actions", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-权限依赖:勾选操作自动补齐查看,取消查看需要确认\\attachments\\03-removing-page-confirms-dependent-actions-0f1864b58272a29fc74d69df297edd82dc941471.png" | |||||
| }, | |||||
| { | |||||
| "name": "sanitized-api-failures", | |||||
| "contentType": "application/json", | |||||
| "body": "W3sicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsInN0YXR1cyI6NDA0fSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifV0=" | |||||
| } | |||||
| ] | |||||
| } | |||||
| ], | |||||
| "status": "expected" | |||||
| } | |||||
| ], | |||||
| "id": "6358900fc7e9a59113e1-4b3691030af7af361df9", | |||||
| "file": "role-permissions-live.spec.ts", | |||||
| "line": 75, | |||||
| "column": 1 | |||||
| }, | |||||
| { | |||||
| "title": "头像弹窗切换自定义角色,清理旧缓存并改变操作能力", | |||||
| "ok": true, | |||||
| "tags": [], | |||||
| "tests": [ | |||||
| { | |||||
| "timeout": 90000, | |||||
| "annotations": [], | |||||
| "expectedStatus": "passed", | |||||
| "projectId": "", | |||||
| "projectName": "", | |||||
| "results": [ | |||||
| { | |||||
| "workerIndex": 0, | |||||
| "parallelIndex": 0, | |||||
| "status": "passed", | |||||
| "duration": 6209, | |||||
| "errors": [], | |||||
| "stdout": [], | |||||
| "stderr": [], | |||||
| "retry": 0, | |||||
| "startTime": "2026-09-06T04:02:01.805Z", | |||||
| "annotations": [], | |||||
| "attachments": [ | |||||
| { | |||||
| "name": "04-custom-reader-model-list", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力\\attachments\\04-custom-reader-model-list-f3116a1108bad705793d298a084fd84d32c45616.png" | |||||
| }, | |||||
| { | |||||
| "name": "05-avatar-role-switch-dialog", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力\\attachments\\05-avatar-role-switch-dialog-be8e9c76621a69cac0d2c6d56ffe05126177d602.png" | |||||
| }, | |||||
| { | |||||
| "name": "06-custom-editor-model-list", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-头像弹窗切换自定义角色,清理旧缓存并改变操作能力\\attachments\\06-custom-editor-model-list-537906d386cfab21188de6e97acba72dc947dd1c.png" | |||||
| }, | |||||
| { | |||||
| "name": "sanitized-api-failures", | |||||
| "contentType": "application/json", | |||||
| "body": "W3sicGF0aCI6Ii9hcGkvdHJhbi92MS9jb250ZW50L3Byb2plY3RzLzE1Ni9jb3ZlciIsInN0YXR1cyI6NTAzfSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNTcvY292ZXIiLCJzdGF0dXMiOjUwM30seyJwYXRoIjoiL2FwaS92MS9maWxlcy8wZDg4NjRhNGZlOGY0NmMyYmY0ZmQ0OTAyZjFiMzY0Ni9jb250ZW50Iiwic3RhdHVzIjo0MDR9LHsicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsImVycm9yIjoibmV0OjpFUlJfQUJPUlRFRCJ9LHsicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsInN0YXR1cyI6NDA0fSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJzdGF0dXMiOjQwNH0seyJwYXRoIjoiL2FwaS92MS9maWxlcy8wZDg4NjRhNGZlOGY0NmMyYmY0ZmQ0OTAyZjFiMzY0Ni9jb250ZW50IiwiZXJyb3IiOiJuZXQ6OkVSUl9BQk9SVEVEIn0seyJwYXRoIjoiL2FwaS90cmFuL3YxL2NvbnRlbnQvcHJvamVjdHMiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifV0=" | |||||
| } | |||||
| ] | |||||
| } | |||||
| ], | |||||
| "status": "expected" | |||||
| } | |||||
| ], | |||||
| "id": "6358900fc7e9a59113e1-0620d79fdeab76b867de", | |||||
| "file": "role-permissions-live.spec.ts", | |||||
| "line": 112, | |||||
| "column": 1 | |||||
| }, | |||||
| { | |||||
| "title": "角色切换接口失败保留原身份与页面", | |||||
| "ok": true, | |||||
| "tags": [], | |||||
| "tests": [ | |||||
| { | |||||
| "timeout": 90000, | |||||
| "annotations": [], | |||||
| "expectedStatus": "passed", | |||||
| "projectId": "", | |||||
| "projectName": "", | |||||
| "results": [ | |||||
| { | |||||
| "workerIndex": 0, | |||||
| "parallelIndex": 0, | |||||
| "status": "passed", | |||||
| "duration": 3666, | |||||
| "errors": [], | |||||
| "stdout": [], | |||||
| "stderr": [], | |||||
| "retry": 0, | |||||
| "startTime": "2026-09-06T04:02:08.032Z", | |||||
| "annotations": [], | |||||
| "attachments": [ | |||||
| { | |||||
| "name": "07-failed-switch-keeps-current-role", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-角色切换接口失败保留原身份与页面\\attachments\\07-failed-switch-keeps-current-role-316057e08801988273f288a039d5591aff4f6f31.png" | |||||
| }, | |||||
| { | |||||
| "name": "sanitized-api-failures", | |||||
| "contentType": "application/json", | |||||
| "body": "W3sicGF0aCI6Ii9hcGkvYXV0aC92MS9hdXRoL2FjdGl2ZS1yb2xlIiwic3RhdHVzIjo1MDN9XQ==" | |||||
| } | |||||
| ] | |||||
| } | |||||
| ], | |||||
| "status": "expected" | |||||
| } | |||||
| ], | |||||
| "id": "6358900fc7e9a59113e1-13060bac3e155c7abc43", | |||||
| "file": "role-permissions-live.spec.ts", | |||||
| "line": 135, | |||||
| "column": 1 | |||||
| }, | |||||
| { | |||||
| "title": "切换已提交但身份加载失败,不展示旧角色业务页面", | |||||
| "ok": true, | |||||
| "tags": [], | |||||
| "tests": [ | |||||
| { | |||||
| "timeout": 90000, | |||||
| "annotations": [], | |||||
| "expectedStatus": "passed", | |||||
| "projectId": "", | |||||
| "projectName": "", | |||||
| "results": [ | |||||
| { | |||||
| "workerIndex": 0, | |||||
| "parallelIndex": 0, | |||||
| "status": "passed", | |||||
| "duration": 5116, | |||||
| "errors": [], | |||||
| "stdout": [], | |||||
| "stderr": [], | |||||
| "retry": 0, | |||||
| "startTime": "2026-09-06T04:02:11.711Z", | |||||
| "annotations": [], | |||||
| "attachments": [ | |||||
| { | |||||
| "name": "08-role-refresh-failure-clears-old-view", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-切换已提交但身份加载失败,不展示旧角色业务页面\\attachments\\08-role-refresh-failure-clears-old-view-849ba5b0acb05d9d8d115afcfda3644f0a4dc52b.png" | |||||
| }, | |||||
| { | |||||
| "name": "sanitized-api-failures", | |||||
| "contentType": "application/json", | |||||
| "body": "W3sicGF0aCI6Ii9hcGkvdHJhbi92MS9jb250ZW50L3Byb2plY3RzLzE1Ni9jb3ZlciIsInN0YXR1cyI6NTAzfSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNTcvY292ZXIiLCJzdGF0dXMiOjUwM30seyJwYXRoIjoiL2FwaS92MS9maWxlcy8wZDg4NjRhNGZlOGY0NmMyYmY0ZmQ0OTAyZjFiMzY0Ni9jb250ZW50Iiwic3RhdHVzIjo0MDR9LHsicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsImVycm9yIjoibmV0OjpFUlJfQUJPUlRFRCJ9LHsicGF0aCI6Ii9hcGkvYXV0aC92MS9hdXRoL21lIiwic3RhdHVzIjo1MDN9XQ==" | |||||
| } | |||||
| ] | |||||
| } | |||||
| ], | |||||
| "status": "expected" | |||||
| } | |||||
| ], | |||||
| "id": "6358900fc7e9a59113e1-be7827a263424b81f5ff", | |||||
| "file": "role-permissions-live.spec.ts", | |||||
| "line": 148, | |||||
| "column": 1 | |||||
| }, | |||||
| { | |||||
| "title": "训练编排未保存时,取消切换保留当前内容与身份", | |||||
| "ok": true, | |||||
| "tags": [], | |||||
| "tests": [ | |||||
| { | |||||
| "timeout": 90000, | |||||
| "annotations": [], | |||||
| "expectedStatus": "passed", | |||||
| "projectId": "", | |||||
| "projectName": "", | |||||
| "results": [ | |||||
| { | |||||
| "workerIndex": 0, | |||||
| "parallelIndex": 0, | |||||
| "status": "passed", | |||||
| "duration": 7870, | |||||
| "errors": [], | |||||
| "stdout": [], | |||||
| "stderr": [], | |||||
| "retry": 0, | |||||
| "startTime": "2026-09-06T04:02:16.842Z", | |||||
| "annotations": [], | |||||
| "attachments": [ | |||||
| { | |||||
| "name": "09-unsaved-training-blocks-role-switch", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-训练编排未保存时,取消切换保留当前内容与身份\\attachments\\09-unsaved-training-blocks-role-switch-395fba2e761d73a715ad9e1bca03e75a60582a98.png" | |||||
| }, | |||||
| { | |||||
| "name": "sanitized-api-failures", | |||||
| "contentType": "application/json", | |||||
| "body": "W3sicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsInN0YXR1cyI6NDA0fSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNjAvYXNzZXRzLzIwMzAvY29udGVudCIsInN0YXR1cyI6NTAzfSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNjAvYXNzZXRzLzIwMzAvY29udGVudCIsInN0YXR1cyI6NTAzfV0=" | |||||
| } | |||||
| ] | |||||
| } | |||||
| ], | |||||
| "status": "expected" | |||||
| } | |||||
| ], | |||||
| "id": "6358900fc7e9a59113e1-03eef81e9aecf1e22039", | |||||
| "file": "role-permissions-live.spec.ts", | |||||
| "line": 160, | |||||
| "column": 1 | |||||
| }, | |||||
| { | |||||
| "title": "未保存训练的保存请求失败时,阻止提交角色切换", | |||||
| "ok": true, | |||||
| "tags": [], | |||||
| "tests": [ | |||||
| { | |||||
| "timeout": 90000, | |||||
| "annotations": [], | |||||
| "expectedStatus": "passed", | |||||
| "projectId": "", | |||||
| "projectName": "", | |||||
| "results": [ | |||||
| { | |||||
| "workerIndex": 0, | |||||
| "parallelIndex": 0, | |||||
| "status": "passed", | |||||
| "duration": 6604, | |||||
| "errors": [], | |||||
| "stdout": [], | |||||
| "stderr": [], | |||||
| "retry": 0, | |||||
| "steps": [ | |||||
| { | |||||
| "title": "Expect \"poll toBe\"", | |||||
| "duration": 1 | |||||
| } | |||||
| ], | |||||
| "startTime": "2026-09-06T04:02:24.729Z", | |||||
| "annotations": [], | |||||
| "attachments": [ | |||||
| { | |||||
| "name": "09b-save-failure-blocks-role-switch", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-live-未保存训练的保存请求失败时,阻止提交角色切换\\attachments\\09b-save-failure-blocks-role-switch-c412151ca410dece5b637c443359b2baccb8aa42.png" | |||||
| }, | |||||
| { | |||||
| "name": "sanitized-api-failures", | |||||
| "contentType": "application/json", | |||||
| "body": "W3sicGF0aCI6Ii9hcGkvdjEvZmlsZXMvMGQ4ODY0YTRmZThmNDZjMmJmNGZkNDkwMmYxYjM2NDYvY29udGVudCIsInN0YXR1cyI6NDA0fSx7InBhdGgiOiIvYXBpL3YxL2ZpbGVzLzBkODg2NGE0ZmU4ZjQ2YzJiZjRmZDQ5MDJmMWIzNjQ2L2NvbnRlbnQiLCJlcnJvciI6Im5ldDo6RVJSX0FCT1JURUQifSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNjAvYXNzZXRzLzIwMzAvY29udGVudCIsInN0YXR1cyI6NTAzfSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNjAvYXNzZXRzLzIwMzAvY29udGVudCIsInN0YXR1cyI6NTAzfSx7InBhdGgiOiIvYXBpL3RyYW4vdjEvY29udGVudC9wcm9qZWN0cy8xNjQiLCJzdGF0dXMiOjUwM31d" | |||||
| } | |||||
| ] | |||||
| } | |||||
| ], | |||||
| "status": "expected" | |||||
| } | |||||
| ], | |||||
| "id": "6358900fc7e9a59113e1-27778e25c714dcc2239e", | |||||
| "file": "role-permissions-live.spec.ts", | |||||
| "line": 185, | |||||
| "column": 1 | |||||
| } | |||||
| ] | |||||
| }, | |||||
| { | |||||
| "title": "role-permissions-races.spec.ts", | |||||
| "file": "role-permissions-races.spec.ts", | |||||
| "column": 0, | |||||
| "line": 0, | |||||
| "specs": [ | |||||
| { | |||||
| "title": "权限加载乱序:关闭 A 后打开 B,A 的迟到响应不能覆盖 B", | |||||
| "ok": true, | |||||
| "tags": [], | |||||
| "tests": [ | |||||
| { | |||||
| "timeout": 90000, | |||||
| "annotations": [], | |||||
| "expectedStatus": "passed", | |||||
| "projectId": "", | |||||
| "projectName": "", | |||||
| "results": [ | |||||
| { | |||||
| "workerIndex": 0, | |||||
| "parallelIndex": 0, | |||||
| "status": "passed", | |||||
| "duration": 6210, | |||||
| "errors": [], | |||||
| "stdout": [], | |||||
| "stderr": [], | |||||
| "retry": 0, | |||||
| "steps": [ | |||||
| { | |||||
| "title": "Expect \"poll toEqual\"", | |||||
| "duration": 437 | |||||
| } | |||||
| ], | |||||
| "startTime": "2026-09-06T04:02:31.380Z", | |||||
| "annotations": [], | |||||
| "attachments": [ | |||||
| { | |||||
| "name": "10-stale-permission-response-is-ignored", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-races-权限加载乱序:关闭-A-后打开-B,A-的迟到响应不能覆盖-B\\attachments\\10-stale-permission-response-is-ignored-bf5697b3945ce8d19e7fb514c307274a0c21551a.png" | |||||
| }, | |||||
| { | |||||
| "name": "10-stale-permission-response-is-ignored-evidence", | |||||
| "contentType": "application/json", | |||||
| "body": "ewogICJkZWxheWVkUm9sZSI6ICJydl9yZWFkZXJfMWU0M2UwNGUiLAogICJjdXJyZW50Um9sZSI6ICJydl9lZGl0b3JfMWU0M2UwNGUiLAogICJkZWxheWVkUGVybWlzc2lvbkNvZGVzIjogWwogICAgImNvbnRlbnQubW9kZWwiLAogICAgImRhc2hib2FyZC52aWV3IgogIF0sCiAgImRpc3BsYXllZFBlcm1pc3Npb25Db2RlcyI6IFsKICAgICJjb250ZW50Lm1vZGVsIiwKICAgICJjb250ZW50Lm1vZGVsLmNyZWF0ZSIsCiAgICAiY29udGVudC5tb2RlbC5kZWxldGUiLAogICAgImNvbnRlbnQubW9kZWwucHVibGlzaCIsCiAgICAiY29udGVudC5tb2RlbC51cGRhdGUiLAogICAgImRhc2hib2FyZC52aWV3IgogIF0sCiAgImJ1c2luZXNzV3JpdGVzIjogMAp9" | |||||
| } | |||||
| ] | |||||
| } | |||||
| ], | |||||
| "status": "expected" | |||||
| } | |||||
| ], | |||||
| "id": "d51c522ad2ea451f8c2b-14a99b2fcefe3f44dfbf", | |||||
| "file": "role-permissions-races.spec.ts", | |||||
| "line": 47, | |||||
| "column": 1 | |||||
| }, | |||||
| { | |||||
| "title": "保存授权期间禁止恢复,PUT 成功但列表刷新失败仍保留提交快照", | |||||
| "ok": true, | |||||
| "tags": [], | |||||
| "tests": [ | |||||
| { | |||||
| "timeout": 90000, | |||||
| "annotations": [], | |||||
| "expectedStatus": "passed", | |||||
| "projectId": "", | |||||
| "projectName": "", | |||||
| "results": [ | |||||
| { | |||||
| "workerIndex": 0, | |||||
| "parallelIndex": 0, | |||||
| "status": "passed", | |||||
| "duration": 6511, | |||||
| "errors": [], | |||||
| "stdout": [], | |||||
| "stderr": [], | |||||
| "retry": 0, | |||||
| "steps": [ | |||||
| { | |||||
| "title": "Expect \"poll toEqual\"", | |||||
| "duration": 960 | |||||
| }, | |||||
| { | |||||
| "title": "Expect \"poll toEqual\"", | |||||
| "duration": 7 | |||||
| } | |||||
| ], | |||||
| "startTime": "2026-09-06T04:02:37.608Z", | |||||
| "annotations": [], | |||||
| "attachments": [ | |||||
| { | |||||
| "name": "11-permission-save-survives-list-refresh-failure", | |||||
| "contentType": "image/png", | |||||
| "path": "E:\\work\\edit_3d\\unreal_tran\\ute2e\\reports\\role-permissions-20260906\\test-results\\role-permissions-races-保存授权期间禁止恢复,PUT-成功但列表刷新失败仍保留提交快照\\attachments\\11-permission-save-survives-list-refresh-failure-8b98f81877c4a0dd52bd2b8bf7557f3304da6916.png" | |||||
| }, | |||||
| { | |||||
| "name": "11-permission-save-survives-list-refresh-failure-evidence", | |||||
| "contentType": "application/json", | |||||
| "body": "ewogICJ0ZW1wb3JhcnlSb2xlIjogInJ2X3JlYWRlcl8xZTQzZTA0ZSIsCiAgIm9yaWdpbmFsUGVybWlzc2lvbkNvZGVzIjogWwogICAgImNvbnRlbnQubW9kZWwiLAogICAgImRhc2hib2FyZC52aWV3IgogIF0sCiAgInN1Ym1pdHRlZFBlcm1pc3Npb25Db2RlcyI6IFsKICAgICJjb250ZW50Lm1vZGVsIiwKICAgICJjb250ZW50Lm1vZGVsLmNyZWF0ZSIsCiAgICAiZGFzaGJvYXJkLnZpZXciCiAgXSwKICAicmVhbFB1dFN0YXR1cyI6IDIwMCwKICAiaW5qZWN0ZWRMaXN0UmVmcmVzaFN0YXR1cyI6IDUwMywKICAicmVzZXREaXNhYmxlZFdoaWxlU2F2aW5nIjogdHJ1ZQp9" | |||||
| }, | |||||
| { | |||||
| "name": "11-temporary-reader-restored", | |||||
| "contentType": "application/json", | |||||
| "body": "eyJyb2xlQ29kZSI6InJ2X3JlYWRlcl8xZTQzZTA0ZSIsInJlc3RvcmVkIjp0cnVlLCJwZXJtaXNzaW9uQ29kZXMiOlsiY29udGVudC5tb2RlbCIsImRhc2hib2FyZC52aWV3Il19" | |||||
| } | |||||
| ] | |||||
| } | |||||
| ], | |||||
| "status": "expected" | |||||
| } | |||||
| ], | |||||
| "id": "d51c522ad2ea451f8c2b-cdb3c98b056833e4a102", | |||||
| "file": "role-permissions-races.spec.ts", | |||||
| "line": 102, | |||||
| "column": 1 | |||||
| } | |||||
| ] | |||||
| } | |||||
| ], | |||||
| "errors": [], | |||||
| "stats": { | |||||
| "startTime": "2026-09-06T04:01:43.840Z", | |||||
| "duration": 91148.27900000001, | |||||
| "expected": 9, | |||||
| "skipped": 0, | |||||
| "unexpected": 0, | |||||
| "flaky": 0 | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,110 @@ | |||||
| { | |||||
| "time": "2026-09-06T04:08:03.624366+00:00", | |||||
| "overallStatus": "PASS", | |||||
| "e2eComplete": true, | |||||
| "build": { | |||||
| "web": "PASS: pnpm build; Vite production build and postbuild 10 iframe payload checks", | |||||
| "auth": "PASS: Maven test/package", | |||||
| "tran": "PASS: Maven test/package" | |||||
| }, | |||||
| "backendTests": { | |||||
| "ut_auth": { | |||||
| "tests": 112, | |||||
| "failures": 0, | |||||
| "errors": 0, | |||||
| "skipped": 0 | |||||
| }, | |||||
| "ut_tran": { | |||||
| "tests": 340, | |||||
| "failures": 0, | |||||
| "errors": 0, | |||||
| "skipped": 0 | |||||
| } | |||||
| }, | |||||
| "nodeTests": { | |||||
| "contentCoverPermissions": { | |||||
| "tests": 57, | |||||
| "failed": 0, | |||||
| "command": "node --test tools/content-permissions.test.mjs tools/training-cover.test.mjs tools/scene-cover-api.test.mjs" | |||||
| }, | |||||
| "uiPermissionContracts": { | |||||
| "tests": 12, | |||||
| "failed": 0, | |||||
| "command": "node --test scripts/permission-selection.contract.test.mjs scripts/teaching-permissions.test.mjs" | |||||
| } | |||||
| }, | |||||
| "realApi": { | |||||
| "permissions": 32, | |||||
| "maintenanceBoundaries": 39, | |||||
| "failed": 0 | |||||
| }, | |||||
| "e2e": { | |||||
| "complete": true, | |||||
| "passed": 9, | |||||
| "failed": 0, | |||||
| "flaky": 0, | |||||
| "skipped": 0, | |||||
| "screenshots": 12, | |||||
| "command": "pnpm exec playwright test --config role-permissions-live.config.ts" | |||||
| }, | |||||
| "ddl": { | |||||
| "status": "PASS", | |||||
| "file": "20260906_036_role_permissions_maintenance.sql", | |||||
| "sha256": "66d6c32edef315c8fcd9264352dda3444ddcdf1ca2d50d2af6f138fc7f4d4bd1", | |||||
| "environment": "Development: unreal_tran / public, KingBase MySQL compatibility", | |||||
| "execution": "200 statements committed; same SHA rerun committed successfully", | |||||
| "catalogLeafCount": 138, | |||||
| "adminFixedLeafCount": 106, | |||||
| "fullDdlUpdated": true, | |||||
| "executionRecord": "unreal_tran_api/database/迁移执行记录.md" | |||||
| }, | |||||
| "runtime": { | |||||
| "gateway": "http://127.0.0.1:6180", | |||||
| "authPort": 6101, | |||||
| "tranPort": 6102, | |||||
| "deployedArtifacts": { | |||||
| "auth": { | |||||
| "sha256": "98aaeedfe29c8e75e99ef9421a3982ae52ab357eb8592c68a30dfc54e633ff85", | |||||
| "runningCopyMatches": true | |||||
| }, | |||||
| "tran": { | |||||
| "sha256": "d66a38602e32c0db0c2680905f6a6461d9cd09ab12f4ba1481c87f99f583e828", | |||||
| "runningCopyMatches": true | |||||
| } | |||||
| }, | |||||
| "rootBootstrap": "Initialized from the local account file; one-time bootstrap disabled on restart; root login verified afterward", | |||||
| "adminProtected": false | |||||
| }, | |||||
| "cleanup": { | |||||
| "time": "2026-09-06T04:07:09.247Z", | |||||
| "projects": [ | |||||
| "165" | |||||
| ], | |||||
| "users": [ | |||||
| "100" | |||||
| ], | |||||
| "roles": [ | |||||
| "20", | |||||
| "21" | |||||
| ], | |||||
| "status": "PASS", | |||||
| "postDeleteReads": "All 4 isolated fixture records return HTTP 404" | |||||
| }, | |||||
| "artifactChecks": { | |||||
| "status": "PASS", | |||||
| "file": "artifact-checks.json", | |||||
| "diffWhitespace": { | |||||
| "unreal_tran_api": "PASS", | |||||
| "unreal_tran_web": "PASS", | |||||
| "ute2e": "PASS" | |||||
| } | |||||
| }, | |||||
| "scopeNotes": [ | |||||
| "Root login and operation audits are retained as INTERNAL; ordinary lists, statistics and exports exclude them.", | |||||
| "Admin has fixed persisted grants; both admin and root cannot edit that built-in role permission set through the application.", | |||||
| "OFD legacy editor continues to store its existing local browser data; session permission checks were added, not a new server persistence workflow.", | |||||
| "Permission and role-switch tests are the focus; this run does not claim complete regression of every teaching or 3D business scenario.", | |||||
| "Temporary test fixtures were deleted; existing training used for dirty-state checks was not saved.", | |||||
| "Development environment only; no production deployment, Git commit or push performed." | |||||
| ] | |||||
| } | |||||
| @@ -0,0 +1,19 @@ | |||||
| import { defineConfig } from '@playwright/test' | |||||
| export default defineConfig({ | |||||
| testDir: './tests', testMatch: ['role-permissions-live.spec.ts', 'role-permissions-races.spec.ts'], | |||||
| outputDir: './reports/role-permissions-20260906/test-results', | |||||
| workers: 1, fullyParallel: false, retries: 0, timeout: 90_000, | |||||
| expect: { timeout: 20_000 }, | |||||
| reporter: [ | |||||
| ['list'], | |||||
| ['html', { outputFolder: 'reports/role-permissions-20260906/playwright', open: 'never' }], | |||||
| ['json', { outputFile: 'reports/role-permissions-20260906/results.json' }], | |||||
| ], | |||||
| use: { | |||||
| baseURL: 'http://127.0.0.1:6180', browserName: 'chromium', channel: 'msedge', headless: true, | |||||
| locale: 'zh-CN', timezoneId: 'Asia/Shanghai', viewport: { width: 1600, height: 1000 }, | |||||
| navigationTimeout: 45_000, actionTimeout: 20_000, | |||||
| screenshot: 'only-on-failure', trace: 'off', video: 'off', | |||||
| }, | |||||
| }) | |||||
| @@ -0,0 +1,213 @@ | |||||
| import { test, expect, type Page, type TestInfo } from '@playwright/test' | |||||
| import fs from 'node:fs' | |||||
| import path from 'node:path' | |||||
| // Local helper reads credentials in memory. Reports and Playwright traces never contain them. | |||||
| // @ts-ignore JS helper is also executable independently for API checks. | |||||
| import { call, login, work } from '../tools/role-permissions-live.mjs' | |||||
| const shots = path.resolve('reports/role-permissions-20260906/screenshots') | |||||
| fs.mkdirSync(shots, { recursive: true }) | |||||
| const fixture = () => JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8')) | |||||
| const networkFailures = new WeakMap<Page, Array<{ path: string; status?: number; error?: string }>>() | |||||
| test.beforeEach(async ({ page }) => { | |||||
| const failures: Array<{ path: string; status?: number; error?: string }> = [] | |||||
| networkFailures.set(page, failures) | |||||
| page.on('requestfailed', request => { | |||||
| if (new URL(request.url()).pathname.startsWith('/api/')) failures.push({ path: new URL(request.url()).pathname, error: request.failure()?.errorText }) | |||||
| }) | |||||
| page.on('response', response => { | |||||
| if (response.status() >= 400 && new URL(response.url()).pathname.startsWith('/api/')) failures.push({ path: new URL(response.url()).pathname, status: response.status() }) | |||||
| }) | |||||
| }) | |||||
| test.afterEach(async ({ page }, info) => { | |||||
| await info.attach('sanitized-api-failures', { body: JSON.stringify(networkFailures.get(page) ?? []), contentType: 'application/json' }) | |||||
| const token = await page.evaluate(() => sessionStorage.getItem('unreal-tran:web:access-token:v1') || localStorage.getItem('unreal-tran:web:access-token:v1')).catch(() => null) | |||||
| if (token) await call('auth/v1/auth/logout', token, {}).catch(() => undefined) | |||||
| }) | |||||
| const shot = async (page: Page, info: TestInfo, name: string) => { | |||||
| const filename = path.join(shots, `${name}.png`) | |||||
| await expect(page.locator('.el-loading-mask:visible')).toHaveCount(0) | |||||
| await page.screenshot({ path: filename, fullPage: true, animations: 'disabled' }) | |||||
| await info.attach(name, { path: filename, contentType: 'image/png' }) | |||||
| } | |||||
| async function signIn(page: Page, role: 'admin' | 'reader' | 'editor' | 'teacher' = 'admin') { | |||||
| let session | |||||
| if (role === 'admin') session = await login() | |||||
| else { | |||||
| const data = fixture() | |||||
| const result = await call('auth/v1/auth/login', null, { userId: data.user.id, password: data.user.password, roleCode: 'teacher', departmentId: data.user.departmentId, rememberMe: false }) | |||||
| expect(result.status).toBe(200) | |||||
| const selected = await call('auth/v1/auth/active-role', result.data.accessToken, { roleId: role === 'teacher' ? data.teacherRoleId : data[role].id }, 'PUT') | |||||
| expect(selected.status).toBe(200) | |||||
| session = selected.data?.accessToken ? selected.data : result.data | |||||
| } | |||||
| await page.addInitScript(({ accessToken, refreshToken }) => { | |||||
| // Only initialize once: a role switch may rotate tokens before a full app reload. | |||||
| if (!sessionStorage.getItem('role-permissions-test-initialized')) { | |||||
| sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken) | |||||
| if (refreshToken) sessionStorage.setItem('unreal-tran:web:refresh-token:v1', refreshToken) | |||||
| sessionStorage.setItem('role-permissions-test-initialized', '1') | |||||
| } | |||||
| }, { accessToken: session.accessToken, refreshToken: session.refreshToken }) | |||||
| } | |||||
| async function openSwitcher(page: Page) { | |||||
| await page.getByRole('button', { name: /^账号菜单:/ }).click() | |||||
| await page.getByRole('menuitem', { name: '切换角色', exact: true }).click() | |||||
| const dialog = page.getByRole('dialog', { name: '切换角色', exact: true }) | |||||
| await expect(dialog).toBeVisible() | |||||
| return dialog | |||||
| } | |||||
| const treeNode = (container: ReturnType<Page['locator']>, code: string) => container.locator('.el-tree-node__content').filter({ has: container.page().locator('code').filter({ hasText: new RegExp(`^${code.replaceAll('.', '\\.')}$$`) }) }) | |||||
| test('管理员授权固定,权限树细分到页面操作', async ({ page }, info) => { | |||||
| await signIn(page) | |||||
| await page.goto('/system/roles') | |||||
| const adminRow = page.locator('.el-table__row').filter({ has: page.locator('.role-identity code').filter({ hasText: /^admin$/ }) }) | |||||
| await adminRow.getByRole('button', { name: '配置权限', exact: true }).click() | |||||
| const drawer = page.getByRole('dialog', { name: '配置角色权限' }) | |||||
| await expect(drawer).toContainText('该角色采用固定授权') | |||||
| await drawer.getByPlaceholder('搜索权限名称或编码').fill('content.model') | |||||
| await expect(drawer.locator('code').filter({ hasText: /^content\.model\.create$/ })).toBeVisible() | |||||
| await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled() | |||||
| await expect(treeNode(drawer, 'content.model.create').getByRole('checkbox')).toBeDisabled() | |||||
| await shot(page, info, '01-admin-fixed-permissions') | |||||
| }) | |||||
| test('权限依赖:勾选操作自动补齐查看,取消查看需要确认', async ({ page }, info) => { | |||||
| await signIn(page) | |||||
| const data = fixture() | |||||
| await page.goto('/system/roles') | |||||
| const row = page.locator('.el-table__row').filter({ has: page.locator('.role-identity code').filter({ hasText: data.reader.code }) }) | |||||
| await row.getByRole('button', { name: '配置权限', exact: true }).click() | |||||
| const drawer = page.getByRole('dialog', { name: '配置角色权限' }) | |||||
| const editor = drawer.locator('.permission-tree-editor') | |||||
| await editor.getByPlaceholder('搜索权限名称或编码').fill('content.model') | |||||
| const read = treeNode(editor, 'content.model').getByRole('checkbox') | |||||
| const create = treeNode(editor, 'content.model.create').getByRole('checkbox') | |||||
| await expect(read).toBeChecked() | |||||
| await expect(create).not.toBeChecked() | |||||
| await treeNode(editor, 'content.model').locator('.el-checkbox').click() | |||||
| await expect(read).not.toBeChecked() | |||||
| await treeNode(editor, 'content.model.create').locator('.el-checkbox').click() | |||||
| await expect(read).toBeChecked() | |||||
| await expect(create).toBeChecked() | |||||
| await shot(page, info, '02-action-adds-page-permission') | |||||
| await treeNode(editor, 'content.model').locator('.el-checkbox').click() | |||||
| const confirm = page.getByRole('dialog', { name: '确认取消关联权限' }) | |||||
| await expect(confirm).toBeVisible() | |||||
| await shot(page, info, '03-removing-page-confirms-dependent-actions') | |||||
| await confirm.getByRole('button', { name: '保留权限' }).click() | |||||
| await expect(read).toBeChecked() | |||||
| await expect(create).toBeChecked() | |||||
| await drawer.getByRole('button', { name: '保存授权' }).click() | |||||
| await expect(drawer).not.toBeVisible() | |||||
| await row.getByRole('button', { name: '配置权限', exact: true }).click() | |||||
| await editor.getByPlaceholder('搜索权限名称或编码').fill('content.model') | |||||
| await expect(create).toBeChecked() | |||||
| await treeNode(editor, 'content.model.create').locator('.el-checkbox').click() | |||||
| await expect(create).not.toBeChecked() | |||||
| await drawer.getByRole('button', { name: '保存授权' }).click() | |||||
| await expect(drawer).not.toBeVisible() | |||||
| }) | |||||
| test('头像弹窗切换自定义角色,清理旧缓存并改变操作能力', async ({ page }, info) => { | |||||
| await signIn(page, 'reader') | |||||
| await page.goto('/content/models') | |||||
| await expect(page.getByText('只读权限', { exact: true })).toBeVisible() | |||||
| await shot(page, info, '04-custom-reader-model-list') | |||||
| const dialog = await openSwitcher(page) | |||||
| await expect(dialog.getByRole('button', { name: /权限实测·模型只读/ })).toContainText('当前角色') | |||||
| await shot(page, info, '05-avatar-role-switch-dialog') | |||||
| await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click() | |||||
| await expect(page).toHaveURL(/\/dashboard$/) | |||||
| await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('权限实测·模型编辑') | |||||
| await page.goto('/content/models') | |||||
| await expect(page.getByText('只读权限', { exact: true })).toHaveCount(0) | |||||
| await expect(page.getByRole('button', { name: /新建模型|新增模型/ }).first()).toBeVisible() | |||||
| await shot(page, info, '06-custom-editor-model-list') | |||||
| const scopes = await page.evaluate(() => Object.keys(JSON.parse(localStorage.getItem('unreal-tran:web:tabs:v2') || '{}'))) | |||||
| const data = fixture() | |||||
| expect(scopes).toContain(`${data.user.id}:${data.reader.id}`) | |||||
| expect(scopes).toContain(`${data.user.id}:${data.editor.id}`) | |||||
| await page.goto('/system/roles') | |||||
| await expect(page).not.toHaveURL(/\/system\/roles$/) | |||||
| }) | |||||
| test('角色切换接口失败保留原身份与页面', async ({ page }, info) => { | |||||
| await signIn(page, 'reader') | |||||
| await page.goto('/content/models') | |||||
| await expect(page.getByText('只读权限', { exact: true })).toBeVisible() | |||||
| await page.route('**/api/auth/v1/auth/active-role', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '角色服务临时不可用', data: null }) })) | |||||
| const dialog = await openSwitcher(page) | |||||
| await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click() | |||||
| await expect(page.locator('.el-message--error')).toContainText('角色服务临时不可用') | |||||
| await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('权限实测·模型只读') | |||||
| await expect(page).toHaveURL(/\/content\/models$/) | |||||
| await shot(page, info, '07-failed-switch-keeps-current-role') | |||||
| }) | |||||
| test('切换已提交但身份加载失败,不展示旧角色业务页面', async ({ page }, info) => { | |||||
| await signIn(page, 'reader') | |||||
| await page.goto('/content/models') | |||||
| await expect(page.getByText('只读权限', { exact: true })).toBeVisible() | |||||
| await page.route('**/api/auth/v1/auth/me', route => route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '身份信息临时不可用', data: null }) })) | |||||
| const dialog = await openSwitcher(page) | |||||
| await dialog.getByRole('button', { name: /权限实测·模型编辑/ }).click() | |||||
| await expect(page).toHaveURL(/\/login/) | |||||
| await expect(page.getByRole('button', { name: /^账号菜单:/ })).toHaveCount(0) | |||||
| await shot(page, info, '08-role-refresh-failure-clears-old-view') | |||||
| }) | |||||
| test('训练编排未保存时,取消切换保留当前内容与身份', async ({ page }, info) => { | |||||
| await signIn(page, 'teacher') | |||||
| await page.goto('/content/training-projects/164/legacy') | |||||
| await page.getByRole('button', { name: '项目设置', exact: true }).click() | |||||
| const name = page.locator('[data-field="name"][data-scope="project"]') | |||||
| await expect(name).toBeVisible() | |||||
| await name.fill('权限切换未保存验证') | |||||
| await name.blur() | |||||
| await page.locator('[data-action="close-dialog"]').filter({ hasText: '完成' }).click() | |||||
| const switcher = await openSwitcher(page) | |||||
| let submitted = 0 | |||||
| page.on('request', request => { if (request.url().endsWith('/auth/active-role') && request.method() === 'PUT') submitted++ }) | |||||
| await switcher.getByRole('button', { name: /权限实测·模型只读/ }).click() | |||||
| const warning = page.getByRole('dialog', { name: '离开训练编排?', exact: true }) | |||||
| await expect(warning).toBeVisible() | |||||
| await shot(page, info, '09-unsaved-training-blocks-role-switch') | |||||
| await warning.getByRole('button', { name: /close|关闭/i }).click() | |||||
| await expect(warning).not.toBeVisible() | |||||
| await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('教员') | |||||
| expect(submitted).toBe(0) | |||||
| await switcher.getByRole('button', { name: '关闭', exact: true }).click() | |||||
| await page.getByRole('button', { name: '项目设置', exact: true }).click() | |||||
| await expect(name).toHaveValue('权限切换未保存验证') | |||||
| }) | |||||
| test('未保存训练的保存请求失败时,阻止提交角色切换', async ({ page }, info) => { | |||||
| await signIn(page, 'teacher') | |||||
| await page.goto('/content/training-projects/164/legacy') | |||||
| await page.getByRole('button', { name: '项目设置', exact: true }).click() | |||||
| const name = page.locator('[data-field="name"][data-scope="project"]') | |||||
| await name.fill('保存失败角色切换验证') | |||||
| await name.blur() | |||||
| await page.locator('[data-action="close-dialog"]').filter({ hasText: '完成' }).click() | |||||
| let roleWrites = 0 | |||||
| let failedSaves = 0 | |||||
| page.on('request', request => { if (request.url().endsWith('/auth/active-role') && request.method() === 'PUT') roleWrites++ }) | |||||
| await page.route('**/api/tran/v1/content/projects/164', async route => { | |||||
| if (route.request().method() !== 'PUT') return route.continue() | |||||
| failedSaves++ | |||||
| return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 'UNAVAILABLE', message: '实测保存失败,请稍后重试', data: null }) }) | |||||
| }) | |||||
| const switcher = await openSwitcher(page) | |||||
| await switcher.getByRole('button', { name: /权限实测·模型只读/ }).click() | |||||
| await page.getByRole('dialog', { name: '离开训练编排?', exact: true }).getByRole('button', { name: '保存并离开' }).click() | |||||
| await expect.poll(() => failedSaves).toBe(1) | |||||
| await expect(page.locator('.el-message--error')).toContainText('实测保存失败') | |||||
| await expect(page.getByRole('button', { name: /^账号菜单:/ })).toContainText('教员') | |||||
| expect(roleWrites).toBe(0) | |||||
| await shot(page, info, '09b-save-failure-blocks-role-switch') | |||||
| await switcher.getByRole('button', { name: '关闭', exact: true }).click() | |||||
| await page.getByRole('button', { name: '项目设置', exact: true }).click() | |||||
| await expect(name).toHaveValue('保存失败角色切换验证') | |||||
| }) | |||||
| @@ -0,0 +1,197 @@ | |||||
| import { test, expect, type Locator, type Page, type TestInfo } from '@playwright/test' | |||||
| import fs from 'node:fs' | |||||
| import path from 'node:path' | |||||
| // Credentials stay in the local helper and browser session; traces/video are disabled by the live config. | |||||
| // @ts-ignore The existing live API helper is JavaScript. | |||||
| import { call, login, work } from '../tools/role-permissions-live.mjs' | |||||
| type FixtureRole = { id: string | number; code: string } | |||||
| type Fixtures = { reader: FixtureRole; editor: FixtureRole; roleIds: Array<string | number> } | |||||
| const fixture = (): Fixtures => JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8')) | |||||
| const sorted = (codes: string[]) => [...new Set(codes)].sort() | |||||
| const permissionCodes = (value: { permissionCodes?: string[]; permissions?: string[] }) => sorted(value.permissionCodes ?? value.permissions ?? []) | |||||
| const deferred = () => { | |||||
| let resolve!: () => void | |||||
| const promise = new Promise<void>(done => { resolve = done }) | |||||
| return { promise, resolve } | |||||
| } | |||||
| async function signIn(page: Page) { | |||||
| const session = await login() | |||||
| await page.addInitScript(({ accessToken, refreshToken }) => { | |||||
| sessionStorage.setItem('unreal-tran:web:access-token:v1', accessToken) | |||||
| if (refreshToken) sessionStorage.setItem('unreal-tran:web:refresh-token:v1', refreshToken) | |||||
| }, { accessToken: session.accessToken, refreshToken: session.refreshToken }) | |||||
| return session.accessToken as string | |||||
| } | |||||
| const roleRow = (page: Page, code: string) => page.locator('.el-table__row').filter({ | |||||
| has: page.locator('.role-identity code').filter({ hasText: new RegExp(`^${code}$`) }), | |||||
| }) | |||||
| const treeNode = (container: Locator, code: string) => container.locator('.el-tree-node__content').filter({ | |||||
| has: container.page().locator('code').filter({ hasText: new RegExp(`^${code.replaceAll('.', '\\.')}$`) }), | |||||
| }) | |||||
| const checkedCodes = (drawer: Locator) => drawer.locator('.el-tree-node__content').evaluateAll(nodes => nodes | |||||
| .filter(node => node.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked) | |||||
| .map(node => node.querySelector('code')?.textContent?.trim() ?? '') | |||||
| .filter(Boolean).sort()) | |||||
| async function attachEvidence(page: Page, info: TestInfo, name: string, evidence: unknown) { | |||||
| const filename = path.resolve('reports/role-permissions-20260906/screenshots', `${name}.png`) | |||||
| fs.mkdirSync(path.dirname(filename), { recursive: true }) | |||||
| await page.screenshot({ path: filename, fullPage: true, animations: 'disabled' }) | |||||
| await info.attach(name, { path: filename, contentType: 'image/png' }) | |||||
| await info.attach(`${name}-evidence`, { body: JSON.stringify(evidence, null, 2), contentType: 'application/json' }) | |||||
| } | |||||
| test('权限加载乱序:关闭 A 后打开 B,A 的迟到响应不能覆盖 B', async ({ page }, info) => { | |||||
| const data = fixture() | |||||
| const token = await signIn(page) | |||||
| const arrived = deferred() | |||||
| const release = deferred() | |||||
| const delivered = deferred() | |||||
| const endpoint = `**/api/auth/v1/permissions/roles/${data.reader.id}` | |||||
| try { | |||||
| const reader = await call(`auth/v1/permissions/roles/${data.reader.id}`, token) | |||||
| const editor = await call(`auth/v1/permissions/roles/${data.editor.id}`, token) | |||||
| expect(reader.status).toBe(200) | |||||
| expect(editor.status).toBe(200) | |||||
| const readerCodes = permissionCodes(reader.data) | |||||
| const editorCodes = permissionCodes(editor.data) | |||||
| expect(readerCodes).not.toEqual(editorCodes) | |||||
| await page.route(endpoint, async route => { | |||||
| if (route.request().method() !== 'GET') return route.continue() | |||||
| // Fetch the actual read-only response, then deliberately deliver it after B has loaded. | |||||
| const response = await route.fetch() | |||||
| arrived.resolve() | |||||
| await release.promise | |||||
| try { await route.fulfill({ response }) } finally { delivered.resolve() } | |||||
| }) | |||||
| await page.goto('/system/roles') | |||||
| await roleRow(page, data.reader.code).getByRole('button', { name: '配置权限', exact: true }).click() | |||||
| await arrived.promise | |||||
| const drawer = page.getByRole('dialog', { name: '配置角色权限', exact: true }) | |||||
| await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled() | |||||
| await drawer.locator('.el-drawer__close-btn').click() | |||||
| await expect(drawer).not.toBeVisible() | |||||
| await roleRow(page, data.editor.code).getByRole('button', { name: '配置权限', exact: true }).click() | |||||
| await expect.poll(() => checkedCodes(drawer)).toEqual(editorCodes) | |||||
| await expect(drawer.locator('.role-permission-summary h3')).toHaveText('权限实测·模型编辑') | |||||
| const lateResponse = page.waitForResponse(response => response.url().endsWith(`/permissions/roles/${data.reader.id}`) | |||||
| && response.request().method() === 'GET') | |||||
| release.resolve() | |||||
| await delivered.promise | |||||
| expect((await lateResponse).status()).toBe(200) | |||||
| await (await lateResponse).finished() | |||||
| // Let the fulfilled response reach the store and Vue rendering before checking for stale writes. | |||||
| await page.waitForTimeout(250) | |||||
| expect(await checkedCodes(drawer)).toEqual(editorCodes) | |||||
| await expect(drawer.locator('.role-permission-summary h3')).toHaveText('权限实测·模型编辑') | |||||
| await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled() | |||||
| await attachEvidence(page, info, '10-stale-permission-response-is-ignored', { | |||||
| delayedRole: data.reader.code, currentRole: data.editor.code, | |||||
| delayedPermissionCodes: readerCodes, displayedPermissionCodes: editorCodes, businessWrites: 0, | |||||
| }) | |||||
| } finally { | |||||
| release.resolve() | |||||
| await page.unrouteAll({ behavior: 'wait' }) | |||||
| await call('auth/v1/auth/logout', token, {}).catch(() => undefined) | |||||
| } | |||||
| }) | |||||
| test('保存授权期间禁止恢复,PUT 成功但列表刷新失败仍保留提交快照', async ({ page }, info) => { | |||||
| const data = fixture() | |||||
| const token = await signIn(page) | |||||
| const arrived = deferred() | |||||
| const release = deferred() | |||||
| let original: string[] | undefined | |||||
| let writeAttempted = false | |||||
| let posted: string[] | undefined | |||||
| let refreshFailures = 0 | |||||
| try { | |||||
| // Only the explicitly prepared temporary reader role may be changed by this regression. | |||||
| expect(data.reader.code).toMatch(/^rv_reader_/) | |||||
| expect(data.roleIds.map(String)).toContain(String(data.reader.id)) | |||||
| const role = await call(`auth/v1/roles/${data.reader.id}`, token) | |||||
| expect(role.status).toBe(200) | |||||
| expect(role.data.code).toBe(data.reader.code) | |||||
| expect(role.data.permissionPolicy).toBe('CONFIGURABLE') | |||||
| expect(Boolean(role.data.builtIn ?? role.data.isBuiltin)).toBe(false) | |||||
| const initial = await call(`auth/v1/permissions/roles/${data.reader.id}`, token) | |||||
| expect(initial.status).toBe(200) | |||||
| original = permissionCodes(initial.data) | |||||
| expect(original).toContain('content.model') | |||||
| const action = 'content.model.create' | |||||
| const submitted = sorted(original.includes(action) ? original.filter(code => code !== action) : [...original, action]) | |||||
| await page.goto('/system/roles') | |||||
| await roleRow(page, data.reader.code).getByRole('button', { name: '配置权限', exact: true }).click() | |||||
| const drawer = page.getByRole('dialog', { name: '配置角色权限', exact: true }) | |||||
| await expect.poll(() => checkedCodes(drawer)).toEqual(original) | |||||
| await drawer.getByPlaceholder('搜索权限名称或编码').fill('content.model') | |||||
| await treeNode(drawer, action).locator('.el-checkbox').click() | |||||
| await expect.poll(() => checkedCodes(drawer)).toEqual(submitted) | |||||
| await page.route(`**/api/auth/v1/permissions/roles/${data.reader.id}`, async route => { | |||||
| if (route.request().method() !== 'PUT') return route.continue() | |||||
| posted = sorted(route.request().postDataJSON().permissionCodes) | |||||
| arrived.resolve() | |||||
| await release.promise | |||||
| writeAttempted = true | |||||
| // Await the real PUT here as well, so cleanup cannot race an outstanding mutation. | |||||
| const response = await route.fetch() | |||||
| await route.fulfill({ response }) | |||||
| }) | |||||
| // Install only after the initial real list load. The permission PUT itself reaches the real API. | |||||
| await page.route('**/api/auth/v1/roles/all', route => { | |||||
| refreshFailures += 1 | |||||
| return route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ | |||||
| code: 'UNAVAILABLE', message: '角色列表刷新临时不可用', data: null, | |||||
| }) }) | |||||
| }) | |||||
| const result = page.waitForResponse(response => response.url().endsWith(`/permissions/roles/${data.reader.id}`) | |||||
| && response.request().method() === 'PUT') | |||||
| await drawer.getByRole('button', { name: '保存授权' }).click() | |||||
| await arrived.promise | |||||
| await expect(drawer.getByRole('button', { name: '恢复', exact: true })).toBeDisabled() | |||||
| await expect(treeNode(drawer, action).getByRole('checkbox')).toBeDisabled() | |||||
| expect(posted).toEqual(submitted) | |||||
| release.resolve() | |||||
| const response = await result | |||||
| expect(response.status()).toBe(200) | |||||
| expect(permissionCodes((await response.json()).data)).toEqual(submitted) | |||||
| await expect(page.locator('.el-message--error')).toContainText('角色列表刷新临时不可用') | |||||
| await expect(drawer).toBeVisible() | |||||
| expect(await checkedCodes(drawer)).toEqual(submitted) | |||||
| await expect(drawer.getByRole('button', { name: '恢复', exact: true })).toBeDisabled() | |||||
| await expect(drawer.getByRole('button', { name: '保存授权' })).toBeDisabled() | |||||
| expect(refreshFailures).toBeGreaterThan(0) | |||||
| await attachEvidence(page, info, '11-permission-save-survives-list-refresh-failure', { | |||||
| temporaryRole: data.reader.code, originalPermissionCodes: original, submittedPermissionCodes: submitted, | |||||
| realPutStatus: response.status(), injectedListRefreshStatus: 503, resetDisabledWhileSaving: true, | |||||
| }) | |||||
| const persisted = await call(`auth/v1/permissions/roles/${data.reader.id}`, token) | |||||
| expect(persisted.status).toBe(200) | |||||
| expect(permissionCodes(persisted.data)).toEqual(submitted) | |||||
| } finally { | |||||
| release.resolve() | |||||
| try { | |||||
| await page.unrouteAll({ behavior: 'wait' }) | |||||
| if (original && writeAttempted) { | |||||
| const current = await call(`auth/v1/permissions/roles/${data.reader.id}`, token) | |||||
| expect(current.status, '读取临时 reader 最新版本以恢复授权').toBe(200) | |||||
| if (JSON.stringify(permissionCodes(current.data)) !== JSON.stringify(original)) { | |||||
| const restored = await call(`auth/v1/permissions/roles/${data.reader.id}`, token, { | |||||
| permissionCodes: original, version: current.data.version ?? current.data.dataVersion, | |||||
| }, 'PUT') | |||||
| expect(restored.status, '恢复临时 reader 原授权').toBe(200) | |||||
| } | |||||
| const verified = await call(`auth/v1/permissions/roles/${data.reader.id}`, token) | |||||
| expect(verified.status).toBe(200) | |||||
| expect(permissionCodes(verified.data), '临时 reader 授权恢复核验').toEqual(original) | |||||
| await info.attach('11-temporary-reader-restored', { | |||||
| body: JSON.stringify({ roleCode: data.reader.code, restored: true, permissionCodes: original }), | |||||
| contentType: 'application/json', | |||||
| }) | |||||
| } | |||||
| } finally { await call('auth/v1/auth/logout', token, {}).catch(() => undefined) } | |||||
| } | |||||
| }) | |||||
| @@ -0,0 +1,164 @@ | |||||
| import assert from 'node:assert/strict' | |||||
| import test from 'node:test' | |||||
| import { readFile } from 'node:fs/promises' | |||||
| import { createRequire } from 'node:module' | |||||
| const requireWeb = createRequire(new URL('../../unreal_tran_web/package.json', import.meta.url)) | |||||
| const ts = requireWeb('typescript') | |||||
| const web = new URL('../../unreal_tran_web/src/', import.meta.url) | |||||
| const dataModule = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}` | |||||
| async function compile(file, replacements = {}) { | |||||
| let source = await readFile(new URL(file, web), 'utf8') | |||||
| for (const [from, to] of Object.entries(replacements)) source = source.replaceAll(from, to) | |||||
| return dataModule(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText) | |||||
| } | |||||
| const { canUsePermission } = await import(await compile('config/roles.ts')) | |||||
| const { contentPermission, contentReviewPermission } = await import(await compile('utils/contentPermissions.ts')) | |||||
| test('content abilities use exact grants independently of teacher/admin/custom role names', () => { | |||||
| for (const code of ['admin', 'teacher', 'custom-author', 'student']) { | |||||
| assert.equal(canUsePermission('content.model.update', ['content.model.update'], [{ code, enabled: true, active: true }], 'SINGLE_ACTIVE'), true) | |||||
| assert.equal(canUsePermission('content.scene.update', ['content.model.update'], [{ code, enabled: true, active: true }], 'UNION'), false) | |||||
| assert.equal(canUsePermission('content.model.update', ['content.update'], [{ code, enabled: true, active: true }], 'UNION'), false) | |||||
| } | |||||
| }) | |||||
| test('copy, delete, review, revoke and submit retain separate action grants', () => { | |||||
| assert.equal(contentPermission('GUIDE', 'create'), 'content.ofd.create') | |||||
| assert.equal(contentPermission('TRAINING', 'delete'), 'content.training.delete') | |||||
| for (const action of ['APPROVE', 'REJECT']) assert.equal(contentReviewPermission('GUIDE', action), 'content.ofd.review') | |||||
| assert.equal(contentReviewPermission('GUIDE', 'REVOKE'), 'content.ofd.publish') | |||||
| for (const action of ['SUBMIT', 'WITHDRAW', 'REVISE']) assert.equal(contentReviewPermission('GUIDE', action), 'content.ofd.update') | |||||
| }) | |||||
| const authUrl = dataModule(`export const auth = { profile: { userId: 'reader', displayName: 'Actual user', permissions: [] }, activeRole: { code: 'admin' }, availableRoles: [], hasEffectiveRole: () => false, hasPermission(permission) { return this.profile.permissions.includes(permission) } }; export const useAuthStore = () => auth;`) | |||||
| const { auth } = await import(authUrl) | |||||
| const storageUrl = await compile('features/legacy-shared/scopedStorage.ts') | |||||
| const guideRuntime = await import(await compile('features/guide-legacy/runtime.ts', { | |||||
| './demo-src/asset-store.js': new URL('features/guide-legacy/demo-src/asset-store.js', web).href, | |||||
| './demo-src/content-list-store.js': new URL('features/guide-legacy/demo-src/content-list-store.js', web).href, | |||||
| '../legacy-shared/scopedStorage': storageUrl, | |||||
| '../../stores/auth': authUrl, | |||||
| })) | |||||
| test('OFD runtime uses the real session and rejects local create/update/delete without exact grants', async () => { | |||||
| const { createInitialOFDState } = await import(new URL('features/guide-legacy/demo-src/ofd-document.js', web)) | |||||
| const context = guideRuntime.legacyGuideRuntimeContext() | |||||
| assert.equal(context.actor.id, 'reader') | |||||
| assert.equal(context.readOnly, true) | |||||
| assert.equal(context.can('content.create'), false) | |||||
| assert.equal(context.storage.length, 0, 'read-only initialization must not seed projects') | |||||
| assert.throws(() => context.contentStore.createOFDProject({ title: 'Denied' }), /权限/) | |||||
| auth.profile.permissions = ['content.ofd.create'] | |||||
| const initial = createInitialOFDState() | |||||
| initial.project.name = 'Full initial payload' | |||||
| const created = context.contentStore.createOFDProject({ title: 'Created only', state: initial }) | |||||
| assert.ok(created.id) | |||||
| assert.equal(created.author, 'Actual user') | |||||
| assert.throws(() => context.contentStore.saveOFDProjectState(created.id, created.state), /权限/) | |||||
| assert.throws(() => context.contentStore.deleteOFDProject(created.id), /权限/) | |||||
| auth.profile.permissions = ['content.ofd.delete'] | |||||
| assert.equal(context.contentStore.deleteOFDProject(created.id), true) | |||||
| }) | |||||
| const { canPerformOFDAction, createInitialOFDState } = await import(new URL('features/guide-legacy/demo-src/ofd-document.js', web)) | |||||
| test('OFD lifecycle does not treat admin/teacher identity as a publish grant', () => { | |||||
| const state = createInitialOFDState() | |||||
| for (const role of ['admin', 'teacher']) assert.equal(canPerformOFDAction(state, 'publish', { role, permissions: [] }), false) | |||||
| assert.equal(canPerformOFDAction(state, 'publish', { role: 'custom', permissions: ['content.ofd.publish'] }), true) | |||||
| assert.equal(canPerformOFDAction(state, 'publish', { role: 'custom', permissions: ['content.ofd.update'] }), false) | |||||
| }) | |||||
| const { installLegacyModelPermissions } = await import(await compile('features/editors/model/legacy-iframe-permissions.ts', { | |||||
| '../common/editor-project-io.js': new URL('features/editors/common/editor-project-io.js', web).href, | |||||
| })) | |||||
| function modelFixture({ update = false, publish = false } = {}) { | |||||
| const calls = [] | |||||
| const editor = { | |||||
| apiEditorHydrated: true, apiState: 'ready', apiSession: { project: { id: 'test', version: 7 }, lastSavedFingerprint: '' }, | |||||
| saveProject: async () => { calls.push('save'); return {} }, | |||||
| publishToSceneLibrary: async () => { calls.push('save-and-publish'); return {} }, | |||||
| saveRecoveryCopy: () => calls.push('recovery'), | |||||
| serializeProject: () => ({}), toast: message => calls.push(message), | |||||
| transform: { enabled: true, detach() {}, attach() { calls.push('transform') } }, | |||||
| } | |||||
| const doc = { defaultView: { editorApp: editor }, body: {}, querySelector: () => null, querySelectorAll: () => [], addEventListener() {}, removeEventListener() {} } | |||||
| const previousObserver = globalThis.MutationObserver | |||||
| globalThis.MutationObserver = class { observe() {} disconnect() {} } | |||||
| const bridge = installLegacyModelPermissions(doc, { | |||||
| canCreate: () => false, canUpdate: () => update, canPublish: () => publish, | |||||
| publishSaved: async version => { calls.push(['publish-saved', version]); return { id: 'test', version: 8 } }, | |||||
| }) | |||||
| globalThis.MutationObserver = previousObserver | |||||
| return { editor, calls, bridge } | |||||
| } | |||||
| test('model iframe read-only bridge blocks autosave, recovery writes and transform attach', async () => { | |||||
| const { editor, calls, bridge } = modelFixture() | |||||
| assert.equal(await editor.saveProject(true, { automatic: true }), null) | |||||
| assert.equal(await editor.publishToSceneLibrary(), null) | |||||
| editor.saveRecoveryCopy() | |||||
| editor.transform.attach({}) | |||||
| assert.deepEqual(calls, []) | |||||
| assert.equal(editor.transform.enabled, false) | |||||
| bridge.dispose() | |||||
| }) | |||||
| test('model iframe publisher-only uses the loaded optimistic-lock version without saving', async () => { | |||||
| const { editor, calls, bridge } = modelFixture({ publish: true }) | |||||
| const published = await editor.publishToSceneLibrary() | |||||
| assert.equal(published.version, 8) | |||||
| assert.deepEqual(calls[0], ['publish-saved', 7]) | |||||
| assert.equal(calls.includes('save-and-publish'), false) | |||||
| bridge.dispose() | |||||
| }) | |||||
| test('committed role switch prevents iframe unload recovery from writing discarded changes', async () => { | |||||
| const { editor, calls, bridge } = modelFixture({ update: true, publish: true }) | |||||
| bridge.roleSwitchCommitted() | |||||
| editor.saveRecoveryCopy() | |||||
| assert.equal(await editor.saveProject(), null) | |||||
| assert.equal(await editor.publishToSceneLibrary(), null) | |||||
| assert.deepEqual(calls, []) | |||||
| bridge.dispose() | |||||
| }) | |||||
| test('read-only model viewing skips automatic migration and recovery without modifying saved data', async () => { | |||||
| const { editor, bridge } = modelFixture() | |||||
| editor.apiDocumentNeedsMigrationSave = true | |||||
| assert.equal(editor.apiDocumentNeedsMigrationSave, false) | |||||
| assert.equal(await editor.saveProject(true), null) | |||||
| bridge.dispose() | |||||
| assert.equal(editor.apiDocumentNeedsMigrationSave, true, 'the host must restore the original runtime state on disposal') | |||||
| }) | |||||
| const policy = await import(await compile('features/guide/publishDeliveriesPolicy.ts')) | |||||
| const publishSource = await readFile(new URL('features/guide/publishDeliveries.ts', web), 'utf8') | |||||
| const publishFunction = ts.transpileModule(publishSource.slice(publishSource.indexOf('export async function publishGuideWithDeliveries(')), { | |||||
| compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, | |||||
| }).outputText.replace(/^export /gm, '') | |||||
| const publishGuide = new Function('normalizeGuideContent', 'resolveGuideDeliveryAssets', 'commitGuideDeliveries', 'reusableGuideDelivery', 'assertCompleteGuideDeliveries', 'GUIDE_DELIVERY_ASSET_CODES', 'artifactFile', 'text', `${publishFunction};return publishGuideWithDeliveries`)( | |||||
| value => value, async () => ({}), policy.commitGuideDeliveries, policy.reusableGuideDelivery, | |||||
| policy.assertCompleteGuideDeliveries, policy.GUIDE_DELIVERY_ASSET_CODES, | |||||
| () => { throw new Error('publisher must not upload') }, value => String(value ?? '').trim(), | |||||
| ) | |||||
| function guidePublisherFixture(savedFingerprint) { | |||||
| const calls = [] | |||||
| const formats = ['OFD', 'OFFLINE_HTML'] | |||||
| const assets = formats.map(format => ({ code: policy.GUIDE_DELIVERY_ASSET_CODES[format], type: 'DOCUMENT', status: 'READY', sha256: 'a'.repeat(64), | |||||
| storageUri: `content://${format}`, metadata: { deliveryFormat: format, sourceFingerprint: savedFingerprint } })) | |||||
| const dependencies = { | |||||
| loadProject: async () => ({ id: 'g', version: 17, status: 'DRAFT', content: {}, assets }), | |||||
| readAsset: async () => new Blob(), uploadAsset: async () => { calls.push('upload'); throw new Error('forbidden') }, | |||||
| buildArtifacts: async () => ({ sourceFingerprint: 'current-source', artifacts: formats.map(format => ({ format, code: policy.GUIDE_DELIVERY_ASSET_CODES[format] })) }), | |||||
| publishProject: async (_id, _type, input) => { calls.push(input); return { id: 'g', version: 18 } }, | |||||
| } | |||||
| return { calls, dependencies } | |||||
| } | |||||
| test('OFD publisher reuses the exact saved source artifacts and optimistic-lock version without uploading', async () => { | |||||
| const { calls, dependencies } = guidePublisherFixture('current-source') | |||||
| const result = await publishGuide('g', { allowUpload: false }, dependencies) | |||||
| assert.equal(result.project.version, 18) | |||||
| assert.deepEqual(result.uploadedFormats, []) | |||||
| assert.deepEqual(result.reusedFormats, ['OFD', 'OFFLINE_HTML']) | |||||
| assert.equal(calls.length, 1) | |||||
| assert.equal(calls[0].version, 17) | |||||
| assert.equal(calls[0].deliveries.length, 2) | |||||
| }) | |||||
| test('OFD publisher cannot regenerate/upload stale artifacts or publish them against changed content', async () => { | |||||
| const { calls, dependencies } = guidePublisherFixture('stale-source') | |||||
| await assert.rejects(publishGuide('g', { allowUpload: false }, dependencies), /交付物/) | |||||
| assert.deepEqual(calls, []) | |||||
| }) | |||||
| @@ -0,0 +1,237 @@ | |||||
| import fs from 'node:fs'; | |||||
| import path from 'node:path'; | |||||
| import crypto from 'node:crypto'; | |||||
| import assert from 'node:assert/strict'; | |||||
| import { spawnSync } from 'node:child_process'; | |||||
| import { fileURLToPath } from 'node:url'; | |||||
| import { login, call, root, work, report } from './role-permissions-live.mjs'; | |||||
| // Run only after migration, service restart and the separate prepare fixture have completed. | |||||
| // Credentials remain inside the existing login helper; no response token, password or user payload is reported. | |||||
| const checks = []; | |||||
| const sessions = []; | |||||
| const withoutFixtures = process.argv.includes('--without-fixtures'); | |||||
| const outcome = { startedAt: new Date().toISOString(), mode: 'LIVE_API', checks, limitations: [] }; | |||||
| let activeCheck = 'initialization'; | |||||
| const rows = value => value?.records ?? value ?? []; | |||||
| const sameId = (left, right) => String(left) === String(right); | |||||
| const ids = values => values.map(String).sort(); | |||||
| function check(name, condition, evidence = {}) { | |||||
| activeCheck = name; | |||||
| checks.push({ name, result: condition ? 'PASS' : 'FAIL', ...evidence }); | |||||
| assert.ok(condition, name); | |||||
| } | |||||
| async function read(endpoint, token) { | |||||
| activeCheck = `读取 ${endpoint.split('?')[0]}`; | |||||
| const result = await call(endpoint, token); | |||||
| assert.equal(result.status, 200, activeCheck); | |||||
| return result.data; | |||||
| } | |||||
| async function failedLogin(username, requestId) { | |||||
| const response = await fetch('http://127.0.0.1:6180/api/auth/v1/auth/login', { | |||||
| method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Request-Id': requestId }, | |||||
| body: JSON.stringify({ username, password: `Bad!${crypto.randomBytes(7).toString('hex')}`, roleCode: 'admin', rememberMe: false }), | |||||
| signal: AbortSignal.timeout(30000), | |||||
| }); | |||||
| const value = await response.json(); | |||||
| return { status: response.status, requestId: value.requestId || response.headers.get('X-Request-Id') }; | |||||
| } | |||||
| function databaseSnapshot(requestIds = []) { | |||||
| const source = String.raw` | |||||
| import importlib.util,json,sys | |||||
| from pathlib import Path | |||||
| p=Path(sys.argv[1])/'ute2e'/'tools'/'review-db.py' | |||||
| spec=importlib.util.spec_from_file_location('review_db_private',p) | |||||
| module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module) | |||||
| connection=module.connect() | |||||
| try: | |||||
| connection.execute('BEGIN READ ONLY') | |||||
| result={} | |||||
| with connection.cursor() as cursor: | |||||
| cursor.execute("SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE result_code=1) AS success, COUNT(*) FILTER(WHERE result_code=0) AS failed FROM ut_sys_audit_log WHERE is_delete=0 AND visibility_scope='BUSINESS'") | |||||
| result['businessAudit']=cursor.fetchone() | |||||
| cursor.execute("SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE result_code=1) AS success, COUNT(*) FILTER(WHERE result_code=0) AS failed FROM ut_sys_audit_log WHERE is_delete=0 AND visibility_scope='INTERNAL'") | |||||
| result['internalAudit']=cursor.fetchone() | |||||
| cursor.execute("SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE status=1) AS enabled FROM ut_sys_user WHERE is_delete=0 AND is_protected=0") | |||||
| result['ordinaryUsers']=cursor.fetchone() | |||||
| cursor.execute("SELECT COUNT(*) AS total FROM ut_sys_role WHERE is_delete=0 AND role_code<>'root'") | |||||
| result['ordinaryRoles']=cursor.fetchone() | |||||
| cursor.execute("SELECT id, request_id, visibility_scope, result_code, actor_user_id, target_type, target_id FROM ut_sys_audit_log WHERE request_id=ANY(%s) ORDER BY id",(json.loads(sys.argv[2]),)) | |||||
| result['events']=cursor.fetchall() | |||||
| connection.rollback() | |||||
| print(json.dumps(result,default=str)) | |||||
| finally: | |||||
| connection.close() | |||||
| `; | |||||
| const result = spawnSync('python', ['-c', source, root, JSON.stringify(requestIds)], { encoding: 'utf8', windowsHide: true }); | |||||
| // Neither stderr nor the private source configuration is copied into the report. | |||||
| assert.equal(result.status, 0, '只读数据库证据查询成功'); | |||||
| return JSON.parse(result.stdout.trim()); | |||||
| } | |||||
| function exportedContains(data, needle) { | |||||
| const source = String.raw` | |||||
| import base64,io,json,sys,zipfile | |||||
| payload=json.load(sys.stdin) | |||||
| with zipfile.ZipFile(io.BytesIO(base64.b64decode(payload['contentBase64']))) as archive: | |||||
| contents=b'\n'.join(archive.read(name) for name in archive.namelist() if name.startswith('xl/') and name.endswith('.xml')) | |||||
| print(json.dumps({'contains':payload['needle'].encode('utf-8') in contents})) | |||||
| `; | |||||
| const result = spawnSync('python', ['-c', source], { | |||||
| input: JSON.stringify({ contentBase64: data.contentBase64, needle }), encoding: 'utf8', windowsHide: true, | |||||
| }); | |||||
| assert.equal(result.status, 0, '导出文件内容校验成功'); | |||||
| return JSON.parse(result.stdout).contains; | |||||
| } | |||||
| function roleCommand(role, overrides = {}) { | |||||
| return { code: role.code, name: role.name, shortName: role.shortName, description: role.description, | |||||
| dataScope: role.dataScope, enabled: role.enabled, sortOrder: role.sortOrder, | |||||
| departmentScopeIds: role.departmentScopeIds, version: role.dataVersion, ...overrides }; | |||||
| } | |||||
| function userCommand(user, overrides = {}) { | |||||
| return { username: user.username, displayName: user.displayName, departmentId: user.departmentId, | |||||
| roleIds: user.roleIds, defaultRoleId: user.defaultRoleId, enabled: user.enabled, remark: user.remark, | |||||
| version: user.dataVersion, ...overrides }; | |||||
| } | |||||
| async function main() { | |||||
| const fixture = withoutFixtures ? {} : JSON.parse(fs.readFileSync(path.join(work, 'fixtures.json'), 'utf8')); | |||||
| if (!withoutFixtures) assert.ok(fixture.user?.id && fixture.adminRoleId, '隔离测试账号已由 prepare 创建'); | |||||
| else outcome.limitations.push('首阶段不依赖隔离账号,内部角色赋值拒绝及测试账号版本保持由完整运行补测。'); | |||||
| const admin = await login('admin'); sessions.push(admin.accessToken); | |||||
| const maintenance = await login('root'); sessions.push(maintenance.accessToken); | |||||
| const token = admin.accessToken, rootToken = maintenance.accessToken; | |||||
| const me = await read('auth/v1/auth/me', token), rootMe = await read('auth/v1/auth/me', rootToken); | |||||
| const rootUserId = rootMe.id, internalRole = rootMe.roles.find(role => role.code === 'root'); | |||||
| check('维护账号使用已分配的内部角色,普通管理员不带维护标记', | |||||
| !!internalRole && rootMe.maintenanceAccount === true && me.maintenanceAccount === false); | |||||
| for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) { | |||||
| const all = rows(await read('auth/v1/roles/all', actorToken)); | |||||
| const page = rows(await read('auth/v1/roles?size=200', actorToken)); | |||||
| check(`${actor} 的普通角色目录和分页都隐藏内部角色`, [...all, ...page].every(role => role.code !== 'root')); | |||||
| for (const suffix of ['', '/users', '/permissions']) { | |||||
| const result = await call(`auth/v1/roles/${internalRole.id}${suffix}`, actorToken); | |||||
| check(`${actor} 不能按已知内部角色 ID 读取${suffix || '/详情'}`, result.status === 404, { status: result.status }); | |||||
| } | |||||
| } | |||||
| const ordinaryRoles = rows(await read('auth/v1/roles/all', token)); | |||||
| for (const role of ordinaryRoles) { | |||||
| const linked = rows(await read(`auth/v1/roles/${role.id}/users`, token)); | |||||
| check(`角色 ${role.code} 的关联用户排除维护账号`, linked.every(user => !sameId(user.id, rootUserId))); | |||||
| } | |||||
| const lookup = rows(await read(`auth/v1/directory/teaching-members?keyword=${encodeURIComponent(rootMe.username)}&limit=100`, token)); | |||||
| check('教学人员搜索不暴露维护账号', lookup.every(user => !sameId(user.id, rootUserId))); | |||||
| const resolved = await call('auth/v1/directory/teaching-members/resolve', token, { ids: [rootUserId] }); | |||||
| check('教学人员按 ID 解析拒绝维护账号', resolved.status === 400, { status: resolved.status }); | |||||
| const candidates = rows(await read(`auth/v1/auth/login-identities?roleCode=teacher&departmentId=${rootMe.departmentId}`)); | |||||
| check('公开登录候选列表不暴露维护账号', candidates.every(user => !sameId(user.id, rootUserId))); | |||||
| if (fixture.user?.id) { | |||||
| const originalUser = await read(`auth/v1/users/${fixture.user.id}`, token); | |||||
| for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) { | |||||
| const attempt = await call(`auth/v1/users/${fixture.user.id}`, actorToken, | |||||
| userCommand(originalUser, { roleIds: [...originalUser.roleIds, internalRole.id] }), 'PUT'); | |||||
| if (attempt.status === 200) { | |||||
| // The only mutable target is the isolated fixture. Restore before reporting an unexpected authorization failure. | |||||
| const current = await read(`auth/v1/users/${fixture.user.id}`, rootToken); | |||||
| await call(`auth/v1/users/${fixture.user.id}`, rootToken, userCommand(originalUser, { version: current.dataVersion }), 'PUT'); | |||||
| } | |||||
| check(`${actor} 无法通过普通用户接口给测试账号授予内部角色`, [400, 403].includes(attempt.status), { status: attempt.status }); | |||||
| } | |||||
| const afterUser = await read(`auth/v1/users/${fixture.user.id}`, token); | |||||
| check('拒绝内部角色分配后测试账号原角色和版本保持不变', | |||||
| JSON.stringify(ids(afterUser.roleIds)) === JSON.stringify(ids(originalUser.roleIds)) && afterUser.dataVersion === originalUser.dataVersion); | |||||
| } | |||||
| const adminRoleId = fixture.adminRoleId ?? ordinaryRoles.find(role => role.code === 'admin')?.id; | |||||
| const fixed = await read(`auth/v1/roles/${adminRoleId}`, token); | |||||
| for (const [actor, actorToken] of [['admin', token], ['root', rootToken]]) { | |||||
| const scope = await call(`auth/v1/roles/${fixed.id}`, actorToken, roleCommand(fixed, { dataScope: 'SELF' }), 'PUT'); | |||||
| if (scope.status === 200) { | |||||
| const current = await read(`auth/v1/roles/${fixed.id}`, rootToken); | |||||
| await call(`auth/v1/roles/${fixed.id}`, rootToken, roleCommand(fixed, { version: current.dataVersion }), 'PUT'); | |||||
| } | |||||
| check(`${actor} 无法通过普通编辑更改固定管理员数据范围`, scope.status === 403, { status: scope.status }); | |||||
| const status = await call(`auth/v1/roles/${fixed.id}/status`, actorToken, { enabled: false, version: fixed.dataVersion }, 'PUT'); | |||||
| if (status.status === 200) { | |||||
| const current = await read(`auth/v1/roles/${fixed.id}`, rootToken); | |||||
| await call(`auth/v1/roles/${fixed.id}/status`, rootToken, { enabled: true, version: current.dataVersion }, 'PUT'); | |||||
| } | |||||
| check(`${actor} 无法停用固定管理员角色`, status.status === 403, { status: status.status }); | |||||
| } | |||||
| const afterRole = await read(`auth/v1/roles/${fixed.id}`, token); | |||||
| check('固定管理员角色状态、范围、版本未被边界请求改变', | |||||
| afterRole.enabled === fixed.enabled && afterRole.dataScope === fixed.dataScope && afterRole.dataVersion === fixed.dataVersion); | |||||
| for (const suffix of ['', '/stats', '/export']) { | |||||
| const denied = await call(`auth/v1/maintenance/audit-logs${suffix}`, token); | |||||
| check(`普通管理员不能读取维护审计${suffix || '/列表'}`, denied.status === 403, { status: denied.status }); | |||||
| } | |||||
| const sequence = crypto.randomBytes(6).toString('hex'); | |||||
| const rootAttempt = await failedLogin(rootMe.username, `maint-root-failure-${sequence}`); | |||||
| const unknownAttempt = await failedLogin(`missing_${sequence}`, `maint-unknown-failure-${sequence}`); | |||||
| check('已知维护账号错误密码和未知账号均返回统一认证失败', rootAttempt.status === 401 && unknownAttempt.status === 401); | |||||
| const rootFilter = encodeURIComponent(rootAttempt.requestId), unknownFilter = encodeURIComponent(unknownAttempt.requestId); | |||||
| const ordinaryKnown = rows(await read(`auth/v1/audit-logs?keyword=${rootFilter}`, token)); | |||||
| const internalKnown = rows(await read(`auth/v1/maintenance/audit-logs?keyword=${rootFilter}`, rootToken)); | |||||
| const ordinaryUnknown = rows(await read(`auth/v1/audit-logs?keyword=${unknownFilter}`, token)); | |||||
| check('维护登录失败在普通列表不可见且内部审计保留真实 USER 目标', ordinaryKnown.length === 0 | |||||
| && internalKnown.some(event => event.targetType === 'USER' && sameId(event.targetId, rootUserId) && event.success === false)); | |||||
| check('未知身份失败仍可在普通安全审计中查到', ordinaryUnknown.some(event => event.requestId === unknownAttempt.requestId && event.success === false)); | |||||
| const ordinaryExport = await read(`auth/v1/audit-logs/export?keyword=${rootFilter}`, token); | |||||
| const internalExport = await read(`auth/v1/maintenance/audit-logs/export?keyword=${rootFilter}`, rootToken); | |||||
| check('普通审计导出不包含维护记录,维护导出保留该记录', ordinaryExport.recordCount === 0 | |||||
| && !exportedContains(ordinaryExport, rootAttempt.requestId) && internalExport.recordCount >= 1 | |||||
| && exportedContains(internalExport, rootAttempt.requestId)); | |||||
| const before = databaseSnapshot([rootAttempt.requestId, unknownAttempt.requestId]); | |||||
| const auditStats = await read('auth/v1/audit-logs/stats', token); | |||||
| const internalStats = await read('auth/v1/maintenance/audit-logs/stats', rootToken); | |||||
| const userStats = await read('auth/v1/users/stats', token); | |||||
| const roleStats = await read('auth/v1/roles/stats', token); | |||||
| const dashboard = await read('auth/v1/dashboard/platform', token); | |||||
| const after = databaseSnapshot([rootAttempt.requestId, unknownAttempt.requestId]); | |||||
| const between = (value, first, last) => value >= first && value <= last; | |||||
| check('普通审计统计只计算 BUSINESS,维护统计只计算 INTERNAL', | |||||
| ['total', 'success', 'failed'].every(key => between(auditStats[key], before.businessAudit[key], after.businessAudit[key]) | |||||
| && between(internalStats[key], before.internalAudit[key], after.internalAudit[key]))); | |||||
| check('普通用户和角色统计排除内部账号及内部角色', | |||||
| between(userStats.total, before.ordinaryUsers.total, after.ordinaryUsers.total) | |||||
| && between(roleStats.total, before.ordinaryRoles.total, after.ordinaryRoles.total)); | |||||
| check('首页人数不计维护账号,最近动态不显示维护失败日志', | |||||
| between(dashboard.metrics.userTotal, before.ordinaryUsers.total, after.ordinaryUsers.total) | |||||
| && !dashboard.activities.some(activity => internalKnown.some(event => sameId(activity.id, event.id)))); | |||||
| const knownDb = after.events.find(event => event.request_id === rootAttempt.requestId); | |||||
| const unknownDb = after.events.find(event => event.request_id === unknownAttempt.requestId); | |||||
| check('数据库只读核验:已知维护失败 INTERNAL,未知失败 BUSINESS', | |||||
| knownDb?.visibility_scope === 'INTERNAL' && sameId(knownDb.target_id, rootUserId) | |||||
| && unknownDb?.visibility_scope === 'BUSINESS' && !unknownDb.target_id && !unknownDb.actor_user_id); | |||||
| outcome.databaseEvidence = { | |||||
| ordinaryUserCount: after.ordinaryUsers.total, ordinaryRoleCount: after.ordinaryRoles.total, | |||||
| businessAuditCount: after.businessAudit.total, internalAuditCount: after.internalAudit.total, | |||||
| knownFailureScope: knownDb?.visibility_scope, unknownFailureScope: unknownDb?.visibility_scope, | |||||
| knownFailurePreservesTarget: sameId(knownDb?.target_id, rootUserId), | |||||
| }; | |||||
| // Restore the failed-login counter by a normal successful authentication; no direct account or credential writes. | |||||
| const verified = await login('root'); sessions.push(verified.accessToken); | |||||
| const normalAdmin = await read(`auth/v1/users/${me.id}`, token); | |||||
| check('维护再次登录后普通 admin 仍未被重新保护', normalAdmin.isProtected === false); | |||||
| outcome.bootstrapReview = { result: 'PASS', note: '初始化无密码配置时立即返回;已有凭据不覆盖;成功时只将旧普通管理员保护置0,没有将其恢复为1的路径。主代理已移除一次性环境变量并重启Auth,本脚本在该服务上核验admin保护仍为false。' }; | |||||
| outcome.status = withoutFixtures ? 'PASS_WITH_LIMITATIONS' : 'PASS'; | |||||
| } | |||||
| if (process.argv[1] === fileURLToPath(import.meta.url)) { | |||||
| try { await main(); } | |||||
| catch (error) { | |||||
| outcome.status = 'FAIL'; outcome.failedStep = activeCheck; | |||||
| outcome.error = error?.name === 'AssertionError' ? '验证断言失败,参见最后一项或 failedStep' : '请求或验证未完成,未输出原始异常以避免泄漏认证信息'; | |||||
| process.exitCode = 1; | |||||
| } finally { | |||||
| for (const token of sessions) await call('auth/v1/auth/logout', token, {}).catch(() => {}); | |||||
| outcome.finishedAt = new Date().toISOString(); | |||||
| fs.writeFileSync(path.join(report, withoutFixtures ? 'maintenance-boundaries-initial.json' : 'maintenance-boundaries.json'), JSON.stringify(outcome, null, 2)); | |||||
| console.log(JSON.stringify({ status: outcome.status, passed: checks.filter(item => item.result === 'PASS').length, | |||||
| failed: checks.filter(item => item.result === 'FAIL').length, failedStep: outcome.failedStep ?? null })); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,175 @@ | |||||
| import fs from 'node:fs'; | |||||
| import path from 'node:path'; | |||||
| import crypto from 'node:crypto'; | |||||
| import assert from 'node:assert/strict'; | |||||
| import { fileURLToPath } from 'node:url'; | |||||
| export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); | |||||
| export const work = path.resolve(root, '../.codex-tmp/permissions-20260906'); | |||||
| export const report = path.resolve(root, 'ute2e/reports/role-permissions-20260906'); | |||||
| fs.mkdirSync(work, { recursive: true }); fs.mkdirSync(report, { recursive: true }); | |||||
| export function credentials(account) { | |||||
| const lines = fs.readFileSync(path.join(root, '账号.MD'), 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/).map(x => x.trim()).filter(Boolean); | |||||
| const pairs = []; | |||||
| for (let i = 0; i + 1 < lines.length; i += 2) pairs.push({ username: lines[i].replace(/^.*?[::]\s*/, '').trim(), password: lines[i + 1].replace(/^.*?[::]\s*/, '').trim() }); | |||||
| const found = pairs.find(x => x.username === account); | |||||
| if (!found?.password) throw new Error('Requested local credential pair is unavailable'); | |||||
| return found; | |||||
| } | |||||
| export async function call(endpoint, token, body, method = body === undefined ? 'GET' : 'POST') { | |||||
| const response = await fetch(`http://127.0.0.1:6180/api/${endpoint}`, { | |||||
| method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) }, | |||||
| body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000), | |||||
| }); | |||||
| const value = await response.json(); | |||||
| return { status: response.status, code: value.code, data: value.data, message: value.message }; | |||||
| } | |||||
| export async function login(account = 'admin') { | |||||
| const result = await call('auth/v1/auth/login', null, { ...credentials(account), roleCode: 'admin', rememberMe: false }); | |||||
| assert.equal(result.status, 200, `Local ${account} login status`); | |||||
| assert.ok(result.data?.accessToken, 'Access token returned'); | |||||
| return result.data; | |||||
| } | |||||
| const checks = []; | |||||
| const check = (name, condition) => { assert.ok(condition, name); checks.push({ name, result: 'PASS' }); }; | |||||
| const records = value => value?.records ?? value ?? []; | |||||
| const fixtureFile = path.join(work, 'fixtures.json'); | |||||
| const write = fixture => fs.writeFileSync(fixtureFile, JSON.stringify(fixture, null, 2)); | |||||
| async function prepare() { | |||||
| const admin = await login(); const maintenance = await login('root'); | |||||
| const token = admin.accessToken; const rootToken = maintenance.accessToken; | |||||
| try { | |||||
| const me = (await call('auth/v1/auth/me', token)).data; | |||||
| const rootMe = (await call('auth/v1/auth/me', rootToken)).data; | |||||
| const rootUserId = String(rootMe.id); | |||||
| check('root 使用管理员登录入口进入内部维护角色', rootMe.roles.some(r => r.code === 'root') && rootMe.maintenanceAccount === true); | |||||
| const roles = records((await call('auth/v1/roles/all', token)).data); | |||||
| const adminRole = roles.find(r => r.code === 'admin'), teacherRole = roles.find(r => r.code === 'teacher'), studentRole = roles.find(r => r.code === 'student'); | |||||
| check('普通角色目录不显示内部维护角色', !roles.some(r => r.code === 'root')); | |||||
| check('管理员角色采用固定的受限授权', adminRole.permissionPolicy === 'FIXED' && adminRole.superAdmin === false); | |||||
| const grants = (await call(`auth/v1/permissions/roles/${adminRole.id}`, token)).data; | |||||
| check('管理员授权已持久化,不动态获得全权限', grants.dynamicAll === false && grants.permissionCodes.length > 0 && !grants.permissionCodes.includes('content.model.create')); | |||||
| for (const [name, actor] of [['admin', token], ['root', rootToken]]) { | |||||
| const forbidden = await call(`auth/v1/permissions/roles/${adminRole.id}`, actor, { permissionCodes: grants.permissionCodes, version: grants.version }, 'PUT'); | |||||
| check(`${name} 无法通过接口修改固定管理员授权`, forbidden.status === 403 || forbidden.status === 400); | |||||
| } | |||||
| const normalAdmin = (await call(`auth/v1/users/${me.id}`, token)).data; | |||||
| check('admin 账号已取消 is_protected', normalAdmin.isProtected === false); | |||||
| const users = records((await call('auth/v1/users?size=100', token)).data); | |||||
| check('用户目录不显示 root 维护账号', !users.some(u => String(u.id) === rootUserId)); | |||||
| for (const [name, body, method, tail] of [ | |||||
| ['查询', undefined, 'GET', ''], ['停用', { enabled: false, version: 0 }, 'PUT', '/status'], | |||||
| ['重置密码', { password: `Test!${crypto.randomBytes(7).toString('hex')}`, version: 0 }, 'PUT', '/password'], | |||||
| ['删除', { version: 0 }, 'DELETE', ''], | |||||
| ]) { | |||||
| const forbidden = await call(`auth/v1/users/${rootUserId}${tail}`, token, body, method); | |||||
| check(`普通账号按 ID ${name} root 受保护`, [400, 403, 404].includes(forbidden.status)); | |||||
| } | |||||
| const maintenanceDenied = await call('auth/v1/maintenance/audit-logs', token); | |||||
| check('管理员无法访问维护审计', maintenanceDenied.status === 403); | |||||
| const normalAudit = records((await call('auth/v1/audit-logs?size=100', token)).data); | |||||
| check('普通审计不混入 root 登录与操作记录', !normalAudit.some(x => String(x.userId) === rootUserId || x.username === 'root')); | |||||
| const internalAudit = (await call('auth/v1/maintenance/audit-logs?size=100', rootToken)).data; | |||||
| check('root 活动保留在可追溯的内部审计', records(internalAudit).some(x => x.username === 'root')); | |||||
| const catalog = (await call('auth/v1/permissions/catalog', token)).data; | |||||
| const permissionItems = catalog.flatMap(s => s.items); | |||||
| check('目录提供模型独立动作与查看依赖', ['create', 'update', 'delete', 'publish'].every(action => permissionItems.some(p => p.code === `content.model.${action}` && p.requires.includes('content.model')))); | |||||
| check('已移除旧的共用内容写权限', !permissionItems.some(p => ['content.create', 'content.update', 'content.publish'].includes(p.code))); | |||||
| const suffix = crypto.randomBytes(4).toString('hex'); | |||||
| const fixture = { createdAt: new Date().toISOString(), roleIds: [], projectIds: [], user: null, adminRoleId: adminRole.id, teacherRoleId: teacherRole.id, studentRoleId: studentRole.id }; | |||||
| if (fs.existsSync(fixtureFile) && !JSON.parse(fs.readFileSync(fixtureFile, 'utf8')).cleanedAt) { | |||||
| throw new Error('Active fixture file exists; cleanup or reuse it first'); | |||||
| } | |||||
| write(fixture); | |||||
| for (const [kind, name, permissions] of [ | |||||
| ['reader', '权限实测·模型只读', ['dashboard.view', 'content.model']], | |||||
| ['editor', '权限实测·模型编辑', ['dashboard.view', 'content.model', 'content.model.create', 'content.model.update', 'content.model.delete', 'content.model.publish']], | |||||
| ]) { | |||||
| const made = await call('auth/v1/roles', token, { code: `rv_${kind}_${suffix}`, name, shortName: '测', description: 'UTE2E-PERMISSIONS-20260906', dataScope: 'ALL', enabled: true, sortOrder: 90, departmentScopeIds: [] }); | |||||
| check(`创建可配置的${kind}测试角色`, made.status === 200); | |||||
| fixture.roleIds.push(made.data.id); fixture[kind] = made.data; write(fixture); | |||||
| const saved = await call(`auth/v1/permissions/roles/${made.data.id}`, token, { permissionCodes: permissions, version: made.data.dataVersion }, 'PUT'); | |||||
| check(`保存${kind}细分授权`, saved.status === 200); | |||||
| } | |||||
| const password = `Rv!${crypto.randomBytes(7).toString('hex')}`; | |||||
| const userCommand = { username: `rv_perm_${suffix}`, displayName: '权限与角色切换实测', departmentId: me.departmentId, roleIds: [teacherRole.id, studentRole.id, ...fixture.roleIds], defaultRoleId: teacherRole.id, enabled: true, password, remark: 'UTE2E-PERMISSIONS-20260906' }; | |||||
| const madeUser = await call('auth/v1/users', token, userCommand); | |||||
| check('建立隔离的多角色测试账号', madeUser.status === 200); | |||||
| fixture.user = { ...madeUser.data, password }; write(fixture); | |||||
| const session = await call('auth/v1/auth/login', null, { userId: fixture.user.id, password, roleCode: 'teacher', departmentId: me.departmentId, rememberMe: false }); | |||||
| check('多角色测试账号正常登录', session.status === 200); | |||||
| let actor = session.data.accessToken; | |||||
| for (const kind of ['reader', 'editor']) { | |||||
| const switched = await call('auth/v1/auth/active-role', actor, { roleId: fixture[kind].id }, 'PUT'); | |||||
| check(`接口切换到${kind}角色`, switched.status === 200); | |||||
| actor = switched.data?.accessToken || actor; | |||||
| const detail = await call('auth/v1/auth/me', actor); | |||||
| check(`${kind}当前身份独立生效`, String(detail.data.activeRoleId) === String(fixture[kind].id)); | |||||
| const list = await call('tran/v1/content/projects?type=MODEL&size=10', actor); | |||||
| check(`${kind}拥有模型查看权限`, list.status === 200); | |||||
| const project = await call('tran/v1/content/projects', actor, { type: 'MODEL', code: `RV-PERM-${suffix}-${kind}`, name: '权限实测临时模型', categoryCode: 'EQUIPMENT', content: {}, dependencies: [], assets: [] }); | |||||
| if (kind === 'reader') check('只有查看权限时,直接新建接口返回403', project.status === 403); | |||||
| else { | |||||
| check('自定义非教员角色获得新增权限即可创建模型', project.status === 200); | |||||
| assert.ok(project.data?.project?.id, 'Created project returns a stable project ID'); | |||||
| fixture.projectIds.push(project.data.project.id); write(fixture); | |||||
| } | |||||
| } | |||||
| await call('auth/v1/auth/logout', actor, {}); | |||||
| const adminWrite = await call('tran/v1/content/projects', token, { type: 'MODEL', name: '禁止生成的模型', content: {} }); | |||||
| check('普通管理员模型查看与创建权限相互独立', adminWrite.status === 403); | |||||
| fs.writeFileSync(path.join(report, 'api-checks.json'), JSON.stringify({ time: new Date().toISOString(), checks, catalogCount: permissionItems.length, adminPermissionCount: grants.permissionCodes.length }, null, 2)); | |||||
| console.log(JSON.stringify({ passed: checks.length, fixturesReady: true })); | |||||
| } finally { | |||||
| await call('auth/v1/auth/logout', token, {}).catch(() => {}); | |||||
| await call('auth/v1/auth/logout', rootToken, {}).catch(() => {}); | |||||
| } | |||||
| } | |||||
| async function cleanup() { | |||||
| const fixture = JSON.parse(fs.readFileSync(fixtureFile, 'utf8')); | |||||
| const session = await login('root'); const token = session.accessToken; | |||||
| const result = { time: new Date().toISOString(), projects: [], users: [], roles: [] }; | |||||
| try { | |||||
| for (const id of fixture.projectIds) { | |||||
| const existing = await call(`tran/v1/content/projects/${id}`, token); | |||||
| if (existing.status === 404) continue; | |||||
| assert.equal(existing.status, 200, 'Read isolated project before cleanup'); | |||||
| const project = existing.data.project; | |||||
| assert.equal(project.name, '权限实测临时模型', 'Only isolated fixture projects can be removed'); | |||||
| assert.equal(String(project.ownerUserId), String(fixture.user.id), 'Fixture project must belong to this run’s temporary account'); | |||||
| assert.ok(project.addTime >= Math.floor(Date.parse(fixture.createdAt) / 1000), 'Fixture project must have been created during this run'); | |||||
| const removed = await call(`tran/v1/content/projects/${id}?version=${project.version}`, token, undefined, 'DELETE'); | |||||
| assert.equal(removed.status, 200, 'Remove isolated project'); result.projects.push(String(id)); | |||||
| } | |||||
| if (fixture.user?.id) { | |||||
| const existing = await call(`auth/v1/users/${fixture.user.id}`, token); | |||||
| if (existing.status !== 404) { | |||||
| assert.equal(existing.status, 200); | |||||
| assert.equal(existing.data.remark, 'UTE2E-PERMISSIONS-20260906'); | |||||
| const removed = await call(`auth/v1/users/${fixture.user.id}`, token, { version: existing.data.version ?? existing.data.dataVersion }, 'DELETE'); | |||||
| assert.equal(removed.status, 200, 'Remove isolated test account'); result.users.push(String(fixture.user.id)); | |||||
| } | |||||
| } | |||||
| for (const id of fixture.roleIds) { | |||||
| const existing = await call(`auth/v1/roles/${id}`, token); | |||||
| if (existing.status === 404) continue; | |||||
| assert.equal(existing.status, 200); | |||||
| assert.equal(existing.data.description, 'UTE2E-PERMISSIONS-20260906'); | |||||
| const removed = await call(`auth/v1/roles/${id}`, token, { version: existing.data.version ?? existing.data.dataVersion }, 'DELETE'); | |||||
| assert.equal(removed.status, 200, 'Remove isolated test role'); result.roles.push(String(id)); | |||||
| } | |||||
| fixture.cleanedAt = result.time; if (fixture.user) delete fixture.user.password; write(fixture); | |||||
| fs.writeFileSync(path.join(report, 'cleanup.json'), JSON.stringify({ ...result, status: 'PASS' }, null, 2)); | |||||
| console.log(JSON.stringify({ cleanup: 'PASS', projects: result.projects.length, users: result.users.length, roles: result.roles.length })); | |||||
| } finally { await call('auth/v1/auth/logout', token, {}).catch(() => {}); } | |||||
| } | |||||
| if (process.argv[1] === fileURLToPath(import.meta.url)) { | |||||
| const cleanupMode = process.argv.includes('--cleanup'); | |||||
| (cleanupMode ? cleanup() : prepare()).catch(error => { | |||||
| fs.writeFileSync(path.join(report, cleanupMode ? 'cleanup.json' : 'api-checks.json'), JSON.stringify({ time: new Date().toISOString(), status: 'FAIL', ...(cleanupMode ? {} : { checks }), error: error.message }, null, 2)); | |||||
| console.error(error.message); process.exitCode = 1; | |||||
| }); | |||||
| } | |||||
| @@ -166,7 +166,7 @@ const saveMethod = editorSource.slice(editorSource.indexOf(' async saveProject( | |||||
| const { saveProject } = new Function(`return ({ ${saveMethod} })`)() | const { saveProject } = new Function(`return ({ ${saveMethod} })`)() | ||||
| function editorFixture(saveDocument) { | function editorFixture(saveDocument) { | ||||
| return { | return { | ||||
| readySettled: true, pendingLoads: 0, readOnly: false, destroyed: false, saving: false, dirty: false, changeRevision: 0, | |||||
| readySettled: true, pendingLoads: 0, readOnly: false, destroyed: false, saving: false, publishing: false, dirty: false, changeRevision: 0, | |||||
| context: { saveDocument }, serialize: () => ({ objects: [] }), applyAccessMode() {}, setSaveState() {}, | context: { saveDocument }, serialize: () => ({ objects: [] }), applyAccessMode() {}, setSaveState() {}, | ||||
| updateHistoryControls() {}, messages: [], toast(message, type) { this.messages.push({ message, type }) }, | updateHistoryControls() {}, messages: [], toast(message, type) { this.messages.push({ message, type }) }, | ||||
| } | } | ||||
| @@ -221,16 +221,44 @@ function publishingEditor(saveDocument) { | |||||
| } | } | ||||
| const hostSource = readFileSync(new URL('../../unreal_tran_web/src/views/content/LegacyScenePageView.vue', import.meta.url), 'utf8') | const hostSource = readFileSync(new URL('../../unreal_tran_web/src/views/content/LegacyScenePageView.vue', import.meta.url), 'utf8') | ||||
| const leaveSource = hostSource.slice(hostSource.indexOf('async function confirmLeave()'), hostSource.indexOf('function resetProjectSession()')) | |||||
| const leaveSource = hostSource.slice(hostSource.indexOf('async function confirmLeave('), hostSource.indexOf('function resetProjectSession()')) | |||||
| const leaveCompiled = ts.transpileModule(leaveSource, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText | const leaveCompiled = ts.transpileModule(leaveSource, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None } }).outputText | ||||
| function leaveEditor(editor) { | |||||
| return new Function('editorHandle', 'ElMessageBox', 'ElMessage', `${leaveCompiled}\nreturn confirmLeave()`)( | |||||
| function leaveEditor(editor, { | |||||
| operations = new LegacySceneOperationQueue(), hostLoading = false, | |||||
| confirm = async () => undefined, preserveOnDiscard = false, | |||||
| } = {}) { | |||||
| return new Function('editorHandle', 'ElMessageBox', 'ElMessage', 'operationQueue', 'loading', 'preserveOnDiscard', `${leaveCompiled}\nreturn confirmLeave({ preserveOnDiscard })`)( | |||||
| editor, | editor, | ||||
| { confirm: async () => undefined }, | |||||
| { error: message => editor.toast(message, 'error') }, | |||||
| { confirm }, | |||||
| { error: message => editor.toast(message, 'error'), info: message => editor.toast(message, 'info') }, | |||||
| operations, { value: hostLoading }, preserveOnDiscard, | |||||
| ) | ) | ||||
| } | } | ||||
| test('publisher-only scene publishes the saved server version without attempting save or uploads', async () => { | |||||
| const editor = publishingEditor(() => { throw new Error('must not save') }) | |||||
| editor.readOnly = true | |||||
| editor.assetStore.validateReferences = () => { throw new Error('must not validate local mutation state') } | |||||
| await publishScene.call(editor) | |||||
| assert.equal(editor.publishCalls, 1) | |||||
| assert.equal(editor.messages.at(-1).type, 'success') | |||||
| }) | |||||
| test('update-only scene never starts saving as a side effect of denied publication', async () => { | |||||
| const editor = publishingEditor(() => { throw new Error('must not save') }) | |||||
| editor.canPublish = false | |||||
| await publishScene.call(editor) | |||||
| assert.equal(editor.publishCalls, 0) | |||||
| }) | |||||
| test('discard while switching roles preserves dirty recovery until all guards and role PUT succeed', async () => { | |||||
| const editor = publishingEditor(async () => ({})) | |||||
| editor.dirty = true | |||||
| const result = await leaveEditor(editor, { confirm: async () => { throw 'cancel' }, preserveOnDiscard: true }) | |||||
| assert.equal(result, true) | |||||
| assert.equal(editor.dirty, true) | |||||
| }) | |||||
| for (const status of [409, 503]) { | for (const status of [409, 503]) { | ||||
| test(`publish after PUT ${status} stays blocked and reports the save failure only once`, async () => { | test(`publish after PUT ${status} stays blocked and reports the save failure only once`, async () => { | ||||
| const editor = publishingEditor(async () => { throw Object.assign(new Error(`HTTP ${status}`), { status }) }) | const editor = publishingEditor(async () => { throw Object.assign(new Error(`HTTP ${status}`), { status }) }) | ||||
| @@ -265,3 +293,85 @@ for (const action of ['publish', 'leave']) { | |||||
| assert.doesNotMatch(editor.messages[0].message, /保存失败|未保存成功/) | assert.doesNotMatch(editor.messages[0].message, /保存失败|未保存成功/) | ||||
| }) | }) | ||||
| } | } | ||||
| for (const state of ['host loading', 'restoring models', 'uploading model']) { | |||||
| test(`role switch blocks ${state} even before the scene becomes dirty`, async () => { | |||||
| const editor = publishingEditor(async () => ({})) | |||||
| if (state === 'restoring models') editor.readySettled = false | |||||
| if (state === 'uploading model') editor.pendingLoads = 1 | |||||
| const allowed = await leaveEditor(editor, { | |||||
| hostLoading: state === 'host loading', | |||||
| confirm: () => assert.fail('must not offer discard during resource loading'), | |||||
| }) | |||||
| assert.equal(allowed, false) | |||||
| assert.equal(editor.dirty, false) | |||||
| assert.equal(editor.messages.length, 1) | |||||
| assert.match(editor.messages[0].message, /加载或上传/) | |||||
| }) | |||||
| } | |||||
| for (const outcome of ['saved', 'failed', 'new edits']) { | |||||
| test(`role switch waits for cover upload and save before checking final dirty: ${outcome}`, async () => { | |||||
| const operations = new LegacySceneOperationQueue() | |||||
| const barrier = defer() | |||||
| const editor = publishingEditor(() => operations.enqueue(() => barrier.promise)) | |||||
| // Saving may start from a clean scene (for example, replacing its cover). | |||||
| const saving = editor.saveProject() | |||||
| let settled = false | |||||
| const leaving = leaveEditor(editor, { | |||||
| operations, confirm: () => assert.fail('must not bypass the current save with a second prompt'), | |||||
| }).then(result => { settled = true; return result }) | |||||
| await new Promise(resolve => setImmediate(resolve)) | |||||
| assert.equal(editor.saving, true) | |||||
| assert.equal(settled, false) | |||||
| if (outcome === 'failed') barrier.reject(new Error('HTTP 409')) | |||||
| else { | |||||
| if (outcome === 'new edits') editor.changeRevision += 1 | |||||
| barrier.resolve({}) | |||||
| } | |||||
| await saving | |||||
| assert.equal(await leaving, outcome === 'saved') | |||||
| assert.equal(editor.dirty, outcome !== 'saved') | |||||
| assert.equal(editor.messages.length, 1) | |||||
| if (outcome === 'failed') assert.match(editor.messages[0].message, /HTTP 409/) | |||||
| }) | |||||
| } | |||||
| test('role switch waits through validation, save, and the later queued publish after dirty clears', async () => { | |||||
| const operations = new LegacySceneOperationQueue() | |||||
| const savingBarrier = defer() | |||||
| const publishingBarrier = defer() | |||||
| const editor = publishingEditor(() => operations.enqueue(() => savingBarrier.promise)) | |||||
| editor.context.publishDocument = () => operations.enqueue(async () => { | |||||
| editor.publishCalls += 1 | |||||
| await publishingBarrier.promise | |||||
| }) | |||||
| const publishing = publishScene.call(editor) | |||||
| let settled = false | |||||
| const leaving = leaveEditor(editor, { operations }).then(result => { settled = true; return result }) | |||||
| await new Promise(resolve => setImmediate(resolve)) | |||||
| assert.equal(settled, false) | |||||
| savingBarrier.resolve({}) | |||||
| await new Promise(resolve => setImmediate(resolve)) | |||||
| assert.equal(editor.dirty, false) | |||||
| assert.equal(editor.publishCalls, 1) | |||||
| assert.equal(editor.publishing, true) | |||||
| assert.equal(settled, false) | |||||
| publishingBarrier.resolve() | |||||
| await publishing | |||||
| assert.equal(await leaving, true) | |||||
| }) | |||||
| test('role switch rechecks resource uploads started while the save was finishing', async () => { | |||||
| const operations = new LegacySceneOperationQueue() | |||||
| const barrier = defer() | |||||
| const editor = publishingEditor(() => operations.enqueue(() => barrier.promise)) | |||||
| const saving = editor.saveProject() | |||||
| const leaving = leaveEditor(editor, { operations }) | |||||
| editor.pendingLoads = 1 | |||||
| barrier.resolve({}) | |||||
| await saving | |||||
| assert.equal(await leaving, false) | |||||
| assert.equal(editor.pendingLoads, 1) | |||||
| assert.match(editor.messages.at(-1).message, /加载或上传/) | |||||
| }) | |||||
| @@ -142,6 +142,7 @@ function editorFixture() { | |||||
| const pending = new Promise(done => { resolve = done }) | const pending = new Promise(done => { resolve = done }) | ||||
| const editor = new EditorMethods() | const editor = new EditorMethods() | ||||
| Object.assign(editor, { | Object.assign(editor, { | ||||
| can: () => true, readOnly: false, | |||||
| project: { id: '161', status: 'draft', scenario: {}, steps: [] }, storage: {}, editRevision: 1, | project: { id: '161', status: 'draft', scenario: {}, steps: [] }, storage: {}, editRevision: 1, | ||||
| persisting: false, dirty: true, serverPending: true, root: { querySelector: () => null, querySelectorAll: () => [] }, | persisting: false, dirty: true, serverPending: true, root: { querySelector: () => null, querySelectorAll: () => [] }, | ||||
| onPersist: () => pending, assetStore: { validateReferences: async () => ({ valid: true }) }, | onPersist: () => pending, assetStore: { validateReferences: async () => ({ valid: true }) }, | ||||
| @@ -149,6 +150,35 @@ function editorFixture() { | |||||
| for (const name of ['renderSaveState', 'renderProjects', 'renderProjectHeader', 'rememberSavedBaseline', 'replaceCurrentHistoryState', 'renderAll', 'toast', 'showValidation']) editor[name] = () => {} | for (const name of ['renderSaveState', 'renderProjects', 'renderProjectHeader', 'rememberSavedBaseline', 'replaceCurrentHistoryState', 'renderAll', 'toast', 'showValidation']) editor[name] = () => {} | ||||
| return { editor, resolve } | return { editor, resolve } | ||||
| } | } | ||||
| test('training without update permission cannot manually save or autosave its recovery buffer', async () => { | |||||
| const { editor } = editorFixture() | |||||
| editor.readOnly = true | |||||
| editor.can = () => false | |||||
| editor.onPersist = () => { throw new Error('no persistence permitted') } | |||||
| assert.equal(await editor.saveDraft({ notify: true }), null) | |||||
| assert.equal(await editor.saveDraft(), null) | |||||
| assert.equal(await editor.publishProject(), null) | |||||
| }) | |||||
| test('publisher-only training sends one publish intent without changing or saving local draft', async () => { | |||||
| const { editor } = editorFixture() | |||||
| editor.readOnly = true | |||||
| editor.can = permission => permission === 'content.training.publish' | |||||
| let calls = 0 | |||||
| editor.assetStore.validateReferences = () => { throw new Error('must use saved server version') } | |||||
| editor.onPersist = async payload => { calls += 1; assert.equal(payload.reason, 'publish'); return { status: 'published' } } | |||||
| assert.ok(await editor.publishProject()) | |||||
| assert.equal(calls, 1) | |||||
| assert.equal(editor.project.status, 'published') | |||||
| }) | |||||
| test('update-only training never invokes host persistence for a denied publish action', async () => { | |||||
| const { editor } = editorFixture() | |||||
| editor.can = permission => permission === 'content.training.update' | |||||
| editor.onPersist = () => { throw new Error('no publication permitted') } | |||||
| assert.equal(await editor.publishProject(), null) | |||||
| }) | |||||
| test('manual save remains pending until server acknowledgment and blocks duplicate writes', async () => { | test('manual save remains pending until server acknowledgment and blocks duplicate writes', async () => { | ||||
| const f = editorFixture() | const f = editorFixture() | ||||
| const pending = f.editor.saveDraft({ notify: true }) | const pending = f.editor.saveDraft({ notify: true }) | ||||