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 MeBody { permissions: string[]; dataScopes: { type: string; customerIds?: string[] }[]; user: { id: string; phone?: string }; mustChangePassword: boolean; } interface TokenBody { accessToken: string; refreshToken: string; expiresIn: number; me: MeBody; } 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 } }); } interface JsonRequestResult { status: number; body: T; } async function callJson( app: CreatedApiApp['app'], method: 'GET' | 'POST' | 'PATCH' | 'DELETE', 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 { status: response.statusCode, body: response.json() }; } const ROLE_NAME_PREFIX = 'IT-角色-'; /** 集成测试创建的角色 id:afterAll 清理 outbox 占位事件,避免污染本地库 */ const createdRoleIds: string[] = []; /** 集成测试专用角色(名称前缀隔离,afterAll 清理;删除 cascades user_roles / role_permissions) */ async function createTestRole(input: { name: string; permissions?: string[]; isSystem?: boolean; dataScope?: { type: string; targetIds?: string[] }; status?: 'ACTIVE' | 'DISABLED'; }): Promise<{ id: string; name: string }> { const id = ulid(); const role = await prisma.role.create({ data: { id, code: `it_role_${id.toLowerCase()}`, name: `${ROLE_NAME_PREFIX}${input.name}`, description: '集成测试角色', isSystem: input.isSystem ?? false, status: input.status ?? 'ACTIVE', permissions: { create: (input.permissions ?? []).map((permissionCode) => ({ permissionCode })), }, dataScopes: { create: [ { id: ulid(), scopeType: input.dataScope?.type ?? 'created_by_me', targetId: input.dataScope?.targetIds?.[0] ?? null, }, ], }, }, }); createdRoleIds.push(role.id); return { id: role.id, name: role.name }; } async function createFixtureUserWithRoleId(suffix: string, roleId: string): Promise { const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } }); const account = `${ACCOUNT_PREFIX}${suffix}`; const id = ulid(); await prisma.user.create({ data: { id, name: `集成测试${suffix}`, account, email: `${account}@lingxi.cn`, phone: `138${String(Date.now()).slice(-8)}`, passwordHash: PASSWORD_HASH, departmentId: department.id, mustChangePassword: false, roles: { create: [{ roleId }] }, }, }); return { id, account }; } 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 } } }); await prisma.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_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.role.deleteMany({ where: { name: { startsWith: ROLE_NAME_PREFIX } } }); if (createdRoleIds.length > 0) { await prisma.outboxEvent.deleteMany({ where: { aggregateId: { in: createdRoleIds } } }); } 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); // 根部门按 sortOrder 排序,管理员可新建无子级的根部门并排在最前; // 这里只校验树里存在带子级的根部门,不依赖当前第一项(本地手工数据会让测试失真)。 expect(treeBody.items.some((item) => item.children.length > 0)).toBe(true); 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); }); // ------------------------------------------------------------------------- // A2:角色权限、权限树、SoD、数据范围、停用即时生效 // ------------------------------------------------------------------------- it('GET /permissions:10 模块树 + 6 特殊权限 + 3 对 SoD;无 role:view 返回 40301', async () => { const { body } = await login(created.app, manager.account); const response = await callJson<{ modules: { module: string; moduleLabel: string }[]; specialPermissions: { code: string }[]; sodPairs: { id: string }[]; totalPermissions: number; }>(created.app, 'GET', '/permissions', body.accessToken); expect(response.status).toBe(200); expect(response.body.totalPermissions).toBe(30); expect(response.body.modules.map((module) => module.module)).toEqual([ 'script', 'material', 'asset', 'delivery', 'customer', 'customer_group', 'identity', 'industry', 'platform', 'audit', ]); expect(response.body.modules.map((module) => module.moduleLabel)).toContain('客户组'); expect(response.body.specialPermissions).toHaveLength(6); expect(response.body.sodPairs).toHaveLength(3); // 三态之一:无 role:view(设计师)→ 40301(用独立 fixture,避免受前面停用用例影响) const designerRole = await prisma.role.findUniqueOrThrow({ where: { code: 'designer' } }); const designerOnly = await createFixtureUserWithRoleId('permissions-denied', designerRole.id); const designerLogin = await login(created.app, designerOnly.account); const denied = await callJson<{ code: number }>( created.app, 'GET', '/permissions', designerLogin.body.accessToken, ); expect(denied.status).toBe(403); expect(denied.body.code).toBe(ErrorCode.PERMISSION_DENIED); }); it('GET /roles:默认只返回启用角色(A1 多选兼容),字段含统计与 dataScope', async () => { const { body } = await login(created.app, manager.account); const response = await callJson<{ items: { id: string; code: string; name: string; isSystem: boolean; permissionCount: number; specialPermissionCount: number; userCount: number; dataScope: { type: string }; }[]; }>(created.app, 'GET', '/roles', body.accessToken); expect(response.status).toBe(200); const codes = response.body.items.map((item) => item.code); expect(codes).not.toContain('review_lead'); expect(codes).toContain('material_operator'); const superAdmin = response.body.items.find((item) => item.code === 'super_admin'); expect(superAdmin?.isSystem).toBe(true); expect(superAdmin?.permissionCount).toBe(24); expect(superAdmin?.specialPermissionCount).toBe(6); expect(superAdmin?.dataScope).toEqual({ type: 'all' }); const all = await callJson<{ items: { code: string }[] }>( created.app, 'GET', '/roles?status=all', body.accessToken, ); expect(all.body.items.map((item) => item.code)).toContain('review_lead'); // A1 兼容:只有 user:view 的角色也能读取角色列表(用于用户管理的角色多选) const onlyUserView = await createTestRole({ name: '用户查看', permissions: ['user:view'] }); const selector = await createFixtureUserWithRoleId('role-selector', onlyUserView.id); const selectorLogin = await login(created.app, selector.account); const selectorRoles = await callJson<{ items: { code: string }[] }>( created.app, 'GET', '/roles', selectorLogin.body.accessToken, ); expect(selectorRoles.status).toBe(200); expect(selectorRoles.body.items.length).toBeGreaterThan(0); const selectorPermissions = await callJson<{ code: number }>( created.app, 'GET', '/permissions', selectorLogin.body.accessToken, ); expect(selectorPermissions.status).toBe(403); }); it('POST /roles:权限默认全空、code 自动生成、数据范围单选;SoD 命中 40304', async () => { const { body } = await login(created.app, manager.account); const createdRole = await callJson<{ role: { id: string; code: string; status: string; permissions: string[]; specialPermissions: string[]; dataScope: { type: string }; boundUsers: unknown[]; }; affectedUserCount: number; warnings: string[]; }>(created.app, 'POST', '/roles', body.accessToken, { name: `${ROLE_NAME_PREFIX}内容运营`, permissions: ['script:read'], dataScope: { type: 'department' }, }); expect(createdRole.status).toBe(201); createdRoleIds.push(createdRole.body.role.id); expect(createdRole.body.role.code).toMatch(/^custom_[0-9a-z]{26}$/); expect(createdRole.body.role.permissions).toEqual(['script:read']); expect(createdRole.body.role.specialPermissions).toEqual([]); expect(createdRole.body.role.dataScope).toEqual({ type: 'department' }); expect(createdRole.body.affectedUserCount).toBe(0); const sod = await callJson<{ code: number; details: { conflicts: { pairId: string }[] } }>( created.app, 'POST', '/roles', body.accessToken, { name: `${ROLE_NAME_PREFIX}互斥冲突`, permissions: ['script:write', 'script:review'], dataScope: { type: 'all' }, }, ); expect(sod.status).toBe(403); expect(sod.body.code).toBe(ErrorCode.SOD_VIOLATION); expect(sod.body.details.conflicts[0]?.pairId).toBe('script-write-review'); const specialInNormal = await callJson<{ code: number }>( created.app, 'POST', '/roles', body.accessToken, { name: `${ROLE_NAME_PREFIX}特殊权限错位`, permissions: ['delivery:export'], dataScope: { type: 'all' }, }, ); expect(specialInNormal.status).toBe(400); expect(specialInNormal.body.code).toBe(ErrorCode.PARAM_INVALID); }); it('PATCH /roles:保存权限返回影响用户数、写 outbox 审计事件;系统角色仅显示名可改', async () => { const { body } = await login(created.app, manager.account); const role = await createTestRole({ name: '待编辑', permissions: ['script:read'], dataScope: { type: 'department' }, }); const boundUser = await createFixtureUserWithRoleId('role-edit', role.id); const patched = await callJson<{ role: { permissions: string[]; specialPermissions: string[]; dataScope: { type: string }; status: string; }; affectedUserCount: number; warnings: string[]; }>(created.app, 'PATCH', `/roles/${role.id}`, body.accessToken, { permissions: ['script:read', 'user:view'], specialPermissions: ['delivery:export'], dataScope: { type: 'owned_customers' }, }); expect(patched.status).toBe(200); expect(patched.body.role.permissions).toEqual(['script:read', 'user:view']); expect(patched.body.role.specialPermissions).toEqual(['delivery:export']); expect(patched.body.role.dataScope).toEqual({ type: 'owned_customers' }); expect(patched.body.affectedUserCount).toBe(1); const outbox = await prisma.outboxEvent.findFirst({ where: { aggregateId: role.id, eventType: 'role.permissions.changed' }, orderBy: { occurredAt: 'desc' }, }); expect(outbox).not.toBeNull(); const payload = outbox?.payloadJson as { roleId: string; affectedUserIds: string[]; changes: { permissions: { added: string[]; removed: string[] } }; }; expect(payload.roleId).toBe(role.id); expect(payload.affectedUserIds).toEqual([boundUser.id]); expect(payload.changes.permissions.added).toEqual(['user:view']); // 系统角色保护:改权限 40903,仅显示名可改 const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }); const protectedUpdate = await callJson<{ code: number }>( created.app, 'PATCH', `/roles/${superAdminRole.id}`, body.accessToken, { permissions: ['user:view'] }, ); expect(protectedUpdate.status).toBe(409); expect(protectedUpdate.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); const renamed = await callJson<{ role: { name: string } }>( created.app, 'PATCH', `/roles/${superAdminRole.id}`, body.accessToken, { name: '超级管理员' }, ); expect(renamed.status).toBe(200); expect(renamed.body.role.name).toBe('超级管理员'); }); it('DELETE /roles:系统角色 / 有绑定用户拒绝(40903),无绑定用户删除成功', async () => { const { body } = await login(created.app, manager.account); const superAdminRole = await prisma.role.findUniqueOrThrow({ where: { code: 'super_admin' } }); const systemDelete = await callJson<{ code: number }>( created.app, 'DELETE', `/roles/${superAdminRole.id}`, body.accessToken, ); expect(systemDelete.status).toBe(409); expect(systemDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); const bound = await createTestRole({ name: '有绑定' }); await createFixtureUserWithRoleId('role-bound', bound.id); const boundDelete = await callJson<{ code: number; details: { userCount: number } }>( created.app, 'DELETE', `/roles/${bound.id}`, body.accessToken, ); expect(boundDelete.status).toBe(409); expect(boundDelete.body.code).toBe(ErrorCode.STATE_NOT_ALLOWED); expect(boundDelete.body.details.userCount).toBe(1); const free = await createTestRole({ name: '无绑定' }); const freeDelete = await created.app.inject({ method: 'DELETE', url: `${API}/roles/${free.id}`, headers: { authorization: `Bearer ${body.accessToken}` }, }); expect(freeDelete.statusCode).toBe(204); const gone = await prisma.role.findUnique({ where: { id: free.id } }); expect(gone).toBeNull(); }); it('角色停用后,绑定用户下一次请求立即失效(40301)', async () => { const { body } = await login(created.app, manager.account); const role = await createTestRole({ name: '待停用', permissions: ['user:view'] }); const user = await createFixtureUserWithRoleId('role-disable', role.id); const userLogin = await login(created.app, user.account); expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200); const disabled = await callJson<{ role: { status: string }; affectedUserCount: number }>( created.app, 'PATCH', `/roles/${role.id}`, body.accessToken, { status: 'disabled', reason: '集成测试停用' }, ); expect(disabled.status).toBe(200); expect(disabled.body.role.status).toBe('disabled'); expect(disabled.body.affectedUserCount).toBe(1); expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(403); const enabled = await callJson<{ role: { status: string } }>( created.app, 'PATCH', `/roles/${role.id}`, body.accessToken, { status: 'enabled' }, ); expect(enabled.body.role.status).toBe('active'); // 再启用后同一 access token 恢复权限(每请求解析,无缓存) expect(await authed(created.app, 'GET', '/users', userLogin.body.accessToken)).toBe(200); }); it('/me:dataScopes 多角色并集;超管固定 all;A3 接入真实客户目录', async () => { const adminLogin = await login(created.app, manager.account); expect(adminLogin.body.me.dataScopes).toEqual([{ type: 'all' }]); const adminMe = await callJson(created.app, 'GET', '/me', adminLogin.body.accessToken); expect(adminMe.body.dataScopes).toEqual([{ type: 'all' }]); const ownedRole = await createTestRole({ name: '负责客户范围', permissions: ['user:view'], dataScope: { type: 'owned_customers' }, }); const deptRole = await createTestRole({ name: '部门范围', permissions: ['customer:read'], dataScope: { type: 'department' }, }); const user = await createFixtureUserWithRoleId('me-scopes', ownedRole.id); await prisma.userRole.create({ data: { userId: user.id, roleId: deptRole.id } }); const userLogin = await login(created.app, user.account); expect(userLogin.status).toBe(200); const scopes = userLogin.body.me.dataScopes; expect(scopes.map((scope) => scope.type)).toEqual(['department', 'owned_customers']); for (const scope of scopes) { expect(Array.isArray(scope.customerIds)).toBe(true); } // A3 起 CustomerDirectory 返回真实客户;本用例用户没有负责 / 创建的客户 expect(scopes.find((scope) => scope.type === 'owned_customers')?.customerIds).toEqual([]); }); });