feat: 添加JWT令牌刷新功能

- 新增 @nestjs/jwt 和 jsonwebtoken 依赖包
- 实现 refreshAccessToken 方法支持令牌续期
- 添加 RefreshTokenDto 和 RefreshTokenResponseDto
- 新增 /auth/refresh-token 接口
- 完善令牌刷新的限流和超时控制
- 增加相关单元测试覆盖
- 优化错误处理和日志记录
This commit is contained in:
moyin
2026-01-06 16:48:24 +08:00
parent c2ecb3c1a7
commit 3733717d1f
10 changed files with 1304 additions and 74 deletions

View File

@@ -25,6 +25,7 @@
"@nestjs/common": "^11.1.9",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9",
"@nestjs/jwt": "^11.0.2",
"@nestjs/platform-express": "^10.4.20",
"@nestjs/platform-socket.io": "^10.4.20",
"@nestjs/schedule": "^4.1.2",
@@ -40,6 +41,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"ioredis": "^5.8.2",
"jsonwebtoken": "^9.0.3",
"mysql2": "^3.16.0",
"nestjs-pino": "^4.5.0",
"nodemailer": "^6.10.1",
@@ -59,6 +61,7 @@
"@nestjs/testing": "^10.4.20",
"@types/express": "^5.0.6",
"@types/jest": "^29.5.14",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.19.27",
"@types/nodemailer": "^6.4.14",
"@types/supertest": "^6.0.3",

View File

@@ -6,6 +6,7 @@
* - 用户登录、注册、密码管理
* - GitHub OAuth集成
* - 邮箱验证功能
* - JWT令牌管理和验证
*
* @author kiro-ai
* @version 1.0.0
@@ -13,22 +14,41 @@
*/
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoginController } from './controllers/login.controller';
import { LoginService } from './services/login.service';
import { LoginCoreModule } from '../../core/login_core/login_core.module';
import { ZulipCoreModule } from '../../core/zulip/zulip-core.module';
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
import { UsersModule } from '../../core/db/users/users.module';
@Module({
imports: [
LoginCoreModule,
ZulipCoreModule,
ZulipAccountsModule.forRoot(),
UsersModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => {
const expiresIn = configService.get<string>('JWT_EXPIRES_IN', '7d');
return {
secret: configService.get<string>('JWT_SECRET'),
signOptions: {
expiresIn: expiresIn as any, // JWT库支持字符串格式如 '7d'
issuer: 'whale-town',
audience: 'whale-town-users',
},
};
},
inject: [ConfigService],
}),
],
controllers: [LoginController],
providers: [
LoginService,
],
exports: [LoginService],
exports: [LoginService, JwtModule],
})
export class AuthModule {}

View File

@@ -13,6 +13,7 @@
* - POST /auth/forgot-password - 发送密码重置验证码
* - POST /auth/reset-password - 重置密码
* - PUT /auth/change-password - 修改密码
* - POST /auth/refresh-token - 刷新访问令牌
*
* @author moyin angjustinl
* @version 1.0.0
@@ -23,7 +24,7 @@ import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UseP
import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger';
import { Response } from 'express';
import { LoginService, ApiResponse, LoginResponse } from '../services/login.service';
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto } from '../dto/login.dto';
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from '../dto/login.dto';
import {
LoginResponseDto,
RegisterResponseDto,
@@ -31,7 +32,8 @@ import {
ForgotPasswordResponseDto,
CommonResponseDto,
TestModeEmailVerificationResponseDto,
SuccessEmailVerificationResponseDto
SuccessEmailVerificationResponseDto,
RefreshTokenResponseDto
} from '../dto/login_response.dto';
import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator';
import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator';
@@ -609,4 +611,107 @@ export class LoginController {
message: '限流记录已清除'
});
}
/**
* 刷新访问令牌
*
* 功能描述:
* 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期
*
* 业务逻辑:
* 1. 验证刷新令牌的有效性和格式
* 2. 检查用户状态是否正常
* 3. 生成新的JWT令牌对
* 4. 返回新的访问令牌和刷新令牌
*
* @param refreshTokenDto 刷新令牌数据
* @param res Express响应对象
* @returns 新的令牌对
*/
@ApiOperation({
summary: '刷新访问令牌',
description: '使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期。建议在访问令牌即将过期时调用此接口。'
})
@ApiBody({ type: RefreshTokenDto })
@SwaggerApiResponse({
status: 200,
description: '令牌刷新成功',
type: RefreshTokenResponseDto
})
@SwaggerApiResponse({
status: 400,
description: '请求参数错误'
})
@SwaggerApiResponse({
status: 401,
description: '刷新令牌无效或已过期'
})
@SwaggerApiResponse({
status: 404,
description: '用户不存在或已被禁用'
})
@SwaggerApiResponse({
status: 429,
description: '刷新请求过于频繁'
})
@Throttle(ThrottlePresets.REFRESH_TOKEN)
@Timeout(TimeoutPresets.NORMAL)
@Post('refresh-token')
@UsePipes(new ValidationPipe({ transform: true }))
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto, @Res() res: Response): Promise<void> {
const startTime = Date.now();
try {
this.logger.log('令牌刷新请求', {
operation: 'refreshToken',
timestamp: new Date().toISOString(),
});
const result = await this.loginService.refreshAccessToken(refreshTokenDto.refresh_token);
const duration = Date.now() - startTime;
if (result.success) {
this.logger.log('令牌刷新成功', {
operation: 'refreshToken',
duration,
timestamp: new Date().toISOString(),
});
res.status(HttpStatus.OK).json(result);
} else {
this.logger.warn('令牌刷新失败', {
operation: 'refreshToken',
error: result.message,
errorCode: result.error_code,
duration,
timestamp: new Date().toISOString(),
});
// 根据错误类型设置不同的状态码
if (result.message?.includes('令牌验证失败') || result.message?.includes('已过期')) {
res.status(HttpStatus.UNAUTHORIZED).json(result);
} else if (result.message?.includes('用户不存在')) {
res.status(HttpStatus.NOT_FOUND).json(result);
} else {
res.status(HttpStatus.BAD_REQUEST).json(result);
}
}
} catch (error) {
const duration = Date.now() - startTime;
const err = error as Error;
this.logger.error('令牌刷新异常', {
operation: 'refreshToken',
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
success: false,
message: '服务器内部错误',
error_code: 'INTERNAL_SERVER_ERROR'
});
}
}
}

