背景: - 开发计划 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:
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user