/** * Token Service Unit Tests * * Tests for token management including: * - JWT token generation * - Token verification * - Session management * - Token refresh * - Session revocation */ import jwt from 'jsonwebtoken'; import type { User, Session, JWTPayload, JWTRefreshPayload } from '../../types/auth.types'; import { mockDb, createMockQueryResult, resetDatabaseMocks } from '../../../../__tests__/mocks/database.mock'; // Mock database jest.mock('../../../../shared/database', () => ({ db: mockDb, })); // Mock config jest.mock('../../../../config', () => ({ config: { jwt: { accessSecret: 'test-access-secret', refreshSecret: 'test-refresh-secret', accessExpiry: '15m', refreshExpiry: '7d', }, }, })); // Import service after mocks import { TokenService } from '../token.service'; describe('TokenService', () => { let tokenService: TokenService; const mockUser: User = { id: 'user-123', email: 'user@example.com', emailVerified: true, phoneVerified: false, primaryAuthProvider: 'email', totpEnabled: false, role: 'investor', status: 'active', failedLoginAttempts: 0, createdAt: new Date(), updatedAt: new Date(), }; beforeEach(() => { resetDatabaseMocks(); tokenService = new TokenService(); }); describe('generateAccessToken', () => { it('should generate a valid access token', () => { const token = tokenService.generateAccessToken(mockUser); expect(token).toBeTruthy(); expect(typeof token).toBe('string'); // Verify token structure const decoded = jwt.verify(token, 'test-access-secret') as JWTPayload; expect(decoded.sub).toBe('user-123'); expect(decoded.email).toBe('user@example.com'); expect(decoded.role).toBe('investor'); expect(decoded.provider).toBe('email'); expect(decoded.exp).toBeDefined(); expect(decoded.iat).toBeDefined(); }); it('should include correct expiry time', () => { const token = tokenService.generateAccessToken(mockUser); const decoded = jwt.verify(token, 'test-access-secret') as JWTPayload; const now = Math.floor(Date.now() / 1000); const expectedExpiry = now + 15 * 60; // 15 minutes expect(decoded.exp).toBeGreaterThan(now); expect(decoded.exp).toBeLessThanOrEqual(expectedExpiry + 5); // Allow 5 second buffer }); }); describe('generateRefreshToken', () => { it('should generate a valid refresh token', () => { const token = tokenService.generateRefreshToken('user-123', 'session-123'); expect(token).toBeTruthy(); expect(typeof token).toBe('string'); // Verify token structure const decoded = jwt.verify(token, 'test-refresh-secret') as JWTRefreshPayload; expect(decoded.sub).toBe('user-123'); expect(decoded.sessionId).toBe('session-123'); expect(decoded.exp).toBeDefined(); expect(decoded.iat).toBeDefined(); }); it('should include correct expiry time for refresh token', () => { const token = tokenService.generateRefreshToken('user-123', 'session-123'); const decoded = jwt.verify(token, 'test-refresh-secret') as JWTRefreshPayload; const now = Math.floor(Date.now() / 1000); const expectedExpiry = now + 7 * 24 * 60 * 60; // 7 days expect(decoded.exp).toBeGreaterThan(now); expect(decoded.exp).toBeLessThanOrEqual(expectedExpiry + 5); // Allow 5 second buffer }); }); describe('verifyAccessToken', () => { it('should verify a valid access token', () => { const token = tokenService.generateAccessToken(mockUser); const payload = tokenService.verifyAccessToken(token); expect(payload).toBeTruthy(); expect(payload?.sub).toBe('user-123'); expect(payload?.email).toBe('user@example.com'); }); it('should return null for invalid token', () => { const payload = tokenService.verifyAccessToken('invalid-token'); expect(payload).toBeNull(); }); it('should return null for expired token', () => { // Create an expired token const expiredToken = jwt.sign( { sub: 'user-123', email: 'user@example.com', role: 'investor', provider: 'email', }, 'test-access-secret', { expiresIn: '-1h' } // Expired 1 hour ago ); const payload = tokenService.verifyAccessToken(expiredToken); expect(payload).toBeNull(); }); it('should return null for token with wrong secret', () => { const wrongToken = jwt.sign( { sub: 'user-123', email: 'user@example.com', }, 'wrong-secret', { expiresIn: '15m' } ); const payload = tokenService.verifyAccessToken(wrongToken); expect(payload).toBeNull(); }); }); describe('verifyRefreshToken', () => { it('should verify a valid refresh token', () => { const token = tokenService.generateRefreshToken('user-123', 'session-123'); const payload = tokenService.verifyRefreshToken(token); expect(payload).toBeTruthy(); expect(payload?.sub).toBe('user-123'); expect(payload?.sessionId).toBe('session-123'); }); it('should return null for invalid refresh token', () => { const payload = tokenService.verifyRefreshToken('invalid-token'); expect(payload).toBeNull(); }); }); describe('createSession', () => { it('should create a new session with tokens', async () => { const mockSession: Session = { id: 'session-123', userId: 'user-123', refreshToken: expect.any(String), userAgent: 'Mozilla/5.0', ipAddress: '127.0.0.1', expiresAt: expect.any(Date), createdAt: expect.any(Date), lastActiveAt: expect.any(Date), }; // Mock: Insert session mockDb.query.mockResolvedValueOnce( createMockQueryResult([{ id: 'session-123', userId: 'user-123', refreshToken: 'refresh-token-value', userAgent: 'Mozilla/5.0', ipAddress: '127.0.0.1', expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), createdAt: new Date(), lastActiveAt: new Date(), }]) ); // Mock: Get user for access token mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser])); const result = await tokenService.createSession( 'user-123', 'Mozilla/5.0', '127.0.0.1', { device: 'desktop' } ); expect(result.session).toBeDefined(); expect(result.session.userId).toBe('user-123'); expect(result.tokens).toBeDefined(); expect(result.tokens.accessToken).toBeTruthy(); expect(result.tokens.refreshToken).toBeTruthy(); expect(result.tokens.tokenType).toBe('Bearer'); expect(result.tokens.expiresIn).toBe(900); // 15 minutes in seconds }); it('should store device information', async () => { const deviceInfo = { browser: 'Chrome', os: 'Windows 10', device: 'desktop', }; // Mock: Insert session mockDb.query.mockResolvedValueOnce( createMockQueryResult([{ id: 'session-123', userId: 'user-123', refreshToken: 'refresh-token-value', deviceInfo, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), createdAt: new Date(), lastActiveAt: new Date(), }]) ); // Mock: Get user mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser])); const result = await tokenService.createSession( 'user-123', 'Mozilla/5.0', '127.0.0.1', deviceInfo ); // Verify INSERT query includes device info const insertQuery = mockDb.query.mock.calls[0][0]; expect(insertQuery).toContain('device_info'); }); }); describe('refreshSession', () => { it('should refresh tokens for valid session', async () => { const refreshToken = tokenService.generateRefreshToken('user-123', 'session-123'); const mockSession: Session = { id: 'session-123', userId: 'user-123', refreshToken: 'refresh-token-value', expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), createdAt: new Date(), lastActiveAt: new Date(), }; // Mock: Get session mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockSession])); // Mock: Update last active mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); // Mock: Get user mockDb.query.mockResolvedValueOnce(createMockQueryResult([mockUser])); const result = await tokenService.refreshSession(refreshToken); expect(result).toBeDefined(); expect(result?.accessToken).toBeTruthy(); expect(result?.refreshToken).toBeTruthy(); expect(result?.tokenType).toBe('Bearer'); }); it('should return null for invalid refresh token', async () => { const result = await tokenService.refreshSession('invalid-token'); expect(result).toBeNull(); }); it('should return null for revoked session', async () => { const refreshToken = tokenService.generateRefreshToken('user-123', 'session-123'); // Mock: Session is revoked mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); const result = await tokenService.refreshSession(refreshToken); expect(result).toBeNull(); }); it('should return null for expired session', async () => { const refreshToken = tokenService.generateRefreshToken('user-123', 'session-123'); const expiredSession: Session = { id: 'session-123', userId: 'user-123', refreshToken: 'refresh-token-value', expiresAt: new Date(Date.now() - 1000), // Expired createdAt: new Date(), lastActiveAt: new Date(), }; // Mock: Session exists but is expired mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); const result = await tokenService.refreshSession(refreshToken); expect(result).toBeNull(); }); }); describe('revokeSession', () => { it('should revoke an active session', async () => { // Mock: Revoke session mockDb.query.mockResolvedValueOnce({ command: 'UPDATE', rowCount: 1, rows: [], oid: 0, fields: [], }); const result = await tokenService.revokeSession('session-123', 'user-123'); expect(result).toBe(true); }); it('should return false if session not found', async () => { // Mock: Session not found mockDb.query.mockResolvedValueOnce({ command: 'UPDATE', rowCount: 0, rows: [], oid: 0, fields: [], }); const result = await tokenService.revokeSession('session-123', 'user-123'); expect(result).toBe(false); }); }); describe('revokeAllUserSessions', () => { it('should revoke all user sessions', async () => { // Mock: Revoke all sessions mockDb.query.mockResolvedValueOnce({ command: 'UPDATE', rowCount: 3, rows: [], oid: 0, fields: [], }); const result = await tokenService.revokeAllUserSessions('user-123'); expect(result).toBe(3); }); it('should revoke all sessions except specified one', async () => { // Mock: Revoke all except one mockDb.query.mockResolvedValueOnce({ command: 'UPDATE', rowCount: 2, rows: [], oid: 0, fields: [], }); const result = await tokenService.revokeAllUserSessions('user-123', 'keep-session-123'); expect(result).toBe(2); // Verify query includes exception const query = mockDb.query.mock.calls[0][0]; expect(query).toContain('id != $2'); }); it('should return 0 if no sessions found', async () => { // Mock: No sessions to revoke mockDb.query.mockResolvedValueOnce({ command: 'UPDATE', rowCount: 0, rows: [], oid: 0, fields: [], }); const result = await tokenService.revokeAllUserSessions('user-123'); expect(result).toBe(0); }); }); describe('getActiveSessions', () => { it('should return all active sessions for user', async () => { const mockSessions: Session[] = [ { id: 'session-1', userId: 'user-123', refreshToken: 'token-1', userAgent: 'Chrome', ipAddress: '127.0.0.1', expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), createdAt: new Date(), lastActiveAt: new Date(), }, { id: 'session-2', userId: 'user-123', refreshToken: 'token-2', userAgent: 'Firefox', ipAddress: '127.0.0.2', expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), createdAt: new Date(), lastActiveAt: new Date(), }, ]; // Mock: Get sessions mockDb.query.mockResolvedValueOnce(createMockQueryResult(mockSessions)); const result = await tokenService.getActiveSessions('user-123'); expect(result).toHaveLength(2); expect(result[0].id).toBe('session-1'); expect(result[1].id).toBe('session-2'); }); it('should return empty array if no active sessions', async () => { // Mock: No sessions mockDb.query.mockResolvedValueOnce(createMockQueryResult([])); const result = await tokenService.getActiveSessions('user-123'); expect(result).toHaveLength(0); }); }); describe('generateEmailToken', () => { it('should generate a random email token', () => { const token1 = tokenService.generateEmailToken(); const token2 = tokenService.generateEmailToken(); expect(token1).toBeTruthy(); expect(token2).toBeTruthy(); expect(token1).not.toBe(token2); expect(token1.length).toBe(64); // 32 bytes = 64 hex chars }); }); describe('hashToken', () => { it('should hash a token consistently', () => { const token = 'test-token-123'; const hash1 = tokenService.hashToken(token); const hash2 = tokenService.hashToken(token); expect(hash1).toBeTruthy(); expect(hash1).toBe(hash2); expect(hash1.length).toBe(64); // SHA-256 = 64 hex chars }); it('should produce different hashes for different tokens', () => { const hash1 = tokenService.hashToken('token-1'); const hash2 = tokenService.hashToken('token-2'); expect(hash1).not.toBe(hash2); }); }); describe('parseExpiry', () => { it('should parse different time formats correctly', () => { // Access private method via type assertion for testing const service = tokenService as unknown as { parseExpiry: (expiry: string) => number; }; expect(service.parseExpiry('60s')).toBe(60 * 1000); expect(service.parseExpiry('15m')).toBe(15 * 60 * 1000); expect(service.parseExpiry('2h')).toBe(2 * 60 * 60 * 1000); expect(service.parseExpiry('7d')).toBe(7 * 24 * 60 * 60 * 1000); }); }); });