411 lines
No EOL
14 KiB
JavaScript
411 lines
No EOL
14 KiB
JavaScript
import { jest } from '@jest/globals';
|
|
|
|
// Mock dependencies
|
|
jest.unstable_mockModule('../dist/utils/behavioral-detection.js', () => ({
|
|
behavioralDetection: {
|
|
config: { enabled: true, Responses: {} },
|
|
isBlocked: jest.fn(),
|
|
getRateLimit: jest.fn(),
|
|
analyzeRequest: jest.fn()
|
|
}
|
|
}));
|
|
|
|
jest.unstable_mockModule('../dist/utils/network.js', () => ({
|
|
getRealIP: jest.fn()
|
|
}));
|
|
|
|
jest.unstable_mockModule('../dist/utils/logs.js', () => ({
|
|
plugin: jest.fn(),
|
|
error: jest.fn()
|
|
}));
|
|
|
|
// Import the modules after mocking
|
|
const BehavioralDetectionMiddleware = (await import('../dist/utils/behavioral-middleware.js')).default;
|
|
const { behavioralDetection } = await import('../dist/utils/behavioral-detection.js');
|
|
const { getRealIP } = await import('../dist/utils/network.js');
|
|
const logs = await import('../dist/utils/logs.js');
|
|
|
|
describe('Behavioral Middleware', () => {
|
|
let mockReq, mockRes, mockNext;
|
|
let activeTimeouts = [];
|
|
let activeImmediates = [];
|
|
|
|
// Track async operations for cleanup
|
|
const originalSetTimeout = global.setTimeout;
|
|
const originalSetImmediate = global.setImmediate;
|
|
|
|
global.setTimeout = (fn, delay, ...args) => {
|
|
const id = originalSetTimeout(fn, delay, ...args);
|
|
activeTimeouts.push(id);
|
|
return id;
|
|
};
|
|
|
|
global.setImmediate = (fn, ...args) => {
|
|
const id = originalSetImmediate(fn, ...args);
|
|
activeImmediates.push(id);
|
|
return id;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
activeTimeouts = [];
|
|
activeImmediates = [];
|
|
|
|
// Mock Express request object
|
|
mockReq = {
|
|
url: '/api/test',
|
|
method: 'GET',
|
|
headers: {
|
|
'user-agent': 'test-agent',
|
|
'x-forwarded-for': '192.168.1.1'
|
|
},
|
|
ip: '192.168.1.1'
|
|
};
|
|
|
|
// Mock Express response object
|
|
mockRes = {
|
|
statusCode: 200,
|
|
status: jest.fn().mockReturnThis(),
|
|
setHeader: jest.fn().mockReturnThis(),
|
|
end: jest.fn(),
|
|
json: jest.fn(),
|
|
send: jest.fn(),
|
|
locals: {}
|
|
};
|
|
|
|
// Mock next function
|
|
mockNext = jest.fn();
|
|
|
|
// Default mock returns
|
|
getRealIP.mockReturnValue('192.168.1.1');
|
|
behavioralDetection.isBlocked.mockResolvedValue({ blocked: false });
|
|
behavioralDetection.getRateLimit.mockResolvedValue(null);
|
|
behavioralDetection.analyzeRequest.mockResolvedValue({ totalScore: 0, patterns: [] });
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clear any pending timeouts and immediates
|
|
activeTimeouts.forEach(id => clearTimeout(id));
|
|
activeImmediates.forEach(id => clearImmediate(id));
|
|
activeTimeouts = [];
|
|
activeImmediates = [];
|
|
});
|
|
|
|
afterAll(() => {
|
|
// Restore original functions
|
|
global.setTimeout = originalSetTimeout;
|
|
global.setImmediate = originalSetImmediate;
|
|
});
|
|
|
|
describe('plugin creation', () => {
|
|
test('should create a behavioral detection middleware plugin', () => {
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
|
|
expect(plugin.name).toBe('behavioral-detection');
|
|
expect(plugin.priority).toBe(90);
|
|
expect(typeof plugin.middleware).toBe('function');
|
|
});
|
|
});
|
|
|
|
describe('middleware execution', () => {
|
|
test('should skip processing when behavioral detection is disabled', async () => {
|
|
behavioralDetection.config.enabled = false;
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(mockNext).toHaveBeenCalled();
|
|
expect(behavioralDetection.isBlocked).not.toHaveBeenCalled();
|
|
|
|
// Restore enabled state
|
|
behavioralDetection.config.enabled = true;
|
|
});
|
|
|
|
test('should process request when behavioral detection is enabled', async () => {
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(mockNext).toHaveBeenCalled();
|
|
expect(getRealIP).toHaveBeenCalledWith(mockReq);
|
|
});
|
|
|
|
test('should capture client IP correctly', async () => {
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
getRealIP.mockReturnValue('10.0.0.1');
|
|
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(getRealIP).toHaveBeenCalledWith(mockReq);
|
|
});
|
|
});
|
|
|
|
describe('blocking functionality', () => {
|
|
test('should block requests from blocked IPs', async () => {
|
|
behavioralDetection.isBlocked.mockResolvedValue({
|
|
blocked: true,
|
|
reason: 'Malicious activity detected'
|
|
});
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
// Trigger response capture by calling end and wait for async processing
|
|
mockRes.end();
|
|
|
|
// Wait for setImmediate and async processing to complete
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(403);
|
|
expect(mockRes.setHeader).toHaveBeenCalledWith('X-Behavioral-Block', 'true');
|
|
expect(mockRes.setHeader).toHaveBeenCalledWith('X-Block-Reason', 'Malicious activity detected');
|
|
expect(logs.plugin).toHaveBeenCalledWith('behavioral', expect.stringContaining('Blocked IP'));
|
|
});
|
|
|
|
test('should use default block message when none configured', async () => {
|
|
behavioralDetection.isBlocked.mockResolvedValue({
|
|
blocked: true,
|
|
reason: 'Suspicious activity'
|
|
});
|
|
behavioralDetection.config.Responses.BlockMessage = undefined;
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.end();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(403);
|
|
});
|
|
|
|
test('should handle blocked IP without reason', async () => {
|
|
behavioralDetection.isBlocked.mockResolvedValue({
|
|
blocked: true
|
|
});
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.end();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(mockRes.setHeader).toHaveBeenCalledWith('X-Block-Reason', 'suspicious activity');
|
|
});
|
|
});
|
|
|
|
describe('rate limiting functionality', () => {
|
|
test('should apply rate limiting when limit exceeded', async () => {
|
|
// Make sure isBlocked returns false so rate limiting is checked
|
|
behavioralDetection.isBlocked.mockResolvedValue({ blocked: false });
|
|
behavioralDetection.getRateLimit.mockResolvedValue({
|
|
exceeded: true,
|
|
requests: 150,
|
|
limit: 100,
|
|
window: 60000,
|
|
resetTime: Date.now() + 60000
|
|
});
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.end();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(mockRes.status).toHaveBeenCalledWith(429);
|
|
expect(mockRes.setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', '100');
|
|
expect(mockRes.setHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', '0');
|
|
expect(mockRes.setHeader).toHaveBeenCalledWith('Retry-After', '60');
|
|
expect(logs.plugin).toHaveBeenCalledWith('behavioral', expect.stringContaining('Rate limit exceeded'));
|
|
});
|
|
|
|
test('should set rate limit headers for non-exceeded limits', async () => {
|
|
behavioralDetection.isBlocked.mockResolvedValue({ blocked: false });
|
|
behavioralDetection.getRateLimit.mockResolvedValue({
|
|
exceeded: false,
|
|
requests: 50,
|
|
limit: 100,
|
|
resetTime: Date.now() + 30000
|
|
});
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.end();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(mockRes.setHeader).toHaveBeenCalledWith('X-RateLimit-Limit', '100');
|
|
expect(mockRes.setHeader).toHaveBeenCalledWith('X-RateLimit-Remaining', '50');
|
|
expect(mockRes.status).not.toHaveBeenCalledWith(429);
|
|
});
|
|
});
|
|
|
|
describe('behavioral analysis', () => {
|
|
test('should analyze request and set behavioral headers', async () => {
|
|
behavioralDetection.isBlocked.mockResolvedValue({ blocked: false });
|
|
behavioralDetection.getRateLimit.mockResolvedValue(null);
|
|
behavioralDetection.analyzeRequest.mockResolvedValue({
|
|
totalScore: 25,
|
|
patterns: [
|
|
{ name: 'rapid_requests', score: 15 },
|
|
{ name: 'suspicious_user_agent', score: 10 }
|
|
]
|
|
});
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.end();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(behavioralDetection.analyzeRequest).toHaveBeenCalledWith(
|
|
'192.168.1.1',
|
|
mockReq,
|
|
expect.objectContaining({
|
|
status: 200,
|
|
responseTime: expect.any(Number)
|
|
})
|
|
);
|
|
|
|
expect(mockRes.setHeader).toHaveBeenCalledWith('X-Behavioral-Score', '25');
|
|
expect(mockRes.setHeader).toHaveBeenCalledWith('X-Behavioral-Patterns', 'rapid_requests, suspicious_user_agent');
|
|
});
|
|
|
|
test('should store behavioral signals in response locals', async () => {
|
|
behavioralDetection.isBlocked.mockResolvedValue({ blocked: false });
|
|
behavioralDetection.getRateLimit.mockResolvedValue(null);
|
|
const analysis = {
|
|
totalScore: 10,
|
|
patterns: [{ name: 'test_pattern', score: 10 }]
|
|
};
|
|
behavioralDetection.analyzeRequest.mockResolvedValue(analysis);
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.end();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(mockRes.locals.behavioralSignals).toEqual(analysis);
|
|
});
|
|
|
|
test('should not set headers when no patterns detected', async () => {
|
|
behavioralDetection.isBlocked.mockResolvedValue({ blocked: false });
|
|
behavioralDetection.getRateLimit.mockResolvedValue(null);
|
|
behavioralDetection.analyzeRequest.mockResolvedValue({
|
|
totalScore: 0,
|
|
patterns: []
|
|
});
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.end();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
expect(mockRes.setHeader).not.toHaveBeenCalledWith('X-Behavioral-Score', expect.anything());
|
|
expect(mockRes.setHeader).not.toHaveBeenCalledWith('X-Behavioral-Patterns', expect.anything());
|
|
});
|
|
});
|
|
|
|
describe('response method interception', () => {
|
|
test('should intercept res.end() calls', async () => {
|
|
const originalEnd = mockRes.end;
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.end('test data');
|
|
|
|
// Should have called the original method
|
|
expect(originalEnd).toHaveBeenCalledWith('test data');
|
|
});
|
|
|
|
test('should intercept res.json() calls', async () => {
|
|
const originalJson = mockRes.json;
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
const testData = { test: 'data' };
|
|
mockRes.json(testData);
|
|
|
|
expect(originalJson).toHaveBeenCalledWith(testData);
|
|
});
|
|
|
|
test('should intercept res.send() calls', async () => {
|
|
const originalSend = mockRes.send;
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.send('test response');
|
|
|
|
expect(originalSend).toHaveBeenCalledWith('test response');
|
|
});
|
|
});
|
|
|
|
describe('error handling', () => {
|
|
test('should handle errors in behavioral analysis gracefully', async () => {
|
|
behavioralDetection.analyzeRequest.mockRejectedValue(new Error('Analysis failed'));
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.end();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setTimeout(resolve, 10)); // Give error handling time
|
|
|
|
expect(logs.error).toHaveBeenCalledWith('behavioral', expect.stringContaining('Error in behavioral analysis'));
|
|
expect(mockNext).toHaveBeenCalled(); // Should not block request flow
|
|
});
|
|
|
|
test('should handle errors in isBlocked check', async () => {
|
|
behavioralDetection.isBlocked.mockRejectedValue(new Error('Block check failed'));
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.end();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
expect(logs.error).toHaveBeenCalledWith('behavioral', expect.stringContaining('Error in behavioral analysis'));
|
|
});
|
|
|
|
test('should fail open for availability on errors', async () => {
|
|
behavioralDetection.isBlocked.mockRejectedValue(new Error('Service unavailable'));
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
expect(mockNext).toHaveBeenCalled();
|
|
// Should not block the request even if behavioral detection fails
|
|
});
|
|
});
|
|
|
|
describe('response locals handling', () => {
|
|
test('should handle missing response locals gracefully', async () => {
|
|
delete mockRes.locals;
|
|
|
|
const plugin = BehavioralDetectionMiddleware();
|
|
await plugin.middleware(mockReq, mockRes, mockNext);
|
|
|
|
mockRes.end();
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
await new Promise(resolve => setImmediate(resolve));
|
|
|
|
// Should not throw error even without locals
|
|
expect(mockNext).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|