test:添加管理后台的单元测试
This commit is contained in:
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