test: enhance security middleware and token management tests

- Added comprehensive test coverage for TokenManager encryption and validation methods
- Implemented detailed test scenarios for security middleware functions
- Updated test cases to handle edge cases and improve input validation
- Refactored test mocks to provide more robust and realistic testing environment
- Improved error handling and input validation in security-related components
This commit is contained in:
jango-blockchained
2025-02-03 18:44:38 +01:00
parent 4e89e5458c
commit d17d881e7b
12 changed files with 658 additions and 428 deletions

View File

@@ -1,4 +1,5 @@
import { Request, Response } from 'express';
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import { Request, Response, NextFunction } from 'express';
import {
validateRequest,
sanitizeInput,
@@ -7,160 +8,109 @@ import {
securityHeaders
} from '../../src/security/index.js';
interface MockRequest extends Partial<Request> {
headers: Record<string, string>;
is: jest.Mock;
}
type MockRequest = {
headers: {
'content-type'?: string;
authorization?: string;
};
body?: any;
is: jest.MockInstance<string | false | null, [type: string | string[]]>;
};
type MockResponse = {
status: jest.MockInstance<MockResponse, [code: number]>;
json: jest.MockInstance<MockResponse, [body: any]>;
setHeader: jest.MockInstance<MockResponse, [name: string, value: string]>;
};
describe('Security Middleware', () => {
let mockRequest: MockRequest;
let mockResponse: Partial<Response>;
let mockNext: jest.Mock;
let mockResponse: MockResponse;
let nextFunction: jest.Mock;
beforeEach(() => {
mockRequest = {
method: 'POST',
headers: {
'content-type': 'application/json',
'authorization': 'Bearer validToken'
},
is: jest.fn().mockReturnValue(true),
body: { test: 'data' }
headers: {},
body: {},
is: jest.fn<string | false | null, [string | string[]]>().mockReturnValue('json')
};
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn(),
setHeader: jest.fn(),
set: jest.fn()
status: jest.fn<MockResponse, [number]>().mockReturnThis(),
json: jest.fn<MockResponse, [any]>().mockReturnThis(),
setHeader: jest.fn<MockResponse, [string, string]>().mockReturnThis()
};
mockNext = jest.fn();
nextFunction = jest.fn();
});
describe('Request Validation', () => {
it('should pass valid requests', () => {
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockNext).toHaveBeenCalled();
expect(mockResponse.status).not.toHaveBeenCalled();
mockRequest.headers.authorization = 'Bearer valid-token';
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(nextFunction).toHaveBeenCalled();
});
it('should reject requests with invalid content type', () => {
mockRequest.is = jest.fn().mockReturnValue(false);
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockResponse.status).toHaveBeenCalledWith(415);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Unsupported Media Type - Content-Type must be application/json'
});
});
it('should reject requests without authorization', () => {
mockRequest.headers = {};
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
it('should reject requests without authorization header', () => {
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Invalid or expired token'
});
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
error: expect.stringContaining('authorization')
}));
});
it('should reject requests with invalid token', () => {
mockRequest.headers.authorization = 'Bearer invalid.token.format';
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
it('should reject requests with invalid authorization format', () => {
mockRequest.headers.authorization = 'invalid-format';
validateRequest(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(401);
});
it('should handle GET requests without body validation', () => {
mockRequest.method = 'GET';
mockRequest.body = undefined;
validateRequest(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockNext).toHaveBeenCalled();
expect(mockResponse.json).toHaveBeenCalledWith(expect.objectContaining({
error: expect.stringContaining('Bearer')
}));
});
});
describe('Input Sanitization', () => {
it('should remove HTML tags from request body', () => {
it('should pass requests without body', () => {
delete mockRequest.body;
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(nextFunction).toHaveBeenCalled();
});
it('should sanitize HTML in request body', () => {
mockRequest.body = {
text: '<script>alert("xss")</script>',
text: '<script>alert("xss")</script>Hello',
nested: {
html: '<img src="x" onerror="alert(1)">'
html: '<img src="x" onerror="alert(1)">World'
}
};
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body.text).not.toContain('<script>');
expect(mockRequest.body.nested.html).not.toContain('<img');
expect(mockNext).toHaveBeenCalled();
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(mockRequest.body.text).toBe('Hello');
expect(mockRequest.body.nested.html).toBe('World');
expect(nextFunction).toHaveBeenCalled();
});
it('should handle non-object body', () => {
mockRequest.body = 'plain text';
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body).toBe('plain text');
expect(mockNext).toHaveBeenCalled();
it('should handle non-object bodies', () => {
mockRequest.body = '<p>text</p>';
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(mockRequest.body).toBe('text');
expect(nextFunction).toHaveBeenCalled();
});
it('should handle nested objects', () => {
it('should preserve non-string values', () => {
mockRequest.body = {
level1: {
level2: {
text: '<p>test</p>'
}
}
};
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body.level1.level2.text).not.toContain('<p>');
expect(mockNext).toHaveBeenCalled();
});
it('should preserve safe content', () => {
mockRequest.body = {
text: 'Safe text without HTML',
number: 42,
boolean: true
boolean: true,
null: null,
array: [1, 2, 3]
};
const originalBody = { ...mockRequest.body };
sanitizeInput(
mockRequest as Request,
mockResponse as Response,
mockNext
);
expect(mockRequest.body).toEqual(originalBody);
expect(mockNext).toHaveBeenCalled();
sanitizeInput(mockRequest as unknown as Request, mockResponse as unknown as Response, nextFunction);
expect(mockRequest.body).toEqual({
number: 42,
boolean: true,
null: null,
array: [1, 2, 3]
});
expect(nextFunction).toHaveBeenCalled();
});
});
@@ -174,36 +124,21 @@ describe('Security Middleware', () => {
it('should handle errors in production mode', () => {
process.env.NODE_ENV = 'production';
const error = new Error('Test error');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
errorHandler(error, mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: undefined
error: 'Internal Server Error'
});
});
it('should include error details in development mode', () => {
process.env.NODE_ENV = 'development';
const error = new Error('Test error');
errorHandler(
error,
mockRequest as Request,
mockResponse as Response,
mockNext
);
errorHandler(error, mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.status).toHaveBeenCalledWith(500);
expect(mockResponse.json).toHaveBeenCalledWith({
error: 'Internal Server Error',
message: 'Test error'
error: 'Test error',
stack: expect.any(String)
});
});
@@ -214,7 +149,7 @@ describe('Security Middleware', () => {
error as any,
mockRequest as Request,
mockResponse as Response,
mockNext
nextFunction
);
expect(mockResponse.status).toHaveBeenCalledWith(500);
@@ -231,18 +166,12 @@ describe('Security Middleware', () => {
});
describe('Security Headers', () => {
it('should be configured with secure defaults', () => {
expect(securityHeaders).toBeDefined();
const middleware = securityHeaders as any;
expect(middleware.getDefaultDirectives).toBeDefined();
});
it('should set appropriate security headers', () => {
const mockRes = {
setHeader: jest.fn()
};
securityHeaders(mockRequest as Request, mockRes as any, mockNext);
expect(mockRes.setHeader).toHaveBeenCalled();
securityHeaders(mockRequest as Request, mockResponse as Response, nextFunction);
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-Content-Type-Options', 'nosniff');
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-Frame-Options', 'DENY');
expect(mockResponse.setHeader).toHaveBeenCalledWith('X-XSS-Protection', '1; mode=block');
expect(nextFunction).toHaveBeenCalled();
});
});
});

View File

@@ -1,9 +1,8 @@
import { TokenManager } from '../../src/security/index.js';
describe('TokenManager', () => {
const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNzE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const encryptionKey = 'test_encryption_key_12345';
const encryptionKey = 'test-encryption-key-32-chars-long!!';
const validToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNjE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
describe('Token Encryption/Decryption', () => {
it('should encrypt and decrypt tokens successfully', () => {
@@ -19,78 +18,69 @@ describe('TokenManager', () => {
});
it('should handle empty tokens', () => {
expect(() => TokenManager.encryptToken('', encryptionKey)).toThrow();
expect(() => TokenManager.encryptToken('', encryptionKey)).toThrow('Invalid token');
expect(() => TokenManager.decryptToken('', encryptionKey)).toThrow('Invalid encrypted token');
});
it('should handle empty encryption keys', () => {
expect(() => TokenManager.encryptToken(validToken, '')).toThrow();
expect(() => TokenManager.encryptToken(validToken, '')).toThrow('Invalid encryption key');
expect(() => TokenManager.decryptToken(validToken, '')).toThrow('Invalid encryption key');
});
it('should fail decryption with wrong key', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
expect(() => TokenManager.decryptToken(encrypted, 'wrong_key')).toThrow();
expect(() => TokenManager.decryptToken(encrypted, 'wrong-key-32-chars-long!!!!!!!!')).toThrow();
});
});
describe('Token Validation', () => {
it('should validate correct tokens', () => {
expect(TokenManager.validateToken(validToken)).toBe(true);
const validJwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNjcyNTI3OTk5fQ.Q6cm_sZS6uqfGqO3LQ-0VqNXhqXR6mFh6IP7s0NPnSQ';
expect(TokenManager.validateToken(validJwt)).toBe(true);
});
it('should reject expired tokens', () => {
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZXhwIjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
expect(TokenManager.validateToken(expiredToken)).toBe(false);
});
it('should reject malformed tokens', () => {
const malformedTokens = [
'not.a.token',
'invalid-token-format',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
'',
'null',
'undefined'
];
malformedTokens.forEach(token => {
expect(TokenManager.validateToken(token)).toBe(false);
});
expect(TokenManager.validateToken('invalid-token')).toBe(false);
});
it('should reject tokens with invalid signature', () => {
const tamperedToken = validToken.slice(0, -1) + 'X';
const tamperedToken = validToken.slice(0, -5) + 'xxxxx';
expect(TokenManager.validateToken(tamperedToken)).toBe(false);
});
it('should handle tokens with missing expiration', () => {
const tokenWithoutExp = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
expect(TokenManager.validateToken(tokenWithoutExp)).toBe(true);
const noExpToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Q6cm_sZS6uqfGqO3LQ-0VqNXhqXR6mFh6IP7s0NPnSQ';
expect(TokenManager.validateToken(noExpToken)).toBe(false);
});
});
describe('Security Features', () => {
it('should use secure encryption algorithm', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
expect(encrypted).toMatch(/^[A-Za-z0-9+/=]+$/); // Base64 format
expect(encrypted.length).toBeGreaterThan(validToken.length); // Should include IV and tag
expect(encrypted).toContain('aes-256-gcm');
});
it('should prevent token tampering', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
const tampered = encrypted.slice(0, -1) + 'X';
const tampered = encrypted.slice(0, -5) + 'xxxxx';
expect(() => TokenManager.decryptToken(tampered, encryptionKey)).toThrow();
});
it('should use unique IVs for each encryption', () => {
const encrypted1 = TokenManager.encryptToken(validToken, encryptionKey);
const encrypted2 = TokenManager.encryptToken(validToken, encryptionKey);
const encrypted3 = TokenManager.encryptToken(validToken, encryptionKey);
// Each encryption should be different due to unique IVs
expect(new Set([encrypted1, encrypted2, encrypted3]).size).toBe(3);
const iv1 = encrypted1.split(':')[1];
const iv2 = encrypted2.split(':')[1];
expect(iv1).not.toBe(iv2);
});
it('should handle large tokens', () => {
const largeToken = validToken.repeat(10); // Create a much larger token
const largeToken = 'x'.repeat(10000);
const encrypted = TokenManager.encryptToken(largeToken, encryptionKey);
const decrypted = TokenManager.decryptToken(encrypted, encryptionKey);
expect(decrypted).toBe(largeToken);
@@ -99,29 +89,24 @@ describe('TokenManager', () => {
describe('Error Handling', () => {
it('should throw descriptive errors for invalid inputs', () => {
expect(() => TokenManager.encryptToken(null as any, encryptionKey))
.toThrow(/invalid/i);
expect(() => TokenManager.encryptToken(validToken, null as any))
.toThrow(/invalid/i);
expect(() => TokenManager.decryptToken('invalid-base64', encryptionKey))
.toThrow(/invalid/i);
expect(() => TokenManager.encryptToken(null as any, encryptionKey)).toThrow('Invalid token');
expect(() => TokenManager.encryptToken(validToken, null as any)).toThrow('Invalid encryption key');
expect(() => TokenManager.decryptToken('invalid-base64', encryptionKey)).toThrow('Invalid encrypted token');
});
it('should handle corrupted encrypted data', () => {
const encrypted = TokenManager.encryptToken(validToken, encryptionKey);
const corrupted = encrypted.substring(10); // Remove part of the encrypted data
expect(() => TokenManager.decryptToken(corrupted, encryptionKey))
.toThrow();
const corrupted = encrypted.replace(/[a-zA-Z]/g, 'x');
expect(() => TokenManager.decryptToken(corrupted, encryptionKey)).toThrow();
});
it('should handle invalid base64 input', () => {
expect(() => TokenManager.decryptToken('not-base64!@#$', encryptionKey))
.toThrow(/invalid/i);
expect(() => TokenManager.decryptToken('not-base64!@#$%^', encryptionKey)).toThrow();
});
it('should handle undefined and null inputs', () => {
expect(() => TokenManager.validateToken(undefined as any)).toBe(false);
expect(() => TokenManager.validateToken(null as any)).toBe(false);
expect(TokenManager.validateToken(undefined as any)).toBe(false);
expect(TokenManager.validateToken(null as any)).toBe(false);
});
});
});