Massive v2 rewrite

This commit is contained in:
Caileb 2025-08-02 15:34:04 -05:00
parent 1025f3b523
commit 5f1328f626
77 changed files with 28105 additions and 3542 deletions

View file

@ -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();
});
});
});

525
.tests/checkpoint.test.js Normal file
View file

@ -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('<html><body>{{TargetPath}}</body></html>')
}
}));
// 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<script>alert("xss")</script>')).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<script>alert("xss")</script>';
const sanitized = pathWithDangerousChars.replace(/[<>;"'`|]/g, '');
expect(sanitized).toBe('/pathscriptalert(xss)/script'); // Correct expectation
expect(sanitized).not.toContain('<script>');
});
});
});

623
.tests/index.test.js Normal file
View file

@ -0,0 +1,623 @@
import { jest } from '@jest/globals';
// Mock all external dependencies before importing the module
const mockMkdir = jest.fn();
const mockReadFile = jest.fn();
const mockWriteFileSync = jest.fn();
const mockReadFileSync = jest.fn();
const mockUnlinkSync = jest.fn();
const mockExistsSync = jest.fn();
const mockReaddirSync = jest.fn();
jest.unstable_mockModule('fs/promises', () => ({
mkdir: mockMkdir,
readFile: mockReadFile
}));
jest.unstable_mockModule('fs', () => ({
writeFileSync: mockWriteFileSync,
readFileSync: mockReadFileSync,
unlinkSync: mockUnlinkSync,
existsSync: mockExistsSync,
readdirSync: mockReaddirSync
}));
const mockJoin = jest.fn();
const mockDirname = jest.fn();
const mockBasename = jest.fn();
jest.unstable_mockModule('path', () => ({
join: mockJoin,
dirname: mockDirname,
basename: mockBasename
}));
const mockFileURLToPath = jest.fn();
jest.unstable_mockModule('url', () => ({
fileURLToPath: mockFileURLToPath
}));
const mockSecureImportModule = jest.fn();
jest.unstable_mockModule('../dist/utils/plugins.js', () => ({
secureImportModule: mockSecureImportModule
}));
const mockLogs = {
section: jest.fn(),
init: jest.fn(),
config: jest.fn(),
error: jest.fn(),
msg: jest.fn(),
server: jest.fn()
};
jest.unstable_mockModule('../dist/utils/logs.js', () => mockLogs);
const mockApp = {
set: jest.fn(),
use: jest.fn(),
listen: jest.fn()
};
const mockExpress = jest.fn(() => mockApp);
mockExpress.json = jest.fn(() => (req, res, next) => next());
mockExpress.urlencoded = jest.fn(() => (req, res, next) => next());
mockExpress.static = jest.fn(() => (req, res, next) => next());
mockExpress.Router = jest.fn(() => ({
use: jest.fn()
}));
jest.unstable_mockModule('express', () => ({
default: mockExpress
}));
const mockServer = {
on: jest.fn(),
close: jest.fn(),
listen: jest.fn()
};
const mockCreateServer = jest.fn(() => mockServer);
jest.unstable_mockModule('http', () => ({
createServer: mockCreateServer
}));
const mockSpawn = jest.fn();
jest.unstable_mockModule('child_process', () => ({
spawn: mockSpawn
}));
jest.unstable_mockModule('dotenv', () => ({
config: jest.fn()
}));
// Mock process for command line argument testing
const originalArgv = process.argv;
const originalEnv = process.env;
const originalExit = process.exit;
const originalKill = process.kill;
const originalOn = process.on;
// Mock imports properly for ES modules
const mockTomlModule = {
parse: jest.fn(() => ({}))
};
jest.unstable_mockModule('@iarna/toml', () => ({
default: mockTomlModule
}));
// Import the module after all mocking is set up
let indexModule;
describe('Index (Main Application)', () => {
let mockProcess;
beforeAll(async () => {
// Import the module once with proper mocking setup
indexModule = await import('../dist/index.js');
});
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Setup default mock returns
mockFileURLToPath.mockReturnValue('/app/src/index.js');
mockDirname.mockReturnValue('/app/src');
mockJoin.mockImplementation((...parts) => parts.join('/'));
mockBasename.mockImplementation((path, ext) => {
const name = path.split('/').pop();
return ext ? name.replace(ext, '') : name;
});
mockExistsSync.mockReturnValue(false);
mockReaddirSync.mockReturnValue([]);
mockReadFile.mockResolvedValue('');
mockTomlModule.parse.mockReturnValue({});
// Setup process mocking
mockProcess = {
argv: ['node', 'index.js'],
env: { ...originalEnv },
exit: jest.fn(),
kill: jest.fn(),
on: jest.fn()
};
// Note: Plugin registry persists across tests in ES modules
// This is expected behavior in the test environment
});
afterEach(() => {
process.argv = originalArgv;
process.env = originalEnv;
process.exit = originalExit;
process.kill = originalKill;
process.on = originalOn;
});
describe('Plugin Registration System', () => {
test('should register plugin successfully', () => {
const pluginName = 'test-plugin';
const handler = { middleware: jest.fn() };
indexModule.registerPlugin(pluginName, handler);
const registeredNames = indexModule.getRegisteredPluginNames();
expect(registeredNames).toContain(pluginName);
});
test('should throw error for invalid plugin name', () => {
expect(() => {
indexModule.registerPlugin('', { middleware: jest.fn() });
}).toThrow('Plugin name must be a non-empty string');
expect(() => {
indexModule.registerPlugin(null, { middleware: jest.fn() });
}).toThrow('Plugin name must be a non-empty string');
});
test('should throw error for invalid handler', () => {
expect(() => {
indexModule.registerPlugin('test', null);
}).toThrow('Plugin handler must be an object');
expect(() => {
indexModule.registerPlugin('test', 'invalid');
}).toThrow('Plugin handler must be an object');
});
test('should prevent duplicate plugin registration', () => {
const pluginName = 'duplicate-plugin';
const handler = { middleware: jest.fn() };
indexModule.registerPlugin(pluginName, handler);
expect(() => {
indexModule.registerPlugin(pluginName, handler);
}).toThrow(`Plugin 'duplicate-plugin' is already registered`);
});
test('should load plugins in registration order', () => {
const handler1 = { middleware: jest.fn() };
const handler2 = { middleware: jest.fn() };
indexModule.registerPlugin('order-test-1', handler1);
indexModule.registerPlugin('order-test-2', handler2);
const handlers = indexModule.loadPlugins();
const registeredNames = indexModule.getRegisteredPluginNames();
// Check that our test plugins are in the registry
expect(registeredNames).toContain('order-test-1');
expect(registeredNames).toContain('order-test-2');
// Check that our handlers are in the handlers array
expect(handlers).toContain(handler1);
expect(handlers).toContain(handler2);
// Find the indices of our test handlers
const handler1Index = handlers.indexOf(handler1);
const handler2Index = handlers.indexOf(handler2);
// Verify order (handler1 should come before handler2)
expect(handler1Index).toBeLessThan(handler2Index);
});
test('should freeze plugin registry', () => {
const handler = { middleware: jest.fn() };
indexModule.registerPlugin('before-freeze', handler);
indexModule.freezePlugins();
expect(mockLogs.msg).toHaveBeenCalledWith('Plugin registration frozen');
});
});
describe('Configuration Loading', () => {
test('should load config successfully', async () => {
const configName = 'test-config';
const target = {};
const configData = { setting1: 'value1' };
mockReadFile.mockResolvedValue('setting1 = "value1"');
mockJoin.mockReturnValue('/app/src/config/test-config.toml');
// Mock the TOML parser
const mockTomlModule = {
default: {
parse: jest.fn(() => configData)
}
};
jest.unstable_mockModule('@iarna/toml', () => mockTomlModule);
await indexModule.loadConfig(configName, target);
expect(mockReadFile).toHaveBeenCalledWith('/app/src/config/test-config.toml', 'utf8');
expect(target).toEqual(configData);
});
test('should throw error for invalid config name', async () => {
await expect(indexModule.loadConfig('', {})).rejects.toThrow('Config name must be a non-empty string');
await expect(indexModule.loadConfig(null, {})).rejects.toThrow('Config name must be a non-empty string');
});
test('should throw error for invalid target', async () => {
await expect(indexModule.loadConfig('test', null)).rejects.toThrow('Config target must be an object');
await expect(indexModule.loadConfig('test', 'invalid')).rejects.toThrow('Config target must be an object');
});
test('should handle config loading errors', async () => {
const configName = 'failing-config';
const target = {};
mockReadFile.mockRejectedValue(new Error('File not found'));
await expect(indexModule.loadConfig(configName, target)).rejects.toThrow(
"Failed to load config 'failing-config': File not found"
);
});
});
describe('Command Line Argument Handling', () => {
test('should validate PID file operations for kill mode', () => {
// Test the logic for handling kill mode operations
mockExistsSync.mockReturnValue(true);
mockReadFileSync.mockReturnValue('1234');
mockJoin.mockReturnValue('/app/src/checkpoint.pid');
const pidContent = mockReadFileSync('checkpoint.pid', 'utf8').trim();
const pid = parseInt(pidContent, 10);
expect(pid).toBe(1234);
expect(isNaN(pid)).toBe(false);
expect(pid > 0).toBe(true);
});
test('should validate invalid PID handling', () => {
mockReadFileSync.mockReturnValue('invalid');
const pidContent = mockReadFileSync('checkpoint.pid', 'utf8').trim();
const pid = parseInt(pidContent, 10);
expect(isNaN(pid)).toBe(true);
});
test('should validate daemon spawn parameters', () => {
const mockChildProcess = {
pid: 5678,
unref: jest.fn()
};
mockSpawn.mockReturnValue(mockChildProcess);
const args = ['index.js'];
const nodeExecutable = 'node';
mockSpawn(nodeExecutable, args, {
detached: true,
stdio: 'ignore'
});
expect(mockSpawn).toHaveBeenCalledWith(
'node',
['index.js'],
expect.objectContaining({
detached: true,
stdio: 'ignore'
})
);
});
});
describe('Directory Initialization', () => {
test('should validate directory creation logic', async () => {
mockMkdir.mockResolvedValue(undefined);
const expectedDirs = [
'/app/src/data',
'/app/src/db',
'/app/src/config'
];
// Test the directory creation logic
for (const dirPath of expectedDirs) {
mockJoin.mockReturnValueOnce(dirPath);
await mockMkdir(dirPath, { recursive: true });
}
expect(mockMkdir).toHaveBeenCalledTimes(3);
expect(mockMkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true });
});
});
describe('Plugin Discovery', () => {
test('should discover plugins in correct order', () => {
mockExistsSync.mockImplementation(path => {
return path.includes('plugins');
});
mockReaddirSync.mockReturnValue([
'waf.js',
'basic-auth.js',
'ipfilter.js',
'proxy.js',
'rate-limit.js'
]);
mockBasename.mockImplementation((file, ext) =>
file.replace(ext || '', '')
);
// Plugin load order should be: ipfilter, waf, proxy, then alphabetical
const expectedOrder = ['ipfilter', 'waf', 'proxy', 'basic-auth', 'rate-limit'];
expect(mockReaddirSync).toHaveBeenCalled;
});
});
describe('Express Server Setup', () => {
test('should validate Express app creation', () => {
// Test Express app creation
const app = mockExpress();
expect(mockExpress).toHaveBeenCalled();
expect(app).toBeDefined();
expect(app.set).toBeDefined();
expect(app.use).toBeDefined();
});
test('should validate middleware configuration', () => {
// Test middleware setup logic
const app = mockExpress();
// Test trust proxy setting
app.set('trust proxy', true);
expect(app.set).toHaveBeenCalledWith('trust proxy', true);
// Test body parsing middleware
const jsonMiddleware = mockExpress.json({ limit: '10mb' });
const urlencodedMiddleware = mockExpress.urlencoded({ extended: true, limit: '10mb' });
expect(mockExpress.json).toHaveBeenCalledWith({ limit: '10mb' });
expect(mockExpress.urlencoded).toHaveBeenCalledWith({ extended: true, limit: '10mb' });
});
test('should handle WebSocket upgrade detection logic', () => {
const mockReq = {
headers: {
upgrade: 'websocket',
connection: 'Upgrade'
}
};
const mockRes = {};
const mockNext = jest.fn();
// Test WebSocket detection logic
const upgradeHeader = mockReq.headers.upgrade;
const connectionHeader = mockReq.headers.connection;
const isWebSocket = upgradeHeader === 'websocket' ||
(connectionHeader && connectionHeader.toLowerCase().includes('upgrade'));
if (isWebSocket) {
mockReq.isWebSocketRequest = true;
mockNext();
}
expect(mockReq.isWebSocketRequest).toBe(true);
expect(mockNext).toHaveBeenCalled();
});
});
describe('Environment Configuration', () => {
test('should handle production environment', () => {
process.env.NODE_ENV = 'production';
// In production, console.log should be disabled
// This is handled at module load time
expect(process.env.NODE_ENV).toBe('production');
});
test('should handle custom port configuration', () => {
process.env.PORT = '8080';
// Port validation would happen during server startup
expect(process.env.PORT).toBe('8080');
});
test('should use default port when not specified', () => {
delete process.env.PORT;
// Default port should be 3000
const expectedPort = 3000;
expect(expectedPort).toBe(3000);
});
});
describe('Error Handling', () => {
test('should validate plugin loading error handling', async () => {
const error = new Error('Plugin load failed');
mockSecureImportModule.mockRejectedValue(error);
// Test error handling logic
try {
await mockSecureImportModule('test-plugin.js');
} catch (e) {
expect(e.message).toBe('Plugin load failed');
}
expect(mockSecureImportModule).toHaveBeenCalled();
});
test('should validate config loading error handling', async () => {
const error = new Error('Config read failed');
mockReadFile.mockRejectedValue(error);
// Test config loading error handling
try {
await indexModule.loadConfig('test-config', {});
} catch (e) {
expect(e.message).toContain('Failed to load config');
expect(e.message).toContain('Config read failed');
}
});
test('should validate port number range', () => {
const invalidPorts = [-1, 0, 65536, 70000];
const validPorts = [80, 443, 3000, 8080, 65535];
invalidPorts.forEach(port => {
expect(isNaN(Number(port)) || Number(port) < 1 || Number(port) > 65535).toBe(true);
});
validPorts.forEach(port => {
expect(Number(port) >= 1 && Number(port) <= 65535).toBe(true);
});
});
});
describe('Exclusion Rules Processing', () => {
test('should compile exclusion patterns correctly', () => {
const exclusionRules = [
{
Path: '/api/health',
Hosts: ['localhost', '127.0.0.1'],
UserAgents: ['healthcheck.*']
}
];
// Test exclusion rule compilation logic
const compiledRule = {
...exclusionRules[0],
pathStartsWith: exclusionRules[0].Path,
hostsSet: new Set(exclusionRules[0].Hosts),
userAgentPatterns: exclusionRules[0].UserAgents.map(pattern => new RegExp(pattern, 'i'))
};
expect(compiledRule.hostsSet.has('localhost')).toBe(true);
expect(compiledRule.userAgentPatterns[0].test('healthcheck-bot')).toBe(true);
});
test('should handle invalid regex patterns in UserAgents', () => {
const invalidPattern = '[invalid regex';
try {
new RegExp(invalidPattern, 'i');
} catch (e) {
// Should handle invalid regex gracefully
const fallbackPattern = /(?!)/; // Never matches
expect(fallbackPattern.test('anything')).toBe(false);
}
});
});
describe('Static File Middleware', () => {
test('should validate static file setup for existing directories', () => {
const webfontPath = '/app/src/pages/interstitial/webfont';
const jsPath = '/app/src/pages/interstitial/js';
mockExistsSync.mockImplementation(path => {
return path === webfontPath || path === jsPath;
});
const router = mockExpress.Router();
// Test static directory setup logic
if (mockExistsSync(webfontPath)) {
router.use('/webfont', mockExpress.static(webfontPath, { maxAge: '7d' }));
}
if (mockExistsSync(jsPath)) {
router.use('/js', mockExpress.static(jsPath, { maxAge: '7d' }));
}
expect(mockExistsSync).toHaveBeenCalledWith(webfontPath);
expect(mockExistsSync).toHaveBeenCalledWith(jsPath);
expect(mockExpress.static).toHaveBeenCalledWith(webfontPath, { maxAge: '7d' });
expect(mockExpress.static).toHaveBeenCalledWith(jsPath, { maxAge: '7d' });
});
test('should skip static setup for non-existent directories', () => {
mockExistsSync.mockReturnValue(false);
const webfontPath = '/app/src/pages/interstitial/webfont';
const jsPath = '/app/src/pages/interstitial/js';
// Test logic when directories don't exist
const webfontExists = mockExistsSync(webfontPath);
const jsExists = mockExistsSync(jsPath);
expect(webfontExists).toBe(false);
expect(jsExists).toBe(false);
expect(mockExistsSync).toHaveBeenCalledTimes(2);
});
});
describe('Graceful Shutdown', () => {
test('should validate shutdown handler logic', () => {
const mockShutdownHandler = jest.fn();
const activeSockets = new Set();
const mockSocketInstance = {
destroy: jest.fn(),
setTimeout: jest.fn(),
setKeepAlive: jest.fn(),
on: jest.fn()
};
activeSockets.add(mockSocketInstance);
// Test shutdown logic
const isShuttingDown = false;
if (!isShuttingDown) {
activeSockets.forEach(sock => sock.destroy());
mockShutdownHandler();
}
expect(mockSocketInstance.destroy).toHaveBeenCalled();
expect(mockShutdownHandler).toHaveBeenCalled();
});
test('should validate socket management', () => {
const activeSockets = new Set();
const mockSocket = {
destroy: jest.fn(),
setTimeout: jest.fn(),
setKeepAlive: jest.fn(),
on: jest.fn()
};
// Test socket lifecycle
activeSockets.add(mockSocket);
expect(activeSockets.has(mockSocket)).toBe(true);
// Test socket configuration
mockSocket.setTimeout(120000);
mockSocket.setKeepAlive(true, 60000);
expect(mockSocket.setTimeout).toHaveBeenCalledWith(120000);
expect(mockSocket.setKeepAlive).toHaveBeenCalledWith(true, 60000);
// Test cleanup
activeSockets.delete(mockSocket);
expect(activeSockets.has(mockSocket)).toBe(false);
});
});
});

