Files
lingxi/apps/api/src/modules/identity/identity.services.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

400 lines
13 KiB
TypeScript

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: '内置' },
]);
});
});