import 'reflect-metadata'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { Test, type TestingModule } from '@nestjs/testing'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; import request from 'supertest'; import { signAccessToken, type PermissionKey } from '@lisglosips/auth'; import { AUDIT_REPOSITORY, type AuditEntryInput, type AuditRepository } from './audit/audit.repository.js'; import { IDENTITY_REPOSITORY, type IdentityRepository } from './security/identity.repository.js'; import type { CurrentUser } from './security/security.metadata.js'; import { ROLES_REPOSITORY, type CreateRoleInput, type PermissionSummary, type RoleSummary, type RolesRepository, type UpdateRoleInput } from './roles/roles.repository.js'; import { USERS_REPOSITORY, type CreateUserInput, type UpdateUserInput, type UserSummary, type UsersRepository } from './users/users.repository.js'; class MemoryIdentityRepository implements IdentityRepository { users = new Map(); async findCurrentUserById(userId: string): Promise { return this.users.get(userId) ?? null; } } class MemoryAuditRepository implements AuditRepository { entries: AuditEntryInput[] = []; async write(input: AuditEntryInput): Promise { this.entries.push(input); } } class MemoryRolesRepository implements RolesRepository { private readonly roles = new Map(); private readonly permissions: PermissionSummary[] = [{ id: 'users.manage', module: 'users', action: 'manage', description: null }]; constructor() { this.roles.set('rol_builtin', this.summary({ id: 'rol_builtin', name: '系统管理员', builtIn: true, userCount: 1 })); this.roles.set('rol_used', this.summary({ id: 'rol_used', name: '运营', userCount: 1 })); this.roles.set('rol_blocked', this.summary({ id: 'rol_blocked', name: '仍在使用', userCount: 1 })); this.roles.set('rol_empty', this.summary({ id: 'rol_empty', name: '空角色', userCount: 0 })); } async listRoles(): Promise { return [...this.roles.values()]; } async listPermissions(): Promise { return this.permissions; } async createRole(input: CreateRoleInput): Promise { const role = this.summary({ id: 'rol_created', name: input.name, description: input.description ?? null }); this.roles.set(role.id, role); return role; } async updateRole(roleId: string, input: UpdateRoleInput): Promise { const current = this.roles.get(roleId) ?? this.summary({ id: roleId, name: 'Missing Role' }); const role: RoleSummary = { ...current, name: input.name ?? current.name, description: input.description === undefined ? current.description : input.description, status: input.status ?? current.status, permissionIds: input.permissionIds ?? current.permissionIds, updatedAt: new Date('2026-06-24T08:00:00.000Z') }; this.roles.set(role.id, role); return role; } async softDeleteRole(roleId: string): Promise { const role = this.roles.get(roleId); if (!role) { throw new BadRequestException({ code: 'ROLE_NOT_FOUND', message: 'Role not found.' }); } if (role.builtIn) { throw new ForbiddenException({ code: 'BUILT_IN_ROLE_PROTECTED', message: 'Built-in roles are protected.' }); } if (role.userCount > 0) { throw new BadRequestException({ code: 'ROLE_HAS_USERS', message: 'Role with active users cannot be deleted.' }); } const deleted: RoleSummary = { ...role, status: 'DISABLED', updatedAt: new Date('2026-06-24T08:00:00.000Z') }; this.roles.delete(roleId); return deleted; } decrementUserCount(roleIds: string[]) { for (const roleId of roleIds) { const role = this.roles.get(roleId); if (role) { this.roles.set(roleId, { ...role, userCount: Math.max(0, role.userCount - 1) }); } } } private summary(input: { id: string; name: string; description?: string | null; builtIn?: boolean; userCount?: number }): RoleSummary { return { id: input.id, name: input.name, description: input.description ?? null, builtIn: input.builtIn ?? false, status: 'ENABLED', permissionIds: ['users.manage'], userCount: input.userCount ?? 0, createdAt: new Date('2026-06-24T07:00:00.000Z'), updatedAt: new Date('2026-06-24T07:00:00.000Z') }; } } class MemoryUsersRepository implements UsersRepository { private readonly users = new Map(); constructor(private readonly roles: MemoryRolesRepository) { this.users.set('usr_seed', this.summary({ id: 'usr_seed', username: 'seed', displayName: 'Seed User', roleIds: ['rol_used'], roles: ['运营'] })); } async list(): Promise { return [...this.users.values()]; } async create(input: CreateUserInput): Promise { const user = this.summary({ id: 'usr_created', username: input.username, displayName: input.displayName, roleIds: input.roleIds }); this.users.set(user.id, user); return user; } async update(userId: string, input: UpdateUserInput): Promise { const current = this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' }); const updated: UserSummary = { ...current, displayName: input.displayName ?? current.displayName, phone: input.phone === undefined ? current.phone : input.phone, email: input.email === undefined ? current.email : input.email, status: input.status ?? current.status, roleIds: input.roleIds ?? current.roleIds, updatedAt: new Date('2026-06-24T08:00:00.000Z') }; this.users.set(userId, updated); return updated; } async resetPassword(userId: string): Promise { return this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' }); } async softDelete(userId: string): Promise { const user = this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' }); const deleted: UserSummary = { ...user, status: 'DISABLED', roleIds: [], roles: [], updatedAt: new Date('2026-06-24T08:00:00.000Z') }; this.users.delete(userId); this.roles.decrementUserCount(user.roleIds); return deleted; } private summary(input: { id: string; username: string; displayName: string; roleIds?: string[]; roles?: string[] }): UserSummary { return { id: input.id, username: input.username, displayName: input.displayName, phone: null, email: null, status: 'ENABLED', requirePasswordChange: false, lastLoginAt: null, roles: input.roles ?? [], roleIds: input.roleIds ?? [], createdAt: new Date('2026-06-24T07:00:00.000Z'), updatedAt: new Date('2026-06-24T07:00:00.000Z') }; } } describe('S40 users and roles delete API', () => { let app: NestFastifyApplication; let audit: MemoryAuditRepository; const tokenFor = (userId: string) => signAccessToken( { sub: userId, username: userId, roles: ['test'], typ: 'access' }, { secret: 'test-only-access-token-secret-min-32-bytes', issuer: 'lisglosips-api', audience: 'lisglosips-web', ttlSeconds: 900 } ); beforeAll(async () => { process.env.DATABASE_URL = 'mysql://lisglosips_app@127.0.0.1:3306/lisglosips'; process.env.REDIS_URL = 'redis://127.0.0.1:6379/0'; process.env.LISGLOSIPS_LOG_LEVEL = 'silent'; process.env.AUTH_ACCESS_TOKEN_SECRET = 'test-only-access-token-secret-min-32-bytes'; const identities = new MemoryIdentityRepository(); audit = new MemoryAuditRepository(); const roles = new MemoryRolesRepository(); identities.users.set('usr_ops', { id: 'usr_ops', username: 'ops', roles: ['运营管理员'], permissions: ['users.view', 'users.manage', 'roles.view', 'roles.manage'] as PermissionKey[] }); identities.users.set('usr_viewer', { id: 'usr_viewer', username: 'viewer', roles: ['只读'], permissions: ['users.view', 'roles.view'] as PermissionKey[] }); const { AppModule } = await import('./app.module.js'); const moduleFixture: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) .overrideProvider(IDENTITY_REPOSITORY) .useValue(identities) .overrideProvider(AUDIT_REPOSITORY) .useValue(audit) .overrideProvider(ROLES_REPOSITORY) .useValue(roles) .overrideProvider(USERS_REPOSITORY) .useValue(new MemoryUsersRepository(roles)) .compile(); app = moduleFixture.createNestApplication(new FastifyAdapter({ logger: false })); app.setGlobalPrefix('api/v2'); await app.init(); await app.getHttpAdapter().getInstance().ready(); }, 30000); afterAll(async () => { await app?.close(); }); it('rejects delete operations without manage permissions', async () => { await request(app.getHttpServer()).delete('/api/v2/users/usr_seed').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(403); await request(app.getHttpServer()).delete('/api/v2/roles/rol_empty').set('Authorization', `Bearer ${tokenFor('usr_viewer')}`).expect(403); }); it('soft deletes users with an audit entry', async () => { await request(app.getHttpServer()) .delete('/api/v2/users/usr_seed') .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) .expect(200) .expect((response) => { expect(response.body).toMatchObject({ id: 'usr_seed', status: 'DISABLED', roleIds: [] }); }); expect(audit.entries.some((entry) => entry.module === 'users' && entry.action === 'delete' && entry.objectId === 'usr_seed')).toBe(true); }); it('protects built-in and still-used roles, then deletes an empty custom role with audit', async () => { await request(app.getHttpServer()).delete('/api/v2/roles/rol_builtin').set('Authorization', `Bearer ${tokenFor('usr_ops')}`).expect(403); await request(app.getHttpServer()) .delete('/api/v2/roles/rol_blocked') .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) .expect(400) .expect((response) => { expect(response.body).toMatchObject({ code: 'ROLE_HAS_USERS' }); }); await request(app.getHttpServer()) .delete('/api/v2/roles/rol_empty') .set('Authorization', `Bearer ${tokenFor('usr_ops')}`) .expect(200) .expect((response) => { expect(response.body).toMatchObject({ id: 'rol_empty', status: 'DISABLED' }); }); expect(audit.entries.some((entry) => entry.module === 'roles' && entry.action === 'delete' && entry.objectId === 'rol_empty')).toBe(true); }); });