/**
* Unit Tests for Phase 03 High Priority Fixes
*
* Tests for:
* - RUNTIME_002: Socket error handler race condition
* - MEMORY_LEAK_001: Event listener accumulation (verified via existing tests)
* - TYPE_SAFETY_001: Unchecked type cast in connection pool
* - UNHANDLED_PROMISE_001: gracefulShutdown not awaited
* - BUG-002: Cache key collision / regex injection
* - BUG-004: Integer overflow in rate limiting
* - BUG-005: Regex ReDoS vulnerability
* - BUG-007: Config validation bypass
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { isBlenderSocketResponse } from '../src/types/index.js';
// ============================================================================
// TYPE_SAFETY_001: Type Guard Tests
// ============================================================================
describe('TYPE_SAFETY_001: BlenderSocketResponse Type Guard', () => {
describe('Valid responses', () => {
it('should accept valid success response', () => {
expect(isBlenderSocketResponse({ status: 'success' })).toBe(true);
});
it('should accept success response with result', () => {
expect(isBlenderSocketResponse({
status: 'success',
result: { objects: ['Cube', 'Camera'] }
})).toBe(true);
});
it('should accept error response', () => {
expect(isBlenderSocketResponse({ status: 'error' })).toBe(true);
});
it('should accept error response with message', () => {
expect(isBlenderSocketResponse({
status: 'error',
message: 'Object not found'
})).toBe(true);
});
it('should accept full response with all fields', () => {
expect(isBlenderSocketResponse({
status: 'success',
result: { data: 'test' },
message: 'Operation completed'
})).toBe(true);
});
});
describe('Invalid responses', () => {
it('should reject null', () => {
expect(isBlenderSocketResponse(null)).toBe(false);
});
it('should reject undefined', () => {
expect(isBlenderSocketResponse(undefined)).toBe(false);
});
it('should reject empty object (missing status)', () => {
expect(isBlenderSocketResponse({})).toBe(false);
});
it('should reject invalid status', () => {
expect(isBlenderSocketResponse({ status: 'unknown' })).toBe(false);
expect(isBlenderSocketResponse({ status: 'SUCCESS' })).toBe(false);
expect(isBlenderSocketResponse({ status: 'ok' })).toBe(false);
expect(isBlenderSocketResponse({ status: 123 })).toBe(false);
});
it('should reject non-string message', () => {
expect(isBlenderSocketResponse({
status: 'error',
message: 123
})).toBe(false);
expect(isBlenderSocketResponse({
status: 'error',
message: ['error1', 'error2']
})).toBe(false);
});
it('should reject primitives', () => {
expect(isBlenderSocketResponse('success')).toBe(false);
expect(isBlenderSocketResponse(123)).toBe(false);
expect(isBlenderSocketResponse(true)).toBe(false);
});
it('should reject arrays', () => {
expect(isBlenderSocketResponse([{ status: 'success' }])).toBe(false);
});
});
describe('Edge cases', () => {
it('should accept response with extra fields', () => {
expect(isBlenderSocketResponse({
status: 'success',
extraField: 'value',
anotherField: 123
})).toBe(true);
});
it('should accept response with null result', () => {
expect(isBlenderSocketResponse({
status: 'success',
result: null
})).toBe(true);
});
it('should accept response with undefined message', () => {
expect(isBlenderSocketResponse({
status: 'error',
message: undefined
})).toBe(true);
});
});
});
// ============================================================================
// BUG-002: Cache Key Collision / Regex Injection Tests
// ============================================================================
describe('BUG-002: Cache Regex Injection Prevention', () => {
// Test implementation that mirrors the fix
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
describe('escapeRegex function', () => {
it('should escape all regex special characters', () => {
const specialChars = '.*+?^${}()|[]\\';
const escaped = escapeRegex(specialChars);
// Each special char should be escaped with backslash
expect(escaped).toBe('\\.\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\');
});
it('should leave normal characters unchanged', () => {
expect(escapeRegex('abc123')).toBe('abc123');
expect(escapeRegex('scene:info')).toBe('scene:info');
expect(escapeRegex('object_name')).toBe('object_name');
});
it('should prevent wildcard injection (.*)', () => {
const malicious = '.*'; // Would match everything
const escaped = escapeRegex(malicious);
const regex = new RegExp(escaped);
// Should only match literal ".*", not everything
expect(regex.test('.*')).toBe(true);
expect(regex.test('anything')).toBe(false);
expect(regex.test('scene:info')).toBe(false);
});
it('should prevent alternation injection (|)', () => {
const malicious = 'scene|object|cache'; // Would match multiple patterns
const escaped = escapeRegex(malicious);
const regex = new RegExp(escaped);
// Should only match literal "scene|object|cache"
expect(regex.test('scene|object|cache')).toBe(true);
expect(regex.test('scene')).toBe(false);
expect(regex.test('object')).toBe(false);
expect(regex.test('cache')).toBe(false);
});
it('should prevent character class injection ([...])', () => {
const malicious = '[a-z]'; // Would match any lowercase letter
const escaped = escapeRegex(malicious);
const regex = new RegExp(escaped);
// Should only match literal "[a-z]"
expect(regex.test('[a-z]')).toBe(true);
expect(regex.test('a')).toBe(false);
expect(regex.test('z')).toBe(false);
});
it('should prevent group injection ((...))', () => {
const malicious = '(.*?)'; // Would create capture group matching anything
const escaped = escapeRegex(malicious);
const regex = new RegExp(escaped);
// Should only match literal "(.*?)"
expect(regex.test('(.*?)')).toBe(true);
expect(regex.test('anything')).toBe(false);
});
it('should handle cache-like key patterns safely', () => {
// Simulate user input that could be malicious
const userInput = 'scene.*|object.*'; // Attempts to invalidate all scene and object entries
const escaped = escapeRegex(userInput);
const regex = new RegExp(escaped);
// Should NOT match actual cache keys
expect(regex.test('scene:info')).toBe(false);
expect(regex.test('object:Cube:info')).toBe(false);
// Should only match the literal string
expect(regex.test('scene.*|object.*')).toBe(true);
});
});
});
// ============================================================================
// BUG-004: Integer Overflow in Rate Limiting Tests
// ============================================================================
describe('BUG-004: Rate Limiter Integer Overflow Prevention', () => {
describe('Token calculation validation', () => {
it('should handle zero time passed', () => {
const timePassed = Math.max(0, 0);
const tokensToAdd = (timePassed / 60000) * 60;
expect(Number.isFinite(tokensToAdd)).toBe(true);
expect(tokensToAdd).toBe(0);
});
it('should handle negative time passed (clock skew)', () => {
// Simulating clock skew where now < lastRefill
const now = 1000;
const lastRefill = 2000;
const timePassed = Math.max(0, now - lastRefill);
expect(timePassed).toBe(0); // Should be clamped to 0
const tokensToAdd = (timePassed / 60000) * 60;
expect(Number.isFinite(tokensToAdd)).toBe(true);
});
it('should handle large time values', () => {
const timePassed = Number.MAX_SAFE_INTEGER;
const tokensToAdd = (timePassed / 60000) * 60;
// Should be finite even with large values
expect(Number.isFinite(tokensToAdd)).toBe(true);
});
it('should detect NaN in token calculation', () => {
const badTimePassed = NaN;
const tokensToAdd = (badTimePassed / 60000) * 60;
expect(Number.isFinite(tokensToAdd)).toBe(false);
});
it('should detect Infinity in token calculation', () => {
const badTimePassed = Infinity;
const tokensToAdd = (badTimePassed / 60000) * 60;
expect(Number.isFinite(tokensToAdd)).toBe(false);
});
it('should handle zero limit safely', () => {
const limit = 0;
const timePassed = 60000;
const tokensToAdd = (timePassed / 60000) * limit;
expect(Number.isFinite(tokensToAdd)).toBe(true);
expect(tokensToAdd).toBe(0);
});
it('should cap tokens to limit', () => {
const limit = 60;
const currentTokens = 50;
const tokensToAdd = 100; // Would exceed limit
const newTokens = Math.min(limit, currentTokens + tokensToAdd);
expect(newTokens).toBe(limit);
});
});
});
// ============================================================================
// BUG-005: Regex ReDoS Prevention Tests
// ============================================================================
describe('BUG-005: Regex ReDoS Prevention', () => {
// Test patterns with bounded quantifiers
const SAFE_PATTERNS = [
{ pattern: /\bos\.system\s{0,10}\(/i, name: 'os.system' },
{ pattern: /\bsubprocess\./i, name: 'subprocess' },
{ pattern: /\b__import__\s{0,10}\(/i, name: '__import__' },
{ pattern: /\beval\s{0,10}\(/i, name: 'eval' },
{ pattern: /\bexec\s{0,10}\(/i, name: 'exec' },
{ pattern: /\bopen\s{0,10}\([^)]{0,50}['"][wa]/i, name: 'open write' }
];
describe('Pattern correctness', () => {
it('should detect os.system calls', () => {
expect(SAFE_PATTERNS[0].pattern.test('os.system("cmd")')).toBe(true);
expect(SAFE_PATTERNS[0].pattern.test('os.system ("cmd")')).toBe(true);
expect(SAFE_PATTERNS[0].pattern.test('print("safe")')).toBe(false);
});
it('should detect subprocess usage', () => {
expect(SAFE_PATTERNS[1].pattern.test('subprocess.run()')).toBe(true);
expect(SAFE_PATTERNS[1].pattern.test('subprocess.Popen()')).toBe(true);
expect(SAFE_PATTERNS[1].pattern.test('safe_code()')).toBe(false);
});
it('should detect eval calls', () => {
expect(SAFE_PATTERNS[3].pattern.test('eval("code")')).toBe(true);
expect(SAFE_PATTERNS[3].pattern.test('eval ("code")')).toBe(true);
expect(SAFE_PATTERNS[3].pattern.test('evalulate()')).toBe(false);
});
it('should detect file write operations', () => {
expect(SAFE_PATTERNS[5].pattern.test('open("file.txt", "w")')).toBe(true);
expect(SAFE_PATTERNS[5].pattern.test('open("file.txt", "a")')).toBe(true);
expect(SAFE_PATTERNS[5].pattern.test('open("file.txt", "r")')).toBe(false);
});
});
describe('Performance with bounded quantifiers', () => {
it('should handle long whitespace sequences quickly', () => {
// Create input with many spaces (would cause catastrophic backtracking with unbounded *)
const longSpaces = 'os.system' + ' '.repeat(100) + '("cmd")';
const start = Date.now();
// Pattern with {0,10} will fail quickly as it only checks up to 10 spaces
const matched = SAFE_PATTERNS[0].pattern.test(longSpaces);
const duration = Date.now() - start;
// Should complete very quickly (< 10ms)
expect(duration).toBeLessThan(100);
expect(matched).toBe(false); // Won't match as there are more than 10 spaces
});
it('should handle long input strings quickly', () => {
// Create a large but safe input
const largeInput = 'x = 1\n'.repeat(10000) + 'print("done")';
const start = Date.now();
for (const { pattern } of SAFE_PATTERNS) {
pattern.test(largeInput);
}
const duration = Date.now() - start;
// All patterns should complete quickly
expect(duration).toBeLessThan(500);
});
it('should limit parentheses content matching', () => {
// Create input with very long content inside parentheses
const longContent = 'open(' + 'x'.repeat(100) + '"w")';
const start = Date.now();
// Pattern with {0,50} will fail quickly
const matched = SAFE_PATTERNS[5].pattern.test(longContent);
const duration = Date.now() - start;
expect(duration).toBeLessThan(100);
expect(matched).toBe(false); // Content exceeds 50 chars before quote
});
});
});
// ============================================================================
// BUG-007: Config Validation Bypass Tests
// ============================================================================
describe('BUG-007: Config Validation Bypass Prevention', () => {
describe('Safe integer validation', () => {
it('should accept valid safe integers', () => {
expect(Number.isSafeInteger(9876)).toBe(true);
expect(Number.isSafeInteger(0)).toBe(true);
expect(Number.isSafeInteger(-1000)).toBe(true);
expect(Number.isSafeInteger(65535)).toBe(true);
});
it('should reject values exceeding safe integer range', () => {
expect(Number.isSafeInteger(Number.MAX_SAFE_INTEGER + 1)).toBe(false);
expect(Number.isSafeInteger(Number.MIN_SAFE_INTEGER - 1)).toBe(false);
});
it('should reject floating point numbers', () => {
expect(Number.isSafeInteger(3.14)).toBe(false);
expect(Number.isSafeInteger(9876.5)).toBe(false);
});
it('should reject NaN and Infinity', () => {
expect(Number.isSafeInteger(NaN)).toBe(false);
expect(Number.isSafeInteger(Infinity)).toBe(false);
expect(Number.isSafeInteger(-Infinity)).toBe(false);
});
});
describe('Environment variable parsing', () => {
// Simulate parseEnvNumber with safety checks
function parseEnvNumberSafe(value: string | undefined, defaultValue: number): number {
if (!value) return defaultValue;
const parsed = parseInt(value, 10);
if (isNaN(parsed)) return defaultValue;
if (!Number.isSafeInteger(parsed)) return defaultValue;
return parsed;
}
it('should return default for missing value', () => {
expect(parseEnvNumberSafe(undefined, 9876)).toBe(9876);
});
it('should parse valid integers', () => {
expect(parseEnvNumberSafe('8080', 9876)).toBe(8080);
expect(parseEnvNumberSafe('0', 9876)).toBe(0);
expect(parseEnvNumberSafe('-1', 9876)).toBe(-1);
});
it('should return default for invalid values', () => {
expect(parseEnvNumberSafe('invalid', 9876)).toBe(9876);
expect(parseEnvNumberSafe('12.34', 9876)).toBe(12); // parseInt truncates
});
it('should reject extremely large values', () => {
// Port 999999 exceeds valid port range
expect(parseEnvNumberSafe('999999', 9876)).toBe(999999); // parseInt succeeds
// But schema validation should catch this (tested in integration)
});
it('should reject values exceeding MAX_SAFE_INTEGER', () => {
const huge = '9999999999999999999'; // Exceeds MAX_SAFE_INTEGER
const result = parseEnvNumberSafe(huge, 9876);
expect(result).toBe(9876); // Should return default
});
});
});
// ============================================================================
// RUNTIME_002: Socket Handler Race Tests (structural tests)
// ============================================================================
describe('RUNTIME_002: Socket Error Handler Order', () => {
it('should demonstrate handler registration before connect pattern', () => {
const events: string[] = [];
// Simulate socket with event registration
const mockSocket = {
handlers: {} as Record<string, (() => void)[]>,
once(event: string, handler: () => void) {
events.push(`register:${event}`);
if (!this.handlers[event]) this.handlers[event] = [];
this.handlers[event].push(handler);
},
connect(_port: number, _host: string, callback: () => void) {
events.push('connect:start');
// Simulate immediate callback
callback();
events.push('connect:complete');
}
};
// Correct order: handlers first, then connect
mockSocket.once('error', () => {});
mockSocket.once('timeout', () => {});
mockSocket.connect(9876, 'localhost', () => {});
// Verify order
expect(events[0]).toBe('register:error');
expect(events[1]).toBe('register:timeout');
expect(events[2]).toBe('connect:start');
expect(events[3]).toBe('connect:complete');
});
it('should handle isSettled flag to prevent double resolution', () => {
let isSettled = false;
const resolutions: string[] = [];
const resolve = () => {
if (isSettled) return;
isSettled = true;
resolutions.push('resolved');
};
const reject = () => {
if (isSettled) return;
isSettled = true;
resolutions.push('rejected');
};
// Simulate both events firing (which can happen with race condition)
resolve();
reject(); // Should be ignored
expect(resolutions).toEqual(['resolved']);
expect(resolutions.length).toBe(1);
});
});
// ============================================================================
// UNHANDLED_PROMISE_001: Async IIFE Pattern Tests
// ============================================================================
describe('UNHANDLED_PROMISE_001: Async IIFE Pattern', () => {
it('should demonstrate void async IIFE pattern', async () => {
const executed: string[] = [];
// Simulate signal handler with void async IIFE
const signalHandler = () => {
void (async () => {
executed.push('shutdown:start');
await new Promise(r => setTimeout(r, 10));
executed.push('shutdown:complete');
})();
};
// Call handler
signalHandler();
// Wait for async completion
await new Promise(r => setTimeout(r, 50));
expect(executed).toEqual(['shutdown:start', 'shutdown:complete']);
});
it('should handle errors in async IIFE gracefully', async () => {
const errors: Error[] = [];
const originalHandler = console.error;
console.error = (e: Error) => errors.push(e);
try {
const errorHandler = () => {
void (async () => {
throw new Error('Test error');
})().catch(e => {
console.error(e);
});
};
errorHandler();
await new Promise(r => setTimeout(r, 10));
// Error should be caught, not unhandled
expect(errors.length).toBeGreaterThan(0);
} finally {
console.error = originalHandler;
}
});
});