Files
lingxi/apps/api/src/modules/identity/identity.int.spec.ts
T
Stev_Wang 4d9eb980af
ci / 质量门禁 (push) Canceled after 0s
feat(identity): 完成 A1 身份认证与用户管理全栈落地
背景:
- 开发计划 A1 批次要求登录/刷新/登出、用户生命周期、路由与按钮权限、
  权限三态用例;视觉已选定方案 B(主从分栏 + 分屏登录)。

改动:
- packages/shared:新增 identity 契约(User/Me/Login/Refresh/Department/Role)
  与 user:view 权限点;写接口 strict 校验。
- apps/api:新增 identity 模块与 Prisma expand 迁移(departments/users/roles/
  user_roles/role_permissions/refresh_tokens);Argon2id、JWT access+refresh
  旋转与 jti 吊销、登录失败锁定、首登强改、守卫链、/me、@Audit 上下文、幂等种子。
- apps/web:按方案 B 落地 /users、/users/new、/users/:id、分屏登录与 403;
  usePermission/<Can>、路由守卫、401 单飞刷新、停用会话兜底、Semi + token。
- e2e:新增 A1-QA-01 六类端到端用例(A1_E2E 门控)。
- openapi 与前端生成类型同步;.env.example 登记认证变量;实施报告入库。

影响面:
- 新增运行时依赖 @nestjs/jwt、argon2(计划 A1-BE-02 已批准,版本精确锁定)。
- 迁移为 expand,不改既有表;本地需 prisma migrate 后重新 seed。
- 平台导航「用户与组织」由 /platform/users 调整为 /users。

回滚方式:
- 代码整体回滚本提交;迁移为 expand,回滚代码不需删表;
- 本地如需彻底回退按实施报告 §6 处理。

验证证据:
- pnpm format:check / lint / typecheck 通过;
- pnpm test:shared 95 / api 60 / web 55;
- pnpm test:int:7 个(真实 PG/Redis);
- A1_E2E=1 playwright test e2e/a1-identity.spec.ts:6/6;
- 报告:docs/开发计划/灵犀_A1身份认证与用户管理_实施报告_V0.1.md
2026-11-03 00:03:33 +08:00

239 lines
8.5 KiB
TypeScript
Raw 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.
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<FixtureUser> {
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<TokenBody>() };
}
async function authed(
app: CreatedApiApp['app'],
method: 'GET' | 'POST' | 'PATCH',
url: string,
token: string,
payload?: unknown,
): Promise<number> {
const response = await app.inject({
method,
url: `${API}${url}`,
headers: { authorization: `Bearer ${token}` },
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
});
return response.statusCode;
}
async function setRole(userId: string, roleCode: string): Promise<void> {
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<string, unknown>[];
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<ErrorBody>();
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<ErrorBody>().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);
});
});