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; }); }