import { API_PREFIX, ErrorCode, ulid } from '@lingxi/shared'; import { PrismaClient } from '@prisma/client'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createApp, type CreatedApiApp } from '../../bootstrap'; import { loadDotenv } from '../../config/load-dotenv'; /** 与 prisma/seed.ts 一致的示例账号初始密码 lingxi2026(Argon2id) */ const PASSWORD_HASH = '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4'; const PASSWORD = 'lingxi2026'; const ACCOUNT_PREFIX = 'it.'; const API = API_PREFIX; const prisma = new PrismaClient(); interface FixtureUser { id: string; account: string; } interface TokenBody { accessToken: string; refreshToken: string; expiresIn: number; me: { permissions: string[]; user: { id: string; phone?: string }; mustChangePassword: boolean }; } interface ErrorBody { code: number; message: string; } async function createFixtureUser(suffix: string, roleCode: string): Promise { const [department, role] = await Promise.all([ prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }), prisma.role.findUniqueOrThrow({ where: { code: roleCode } }), ]); const account = `${ACCOUNT_PREFIX}${suffix}`; const id = ulid(); await prisma.user.create({ data: { id, name: `集成测试${suffix}`, account, email: `${account}@lingxi.cn`, phone: `137${String(Date.now()).slice(-8)}`, passwordHash: PASSWORD_HASH, departmentId: department.id, mustChangePassword: false, roles: { create: [{ roleId: role.id }] }, }, }); return { id, account }; } async function login( app: CreatedApiApp['app'], account: string, ): Promise<{ status: number; body: TokenBody }> { const response = await app.inject({ method: 'POST', url: `${API}/auth/login`, payload: { account, password: PASSWORD }, }); return { status: response.statusCode, body: response.json() }; } async function authed( app: CreatedApiApp['app'], method: 'GET' | 'POST' | 'PATCH', url: string, token: string, payload?: unknown, ): Promise { const response = await app.inject({ method, url: `${API}${url}`, headers: { authorization: `Bearer ${token}` }, ...(payload === undefined ? {} : { payload: payload as Record }), }); return response.statusCode; } async function setRole(userId: string, roleCode: string): Promise { const role = await prisma.role.findUniqueOrThrow({ where: { code: roleCode } }); await prisma.userRole.deleteMany({ where: { userId } }); await prisma.userRole.create({ data: { userId, roleId: role.id } }); } describe('identity 集成(真实 PG / Redis)', () => { let created: CreatedApiApp; let manager: FixtureUser; let viewer: FixtureUser; beforeAll(async () => { loadDotenv(); created = await createApp(); await created.app.init(); await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } }); manager = await createFixtureUser('manager', 'platform_admin'); viewer = await createFixtureUser('viewer', 'designer'); }); afterAll(async () => { await prisma.user.deleteMany({ where: { account: { startsWith: ACCOUNT_PREFIX } } }); await prisma.$disconnect(); await created.app.close(); }); it('登录返回 tokenPair + me,且权限点来自角色', async () => { const { status, body } = await login(created.app, manager.account); expect(status).toBe(200); expect(body.refreshToken.length).toBeGreaterThan(0); expect(body.expiresIn).toBeGreaterThan(0); expect(body.me.permissions).toContain('user:manage'); expect(body.me.mustChangePassword).toBe(false); expect(body.me.user.phone).toBeDefined(); }); it('/me 与登录上下文一致;列表只回 phoneMasked,详情对 user:manage 回全号', async () => { const { body } = await login(created.app, manager.account); const meResponse = await created.app.inject({ method: 'GET', url: `${API}/me`, headers: { authorization: `Bearer ${body.accessToken}` }, }); expect(meResponse.statusCode).toBe(200); const me = meResponse.json<{ user: { account: string } }>(); expect(me.user.account).toBe(manager.account); const listResponse = await created.app.inject({ method: 'GET', url: `${API}/users?limit=5`, headers: { authorization: `Bearer ${body.accessToken}` }, }); expect(listResponse.statusCode).toBe(200); const list = listResponse.json<{ items: Record[]; next_cursor: string | null; }>(); expect(list.items.length).toBeGreaterThan(0); expect(list.items[0]?.phoneMasked).toMatch(/^\d{3}\*{4}\d{4}$/); expect(list.items[0]?.phone).toBeUndefined(); expect(list.next_cursor === null || typeof list.next_cursor === 'string').toBe(true); const detailResponse = await created.app.inject({ method: 'GET', url: `${API}/users/${manager.id}`, headers: { authorization: `Bearer ${body.accessToken}` }, }); expect(detailResponse.statusCode).toBe(200); const detail = detailResponse.json<{ phone?: string }>(); expect(detail.phone).toBeDefined(); }); it('无 user:view 权限返回 40301;授予角色后下一次请求立即生效', async () => { const { body } = await login(created.app, viewer.account); expect(await authed(created.app, 'GET', '/users', body.accessToken)).toBe(403); await setRole(viewer.id, 'platform_admin'); expect(await authed(created.app, 'GET', '/users', body.accessToken)).toBe(200); }); it('刷新旋转:旧 refresh 立即失效,重放触发整族吊销', async () => { const { body } = await login(created.app, manager.account); const refreshed = await created.app.inject({ method: 'POST', url: `${API}/auth/refresh`, payload: { refreshToken: body.refreshToken }, }); expect(refreshed.statusCode).toBe(200); const next = refreshed.json<{ refreshToken: string }>(); expect(next.refreshToken).not.toBe(body.refreshToken); const replay = await created.app.inject({ method: 'POST', url: `${API}/auth/refresh`, payload: { refreshToken: body.refreshToken }, }); expect(replay.statusCode).toBe(401); const revoked = replay.json(); expect(revoked.code).toBe(ErrorCode.UNAUTHENTICATED); }); it('停用后旧 access token 立即失效,且无法再登录', async () => { const { body } = await login(created.app, viewer.account); const managerLogin = await login(created.app, manager.account); const managerDisabled = await created.app.inject({ method: 'POST', url: `${API}/users/${viewer.id}/disable`, headers: { authorization: `Bearer ${managerLogin.body.accessToken}` }, payload: { reason: '集成测试停用' }, }); expect(managerDisabled.statusCode).toBe(200); const meAfter = await created.app.inject({ method: 'GET', url: `${API}/me`, headers: { authorization: `Bearer ${body.accessToken}` }, }); expect(meAfter.statusCode).toBe(403); expect(meAfter.json().code).toBe(ErrorCode.PERMISSION_DENIED); const relogin = await login(created.app, viewer.account); expect(relogin.status).toBe(403); }); it('登出后 refresh 不可用;部门树与角色列表可读', async () => { const { body } = await login(created.app, manager.account); const logout = await created.app.inject({ method: 'POST', url: `${API}/auth/logout`, payload: { refreshToken: body.refreshToken }, }); expect(logout.statusCode).toBe(204); const refreshAfter = await created.app.inject({ method: 'POST', url: `${API}/auth/refresh`, payload: { refreshToken: body.refreshToken }, }); expect(refreshAfter.statusCode).toBe(401); const tree = await created.app.inject({ method: 'GET', url: `${API}/departments/tree`, headers: { authorization: `Bearer ${body.accessToken}` }, }); expect(tree.statusCode).toBe(200); const treeBody = tree.json<{ items: { name: string; children: unknown[] }[] }>(); expect(treeBody.items.length).toBeGreaterThanOrEqual(3); expect(treeBody.items[0]?.children.length).toBeGreaterThan(0); const roles = await created.app.inject({ method: 'GET', url: `${API}/roles`, headers: { authorization: `Bearer ${body.accessToken}` }, }); expect(roles.statusCode).toBe(200); expect(roles.json<{ items: unknown[] }>().items.length).toBeGreaterThanOrEqual(8); }); });