86211923db
Repository Interface Optimization: - Created IBaseRepository.ts and IPaginatedRepository.ts - Refactored all 7 repository interfaces to extend base interfaces - Eliminated ~200 lines of redundant code (70% reduction) - Improved type safety and maintainability Dependency Injection Improvements: - Added EmailService and GameTokenService to DIContainer - Updated CreateUserCommandHandler constructor for DI - Updated RequestPasswordResetCommandHandler constructor for DI - Enhanced testability and service consistency Environment Configuration: - Created comprehensive .env.example with 40+ variables - Organized into 12 logical sections (Database, Security, Email, etc.) - Added security guidelines and best practices - Documented all backend environment requirements Documentation: - Added comprehensive codebase review - Created refactoring summary report - Added frontend implementation guide Impact: Improved code quality, reduced maintenance overhead, enhanced developer experience
406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
import { TokenService } from '../../../src/Application/Services/TokenService';
|
|
import * as crypto from 'crypto';
|
|
|
|
// Mock crypto module
|
|
jest.mock('crypto');
|
|
|
|
describe('TokenService', () => {
|
|
let mockRandomBytes: jest.Mock;
|
|
let mockCreateHash: jest.Mock;
|
|
let mockHashUpdate: jest.Mock;
|
|
let mockHashDigest: jest.Mock;
|
|
let dateSpy: jest.SpyInstance;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
|
|
// Restore Date mock if it exists
|
|
if (dateSpy) {
|
|
dateSpy.mockRestore();
|
|
}
|
|
|
|
mockRandomBytes = jest.mocked(crypto.randomBytes);
|
|
mockHashUpdate = jest.fn().mockReturnThis();
|
|
mockHashDigest = jest.fn();
|
|
mockCreateHash = jest.fn().mockReturnValue({
|
|
update: mockHashUpdate,
|
|
digest: mockHashDigest
|
|
});
|
|
|
|
// Mock crypto.createHash properly
|
|
jest.mocked(crypto.createHash).mockImplementation(mockCreateHash);
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clean up Date mock
|
|
if (dateSpy) {
|
|
dateSpy.mockRestore();
|
|
dateSpy = undefined as any;
|
|
}
|
|
});
|
|
|
|
describe('generateSecureToken', () => {
|
|
it('should generate a secure token with default length', () => {
|
|
// Arrange
|
|
const mockBuffer = {
|
|
toString: jest.fn().mockReturnValue('abcdef1234567890')
|
|
};
|
|
mockRandomBytes.mockReturnValue(mockBuffer as any);
|
|
|
|
// Act
|
|
const token = TokenService.generateSecureToken();
|
|
|
|
// Assert
|
|
expect(token).toBe('abcdef1234567890');
|
|
expect(mockRandomBytes).toHaveBeenCalledWith(32);
|
|
expect(mockBuffer.toString).toHaveBeenCalledWith('hex');
|
|
});
|
|
|
|
it('should generate a secure token with custom length', () => {
|
|
// Arrange
|
|
const mockBuffer = {
|
|
toString: jest.fn().mockReturnValue('abcdef')
|
|
};
|
|
mockRandomBytes.mockReturnValue(mockBuffer as any);
|
|
|
|
// Act
|
|
const token = TokenService.generateSecureToken(16);
|
|
|
|
// Assert
|
|
expect(token).toBe('abcdef');
|
|
expect(mockRandomBytes).toHaveBeenCalledWith(16);
|
|
expect(mockBuffer.toString).toHaveBeenCalledWith('hex');
|
|
});
|
|
|
|
it('should handle crypto errors', () => {
|
|
// Arrange
|
|
mockRandomBytes.mockImplementation(() => {
|
|
throw new Error('Crypto error');
|
|
});
|
|
|
|
// Act & Assert
|
|
expect(() => TokenService.generateSecureToken()).toThrow('Failed to generate secure token');
|
|
expect(mockRandomBytes).toHaveBeenCalledWith(32);
|
|
});
|
|
});
|
|
|
|
describe('generateVerificationToken', () => {
|
|
it('should generate verification token with correct expiration', () => {
|
|
// Arrange
|
|
const mockBuffer = {
|
|
toString: jest.fn().mockReturnValue('verification123')
|
|
};
|
|
mockRandomBytes.mockReturnValue(mockBuffer as any);
|
|
const mockDate = new Date('2023-01-01T12:00:00Z');
|
|
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);
|
|
|
|
// Act
|
|
const result = TokenService.generateVerificationToken();
|
|
|
|
// Assert
|
|
expect(result.token).toBe('verification123');
|
|
expect(result.createdAt).toEqual(mockDate);
|
|
expect(result.expiresAt).toEqual(new Date('2023-01-02T12:00:00Z')); // 24 hours later
|
|
expect(mockRandomBytes).toHaveBeenCalledWith(32);
|
|
expect(mockBuffer.toString).toHaveBeenCalledWith('hex');
|
|
});
|
|
|
|
it('should handle token generation errors', () => {
|
|
// Arrange
|
|
mockRandomBytes.mockImplementation(() => {
|
|
throw new Error('Random bytes failed');
|
|
});
|
|
|
|
// Act & Assert
|
|
expect(() => TokenService.generateVerificationToken()).toThrow('Failed to generate verification token');
|
|
});
|
|
});
|
|
|
|
describe('generatePasswordResetToken', () => {
|
|
it('should generate password reset token with correct expiration', () => {
|
|
// Arrange
|
|
const mockBuffer = {
|
|
toString: jest.fn().mockReturnValue('reset456')
|
|
};
|
|
mockRandomBytes.mockReturnValue(mockBuffer as any);
|
|
const mockDate = new Date('2023-01-01T12:00:00Z');
|
|
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => mockDate as any);
|
|
|
|
// Act
|
|
const result = TokenService.generatePasswordResetToken();
|
|
|
|
// Assert
|
|
expect(result.token).toBe('reset456');
|
|
expect(result.createdAt).toEqual(mockDate);
|
|
expect(result.expiresAt).toEqual(new Date('2023-01-01T13:00:00Z')); // 1 hour later
|
|
expect(mockRandomBytes).toHaveBeenCalledWith(32);
|
|
expect(mockBuffer.toString).toHaveBeenCalledWith('hex');
|
|
});
|
|
|
|
it('should handle token generation errors', () => {
|
|
// Arrange
|
|
mockRandomBytes.mockImplementation(() => {
|
|
throw new Error('Random bytes failed');
|
|
});
|
|
|
|
// Act & Assert
|
|
expect(() => TokenService.generatePasswordResetToken()).toThrow('Failed to generate password reset token');
|
|
});
|
|
});
|
|
|
|
describe('hashToken', () => {
|
|
it('should hash token correctly', async () => {
|
|
// Arrange
|
|
const token = 'test-token-123';
|
|
const hashedToken = 'hashed-token-result';
|
|
mockHashDigest.mockReturnValue(hashedToken);
|
|
|
|
// Act
|
|
const result = await TokenService.hashToken(token);
|
|
|
|
// Assert
|
|
expect(result).toBe(hashedToken);
|
|
expect(mockCreateHash).toHaveBeenCalledWith('sha256');
|
|
expect(mockHashUpdate).toHaveBeenCalledWith(token);
|
|
expect(mockHashDigest).toHaveBeenCalledWith('hex');
|
|
});
|
|
|
|
it('should handle hashing errors', async () => {
|
|
// Arrange
|
|
const token = 'test-token-123';
|
|
mockCreateHash.mockImplementation(() => {
|
|
throw new Error('Hashing failed');
|
|
});
|
|
|
|
// Act & Assert
|
|
await expect(TokenService.hashToken(token)).rejects.toThrow('Failed to hash token');
|
|
});
|
|
});
|
|
|
|
describe('verifyToken', () => {
|
|
it('should return true when tokens match', async () => {
|
|
// Arrange
|
|
const plainToken = 'plain-token';
|
|
const hashedToken = 'expected-hash';
|
|
mockHashDigest.mockReturnValue(hashedToken);
|
|
|
|
// Act
|
|
const result = await TokenService.verifyToken(plainToken, hashedToken);
|
|
|
|
// Assert
|
|
expect(result).toBe(true);
|
|
expect(mockCreateHash).toHaveBeenCalledWith('sha256');
|
|
expect(mockHashUpdate).toHaveBeenCalledWith(plainToken);
|
|
expect(mockHashDigest).toHaveBeenCalledWith('hex');
|
|
});
|
|
|
|
it('should return false when tokens do not match', async () => {
|
|
// Arrange
|
|
const plainToken = 'plain-token';
|
|
const hashedToken = 'expected-hash';
|
|
const actualHash = 'different-hash';
|
|
mockHashDigest.mockReturnValue(actualHash);
|
|
|
|
// Act
|
|
const result = await TokenService.verifyToken(plainToken, hashedToken);
|
|
|
|
// Assert
|
|
expect(result).toBe(false);
|
|
expect(mockCreateHash).toHaveBeenCalledWith('sha256');
|
|
expect(mockHashUpdate).toHaveBeenCalledWith(plainToken);
|
|
expect(mockHashDigest).toHaveBeenCalledWith('hex');
|
|
});
|
|
|
|
it('should handle verification errors', async () => {
|
|
// Arrange
|
|
const plainToken = 'plain-token';
|
|
const hashedToken = 'expected-hash';
|
|
mockCreateHash.mockImplementation(() => {
|
|
throw new Error('Hash creation failed');
|
|
});
|
|
|
|
// Act & Assert - TokenService.verifyToken catches errors and returns false, doesn't throw
|
|
const result = await TokenService.verifyToken(plainToken, hashedToken);
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('isTokenExpired', () => {
|
|
it('should return false for non-expired token', () => {
|
|
// Arrange
|
|
const currentTime = new Date('2023-01-01T12:00:00Z');
|
|
const futureDate = new Date('2023-01-01T13:00:00Z'); // 1 hour from now
|
|
|
|
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => currentTime as any);
|
|
|
|
// Act
|
|
const result = TokenService.isTokenExpired(futureDate);
|
|
|
|
// Assert
|
|
expect(result).toBe(false);
|
|
|
|
// Cleanup
|
|
dateSpy.mockRestore();
|
|
});
|
|
|
|
it('should return true for expired token', () => {
|
|
// Arrange
|
|
const currentTime = new Date('2023-01-01T12:00:00Z');
|
|
const pastDate = new Date('2023-01-01T11:00:00Z'); // 1 hour ago
|
|
|
|
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => currentTime as any);
|
|
|
|
// Act
|
|
const result = TokenService.isTokenExpired(pastDate);
|
|
|
|
// Assert
|
|
expect(result).toBe(true);
|
|
|
|
// Cleanup
|
|
dateSpy.mockRestore();
|
|
});
|
|
|
|
it('should return true for exactly expired token', () => {
|
|
// Arrange
|
|
const currentTime = new Date('2023-01-01T12:00:00Z');
|
|
const exactlyNow = new Date('2023-01-01T12:00:00Z');
|
|
|
|
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => currentTime as any);
|
|
|
|
// Act
|
|
const result = TokenService.isTokenExpired(exactlyNow);
|
|
|
|
// Assert
|
|
expect(result).toBe(false); // new Date() > expiresAt is false when they're equal
|
|
|
|
// Cleanup
|
|
dateSpy.mockRestore();
|
|
});
|
|
});
|
|
|
|
describe('generateTokenWithExpiry', () => {
|
|
it('should validate token format correctly', () => {
|
|
// Arrange - valid hex token with expected length (64 chars for 32 bytes)
|
|
const validToken = 'a'.repeat(64); // 64 hex characters
|
|
|
|
// Act
|
|
const result = TokenService.isValidTokenFormat(validToken);
|
|
|
|
// Assert
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should reject invalid token format', () => {
|
|
// Arrange
|
|
const invalidTokens = [
|
|
'', // empty
|
|
'invalid-token-with-dashes', // non-hex characters
|
|
'abc123', // too short
|
|
null as any, // null
|
|
undefined as any, // undefined
|
|
123 as any // not string
|
|
];
|
|
|
|
invalidTokens.forEach(invalidToken => {
|
|
// Act
|
|
const result = TokenService.isValidTokenFormat(invalidToken);
|
|
|
|
// Assert
|
|
expect(result).toBe(false);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('generateVerificationUrl', () => {
|
|
it('should generate correct verification URL', () => {
|
|
// Arrange
|
|
const baseUrl = 'https://example.com';
|
|
const token = 'verification-token-123';
|
|
|
|
// Act
|
|
const url = TokenService.generateVerificationUrl(baseUrl, token);
|
|
|
|
// Assert
|
|
expect(url).toBe('https://example.com/api/auth/verify-email?token=verification-token-123');
|
|
});
|
|
|
|
it('should handle base URL with trailing slash', () => {
|
|
// Arrange
|
|
const baseUrl = 'https://example.com/';
|
|
const token = 'verification-token-123';
|
|
|
|
// Act
|
|
const url = TokenService.generateVerificationUrl(baseUrl, token);
|
|
|
|
// Assert
|
|
expect(url).toBe('https://example.com/api/auth/verify-email?token=verification-token-123');
|
|
});
|
|
|
|
it('should encode special characters in token', () => {
|
|
// Arrange
|
|
const baseUrl = 'https://example.com';
|
|
const token = 'token+with/special=chars';
|
|
|
|
// Act
|
|
const url = TokenService.generateVerificationUrl(baseUrl, token);
|
|
|
|
// Assert
|
|
expect(url).toContain(encodeURIComponent(token));
|
|
});
|
|
});
|
|
|
|
describe('generatePasswordResetUrl', () => {
|
|
it('should generate correct password reset URL', () => {
|
|
// Arrange
|
|
const baseUrl = 'https://example.com';
|
|
const token = 'reset-token-456';
|
|
|
|
// Act
|
|
const url = TokenService.generatePasswordResetUrl(baseUrl, token);
|
|
|
|
// Assert
|
|
expect(url).toBe('https://example.com/api/auth/reset-password?token=reset-token-456');
|
|
});
|
|
});
|
|
|
|
describe('getExpirationInfo', () => {
|
|
it('should return correct info for non-expired token', () => {
|
|
// Arrange
|
|
const currentTime = new Date('2023-01-01T12:00:00Z');
|
|
const futureDate = new Date('2023-01-01T14:00:00Z'); // 2 hours from now
|
|
|
|
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => currentTime as any);
|
|
|
|
// Act
|
|
const result = TokenService.getExpirationInfo(futureDate);
|
|
|
|
// Assert
|
|
expect(result.expired).toBe(false);
|
|
expect(result.timeLeft).toContain('Expires in');
|
|
expect(result.timeLeft).toContain('hour(s)');
|
|
|
|
// Cleanup
|
|
dateSpy.mockRestore();
|
|
});
|
|
|
|
it('should return correct info for expired token', () => {
|
|
// Arrange
|
|
const currentTime = new Date('2023-01-01T12:00:00Z');
|
|
const pastDate = new Date('2023-01-01T11:30:00Z'); // 30 minutes ago
|
|
|
|
dateSpy = jest.spyOn(global, 'Date').mockImplementation(() => currentTime as any);
|
|
|
|
// Act
|
|
const result = TokenService.getExpirationInfo(pastDate);
|
|
|
|
// Assert
|
|
expect(result.expired).toBe(true);
|
|
expect(result.timeLeft).toContain('Expired');
|
|
expect(result.timeLeft).toContain('minute(s) ago');
|
|
|
|
// Cleanup
|
|
dateSpy.mockRestore();
|
|
});
|
|
});
|
|
});
|