No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 
 
 

176 líneas
13 KiB

  1. import fs from 'node:fs';
  2. import path from 'node:path';
  3. import crypto from 'node:crypto';
  4. import assert from 'node:assert/strict';
  5. import { fileURLToPath } from 'node:url';
  6. export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
  7. export const work = path.resolve(root, '../.codex-tmp/permissions-20260906');
  8. export const report = path.resolve(root, 'ute2e/reports/role-permissions-20260906');
  9. fs.mkdirSync(work, { recursive: true }); fs.mkdirSync(report, { recursive: true });
  10. export function credentials(account) {
  11. const lines = fs.readFileSync(path.join(root, '账号.MD'), 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/).map(x => x.trim()).filter(Boolean);
  12. const pairs = [];
  13. 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() });
  14. const found = pairs.find(x => x.username === account);
  15. if (!found?.password) throw new Error('Requested local credential pair is unavailable');
  16. return found;
  17. }
  18. export async function call(endpoint, token, body, method = body === undefined ? 'GET' : 'POST') {
  19. const response = await fetch(`http://127.0.0.1:6180/api/${endpoint}`, {
  20. method, headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
  21. body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000),
  22. });
  23. const value = await response.json();
  24. return { status: response.status, code: value.code, data: value.data, message: value.message };
  25. }
  26. export async function login(account = 'admin') {
  27. const result = await call('auth/v1/auth/login', null, { ...credentials(account), roleCode: 'admin', rememberMe: false });
  28. assert.equal(result.status, 200, `Local ${account} login status`);
  29. assert.ok(result.data?.accessToken, 'Access token returned');
  30. return result.data;
  31. }
  32. const checks = [];
  33. const check = (name, condition) => { assert.ok(condition, name); checks.push({ name, result: 'PASS' }); };
  34. const records = value => value?.records ?? value ?? [];
  35. const fixtureFile = path.join(work, 'fixtures.json');
  36. const write = fixture => fs.writeFileSync(fixtureFile, JSON.stringify(fixture, null, 2));
  37. async function prepare() {
  38. const admin = await login(); const maintenance = await login('root');
  39. const token = admin.accessToken; const rootToken = maintenance.accessToken;
  40. try {
  41. const me = (await call('auth/v1/auth/me', token)).data;
  42. const rootMe = (await call('auth/v1/auth/me', rootToken)).data;
  43. const rootUserId = String(rootMe.id);
  44. check('root 使用管理员登录入口进入内部维护角色', rootMe.roles.some(r => r.code === 'root') && rootMe.maintenanceAccount === true);
  45. const roles = records((await call('auth/v1/roles/all', token)).data);
  46. const adminRole = roles.find(r => r.code === 'admin'), teacherRole = roles.find(r => r.code === 'teacher'), studentRole = roles.find(r => r.code === 'student');
  47. check('普通角色目录不显示内部维护角色', !roles.some(r => r.code === 'root'));
  48. check('管理员角色采用固定的受限授权', adminRole.permissionPolicy === 'FIXED' && adminRole.superAdmin === false);
  49. const grants = (await call(`auth/v1/permissions/roles/${adminRole.id}`, token)).data;
  50. check('管理员授权已持久化,不动态获得全权限', grants.dynamicAll === false && grants.permissionCodes.length > 0 && !grants.permissionCodes.includes('content.model.create'));
  51. for (const [name, actor] of [['admin', token], ['root', rootToken]]) {
  52. const forbidden = await call(`auth/v1/permissions/roles/${adminRole.id}`, actor, { permissionCodes: grants.permissionCodes, version: grants.version }, 'PUT');
  53. check(`${name} 无法通过接口修改固定管理员授权`, forbidden.status === 403 || forbidden.status === 400);
  54. }
  55. const normalAdmin = (await call(`auth/v1/users/${me.id}`, token)).data;
  56. check('admin 账号已取消 is_protected', normalAdmin.isProtected === false);
  57. const users = records((await call('auth/v1/users?size=100', token)).data);
  58. check('用户目录不显示 root 维护账号', !users.some(u => String(u.id) === rootUserId));
  59. for (const [name, body, method, tail] of [
  60. ['查询', undefined, 'GET', ''], ['停用', { enabled: false, version: 0 }, 'PUT', '/status'],
  61. ['重置密码', { password: `Test!${crypto.randomBytes(7).toString('hex')}`, version: 0 }, 'PUT', '/password'],
  62. ['删除', { version: 0 }, 'DELETE', ''],
  63. ]) {
  64. const forbidden = await call(`auth/v1/users/${rootUserId}${tail}`, token, body, method);
  65. check(`普通账号按 ID ${name} root 受保护`, [400, 403, 404].includes(forbidden.status));
  66. }
  67. const maintenanceDenied = await call('auth/v1/maintenance/audit-logs', token);
  68. check('管理员无法访问维护审计', maintenanceDenied.status === 403);
  69. const normalAudit = records((await call('auth/v1/audit-logs?size=100', token)).data);
  70. check('普通审计不混入 root 登录与操作记录', !normalAudit.some(x => String(x.userId) === rootUserId || x.username === 'root'));
  71. const internalAudit = (await call('auth/v1/maintenance/audit-logs?size=100', rootToken)).data;
  72. check('root 活动保留在可追溯的内部审计', records(internalAudit).some(x => x.username === 'root'));
  73. const catalog = (await call('auth/v1/permissions/catalog', token)).data;
  74. const permissionItems = catalog.flatMap(s => s.items);
  75. check('目录提供模型独立动作与查看依赖', ['create', 'update', 'delete', 'publish'].every(action => permissionItems.some(p => p.code === `content.model.${action}` && p.requires.includes('content.model'))));
  76. check('已移除旧的共用内容写权限', !permissionItems.some(p => ['content.create', 'content.update', 'content.publish'].includes(p.code)));
  77. const suffix = crypto.randomBytes(4).toString('hex');
  78. const fixture = { createdAt: new Date().toISOString(), roleIds: [], projectIds: [], user: null, adminRoleId: adminRole.id, teacherRoleId: teacherRole.id, studentRoleId: studentRole.id };
  79. if (fs.existsSync(fixtureFile) && !JSON.parse(fs.readFileSync(fixtureFile, 'utf8')).cleanedAt) {
  80. throw new Error('Active fixture file exists; cleanup or reuse it first');
  81. }
  82. write(fixture);
  83. for (const [kind, name, permissions] of [
  84. ['reader', '权限实测·模型只读', ['dashboard.view', 'content.model']],
  85. ['editor', '权限实测·模型编辑', ['dashboard.view', 'content.model', 'content.model.create', 'content.model.update', 'content.model.delete', 'content.model.publish']],
  86. ]) {
  87. 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: [] });
  88. check(`创建可配置的${kind}测试角色`, made.status === 200);
  89. fixture.roleIds.push(made.data.id); fixture[kind] = made.data; write(fixture);
  90. const saved = await call(`auth/v1/permissions/roles/${made.data.id}`, token, { permissionCodes: permissions, version: made.data.dataVersion }, 'PUT');
  91. check(`保存${kind}细分授权`, saved.status === 200);
  92. }
  93. const password = `Rv!${crypto.randomBytes(7).toString('hex')}`;
  94. 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' };
  95. const madeUser = await call('auth/v1/users', token, userCommand);
  96. check('建立隔离的多角色测试账号', madeUser.status === 200);
  97. fixture.user = { ...madeUser.data, password }; write(fixture);
  98. const session = await call('auth/v1/auth/login', null, { userId: fixture.user.id, password, roleCode: 'teacher', departmentId: me.departmentId, rememberMe: false });
  99. check('多角色测试账号正常登录', session.status === 200);
  100. let actor = session.data.accessToken;
  101. for (const kind of ['reader', 'editor']) {
  102. const switched = await call('auth/v1/auth/active-role', actor, { roleId: fixture[kind].id }, 'PUT');
  103. check(`接口切换到${kind}角色`, switched.status === 200);
  104. actor = switched.data?.accessToken || actor;
  105. const detail = await call('auth/v1/auth/me', actor);
  106. check(`${kind}当前身份独立生效`, String(detail.data.activeRoleId) === String(fixture[kind].id));
  107. const list = await call('tran/v1/content/projects?type=MODEL&size=10', actor);
  108. check(`${kind}拥有模型查看权限`, list.status === 200);
  109. const project = await call('tran/v1/content/projects', actor, { type: 'MODEL', code: `RV-PERM-${suffix}-${kind}`, name: '权限实测临时模型', categoryCode: 'EQUIPMENT', content: {}, dependencies: [], assets: [] });
  110. if (kind === 'reader') check('只有查看权限时,直接新建接口返回403', project.status === 403);
  111. else {
  112. check('自定义非教员角色获得新增权限即可创建模型', project.status === 200);
  113. assert.ok(project.data?.project?.id, 'Created project returns a stable project ID');
  114. fixture.projectIds.push(project.data.project.id); write(fixture);
  115. }
  116. }
  117. await call('auth/v1/auth/logout', actor, {});
  118. const adminWrite = await call('tran/v1/content/projects', token, { type: 'MODEL', name: '禁止生成的模型', content: {} });
  119. check('普通管理员模型查看与创建权限相互独立', adminWrite.status === 403);
  120. fs.writeFileSync(path.join(report, 'api-checks.json'), JSON.stringify({ time: new Date().toISOString(), checks, catalogCount: permissionItems.length, adminPermissionCount: grants.permissionCodes.length }, null, 2));
  121. console.log(JSON.stringify({ passed: checks.length, fixturesReady: true }));
  122. } finally {
  123. await call('auth/v1/auth/logout', token, {}).catch(() => {});
  124. await call('auth/v1/auth/logout', rootToken, {}).catch(() => {});
  125. }
  126. }
  127. async function cleanup() {
  128. const fixture = JSON.parse(fs.readFileSync(fixtureFile, 'utf8'));
  129. const session = await login('root'); const token = session.accessToken;
  130. const result = { time: new Date().toISOString(), projects: [], users: [], roles: [] };
  131. try {
  132. for (const id of fixture.projectIds) {
  133. const existing = await call(`tran/v1/content/projects/${id}`, token);
  134. if (existing.status === 404) continue;
  135. assert.equal(existing.status, 200, 'Read isolated project before cleanup');
  136. const project = existing.data.project;
  137. assert.equal(project.name, '权限实测临时模型', 'Only isolated fixture projects can be removed');
  138. assert.equal(String(project.ownerUserId), String(fixture.user.id), 'Fixture project must belong to this run’s temporary account');
  139. assert.ok(project.addTime >= Math.floor(Date.parse(fixture.createdAt) / 1000), 'Fixture project must have been created during this run');
  140. const removed = await call(`tran/v1/content/projects/${id}?version=${project.version}`, token, undefined, 'DELETE');
  141. assert.equal(removed.status, 200, 'Remove isolated project'); result.projects.push(String(id));
  142. }
  143. if (fixture.user?.id) {
  144. const existing = await call(`auth/v1/users/${fixture.user.id}`, token);
  145. if (existing.status !== 404) {
  146. assert.equal(existing.status, 200);
  147. assert.equal(existing.data.remark, 'UTE2E-PERMISSIONS-20260906');
  148. const removed = await call(`auth/v1/users/${fixture.user.id}`, token, { version: existing.data.version ?? existing.data.dataVersion }, 'DELETE');
  149. assert.equal(removed.status, 200, 'Remove isolated test account'); result.users.push(String(fixture.user.id));
  150. }
  151. }
  152. for (const id of fixture.roleIds) {
  153. const existing = await call(`auth/v1/roles/${id}`, token);
  154. if (existing.status === 404) continue;
  155. assert.equal(existing.status, 200);
  156. assert.equal(existing.data.description, 'UTE2E-PERMISSIONS-20260906');
  157. const removed = await call(`auth/v1/roles/${id}`, token, { version: existing.data.version ?? existing.data.dataVersion }, 'DELETE');
  158. assert.equal(removed.status, 200, 'Remove isolated test role'); result.roles.push(String(id));
  159. }
  160. fixture.cleanedAt = result.time; if (fixture.user) delete fixture.user.password; write(fixture);
  161. fs.writeFileSync(path.join(report, 'cleanup.json'), JSON.stringify({ ...result, status: 'PASS' }, null, 2));
  162. console.log(JSON.stringify({ cleanup: 'PASS', projects: result.projects.length, users: result.users.length, roles: result.roles.length }));
  163. } finally { await call('auth/v1/auth/logout', token, {}).catch(() => {}); }
  164. }
  165. if (process.argv[1] === fileURLToPath(import.meta.url)) {
  166. const cleanupMode = process.argv.includes('--cleanup');
  167. (cleanupMode ? cleanup() : prepare()).catch(error => {
  168. 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));
  169. console.error(error.message); process.exitCode = 1;
  170. });
  171. }