const { ValidationError, validateDNSRecord, validateDockerDeployment, validateFilePath, validateVolumePath, validateURL, validateToken, validateServiceConfig, sanitizeString, isValidPort, isPrivateIP } = require('../input-validator'); // Helper: extract .errors from ValidationError function getErrors(fn) { try { fn(); return null; } catch (e) { return e; } } describe('ValidationError', () => { test('creates error with message and field', () => { const err = new ValidationError('bad input', 'name'); expect(err.message).toBe('bad input'); expect(err.field).toBe('name'); }); test('has statusCode 400', () => { expect(new ValidationError('x').statusCode).toBe(400); }); test('has name "ValidationError"', () => { expect(new ValidationError('x').name).toBe('ValidationError'); }); test('defaults field to null', () => { expect(new ValidationError('x').field).toBeNull(); }); test('is instance of Error', () => { expect(new ValidationError('x')).toBeInstanceOf(Error); }); }); describe('validateDNSRecord', () => { const valid = { subdomain: 'myapp', ip: '192.168.1.1' }; describe('valid inputs', () => { test('accepts valid subdomain and ip', () => { const result = validateDNSRecord(valid); expect(result.subdomain).toBe('myapp'); expect(result.ip).toBe('192.168.1.1'); }); test('returns sanitized lowercase output', () => { const result = validateDNSRecord({ subdomain: 'MyApp', ip: '10.0.0.1' }); expect(result.subdomain).toBe('myapp'); }); test('defaults ttl to 3600 when not provided', () => { expect(validateDNSRecord(valid).ttl).toBe(3600); }); test('accepts explicit ttl', () => { expect(validateDNSRecord({ ...valid, ttl: 300 }).ttl).toBe(300); }); test('accepts IPv6 addresses', () => { const result = validateDNSRecord({ subdomain: 'test', ip: '::1' }); expect(result.ip).toBe('::1'); }); test('accepts valid domain', () => { const result = validateDNSRecord({ ...valid, domain: 'example.local' }); expect(result.domain).toBe('example.local'); }); test('returns null domain when not provided', () => { expect(validateDNSRecord(valid).domain).toBeNull(); }); test('lowercases and trims subdomain in output', () => { const result = validateDNSRecord({ subdomain: 'MyApp', ip: '10.0.0.1' }); expect(result.subdomain).toBe('myapp'); }); }); describe('subdomain validation', () => { test('rejects missing subdomain', () => { const err = getErrors(() => validateDNSRecord({ ip: '1.2.3.4' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects non-string subdomain', () => { const err = getErrors(() => validateDNSRecord({ subdomain: 123, ip: '1.2.3.4' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects subdomain starting with hyphen', () => { const err = getErrors(() => validateDNSRecord({ subdomain: '-bad', ip: '1.2.3.4' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects subdomain ending with hyphen', () => { const err = getErrors(() => validateDNSRecord({ subdomain: 'bad-', ip: '1.2.3.4' })); expect(err).toBeInstanceOf(ValidationError); }); test('accepts single-character subdomain', () => { expect(validateDNSRecord({ subdomain: 'a', ip: '1.2.3.4' }).subdomain).toBe('a'); }); test('accepts subdomain with hyphens in middle', () => { expect(validateDNSRecord({ subdomain: 'my-app', ip: '1.2.3.4' }).subdomain).toBe('my-app'); }); test('rejects subdomain exceeding 63 characters', () => { const long = 'a'.repeat(64); const err = getErrors(() => validateDNSRecord({ subdomain: long, ip: '1.2.3.4' })); expect(err).toBeInstanceOf(ValidationError); }); }); describe('injection prevention', () => { const chars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r', '\\']; chars.forEach(char => { test(`rejects "${char === '\n' ? '\\n' : char === '\r' ? '\\r' : char}" in subdomain`, () => { const err = getErrors(() => validateDNSRecord({ subdomain: `test${char}bad`, ip: '1.2.3.4' })); expect(err).toBeInstanceOf(ValidationError); }); }); }); describe('IP validation', () => { test('rejects missing IP', () => { const err = getErrors(() => validateDNSRecord({ subdomain: 'test' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects invalid IP format', () => { const err = getErrors(() => validateDNSRecord({ subdomain: 'test', ip: '999.999.999.999' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects non-string IP', () => { const err = getErrors(() => validateDNSRecord({ subdomain: 'test', ip: 12345 })); expect(err).toBeInstanceOf(ValidationError); }); test('blocks private IP when blockPrivateIPs is true', () => { const err = getErrors(() => validateDNSRecord({ subdomain: 'test', ip: '192.168.1.1', blockPrivateIPs: true })); expect(err).toBeInstanceOf(ValidationError); }); test('allows private IP when blockPrivateIPs is absent', () => { expect(validateDNSRecord({ subdomain: 'test', ip: '192.168.1.1' }).ip).toBe('192.168.1.1'); }); }); describe('TTL validation', () => { test('rejects TTL below 60', () => { const err = getErrors(() => validateDNSRecord({ ...valid, ttl: 10 })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects TTL above 86400', () => { const err = getErrors(() => validateDNSRecord({ ...valid, ttl: 100000 })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects non-numeric TTL', () => { const err = getErrors(() => validateDNSRecord({ ...valid, ttl: 'abc' })); expect(err).toBeInstanceOf(ValidationError); }); test('accepts TTL at lower boundary (60)', () => { expect(validateDNSRecord({ ...valid, ttl: 60 }).ttl).toBe(60); }); test('accepts TTL at upper boundary (86400)', () => { expect(validateDNSRecord({ ...valid, ttl: 86400 }).ttl).toBe(86400); }); }); describe('error aggregation', () => { test('returns multiple errors for multiple invalid fields', () => { const err = getErrors(() => validateDNSRecord({ ttl: 1 })); expect(err.errors.length).toBeGreaterThan(1); }); test('throws ValidationError with .errors array', () => { const err = getErrors(() => validateDNSRecord({})); expect(err).toBeInstanceOf(ValidationError); expect(Array.isArray(err.errors)).toBe(true); }); }); }); describe('validateDockerDeployment', () => { const valid = { name: 'myapp', image: 'nginx:latest' }; describe('valid inputs', () => { test('accepts valid name and image', () => { const result = validateDockerDeployment(valid); expect(result.name).toBe('myapp'); expect(result.image).toBe('nginx:latest'); }); test('returns defaults for optional fields', () => { const result = validateDockerDeployment(valid); expect(result.ports).toEqual([]); expect(result.volumes).toEqual([]); expect(result.environment).toEqual({}); }); }); describe('container name validation', () => { test('rejects missing name', () => { const err = getErrors(() => validateDockerDeployment({ image: 'nginx' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects name starting with special char', () => { const err = getErrors(() => validateDockerDeployment({ name: '-bad', image: 'nginx' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects name exceeding 255 characters', () => { const err = getErrors(() => validateDockerDeployment({ name: 'a'.repeat(256), image: 'nginx' })); expect(err).toBeInstanceOf(ValidationError); }); test('accepts name with underscores, periods, hyphens', () => { const result = validateDockerDeployment({ name: 'my_app.v1-test', image: 'nginx' }); expect(result.name).toBe('my_app.v1-test'); }); }); describe('image validation', () => { test('rejects missing image', () => { const err = getErrors(() => validateDockerDeployment({ name: 'app' })); expect(err).toBeInstanceOf(ValidationError); }); test('accepts simple image', () => { expect(validateDockerDeployment({ name: 'a', image: 'alpine' }).image).toBe('alpine'); }); test('accepts image with tag', () => { expect(validateDockerDeployment({ name: 'a', image: 'nginx:latest' }).image).toBe('nginx:latest'); }); test('accepts fully qualified image', () => { const result = validateDockerDeployment({ name: 'a', image: 'docker.io/library/nginx:1.21' }); expect(result.image).toBe('docker.io/library/nginx:1.21'); }); test('rejects image with semicolon', () => { const err = getErrors(() => validateDockerDeployment({ name: 'a', image: 'nginx;rm -rf /' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects image with $( subshell', () => { const err = getErrors(() => validateDockerDeployment({ name: 'a', image: 'nginx$(evil)' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects image exceeding 512 characters', () => { const err = getErrors(() => validateDockerDeployment({ name: 'a', image: 'a'.repeat(513) })); expect(err).toBeInstanceOf(ValidationError); }); }); describe('ports validation', () => { test('rejects non-array ports', () => { const err = getErrors(() => validateDockerDeployment({ ...valid, ports: 'bad' })); expect(err).toBeInstanceOf(ValidationError); }); test('accepts string port format "8080:80"', () => { const result = validateDockerDeployment({ ...valid, ports: ['8080:80'] }); expect(result.ports).toEqual(['8080:80']); }); test('accepts port format with protocol "8080:80/tcp"', () => { const result = validateDockerDeployment({ ...valid, ports: ['8080:80/tcp'] }); expect(result.ports).toEqual(['8080:80/tcp']); }); test('rejects invalid port format', () => { const err = getErrors(() => validateDockerDeployment({ ...valid, ports: ['bad'] })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects port numbers > 65535', () => { const err = getErrors(() => validateDockerDeployment({ ...valid, ports: ['70000:80'] })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects port numbers < 1', () => { const err = getErrors(() => validateDockerDeployment({ ...valid, ports: ['0:80'] })); expect(err).toBeInstanceOf(ValidationError); }); test('accepts numeric port values', () => { const result = validateDockerDeployment({ ...valid, ports: [8080] }); expect(result.ports).toEqual([8080]); }); test('rejects non-string non-number port values', () => { const err = getErrors(() => validateDockerDeployment({ ...valid, ports: [{}] })); expect(err).toBeInstanceOf(ValidationError); }); }); describe('volumes validation', () => { test('rejects non-array volumes', () => { const err = getErrors(() => validateDockerDeployment({ ...valid, volumes: 'bad' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects non-string volume entries', () => { const err = getErrors(() => validateDockerDeployment({ ...valid, volumes: [123] })); expect(err).toBeInstanceOf(ValidationError); }); }); describe('environment validation', () => { test('rejects non-object environment', () => { const err = getErrors(() => validateDockerDeployment({ ...valid, environment: 'bad' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects array as environment', () => { const err = getErrors(() => validateDockerDeployment({ ...valid, environment: [] })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects invalid env var names', () => { const err = getErrors(() => validateDockerDeployment({ ...valid, environment: { '1BAD': 'val' } })); expect(err).toBeInstanceOf(ValidationError); }); test('accepts valid env var names', () => { const result = validateDockerDeployment({ ...valid, environment: { MY_VAR: 'test', _under: '1' } }); expect(result.environment).toEqual({ MY_VAR: 'test', _under: '1' }); }); test('accepts string, number, boolean values', () => { const env = { A: 'str', B: 42, C: true }; const result = validateDockerDeployment({ ...valid, environment: env }); expect(result.environment).toEqual(env); }); test('rejects object values', () => { const err = getErrors(() => validateDockerDeployment({ ...valid, environment: { X: { nested: true } } })); expect(err).toBeInstanceOf(ValidationError); }); }); }); describe('validateFilePath', () => { const isWindows = process.platform === 'win32'; test('rejects empty path', () => { expect(() => validateFilePath('')).toThrow(ValidationError); }); test('rejects null path', () => { expect(() => validateFilePath(null)).toThrow(ValidationError); }); test('rejects path with ~', () => { expect(() => validateFilePath('~/secrets')).toThrow(ValidationError); }); // On Windows, path.normalize resolves '..' so the normalized path may not contain '..' // On Linux, '/app/../etc/passwd' normalizes to '/etc/passwd' which is blocked test('blocks C:\\Windows path', () => { expect(() => validateFilePath('C:\\Windows\\System32')).toThrow(ValidationError); }); test('blocks C:\\Program Files path', () => { expect(() => validateFilePath('C:\\Program Files\\test')).toThrow(ValidationError); }); if (!isWindows) { test('rejects path with ..', () => { expect(() => validateFilePath('/app/../etc/passwd')).toThrow(ValidationError); }); test('blocks /etc path', () => { expect(() => validateFilePath('/etc/passwd')).toThrow(ValidationError); }); test('blocks /proc path', () => { expect(() => validateFilePath('/proc/self/environ')).toThrow(ValidationError); }); test('blocks /sys path', () => { expect(() => validateFilePath('/sys/class')).toThrow(ValidationError); }); test('blocks /root path', () => { expect(() => validateFilePath('/root/.ssh')).toThrow(ValidationError); }); test('blocks /var/run path', () => { expect(() => validateFilePath('/var/run/docker.sock')).toThrow(ValidationError); }); test('blocks /var/lib/docker path', () => { expect(() => validateFilePath('/var/lib/docker/containers')).toThrow(ValidationError); }); } test('returns normalized path for valid input', () => { const testPath = isWindows ? 'D:\\app\\data\\config' : '/app/data/config'; const result = validateFilePath(testPath); expect(result).toBeTruthy(); }); test('enforces allowedBasePaths when specified', () => { const testPath = isWindows ? 'D:\\app\\data' : '/app/data'; const allowedBase = isWindows ? 'D:\\opt' : '/opt'; expect(() => validateFilePath(testPath, [allowedBase])).toThrow(ValidationError); }); test('accepts path within allowedBasePaths', () => { const testPath = isWindows ? 'D:\\opt\\myapp\\config' : '/opt/myapp/config'; const allowedBase = isWindows ? 'D:\\opt' : '/opt'; const result = validateFilePath(testPath, [allowedBase]); expect(result).toBeTruthy(); }); test('accepts any path when allowedBasePaths is empty', () => { const testPath = isWindows ? 'D:\\app\\data' : '/app/data'; const result = validateFilePath(testPath, []); expect(result).toBeTruthy(); }); }); describe('validateVolumePath', () => { test('rejects invalid volume format', () => { const errors = validateVolumePath('not-a-volume', 0); expect(errors.length).toBeGreaterThan(0); }); test('rejects container path with ..', () => { const errors = validateVolumePath('/app/data:/../etc:ro', 0); expect(errors.length).toBeGreaterThan(0); }); test('accepts valid modes: ro, rw, z, Z', () => { ['ro', 'rw', 'z', 'Z'].forEach(mode => { const errors = validateVolumePath(`/app/data:/container/path:${mode}`, 0); // Filter to only mode-related errors const modeErrors = errors.filter(e => e.field && e.field.includes('mode')); expect(modeErrors).toHaveLength(0); }); }); test('accepts valid volume without mode', () => { const errors = validateVolumePath('/app/data:/container/path', 0); // Should have no container path errors const containerErrors = errors.filter(e => e.field && e.field.includes('containerPath')); expect(containerErrors).toHaveLength(0); }); }); describe('validateURL', () => { test('rejects empty URL', () => { expect(() => validateURL('')).toThrow(ValidationError); }); test('rejects null URL', () => { expect(() => validateURL(null)).toThrow(ValidationError); }); test('accepts valid https URL', () => { expect(validateURL('https://example.com')).toBe('https://example.com'); }); test('accepts valid http URL', () => { expect(validateURL('http://example.com')).toBe('http://example.com'); }); test('rejects non-URL strings', () => { expect(() => validateURL('not a url')).toThrow(ValidationError); }); test('blocks localhost when blockPrivate is true', () => { expect(() => validateURL('http://localhost:3000', { blockPrivate: true })).toThrow(ValidationError); }); test('blocks 127.0.0.1 when blockPrivate is true', () => { expect(() => validateURL('http://127.0.0.1:3000', { blockPrivate: true })).toThrow(ValidationError); }); test('blocks private IPs when blockPrivate is true', () => { expect(() => validateURL('http://192.168.1.1', { blockPrivate: true })).toThrow(ValidationError); }); test('allows private IPs when blockPrivate is false', () => { expect(validateURL('http://192.168.1.1')).toBe('http://192.168.1.1'); }); }); describe('validateToken', () => { test('rejects empty token', () => { expect(() => validateToken('')).toThrow(ValidationError); }); test('rejects null token', () => { expect(() => validateToken(null)).toThrow(ValidationError); }); test('rejects token shorter than 8 chars', () => { expect(() => validateToken('short')).toThrow(ValidationError); }); test('rejects token longer than 512 chars', () => { expect(() => validateToken('a'.repeat(513))).toThrow(ValidationError); }); test('rejects token with semicolon', () => { expect(() => validateToken('token123;evil')).toThrow(ValidationError); }); test('rejects token with $( subshell', () => { expect(() => validateToken('token123$(evil)')).toThrow(ValidationError); }); test('rejects token with &&', () => { expect(() => validateToken('token123&&evil')).toThrow(ValidationError); }); test('accepts valid alphanumeric token', () => { expect(validateToken('abcdef12345678')).toBe('abcdef12345678'); }); test('trims whitespace', () => { expect(validateToken(' abcdef12345678 ')).toBe('abcdef12345678'); }); test('accepts token at minimum length (8)', () => { expect(validateToken('12345678')).toBe('12345678'); }); test('accepts token at maximum length (512)', () => { const token = 'a'.repeat(512); expect(validateToken(token)).toBe(token); }); }); describe('validateServiceConfig', () => { const valid = { id: 'my-service', name: 'My Service' }; test('accepts valid service config', () => { const result = validateServiceConfig(valid); expect(result.id).toBe('my-service'); }); test('rejects missing ID', () => { const err = getErrors(() => validateServiceConfig({ name: 'Test' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects invalid ID format', () => { const err = getErrors(() => validateServiceConfig({ id: 'bad id!', name: 'Test' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects missing name', () => { const err = getErrors(() => validateServiceConfig({ id: 'test' })); expect(err).toBeInstanceOf(ValidationError); }); test('rejects name exceeding 100 chars', () => { const err = getErrors(() => validateServiceConfig({ id: 'test', name: 'a'.repeat(101) })); expect(err).toBeInstanceOf(ValidationError); }); test('validates URL when present', () => { const err = getErrors(() => validateServiceConfig({ ...valid, url: 'not a url' })); expect(err).toBeInstanceOf(ValidationError); }); test('validates port when present', () => { const err = getErrors(() => validateServiceConfig({ ...valid, port: 99999 })); expect(err).toBeInstanceOf(ValidationError); }); test('accepts valid URL and port', () => { const result = validateServiceConfig({ ...valid, url: 'http://example.com', port: 8080 }); expect(result.id).toBe('my-service'); }); test('aggregates multiple errors', () => { const err = getErrors(() => validateServiceConfig({})); expect(err.errors.length).toBeGreaterThan(1); }); }); describe('isValidPort', () => { test('accepts port 1', () => { expect(isValidPort(1)).toBe(true); }); test('accepts port 65535', () => { expect(isValidPort(65535)).toBe(true); }); test('rejects port 0', () => { expect(isValidPort(0)).toBe(false); }); test('rejects port 65536', () => { expect(isValidPort(65536)).toBe(false); }); test('accepts string port "8080"', () => { expect(isValidPort('8080')).toBe(true); }); test('rejects NaN', () => { expect(isValidPort('abc')).toBe(false); }); test('rejects negative port', () => { expect(isValidPort(-1)).toBe(false); }); }); describe('isPrivateIP', () => { test('detects 10.x.x.x as private', () => { expect(isPrivateIP('10.0.0.1')).toBe(true); expect(isPrivateIP('10.255.255.255')).toBe(true); }); test('detects 172.16-31.x.x as private', () => { expect(isPrivateIP('172.16.0.1')).toBe(true); expect(isPrivateIP('172.31.255.255')).toBe(true); }); test('does not flag 172.15.x.x as private', () => { expect(isPrivateIP('172.15.0.1')).toBe(false); }); test('does not flag 172.32.x.x as private', () => { expect(isPrivateIP('172.32.0.1')).toBe(false); }); test('detects 192.168.x.x as private', () => { expect(isPrivateIP('192.168.1.1')).toBe(true); }); test('detects 127.x.x.x as private', () => { expect(isPrivateIP('127.0.0.1')).toBe(true); expect(isPrivateIP('127.255.255.255')).toBe(true); }); test('detects 169.254.x.x as private', () => { expect(isPrivateIP('169.254.1.1')).toBe(true); }); test('detects ::1 as private', () => { expect(isPrivateIP('::1')).toBe(true); }); test('detects fc00: as private', () => { expect(isPrivateIP('fc00::1')).toBe(true); }); test('detects fe80: as private', () => { expect(isPrivateIP('fe80::1')).toBe(true); }); test('identifies 8.8.8.8 as public', () => { expect(isPrivateIP('8.8.8.8')).toBe(false); }); test('identifies 1.1.1.1 as public', () => { expect(isPrivateIP('1.1.1.1')).toBe(false); }); }); describe('sanitizeString', () => { test('escapes < to <', () => { expect(sanitizeString('