Files
lisglosips/apps/api/src/modules/users-roles-delete.e2e.spec.ts
T

290 lines
11 KiB
TypeScript

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<string, CurrentUser>();
async findCurrentUserById(userId: string): Promise<CurrentUser | null> {
return this.users.get(userId) ?? null;
}
}
class MemoryAuditRepository implements AuditRepository {
entries: AuditEntryInput[] = [];
async write(input: AuditEntryInput): Promise<void> {
this.entries.push(input);
}
}
class MemoryRolesRepository implements RolesRepository {
private readonly roles = new Map<string, RoleSummary>();
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<RoleSummary[]> {
return [...this.roles.values()];
}
async listPermissions(): Promise<PermissionSummary[]> {
return this.permissions;
}
async createRole(input: CreateRoleInput): Promise<RoleSummary> {
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<RoleSummary> {
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<RoleSummary> {
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<string, UserSummary>();
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<UserSummary[]> {
return [...this.users.values()];
}
async create(input: CreateUserInput): Promise<UserSummary> {
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<UserSummary> {
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<UserSummary> {
return this.users.get(userId) ?? this.summary({ id: userId, username: 'missing', displayName: 'Missing' });
}
async softDelete(userId: string): Promise<UserSummary> {
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<NestFastifyApplication>(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);
});
});