feat:添加管理员后台功能 #17
159
src/business/admin/admin.service.spec.ts
Normal file
159
src/business/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminCoreService } from '../../core/admin_core/admin_core.service';
|
||||
import { LogManagementService } from '../../core/utils/logger/log_management.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
|
||||
describe('AdminService', () => {
|
||||
let service: AdminService;
|
||||
|
||||
const adminCoreServiceMock: Pick<AdminCoreService, 'login' | 'resetUserPassword'> = {
|
||||
login: jest.fn(),
|
||||
resetUserPassword: jest.fn(),
|
||||
};
|
||||
|
||||
const usersServiceMock = {
|
||||
findAll: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const logManagementServiceMock: Pick<LogManagementService, 'getRuntimeLogTail' | 'getLogDirAbsolutePath'> = {
|
||||
getRuntimeLogTail: jest.fn(),
|
||||
getLogDirAbsolutePath: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
service = new AdminService(
|
||||
adminCoreServiceMock as unknown as AdminCoreService,
|
||||
usersServiceMock as any,
|
||||
logManagementServiceMock as unknown as LogManagementService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should login admin successfully', async () => {
|
||||
(adminCoreServiceMock.login as jest.Mock).mockResolvedValue({
|
||||
admin: { id: '1', username: 'admin', nickname: '管理员', role: 9 },
|
||||
access_token: 'token',
|
||||
expires_at: 123,
|
||||
});
|
||||
|
||||
const res = await service.login('admin', 'Admin123456');
|
||||
|
||||
expect(adminCoreServiceMock.login).toHaveBeenCalledWith({ identifier: 'admin', password: 'Admin123456' });
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data?.admin?.role).toBe(9);
|
||||
expect(res.message).toBe('管理员登录成功');
|
||||
});
|
||||
|
||||
it('should handle login failure', async () => {
|
||||
(adminCoreServiceMock.login as jest.Mock).mockRejectedValue(new Error('密码错误'));
|
||||
|
||||
const res = await service.login('admin', 'bad');
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error_code).toBe('ADMIN_LOGIN_FAILED');
|
||||
expect(res.message).toBe('密码错误');
|
||||
});
|
||||
|
||||
it('should handle non-Error login failure', async () => {
|
||||
(adminCoreServiceMock.login as jest.Mock).mockRejectedValue('boom');
|
||||
|
||||
const res = await service.login('admin', 'bad');
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
expect(res.error_code).toBe('ADMIN_LOGIN_FAILED');
|
||||
expect(res.message).toBe('管理员登录失败');
|
||||
});
|
||||
|
||||
it('should list users with pagination', async () => {
|
||||
const user = {
|
||||
id: BigInt(1),
|
||||
username: 'u1',
|
||||
nickname: 'U1',
|
||||
email: 'u1@test.com',
|
||||
email_verified: true,
|
||||
phone: null,
|
||||
avatar_url: null,
|
||||
role: 1,
|
||||
created_at: new Date('2025-01-01T00:00:00Z'),
|
||||
updated_at: new Date('2025-01-02T00:00:00Z'),
|
||||
} as unknown as Users;
|
||||
|
||||
usersServiceMock.findAll.mockResolvedValue([user]);
|
||||
|
||||
const res = await service.listUsers(100, 0);
|
||||
|
||||
expect(usersServiceMock.findAll).toHaveBeenCalledWith(100, 0);
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data?.users).toHaveLength(1);
|
||||
expect(res.data?.users[0]).toMatchObject({
|
||||
id: '1',
|
||||
username: 'u1',
|
||||
nickname: 'U1',
|
||||
role: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get user by id', async () => {
|
||||
const user = {
|
||||
id: BigInt(3),
|
||||
username: 'u3',
|
||||
nickname: 'U3',
|
||||
email: null,
|
||||
email_verified: false,
|
||||
phone: '123',
|
||||
avatar_url: null,
|
||||
role: 1,
|
||||
created_at: new Date('2025-01-01T00:00:00Z'),
|
||||
updated_at: new Date('2025-01-02T00:00:00Z'),
|
||||
} as unknown as Users;
|
||||
|
||||
usersServiceMock.findOne.mockResolvedValue(user);
|
||||
|
||||
const res = await service.getUser(BigInt(3));
|
||||
|
||||
expect(usersServiceMock.findOne).toHaveBeenCalledWith(BigInt(3));
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data?.user).toMatchObject({ id: '3', username: 'u3', nickname: 'U3' });
|
||||
});
|
||||
|
||||
it('should reset user password', async () => {
|
||||
usersServiceMock.findOne.mockResolvedValue({ id: BigInt(2) } as unknown as Users);
|
||||
(adminCoreServiceMock.resetUserPassword as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const res = await service.resetPassword(BigInt(2), 'NewPass1234');
|
||||
|
||||
expect(usersServiceMock.findOne).toHaveBeenCalledWith(BigInt(2));
|
||||
expect(adminCoreServiceMock.resetUserPassword).toHaveBeenCalledWith(BigInt(2), 'NewPass1234');
|
||||
expect(res).toEqual({ success: true, message: '密码重置成功' });
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when resetting password for missing user', async () => {
|
||||
usersServiceMock.findOne.mockRejectedValue(new Error('not found'));
|
||||
|
||||
await expect(service.resetPassword(BigInt(999), 'NewPass1234')).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should get runtime logs', async () => {
|
||||
(logManagementServiceMock.getRuntimeLogTail as jest.Mock).mockResolvedValue({
|
||||
file: 'dev.log',
|
||||
updated_at: '2025-01-01T00:00:00.000Z',
|
||||
lines: ['a', 'b'],
|
||||
});
|
||||
|
||||
const res = await service.getRuntimeLogs(2);
|
||||
|
||||
expect(logManagementServiceMock.getRuntimeLogTail).toHaveBeenCalledWith({ lines: 2 });
|
||||
expect(res.success).toBe(true);
|
||||
expect(res.data?.file).toBe('dev.log');
|
||||
expect(res.data?.lines).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('should expose log dir absolute path', () => {
|
||||
(logManagementServiceMock.getLogDirAbsolutePath as jest.Mock).mockReturnValue('/abs/logs');
|
||||
|
||||
expect(service.getLogDirAbsolutePath()).toBe('/abs/logs');
|
||||
expect(logManagementServiceMock.getLogDirAbsolutePath).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
459
src/core/admin_core/admin_core.service.spec.ts
Normal file
459
src/core/admin_core/admin_core.service.spec.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import * as crypto from 'crypto';
|
||||
import { AdminAuthPayload, AdminCoreService } from './admin_core.service';
|
||||
import { Users } from '../db/users/users.entity';
|
||||
|
||||
jest.mock('bcrypt', () => ({
|
||||
compare: jest.fn(),
|
||||
hash: jest.fn(),
|
||||
}));
|
||||
|
||||
type UsersServiceLike = {
|
||||
findByUsername: jest.Mock;
|
||||
findByEmail: jest.Mock;
|
||||
findAll: jest.Mock;
|
||||
update: jest.Mock;
|
||||
create: jest.Mock;
|
||||
};
|
||||
|
||||
describe('AdminCoreService', () => {
|
||||
let configService: Pick<ConfigService, 'get'>;
|
||||
let usersService: UsersServiceLike;
|
||||
let service: AdminCoreService;
|
||||
|
||||
const secret = '0123456789abcdef';
|
||||
|
||||
const signToken = (payload: AdminAuthPayload, tokenSecret: string): string => {
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const payloadPart = Buffer.from(payloadJson, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
const signature = crypto
|
||||
.createHmac('sha256', tokenSecret)
|
||||
.update(payloadPart)
|
||||
.digest('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
return `${payloadPart}.${signature}`;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
configService = {
|
||||
get: jest.fn((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
if (key === 'ADMIN_TOKEN_TTL_SECONDS') return defaultValue ?? '28800';
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'false';
|
||||
return defaultValue;
|
||||
}),
|
||||
};
|
||||
|
||||
usersService = {
|
||||
findByUsername: jest.fn(),
|
||||
findByEmail: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
update: jest.fn(),
|
||||
create: jest.fn(),
|
||||
};
|
||||
|
||||
service = new AdminCoreService(configService as ConfigService, usersService as any);
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should reject when admin does not exist', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
|
||||
await expect(service.login({ identifier: 'admin', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject non-admin user', async () => {
|
||||
usersService.findByUsername.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'user',
|
||||
nickname: 'U',
|
||||
role: 1,
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users);
|
||||
|
||||
await expect(service.login({ identifier: 'user', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject admin without password_hash', async () => {
|
||||
usersService.findByUsername.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
password_hash: null,
|
||||
} as unknown as Users);
|
||||
|
||||
await expect(service.login({ identifier: 'admin', password: 'Admin123456' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject wrong password', async () => {
|
||||
usersService.findByUsername.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users);
|
||||
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.compare.mockResolvedValue(false);
|
||||
|
||||
await expect(service.login({ identifier: 'admin', password: 'bad' })).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should login with valid credentials and generate verifiable token', async () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
usersService.findByUsername.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users);
|
||||
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.compare.mockResolvedValue(true);
|
||||
|
||||
const result = await service.login({ identifier: 'admin', password: 'Admin123456' });
|
||||
|
||||
expect(result.admin).toEqual({ id: '1', username: 'admin', nickname: '管理员', role: 9 });
|
||||
expect(result.access_token).toContain('.');
|
||||
expect(result.expires_at).toBeGreaterThan(now);
|
||||
|
||||
const payload = service.verifyToken(result.access_token);
|
||||
expect(payload).toMatchObject({ adminId: '1', username: 'admin', role: 9 });
|
||||
expect(payload.iat).toBe(now);
|
||||
expect(payload.exp).toBe(result.expires_at);
|
||||
});
|
||||
|
||||
it('should find admin by email identifier', async () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
usersService.findByEmail.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users);
|
||||
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.compare.mockResolvedValue(true);
|
||||
|
||||
const result = await service.login({ identifier: 'admin@test.com', password: 'Admin123456' });
|
||||
expect(result.admin.role).toBe(9);
|
||||
});
|
||||
|
||||
it('should find admin by phone identifier', async () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
usersService.findByEmail.mockResolvedValue(null);
|
||||
usersService.findAll.mockResolvedValue([
|
||||
{
|
||||
id: BigInt(2),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
phone: '+86 13800000000',
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users,
|
||||
]);
|
||||
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.compare.mockResolvedValue(true);
|
||||
|
||||
const result = await service.login({ identifier: '+86 13800000000', password: 'Admin123456' });
|
||||
expect(result.admin.id).toBe('2');
|
||||
});
|
||||
|
||||
it('should return phone-matched user via findUserByIdentifier (coverage for line 168)', async () => {
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
usersService.findByEmail.mockResolvedValue(null);
|
||||
usersService.findAll.mockResolvedValue([
|
||||
{
|
||||
id: BigInt(10),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
phone: '13800000000',
|
||||
} as unknown as Users,
|
||||
]);
|
||||
|
||||
const found = await (service as any).findUserByIdentifier('13800000000');
|
||||
expect(found?.id?.toString()).toBe('10');
|
||||
});
|
||||
|
||||
it('should fallback to default TTL when ADMIN_TOKEN_TTL_SECONDS is invalid', async () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
if (key === 'ADMIN_TOKEN_TTL_SECONDS') return 'not-a-number';
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'false';
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
usersService.findByUsername.mockResolvedValue({
|
||||
id: BigInt(1),
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
password_hash: 'hash',
|
||||
} as unknown as Users);
|
||||
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.compare.mockResolvedValue(true);
|
||||
|
||||
const result = await service.login({ identifier: 'admin', password: 'Admin123456' });
|
||||
expect(result.expires_at).toBe(now + 28800 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyToken', () => {
|
||||
it('should reject token when secret missing/too short', () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return 'short';
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
expect(() => service.verifyToken('a.b')).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should reject invalid token format', () => {
|
||||
expect(() => service.verifyToken('no-dot')).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject token when payload JSON cannot be parsed (but signature valid)', () => {
|
||||
const payloadPart = Buffer.from('not-json', 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
const signature = crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(payloadPart)
|
||||
.digest('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
expect(() => service.verifyToken(`${payloadPart}.${signature}`)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should accept valid signed token and return payload', () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'admin',
|
||||
role: 9,
|
||||
iat: now,
|
||||
exp: now + 1000,
|
||||
};
|
||||
|
||||
const token = signToken(payload, secret);
|
||||
expect(service.verifyToken(token)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('should reject expired token', () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'admin',
|
||||
role: 9,
|
||||
iat: now - 1000,
|
||||
exp: now - 1,
|
||||
};
|
||||
|
||||
const token = signToken(payload, secret);
|
||||
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject token with invalid signature', () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'admin',
|
||||
role: 9,
|
||||
iat: now,
|
||||
exp: now + 1000,
|
||||
};
|
||||
|
||||
const token = signToken(payload, 'different_secret_012345');
|
||||
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject token with non-admin role', () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'user',
|
||||
role: 1,
|
||||
iat: now,
|
||||
exp: now + 1000,
|
||||
};
|
||||
|
||||
const token = signToken(payload, secret);
|
||||
expect(() => service.verifyToken(token)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should reject token when signature length mismatches expected', () => {
|
||||
const now = 1735689600000;
|
||||
jest.spyOn(Date, 'now').mockReturnValue(now);
|
||||
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'admin',
|
||||
role: 9,
|
||||
iat: now,
|
||||
exp: now + 1000,
|
||||
};
|
||||
|
||||
// Valid payloadPart, but deliberately wrong signature length
|
||||
const payloadJson = JSON.stringify(payload);
|
||||
const payloadPart = Buffer.from(payloadJson, 'utf8')
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
|
||||
expect(() => service.verifyToken(`${payloadPart}.x`)).toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetUserPassword', () => {
|
||||
it('should update user password_hash when password is strong', async () => {
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.hash.mockResolvedValue('hashed');
|
||||
usersService.update.mockResolvedValue({} as any);
|
||||
|
||||
await service.resetUserPassword(BigInt(5), 'NewPass1234');
|
||||
|
||||
expect(usersService.update).toHaveBeenCalledWith(BigInt(5), { password_hash: 'hashed' });
|
||||
});
|
||||
|
||||
it('should reject weak password', async () => {
|
||||
await expect(service.resetUserPassword(BigInt(5), 'short')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should validate password strength directly (letters + numbers, 8+)', () => {
|
||||
expect(() => (service as any).validatePasswordStrength('12345678')).toThrow(BadRequestException);
|
||||
expect(() => (service as any).validatePasswordStrength('abcdefgh')).toThrow(BadRequestException);
|
||||
expect(() => (service as any).validatePasswordStrength('Abcdef12')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject too-long password (>128)', () => {
|
||||
const long = `Abc1${'x'.repeat(200)}`;
|
||||
expect(() => (service as any).validatePasswordStrength(long)).toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bootstrapAdminIfEnabled', () => {
|
||||
it('should do nothing when bootstrap disabled', async () => {
|
||||
await service.onModuleInit();
|
||||
expect(usersService.findByUsername).not.toHaveBeenCalled();
|
||||
expect(usersService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip when enabled but missing username/password', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||
if (key === 'ADMIN_USERNAME') return undefined;
|
||||
if (key === 'ADMIN_PASSWORD') return undefined;
|
||||
if (key === 'ADMIN_NICKNAME') return defaultValue ?? '管理员';
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(usersService.findByUsername).not.toHaveBeenCalled();
|
||||
expect(usersService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip when existing user already present', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||
if (key === 'ADMIN_USERNAME') return 'admin';
|
||||
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
|
||||
if (key === 'ADMIN_NICKNAME') return '管理员';
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
usersService.findByUsername.mockResolvedValue({ id: BigInt(1), username: 'admin', role: 9 } as any);
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(usersService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip and warn when existing user has same username but non-admin role', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||
if (key === 'ADMIN_USERNAME') return 'admin';
|
||||
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
|
||||
if (key === 'ADMIN_NICKNAME') return '管理员';
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
usersService.findByUsername.mockResolvedValue({ id: BigInt(1), username: 'admin', role: 1 } as any);
|
||||
|
||||
await service.onModuleInit();
|
||||
expect(usersService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create admin user when enabled and not existing', async () => {
|
||||
(configService.get as jest.Mock).mockImplementation((key: string, defaultValue?: any) => {
|
||||
if (key === 'ADMIN_BOOTSTRAP_ENABLED') return 'true';
|
||||
if (key === 'ADMIN_USERNAME') return 'admin';
|
||||
if (key === 'ADMIN_PASSWORD') return 'Admin123456';
|
||||
if (key === 'ADMIN_NICKNAME') return '管理员';
|
||||
if (key === 'ADMIN_TOKEN_SECRET') return secret;
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
usersService.findByUsername.mockResolvedValue(null);
|
||||
const bcryptAny = bcrypt as any;
|
||||
bcryptAny.hash.mockResolvedValue('hashed');
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(usersService.create).toHaveBeenCalledWith({
|
||||
username: 'admin',
|
||||
password_hash: 'hashed',
|
||||
nickname: '管理员',
|
||||
role: 9,
|
||||
email_verified: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
81
src/core/guards/admin.guard.spec.ts
Normal file
81
src/core/guards/admin.guard.spec.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { AdminCoreService, AdminAuthPayload } from '../admin_core/admin_core.service';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
|
||||
describe('AdminGuard', () => {
|
||||
const payload: AdminAuthPayload = {
|
||||
adminId: '1',
|
||||
username: 'admin',
|
||||
role: 9,
|
||||
iat: 1,
|
||||
exp: 2,
|
||||
};
|
||||
|
||||
const adminCoreServiceMock: Pick<AdminCoreService, 'verifyToken'> = {
|
||||
verifyToken: jest.fn(),
|
||||
};
|
||||
|
||||
const makeContext = (authorization?: any) => {
|
||||
const req: any = { headers: {} };
|
||||
if (authorization !== undefined) {
|
||||
req.headers['authorization'] = authorization;
|
||||
}
|
||||
|
||||
const ctx: Partial<ExecutionContext> = {
|
||||
switchToHttp: () => ({
|
||||
getRequest: () => req,
|
||||
getResponse: () => ({} as any),
|
||||
getNext: () => ({} as any),
|
||||
}),
|
||||
};
|
||||
|
||||
return { ctx: ctx as ExecutionContext, req };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should allow access with valid admin token', () => {
|
||||
(adminCoreServiceMock.verifyToken as jest.Mock).mockReturnValue(payload);
|
||||
|
||||
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||
const { ctx, req } = makeContext('Bearer valid');
|
||||
|
||||
expect(guard.canActivate(ctx)).toBe(true);
|
||||
expect(adminCoreServiceMock.verifyToken).toHaveBeenCalledWith('valid');
|
||||
expect(req.admin).toEqual(payload);
|
||||
});
|
||||
|
||||
it('should deny access without token', () => {
|
||||
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||
const { ctx } = makeContext(undefined);
|
||||
|
||||
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should deny access with invalid Authorization format', () => {
|
||||
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||
const { ctx } = makeContext('InvalidFormat');
|
||||
|
||||
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should deny access when verifyToken throws (invalid/expired)', () => {
|
||||
(adminCoreServiceMock.verifyToken as jest.Mock).mockImplementation(() => {
|
||||
throw new UnauthorizedException('Token已过期');
|
||||
});
|
||||
|
||||
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||
const { ctx } = makeContext('Bearer bad');
|
||||
|
||||
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should deny access when Authorization header is an array', () => {
|
||||
const guard = new AdminGuard(adminCoreServiceMock as AdminCoreService);
|
||||
const { ctx } = makeContext(['Bearer token']);
|
||||
|
||||
expect(() => guard.canActivate(ctx)).toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user