trading-platform-backend/src/modules/auth/services/__tests__/token.service.spec.ts

490 lines
15 KiB
TypeScript

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