Files
aurak/test-user-lifecycle.mjs
Developer 64771f10ed test: 用户管理全生命周期测试(42项)覆盖异常case
覆盖场景:
- 创建用户异常:重复用户名、密码太短、空字段
- 权限边界:USER 不能创建/查看/删除用户
- 角色变更:USER↔TENANT_ADMIN 切换后权限实时生效
- 删除异常:删自己、删 admin、删不存在用户
- UI 验证:角色列、编辑弹窗、权限管理页、权限矩阵

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 09:00:23 +08:00

402 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 用户管理全生命周期测试
*
* 覆盖场景:
* - 三种角色(SUPER_ADMIN / TENANT_ADMIN / USER)的 CRUD 权限
* - 创建用户的各种异常 case(重复用户名、密码太短、空字段)
* - 编辑用户(改名、改角色)
* - 删除用户(删自己、删 admin、删不存在的人)
* - 角色变更后权限实时生效
* - UI 交互验证(Playwright
*/
import { chromium } from 'playwright';
const API = 'http://localhost:3001';
const BASE = 'http://localhost:13001';
const TENANT_ID = 'a140a68e-f70a-44d3-b753-fa33d48cf234';
// ── 工具函数 ──
let pass = 0, fail = 0;
function assert(label, ok, detail = '') {
if (ok) {
console.log(`${label}`);
pass++;
} else {
console.log(`${label}${detail ? ' — ' + detail : ''}`);
fail++;
}
}
async function loginApi(username, password) {
const r = await fetch(`${API}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
if (!r.ok) return null;
const data = await r.json();
return data.access_token;
}
async function api(token, method, path, body = null) {
const opts = {
method,
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
};
if (body) opts.body = JSON.stringify(body);
const r = await fetch(`${API}/api${path}`, opts);
const status = r.status;
let data = null;
try { data = await r.json(); } catch { data = null; }
return { status, data };
}
/** 通过 Playwright 登录并获取 apiKey */
async function getApiKey(page, username, password) {
await page.goto(`${BASE}/login`, { waitUntil: 'networkidle' });
await page.waitForTimeout(1000);
await page.locator('input[type="text"]').first().fill(username);
await page.locator('input[type="password"]').first().fill(password);
await page.locator('button[type="submit"]').click();
await page.waitForURL('**/');
await page.waitForTimeout(500);
return await page.evaluate(() => localStorage.getItem('kb_api_key') || '');
}
// ── 主测试 ──
async function run() {
const browser = await chromium.launch({ headless: true });
console.log('\n' + '='.repeat(70));
console.log('🧪 用户管理全生命周期测试');
console.log('='.repeat(70));
// ──────────────────────────────────
// Phase 1: Admin 登录 + 创建测试用户
// ──────────────────────────────────
console.log('\n📦 Phase 1: 环境准备');
const adminToken = await loginApi('admin', 'admin123');
assert('admin 登录', !!adminToken);
// 创建 test-user-a(正常用户)
const r1 = await api(adminToken, 'POST', '/users', {
username: 'e2e-user-a', password: 'pass123', displayName: '测试用户A',
});
const userAId = r1.data?.user?.id || r1.data?.id;
assert('创建 userA', r1.status === 201 || r1.status === 200, `status=${r1.status}`);
assert(' userA 有 ID', !!userAId);
// 加到租户
const r1m = await api(adminToken, 'POST', `/v1/tenants/${TENANT_ID}/members`, {
userId: userAId, role: 'USER',
});
assert(' userA 加入租户', r1m.status === 201 || r1m.status === 200, `status=${r1m.status}`);
// 创建 test-user-b(后来会升为 TENANT_ADMIN
const r2 = await api(adminToken, 'POST', '/users', {
username: 'e2e-user-b', password: 'pass456', displayName: '测试用户B',
});
const userBId = r2.data?.user?.id || r2.data?.id;
assert('创建 userB', !!userBId);
const r2m = await api(adminToken, 'POST', `/v1/tenants/${TENANT_ID}/members`, {
userId: userBId, role: 'USER',
});
assert(' userB 加入租户', r2m.status === 201 || r2m.status === 200);
// ──────────────────────────────────
// Phase 2: 创建用户的异常情况
// ──────────────────────────────────
console.log('\n📦 Phase 2: 创建用户 — 异常 case');
// 2a. 重复用户名
const rDup = await api(adminToken, 'POST', '/users', {
username: 'e2e-user-a', password: 'pass123', displayName: '重复用户',
});
assert(' 重复用户名拒绝', rDup.status >= 400, `status=${rDup.status}`);
// 2b. 密码太短
const rShort = await api(adminToken, 'POST', '/users', {
username: 'e2e-user-short', password: '12', displayName: '短密码',
});
assert(' 密码太短拒绝', rShort.status >= 400, `status=${rShort.status}`);
// 2c. 空用户名
const rEmpty = await api(adminToken, 'POST', '/users', {
username: '', password: 'pass123', displayName: '空用户名',
});
assert(' 空用户名拒绝', rEmpty.status >= 400, `status=${rEmpty.status}`);
// 2d. 不传密码
const rNoPass = await api(adminToken, 'POST', '/users', {
username: 'e2e-user-nopass', displayName: '无密码',
});
assert(' 无密码拒绝', rNoPass.status >= 400, `status=${rNoPass.status}`);
// ──────────────────────────────────
// Phase 3: USER 角色不能创建用户
// ──────────────────────────────────
console.log('\n📦 Phase 3: 权限边界 — USER 不能创建/删除用户');
const userAToken = await loginApi('e2e-user-a', 'pass123');
assert(' userA 登录', !!userAToken);
const rForbidCreate = await api(userAToken, 'POST', '/users', {
username: 'e2e-user-forbid', password: 'pass123',
});
assert(' USER 创建用户被拒', rForbidCreate.status === 403, `got ${rForbidCreate.status}`);
const rForbidList = await api(userAToken, 'GET', '/users');
assert(' USER 查看用户列表被拒', rForbidList.status === 403, `got ${rForbidList.status}`);
const rForbidDelete = await api(userAToken, 'DELETE', `/users/${userBId}`);
assert(' USER 删除用户被拒', rForbidDelete.status === 403, `got ${rForbidDelete.status}`);
// ──────────────────────────────────
// Phase 4: 编辑用户 + 角色变更
// ──────────────────────────────────
console.log('\n📦 Phase 4: 编辑用户 & 角色变更');
// 4a. 改名
const rRename = await api(adminToken, 'PUT', `/users/${userAId}`, {
displayName: '用户A已改名',
});
assert(' 编辑用户名', rRename.status === 200, `got ${rRename.status}`);
// 4b. 提升为 TENANT_ADMIN
const rPromote = await api(adminToken, 'PATCH', `/v1/tenants/${TENANT_ID}/members/${userAId}`, {
role: 'TENANT_ADMIN',
});
assert(' 提升 userA 为管理员', rPromote.status === 200, `got ${rPromote.status}`);
// 4c. 验证权限实时生效
const userA2Token = await loginApi('e2e-user-a', 'pass123');
assert(' userA 重新登录', !!userA2Token);
const rCheckPerm = await fetch(`${API}/api/permissions/mine`, {
headers: { 'Authorization': `Bearer ${userA2Token}` },
});
const permData = await rCheckPerm.json();
const permCount = (permData.permissions || []).length;
assert(` userA 权限从 5→${permCount}`, permCount >= 20, `实际=${permCount}`);
// 4d. 验证现在可以查看用户列表了
const rCanList = await api(userA2Token, 'GET', '/users');
assert(' userA(TENANT_ADMIN) 能查看用户列表', rCanList.status === 200);
// 4e. 降回 USER
const rDemote = await api(adminToken, 'PATCH', `/v1/tenants/${TENANT_ID}/members/${userAId}`, {
role: 'USER',
});
assert(' 降回 userA 为 USER', rDemote.status === 200);
const userA3Token = await loginApi('e2e-user-a', 'pass123');
const rCheckPerm2 = await fetch(`${API}/api/permissions/mine`, {
headers: { 'Authorization': `Bearer ${userA3Token}` },
});
const permData2 = await rCheckPerm2.json();
const permCount2 = (permData2.permissions || []).length;
assert(` userA 权限从 ${permCount}${permCount2}`, permCount2 <= 5, `实际=${permCount2}`);
// ──────────────────────────────────
// Phase 5: 删除用户的异常情况
// ──────────────────────────────────
console.log('\n📦 Phase 5: 删除用户 — 异常 case');
// 5a. 删自己
// 先获取 admin 自己的 ID
const adminProfile = await api(adminToken, 'GET', '/users/me');
const adminId = adminProfile.data?.id;
assert(' admin profile 有 ID', !!adminId, `data=${JSON.stringify(adminProfile.data)}`);
if (adminId) {
const rSelf = await api(adminToken, 'DELETE', `/users/${adminId}`);
assert(' 不能删自己', rSelf.status >= 400, `got ${rSelf.status} msg=${JSON.stringify(rSelf.data)}`);
// 验证 admin 还在——通过 users 列表
const rCheckList = await api(adminToken, 'GET', '/users');
const allUsersAfter = Array.isArray(rCheckList.data) ? rCheckList.data : (rCheckList.data?.data || []);
const adminStillThere = allUsersAfter.some(u => u.id === adminId);
assert(' admin 还在', adminStillThere, `列表中无 admin ID`);
}
// 5b. 删不存在的用户
const rNonExist = await api(adminToken, 'DELETE', '/users/non-existent-id');
assert(' 删不存在用户返回 404', rNonExist.status === 404, `got ${rNonExist.status}`);
// 5c. 删 admin 账户
// 先查 admin 的 ID
const usersList = await api(adminToken, 'GET', '/users');
const allUsers = Array.isArray(usersList.data) ? usersList.data : (usersList.data?.data || []);
const realAdmin = allUsers.find(u => u.username === 'admin');
if (realAdmin) {
const rDelAdmin = await api(adminToken, 'DELETE', `/users/${realAdmin.id}`);
assert(' 不能删 admin 账号', rDelAdmin.status >= 400, `got ${rDelAdmin.status} msg=${JSON.stringify(rDelAdmin.data)}`);
}
// 5d. TENANT_ADMIN 删其他租户的用户(如果有的话)
// 创建另一个租户的用户
const rOtherTenant = await api(adminToken, 'POST', '/v1/tenants', { name: 'temp-other-tenant' });
const otherTenantId = rOtherTenant.data?.id;
if (otherTenantId) {
// 删除临时租户
await api(adminToken, 'DELETE', `/v1/tenants/${otherTenantId}`);
}
// ──────────────────────────────────
// Phase 6: 正常删除用户
// ──────────────────────────────────
console.log('\n📦 Phase 6: 正常删除用户(清理测试数据)');
const rDelA = await api(adminToken, 'DELETE', `/users/${userAId}`);
assert(' 删除 userA', rDelA.status === 200, `got ${rDelA.status}`);
const rCheckA = await api(adminToken, 'GET', `/users/${userAId}`);
assert(' userA 已不存在', rCheckA.status === 404, `got ${rCheckA.status}`);
const rDelB = await api(adminToken, 'DELETE', `/users/${userBId}`);
assert(' 删除 userB', rDelB.status === 200, `got ${rDelB.status}`);
// 验证删除后登录失败
const rLoginDel = await loginApi('e2e-user-a', 'pass123');
assert(' 删除后 userA 无法登录', !rLoginDel, `token=${!!rLoginDel}`);
// ──────────────────────────────────
// Phase 7: UI 验证
// ──────────────────────────────────
console.log('\n📦 Phase 7: UI 交互验证');
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
// 7a. 登录 admin
const apiKey = await getApiKey(page, 'admin', 'admin123');
assert(' UI 登录成功', !!apiKey);
// 7b. 进入设置 → 点击「用户管理」侧栏按钮
await page.goto(`${BASE}/settings`, { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
const sidebarBtns = await page.evaluate(() => {
// 侧栏在 class 包含 w-64 和 bg-slate-50 的 div 里
const aside = document.querySelector('[class*="w-64"]') || document.querySelector('aside');
if (!aside) return [];
return Array.from(aside.querySelectorAll('button')).map(b => (b.textContent || '').trim()).filter(Boolean);
});
assert(' 设置页侧栏有按钮', sidebarBtns.length > 0, `按钮: ${sidebarBtns.slice(0,5).join(', ')}`);
// 点击用户管理
const userMgmtBtn = page.locator('button:has-text("用户管理")');
if (await userMgmtBtn.isVisible().catch(() => false)) {
await userMgmtBtn.click();
await page.waitForTimeout(2000);
assert(' 用户管理 Tab 可点击', true);
} else {
assert(' 用户管理按钮可见', false, '侧栏无"用户管理"');
}
// 7c. 检查用户表
const tables = await page.evaluate(() => document.querySelectorAll('table').length);
assert(' 用户表存在', tables > 0, `找到 ${tables} 个 table`);
const headers = await page.evaluate(() => {
return Array.from(document.querySelectorAll('th')).map(th => th.textContent?.trim());
});
assert(' 用户表有角色列', headers.some(h => h?.includes('角色')), `列: ${headers.join(', ')}`);
const rowCount = await page.evaluate(() => document.querySelectorAll('tbody tr').length);
assert(' 用户表有数据行', rowCount > 0, `${rowCount}`);
// 7d. 编辑用户弹窗 — 打开(找任意行的第一个操作按钮)
// 操作栏中编辑按钮在第1个
const firstActionBtn = page.locator('tbody tr button').first();
if (await firstActionBtn.isVisible().catch(() => false)) {
await firstActionBtn.click();
await page.waitForTimeout(1500);
// 检查弹窗中是否有角色选择按钮
const roleBtns = await page.evaluate(() => {
return Array.from(document.querySelectorAll('.fixed button, [class*="fixed"] button, [class*="inset-0"] button'))
.map(b => b.textContent?.trim())
.filter(t => t === '用户' || t === '管理员' || t === '超级管理员');
});
assert(' 编辑弹窗有角色选项', roleBtns.length >= 2, `找到 ${roleBtns.length}`);
// 检查是否有权限预览
const permPreview = await page.evaluate(() => {
return document.body.textContent?.includes('该角色的权限');
});
assert(' 编辑弹窗有权限预览', !!permPreview, '未找到"该角色的权限"');
// 关闭弹窗——点右上角 X 或点取消
const cancelBtn = page.locator('button:has-text("取消")').last();
if (await cancelBtn.isVisible().catch(() => false)) {
await cancelBtn.click();
}
// 等弹窗完全消失
await page.waitForTimeout(2000);
await page.waitForFunction(() => !document.querySelector('[class*="inset-0"][class*="z-\\[1000\\]"]'), { timeout: 5000 }).catch(() => {});
} else {
assert(' 操作按钮可见', false, '未找到任何行内操作按钮');
}
// 7e. 权限管理 Tab
// 先确保没有弹窗遮挡
await page.waitForFunction(() => !document.querySelector('.fixed.inset-0'), { timeout: 5000 }).catch(() => {});
await page.waitForTimeout(1500);
// 用 evaluate 直接点击,绕过任何 DOM 遮挡
const clicked = await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const permBtn = btns.find(b => (b.textContent || '').includes('权限管理'));
if (permBtn) { permBtn.scrollIntoView({ block: 'center' }); permBtn.click(); return true; }
return false;
});
assert(' 权限管理按钮可点击', clicked);
await page.waitForTimeout(2000);
// 检查是否三个系统角色渲染
const hasRoles = await page.evaluate(() => {
const body = document.body.textContent || '';
return body.includes('SUPER_ADMIN') && body.includes('TENANT_ADMIN') && body.includes('USER');
});
assert(' 权限管理页显示三个系统角色', !!hasRoles);
// 点击 SUPER_ADMIN 角色查看权限
const superClicked = await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'));
const superBtn = btns.find(b => (b.textContent || '').includes('SUPER_ADMIN'));
if (superBtn) { superBtn.scrollIntoView({ block: 'center' }); superBtn.click(); return true; }
return false;
});
assert(' SUPER_ADMIN 角色可点击', superClicked);
await page.waitForTimeout(1500);
const permMatrix = await page.evaluate(() => {
const body = document.body.textContent || '';
return body.includes('用户管理') && body.includes('知识库');
});
assert(' 权限矩阵渲染', !!permMatrix);
await page.close();
// ──────────────────────────────────
// 汇总
// ──────────────────────────────────
console.log('\n' + '='.repeat(70));
console.log(`📊 测试汇总: ${pass} ✅ | ${fail} ❌ | 共 ${pass+fail}`);
console.log('='.repeat(70));
if (fail > 0) {
console.log('\n⚠️ 部分测试未通过,请检查以上 ❌ 项');
process.exit(1);
} else {
console.log('\n🎉 所有测试通过!用户管理功能闭环正常。');
}
await browser.close();
}
run().catch(e => { console.error('\n💥 测试异常:', e.message); process.exit(1); });