feat(identity): 完成 A1 身份认证与用户管理全栈落地
ci / 质量门禁 (push) Canceled after 0s

背景:
- 开发计划 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
This commit is contained in:
Stev_Wang committed 2026-11-03 00:03:33 +08:00
1 parent f0d218bd99
commit 4d9eb980af
120 files changed
+13112 -314

No files matched your search

@@ -0,0 +1,238 @@
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);
});
});