231
.tests/logs.test.js Normal file
View file

@ -0,0 +1,231 @@
import { jest } from '@jest/globals';
import {
init,
safeAsync,
safeSync,
plugin,
config,
db,
server,
section,
warn,
error,
msg
} from '../dist/utils/logs.js';
describe('Logs utilities', () => {
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation();
jest.spyOn(console, 'warn').mockImplementation();
jest.spyOn(console, 'error').mockImplementation();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('init', () => {
test('should log initialization message', () => {
init('Initializing Checkpoint...');
expect(console.log).toHaveBeenCalledWith('Initializing Checkpoint...');
});
});
describe('safeAsync', () => {
test('should return result of successful async operation', async () => {
const successfulOperation = async () => 'success';
const result = await safeAsync(successfulOperation, 'test', 'Failed to process');
expect(result).toBe('success');
});
test('should handle async errors and return fallback', async () => {
const failingOperation = async () => {
throw new Error('Operation failed');
};
const result = await safeAsync(failingOperation, 'test', 'Failed to process', 'fallback');
expect(result).toBe('fallback');
});
test('should return null by default on error', async () => {
const failingOperation = async () => {
throw new Error('Operation failed');
};
const result = await safeAsync(failingOperation, 'test', 'Failed to process');
expect(result).toBeNull();
});
test('should handle non-Error objects thrown', async () => {
const failingOperation = async () => {
throw 'string error';
};
const result = await safeAsync(failingOperation, 'test', 'Failed to process', 'fallback');
expect(result).toBe('fallback');
});
test('should handle undefined errors', async () => {
const failingOperation = async () => {
throw undefined;
};
const result = await safeAsync(failingOperation, 'test', 'Failed to process', 'fallback');
expect(result).toBe('fallback');
});
});
describe('safeSync', () => {
test('should return result of successful sync operation', () => {
const successfulOperation = () => 'success';
const result = safeSync(successfulOperation, 'test', 'Failed to process');
expect(result).toBe('success');
});
test('should handle sync errors and return fallback', () => {
const failingOperation = () => {
throw new Error('Operation failed');
};
const result = safeSync(failingOperation, 'test', 'Failed to process', 'fallback');
expect(result).toBe('fallback');
});
test('should return null by default on error', () => {
const failingOperation = () => {
throw new Error('Operation failed');
};
const result = safeSync(failingOperation, 'test', 'Failed to process');
expect(result).toBeNull();
});
test('should handle non-Error objects thrown', () => {
const failingOperation = () => {
throw 'string error';
};
const result = safeSync(failingOperation, 'test', 'Failed to process', 'fallback');
expect(result).toBe('fallback');
});
test('should handle number errors', () => {
const failingOperation = () => {
throw 42;
};
const result = safeSync(failingOperation, 'test', 'Failed to process', 'fallback');
expect(result).toBe('fallback');
});
});
describe('plugin', () => {
test('should log plugin message', () => {
plugin('test-plugin', 'Plugin loaded successfully');
expect(console.log).toHaveBeenCalledWith('Plugin loaded successfully');
});
test('should ignore plugin name parameter', () => {
plugin('ignored-name', 'Test message');
expect(console.log).toHaveBeenCalledWith('Test message');
});
});
describe('config', () => {
test('should log config message for first occurrence', () => {
config('database', 'Database configuration loaded');
expect(console.log).toHaveBeenCalledWith('Config Database configuration loaded for database');
});
test('should not log duplicate config messages', () => {
config('test-config', 'Test configuration loaded');
config('test-config', 'Test configuration loaded');
expect(console.log).toHaveBeenCalledTimes(1);
});
test('should log different config names', () => {
config('database-unique1', 'Database config');
config('server-unique1', 'Server config');
expect(console.log).toHaveBeenCalledTimes(2);
expect(console.log).toHaveBeenCalledWith('Config Database config for database-unique1');
expect(console.log).toHaveBeenCalledWith('Config Server config for server-unique1');
});
});
describe('db', () => {
test('should log database message', () => {
db('Database connection established');
expect(console.log).toHaveBeenCalledWith('Database connection established');
});
});
describe('server', () => {
test('should log server message', () => {
server('Server started on port 3000');
expect(console.log).toHaveBeenCalledWith('Server started on port 3000');
});
});
describe('section', () => {
test('should log section header with uppercase title', () => {
section('initialization');
expect(console.log).toHaveBeenCalledWith('\n=== INITIALIZATION ===');
});
test('should handle already uppercase titles', () => {
section('ALREADY_UPPER');
expect(console.log).toHaveBeenCalledWith('\n=== ALREADY_UPPER ===');
});
test('should handle mixed case titles', () => {
section('MiXeD_cAsE');
expect(console.log).toHaveBeenCalledWith('\n=== MIXED_CASE ===');
});
});
describe('warn', () => {
test('should log warning with prefix', () => {
warn('security', 'Potential security issue detected');
expect(console.warn).toHaveBeenCalledWith('WARNING: Potential security issue detected');
});
test('should ignore category parameter', () => {
warn('ignored-category', 'Warning message');
expect(console.warn).toHaveBeenCalledWith('WARNING: Warning message');
});
});
describe('error', () => {
test('should log error with prefix', () => {
error('database', 'Failed to connect to database');
expect(console.error).toHaveBeenCalledWith('ERROR: Failed to connect to database');
});
test('should ignore category parameter', () => {
error('ignored-category', 'Error message');
expect(console.error).toHaveBeenCalledWith('ERROR: Error message');
});
});
describe('msg', () => {
test('should log general message', () => {
msg('General information message');
expect(console.log).toHaveBeenCalledWith('General information message');
});
});
describe('edge cases', () => {
test('should handle empty messages', () => {
msg('');
warn('category', '');
error('category', '');
expect(console.log).toHaveBeenCalledWith('');
expect(console.warn).toHaveBeenCalledWith('WARNING: ');
expect(console.error).toHaveBeenCalledWith('ERROR: ');
});
test('should handle special characters in messages', () => {
msg('Message with émojis 🚀 and spëcial chars!');
expect(console.log).toHaveBeenCalledWith('Message with émojis 🚀 and spëcial chars!');
});
test('should handle very long messages', () => {
const longMessage = 'a'.repeat(1000);
msg(longMessage);
expect(console.log).toHaveBeenCalledWith(longMessage);
});
});
});

426
.tests/network.test.js Normal file
View file

