490 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|