diff --git a/test-user-lifecycle.mjs b/test-user-lifecycle.mjs new file mode 100644 index 0000000..cd9d07a --- /dev/null +++ b/test-user-lifecycle.mjs @@ -0,0 +1,401 @@ +/** + * 用户管理全生命周期测试 + * + * 覆盖场景: + * - 三种角色(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); });