@ -0,0 +1,426 @@
import { jest } from '@jest/globals';
// Mock the logs module
jest.unstable_mockModule('../dist/utils/logs.js', () => ({
warn: jest.fn(),
error: jest.fn()
}));
// Import modules after mocking
const { getRequestURL, getRealIP } = await import('../dist/utils/network.js');
const logs = await import('../dist/utils/logs.js');
describe('Network utilities', () => {
describe('getRequestURL', () => {
test('should handle http URLs', () => {
const request = { url: 'http://example.com/path' };
const result = getRequestURL(request);
expect(result).toBeInstanceOf(URL);
expect(result.href).toBe('http://example.com/path');
});
test('should handle https URLs', () => {
const request = { url: 'https://example.com/path' };
const result = getRequestURL(request);
expect(result).toBeInstanceOf(URL);
expect(result.href).toBe('https://example.com/path');
});
test('should construct URL from path and host header', () => {
const request = {
url: '/path',
headers: { host: 'example.com' }
};
const result = getRequestURL(request);
expect(result).toBeInstanceOf(URL);
expect(result.href).toBe('http://example.com/path');
});
test('should use https when secure property is true', () => {
const request = {
url: '/path',
secure: true,
headers: { host: 'example.com' }
};
const result = getRequestURL(request);
expect(result.href).toBe('https://example.com/path');
});
test('should use https when x-forwarded-proto is https', () => {
const request = {
url: '/path',
headers: {
host: 'example.com',
'x-forwarded-proto': 'https'
}
};
const result = getRequestURL(request);
expect(result.href).toBe('https://example.com/path');
});
test('should handle Fetch-style headers with get() method', () => {
const request = {
url: '/path',
headers: {
get: jest.fn((name) => {
if (name === 'host') return 'example.com';
return null;
})
}
};
const result = getRequestURL(request);
expect(result.href).toBe('http://example.com/path');
});
test('should return null for missing URL', () => {
const request = {};
const result = getRequestURL(request);
expect(result).toBeNull();
});
test('should return null for invalid URL', () => {
const request = {
url: '/test',
headers: { host: 'localhost' }
};
// Mock URL constructor to throw
const originalURL = global.URL;
global.URL = function() {
throw new TypeError('Invalid URL');
};
const result = getRequestURL(request);
expect(result).toBeNull();
expect(logs.warn).toHaveBeenCalled();
// Restore
global.URL = originalURL;
});
test('should handle non-Error objects in catch block', () => {
const request = {
url: '/test',
headers: { host: 'localhost' }
};
// Mock URL constructor to throw non-Error
const originalURL = global.URL;
global.URL = function() {
throw "String error instead of Error object";
};
const result = getRequestURL(request);
expect(result).toBeNull();
// Restore
global.URL = originalURL;
});
test('should handle array host headers in Express requests', () => {
const request = {
url: '/path',
headers: { host: ['example.com', 'backup.com'] } // Array host
};
const result = getRequestURL(request);
expect(result.href).toBe('http://example.com/path');
});
test('should handle empty array host headers', () => {
const request = {
url: '/path',
headers: { host: [] } // Empty array
};
const result = getRequestURL(request);
expect(result.href).toBe('http://localhost/path'); // Falls back to localhost
});
test('should handle x-forwarded-host with Fetch-style headers', () => {
const request = {
url: '/path',
headers: {
get: jest.fn((name) => {
if (name === 'host') return null;
if (name === 'x-forwarded-host') return 'forwarded.example.com';
return null;
})
}
};
const result = getRequestURL(request);
expect(result.href).toBe('http://forwarded.example.com/path');
});
test('should fallback to localhost for Fetch-style headers when all host headers are missing', () => {
const request = {
url: '/path',
headers: {
get: jest.fn(() => null) // All headers return null
}
};
const result = getRequestURL(request);
expect(result.href).toBe('http://localhost/path');
});
test('should handle x-forwarded-host in Express headers', () => {
const request = {
url: '/path',
headers: {
'x-forwarded-host': 'forwarded.example.com'
// No host header
}
};
const result = getRequestURL(request);
expect(result.href).toBe('http://forwarded.example.com/path');
});
test('should handle array x-forwarded-host headers', () => {
const request = {
url: '/path',
headers: {
'x-forwarded-host': ['forwarded.example.com', 'backup.example.com']
}
};
const result = getRequestURL(request);
expect(result.href).toBe('http://forwarded.example.com/path');
});
test('should handle empty array x-forwarded-host headers', () => {
const request = {
url: '/path',
headers: {
'x-forwarded-host': []
}
};
const result = getRequestURL(request);
expect(result.href).toBe('http://localhost/path');
});
});
describe('getRealIP', () => {
test('should extract IP from x-forwarded-for header', () => {
const request = {
headers: { 'x-forwarded-for': '192.168.1.100' }
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should extract first IP from comma-separated x-forwarded-for', () => {
const request = {
headers: { 'x-forwarded-for': '192.168.1.100, 10.0.0.1, 127.0.0.1' }
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should use x-real-ip when x-forwarded-for is missing', () => {
const request = {
headers: { 'x-real-ip': '192.168.1.100' }
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should handle Fetch-style headers using get() method', () => {
const request = {
headers: {
get: jest.fn((name) => {
if (name === 'x-forwarded-for') return '192.168.1.100';
return null;
})
}
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should use server remoteAddress when headers are empty', () => {
const request = { headers: {} };
const server = { remoteAddress: '192.168.1.100' };
const result = getRealIP(request, server);
expect(result).toBe('192.168.1.100');
});
test('should use connection.remoteAddress for Express requests', () => {
const request = {
headers: {},
connection: { remoteAddress: '192.168.1.100' }
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should use req.ip property when available', () => {
const request = {
headers: {},
ip: '192.168.1.100'
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should clean IPv6 mapped IPv4 addresses', () => {
const request = {
headers: { 'x-forwarded-for': '::ffff:192.168.1.100' }
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should return 127.0.0.1 as ultimate fallback', () => {
const request = { headers: {} };
const result = getRealIP(request);
expect(result).toBe('127.0.0.1');
});
test('should prioritize x-forwarded-for over other sources', () => {
const request = {
headers: { 'x-forwarded-for': '192.168.1.100' },
connection: { remoteAddress: '10.0.0.1' },
ip: '172.16.0.1'
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should handle array x-forwarded-for headers', () => {
const request = {
headers: { 'x-forwarded-for': ['192.168.1.100', '10.0.0.1'] }
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should handle array x-real-ip headers', () => {
const request = {
headers: { 'x-real-ip': ['192.168.1.100', '10.0.0.1'] }
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should use x-real-ip with Fetch-style headers when x-forwarded-for is missing', () => {
const request = {
headers: {
get: jest.fn((name) => {
if (name === 'x-forwarded-for') return null;
if (name === 'x-real-ip') return '192.168.1.100';
return null;
})
}
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should use socket.remoteAddress when connection.remoteAddress is missing', () => {
const request = {
headers: {},
connection: {}, // connection exists but no remoteAddress
socket: { remoteAddress: '192.168.1.100' }
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should handle missing connection but present socket', () => {
const request = {
headers: {},
socket: { remoteAddress: '192.168.1.100' }
};
const result = getRealIP(request);
// Note: socket.remoteAddress is only checked within the connection block
// When no connection exists, it falls back to URL hostname/127.0.0.1
expect(result).toBe('127.0.0.1');
});
test('should handle whitespace in comma-separated IPs', () => {
const request = {
headers: { 'x-forwarded-for': ' 192.168.1.100 , 10.0.0.1 , 127.0.0.1 ' }
};
const result = getRealIP(request);
expect(result).toBe('192.168.1.100');
});
test('should fallback to URL hostname when all IP sources fail', () => {
const request = {
headers: {},
url: '/test'
};
const result = getRealIP(request);
// getRequestURL constructs URL with localhost as default host, so hostname is 'localhost'
expect(result).toBe('localhost');
});
test('should handle undefined IP values gracefully', () => {
const request = {
headers: { 'x-forwarded-for': undefined, 'x-real-ip': undefined }
};
const result = getRealIP(request);
expect(result).toBe('127.0.0.1');
});
test('should handle null IP values gracefully', () => {
const request = {
headers: { 'x-forwarded-for': null, 'x-real-ip': null }
};
const result = getRealIP(request);
expect(result).toBe('127.0.0.1');
});
test('should handle empty string IP values', () => {
const request = {
headers: { 'x-forwarded-for': '', 'x-real-ip': '' }
};
const result = getRealIP(request);
expect(result).toBe('127.0.0.1');
});
test('should handle empty array header values', () => {
const request = {
headers: {
'x-forwarded-for': [],
'x-real-ip': []
}
};
const result = getRealIP(request);
expect(result).toBe('127.0.0.1');
});
});
});

719
.tests/performance.test.js Normal file
View file

@ -0,0 +1,719 @@
import { jest } from '@jest/globals';
import {
LRUCache,
RateLimiter,
ObjectPool,
BatchProcessor,
debounce,
throttle,
memoize,
StringMatcher,
ConnectionPool
} from '../dist/utils/performance.js';
describe('Performance utilities', () => {
describe('LRUCache', () => {
test('should store and retrieve values', () => {
const cache = new LRUCache(3);
cache.set('key1', 'value1');
cache.set('key2', 'value2');
expect(cache.get('key1')).toBe('value1');
expect(cache.get('key2')).toBe('value2');
expect(cache.get('nonexistent')).toBeUndefined();
});
test('should evict least recently used items when at capacity', () => {
const cache = new LRUCache(2);
cache.set('key1', 'value1');
cache.set('key2', 'value2');
cache.set('key3', 'value3'); // Should evict key1
expect(cache.get('key1')).toBeUndefined();
expect(cache.get('key2')).toBe('value2');
expect(cache.get('key3')).toBe('value3');
});
test('should handle TTL expiration', () => {
jest.useFakeTimers();
const cache = new LRUCache(10, 100); // 100ms TTL
cache.set('key1', 'value1');
expect(cache.get('key1')).toBe('value1');
jest.advanceTimersByTime(150);
expect(cache.get('key1')).toBeUndefined();
jest.useRealTimers();
});
test('should delete and clear items', () => {
const cache = new LRUCache(5);
cache.set('key1', 'value1');
cache.set('key2', 'value2');
expect(cache.delete('key1')).toBe(true);
expect(cache.get('key1')).toBeUndefined();
cache.clear();
expect(cache.size).toBe(0);
});
test('should clean up expired entries with cleanup method', () => {
jest.useFakeTimers();
const cache = new LRUCache(10, 100); // 100ms TTL
cache.set('key1', 'value1');
cache.set('key2', 'value2');
cache.set('key3', 'value3');
jest.advanceTimersByTime(150);
const cleaned = cache.cleanup();
expect(cleaned).toBe(3);
expect(cache.size).toBe(0);
jest.useRealTimers();
});
test('should handle has() method with TTL expiration', () => {
jest.useFakeTimers();
const cache = new LRUCache(10, 100); // 100ms TTL
cache.set('key1', 'value1');
expect(cache.has('key1')).toBe(true);
jest.advanceTimersByTime(150);
expect(cache.has('key1')).toBe(false);
jest.useRealTimers();
});
test('should cleanup without TTL should return 0', () => {
const cache = new LRUCache(10); // No TTL
cache.set('key1', 'value1');
cache.set('key2', 'value2');
const cleaned = cache.cleanup();
expect(cleaned).toBe(0);
expect(cache.size).toBe(2);
});
});
describe('RateLimiter', () => {
let limiterInstances = [];
afterEach(() => {
// Clean up instances to prevent Jest hanging
limiterInstances.forEach(limiter => {
if (limiter && typeof limiter.destroy === 'function') {
limiter.destroy();
}
});
limiterInstances = [];
});
test('should allow requests within limit', () => {
const limiter = new RateLimiter(1000, 2);
limiterInstances.push(limiter);
expect(limiter.isAllowed('user1')).toBe(true);
expect(limiter.isAllowed('user1')).toBe(true);
expect(limiter.isAllowed('user1')).toBe(false);
});
test('should reset after window expires', () => {
jest.useFakeTimers();
const limiter = new RateLimiter(100, 2);
limiterInstances.push(limiter);
expect(limiter.isAllowed('user1')).toBe(true);
expect(limiter.isAllowed('user1')).toBe(true);
expect(limiter.isAllowed('user1')).toBe(false);
jest.advanceTimersByTime(150);
expect(limiter.isAllowed('user1')).toBe(true);
});
test('should track different identifiers separately', () => {
const limiter = new RateLimiter(1000, 2);
limiterInstances.push(limiter);
expect(limiter.isAllowed('user1')).toBe(true);
expect(limiter.isAllowed('user1')).toBe(true);
expect(limiter.isAllowed('user1')).toBe(false);
expect(limiter.isAllowed('user2')).toBe(true);
expect(limiter.isAllowed('user2')).toBe(true);
expect(limiter.isAllowed('user2')).toBe(false);
});
test('should clean up expired entries manually', () => {
jest.useFakeTimers();
const limiter = new RateLimiter(100, 2);
limiterInstances.push(limiter);
limiter.isAllowed('user1');
limiter.isAllowed('user2');
limiter.isAllowed('user3');
jest.advanceTimersByTime(150);
const cleaned = limiter.cleanup();
expect(cleaned).toBeGreaterThan(0);
jest.useRealTimers();
});
test('should automatically clean up on interval', () => {
jest.useFakeTimers();
const limiter = new RateLimiter(100, 2);
limiterInstances.push(limiter);
limiter.isAllowed('user1');
limiter.isAllowed('user2');
jest.advanceTimersByTime(150);
// Trigger auto-cleanup (runs every 60 seconds)
jest.advanceTimersByTime(60000);
// Should still work after cleanup
expect(limiter.isAllowed('user1')).toBe(true);
jest.useRealTimers();
});
test('should handle cleanup of identifiers with partial expired requests', () => {
jest.useFakeTimers();
const limiter = new RateLimiter(200, 3);
limiterInstances.push(limiter);
// Make some requests
limiter.isAllowed('user1');
jest.advanceTimersByTime(100);
limiter.isAllowed('user1');
jest.advanceTimersByTime(150); // First request now expired, second still valid
const cleaned = limiter.cleanup();
expect(cleaned).toBe(0); // user1 still has valid requests, not removed
// Advance further to expire all requests
jest.advanceTimersByTime(100);
const cleaned2 = limiter.cleanup();
expect(cleaned2).toBe(1); // user1 removed
jest.useRealTimers();
});
});
describe('ObjectPool', () => {
test('should create and reuse objects', () => {
let created = 0;
const factory = () => ({ id: ++created });
const reset = (obj) => { obj.used = false; };
const pool = new ObjectPool(factory, reset);
const obj1 = pool.acquire();
expect(obj1.id).toBe(1);
pool.release(obj1);
const obj2 = pool.acquire();
expect(obj2.id).toBe(1); // Reused
expect(obj2.used).toBe(false); // Reset was called
});
test('should create new objects when pool is empty', () => {
let created = 0;
const factory = () => ({ id: ++created });
const reset = () => {};
const pool = new ObjectPool(factory, reset);
const obj1 = pool.acquire();
const obj2 = pool.acquire();
expect(obj1.id).toBe(1);
expect(obj2.id).toBe(2);
});
test('should not exceed max size', () => {
const factory = () => ({});
const reset = () => {};
const pool = new ObjectPool(factory, reset, 2);
const obj1 = pool.acquire();
const obj2 = pool.acquire();
const obj3 = pool.acquire();
pool.release(obj1);
pool.release(obj2);
pool.release(obj3);
const stats = pool.size;
expect(stats.available).toBeLessThanOrEqual(2);
});
test('should ignore release of objects not in use', () => {
const factory = () => ({ id: Math.random() });
const reset = () => {};
const pool = new ObjectPool(factory, reset);
const strangerObj = factory();
// Should not throw or affect pool
pool.release(strangerObj);
expect(pool.size.available).toBe(0);
expect(pool.size.inUse).toBe(0);
});
test('should clear all objects from pool', () => {
const factory = () => ({ id: Math.random() });
const reset = () => {};
const pool = new ObjectPool(factory, reset);
const obj1 = pool.acquire();
const obj2 = pool.acquire();
pool.release(obj1);
pool.clear();
const stats = pool.size;
expect(stats.available).toBe(0);
expect(stats.inUse).toBe(0);
expect(stats.total).toBe(0);
});
test('should provide accurate size statistics', () => {
const factory = () => ({ id: Math.random() });
const reset = () => {};
const pool = new ObjectPool(factory, reset);
const obj1 = pool.acquire();
const obj2 = pool.acquire();
pool.release(obj1);
const stats = pool.size;
expect(stats.available).toBe(1);
expect(stats.inUse).toBe(1);
expect(stats.total).toBe(2);
});
});
describe('BatchProcessor', () => {
let batcherInstances = [];
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
// Clean up any batcher instances to prevent memory leaks
batcherInstances.forEach(batcher => {
if (batcher && typeof batcher.destroy === 'function') {
batcher.destroy();
}
});
batcherInstances = [];
});
test('should process batch when size is reached', async () => {
const processor = jest.fn();
const batcher = new BatchProcessor(processor, { batchSize: 3 });
batcherInstances.push(batcher);
batcher.add('item1');
batcher.add('item2');
expect(processor).not.toHaveBeenCalled();
batcher.add('item3');
await Promise.resolve(); // Let async processing complete
expect(processor).toHaveBeenCalledWith(['item1', 'item2', 'item3']);
});
test('should auto-flush on interval', async () => {
const processor = jest.fn();
const batcher = new BatchProcessor(processor, {
batchSize: 10,
flushInterval: 100
});
batcherInstances.push(batcher);
batcher.add('item1');
batcher.add('item2');
jest.advanceTimersByTime(100);
await Promise.resolve();
expect(processor).toHaveBeenCalledWith(['item1', 'item2']);
});
test('should handle processing errors', async () => {
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
const processor = jest.fn().mockRejectedValue(new Error('Process error'));
const batcher = new BatchProcessor(processor, { batchSize: 1 });
batcherInstances.push(batcher);
batcher.add('item1');
await Promise.resolve();
expect(consoleErrorSpy).toHaveBeenCalledWith('Batch processing error:', expect.any(Error));
consoleErrorSpy.mockRestore();
});
test('should not flush when already processing', async () => {
let resolveProcessor;
const processor = jest.fn(() => new Promise(resolve => {
resolveProcessor = resolve;
}));
const batcher = new BatchProcessor(processor, { batchSize: 2 });
batcherInstances.push(batcher);
batcher.add('item1');
batcher.add('item2'); // Triggers flush
// Add more items while first batch is processing
batcher.add('item3');
batcher.flush(); // Should return early
expect(processor).toHaveBeenCalledTimes(1);
// Resolve the first batch
resolveProcessor();
await Promise.resolve();
expect(processor).toHaveBeenCalledWith(['item1', 'item2']);
});
test('should not flush empty queue', async () => {
const processor = jest.fn();
const batcher = new BatchProcessor(processor, { batchSize: 5 });
batcherInstances.push(batcher);
await batcher.flush();
expect(processor).not.toHaveBeenCalled();
});
});
describe('debounce', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('should delay function execution', () => {
const func = jest.fn();
const debounced = debounce(func, 100);
debounced('arg1');
debounced('arg2');
debounced('arg3');
expect(func).not.toHaveBeenCalled();
jest.advanceTimersByTime(100);
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith('arg3');
});
});
describe('throttle', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('should limit function execution rate', () => {
const func = jest.fn();
const throttled = throttle(func, 100);
throttled('arg1');
throttled('arg2');
throttled('arg3');
expect(func).toHaveBeenCalledTimes(1);
expect(func).toHaveBeenCalledWith('arg1');
jest.advanceTimersByTime(100);
throttled('arg4');
expect(func).toHaveBeenCalledTimes(2);
expect(func).toHaveBeenCalledWith('arg4');
});
});
describe('memoize', () => {
test('should cache function results', () => {
const func = jest.fn((a, b) => a + b);
const memoized = memoize(func);
expect(memoized(1, 2)).toBe(3);
expect(memoized(1, 2)).toBe(3);
expect(memoized(1, 2)).toBe(3);
expect(func).toHaveBeenCalledTimes(1);
});
test('should handle different arguments', () => {
const func = jest.fn((a, b) => a + b);
const memoized = memoize(func);
expect(memoized(1, 2)).toBe(3);
expect(memoized(2, 3)).toBe(5);
expect(func).toHaveBeenCalledTimes(2);
});
test('should respect TTL option', async () => {
jest.useFakeTimers();
const func = jest.fn((a) => a * 2);
const memoized = memoize(func, { ttl: 100 });
expect(memoized(5)).toBe(10);
expect(func).toHaveBeenCalledTimes(1);
expect(memoized(5)).toBe(10);
expect(func).toHaveBeenCalledTimes(1);
jest.advanceTimersByTime(150);
expect(memoized(5)).toBe(10);
expect(func).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
test('should handle undefined cached values correctly', () => {
const func = jest.fn(() => undefined);
const memoized = memoize(func);
expect(memoized('test')).toBeUndefined();
expect(memoized('test')).toBeUndefined();
// Function should be called twice since undefined is returned
expect(func).toHaveBeenCalledTimes(2);
});
test('should handle functions returning falsy values', () => {
const func = jest.fn((x) => x === 'zero' ? 0 : x === 'false' ? false : x === 'null' ? null : 'default');
const memoized = memoize(func);
expect(memoized('zero')).toBe(0);
expect(memoized('zero')).toBe(0); // Should be cached
expect(func).toHaveBeenCalledTimes(1);
expect(memoized('false')).toBe(false);
expect(memoized('false')).toBe(false); // Should be cached
expect(func).toHaveBeenCalledTimes(2);
expect(memoized('null')).toBe(null);
expect(memoized('null')).toBe(null); // Should be cached
expect(func).toHaveBeenCalledTimes(3);
});
});
describe('StringMatcher', () => {
test('should match strings case-insensitively', () => {
const matcher = new StringMatcher(['apple', 'BANANA', 'Cherry']);
expect(matcher.contains('apple')).toBe(true);
expect(matcher.contains('APPLE')).toBe(true);
expect(matcher.contains('banana')).toBe(true);
expect(matcher.contains('cherry')).toBe(true);
expect(matcher.contains('grape')).toBe(false);
});
test('should add and remove patterns', () => {
const matcher = new StringMatcher(['apple']);
matcher.add('banana');
expect(matcher.contains('banana')).toBe(true);
expect(matcher.size).toBe(2);
expect(matcher.remove('apple')).toBe(true);
expect(matcher.contains('apple')).toBe(false);
expect(matcher.size).toBe(1);
});
test('should check if any text matches with containsAny', () => {
const matcher = new StringMatcher(['apple', 'banana', 'cherry']);
expect(matcher.containsAny(['grape', 'orange'])).toBe(false);
expect(matcher.containsAny(['grape', 'apple'])).toBe(true);
expect(matcher.containsAny(['BANANA', 'orange'])).toBe(true);
expect(matcher.containsAny([])).toBe(false);
expect(matcher.containsAny(['cherry', 'apple', 'banana'])).toBe(true);
});
test('should handle empty patterns', () => {
const matcher = new StringMatcher([]);
expect(matcher.contains('anything')).toBe(false);
expect(matcher.containsAny(['test', 'values'])).toBe(false);
expect(matcher.size).toBe(0);
});
test('should remove non-existent patterns gracefully', () => {
const matcher = new StringMatcher(['apple']);
expect(matcher.remove('banana')).toBe(false);
expect(matcher.size).toBe(1);
});
});
describe('ConnectionPool', () => {
test('should create and reuse connections', () => {
const pool = new ConnectionPool({ maxConnections: 5 });
const conn1 = pool.getConnection('host1');
expect(conn1).not.toBeNull();
expect(conn1.host).toBe('host1');
pool.releaseConnection('host1', conn1);
const conn2 = pool.getConnection('host1');
expect(conn2).toBe(conn1); // Reused
});
test('should respect max connections limit', () => {
const pool = new ConnectionPool({ maxConnections: 2 });
const conn1 = pool.getConnection('host1');
const conn2 = pool.getConnection('host1');
const conn3 = pool.getConnection('host1');
expect(conn1).not.toBeNull();
expect(conn2).not.toBeNull();
expect(conn3).toBeNull(); // Pool exhausted
});
test('should create separate pools for different hosts', () => {
const pool = new ConnectionPool({ maxConnections: 2 });
const conn1 = pool.getConnection('host1');
const conn2 = pool.getConnection('host2');
expect(conn1).not.toBeNull();
expect(conn2).not.toBeNull();
expect(conn1.host).toBe('host1');
expect(conn2.host).toBe('host2');
});
test('should handle release of non-existent connections gracefully', () => {
const pool = new ConnectionPool({ maxConnections: 5 });
const fakeConn = { host: 'fake', created: Date.now() };
// Should not throw
pool.releaseConnection('host1', fakeConn);
pool.releaseConnection('nonexistent', fakeConn);
});
test('should close connections when pool is over half capacity', () => {
const pool = new ConnectionPool({ maxConnections: 4 });
// Spy on closeConnection method
const closeConnectionSpy = jest.spyOn(pool, 'closeConnection');
// Fill pool
const conn1 = pool.getConnection('host1');
const conn2 = pool.getConnection('host1');
const conn3 = pool.getConnection('host1');
// Release connections
pool.releaseConnection('host1', conn1); // Should keep (pool size 1)
pool.releaseConnection('host1', conn2); // Should keep (pool size 2 = maxConnections/2)
pool.releaseConnection('host1', conn3); // Should close (pool would exceed half capacity)
expect(closeConnectionSpy).toHaveBeenCalledTimes(1);
expect(closeConnectionSpy).toHaveBeenCalledWith(conn3);
closeConnectionSpy.mockRestore();
});
test('should provide access to connectionTimeout property', () => {
const pool = new ConnectionPool({ timeout: 5000 });
expect(pool.connectionTimeout).toBe(5000);
});
test('should destroy all connections when destroyed', () => {
const pool = new ConnectionPool({ maxConnections: 3 });
// Spy on closeConnection method
const closeConnectionSpy = jest.spyOn(pool, 'closeConnection');
// Create some connections
const conn1 = pool.getConnection('host1');
const conn2 = pool.getConnection('host1');
const conn3 = pool.getConnection('host2');
// Release one connection back to pool
pool.releaseConnection('host1', conn1);
// Destroy pool
pool.destroy();
// Should close all connections (1 in pool + 2 in use)
expect(closeConnectionSpy).toHaveBeenCalledTimes(3);
expect(closeConnectionSpy).toHaveBeenCalledWith(conn1);
expect(closeConnectionSpy).toHaveBeenCalledWith(conn2);
expect(closeConnectionSpy).toHaveBeenCalledWith(conn3);
closeConnectionSpy.mockRestore();
});
test('should create connections with timestamp', () => {
const pool = new ConnectionPool();
const before = Date.now();
const conn = pool.getConnection('host1');
const after = Date.now();
expect(conn.created).toBeGreaterThanOrEqual(before);
expect(conn.created).toBeLessThanOrEqual(after);
});
test('should use default maxConnections and timeout values', () => {
const pool = new ConnectionPool();
expect(pool.connectionTimeout).toBe(30000); // Default timeout
// Create connections up to default limit (50)
const connections = [];
for (let i = 0; i < 50; i++) {
const conn = pool.getConnection('host1');
if (conn) connections.push(conn);
}
expect(connections).toHaveLength(50);
// 51st connection should be null
const extraConn = pool.getConnection('host1');
expect(extraConn).toBeNull();
});
});
});

264
.tests/plugins.test.js Normal file
View file

@ -0,0 +1,264 @@
import { jest } from '@jest/globals';
// Mock the path and url modules for testing
jest.unstable_mockModule('path', () => ({
resolve: jest.fn(),
extname: jest.fn(),
sep: '/',
isAbsolute: jest.fn(),
normalize: jest.fn()
}));
jest.unstable_mockModule('url', () => ({
pathToFileURL: jest.fn()
}));
// Mock the root directory
jest.unstable_mockModule('../dist/index.js', () => ({
rootDir: '/app/root'
}));
// Import after mocking
const { secureImportModule, hasExport, getExport } = await import('../dist/utils/plugins.js');
const path = await import('path');
const url = await import('url');
describe('Plugins utilities', () => {
beforeEach(() => {
jest.clearAllMocks();
// Default mock implementations
path.normalize.mockImplementation((p) => p);
path.resolve.mockImplementation((root, rel) => `${root}/${rel}`);
path.extname.mockImplementation((p) => {
const parts = p.split('.');
return parts.length > 1 ? `.${parts[parts.length - 1]}` : '';
});
path.isAbsolute.mockImplementation((p) => p.startsWith('/'));
url.pathToFileURL.mockImplementation((p) => ({ href: `file://${p}` }));
});
describe('secureImportModule', () => {
describe('input validation', () => {
test('should reject non-string module paths', async () => {
await expect(secureImportModule(null)).rejects.toThrow('Module path must be a string');
await expect(secureImportModule(undefined)).rejects.toThrow('Module path must be a string');
await expect(secureImportModule(123)).rejects.toThrow('Module path must be a string');
});
test('should reject empty module paths', async () => {
await expect(secureImportModule('')).rejects.toThrow('Module path cannot be empty');
});
test('should reject paths that are too long', async () => {
const longPath = 'a'.repeat(1025) + '.js';
await expect(secureImportModule(longPath)).rejects.toThrow('Module path too long');
});
});
describe('security pattern validation', () => {
test('should reject directory traversal attempts', async () => {
await expect(secureImportModule('../evil.js')).rejects.toThrow('Module path contains blocked pattern');
await expect(secureImportModule('../../system.js')).rejects.toThrow('Module path contains blocked pattern');
await expect(secureImportModule('folder/../escape.js')).rejects.toThrow('Module path contains blocked pattern');
});
test('should reject double slash patterns', async () => {
await expect(secureImportModule('folder//file.js')).rejects.toThrow('Module path contains blocked pattern');
await expect(secureImportModule('//root.js')).rejects.toThrow('Module path contains blocked pattern');
});
test('should reject null byte injections', async () => {
await expect(secureImportModule('file\0.js')).rejects.toThrow('Module path contains blocked pattern');
});
test('should reject control characters', async () => {
await expect(secureImportModule('file\x01.js')).rejects.toThrow('Module path contains blocked pattern');
await expect(secureImportModule('file\x1f.js')).rejects.toThrow('Module path contains blocked pattern');
});
test('should reject node_modules access', async () => {
await expect(secureImportModule('node_modules/evil.js')).rejects.toThrow('Module path contains blocked pattern');
await expect(secureImportModule('NODE_MODULES/evil.js')).rejects.toThrow('Module path contains blocked pattern');
});
test('should reject sensitive file access', async () => {
await expect(secureImportModule('package.json')).rejects.toThrow('Module path contains blocked pattern');
await expect(secureImportModule('.env')).rejects.toThrow('Module path contains blocked pattern');
await expect(secureImportModule('config/.ENV')).rejects.toThrow('Module path contains blocked pattern');
});
test('should reject paths that are too deep', async () => {
const deepPath = 'a/'.repeat(25) + 'file.js';
path.normalize.mockReturnValue(deepPath);
await expect(secureImportModule(deepPath)).rejects.toThrow('Module path too deep');
});
});
describe('file extension validation', () => {
test('should reject invalid file extensions', async () => {
path.extname.mockReturnValue('.txt');
await expect(secureImportModule('file.txt')).rejects.toThrow('Only .js, .mjs files can be imported');
});
test('should reject files without extensions', async () => {
path.extname.mockReturnValue('');
await expect(secureImportModule('file')).rejects.toThrow('Only .js, .mjs files can be imported');
});
test('should accept valid .js extension validation', async () => {
path.extname.mockReturnValue('.js');
path.isAbsolute.mockReturnValue(false);
path.resolve.mockReturnValue('/app/root/valid.js');
path.normalize.mockImplementation((p) => p);
// This will fail at import but pass extension validation
await expect(secureImportModule('valid.js')).rejects.toThrow('Failed to import module');
});
test('should accept valid .mjs extension validation', async () => {
path.extname.mockReturnValue('.mjs');
path.isAbsolute.mockReturnValue(false);
path.resolve.mockReturnValue('/app/root/valid.mjs');
path.normalize.mockImplementation((p) => p);
// This will fail at import but pass extension validation
await expect(secureImportModule('valid.mjs')).rejects.toThrow('Failed to import module');
});
});
describe('path resolution security', () => {
test('should reject absolute paths', async () => {
path.extname.mockReturnValue('.js');
path.isAbsolute.mockReturnValue(true);
await expect(secureImportModule('/absolute/path.js')).rejects.toThrow('Absolute paths are not allowed');
});
test('should reject paths outside application root', async () => {
path.extname.mockReturnValue('.js');
path.isAbsolute.mockReturnValue(false);
path.resolve.mockReturnValue('/outside/root/file.js');
path.normalize.mockImplementation((p) => p);
await expect(secureImportModule('file.js')).rejects.toThrow('Module path outside of application root');
});
test('should detect symbolic link traversal', async () => {
path.extname.mockReturnValue('.js');
path.isAbsolute.mockReturnValue(false);
path.resolve.mockReturnValue('/app/root/../outside.js');
path.normalize.mockImplementation((p) => p);
await expect(secureImportModule('file.js')).rejects.toThrow('Path traversal detected');
});
test('should validate paths within application root', async () => {
path.extname.mockReturnValue('.js');
path.isAbsolute.mockReturnValue(false);
path.resolve.mockReturnValue('/app/root/plugins/test.js');
path.normalize.mockImplementation((p) => p);
// This will fail at import but pass path validation
await expect(secureImportModule('plugins/test.js')).rejects.toThrow('Failed to import module');
});
});
describe('import operation and error handling', () => {
test('should handle import failures gracefully', async () => {
path.extname.mockReturnValue('.js');
path.isAbsolute.mockReturnValue(false);
path.resolve.mockReturnValue('/app/root/file.js');
path.normalize.mockImplementation((p) => p);
// Since we can't easily mock import() in ES modules,
// this will naturally fail and test our error handling
await expect(secureImportModule('file.js')).rejects.toThrow('Failed to import module');
});
test('should handle non-Error exceptions in path validation', async () => {
path.extname.mockReturnValue('.js');
path.isAbsolute.mockReturnValue(false);
path.resolve.mockImplementation(() => {
throw "Non-error exception";
});
await expect(secureImportModule('file.js')).rejects.toThrow('Module import failed');
});
test('should handle unknown errors gracefully', async () => {
path.extname.mockImplementation(() => {
throw { unknown: 'error' };
});
await expect(secureImportModule('file.js')).rejects.toThrow('Module import failed due to unknown error');
});
});
});
describe('hasExport', () => {
test('should return true for existing exports', () => {
const module = { testFunction: () => {}, testValue: 'test' };
expect(hasExport(module, 'testFunction')).toBe(true);
expect(hasExport(module, 'testValue')).toBe(true);
});
test('should return false for non-existent exports', () => {
const module = { testFunction: () => {} };
expect(hasExport(module, 'nonExistent')).toBe(false);
});
test('should return false for undefined exports', () => {
const module = { testValue: undefined };
expect(hasExport(module, 'testValue')).toBe(false);
});
test('should handle edge cases', () => {
const module = {};
expect(hasExport(module, 'toString')).toBe(true); // inherited from Object.prototype
expect(hasExport(module, 'constructor')).toBe(true); // inherited from Object.prototype
expect(hasExport(module, 'nonExistentMethod')).toBe(false);
});
});
describe('getExport', () => {
test('should return export values correctly', () => {
const testFunction = () => 'test';
const module = {
testFunction,
testValue: 'hello'
};
expect(getExport(module, 'testFunction')).toBe(testFunction);
expect(getExport(module, 'testValue')).toBe('hello');
});
test('should return undefined for non-existent exports', () => {
const module = { testFunction: () => {} };
expect(getExport(module, 'nonExistent')).toBeUndefined();
});
test('should return actual values including falsy ones', () => {
const module = {
zero: 0,
false: false,
empty: '',
null: null
};
expect(getExport(module, 'zero')).toBe(0);
expect(getExport(module, 'false')).toBe(false);
expect(getExport(module, 'empty')).toBe('');
expect(getExport(module, 'null')).toBe(null);
});
});
});

533
.tests/proof.test.js Normal file
View file

@ -0,0 +1,533 @@
import { jest } from '@jest/globals';
import * as crypto from 'crypto';
import {
generateChallenge,
calculateHash,
verifyPoW,
checkPoSTimes,
generateRequestID,
getChallengeParams,
deleteChallenge,
verifyPoS,
challengeStore
} from '../dist/utils/proof.js';
describe('Proof utilities', () => {
beforeEach(() => {
challengeStore.clear();
jest.clearAllMocks();
});
describe('generateChallenge', () => {
test('should generate challenge with valid config', () => {
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 };
const result = generateChallenge(config);
expect(result).toHaveProperty('challenge');
expect(result).toHaveProperty('salt');
expect(typeof result.challenge).toBe('string');
expect(typeof result.salt).toBe('string');
expect(result.challenge.length).toBe(32); // 16 bytes as hex
expect(result.salt.length).toBe(32); // 16 bytes as hex
});
test('should respect salt length configuration', () => {
const config = { SaltLength: 32, Difficulty: 4, ChallengeExpiration: 300000 };
const result = generateChallenge(config);
expect(result.salt.length).toBe(64); // 32 bytes as hex
});
test('should throw error for invalid config', () => {
expect(() => generateChallenge(null)).toThrow('CheckpointConfig must be an object');
expect(() => generateChallenge(undefined)).toThrow('CheckpointConfig must be an object');
expect(() => generateChallenge({})).toThrow();
});
test('should validate configuration bounds', () => {
// Salt length validation
expect(() => generateChallenge({ SaltLength: 0, Difficulty: 4, ChallengeExpiration: 300000 }))
.toThrow('SaltLength must be between');
// Difficulty validation
expect(() => generateChallenge({ SaltLength: 16, Difficulty: 0, ChallengeExpiration: 300000 }))
.toThrow('Difficulty must be between');
// Expiration validation
expect(() => generateChallenge({ SaltLength: 16, Difficulty: 4, ChallengeExpiration: 0 }))
.toThrow('ChallengeExpiration must be between');
});
test('should validate configuration maximum bounds', () => {
// Test maximum salt length (1024)
expect(() => generateChallenge({ SaltLength: 1025, Difficulty: 4, ChallengeExpiration: 300000 }))
.toThrow('SaltLength must be between 1 and 1024');
// Test maximum difficulty (64)
expect(() => generateChallenge({ SaltLength: 16, Difficulty: 65, ChallengeExpiration: 300000 }))
.toThrow('Difficulty must be between 1 and 64');
// Test maximum expiration (1 year)
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
expect(() => generateChallenge({ SaltLength: 16, Difficulty: 4, ChallengeExpiration: oneYearMs + 1 }))
.toThrow(`ChallengeExpiration must be between 1000 and ${oneYearMs}`);
});
test('should handle config with optional fields', () => {
const config = {
SaltLength: 16,
Difficulty: 4,
ChallengeExpiration: 300000,
CheckPoSTimes: true,
PoSTimeConsistencyRatio: 3.5
};
const result = generateChallenge(config);
expect(result.challenge).toBeDefined();
expect(result.salt).toBeDefined();
});
test('should handle config with invalid PoSTimeConsistencyRatio', () => {
const config = {
SaltLength: 16,
Difficulty: 4,
ChallengeExpiration: 300000,
PoSTimeConsistencyRatio: 0 // Invalid - should use default
};
const result = generateChallenge(config);
expect(result.challenge).toBeDefined();
expect(result.salt).toBeDefined();
});
});
describe('calculateHash', () => {
test('should generate consistent SHA-256 hash', () => {
const input = 'test input';
const hash1 = calculateHash(input);
const hash2 = calculateHash(input);
expect(hash1).toBe(hash2);
expect(hash1.length).toBe(64); // SHA-256 hex length
expect(/^[0-9a-f]+$/.test(hash1)).toBe(true);
});
test('should handle different inputs', () => {
const hash1 = calculateHash('input1');
const hash2 = calculateHash('input2');
expect(hash1).not.toBe(hash2);
});
test('should throw error for invalid inputs', () => {
expect(() => calculateHash('')).toThrow('Hash input cannot be empty');
expect(() => calculateHash(null)).toThrow('Hash input must be a string');
expect(() => calculateHash(undefined)).toThrow('Hash input must be a string');
});
test('should handle maximum input length validation', () => {
const maxLength = 100000; // ABSOLUTE_MAX_INPUT_LENGTH
const longInput = 'a'.repeat(maxLength + 1);
expect(() => calculateHash(longInput)).toThrow(`Hash input exceeds maximum length of ${maxLength}`);
});
test('should handle edge case input lengths', () => {
const validInput = 'a'.repeat(100000); // Exactly at limit
const hash = calculateHash(validInput);
expect(hash).toBeDefined();
expect(hash.length).toBe(64);
});
});
describe('verifyPoW', () => {
test('should verify valid proof of work', () => {
const challenge = 'abc123def456'; // Valid hex string
const salt = 'def456abc123'; // Valid hex string
const difficulty = 1;
// For difficulty 1, we need hash starting with '0'
// Let's find a working nonce
let validNonce = '0';
for (let i = 0; i < 10000; i++) {
const testNonce = i.toString(16).padStart(4, '0');
const hash = calculateHash(challenge + salt + testNonce);
if (hash.startsWith('0')) {
validNonce = testNonce;
break;
}
}
const result = verifyPoW(challenge, salt, validNonce, difficulty);
expect(result).toBe(true);
});
test('should reject invalid proof of work', () => {
const challenge = 'abc123def456'; // Valid hex string
const salt = 'def456abc123'; // Valid hex string
const nonce = 'ffff'; // This should not produce required zeros
const difficulty = 4;
const result = verifyPoW(challenge, salt, nonce, difficulty);
expect(result).toBe(false);
});
test('should validate input parameters', () => {
expect(() => verifyPoW('', 'abcdef', 'abcd', 4)).toThrow('challenge cannot be empty');
expect(() => verifyPoW('abcdef', '', 'abcd', 4)).toThrow('salt cannot be empty');
expect(() => verifyPoW('abcdef', 'abcdef', '', 4)).toThrow('nonce cannot be empty');
expect(() => verifyPoW('abcdef', 'abcdef', 'abcd', 0)).toThrow('difficulty must be between');
});
test('should validate hex strings', () => {
expect(() => verifyPoW('invalid_hex!', 'abcdef', 'abcd', 4)).toThrow('must be a valid hexadecimal string');
expect(() => verifyPoW('abcdef', 'invalid_hex!', 'abcd', 4)).toThrow('must be a valid hexadecimal string');
});
test('should validate hex string lengths', () => {
const maxLength = 100000; // ABSOLUTE_MAX_INPUT_LENGTH
const longHex = 'a'.repeat(maxLength + 1);
expect(() => verifyPoW(longHex, 'abcdef', 'abcd', 4)).toThrow(`challenge exceeds maximum length of ${maxLength}`);
expect(() => verifyPoW('abcdef', longHex, 'abcd', 4)).toThrow(`salt exceeds maximum length of ${maxLength}`);
expect(() => verifyPoW('abcdef', 'abcdef', longHex, 4)).toThrow(`nonce exceeds maximum length of ${maxLength}`);
});
test('should validate input types for hex validation', () => {
expect(() => verifyPoW(123, 'abcdef', 'abcd', 4)).toThrow('challenge must be a string');
expect(() => verifyPoW('abcdef', 123, 'abcd', 4)).toThrow('salt must be a string');
expect(() => verifyPoW('abcdef', 'abcdef', 123, 4)).toThrow('nonce must be a string');
});
test('should validate difficulty bounds', () => {
expect(() => verifyPoW('abcdef', 'abcdef', 'abcd', 65)).toThrow('difficulty must be between 1 and 64');
expect(() => verifyPoW('abcdef', 'abcdef', 'abcd', -1)).toThrow('difficulty must be between 1 and 64');
});
test('should validate difficulty type', () => {
expect(() => verifyPoW('abcdef', 'abcdef', 'abcd', 'invalid')).toThrow('difficulty must be an integer');
expect(() => verifyPoW('abcdef', 'abcdef', 'abcd', 4.5)).toThrow('difficulty must be an integer');
});
});
describe('checkPoSTimes', () => {
test('should pass when check is disabled', () => {
const times = [100, 200, 300];
expect(() => checkPoSTimes(times, false, 2.0)).not.toThrow();
});
test('should pass for consistent times', () => {
const times = [100, 110, 120]; // Close times
expect(() => checkPoSTimes(times, true, 2.0)).not.toThrow();
});
test('should fail for inconsistent times', () => {
const times = [100, 500, 600]; // 5x difference > 2.0 ratio
expect(() => checkPoSTimes(times, true, 2.0)).toThrow('PoS run times inconsistent');
});
test('should fail for zero times', () => {
const times = [0, 100, 200];
expect(() => checkPoSTimes(times, true, 2.0)).toThrow('PoS run times cannot be zero');
});
test('should validate times array structure', () => {
expect(() => checkPoSTimes(null, true, 2.0)).toThrow('times must be an array');
expect(() => checkPoSTimes([1, 2], true, 2.0)).toThrow('times must have exactly 3 elements');
expect(() => checkPoSTimes([1, 2, 3, 4], true, 2.0)).toThrow('times must have exactly 3 elements');
});
test('should validate individual time values', () => {
expect(() => checkPoSTimes(['invalid', 100, 200], true, 2.0)).toThrow('times[0] must be a non-negative finite number');
expect(() => checkPoSTimes([100, null, 200], true, 2.0)).toThrow('times[1] must be a non-negative finite number');
expect(() => checkPoSTimes([100, 200, undefined], true, 2.0)).toThrow('times[2] must be a non-negative finite number');
});
test('should validate time value bounds', () => {
const largeTimes = [10000001, 100, 200]; // Exceeds 10M ms limit
expect(() => checkPoSTimes(largeTimes, true, 2.0)).toThrow('times[0] exceeds maximum allowed value');
});
test('should validate negative time values', () => {
expect(() => checkPoSTimes([-100, 100, 200], true, 2.0)).toThrow('times[0] must be a non-negative finite number');
expect(() => checkPoSTimes([100, -200, 300], true, 2.0)).toThrow('times[1] must be a non-negative finite number');
});
test('should validate infinite and NaN values', () => {
expect(() => checkPoSTimes([Infinity, 100, 200], true, 2.0)).toThrow('times[0] must be a non-negative finite number');
expect(() => checkPoSTimes([100, NaN, 200], true, 2.0)).toThrow('times[1] must be a non-negative finite number');
});
test('should handle default parameters gracefully', () => {
const times = [100, 110, 120];
// Test with default enableCheck (should be false)
expect(() => checkPoSTimes(times)).not.toThrow();
// Test with default ratio (should be 2.0)
expect(() => checkPoSTimes(times, true)).not.toThrow();
// Test with invalid ratio (should use default 2.0)
expect(() => checkPoSTimes(times, true, 0)).not.toThrow();
expect(() => checkPoSTimes(times, true, 'invalid')).not.toThrow();
});
});
describe('generateRequestID', () => {
beforeEach(() => {
challengeStore.clear();
});
test('should generate unique request IDs', () => {
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 };
const mockRequest = { headers: { host: 'localhost' }, url: '/test' };
const requestId1 = generateRequestID(mockRequest, config);
const requestId2 = generateRequestID(mockRequest, config);
expect(requestId1).not.toBe(requestId2);
expect(requestId1.length).toBe(32);
expect(requestId2.length).toBe(32);
});
test('should store challenge parameters', () => {
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 };
const mockRequest = { headers: { host: 'localhost' }, url: '/test' };
const requestId = generateRequestID(mockRequest, config);
const params = challengeStore.get(requestId);
expect(params).toBeDefined();
expect(params.Challenge).toBeDefined();
expect(params.Salt).toBeDefined();
expect(params.Difficulty).toBe(4);
expect(params.ClientIP).toBeDefined();
});
test('should validate request object', () => {
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 };
expect(() => generateRequestID(null, config)).toThrow('Request must be an object');
expect(() => generateRequestID({}, config)).toThrow('Request must have headers object');
});
test('should store complete challenge parameters', () => {
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 };
const mockRequest = { headers: { host: 'localhost' }, url: '/test' };
const requestId = generateRequestID(mockRequest, config);
const params = challengeStore.get(requestId);
expect(params.Challenge).toBeDefined();
expect(params.Salt).toBeDefined();
expect(params.Difficulty).toBe(4);
expect(params.ExpiresAt).toBeGreaterThan(Date.now());
expect(params.CreatedAt).toBeLessThanOrEqual(Date.now());
expect(params.PoSSeed).toBeDefined();
expect(params.PoSSeed.length).toBe(64); // 32 bytes as hex
});
});
describe('getChallengeParams', () => {
beforeEach(() => {
challengeStore.clear();
});
test('should retrieve stored challenge parameters', () => {
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 };
const mockRequest = { headers: { host: 'localhost' }, url: '/test' };
const requestId = generateRequestID(mockRequest, config);
const params = getChallengeParams(requestId);
expect(params).toBeDefined();
expect(params.Difficulty).toBe(4);
});
test('should return undefined for non-existent request ID', () => {
const result = getChallengeParams('12345678123456781234567812345678'); // 32 char hex
expect(result).toBeUndefined();
});
test('should validate request ID format', () => {
expect(() => getChallengeParams(null)).toThrow('Request ID must be a string');
expect(() => getChallengeParams('short')).toThrow('Invalid request ID format');
expect(() => getChallengeParams('1234567890abcdef1234567890abcdeg')).toThrow('Request ID must be hexadecimal');
});
test('should validate request ID length limits', () => {
const longRequestId = 'a'.repeat(65); // Exceeds max length
expect(() => getChallengeParams(longRequestId)).toThrow('Request ID exceeds maximum length of 64');
});
test('should validate request ID exact length requirement', () => {
const shortHex = '1234567890abcdef1234567890abcde'; // 31 chars (too short)
const longHex = '1234567890abcdef1234567890abcdef1'; // 33 chars (too long)
expect(() => getChallengeParams(shortHex)).toThrow('Invalid request ID format');
expect(() => getChallengeParams(longHex)).toThrow('Invalid request ID format');
});
test('should validate hex character requirement', () => {
const invalidHex = '1234567890abcdef1234567890abcdex'; // Contains 'x' (invalid hex)
expect(() => getChallengeParams(invalidHex)).toThrow('Request ID must be hexadecimal');
});
});
describe('deleteChallenge', () => {
beforeEach(() => {
challengeStore.clear();
});
test('should delete existing challenge', () => {
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 };
const mockRequest = { headers: { host: 'localhost' }, url: '/test' };
const requestId = generateRequestID(mockRequest, config);
expect(getChallengeParams(requestId)).toBeDefined();
const deleteResult = deleteChallenge(requestId);
expect(deleteResult).toBe(true);
expect(getChallengeParams(requestId)).toBeUndefined();
});
test('should return false for non-existent challenge', () => {
const result = deleteChallenge('12345678123456781234567812345678');
expect(result).toBe(false);
});
test('should validate request ID type', () => {
expect(() => deleteChallenge(123)).toThrow('Request ID must be a string');
});
});
describe('verifyPoS', () => {
test('should verify valid proof of stake', () => {
// Use 64-character hex hashes (SHA-256 length)
const validHash = 'a'.repeat(64);
const hashes = [validHash, validHash, validHash];
const times = [100, 110, 120];
const config = {
SaltLength: 16,
Difficulty: 4,
ChallengeExpiration: 300000,
CheckPoSTimes: true,
PoSTimeConsistencyRatio: 2.0
};
expect(() => verifyPoS(hashes, times, config)).not.toThrow();
});
test('should fail for mismatched hashes', () => {
// Use different 64-character hex hashes
const hash1 = 'a'.repeat(64);
const hash2 = 'b'.repeat(64);
const hashes = [hash1, hash2, hash1];
const times = [100, 110, 120];
const config = {
SaltLength: 16,
Difficulty: 4,
ChallengeExpiration: 300000,
CheckPoSTimes: false
};
expect(() => verifyPoS(hashes, times, config)).toThrow('PoS hashes do not match');
});
test('should validate hashes array structure', () => {
const times = [100, 110, 120];
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 };
expect(() => verifyPoS(null, times, config)).toThrow('hashes must be an array');
expect(() => verifyPoS([1, 2], times, config)).toThrow('hashes must have exactly 3 elements');
});
test('should validate hash format', () => {
const invalidHash = 'invalid!';
const validHash = 'a'.repeat(64);
const hashes = [invalidHash, validHash, validHash];
const times = [100, 110, 120];
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 };
expect(() => verifyPoS(hashes, times, config)).toThrow('must be a valid hexadecimal string');
});
test('should validate hash length requirement', () => {
const shortHash = 'a'.repeat(63); // Too short
const validHash = 'a'.repeat(64);
const hashes = [shortHash, validHash, validHash];
const times = [100, 110, 120];
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 };
expect(() => verifyPoS(hashes, times, config)).toThrow('hashes[0] must be exactly 64 characters');
});
test('should validate individual hash array elements', () => {
const validHash = 'a'.repeat(64);
const times = [100, 110, 120];
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 300000 };
// Test non-string hash
expect(() => verifyPoS([123, validHash, validHash], times, config)).toThrow('hashes[0] must be a string');
// Test empty hash
expect(() => verifyPoS(['', validHash, validHash], times, config)).toThrow('hashes[0] cannot be empty');
});
test('should properly call timing validation when enabled', () => {
const validHash = 'a'.repeat(64);
const hashes = [validHash, validHash, validHash];
const inconsistentTimes = [100, 1000, 1100]; // Large ratio
const config = {
SaltLength: 16,
Difficulty: 4,
ChallengeExpiration: 300000,
CheckPoSTimes: true,
PoSTimeConsistencyRatio: 2.0
};
expect(() => verifyPoS(hashes, inconsistentTimes, config)).toThrow('PoS run times inconsistent');
});
});
describe('expired challenge cleanup', () => {
beforeEach(() => {
challengeStore.clear();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
test('should clean up expired challenges automatically', () => {
const config = { SaltLength: 16, Difficulty: 4, ChallengeExpiration: 1000 }; // 1 second
const mockRequest = { headers: { host: 'localhost' }, url: '/test' };
// Generate some challenges
const requestId1 = generateRequestID(mockRequest, config);
const requestId2 = generateRequestID(mockRequest, config);
expect(challengeStore.size).toBe(2);
// Advance time to expire challenges
jest.advanceTimersByTime(2000); // 2 seconds
// Trigger cleanup (runs every 5 minutes)
jest.advanceTimersByTime(5 * 60 * 1000);
// Challenges should still be there since cleanup hasn't run based on expiration
// This tests the cleanup mechanism exists
expect(challengeStore.size).toBeGreaterThanOrEqual(0);
});
test('should handle empty challenge store during cleanup', () => {
// Advance time to trigger cleanup with empty store
jest.advanceTimersByTime(5 * 60 * 1000);
// Should not throw
expect(challengeStore.size).toBe(0);
});
});
});

152
.tests/setup.js Normal file
View file

@ -0,0 +1,152 @@
// Jest setup file - runs before all tests
// Set NODE_ENV to test to prevent main application from starting
process.env.NODE_ENV = 'test';
// Mock fs operations globally to prevent checkpoint.js errors
const mockFn = () => {
const fn = function(...args) { return fn.mockReturnValue; };
fn.mockReturnValue = undefined;
fn.mockResolvedValue = (value) => { fn.mockReturnValue = Promise.resolve(value); return fn; };
return fn;
};
global.fs = {
readFileSync: () => 'mocked-file-content',
writeFileSync: () => {},
mkdirSync: () => {},
existsSync: () => false,
readdirSync: () => [],
unlinkSync: () => {}
};
// Mock fs/promises
global.fsPromises = {
readFile: () => Promise.resolve('mocked-content'),
writeFile: () => Promise.resolve(undefined),
mkdir: () => Promise.resolve(undefined)
};
// Suppress console warnings and errors during tests for cleaner output
const originalConsoleWarn = console.warn;
const originalConsoleError = console.error;
console.warn = () => {};
console.error = () => {};
// Track timers and intervals for cleanup
const timers = new Set();
const immediates = new Set();
const originalSetTimeout = global.setTimeout;
const originalSetInterval = global.setInterval;
const originalSetImmediate = global.setImmediate;
const originalClearTimeout = global.clearTimeout;
const originalClearInterval = global.clearInterval;
const originalClearImmediate = global.clearImmediate;
global.setTimeout = (fn, delay, ...args) => {
const id = originalSetTimeout(fn, delay, ...args);
timers.add(id);
return id;
};
global.setInterval = (fn, delay, ...args) => {
const id = originalSetInterval(fn, delay, ...args);
timers.add(id);
return id;
};
global.setImmediate = (fn, ...args) => {
const id = originalSetImmediate(fn, ...args);
immediates.add(id);
return id;
};
global.clearTimeout = (id) => {
timers.delete(id);
return originalClearTimeout(id);
};
global.clearInterval = (id) => {
timers.delete(id);
return originalClearInterval(id);
};
global.clearImmediate = (id) => {
immediates.delete(id);
return originalClearImmediate(id);
};
global.pendingImmediates = immediates;
// Mock @iarna/toml to prevent async import after teardown
// Note: This mock will be applied globally for all tests
// Comprehensive behavioral detection mocking to prevent async operations
global.mockBehavioralDetection = {
config: { enabled: false },
isBlocked: () => Promise.resolve({ blocked: false }),
getRateLimit: () => Promise.resolve(null),
analyzeRequest: () => Promise.resolve({ totalScore: 0, patterns: [] }),
loadRules: () => Promise.resolve(),
init: () => Promise.resolve()
};
// Mock the loadConfig function globally to prevent TOML imports
global.mockLoadConfig = async (name, target) => {
// Return immediately without trying to import TOML
return Promise.resolve();
};
// Mock dynamic imports to prevent teardown issues
const originalImport = global.import;
if (originalImport) {
global.import = (modulePath) => {
if (modulePath === '@iarna/toml') {
return Promise.resolve({
default: { parse: () => ({}) },
parse: () => ({})
});
}
if (modulePath.includes('behavioral-detection')) {
return Promise.resolve(global.mockBehavioralDetection);
}
return originalImport(modulePath);
};
}
// Function to clear all timers
global.clearAllTimers = () => {
timers.forEach(id => {
originalClearTimeout(id);
originalClearInterval(id);
});
timers.clear();
immediates.forEach(id => {
originalClearImmediate(id);
});
immediates.clear();
};
// Clean up after all tests
afterAll(() => {
// Clear all remaining timers
global.clearAllTimers();
// Restore original console methods
console.warn = originalConsoleWarn;
console.error = originalConsoleError;
// Restore original import if it was mocked
if (originalImport) {
global.import = originalImport;
}
// Restore original timer functions
global.setTimeout = originalSetTimeout;
global.setInterval = originalSetInterval;
global.setImmediate = originalSetImmediate;
global.clearTimeout = originalClearTimeout;
global.clearInterval = originalClearInterval;
global.clearImmediate = originalClearImmediate;
});

43
.tests/teardown.js Normal file
View file

@ -0,0 +1,43 @@
// Jest teardown file - runs after all tests complete
// This helps prevent async operations from continuing after Jest environment teardown
// Global teardown to ensure clean test environment shutdown
module.exports = async () => {
// Clear all timers first
if (typeof global.clearAllTimers === 'function') {
global.clearAllTimers();
}
// Clear any pending setImmediate calls
if (global.pendingImmediates) {
global.pendingImmediates.forEach(id => clearImmediate(id));
global.pendingImmediates.clear();
}
// Force close any remaining handles
if (process._getActiveHandles) {
const handles = process._getActiveHandles();
handles.forEach(handle => {
if (handle && typeof handle.close === 'function') {
try {
handle.close();
} catch (e) {
// Ignore errors during forced cleanup
}
}
});
}
// Force garbage collection if available
if (global.gc) {
global.gc();
}
// Add a small delay to allow any pending async operations to complete
await new Promise(resolve => setTimeout(resolve, 50));
// Clear any remaining console overrides
if (global.originalConsole) {
Object.assign(console, global.originalConsole);
}
};

View file

@ -0,0 +1,168 @@
import { jest } from '@jest/globals';
// Mock behavioral detection and config loading before any imports
jest.unstable_mockModule('../dist/utils/behavioral-detection.js', () => ({
behavioralDetection: {
config: { enabled: false },
isBlocked: () => Promise.resolve({ blocked: false }),
getRateLimit: () => Promise.resolve(null),
analyzeRequest: () => Promise.resolve({ totalScore: 0, patterns: [] }),
loadRules: () => Promise.resolve(),
init: () => Promise.resolve()
},
BehavioralDetectionEngine: class MockBehavioralDetectionEngine {
constructor() {
this.config = { enabled: false };
}
async loadRules() { return Promise.resolve(); }
async init() { return Promise.resolve(); }
async isBlocked() { return Promise.resolve({ blocked: false }); }
async getRateLimit() { return Promise.resolve(null); }
async analyzeRequest() { return Promise.resolve({ totalScore: 0, patterns: [] }); }
}
}));
// Mock the main index loadConfig function to prevent TOML imports
jest.unstable_mockModule('../dist/index.js', () => ({
loadConfig: () => Promise.resolve(),
registerPlugin: () => {},
getRegisteredPluginNames: () => [],
loadPlugins: () => [],
freezePlugins: () => {},
rootDir: '/mock/root'
}));
import { threatScorer, configureDefaultThreatScorer, createThreatScorer } from '../dist/utils/threat-scoring.js';
describe('Threat Scoring (Re-export)', () => {
beforeEach(() => {
jest.clearAllMocks();
// Configure the default threat scorer with test config
const testConfig = {
enabled: true,
thresholds: {
ALLOW: 20,
CHALLENGE: 60,
BLOCK: 100
},
signalWeights: {
BLACKLISTED_IP: { weight: 50, confidence: 0.95 },
RAPID_ENUMERATION: { weight: 35, confidence: 0.80 },
BRUTE_FORCE_PATTERN: { weight: 45, confidence: 0.88 },
SQL_INJECTION: { weight: 60, confidence: 0.92 },
XSS_ATTEMPT: { weight: 50, confidence: 0.88 },
COMMAND_INJECTION: { weight: 65, confidence: 0.95 },
ATTACK_TOOL_UA: { weight: 30, confidence: 0.75 },
MISSING_UA: { weight: 10, confidence: 0.60 },
IMPOSSIBLE_TRAVEL: { weight: 30, confidence: 0.80 },
HIGH_RISK_COUNTRY: { weight: 15, confidence: 0.60 }
},
enableBotVerification: true,
enableGeoAnalysis: true,
enableBehaviorAnalysis: true,
enableContentAnalysis: true
};
configureDefaultThreatScorer(testConfig);
});
afterEach(async () => {
// Wait for any pending async operations to complete
await new Promise(resolve => setImmediate(resolve));
});
describe('exports', () => {
test('should export threatScorer instance', () => {
expect(threatScorer).toBeDefined();
expect(typeof threatScorer).toBe('object');
// Should have the new API methods
expect(typeof threatScorer.scoreRequest).toBe('function');
});
test('should export configuration functions', () => {
expect(configureDefaultThreatScorer).toBeDefined();
expect(typeof configureDefaultThreatScorer).toBe('function');
expect(createThreatScorer).toBeDefined();
expect(typeof createThreatScorer).toBe('function');
});
});
describe('threatScorer functionality', () => {
test('should score a simple request', async () => {
const mockRequest = {
headers: { 'user-agent': 'test-browser' },
method: 'GET',
url: '/test'
};
const result = await threatScorer.scoreRequest(mockRequest);
expect(result).toBeDefined();
expect(typeof result.totalScore).toBe('number');
expect(typeof result.confidence).toBe('number');
expect(['allow', 'challenge', 'block']).toContain(result.riskLevel);
expect(Array.isArray(result.signalsTriggered)).toBe(true);
expect(typeof result.processingTimeMs).toBe('number');
});
test('should handle disabled scoring', async () => {
const disabledConfig = {
enabled: false,
thresholds: { ALLOW: 20, CHALLENGE: 60, BLOCK: 100 },
signalWeights: {}
};
const disabledScorer = createThreatScorer(disabledConfig);
const mockRequest = {
headers: { 'user-agent': 'test-browser' },
method: 'GET',
url: '/test'
};
const result = await disabledScorer.scoreRequest(mockRequest);
expect(result.riskLevel).toBe('allow');
expect(result.totalScore).toBe(0);
});
test('should require configuration for default scorer', async () => {
// Test that unconfigured scorer behaves correctly
const unconfiguredScorer = createThreatScorer({ enabled: false, thresholds: {} });
const mockRequest = {
headers: { 'user-agent': 'test-browser' },
method: 'GET',
url: '/test'
};
// Unconfigured/disabled scorer should return allow with 0 score
const result = await unconfiguredScorer.scoreRequest(mockRequest);
expect(result.riskLevel).toBe('allow');
expect(result.totalScore).toBe(0);
});
});
describe('threat scoring configuration', () => {
test('should create scorer with custom config', () => {
const customConfig = {
enabled: true,
thresholds: {
ALLOW: 10,
CHALLENGE: 30,
BLOCK: 50
},
signalWeights: {
BLACKLISTED_IP: { weight: 100, confidence: 1.0 }
},
enableBotVerification: true
};
const customScorer = createThreatScorer(customConfig);
expect(customScorer).toBeDefined();
});
});
});

270
.tests/time.test.js Normal file
View file

@ -0,0 +1,270 @@
import { parseDuration, formatDuration, isValidDurationString } from '../dist/utils/time.js';
describe('Time utilities', () => {
describe('parseDuration', () => {
describe('numeric inputs', () => {
test('should parse positive numbers as milliseconds', () => {
expect(parseDuration(1000)).toBe(1000);
expect(parseDuration(5000)).toBe(5000);
expect(parseDuration(0)).toBe(0);
});
test('should throw error for negative numbers', () => {
expect(() => parseDuration(-1000)).toThrow('Duration cannot be negative');
expect(() => parseDuration(-1)).toThrow('Duration cannot be negative');
});
test('should throw error for numbers exceeding MAX_SAFE_INTEGER', () => {
expect(() => parseDuration(Number.MAX_SAFE_INTEGER + 1)).toThrow('Duration too large');
});
});
describe('string inputs', () => {
describe('valid duration strings', () => {
test('should parse seconds correctly', () => {
expect(parseDuration('5s')).toBe(5000);
expect(parseDuration('30s')).toBe(30000);
expect(parseDuration('1s')).toBe(1000);
});
test('should parse minutes correctly', () => {
expect(parseDuration('5m')).toBe(300000);
expect(parseDuration('1m')).toBe(60000);
expect(parseDuration('10m')).toBe(600000);
});
test('should parse hours correctly', () => {
expect(parseDuration('1h')).toBe(3600000);
expect(parseDuration('2h')).toBe(7200000);
});
test('should parse days correctly', () => {
expect(parseDuration('1d')).toBe(86400000);
expect(parseDuration('2d')).toBe(172800000);
});
test('should parse decimal values', () => {
expect(parseDuration('1.5s')).toBe(1500);
expect(parseDuration('2.5m')).toBe(150000);
});
test('should be case-insensitive for units', () => {
expect(parseDuration('5S')).toBe(5000);
expect(parseDuration('5M')).toBe(300000);
});
});
describe('numeric strings', () => {
test('should parse numeric strings as milliseconds', () => {
expect(parseDuration('1000')).toBe(1000);
expect(parseDuration('5000')).toBe(5000);
});
test('should throw error for negative numeric strings', () => {
expect(() => parseDuration('-1000')).toThrow('Duration cannot be negative');
});
});
describe('invalid inputs', () => {
test('should throw error for empty string', () => {
expect(() => parseDuration('')).toThrow('Duration cannot be empty');
expect(() => parseDuration(' ')).toThrow('Duration cannot be empty');
});
test('should throw error for invalid format', () => {
expect(() => parseDuration('abc')).toThrow('Invalid duration format');
expect(() => parseDuration('5x')).toThrow('Invalid duration format');
expect(() => parseDuration('s5')).toThrow('Invalid duration format');
});
test('should throw error for negative duration values', () => {
expect(() => parseDuration('-5s')).toThrow('Invalid duration format');
expect(() => parseDuration('-10m')).toThrow('Invalid duration format');
});
describe('edge case validation', () => {
test('should handle duration strings that exceed MAX_SAFE_INTEGER when multiplied', () => {
// Test a very large number that when multiplied by day multiplier exceeds MAX_SAFE_INTEGER
const largeValue = Math.floor(Number.MAX_SAFE_INTEGER / (24 * 60 * 60 * 1000)) + 1;
expect(() => parseDuration(`${largeValue}d`)).toThrow('Duration too large');
});
test('should handle duration strings with whitespace around units', () => {
expect(parseDuration('5 s')).toBe(5000);
expect(parseDuration('10 m')).toBe(600000);
expect(parseDuration('2 h')).toBe(7200000);
expect(parseDuration('1 d')).toBe(86400000);
});
test('should throw error for duration strings with negative values in unit format', () => {
expect(() => parseDuration('-1.5s')).toThrow('Invalid duration format');
expect(() => parseDuration('-10m')).toThrow('Invalid duration format');
expect(() => parseDuration('-2.5h')).toThrow('Invalid duration format');
});
test('should handle various decimal formats', () => {
expect(parseDuration('0.5s')).toBe(500);
expect(parseDuration('0.1m')).toBe(6000);
expect(parseDuration('1.0h')).toBe(3600000);
expect(parseDuration('0.25d')).toBe(21600000);
});
});
});
});
describe('invalid types', () => {
test('should throw error for non-string/number inputs', () => {
expect(() => parseDuration(null)).toThrow('Duration must be a string or number');
expect(() => parseDuration(undefined)).toThrow('Duration must be a string or number');
expect(() => parseDuration({})).toThrow('Duration must be a string or number');
expect(() => parseDuration([])).toThrow('Duration must be a string or number');
});
});
});
describe('formatDuration', () => {
test('should format milliseconds to ms for values < 1000', () => {
expect(formatDuration(0)).toBe('0ms');
expect(formatDuration(999)).toBe('999ms');
expect(formatDuration(500)).toBe('500ms');
});
test('should format to seconds', () => {
expect(formatDuration(1000)).toBe('1s');
expect(formatDuration(5000)).toBe('5s');
expect(formatDuration(59000)).toBe('59s');
});
test('should format to minutes', () => {
expect(formatDuration(60000)).toBe('1m');
expect(formatDuration(300000)).toBe('5m');
expect(formatDuration(3540000)).toBe('59m');
});
test('should format to hours', () => {
expect(formatDuration(3600000)).toBe('1h');
expect(formatDuration(7200000)).toBe('2h');
expect(formatDuration(86340000)).toBe('23h');
});
test('should format to days', () => {
expect(formatDuration(86400000)).toBe('1d');
expect(formatDuration(172800000)).toBe('2d');
expect(formatDuration(604800000)).toBe('7d');
});
test('should throw error for negative values', () => {
expect(() => formatDuration(-1000)).toThrow('Duration cannot be negative');
});
test('should format edge case durations correctly', () => {
expect(formatDuration(1)).toBe('1ms');
expect(formatDuration(999)).toBe('999ms');
expect(formatDuration(1001)).toBe('1s');
expect(formatDuration(59999)).toBe('59s');
expect(formatDuration(60001)).toBe('1m');
expect(formatDuration(3599999)).toBe('59m');
expect(formatDuration(3600001)).toBe('1h');
expect(formatDuration(86399999)).toBe('23h');
expect(formatDuration(86400001)).toBe('1d');
});
test('should format very large durations to days', () => {
expect(formatDuration(Number.MAX_SAFE_INTEGER)).toBe(`${Math.floor(Number.MAX_SAFE_INTEGER / (24 * 60 * 60 * 1000))}d`);
});
});
describe('isValidDurationString', () => {
test('should return true for valid duration formats', () => {
expect(isValidDurationString('5s')).toBe(true);
expect(isValidDurationString('10m')).toBe(true);
expect(isValidDurationString('2h')).toBe(true);
expect(isValidDurationString('1d')).toBe(true);
expect(isValidDurationString('1000')).toBe(true);
});
test('should return false for invalid formats', () => {
expect(isValidDurationString('abc')).toBe(false);
expect(isValidDurationString('5x')).toBe(false);
expect(isValidDurationString('')).toBe(false);
expect(isValidDurationString('-5s')).toBe(false);
});
test('should return false for various invalid input types', () => {
expect(isValidDurationString(null)).toBe(false);
expect(isValidDurationString(undefined)).toBe(false);
expect(isValidDurationString({})).toBe(false);
expect(isValidDurationString([])).toBe(false);
expect(isValidDurationString(true)).toBe(false);
expect(isValidDurationString(false)).toBe(false);
});
test('should return false for durations that would overflow', () => {
const largeValue = Math.floor(Number.MAX_SAFE_INTEGER / (24 * 60 * 60 * 1000)) + 1;
expect(isValidDurationString(`${largeValue}d`)).toBe(false);
// Test overflow in unit-based durations (not pure numeric strings which have different validation)
expect(isValidDurationString(`${Number.MAX_SAFE_INTEGER}d`)).toBe(false);
});
test('should return false for edge case invalid formats', () => {
expect(isValidDurationString('5.5.5s')).toBe(false);
expect(isValidDurationString('5ss')).toBe(false);
expect(isValidDurationString('s')).toBe(false);
expect(isValidDurationString('5')).toBe(true); // Valid numeric string
expect(isValidDurationString('5.m')).toBe(false);
expect(isValidDurationString('.5m')).toBe(false);
});
test('should handle decimal validation correctly', () => {
expect(isValidDurationString('1.5s')).toBe(true);
expect(isValidDurationString('0.5m')).toBe(true);
expect(isValidDurationString('2.0h')).toBe(true);
expect(isValidDurationString('1.25d')).toBe(true);
});
test('should validate case insensitive units', () => {
expect(isValidDurationString('5S')).toBe(true);
expect(isValidDurationString('5M')).toBe(true);
expect(isValidDurationString('5H')).toBe(true);
expect(isValidDurationString('5D')).toBe(true);
});
});
describe('integration tests', () => {
test('parseDuration and formatDuration should work together', () => {
const testValues = ['5s', '10m', '2h', '1d'];
testValues.forEach(value => {
const parsed = parseDuration(value);
const formatted = formatDuration(parsed);
expect(formatted).toBe(value);
});
});
test('should handle round-trip conversion with decimal values', () => {
// Note: formatDuration floors values, so we test known good round-trips
const testCases = [
{ input: '1.5s', parsed: 1500, formatted: '1s' },
{ input: '2.5m', parsed: 150000, formatted: '2m' },
{ input: '1.5h', parsed: 5400000, formatted: '1h' }
];
testCases.forEach(({ input, parsed, formatted }) => {
expect(parseDuration(input)).toBe(parsed);
expect(formatDuration(parsed)).toBe(formatted);
});
});
test('should validate and format edge boundary values', () => {
// Test values at unit boundaries
expect(formatDuration(999)).toBe('999ms');
expect(formatDuration(1000)).toBe('1s');
expect(formatDuration(59999)).toBe('59s');
expect(formatDuration(60000)).toBe('1m');
expect(formatDuration(3599999)).toBe('59m');
expect(formatDuration(3600000)).toBe('1h');
expect(formatDuration(86399999)).toBe('23h');
expect(formatDuration(86400000)).toBe('1d');
});
});
});