404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
import { JWTService, TokenPayload } from '../../../src/Application/Services/JWTService';
|
|
import { Request, Response } from 'express';
|
|
import { UserState } from '../../../src/Domain/User/UserAggregate';
|
|
|
|
|
|
describe('JWTService', () => {
|
|
let jwtService: JWTService;
|
|
let mockRequest: Partial<Request>;
|
|
let mockResponse: Partial<Response>;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
jwtService = new JWTService();
|
|
|
|
// Set a test secret for consistent testing
|
|
process.env.JWT_SECRET = 'test-secret-key-for-testing';
|
|
process.env.JWT_EXPIRY = '3600'; // 1 hour
|
|
|
|
// Mock express Request and Response
|
|
mockRequest = {
|
|
cookies: {}
|
|
};
|
|
|
|
mockResponse = {
|
|
cookie: jest.fn()
|
|
};
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clean up environment
|
|
delete process.env.JWT_SECRET;
|
|
delete process.env.JWT_EXPIRY;
|
|
});
|
|
|
|
describe('create', () => {
|
|
it('should create a valid JWT token and set cookie', () => {
|
|
// Arrange
|
|
const payload: TokenPayload = {
|
|
userId: 'user-123',
|
|
authLevel: 1 as const,
|
|
userStatus: UserState.VERIFIED_REGULAR,
|
|
orgId: 'org-456'
|
|
};
|
|
|
|
// Act
|
|
const token = jwtService.create(payload, mockResponse as Response);
|
|
|
|
// Assert
|
|
expect(token).toBeDefined();
|
|
expect(typeof token).toBe('string');
|
|
expect(token.split('.')).toHaveLength(3); // JWT has 3 parts
|
|
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
|
'auth_token',
|
|
token,
|
|
expect.objectContaining({
|
|
httpOnly: true,
|
|
sameSite: 'strict',
|
|
maxAge: 86400000 // 24 hours in milliseconds
|
|
})
|
|
);
|
|
});
|
|
|
|
it('should create different tokens for different payloads', () => {
|
|
// Arrange
|
|
const payload1: TokenPayload = {
|
|
userId: 'user-1',
|
|
authLevel: 0 as const,
|
|
userStatus: UserState.VERIFIED_REGULAR,
|
|
orgId: 'org-1'
|
|
};
|
|
|
|
const payload2: TokenPayload = {
|
|
userId: 'user-2',
|
|
authLevel: 1 as const,
|
|
userStatus: UserState.VERIFIED_PREMIUM,
|
|
orgId: 'org-2'
|
|
};
|
|
|
|
// Act
|
|
const token1 = jwtService.create(payload1, mockResponse as Response);
|
|
const token2 = jwtService.create(payload2, mockResponse as Response);
|
|
|
|
// Assert
|
|
expect(token1).toBeDefined();
|
|
expect(token2).toBeDefined();
|
|
expect(token1).not.toBe(token2);
|
|
});
|
|
|
|
it('should set secure cookie in production environment', () => {
|
|
// Arrange
|
|
process.env.NODE_ENV = 'production';
|
|
const payload: TokenPayload = {
|
|
userId: 'user-123',
|
|
authLevel: 1 as const,
|
|
userStatus: UserState.VERIFIED_REGULAR,
|
|
orgId: 'org-456'
|
|
};
|
|
|
|
// Act
|
|
const token = jwtService.create(payload, mockResponse as Response);
|
|
|
|
// Assert
|
|
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
|
'auth_token',
|
|
token,
|
|
expect.objectContaining({
|
|
secure: true
|
|
})
|
|
);
|
|
|
|
// Clean up
|
|
delete process.env.NODE_ENV;
|
|
});
|
|
});
|
|
|
|
describe('verify', () => {
|
|
it('should verify a valid token from cookies', () => {
|
|
// Arrange
|
|
const payload: TokenPayload = {
|
|
userId: 'user-123',
|
|
authLevel: 1 as const,
|
|
userStatus: UserState.VERIFIED_REGULAR,
|
|
orgId: 'org-456'
|
|
};
|
|
|
|
const token = jwtService.create(payload, mockResponse as Response);
|
|
mockRequest.cookies = { auth_token: token };
|
|
|
|
// Act
|
|
const result = jwtService.verify(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(result).toBeDefined();
|
|
expect(result!.userId).toBe('user-123');
|
|
expect(result!.authLevel).toBe(1);
|
|
expect(result!.orgId).toBe('org-456');
|
|
});
|
|
|
|
it('should return null when no token is present in cookies', () => {
|
|
// Arrange
|
|
mockRequest.cookies = {};
|
|
|
|
// Act
|
|
const result = jwtService.verify(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null for invalid token', () => {
|
|
// Arrange
|
|
mockRequest.cookies = { auth_token: 'invalid.jwt.token' };
|
|
|
|
// Act
|
|
const result = jwtService.verify(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('should return null for malformed token', () => {
|
|
// Arrange
|
|
mockRequest.cookies = { auth_token: 'not-a-jwt-token' };
|
|
|
|
// Act
|
|
const result = jwtService.verify(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('token creation with different payloads', () => {
|
|
it('should create tokens with dynamic user data', () => {
|
|
// Arrange
|
|
const timestamp = Date.now();
|
|
const testPayload: TokenPayload = {
|
|
userId: `test-user-${timestamp}`,
|
|
authLevel: 1 as const,
|
|
userStatus: UserState.VERIFIED_REGULAR,
|
|
orgId: `test-org-${timestamp}`
|
|
};
|
|
|
|
// Act
|
|
const token = jwtService.create(testPayload, mockResponse as Response);
|
|
|
|
// Assert
|
|
expect(token).toBeDefined();
|
|
expect(typeof token).toBe('string');
|
|
expect(mockResponse.cookie).toHaveBeenCalled();
|
|
|
|
// Verify we can decode it back
|
|
mockRequest.cookies = { auth_token: token };
|
|
const verifiedPayload = jwtService.verify(mockRequest as Request);
|
|
|
|
expect(verifiedPayload).toBeDefined();
|
|
expect(verifiedPayload!.userId).toBe(testPayload.userId);
|
|
expect(verifiedPayload!.orgId).toBe(testPayload.orgId);
|
|
expect(verifiedPayload!.authLevel).toBe(testPayload.authLevel);
|
|
});
|
|
|
|
it('should create different tokens for different timestamps', async () => {
|
|
// Arrange
|
|
const timestamp1 = Date.now();
|
|
const payload1: TokenPayload = {
|
|
userId: `test-user-${timestamp1}`,
|
|
authLevel: 0 as const,
|
|
userStatus: UserState.VERIFIED_REGULAR,
|
|
orgId: `test-org-${timestamp1}`
|
|
};
|
|
|
|
// Add a small delay to ensure different timestamps
|
|
await new Promise(resolve => setTimeout(resolve, 1));
|
|
|
|
const timestamp2 = Date.now();
|
|
const payload2: TokenPayload = {
|
|
userId: `test-user-${timestamp2}`,
|
|
authLevel: 1 as const,
|
|
userStatus: UserState.VERIFIED_PREMIUM,
|
|
orgId: `test-org-${timestamp2}`
|
|
};
|
|
|
|
// Act
|
|
const token1 = jwtService.create(payload1, mockResponse as Response);
|
|
const token2 = jwtService.create(payload2, mockResponse as Response);
|
|
|
|
// Assert
|
|
expect(token1).not.toBe(token2);
|
|
expect(payload1.userId).not.toBe(payload2.userId);
|
|
expect(payload1.orgId).not.toBe(payload2.orgId);
|
|
});
|
|
});
|
|
|
|
describe('integration scenarios', () => {
|
|
it('should create and verify token in complete flow', () => {
|
|
// Arrange
|
|
const originalPayload: TokenPayload = {
|
|
userId: 'integration-user',
|
|
authLevel: 1 as const,
|
|
userStatus: UserState.VERIFIED_REGULAR,
|
|
orgId: 'integration-org'
|
|
};
|
|
|
|
// Act - Complete flow
|
|
const token = jwtService.create(originalPayload, mockResponse as Response);
|
|
mockRequest.cookies = { auth_token: token };
|
|
const verifiedPayload = jwtService.verify(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(token).toBeDefined();
|
|
expect(verifiedPayload).toBeDefined();
|
|
expect(verifiedPayload!.userId).toBe('integration-user');
|
|
expect(verifiedPayload!.authLevel).toBe(1);
|
|
expect(verifiedPayload!.orgId).toBe('integration-org');
|
|
});
|
|
});
|
|
|
|
describe('JWT_EXPIRATION duration parsing', () => {
|
|
it('should parse JWT_EXPIRATION in hours format', () => {
|
|
// Arrange
|
|
delete process.env.JWT_EXPIRY;
|
|
process.env.JWT_EXPIRATION = '2h';
|
|
|
|
// Act
|
|
const newJwtService = new JWTService();
|
|
const payload: TokenPayload = {
|
|
userId: 'user-123',
|
|
authLevel: 1 as const,
|
|
userStatus: UserState.VERIFIED_REGULAR,
|
|
orgId: 'org-456'
|
|
};
|
|
|
|
const token = newJwtService.create(payload, mockResponse as Response);
|
|
|
|
// Update mock request with the created token
|
|
mockRequest.cookies = { auth_token: token };
|
|
const verifiedPayload = newJwtService.verify(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(token).toBeDefined();
|
|
expect(verifiedPayload).toBeDefined();
|
|
expect(verifiedPayload!.exp).toBeDefined();
|
|
|
|
// Token should expire in approximately 2 hours (7200 seconds)
|
|
const expectedExp = Math.floor(Date.now() / 1000) + 7200;
|
|
expect(verifiedPayload!.exp).toBeCloseTo(expectedExp, -1); // Within 10 seconds
|
|
|
|
// Cleanup
|
|
delete process.env.JWT_EXPIRATION;
|
|
});
|
|
|
|
it('should parse JWT_EXPIRATION in days format', () => {
|
|
// Arrange
|
|
delete process.env.JWT_EXPIRY;
|
|
process.env.JWT_EXPIRATION = '7d';
|
|
|
|
// Act
|
|
const newJwtService = new JWTService();
|
|
const payload: TokenPayload = {
|
|
userId: 'user-123',
|
|
authLevel: 1 as const,
|
|
userStatus: UserState.VERIFIED_REGULAR,
|
|
orgId: 'org-456'
|
|
};
|
|
|
|
const token = newJwtService.create(payload, mockResponse as Response);
|
|
|
|
// Update mock request with the created token
|
|
mockRequest.cookies = { auth_token: token };
|
|
const verifiedPayload = newJwtService.verify(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(token).toBeDefined();
|
|
expect(verifiedPayload).toBeDefined();
|
|
|
|
// Token should expire in approximately 7 days (604800 seconds)
|
|
const expectedExp = Math.floor(Date.now() / 1000) + 604800;
|
|
expect(verifiedPayload!.exp).toBeCloseTo(expectedExp, -1); // Within 10 seconds
|
|
|
|
// Cleanup
|
|
delete process.env.JWT_EXPIRATION;
|
|
});
|
|
|
|
it('should parse JWT_EXPIRATION in minutes format', () => {
|
|
// Arrange
|
|
delete process.env.JWT_EXPIRY;
|
|
process.env.JWT_EXPIRATION = '30m';
|
|
|
|
// Act
|
|
const newJwtService = new JWTService();
|
|
const payload: TokenPayload = {
|
|
userId: 'user-123',
|
|
authLevel: 1 as const,
|
|
userStatus: UserState.VERIFIED_REGULAR,
|
|
orgId: 'org-456'
|
|
};
|
|
|
|
const token = newJwtService.create(payload, mockResponse as Response);
|
|
|
|
// Update mock request with the created token
|
|
mockRequest.cookies = { auth_token: token };
|
|
const verifiedPayload = newJwtService.verify(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(token).toBeDefined();
|
|
expect(verifiedPayload).toBeDefined();
|
|
|
|
// Token should expire in approximately 30 minutes (1800 seconds)
|
|
const expectedExp = Math.floor(Date.now() / 1000) + 1800;
|
|
expect(verifiedPayload!.exp).toBeCloseTo(expectedExp, -1); // Within 10 seconds
|
|
|
|
// Cleanup
|
|
delete process.env.JWT_EXPIRATION;
|
|
});
|
|
|
|
it('should prioritize JWT_EXPIRY over JWT_EXPIRATION when both are set', () => {
|
|
// Arrange
|
|
process.env.JWT_EXPIRY = '1800'; // 30 minutes in seconds
|
|
process.env.JWT_EXPIRATION = '1h'; // 1 hour
|
|
|
|
// Act
|
|
const newJwtService = new JWTService();
|
|
const payload: TokenPayload = {
|
|
userId: 'user-123',
|
|
authLevel: 1 as const,
|
|
userStatus: UserState.VERIFIED_REGULAR,
|
|
orgId: 'org-456'
|
|
};
|
|
|
|
const token = newJwtService.create(payload, mockResponse as Response);
|
|
|
|
// Update mock request with the created token
|
|
mockRequest.cookies = { auth_token: token };
|
|
const verifiedPayload = newJwtService.verify(mockRequest as Request);
|
|
|
|
// Assert
|
|
expect(token).toBeDefined();
|
|
expect(verifiedPayload).toBeDefined();
|
|
|
|
// Should use JWT_EXPIRY (1800 seconds), not JWT_EXPIRATION (3600 seconds)
|
|
const expectedExp = Math.floor(Date.now() / 1000) + 1800;
|
|
expect(verifiedPayload!.exp).toBeCloseTo(expectedExp, -1); // Within 10 seconds
|
|
|
|
// Cleanup
|
|
delete process.env.JWT_EXPIRY;
|
|
delete process.env.JWT_EXPIRATION;
|
|
});
|
|
|
|
it('should throw error for invalid JWT_EXPIRATION format', () => {
|
|
// Arrange
|
|
delete process.env.JWT_EXPIRY;
|
|
process.env.JWT_EXPIRATION = 'invalid-format';
|
|
|
|
// Act & Assert
|
|
expect(() => {
|
|
new JWTService();
|
|
}).toThrow('Invalid duration format: invalid-format. Use format like \'24h\', \'7d\', \'30m\'');
|
|
|
|
// Cleanup
|
|
delete process.env.JWT_EXPIRATION;
|
|
});
|
|
});
|
|
});
|