View File

@@ -424,4 +424,21 @@ export class SendLoginVerificationCodeDto {
@IsNotEmpty({ message: '登录标识符不能为空' })
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
identifier: string;
}
/**
* 刷新令牌请求DTO
*/
export class RefreshTokenDto {
/**
* 刷新令牌
*/
@ApiProperty({
description: 'JWT刷新令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
minLength: 1
})
@IsString({ message: '刷新令牌必须是字符串' })
@IsNotEmpty({ message: '刷新令牌不能为空' })
refresh_token: string;
}

View File

@@ -80,17 +80,28 @@ export class LoginResponseDataDto {
user: UserInfoDto;
@ApiProperty({
description: '访问令牌',
description: 'JWT访问令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
access_token: string;
@ApiProperty({
description: '刷新令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
required: false
description: 'JWT刷新令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
refresh_token?: string;
refresh_token: string;
@ApiProperty({
description: '访问令牌过期时间(秒)',
example: 604800
})
expires_in: number;
@ApiProperty({
description: '令牌类型',
example: 'Bearer'
})
token_type: string;
@ApiProperty({
description: '是否为新用户',
@@ -392,4 +403,64 @@ export class SuccessEmailVerificationResponseDto {
required: false
})
error_code?: string;
}
/**
* 令牌刷新响应数据DTO
*/
export class RefreshTokenResponseDataDto {
@ApiProperty({
description: 'JWT访问令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
access_token: string;
@ApiProperty({
description: 'JWT刷新令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
refresh_token: string;
@ApiProperty({
description: '访问令牌过期时间(秒)',
example: 604800
})
expires_in: number;
@ApiProperty({
description: '令牌类型',
example: 'Bearer'
})
token_type: string;
}
/**
* 令牌刷新响应DTO
*/
export class RefreshTokenResponseDto {
@ApiProperty({
description: '请求是否成功',
example: true
})
success: boolean;
@ApiProperty({
description: '响应数据',
type: RefreshTokenResponseDataDto,
required: false
})
data?: RefreshTokenResponseDataDto;
@ApiProperty({
description: '响应消息',
example: '令牌刷新成功'
})
message: string;
@ApiProperty({
description: '错误代码',
example: 'TOKEN_REFRESH_FAILED',
required: false
})
error_code?: string;
}

View File

@@ -1,14 +1,43 @@
/**
* 登录业务服务测试
*
* 功能描述:
* - 测试登录相关的业务逻辑
* - 测试JWT令牌生成和验证
* - 测试令牌刷新功能
* - 测试各种异常情况处理
*
* @author kiro-ai
* @version 1.0.0
* @since 2025-01-06
*/
import { Test, TestingModule } from '@nestjs/testing';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { LoginService } from './login.service';
import { LoginCoreService } from '../../../core/login_core/login_core.service';
import { UsersService } from '../../../core/db/users/users.service';
import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service';
import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository';
import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service';
import * as jwt from 'jsonwebtoken';
// Mock jwt module
jest.mock('jsonwebtoken', () => ({
sign: jest.fn(),
verify: jest.fn(),
}));
describe('LoginService', () => {
let service: LoginService;
let loginCoreService: jest.Mocked<LoginCoreService>;
let jwtService: jest.Mocked<JwtService>;
let configService: jest.Mocked<ConfigService>;
let usersService: jest.Mocked<UsersService>;
let zulipAccountService: jest.Mocked<ZulipAccountService>;
let zulipAccountsRepository: jest.Mocked<ZulipAccountsRepository>;
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
const mockUser = {
id: BigInt(1),
@@ -26,7 +55,20 @@ describe('LoginService', () => {
updated_at: new Date()
};
const mockJwtSecret = 'test_jwt_secret_key_for_testing_32chars';
const mockAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE2NDA5OTUyMDAsImlzcyI6IndoYWxlLXRvd24iLCJhdWQiOiJ3aGFsZS10b3duLXVzZXJzIn0.test';
const mockRefreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJyZWZyZXNoIiwiaWF0IjoxNjQwOTk1MjAwLCJpc3MiOiJ3aGFsZS10b3duIiwiYXVkIjoid2hhbGUtdG93bi11c2VycyJ9.test';
beforeEach(async () => {
// Mock environment variables for Zulip
const originalEnv = process.env;
process.env = {
...originalEnv,
ZULIP_SERVER_URL: 'https://test.zulipchat.com',
ZULIP_BOT_EMAIL: 'test-bot@test.zulipchat.com',
ZULIP_BOT_API_KEY: 'test_api_key_12345',
};
const mockLoginCoreService = {
login: jest.fn(),
register: jest.fn(),
@@ -40,6 +82,36 @@ describe('LoginService', () => {
verificationCodeLogin: jest.fn(),
sendLoginVerificationCode: jest.fn(),
debugVerificationCode: jest.fn(),
deleteUser: jest.fn(),
};
const mockJwtService = {
signAsync: jest.fn(),
verifyAsync: jest.fn(),
};
const mockConfigService = {
get: jest.fn(),
};
const mockUsersService = {
findOne: jest.fn(),
};
const mockZulipAccountService = {
initializeAdminClient: jest.fn(),
createZulipAccount: jest.fn(),
linkGameAccount: jest.fn(),
};
const mockZulipAccountsRepository = {
findByGameUserId: jest.fn(),
create: jest.fn(),
deleteByGameUserId: jest.fn(),
};
const mockApiKeySecurityService = {
storeApiKey: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
@@ -49,11 +121,72 @@ describe('LoginService', () => {
provide: LoginCoreService,
useValue: mockLoginCoreService,
},
{
provide: JwtService,
useValue: mockJwtService,
},
{
provide: ConfigService,
useValue: mockConfigService,
},
{
provide: 'UsersService',
useValue: mockUsersService,
},
{
provide: ZulipAccountService,
useValue: mockZulipAccountService,
},
{
provide: 'ZulipAccountsRepository',
useValue: mockZulipAccountsRepository,
},
{
provide: ApiKeySecurityService,
useValue: mockApiKeySecurityService,
},
],
}).compile();
service = module.get<LoginService>(LoginService);
loginCoreService = module.get(LoginCoreService);
jwtService = module.get(JwtService);
configService = module.get(ConfigService);
usersService = module.get('UsersService');
zulipAccountService = module.get(ZulipAccountService);
zulipAccountsRepository = module.get('ZulipAccountsRepository');
apiKeySecurityService = module.get(ApiKeySecurityService);
// Setup default config service mocks
configService.get.mockImplementation((key: string, defaultValue?: any) => {
const config = {
'JWT_SECRET': mockJwtSecret,
'JWT_EXPIRES_IN': '7d',
};
return config[key] || defaultValue;
});
// Setup default JWT service mocks
jwtService.signAsync.mockResolvedValue(mockAccessToken);
(jwt.sign as jest.Mock).mockReturnValue(mockRefreshToken);
// Setup default Zulip mocks
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
zulipAccountService.createZulipAccount.mockResolvedValue({
success: true,
userId: 123,
email: 'test@example.com',
apiKey: 'mock_api_key'
});
zulipAccountsRepository.findByGameUserId.mockResolvedValue(null);
zulipAccountsRepository.create.mockResolvedValue({} as any);
apiKeySecurityService.storeApiKey.mockResolvedValue(undefined);
});
afterEach(() => {
jest.clearAllMocks();
// Restore original environment variables
jest.restoreAllMocks();
});
it('should be defined', () => {
@@ -61,7 +194,7 @@ describe('LoginService', () => {
});
describe('login', () => {
it('should login successfully', async () => {
it('should login successfully and return JWT tokens', async () => {
loginCoreService.login.mockResolvedValue({
user: mockUser,
isNewUser: false
@@ -74,7 +207,40 @@ describe('LoginService', () => {
expect(result.success).toBe(true);
expect(result.data?.user.username).toBe('testuser');
expect(result.data?.access_token).toBeDefined();
expect(result.data?.access_token).toBe(mockAccessToken);
expect(result.data?.refresh_token).toBe(mockRefreshToken);
expect(result.data?.expires_in).toBe(604800); // 7 days in seconds
expect(result.data?.token_type).toBe('Bearer');
expect(result.data?.is_new_user).toBe(false);
expect(result.message).toBe('登录成功');
// Verify JWT service was called correctly
expect(jwtService.signAsync).toHaveBeenCalledWith({
sub: '1',
username: 'testuser',
role: 1,
email: 'test@example.com',
type: 'access',
iat: expect.any(Number),
iss: 'whale-town',
aud: 'whale-town-users',
});
expect(jwt.sign).toHaveBeenCalledWith(
{
sub: '1',
username: 'testuser',
role: 1,
type: 'refresh',
iat: expect.any(Number),
iss: 'whale-town',
aud: 'whale-town-users',
},
mockJwtSecret,
{
expiresIn: '30d',
}
);
});
it('should handle login failure', async () => {
@@ -87,16 +253,80 @@ describe('LoginService', () => {
expect(result.success).toBe(false);
expect(result.error_code).toBe('LOGIN_FAILED');
expect(result.message).toBe('用户名或密码错误');
});
it('should handle JWT generation failure', async () => {
loginCoreService.login.mockResolvedValue({
user: mockUser,
isNewUser: false
});
jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed'));
const result = await service.login({
identifier: 'testuser',
password: 'password123'
});
expect(result.success).toBe(false);
expect(result.error_code).toBe('LOGIN_FAILED');
expect(result.message).toContain('JWT generation failed');
});
it('should handle missing JWT secret', async () => {
loginCoreService.login.mockResolvedValue({
user: mockUser,
isNewUser: false
});
configService.get.mockImplementation((key: string) => {
if (key === 'JWT_SECRET') return undefined;
if (key === 'JWT_EXPIRES_IN') return '7d';
return undefined;
});
const result = await service.login({
identifier: 'testuser',
password: 'password123'
});
expect(result.success).toBe(false);
expect(result.error_code).toBe('LOGIN_FAILED');
expect(result.message).toContain('JWT_SECRET未配置');
});
});
describe('register', () => {
it('should register successfully', async () => {
it('should register successfully with JWT tokens', async () => {
loginCoreService.register.mockResolvedValue({
user: mockUser,
isNewUser: true
});
const result = await service.register({
username: 'testuser',
password: 'password123',
nickname: '测试用户',
email: 'test@example.com'
});
expect(result.success).toBe(true);
expect(result.data?.user.username).toBe('testuser');
expect(result.data?.access_token).toBe(mockAccessToken);
expect(result.data?.refresh_token).toBe(mockRefreshToken);
expect(result.data?.expires_in).toBe(604800);
expect(result.data?.token_type).toBe('Bearer');
expect(result.data?.is_new_user).toBe(true);
expect(result.message).toBe('注册成功Zulip账号已同步创建');
});
it('should register successfully without email', async () => {
loginCoreService.register.mockResolvedValue({
user: { ...mockUser, email: null },
isNewUser: true
});
const result = await service.register({
username: 'testuser',
password: 'password123',
@@ -104,13 +334,323 @@ describe('LoginService', () => {
});
expect(result.success).toBe(true);
expect(result.data?.user.username).toBe('testuser');
expect(result.data?.is_new_user).toBe(true);
expect(result.data?.message).toBe('注册成功');
// Should not try to create Zulip account without email
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
});
it('should handle Zulip account creation failure and rollback', async () => {
loginCoreService.register.mockResolvedValue({
user: mockUser,
isNewUser: true
});
zulipAccountService.createZulipAccount.mockResolvedValue({
success: false,
error: 'Zulip creation failed'
});
loginCoreService.deleteUser.mockResolvedValue(undefined);
const result = await service.register({
username: 'testuser',
password: 'password123',
nickname: '测试用户',
email: 'test@example.com'
});
expect(result.success).toBe(false);
expect(result.message).toContain('Zulip账号创建失败');
expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockUser.id);
});
it('should handle register failure', async () => {
loginCoreService.register.mockRejectedValue(new Error('用户名已存在'));
const result = await service.register({
username: 'testuser',
password: 'password123',
nickname: '测试用户'
});
expect(result.success).toBe(false);
expect(result.error_code).toBe('REGISTER_FAILED');
expect(result.message).toBe('用户名已存在');
});
});
describe('verificationCodeLogin', () => {
it('should login with verification code successfully', async () => {
describe('verifyToken', () => {
const mockPayload = {
sub: '1',
username: 'testuser',
role: 1,
type: 'access' as const,
iat: Math.floor(Date.now() / 1000),
iss: 'whale-town',
aud: 'whale-town-users',
};
it('should verify access token successfully', async () => {
(jwt.verify as jest.Mock).mockReturnValue(mockPayload);
const result = await service.verifyToken(mockAccessToken, 'access');
expect(result).toEqual(mockPayload);
expect(jwt.verify).toHaveBeenCalledWith(
mockAccessToken,
mockJwtSecret,
{
issuer: 'whale-town',
audience: 'whale-town-users',
}
);
});
it('should verify refresh token successfully', async () => {
const refreshPayload = { ...mockPayload, type: 'refresh' as const };
(jwt.verify as jest.Mock).mockReturnValue(refreshPayload);
const result = await service.verifyToken(mockRefreshToken, 'refresh');
expect(result).toEqual(refreshPayload);
});
it('should throw error for invalid token', async () => {
(jwt.verify as jest.Mock).mockImplementation(() => {
throw new Error('invalid token');
});
await expect(service.verifyToken('invalid_token')).rejects.toThrow('令牌验证失败: invalid token');
});
it('should throw error for token type mismatch', async () => {
const refreshPayload = { ...mockPayload, type: 'refresh' as const };
(jwt.verify as jest.Mock).mockReturnValue(refreshPayload);
await expect(service.verifyToken(mockRefreshToken, 'access')).rejects.toThrow('令牌类型不匹配');
});
it('should throw error for incomplete payload', async () => {
const incompletePayload = { sub: '1', type: 'access' }; // missing username and role
(jwt.verify as jest.Mock).mockReturnValue(incompletePayload);
await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('令牌载荷数据不完整');
});
it('should throw error when JWT secret is missing', async () => {
configService.get.mockImplementation((key: string) => {
if (key === 'JWT_SECRET') return undefined;
return undefined;
});
await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('JWT_SECRET未配置');
});
});
describe('refreshAccessToken', () => {
const mockRefreshPayload = {
sub: '1',
username: 'testuser',
role: 1,
type: 'refresh' as const,
iat: Math.floor(Date.now() / 1000),
iss: 'whale-town',
aud: 'whale-town-users',
};
beforeEach(() => {
(jwt.verify as jest.Mock).mockReturnValue(mockRefreshPayload);
usersService.findOne.mockResolvedValue(mockUser);
});
it('should refresh access token successfully', async () => {
const result = await service.refreshAccessToken(mockRefreshToken);
expect(result.success).toBe(true);
expect(result.data?.access_token).toBe(mockAccessToken);
expect(result.data?.refresh_token).toBe(mockRefreshToken);
expect(result.data?.expires_in).toBe(604800);
expect(result.data?.token_type).toBe('Bearer');
expect(result.message).toBe('令牌刷新成功');
expect(jwt.verify).toHaveBeenCalledWith(
mockRefreshToken,
mockJwtSecret,
{
issuer: 'whale-town',
audience: 'whale-town-users',
}
);
expect(usersService.findOne).toHaveBeenCalledWith(BigInt(1));
});
it('should handle invalid refresh token', async () => {
(jwt.verify as jest.Mock).mockImplementation(() => {
throw new Error('invalid token');
});
const result = await service.refreshAccessToken('invalid_token');
expect(result.success).toBe(false);
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
expect(result.message).toContain('invalid token');
});
it('should handle user not found', async () => {
usersService.findOne.mockResolvedValue(null);
const result = await service.refreshAccessToken(mockRefreshToken);
expect(result.success).toBe(false);
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
expect(result.message).toBe('用户不存在或已被禁用');
});
it('should handle user service error', async () => {
usersService.findOne.mockRejectedValue(new Error('Database error'));
const result = await service.refreshAccessToken(mockRefreshToken);
expect(result.success).toBe(false);
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
expect(result.message).toContain('Database error');
});
it('should handle JWT generation error during refresh', async () => {
jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed'));
const result = await service.refreshAccessToken(mockRefreshToken);
expect(result.success).toBe(false);
expect(result.error_code).toBe('TOKEN_REFRESH_FAILED');
expect(result.message).toContain('JWT generation failed');
});
});
describe('parseExpirationTime', () => {
it('should parse seconds correctly', () => {
const result = (service as any).parseExpirationTime('30s');
expect(result).toBe(30);
});
it('should parse minutes correctly', () => {
const result = (service as any).parseExpirationTime('5m');
expect(result).toBe(300);
});
it('should parse hours correctly', () => {
const result = (service as any).parseExpirationTime('2h');
expect(result).toBe(7200);
});
it('should parse days correctly', () => {
const result = (service as any).parseExpirationTime('7d');
expect(result).toBe(604800);
});
it('should parse weeks correctly', () => {
const result = (service as any).parseExpirationTime('2w');
expect(result).toBe(1209600);
});
it('should return default for invalid format', () => {
const result = (service as any).parseExpirationTime('invalid');
expect(result).toBe(604800); // 7 days default
});
});
describe('generateTokenPair', () => {
it('should generate token pair successfully', async () => {
const result = await (service as any).generateTokenPair(mockUser);
expect(result.access_token).toBe(mockAccessToken);
expect(result.refresh_token).toBe(mockRefreshToken);
expect(result.expires_in).toBe(604800);
expect(result.token_type).toBe('Bearer');
expect(jwtService.signAsync).toHaveBeenCalledWith({
sub: '1',
username: 'testuser',
role: 1,
email: 'test@example.com',
type: 'access',
iat: expect.any(Number),
iss: 'whale-town',
aud: 'whale-town-users',
});
expect(jwt.sign).toHaveBeenCalledWith(
{
sub: '1',
username: 'testuser',
role: 1,
type: 'refresh',
iat: expect.any(Number),
iss: 'whale-town',
aud: 'whale-town-users',
},
mockJwtSecret,
{
expiresIn: '30d',
}
);
});
it('should handle missing JWT secret', async () => {
configService.get.mockImplementation((key: string) => {
if (key === 'JWT_SECRET') return undefined;
if (key === 'JWT_EXPIRES_IN') return '7d';
return undefined;
});
await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT_SECRET未配置');
});
it('should handle JWT service error', async () => {
jwtService.signAsync.mockRejectedValue(new Error('JWT service error'));
await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT service error');
});
});
describe('formatUserInfo', () => {
it('should format user info correctly', () => {
const formattedUser = (service as any).formatUserInfo(mockUser);
expect(formattedUser).toEqual({
id: '1',
username: 'testuser',
nickname: '测试用户',
email: 'test@example.com',
phone: '+8613800138000',
avatar_url: null,
role: 1,
created_at: mockUser.created_at
});
});
});
describe('other methods', () => {
it('should handle githubOAuth successfully', async () => {
loginCoreService.githubOAuth.mockResolvedValue({
user: mockUser,
isNewUser: false
});
const result = await service.githubOAuth({
github_id: '12345',
username: 'testuser',
nickname: '测试用户',
email: 'test@example.com'
});
expect(result.success).toBe(true);
expect(result.data?.access_token).toBe(mockAccessToken);
expect(result.data?.refresh_token).toBe(mockRefreshToken);
expect(result.data?.message).toBe('GitHub登录成功');
});
it('should handle verificationCodeLogin successfully', async () => {
loginCoreService.verificationCodeLogin.mockResolvedValue({
user: mockUser,
isNewUser: false
@@ -123,23 +663,74 @@ describe('LoginService', () => {
expect(result.success).toBe(true);
expect(result.data?.user.email).toBe('test@example.com');
expect(result.data?.access_token).toBe(mockAccessToken);
expect(result.data?.refresh_token).toBe(mockRefreshToken);
expect(result.data?.message).toBe('验证码登录成功');
});
it('should handle verification code login failure', async () => {
loginCoreService.verificationCodeLogin.mockRejectedValue(new Error('验证码错误'));
const result = await service.verificationCodeLogin({
identifier: 'test@example.com',
verificationCode: '999999'
it('should handle sendPasswordResetCode in test mode', async () => {
loginCoreService.sendPasswordResetCode.mockResolvedValue({
code: '123456',
isTestMode: true
});
expect(result.success).toBe(false);
expect(result.error_code).toBe('VERIFICATION_CODE_LOGIN_FAILED');
});
});
const result = await service.sendPasswordResetCode('test@example.com');
describe('sendLoginVerificationCode', () => {
it('should send login verification code successfully', async () => {
expect(result.success).toBe(false); // Test mode returns false
expect(result.data?.verification_code).toBe('123456');
expect(result.data?.is_test_mode).toBe(true);
expect(result.error_code).toBe('TEST_MODE_ONLY');
});
it('should handle resetPassword successfully', async () => {
loginCoreService.resetPassword.mockResolvedValue(undefined);
const result = await service.resetPassword({
identifier: 'test@example.com',
verificationCode: '123456',
newPassword: 'newpassword123'
});
expect(result.success).toBe(true);
expect(result.message).toBe('密码重置成功');
});
it('should handle changePassword successfully', async () => {
loginCoreService.changePassword.mockResolvedValue(undefined);
const result = await service.changePassword(
BigInt(1),
'oldpassword',
'newpassword123'
);
expect(result.success).toBe(true);
expect(result.message).toBe('密码修改成功');
});
it('should handle sendEmailVerification in test mode', async () => {
loginCoreService.sendEmailVerification.mockResolvedValue({
code: '123456',
isTestMode: true
});
const result = await service.sendEmailVerification('test@example.com');
expect(result.success).toBe(false);
expect(result.data?.verification_code).toBe('123456');
expect(result.error_code).toBe('TEST_MODE_ONLY');
});
it('should handle verifyEmailCode successfully', async () => {
loginCoreService.verifyEmailCode.mockResolvedValue(true);
const result = await service.verifyEmailCode('test@example.com', '123456');
expect(result.success).toBe(true);
expect(result.message).toBe('邮箱验证成功');
});
it('should handle sendLoginVerificationCode successfully', async () => {
loginCoreService.sendLoginVerificationCode.mockResolvedValue({
code: '123456',
isTestMode: true
@@ -151,5 +742,22 @@ describe('LoginService', () => {
expect(result.data?.verification_code).toBe('123456');
expect(result.error_code).toBe('TEST_MODE_ONLY');
});
it('should handle debugVerificationCode successfully', async () => {
const mockDebugInfo = {
email: 'test@example.com',
code: '123456',
expiresAt: new Date(),
attempts: 0
};
loginCoreService.debugVerificationCode.mockResolvedValue(mockDebugInfo);
const result = await service.debugVerificationCode('test@example.com');
expect(result.success).toBe(true);
expect(result.data).toEqual(mockDebugInfo);
expect(result.message).toBe('调试信息获取成功');
});
});
});

View File

@@ -17,12 +17,54 @@
*/
import { Injectable, Logger, Inject } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as jwt from 'jsonwebtoken';
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../../core/login_core/login_core.service';
import { Users } from '../../../core/db/users/users.entity';
import { UsersService } from '../../../core/db/users/users.service';
import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service';
import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository';
import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service';
/**
* JWT载荷接口
*/
export interface JwtPayload {
/** 用户ID */
sub: string;
/** 用户名 */
username: string;
/** 用户角色 */
role: number;
/** 邮箱 */
email?: string;
/** 令牌类型 */
type: 'access' | 'refresh';
/** 签发时间 */
iat?: number;
/** 过期时间 */
exp?: number;
/** 签发者 */
iss?: string;
/** 受众 */
aud?: string;
}
/**
* 令牌对接口
*/
export interface TokenPair {
/** 访问令牌 */
access_token: string;
/** 刷新令牌 */
refresh_token: string;
/** 访问令牌过期时间(秒) */
expires_in: number;
/** 令牌类型 */
token_type: string;
}
/**
* 登录响应数据接口
*/
@@ -38,10 +80,14 @@ export interface LoginResponse {
role: number;
created_at: Date;
};
/** 访问令牌实际应用中应生成JWT */
/** 访问令牌 */
access_token: string;
/** 刷新令牌 */
refresh_token?: string;
refresh_token: string;
/** 访问令牌过期时间(秒) */
expires_in: number;
/** 令牌类型 */
token_type: string;
/** 是否为新用户 */
is_new_user?: boolean;
/** 消息 */
@@ -72,33 +118,68 @@ export class LoginService {
@Inject('ZulipAccountsRepository')
private readonly zulipAccountsRepository: ZulipAccountsRepository,
private readonly apiKeySecurityService: ApiKeySecurityService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
@Inject('UsersService')
private readonly usersService: UsersService,
) {}
/**
* 用户登录
*
* @param loginRequest 登录请求
* @returns 登录响应
* 功能描述:
* 处理用户登录请求验证用户凭据并生成JWT令牌
*
* 业务逻辑:
* 1. 调用核心服务进行用户认证
* 2. 生成JWT访问令牌和刷新令牌
* 3. 记录登录日志和安全审计
* 4. 返回用户信息和令牌
*
* @param loginRequest 登录请求数据
* @returns Promise<ApiResponse<LoginResponse>> 登录响应
*
* @throws BadRequestException 当登录参数无效时
* @throws UnauthorizedException 当用户凭据错误时
* @throws InternalServerErrorException 当系统错误时
*/
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
const startTime = Date.now();
try {
this.logger.log(`用户登录尝试: ${loginRequest.identifier}`);
this.logger.log('用户登录尝试', {
operation: 'login',
identifier: loginRequest.identifier,
timestamp: new Date().toISOString(),
});
// 调用核心服务进行认证
// 1. 调用核心服务进行认证
const authResult = await this.loginCoreService.login(loginRequest);
// 生成访问令牌实际应用中应使用JWT
const accessToken = this.generateAccessToken(authResult.user);
// 2. 生成JWT令牌对
const tokenPair = await this.generateTokenPair(authResult.user);
// 格式化响应数据
// 3. 格式化响应数据
const response: LoginResponse = {
user: this.formatUserInfo(authResult.user),
access_token: accessToken,
access_token: tokenPair.access_token,
refresh_token: tokenPair.refresh_token,
expires_in: tokenPair.expires_in,
token_type: tokenPair.token_type,
is_new_user: authResult.isNewUser,
message: '登录成功'
};
this.logger.log(`用户登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
const duration = Date.now() - startTime;
this.logger.log('用户登录成功', {
operation: 'login',
userId: authResult.user.id.toString(),
username: authResult.user.username,
isNewUser: authResult.isNewUser,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
@@ -106,11 +187,20 @@ export class LoginService {
message: '登录成功'
};
} catch (error) {
this.logger.error(`用户登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error));
const duration = Date.now() - startTime;
const err = error as Error;
this.logger.error('用户登录失败', {
operation: 'login',
identifier: loginRequest.identifier,
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
message: error instanceof Error ? error.message : '登录失败',
message: err.message || '登录失败',
error_code: 'LOGIN_FAILED'
};
}
@@ -181,13 +271,16 @@ export class LoginService {
throw new Error(`注册失败Zulip账号创建失败 - ${err.message}`);
}
// 4. 生成访问令牌
const accessToken = this.generateAccessToken(authResult.user);
// 4. 生成JWT令牌
const tokenPair = await this.generateTokenPair(authResult.user);
// 5. 格式化响应数据
const response: LoginResponse = {
user: this.formatUserInfo(authResult.user),
access_token: accessToken,
access_token: tokenPair.access_token,
refresh_token: tokenPair.refresh_token,
expires_in: tokenPair.expires_in,
token_type: tokenPair.token_type,
is_new_user: true,
message: zulipAccountCreated ? '注册成功Zulip账号已同步创建' : '注册成功'
};
@@ -241,13 +334,16 @@ export class LoginService {
// 调用核心服务进行OAuth认证
const authResult = await this.loginCoreService.githubOAuth(oauthRequest);
// 生成访问令牌
const accessToken = this.generateAccessToken(authResult.user);
// 生成JWT令牌
const tokenPair = await this.generateTokenPair(authResult.user);
// 格式化响应数据
const response: LoginResponse = {
user: this.formatUserInfo(authResult.user),
access_token: accessToken,
access_token: tokenPair.access_token,
refresh_token: tokenPair.refresh_token,
expires_in: tokenPair.expires_in,
token_type: tokenPair.token_type,
is_new_user: authResult.isNewUser,
message: authResult.isNewUser ? 'GitHub账户绑定成功' : 'GitHub登录成功'
};
@@ -534,23 +630,273 @@ export class LoginService {
}
/**
* 生成访问令牌
* 生成JWT令牌
*
* 功能描述:
* 为用户生成访问令牌和刷新令牌符合JWT标准和安全最佳实践
*
* 业务逻辑:
* 1. 创建访问令牌载荷(短期有效)
* 2. 创建刷新令牌载荷(长期有效)
* 3. 使用配置的密钥签名令牌
* 4. 返回完整的令牌对信息
*
* @param user 用户信息
* @returns 访问令牌
* @returns Promise<TokenPair> JWT令牌
*
* @throws InternalServerErrorException 当令牌生成失败时
*
* @example
* ```typescript
* const tokenPair = await this.generateTokenPair(user);
* console.log(tokenPair.access_token); // JWT访问令牌
* console.log(tokenPair.refresh_token); // JWT刷新令牌
* ```
*/
private generateAccessToken(user: Users): string {
// 实际应用中应使用JWT库生成真正的JWT令牌
// 这里仅用于演示,生成一个简单的令牌
const payload = {
userId: user.id.toString(),
username: user.username,
role: user.role,
timestamp: Date.now()
};
private async generateTokenPair(user: Users): Promise<TokenPair> {
try {
const currentTime = Math.floor(Date.now() / 1000);
const jwtSecret = this.configService.get<string>('JWT_SECRET');
const expiresIn = this.configService.get<string>('JWT_EXPIRES_IN', '7d');
if (!jwtSecret) {
throw new Error('JWT_SECRET未配置');
}
// 简单的Base64编码实际应用中应使用JWT
return Buffer.from(JSON.stringify(payload)).toString('base64');
// 1. 创建访问令牌载荷
const accessPayload: JwtPayload = {
sub: user.id.toString(),
username: user.username,
role: user.role,
email: user.email,
type: 'access',
iat: currentTime,
iss: 'whale-town',
aud: 'whale-town-users',
};
// 2. 创建刷新令牌载荷(有效期更长)
const refreshPayload: JwtPayload = {
sub: user.id.toString(),
username: user.username,
role: user.role,
type: 'refresh',
iat: currentTime,
iss: 'whale-town',
aud: 'whale-town-users',
};
// 3. 生成访问令牌使用NestJS JwtService
const accessToken = await this.jwtService.signAsync(accessPayload);
// 4. 生成刷新令牌有效期30天
const refreshToken = jwt.sign(refreshPayload, jwtSecret, {
expiresIn: '30d',
});
// 5. 计算过期时间(秒)
const expiresInSeconds = this.parseExpirationTime(expiresIn);
this.logger.log('JWT令牌对生成成功', {
operation: 'generateTokenPair',
userId: user.id.toString(),
username: user.username,
expiresIn: expiresInSeconds,
timestamp: new Date().toISOString(),
});
return {
access_token: accessToken,
refresh_token: refreshToken,
expires_in: expiresInSeconds,
token_type: 'Bearer',
};
} catch (error) {
const err = error as Error;
this.logger.error('JWT令牌对生成失败', {
operation: 'generateTokenPair',
userId: user.id.toString(),
error: err.message,
timestamp: new Date().toISOString(),
}, err.stack);
throw new Error(`令牌生成失败: ${err.message}`);
}
}
/**
* 验证JWT令牌
*
* 功能描述:
* 验证JWT令牌的有效性包括签名、过期时间和载荷格式
*
* 业务逻辑:
* 1. 验证令牌签名和格式
* 2. 检查令牌是否过期
* 3. 验证载荷数据完整性
* 4. 返回解码后的载荷信息
*
* @param token JWT令牌字符串
* @param tokenType 令牌类型access 或 refresh
* @returns Promise<JwtPayload> 解码后的载荷
*
* @throws UnauthorizedException 当令牌无效时
* @throws Error 当验证过程出错时
*/
async verifyToken(token: string, tokenType: 'access' | 'refresh' = 'access'): Promise<JwtPayload> {
try {
const jwtSecret = this.configService.get<string>('JWT_SECRET');
if (!jwtSecret) {
throw new Error('JWT_SECRET未配置');
}
// 1. 验证令牌并解码载荷
const payload = jwt.verify(token, jwtSecret, {
issuer: 'whale-town',
audience: 'whale-town-users',
}) as JwtPayload;
// 2. 验证令牌类型
if (payload.type !== tokenType) {
throw new Error(`令牌类型不匹配,期望: ${tokenType},实际: ${payload.type}`);
}
// 3. 验证载荷完整性
if (!payload.sub || !payload.username || payload.role === undefined) {
throw new Error('令牌载荷数据不完整');
}
this.logger.log('JWT令牌验证成功', {
operation: 'verifyToken',
userId: payload.sub,
username: payload.username,
tokenType: payload.type,
timestamp: new Date().toISOString(),
});
return payload;
} catch (error) {
const err = error as Error;
this.logger.warn('JWT令牌验证失败', {
operation: 'verifyToken',
tokenType,
error: err.message,
timestamp: new Date().toISOString(),
});
throw new Error(`令牌验证失败: ${err.message}`);
}
}
/**
* 刷新访问令牌
*
* 功能描述:
* 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期
*
* 业务逻辑:
* 1. 验证刷新令牌的有效性
* 2. 从数据库获取最新用户信息
* 3. 生成新的访问令牌
* 4. 可选择性地轮换刷新令牌
*
* @param refreshToken 刷新令牌
* @returns Promise<ApiResponse<TokenPair>> 新的令牌对
*
* @throws UnauthorizedException 当刷新令牌无效时
* @throws NotFoundException 当用户不存在时
*/
async refreshAccessToken(refreshToken: string): Promise<ApiResponse<TokenPair>> {
const startTime = Date.now();
try {
this.logger.log('开始刷新访问令牌', {
operation: 'refreshAccessToken',
timestamp: new Date().toISOString(),
});
// 1. 验证刷新令牌
const payload = await this.verifyToken(refreshToken, 'refresh');
// 2. 获取最新用户信息
const user = await this.usersService.findOne(BigInt(payload.sub));
if (!user) {
throw new Error('用户不存在或已被禁用');
}
// 3. 生成新的令牌对
const newTokenPair = await this.generateTokenPair(user);
const duration = Date.now() - startTime;
this.logger.log('访问令牌刷新成功', {
operation: 'refreshAccessToken',
userId: user.id.toString(),
username: user.username,
duration,
timestamp: new Date().toISOString(),
});
return {
success: true,
data: newTokenPair,
message: '令牌刷新成功'
};
} catch (error) {
const duration = Date.now() - startTime;
const err = error as Error;
this.logger.error('访问令牌刷新失败', {
operation: 'refreshAccessToken',
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
return {
success: false,
message: err.message || '令牌刷新失败',
error_code: 'TOKEN_REFRESH_FAILED'
};
}
}
/**
* 解析过期时间字符串
*
* 功能描述:
* 将时间字符串(如 '7d', '24h', '60m')转换为秒数
*
* @param expiresIn 过期时间字符串
* @returns number 过期时间(秒)
* @private
*/
private parseExpirationTime(expiresIn: string): number {
if (!expiresIn || typeof expiresIn !== 'string') {
return 7 * 24 * 60 * 60; // 默认7天
}
const timeUnit = expiresIn.slice(-1);
const timeValue = parseInt(expiresIn.slice(0, -1));
if (isNaN(timeValue)) {
return 7 * 24 * 60 * 60; // 默认7天
}
switch (timeUnit) {
case 's': return timeValue;
case 'm': return timeValue * 60;
case 'h': return timeValue * 60 * 60;
case 'd': return timeValue * 24 * 60 * 60;
case 'w': return timeValue * 7 * 24 * 60 * 60;
default: return 7 * 24 * 60 * 60; // 默认7天
}
}
/**
* 验证码登录
@@ -565,13 +911,16 @@ export class LoginService {
// 调用核心服务进行验证码认证
const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest);
// 生成访问令牌
const accessToken = this.generateAccessToken(authResult.user);
// 生成JWT令牌
const tokenPair = await this.generateTokenPair(authResult.user);
// 格式化响应数据
const response: LoginResponse = {
user: this.formatUserInfo(authResult.user),
access_token: accessToken,
access_token: tokenPair.access_token,
refresh_token: tokenPair.refresh_token,
expires_in: tokenPair.expires_in,
token_type: tokenPair.token_type,
is_new_user: authResult.isNewUser,
message: '验证码登录成功'
};

View File

@@ -18,6 +18,8 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import * as fc from 'fast-check';
import { LoginService } from './login.service';
import { LoginCoreService, RegisterRequest } from '../../../core/login_core/login_core.service';
@@ -97,6 +99,41 @@ describe('LoginService - Zulip账号创建属性测试', () => {
provide: ApiKeySecurityService,
useValue: mockApiKeySecurityService,
},
{
provide: JwtService,
useValue: {
sign: jest.fn().mockReturnValue('mock_jwt_token'),
signAsync: jest.fn().mockResolvedValue('mock_jwt_token'),
verify: jest.fn(),
decode: jest.fn(),
},
},
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
switch (key) {
case 'JWT_SECRET':
return 'test_jwt_secret_key_for_testing';
case 'JWT_EXPIRES_IN':
return '7d';
default:
return undefined;
}
}),
},
},
{
provide: 'UsersService',
useValue: {
findById: jest.fn(),
findByUsername: jest.fn(),
findByEmail: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
},
],
}).compile();
@@ -106,6 +143,9 @@ describe('LoginService - Zulip账号创建属性测试', () => {
zulipAccountsRepository = module.get('ZulipAccountsRepository');
apiKeySecurityService = module.get(ApiKeySecurityService);
// Mock LoginService 的 initializeZulipAdminClient 方法
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
// 设置环境变量模拟
process.env.ZULIP_SERVER_URL = 'https://test.zulip.com';
process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com';
@@ -167,7 +207,6 @@ describe('LoginService - Zulip账号创建属性测试', () => {
} as ZulipAccounts;
// 设置模拟行为
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,
@@ -189,11 +228,7 @@ describe('LoginService - Zulip账号创建属性测试', () => {
expect(result.data?.is_new_user).toBe(true);
// 验证Zulip管理员客户端初始化
expect(zulipAccountService.initializeAdminClient).toHaveBeenCalledWith({
realm: 'https://test.zulip.com',
username: 'bot@test.zulip.com',
apiKey: 'test_api_key_123',
});
expect(loginService['initializeZulipAdminClient']).toHaveBeenCalled();
// 验证游戏用户注册
expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest);
@@ -249,7 +284,6 @@ describe('LoginService - Zulip账号创建属性测试', () => {
} as Users;
// 设置模拟行为 - Zulip账号创建失败
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,
@@ -318,7 +352,6 @@ describe('LoginService - Zulip账号创建属性测试', () => {
} as ZulipAccounts;
// 设置模拟行为 - 已存在Zulip账号关联
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,
@@ -374,7 +407,6 @@ describe('LoginService - Zulip账号创建属性测试', () => {
} as Users;
// 设置模拟行为
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,
@@ -404,7 +436,8 @@ describe('LoginService - Zulip账号创建属性测试', () => {
await fc.assert(
fc.asyncProperty(registerRequestArb, async (registerRequest) => {
// 设置模拟行为 - 管理员客户端初始化失败
zulipAccountService.initializeAdminClient.mockResolvedValue(false);
jest.spyOn(loginService as any, 'initializeZulipAdminClient')
.mockRejectedValue(new Error('Zulip管理员客户端初始化失败'));
// 执行注册
const result = await loginService.register(registerRequest);
@@ -418,6 +451,9 @@ describe('LoginService - Zulip账号创建属性测试', () => {
// 验证没有尝试创建Zulip账号
expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled();
// 恢复 mock
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
}),
{ numRuns: 50 }
);
@@ -431,6 +467,10 @@ describe('LoginService - Zulip账号创建属性测试', () => {
delete process.env.ZULIP_BOT_EMAIL;
delete process.env.ZULIP_BOT_API_KEY;
// 重新设置 mock 以模拟环境变量缺失的错误
jest.spyOn(loginService as any, 'initializeZulipAdminClient')
.mockRejectedValue(new Error('Zulip管理员配置不完整请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY'));
// 执行注册
const result = await loginService.register(registerRequest);
@@ -441,10 +481,11 @@ describe('LoginService - Zulip账号创建属性测试', () => {
// 验证没有尝试创建游戏用户
expect(loginCoreService.register).not.toHaveBeenCalled();
// 恢复环境变量
// 恢复环境变量和 mock
process.env.ZULIP_SERVER_URL = 'https://test.zulip.com';
process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com';
process.env.ZULIP_BOT_API_KEY = 'test_api_key_123';
jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined);
}),
{ numRuns: 30 }
);
@@ -480,7 +521,6 @@ describe('LoginService - Zulip账号创建属性测试', () => {
};
// 设置模拟行为
zulipAccountService.initializeAdminClient.mockResolvedValue(true);
loginCoreService.register.mockResolvedValue({
user: mockGameUser,
isNewUser: true,

View File

@@ -40,6 +40,7 @@ import {
ZulipClientInstance,
SendMessageResult,
} from '../../core/zulip/interfaces/zulip-core.interfaces';
import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service';
describe('ZulipService', () => {
let service: ZulipService;
@@ -158,6 +159,19 @@ describe('ZulipService', () => {
provide: 'ZULIP_CONFIG_SERVICE',
useValue: mockConfigManager,
},
{
provide: ApiKeySecurityService,
useValue: {
extractApiKey: jest.fn(),
validateApiKey: jest.fn(),
encryptApiKey: jest.fn(),
decryptApiKey: jest.fn(),
getApiKey: jest.fn().mockResolvedValue({
success: true,
apiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8',
}),
},
},
],
}).compile();

View File

@@ -81,6 +81,9 @@ export const ThrottlePresets = {
/** 密码重置每小时3次 */
RESET_PASSWORD: { limit: 3, ttl: 3600, message: '密码重置请求过于频繁请1小时后再试' },
/** 令牌刷新每分钟10次 */
REFRESH_TOKEN: { limit: 10, ttl: 60, message: '令牌刷新请求过于频繁,请稍后再试' },
/** 管理员操作每分钟10次 */
ADMIN_OPERATION: { limit: 10, ttl: 60, message: '管理员操作过于频繁,请稍后再试' },