290 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|