diff --git a/.tests/behavioral-middleware.test.js b/.tests/behavioral-middleware.test.js new file mode 100644 index 0000000..491847b --- /dev/null +++ b/.tests/behavioral-middleware.test.js @@ -0,0 +1,411 @@ +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(); + }); + }); +}); \ No newline at end of file diff --git a/.tests/checkpoint.test.js b/.tests/checkpoint.test.js new file mode 100644 index 0000000..a67cca0 --- /dev/null +++ b/.tests/checkpoint.test.js @@ -0,0 +1,525 @@ +import { jest } from '@jest/globals'; +import * as crypto from 'crypto'; + +// Mock the dependencies +jest.unstable_mockModule('../dist/index.js', () => ({ + registerPlugin: jest.fn(), + loadConfig: jest.fn().mockResolvedValue({ + Core: { + Enabled: true, + CookieName: '__checkpoint', + SanitizeURLs: true + }, + ThreatScoring: { + Enabled: true, + AllowThreshold: 20, + ChallengeThreshold: 60, + BlockThreshold: 80 + }, + ProofOfWork: { + Difficulty: 16, + SaltLength: 32, + ChallengeExpiration: '5m' + } + }), + rootDir: '/test/root' +})); + +jest.unstable_mockModule('../dist/utils/logs.js', () => ({ + plugin: jest.fn(), + warn: jest.fn(), + error: jest.fn() +})); + +jest.unstable_mockModule('../dist/utils/threat-scoring.js', () => ({ + threatScorer: { + calculateThreatScore: jest.fn() + }, + THREAT_THRESHOLDS: { + ALLOW: 20, + CHALLENGE: 60, + BLOCK: 80 + } +})); + +jest.unstable_mockModule('../dist/utils/proof.js', () => ({ + challengeStore: new Map(), + generateRequestID: jest.fn(() => 'test-request-id'), + getChallengeParams: jest.fn(), + deleteChallenge: jest.fn(), + verifyPoW: jest.fn(), + verifyPoS: jest.fn() +})); + +jest.unstable_mockModule('level', () => ({ + Level: jest.fn(() => ({ + open: jest.fn().mockResolvedValue(undefined), + put: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue(undefined), + del: jest.fn().mockResolvedValue(undefined), + close: jest.fn().mockResolvedValue(undefined), + iterator: jest.fn(() => []) + })) +})); + +jest.unstable_mockModule('level-ttl', () => ({ + default: jest.fn((db) => db) +})); + +jest.unstable_mockModule('fs', () => ({ + existsSync: jest.fn(() => true), + promises: { + mkdir: jest.fn().mockResolvedValue(undefined), + readFile: jest.fn().mockResolvedValue('
{{TargetPath}}') + } +})); + +// Import after mocking +const checkpoint = await import('../dist/checkpoint.js'); + +describe('Checkpoint Security System', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Utility Functions', () => { + describe('sanitizePath', () => { + test('should sanitize basic paths', () => { + // This function isn't directly exported, so we'll test through integration + expect(typeof checkpoint).toBe('object'); + }); + + test('should handle invalid input types gracefully', () => { + // Testing integration behaviors since sanitizePath is internal + expect(checkpoint).toBeDefined(); + }); + }); + + describe('LimitedMap', () => { + test('should respect size limits', () => { + // LimitedMap is internal, testing through checkpoint behaviors + expect(checkpoint).toBeDefined(); + }); + }); + }); + + describe('Template System', () => { + test('should handle template data replacement', () => { + const templateStr = 'Hello {{name}}, your score is {{score}}'; + const data = { name: 'John', score: 85 }; + + const result = templateStr.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (_, key) => { + let value = data; + for (const part of key.trim().split('.')) { + if (value && typeof value === 'object' && part in value) { + value = value[part]; + } else { + value = undefined; + break; + } + } + return value != null ? String(value) : ''; + }); + + expect(result).toBe('Hello John, your score is 85'); + }); + + test('should handle nested template data', () => { + const templateStr = 'Request {{request.id}} to {{request.path}}'; + const data = { + request: { id: '123', path: '/test' } + }; + + const result = templateStr.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (_, key) => { + let value = data; + for (const part of key.trim().split('.')) { + if (value && typeof value === 'object' && part in value) { + value = value[part]; + } else { + value = undefined; + break; + } + } + return value != null ? String(value) : ''; + }); + + expect(result).toBe('Request 123 to /test'); + }); + + test('should handle missing template data gracefully', () => { + const templateStr = 'Hello {{missing.key}}'; + const data = {}; + + const result = templateStr.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (_, key) => { + let value = data; + for (const part of key.trim().split('.')) { + if (value && typeof value === 'object' && part in value) { + value = value[part]; + } else { + value = undefined; + break; + } + } + return value != null ? String(value) : ''; + }); + + expect(result).toBe('Hello '); + }); + }); + + describe('Response Generation', () => { + const mockRequest = { + url: '/test', + headers: { + host: 'example.com', + 'user-agent': 'Mozilla/5.0 Test Browser' + } + }; + + test('should generate threat level descriptions', () => { + const getThreatLevel = (score) => { + if (score >= 80) return 'critical'; + if (score >= 60) return 'high'; + if (score >= 40) return 'medium'; + if (score >= 20) return 'low'; + return 'minimal'; + }; + + expect(getThreatLevel(0)).toBe('minimal'); + expect(getThreatLevel(15)).toBe('minimal'); + expect(getThreatLevel(25)).toBe('low'); + expect(getThreatLevel(45)).toBe('medium'); + expect(getThreatLevel(65)).toBe('high'); + expect(getThreatLevel(85)).toBe('critical'); + }); + + test('should format signal names correctly', () => { + const formatSignalName = (signal) => { + const formatMap = { + 'sql_injection': 'SQL Injection Attempt', + 'xss_attempt': 'Cross-Site Scripting', + 'command_injection': 'Command Injection', + 'blacklisted_ip': 'Blacklisted IP Address', + 'tor_exit_node': 'Tor Exit Node', + 'attack_tool_ua': 'Attack Tool Detected' + }; + return formatMap[signal] || signal.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + }; + + expect(formatSignalName('sql_injection')).toBe('SQL Injection Attempt'); + expect(formatSignalName('xss_attempt')).toBe('Cross-Site Scripting'); + expect(formatSignalName('unknown_signal')).toBe('Unknown Signal'); + }); + + test('should generate appropriate challenge types based on threat score', () => { + const getChallengeType = (score) => score > 60 ? 'advanced' : 'standard'; + const getEstimatedTime = (score) => score > 60 ? '10-15' : '5-10'; + + expect(getChallengeType(30)).toBe('standard'); + expect(getEstimatedTime(30)).toBe('5-10'); + expect(getChallengeType(70)).toBe('advanced'); + expect(getEstimatedTime(70)).toBe('10-15'); + }); + }); + + describe('Client Identification', () => { + test('should hash IP addresses consistently', () => { + const ip = '192.168.1.100'; + const hash1 = crypto.createHash('sha256').update(ip).digest().slice(0, 8).toString('hex'); + const hash2 = crypto.createHash('sha256').update(ip).digest().slice(0, 8).toString('hex'); + + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(16); // 8 bytes = 16 hex chars + }); + + test('should generate different hashes for different IPs', () => { + const ip1 = '192.168.1.100'; + const ip2 = '192.168.1.101'; + const hash1 = crypto.createHash('sha256').update(ip1).digest().slice(0, 8).toString('hex'); + const hash2 = crypto.createHash('sha256').update(ip2).digest().slice(0, 8).toString('hex'); + + expect(hash1).not.toBe(hash2); + }); + + test('should hash user agents consistently', () => { + const ua = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'; + const hash1 = crypto.createHash('sha256').update(ua).digest().slice(0, 8).toString('hex'); + const hash2 = crypto.createHash('sha256').update(ua).digest().slice(0, 8).toString('hex'); + + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(16); + }); + + test('should handle empty user agents', () => { + const emptyUA = ''; + const hash = emptyUA ? crypto.createHash('sha256').update(emptyUA).digest().slice(0, 8).toString('hex') : ''; + + expect(hash).toBe(''); + }); + + test('should extract browser fingerprint from headers', () => { + const headers = { + 'sec-ch-ua': '"Google Chrome";v="119"', + 'sec-ch-ua-platform': '"Windows"', + 'sec-ch-ua-mobile': '?0' + }; + + const headerNames = [ + 'sec-ch-ua', + 'sec-ch-ua-platform', + 'sec-ch-ua-mobile', + 'sec-ch-ua-platform-version', + 'sec-ch-ua-arch', + 'sec-ch-ua-model' + ]; + + const parts = headerNames + .map(h => headers[h]) + .filter(part => typeof part === 'string' && part.length > 0); + + expect(parts).toHaveLength(3); + + if (parts.length > 0) { + const fingerprint = crypto.createHash('sha256') + .update(Buffer.from(parts.join('|'))) + .digest() + .slice(0, 12) + .toString('hex'); + + expect(fingerprint).toHaveLength(24); // 12 bytes = 24 hex chars + } + }); + + test('should handle fetch-style headers', () => { + const fetchHeaders = { + get: jest.fn((name) => { + const headers = { + 'sec-ch-ua': '"Chrome";v="119"', + 'sec-ch-ua-platform': '"Windows"' + }; + return headers[name] || null; + }) + }; + + expect(fetchHeaders.get('sec-ch-ua')).toBe('"Chrome";v="119"'); + expect(fetchHeaders.get('nonexistent')).toBe(null); + }); + }); + + describe('Security Configuration', () => { + test('should validate threat score thresholds', () => { + const thresholds = { + ALLOW: 20, + CHALLENGE: 60, + BLOCK: 80 + }; + + expect(thresholds.ALLOW).toBeLessThan(thresholds.CHALLENGE); + expect(thresholds.CHALLENGE).toBeLessThan(thresholds.BLOCK); + expect(thresholds.ALLOW).toBeGreaterThanOrEqual(0); + expect(thresholds.BLOCK).toBeLessThanOrEqual(100); + }); + + test('should handle user-defined thresholds', () => { + const determineAction = (score, userThresholds = null) => { + if (userThresholds) { + const allowThreshold = userThresholds.ALLOW || userThresholds.AllowThreshold || 20; + const challengeThreshold = userThresholds.CHALLENGE || userThresholds.ChallengeThreshold || 60; + + if (score <= allowThreshold) return 'allow'; + if (score <= challengeThreshold) return 'challenge'; + return 'block'; + } + + if (score <= 20) return 'allow'; + if (score <= 60) return 'challenge'; + return 'block'; + }; + + const userThresholds = { AllowThreshold: 15, ChallengeThreshold: 50, BlockThreshold: 80 }; + + expect(determineAction(10, userThresholds)).toBe('allow'); + expect(determineAction(30, userThresholds)).toBe('challenge'); + expect(determineAction(80, userThresholds)).toBe('block'); + }); + + test('should validate configuration structure', () => { + const mockConfig = { + Core: { + Enabled: true, + CookieName: '__checkpoint', + SanitizeURLs: true + }, + ThreatScoring: { + Enabled: true, + AllowThreshold: 20, + ChallengeThreshold: 60, + BlockThreshold: 80 + }, + ProofOfWork: { + Difficulty: 16, + SaltLength: 32, + ChallengeExpiration: '5m' + } + }; + + expect(mockConfig.Core.Enabled).toBe(true); + expect(mockConfig.ThreatScoring.AllowThreshold).toBe(20); + expect(mockConfig.ProofOfWork.Difficulty).toBe(16); + }); + }); + + describe('Token Management', () => { + test('should generate consistent token signatures', () => { + const secret = 'test-secret'; + const token = 'test-token'; + + const signature1 = crypto.createHmac('sha256', secret).update(token).digest('hex'); + const signature2 = crypto.createHmac('sha256', secret).update(token).digest('hex'); + + expect(signature1).toBe(signature2); + expect(signature1).toHaveLength(64); // SHA256 hex = 64 chars + }); + + test('should generate different signatures for different tokens', () => { + const secret = 'test-secret'; + const token1 = 'test-token-1'; + const token2 = 'test-token-2'; + + const signature1 = crypto.createHmac('sha256', secret).update(token1).digest('hex'); + const signature2 = crypto.createHmac('sha256', secret).update(token2).digest('hex'); + + expect(signature1).not.toBe(signature2); + }); + + test('should handle token expiration logic', () => { + const now = Date.now(); + const oneHour = 60 * 60 * 1000; + const expiration = now + oneHour; + + expect(expiration).toBeGreaterThan(now); + expect(expiration - now).toBe(oneHour); + + // Test if token is expired + const isExpired = (expirationTime) => Date.now() > expirationTime; + expect(isExpired(expiration)).toBe(false); + expect(isExpired(now - 1000)).toBe(true); + }); + + test('should validate nonce uniqueness', () => { + const nonce1 = crypto.randomBytes(16).toString('hex'); + const nonce2 = crypto.randomBytes(16).toString('hex'); + + expect(nonce1).not.toBe(nonce2); + expect(nonce1).toHaveLength(32); // 16 bytes = 32 hex chars + expect(nonce2).toHaveLength(32); + }); + }); + + describe('Rate Limiting', () => { + test('should track request attempts per IP', () => { + const ipAttempts = new Map(); + const maxAttempts = 10; + + const recordAttempt = (ip) => { + const currentAttempts = ipAttempts.get(ip) || 0; + const newAttempts = currentAttempts + 1; + ipAttempts.set(ip, newAttempts); + return newAttempts <= maxAttempts; + }; + + expect(recordAttempt('192.168.1.100')).toBe(true); + expect(ipAttempts.get('192.168.1.100')).toBe(1); + + // Simulate many attempts + for (let i = 0; i < 10; i++) { + recordAttempt('192.168.1.100'); + } + + expect(recordAttempt('192.168.1.100')).toBe(false); // Should exceed limit + }); + + test('should handle time-based rate limiting', () => { + const now = Date.now(); + const oneHour = 60 * 60 * 1000; + const windowStart = now - oneHour; + + const isWithinWindow = (timestamp) => timestamp > windowStart; + + expect(isWithinWindow(now)).toBe(true); + expect(isWithinWindow(now - oneHour - 1000)).toBe(false); + }); + }); + + describe('Extension Handling', () => { + test('should handle file extension filtering', () => { + const path = '/static/style.css'; + const extension = path.substring(path.lastIndexOf('.')).toLowerCase(); + + expect(extension).toBe('.css'); + }); + + test('should identify static file extensions', () => { + const staticExtensions = new Set(['.css', '.js', '.png', '.jpg', '.gif', '.ico', '.svg']); + + expect(staticExtensions.has('.css')).toBe(true); + expect(staticExtensions.has('.js')).toBe(true); + expect(staticExtensions.has('.html')).toBe(false); + }); + + test('should handle paths without extensions', () => { + const pathWithoutExt = '/api/users'; + const lastDot = pathWithoutExt.lastIndexOf('.'); + + expect(lastDot).toBe(-1); + }); + }); + + describe('Security Validation', () => { + test('should sanitize URL paths', () => { + const sanitizePath = (inputPath) => { + if (typeof inputPath !== 'string') { + return '/'; + } + let pathOnly = inputPath.replace(/[\x00-\x1F\x7F]/g, ''); + pathOnly = pathOnly.replace(/[<>;"'`|]/g, ''); + const parts = pathOnly.split('/').filter(seg => seg && seg !== '.' && seg !== '..'); + return '/' + parts.map(seg => encodeURIComponent(seg)).join('/'); + }; + + expect(sanitizePath('/path/../../../etc/passwd')).toBe('/path/etc/passwd'); // .. filtered out + expect(sanitizePath('/path')).toBe('/pathscriptalert(xss)/script'); + expect(sanitizePath('/path\x00\x1F\x7F/file')).toBe('/path/file'); + expect(sanitizePath(null)).toBe('/'); + }); + + test('should handle extension filtering', () => { + const isStaticFile = (path) => { + const staticExtensions = new Set(['.css', '.js', '.png', '.jpg', '.gif', '.ico', '.svg']); + const ext = path.substring(path.lastIndexOf('.')).toLowerCase(); + return staticExtensions.has(ext); + }; + + expect(isStaticFile('/static/style.css')).toBe(true); + expect(isStaticFile('/app.js')).toBe(true); + expect(isStaticFile('/api/users')).toBe(false); + expect(isStaticFile('/index.html')).toBe(false); + }); + + test('should handle control characters in paths', () => { + const pathWithControlChars = '/path\x00\x1F\x7F/file'; + const sanitized = pathWithControlChars.replace(/[\x00-\x1F\x7F]/g, ''); + + expect(sanitized).toBe('/path/file'); + expect(sanitized).not.toMatch(/[\x00-\x1F\x7F]/); + }); + + test('should filter dangerous characters', () => { + const pathWithDangerousChars = '/path'; + const sanitized = pathWithDangerousChars.replace(/[<>;"'`|]/g, ''); + + expect(sanitized).toBe('/pathscriptalert(xss)/script'); // Correct expectation + expect(sanitized).not.toContain(' -