test:添加管理后台的单元测试

This commit is contained in:
jianuo
2025-12-19 23:18:57 +08:00
parent a4a3a60db7
commit 43c9cbc863
3 changed files with 699 additions and 0 deletions

View 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,
});
});
});
});

View 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);
});
});