feat:添加管理员后台功能 #17

Merged
moyin merged 8 commits from jianuo/whale-town-end:feat_2 into main 2025-12-22 14:56:14 +08:00
3 changed files with 699 additions and 0 deletions
Showing only changes of commit 43c9cbc863 - Show all commits

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

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