/** * 登录业务服务测试 * * 功能描述: * - 测试登录相关的业务逻辑 * - 测试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; let jwtService: jest.Mocked; let configService: jest.Mocked; let usersService: jest.Mocked; let zulipAccountService: jest.Mocked; let zulipAccountsRepository: jest.Mocked; let apiKeySecurityService: jest.Mocked; const mockUser = { id: BigInt(1), username: 'testuser', email: 'test@example.com', phone: '+8613800138000', password_hash: '$2b$12$hashedpassword', nickname: '测试用户', github_id: null as string | null, avatar_url: null as string | null, role: 1, email_verified: false, status: 'active' as any, created_at: new Date(), 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(), githubOAuth: jest.fn(), sendPasswordResetCode: jest.fn(), resetPassword: jest.fn(), changePassword: jest.fn(), sendEmailVerification: jest.fn(), verifyEmailCode: jest.fn(), resendEmailVerification: jest.fn(), 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({ providers: [ 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); 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', () => { expect(service).toBeDefined(); }); describe('login', () => { it('should login successfully and return JWT tokens', async () => { loginCoreService.login.mockResolvedValue({ user: mockUser, isNewUser: false }); const result = await service.login({ identifier: 'testuser', password: 'password123' }); 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); // 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 () => { loginCoreService.login.mockRejectedValue(new Error('用户名或密码错误')); const result = await service.login({ identifier: 'testuser', password: 'wrongpassword' }); 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 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', nickname: '测试用户' }); expect(result.success).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('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 }); const result = await service.verificationCodeLogin({ identifier: 'test@example.com', verificationCode: '123456' }); 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 sendPasswordResetCode in test mode', async () => { loginCoreService.sendPasswordResetCode.mockResolvedValue({ code: '123456', isTestMode: true }); const result = await service.sendPasswordResetCode('test@example.com'); 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 }); const result = await service.sendLoginVerificationCode('test@example.com'); expect(result.success).toBe(false); // 测试模式下返回false 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('调试信息获取成功'); }); }); });