背景: - 开发计划 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,399 @@
|
||||
import { ErrorCode } from '@lingxi/shared';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AppException } from '../../common/errors/app-exception';
|
||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||
import { DepartmentsService } from './departments.service';
|
||||
import { LoginFailureService } from './login-failure.service';
|
||||
import { MeService } from './me.service';
|
||||
import { PasswordService } from './password.service';
|
||||
import { RolesService } from './roles.service';
|
||||
import { maskPhone, toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
function userFixture(overrides: Partial<UserWithRelations> = {}): UserWithRelations {
|
||||
const base: UserWithRelations = {
|
||||
id: 'u1',
|
||||
account: 'chen.mo',
|
||||
email: 'chen.mo@lingxi.cn',
|
||||
name: '陈默',
|
||||
phone: '13866202046',
|
||||
passwordHash: 'hash',
|
||||
departmentId: 'g1',
|
||||
jobTitle: '主管',
|
||||
status: 'ACTIVE',
|
||||
mustChangePassword: false,
|
||||
lastLoginAt: new Date('2026-11-02T01:41:00.000Z'),
|
||||
lastLoginIp: '192.168.12.46',
|
||||
disabledAt: null,
|
||||
remark: null,
|
||||
createdBy: null,
|
||||
createdAt: new Date('2026-09-30T10:40:00.000Z'),
|
||||
updatedAt: new Date('2026-11-01T08:22:00.000Z'),
|
||||
department: {
|
||||
id: 'g1',
|
||||
code: 'content-g1',
|
||||
name: '素材一组',
|
||||
parentId: 'd1',
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
parent: {
|
||||
id: 'd1',
|
||||
code: 'content',
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
roles: [
|
||||
{
|
||||
userId: 'u1',
|
||||
roleId: 'r1',
|
||||
createdAt: new Date(),
|
||||
role: {
|
||||
id: 'r1',
|
||||
code: 'material_operator',
|
||||
name: '素材运营',
|
||||
description: '素材生产',
|
||||
isSystem: false,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: 'u1',
|
||||
roleId: 'r2',
|
||||
createdAt: new Date(),
|
||||
role: {
|
||||
id: 'r2',
|
||||
code: 'account_owner',
|
||||
name: '客户负责人',
|
||||
description: '',
|
||||
isSystem: false,
|
||||
status: 'DISABLED',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||
return promise.then(
|
||||
() => expect.unreachable('应抛出异常'),
|
||||
(error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AppException);
|
||||
expect((error as AppException).code).toBe(code);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('user.mapper', () => {
|
||||
it('手机号脱敏与部门 / 小组拆分', () => {
|
||||
expect(maskPhone('13866202046')).toBe('138****2046');
|
||||
const summary = toUserSummary(userFixture());
|
||||
expect(summary.departmentId).toBe('d1');
|
||||
expect(summary.departmentName).toBe('内容生产中心');
|
||||
expect(summary.groupId).toBe('g1');
|
||||
expect(summary.groupName).toBe('素材一组');
|
||||
expect(summary.status).toBe('active');
|
||||
expect(summary.roles).toHaveLength(1);
|
||||
expect(summary.roles[0]?.code).toBe('material_operator');
|
||||
expect(summary.lastLoginAt).toBe('2026-11-02T01:41:00.000Z');
|
||||
});
|
||||
|
||||
it('一级部门直属:groupId 为空;详情按权限决定是否给全号', () => {
|
||||
const direct = toUserSummary(
|
||||
userFixture({
|
||||
department: {
|
||||
...userFixture().department,
|
||||
id: 'd1',
|
||||
name: '平台技术部',
|
||||
parentId: null,
|
||||
parent: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(direct.groupId).toBeNull();
|
||||
expect(direct.departmentName).toBe('平台技术部');
|
||||
|
||||
const withPhone = toUserDetail(userFixture(), true);
|
||||
const withoutPhone = toUserDetail(userFixture(), false);
|
||||
expect(withPhone.phone).toBe('13866202046');
|
||||
expect(withoutPhone.phone).toBeUndefined();
|
||||
expect(withPhone.mustChangePassword).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PasswordService', () => {
|
||||
it('哈希后可校验,错误口令与非法哈希返回 false', async () => {
|
||||
const service = new PasswordService();
|
||||
const hash = await service.hash('lingxi2026');
|
||||
await expect(service.verify(hash, 'lingxi2026')).resolves.toBe(true);
|
||||
await expect(service.verify(hash, 'wrong-pass1')).resolves.toBe(false);
|
||||
await expect(service.verify('not-a-hash', 'x')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LoginFailureService', () => {
|
||||
function fakeRedis(): {
|
||||
service: LoginFailureService;
|
||||
store: Map<string, number>;
|
||||
del: () => void;
|
||||
} {
|
||||
const store = new Map<string, number>();
|
||||
const client = {
|
||||
status: 'ready',
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn((key: string) => Promise.resolve(store.has(key) ? String(store.get(key)) : null)),
|
||||
del: vi.fn((key: string) => {
|
||||
store.delete(key);
|
||||
return Promise.resolve(1);
|
||||
}),
|
||||
multi: vi.fn(() => {
|
||||
let key = '';
|
||||
const api = {
|
||||
incr: (target: string) => {
|
||||
key = target;
|
||||
store.set(key, (store.get(key) ?? 0) + 1);
|
||||
return api;
|
||||
},
|
||||
expire: () => api,
|
||||
exec: () => Promise.resolve([[null, store.get(key) ?? 0]]),
|
||||
};
|
||||
return api;
|
||||
}),
|
||||
};
|
||||
const service = new LoginFailureService(
|
||||
{ getClient: () => client } as never,
|
||||
{ authMaxLoginFailures: 3, authLockMinutes: 15 } as never,
|
||||
);
|
||||
return {
|
||||
service,
|
||||
store,
|
||||
del: () => {
|
||||
store.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it('累计失败达阈值后锁定,成功登录清零', async () => {
|
||||
const { service, store, del } = fakeRedis();
|
||||
await expect(service.status('Chen.Mo')).resolves.toEqual({ locked: false, remaining: 3 });
|
||||
await service.record('chen.mo');
|
||||
await service.record('chen.mo');
|
||||
await expect(service.count('chen.mo')).resolves.toBe(2);
|
||||
await expect(service.status('chen.mo')).resolves.toEqual({ locked: false, remaining: 1 });
|
||||
await service.record('chen.mo');
|
||||
await expect(service.status('chen.mo')).resolves.toEqual({ locked: true, remaining: 0 });
|
||||
await service.clear('chen.mo');
|
||||
await expect(service.count('chen.mo')).resolves.toBe(0);
|
||||
del();
|
||||
expect(store.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('UsersService', () => {
|
||||
const passwords = { hash: vi.fn().mockResolvedValue('hashed') };
|
||||
|
||||
function serviceOf(prisma: Record<string, unknown>): UsersService {
|
||||
return new UsersService(
|
||||
prisma as unknown as PrismaService,
|
||||
passwords as unknown as PasswordService,
|
||||
);
|
||||
}
|
||||
|
||||
it('详情:user:manage 才带手机号全号', async () => {
|
||||
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
|
||||
const service = serviceOf(prisma);
|
||||
const withPhone = await service.detail('u1', ['user:manage']);
|
||||
const withoutPhone = await service.detail('u1', ['user:view']);
|
||||
expect(withPhone.phone).toBe('13866202046');
|
||||
expect(withoutPhone.phone).toBeUndefined();
|
||||
});
|
||||
|
||||
it('列表:游标分页、状态默认 active、排序白名单', async () => {
|
||||
const rows = [userFixture({ id: 'u1' }), userFixture({ id: 'u2' }), userFixture({ id: 'u3' })];
|
||||
const findMany = vi.fn().mockResolvedValue(rows);
|
||||
const service = serviceOf({ user: { findMany } });
|
||||
const result = await service.list({
|
||||
limit: 2,
|
||||
status: 'active',
|
||||
sort: 'last_login',
|
||||
order: 'desc',
|
||||
});
|
||||
expect(result.items).toHaveLength(2);
|
||||
expect(result.next_cursor).toBe('u2');
|
||||
const args = findMany.mock.calls[0]?.[0] as {
|
||||
where: { status: string };
|
||||
orderBy: unknown[];
|
||||
take: number;
|
||||
};
|
||||
expect(args.where.status).toBe('ACTIVE');
|
||||
expect(args.take).toBe(3);
|
||||
expect(args.orderBy).toEqual([{ lastLoginAt: { sort: 'desc', nulls: 'last' } }, { id: 'asc' }]);
|
||||
});
|
||||
|
||||
it('停用:重复停用 40903,停用自己 42201,客户转交占位 42201', async () => {
|
||||
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
|
||||
const service = serviceOf(prisma);
|
||||
await expectRejected(service.disable('u1', {}, 'u1', []), ErrorCode.BUSINESS_RULE_VIOLATION);
|
||||
|
||||
const disabled = userFixture({ status: 'DISABLED' });
|
||||
const service2 = serviceOf({ user: { findUnique: vi.fn().mockResolvedValue(disabled) } });
|
||||
await expectRejected(service2.disable('u1', {}, 'u2', []), ErrorCode.STATE_NOT_ALLOWED);
|
||||
|
||||
expect(() => service.transferCustomers()).toThrow(AppException);
|
||||
});
|
||||
|
||||
it('停用成功会同步吊销 refresh token;启用恢复状态', async () => {
|
||||
const transaction = vi.fn().mockResolvedValue([]);
|
||||
const findUnique = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(userFixture())
|
||||
.mockResolvedValueOnce(userFixture({ status: 'DISABLED' }));
|
||||
const service = serviceOf({
|
||||
user: { findUnique, update: vi.fn().mockResolvedValue(userFixture()) },
|
||||
refreshToken: { updateMany: vi.fn() },
|
||||
$transaction: transaction,
|
||||
});
|
||||
const detail = await service.disable('u1', { reason: '离职' }, 'u9', ['user:manage']);
|
||||
expect(detail.status).toBe('disabled');
|
||||
expect(transaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
const enableService = serviceOf({
|
||||
user: {
|
||||
findUnique: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(userFixture({ status: 'DISABLED' }))
|
||||
.mockResolvedValueOnce(userFixture()),
|
||||
update: vi.fn().mockResolvedValue(userFixture()),
|
||||
},
|
||||
});
|
||||
const enabled = await enableService.enable('u1', ['user:manage']);
|
||||
expect(enabled.status).toBe('active');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DepartmentsService', () => {
|
||||
it('部门树:两级嵌套且一级成员数含下级', async () => {
|
||||
const departments = [
|
||||
{
|
||||
id: 'd1',
|
||||
code: 'content',
|
||||
name: '内容生产中心',
|
||||
parentId: null,
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date(),
|
||||
leader: { id: 'u1', name: '陈默' },
|
||||
},
|
||||
{
|
||||
id: 'g1',
|
||||
code: 'content-g1',
|
||||
name: '素材一组',
|
||||
parentId: 'd1',
|
||||
leaderUserId: null,
|
||||
sortOrder: 1,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date(),
|
||||
leader: null,
|
||||
},
|
||||
];
|
||||
const service = new DepartmentsService({
|
||||
department: { findMany: vi.fn().mockResolvedValue(departments) },
|
||||
user: { groupBy: vi.fn().mockResolvedValue([{ departmentId: 'g1', _count: { _all: 5 } }]) },
|
||||
} as unknown as PrismaService);
|
||||
const tree = await service.tree();
|
||||
expect(tree).toHaveLength(1);
|
||||
expect(tree[0]?.memberCount).toBe(5);
|
||||
expect(tree[0]?.children[0]?.name).toBe('素材一组');
|
||||
});
|
||||
|
||||
it('新建:父级不存在 40001,超过两级 42201', async () => {
|
||||
const service = new DepartmentsService({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValueOnce(null),
|
||||
},
|
||||
} as unknown as PrismaService);
|
||||
await expectRejected(
|
||||
service.create({ name: '新组', parentId: 'missing' }),
|
||||
ErrorCode.PARAM_INVALID,
|
||||
);
|
||||
|
||||
const service2 = new DepartmentsService({
|
||||
department: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
id: 'g1',
|
||||
parentId: 'd1',
|
||||
}),
|
||||
},
|
||||
} as unknown as PrismaService);
|
||||
await expectRejected(
|
||||
service2.create({ name: '三级', parentId: 'g1' }),
|
||||
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||
);
|
||||
});
|
||||
|
||||
it('重命名不存在的部门返回 40401', async () => {
|
||||
const service = new DepartmentsService({
|
||||
department: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
} as unknown as PrismaService);
|
||||
await expectRejected(service.rename('missing', '新名字'), ErrorCode.NOT_FOUND);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MeService / RolesService', () => {
|
||||
it('/me:手机号按 user:manage 返回,角色与数据范围来自 DB', async () => {
|
||||
const prisma = { user: { findUnique: vi.fn().mockResolvedValue(userFixture()) } };
|
||||
const service = new MeService(prisma as unknown as PrismaService);
|
||||
const me = await service.getMe('u1', ['user:manage']);
|
||||
expect(me.user.phone).toBe('13866202046');
|
||||
expect(me.permissions).toEqual(['user:manage']);
|
||||
expect(me.roles).toHaveLength(1);
|
||||
const meWithout = await service.getMe('u1', []);
|
||||
expect(meWithout.user.phone).toBeUndefined();
|
||||
await expectRejected(
|
||||
new MeService({
|
||||
user: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||
} as unknown as PrismaService).getMe('u1', []),
|
||||
ErrorCode.UNAUTHENTICATED,
|
||||
);
|
||||
});
|
||||
|
||||
it('角色列表只返回启用角色', async () => {
|
||||
const prisma = {
|
||||
role: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'r1',
|
||||
code: 'super_admin',
|
||||
name: '超级管理员',
|
||||
description: '内置',
|
||||
isSystem: true,
|
||||
status: 'ACTIVE',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
const roles = await new RolesService(prisma as unknown as PrismaService).list();
|
||||
expect(roles).toEqual([
|
||||
{ id: 'r1', code: 'super_admin', name: '超级管理员', isSystem: true, description: '内置' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user