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

@@ -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('调试信息获取成功');
});
});
});