const { ValidationError, validateDNSRecord, validateDockerDeployment, validateFilePath, validateVolumePath, validateURL, validateToken, validateServiceConfig, sanitizeString, isValidPort, isPrivateIP, validateSecurePath } = require('../input-validator'); describe('Input Validator', () => { describe('ValidationError', () => { it('has correct name, message, field, and statusCode', () => { const err = new ValidationError('bad input', 'email'); expect(err.name).toBe('ValidationError'); expect(err.message).toBe('bad input'); expect(err.field).toBe('email'); expect(err.statusCode).toBe(400); expect(err).toBeInstanceOf(Error); }); it('field defaults to null', () => { const err = new ValidationError('oops'); expect(err.field).toBeNull(); }); }); describe('validateDNSRecord', () => { const validRecord = { subdomain: 'myapp', ip: '8.8.8.8' }; it('valid record returns sanitized data with lowercase subdomain and default TTL', () => { const result = validateDNSRecord({ subdomain: 'MyApp', ip: '1.2.3.4' }); expect(result.subdomain).toBe('myapp'); expect(result.ip).toBe('1.2.3.4'); expect(result.ttl).toBe(3600); }); it('accepts valid domain and custom TTL', () => { const result = validateDNSRecord({ subdomain: 'test', ip: '8.8.8.8', domain: 'example.com', ttl: 300 }); expect(result.domain).toBe('example.com'); expect(result.ttl).toBe(300); }); it('rejects missing subdomain', () => { expect(() => validateDNSRecord({ ip: '1.2.3.4' })).toThrow(ValidationError); }); it('rejects invalid subdomain format', () => { expect(() => validateDNSRecord({ subdomain: '-bad', ip: '1.2.3.4' })).toThrow(ValidationError); expect(() => validateDNSRecord({ subdomain: 'a'.repeat(64), ip: '1.2.3.4' })).toThrow(ValidationError); }); it('rejects DNS injection chars', () => { const dangerous = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r', '\\']; for (const char of dangerous) { expect(() => validateDNSRecord({ subdomain: `test${char}cmd`, ip: '1.2.3.4' })) .toThrow(ValidationError); } }); it('rejects invalid domain format', () => { expect(() => validateDNSRecord({ subdomain: 'app', ip: '1.2.3.4', domain: 'not valid!!' })) .toThrow(ValidationError); }); it('rejects missing IP', () => { expect(() => validateDNSRecord({ subdomain: 'test' })).toThrow(ValidationError); }); it('rejects invalid IP format', () => { expect(() => validateDNSRecord({ subdomain: 'test', ip: '999.999.999.999' })) .toThrow(ValidationError); }); it('blocks private IPs when blockPrivateIPs flag set', () => { expect(() => validateDNSRecord({ subdomain: 'test', ip: '192.168.1.1', blockPrivateIPs: true })).toThrow(ValidationError); }); it('allows private IPs when flag not set', () => { const result = validateDNSRecord({ subdomain: 'test', ip: '192.168.1.1' }); expect(result.ip).toBe('192.168.1.1'); }); it('rejects TTL below 60', () => { expect(() => validateDNSRecord({ subdomain: 'test', ip: '1.2.3.4', ttl: 10 })) .toThrow(ValidationError); }); it('rejects TTL above 86400', () => { expect(() => validateDNSRecord({ subdomain: 'test', ip: '1.2.3.4', ttl: 100000 })) .toThrow(ValidationError); }); it('aggregates multiple errors', () => { try { validateDNSRecord({ subdomain: '', ip: '' }); fail('Should have thrown'); } catch (err) { expect(err.errors).toBeDefined(); expect(err.errors.length).toBeGreaterThan(1); } }); }); describe('validateDockerDeployment', () => { const valid = { name: 'my-app', image: 'nginx:latest' }; it('valid deployment returns sanitized data', () => { const result = validateDockerDeployment(valid); expect(result.name).toBe('my-app'); expect(result.image).toBe('nginx:latest'); expect(result.ports).toEqual([]); expect(result.volumes).toEqual([]); expect(result.environment).toEqual({}); }); it('rejects missing container name', () => { expect(() => validateDockerDeployment({ image: 'nginx' })).toThrow(ValidationError); }); it('rejects invalid container name chars', () => { expect(() => validateDockerDeployment({ name: '!invalid', image: 'nginx' })) .toThrow(ValidationError); }); it('rejects container name > 255 chars', () => { expect(() => validateDockerDeployment({ name: 'a'.repeat(256), image: 'nginx' })) .toThrow(ValidationError); }); it('rejects missing image', () => { expect(() => validateDockerDeployment({ name: 'app' })).toThrow(ValidationError); }); it('blocks dangerous chars in image', () => { const dangerous = [';', '&', '|', '`', '$', '$(', '&&', '||', '\n']; for (const char of dangerous) { expect(() => validateDockerDeployment({ name: 'app', image: `nginx${char}rm` })) .toThrow(ValidationError); } }); it('rejects image name > 512 chars', () => { expect(() => validateDockerDeployment({ name: 'app', image: 'a'.repeat(513) })) .toThrow(ValidationError); }); it('validates port format "8080:80" and "8080:80/tcp"', () => { const result = validateDockerDeployment({ ...valid, ports: ['8080:80', '443:443/tcp'] }); expect(result.ports).toEqual(['8080:80', '443:443/tcp']); }); it('rejects invalid port format', () => { expect(() => validateDockerDeployment({ ...valid, ports: ['bad'] })) .toThrow(ValidationError); }); it('rejects port numbers outside 1-65535', () => { expect(() => validateDockerDeployment({ ...valid, ports: ['99999:80'] })) .toThrow(ValidationError); }); it('rejects ports that is not an array', () => { expect(() => validateDockerDeployment({ ...valid, ports: 'not-array' })) .toThrow(ValidationError); }); it('validates volume format', () => { const result = validateDockerDeployment({ ...valid, volumes: ['/data:/app/data', '/config:/app/config:ro'] }); expect(result.volumes).toHaveLength(2); }); it('rejects volumes that is not an array', () => { expect(() => validateDockerDeployment({ ...valid, volumes: 'not-array' })) .toThrow(ValidationError); }); it('validates environment variable names', () => { const result = validateDockerDeployment({ ...valid, environment: { NODE_ENV: 'production', PORT: 3000, DEBUG: true } }); expect(result.environment).toEqual({ NODE_ENV: 'production', PORT: 3000, DEBUG: true }); }); it('rejects invalid env var names', () => { expect(() => validateDockerDeployment({ ...valid, environment: { '123invalid': 'val' } })).toThrow(ValidationError); }); it('rejects environment that is not an object', () => { expect(() => validateDockerDeployment({ ...valid, environment: 'bad' })) .toThrow(ValidationError); }); }); describe('validateFilePath', () => { it('returns normalized path for valid input', () => { const result = validateFilePath('/app/data/file.json'); expect(result).toBeDefined(); }); it('rejects null/empty/non-string path', () => { expect(() => validateFilePath(null)).toThrow(ValidationError); expect(() => validateFilePath('')).toThrow(ValidationError); expect(() => validateFilePath(123)).toThrow(ValidationError); }); it('rejects directory traversal (..)', () => { // Use relative path so .. survives path.normalize on all platforms expect(() => validateFilePath('foo/../../bar')).toThrow('Path traversal detected'); }); it('rejects tilde (~)', () => { expect(() => validateFilePath('data/~/secret')).toThrow('Path traversal detected'); }); it('blocks sensitive paths', () => { if (process.platform === 'win32') { expect(() => validateFilePath('C:\\Windows\\System32\\config')).toThrow('not allowed'); expect(() => validateFilePath('C:\\Program Files\\test')).toThrow('not allowed'); } else { expect(() => validateFilePath('/etc/passwd')).toThrow('not allowed'); expect(() => validateFilePath('/proc/1/status')).toThrow('not allowed'); expect(() => validateFilePath('/sys/kernel')).toThrow('not allowed'); expect(() => validateFilePath('/root/.ssh')).toThrow('not allowed'); expect(() => validateFilePath('/var/run/docker.sock')).toThrow('not allowed'); expect(() => validateFilePath('/var/lib/docker/containers')).toThrow('not allowed'); } }); it('validates against allowedBasePaths', () => { const result = validateFilePath('/app/data/file.txt', ['/app/data']); expect(result).toBeDefined(); }); it('rejects paths outside allowed base', () => { expect(() => validateFilePath('/other/file.txt', ['/app/data'])) .toThrow('outside allowed directories'); }); }); describe('validateVolumePath', () => { it('valid volume returns no errors', () => { const errors = validateVolumePath('/host/path:/container/path', 0); expect(errors).toHaveLength(0); }); it('valid volume with mode returns no errors', () => { const errors = validateVolumePath('/host/path:/container/path:ro', 0); expect(errors).toHaveLength(0); }); it('detects invalid format', () => { const errors = validateVolumePath('invalidformat', 0); expect(errors.length).toBeGreaterThan(0); expect(errors[0].message).toContain('Invalid volume format'); }); it('validates container path must be absolute', () => { const errors = validateVolumePath('/host:relative/path', 0); expect(errors.length).toBeGreaterThan(0); }); }); describe('validateURL', () => { it('accepts valid http/https URLs', () => { expect(validateURL('https://example.com')).toBe('https://example.com'); expect(validateURL('http://example.com/path')).toBe('http://example.com/path'); }); it('rejects missing URL', () => { expect(() => validateURL(null)).toThrow(ValidationError); expect(() => validateURL('')).toThrow(ValidationError); }); it('rejects invalid URL format', () => { expect(() => validateURL('not-a-url')).toThrow(ValidationError); }); it('blocks private IP when blockPrivate is true', () => { expect(() => validateURL('http://10.0.0.1/', { blockPrivate: true })) .toThrow('Private URLs'); }); it('blocks 192.168.x.x when blockPrivate is true', () => { expect(() => validateURL('http://192.168.1.1/', { blockPrivate: true })) .toThrow('Private URLs'); }); it('allows private IPs when blockPrivate is false', () => { expect(validateURL('http://10.0.0.1/')).toBe('http://10.0.0.1/'); }); }); describe('validateToken', () => { it('accepts valid tokens', () => { const result = validateToken('abcdef1234567890'); expect(result).toBe('abcdef1234567890'); }); it('trims whitespace', () => { const result = validateToken(' validtoken '); expect(result).toBe('validtoken'); }); it('rejects missing/non-string token', () => { expect(() => validateToken(null)).toThrow(ValidationError); expect(() => validateToken(123)).toThrow(ValidationError); }); it('rejects token < 8 chars', () => { expect(() => validateToken('short')).toThrow('too short'); }); it('rejects token > 512 chars', () => { expect(() => validateToken('a'.repeat(513))).toThrow('too long'); }); it('rejects tokens with injection chars', () => { const dangerous = [';', '&', '|', '`', '\n', '\r', '$(', '&&']; for (const char of dangerous) { expect(() => validateToken(`validtoken${char}inject`)).toThrow('invalid characters'); } }); }); describe('validateServiceConfig', () => { const valid = { id: 'my-service', name: 'My Service' }; it('valid service config passes', () => { const result = validateServiceConfig(valid); expect(result.id).toBe('my-service'); }); it('rejects missing id', () => { expect(() => validateServiceConfig({ name: 'Test' })).toThrow(ValidationError); }); it('rejects invalid id format', () => { expect(() => validateServiceConfig({ id: 'bad id!', name: 'Test' })) .toThrow(ValidationError); }); it('rejects missing name', () => { expect(() => validateServiceConfig({ id: 'test' })).toThrow(ValidationError); }); it('rejects name > 100 chars', () => { expect(() => validateServiceConfig({ id: 'test', name: 'x'.repeat(101) })) .toThrow(ValidationError); }); it('validates URL when provided', () => { expect(() => validateServiceConfig({ id: 'test', name: 'Test', url: 'not-valid' })) .toThrow(ValidationError); }); it('validates port when provided', () => { expect(() => validateServiceConfig({ id: 'test', name: 'Test', port: 99999 })) .toThrow(ValidationError); }); it('accepts valid port', () => { const result = validateServiceConfig({ id: 'test', name: 'Test', port: 8080 }); expect(result.port).toBe(8080); }); }); describe('sanitizeString', () => { it('escapes < > \' " to HTML entities', () => { expect(sanitizeString('')).toBe( '<script>"alert('xss')"</script>' ); }); it('truncates to maxLength', () => { expect(sanitizeString('hello world', 5)).toBe('hello'); }); it('returns empty string for non-string input', () => { expect(sanitizeString(123)).toBe(''); expect(sanitizeString(null)).toBe(''); expect(sanitizeString(undefined)).toBe(''); }); }); describe('isValidPort', () => { it('returns true for valid ports', () => { expect(isValidPort(1)).toBe(true); expect(isValidPort(80)).toBe(true); expect(isValidPort(443)).toBe(true); expect(isValidPort(65535)).toBe(true); }); it('returns false for invalid ports', () => { expect(isValidPort(0)).toBe(false); expect(isValidPort(-1)).toBe(false); expect(isValidPort(65536)).toBe(false); expect(isValidPort(NaN)).toBe(false); }); it('handles string numbers', () => { expect(isValidPort('8080')).toBe(true); expect(isValidPort('0')).toBe(false); expect(isValidPort('abc')).toBe(false); }); }); describe('isPrivateIP', () => { it('identifies 10.x.x.x as private', () => { expect(isPrivateIP('10.0.0.1')).toBe(true); expect(isPrivateIP('10.255.255.255')).toBe(true); }); it('identifies 172.16-31.x.x as private', () => { expect(isPrivateIP('172.16.0.1')).toBe(true); expect(isPrivateIP('172.31.255.255')).toBe(true); }); it('identifies 192.168.x.x as private', () => { expect(isPrivateIP('192.168.1.1')).toBe(true); }); it('identifies 127.x.x.x as private', () => { expect(isPrivateIP('127.0.0.1')).toBe(true); }); it('identifies 169.254.x.x as private', () => { expect(isPrivateIP('169.254.0.1')).toBe(true); }); it('identifies IPv6 loopback as private', () => { expect(isPrivateIP('::1')).toBe(true); }); it('identifies fc00: and fe80: as private', () => { expect(isPrivateIP('fc00::1')).toBe(true); expect(isPrivateIP('fe80::1')).toBe(true); }); it('public IPs return false', () => { expect(isPrivateIP('8.8.8.8')).toBe(false); expect(isPrivateIP('1.1.1.1')).toBe(false); expect(isPrivateIP('203.0.113.1')).toBe(false); }); }); describe('validateSecurePath', () => { const mockRealpath = jest.fn(); beforeEach(() => { jest.resetModules(); // Mock fs.promises.realpath jest.doMock('fs', () => ({ ...jest.requireActual('fs'), promises: { realpath: mockRealpath, }, })); mockRealpath.mockReset(); }); // Re-require after mocking fs function getValidateSecurePath() { return require('../input-validator').validateSecurePath; } it('resolves valid path within allowed roots', async () => { const fn = getValidateSecurePath(); mockRealpath.mockResolvedValue('/app/data/file.txt'); const result = await fn('/app/data/file.txt', ['/app/data']); expect(result).toBe('/app/data/file.txt'); }); it('rejects null/empty path', async () => { const fn = getValidateSecurePath(); await expect(fn(null, ['/app'])).rejects.toThrow('Path is required'); await expect(fn('', ['/app'])).rejects.toThrow('Path is required'); }); it('rejects null byte injection', async () => { const fn = getValidateSecurePath(); await expect(fn('/app/data\0/evil', ['/app'])) .rejects.toThrow('null byte detected'); }); it('rejects .. traversal sequences', async () => { const fn = getValidateSecurePath(); await expect(fn('/app/../etc/passwd', ['/app'])) .rejects.toThrow('Path traversal detected'); }); it('rejects URL-encoded traversal', async () => { const fn = getValidateSecurePath(); await expect(fn('/app/%2e%2e/etc/passwd', ['/app'])) .rejects.toThrow('Path traversal detected'); }); it('rejects path outside allowed roots', async () => { const fn = getValidateSecurePath(); mockRealpath.mockResolvedValue('/other/place/file.txt'); await expect(fn('/other/place/file.txt', ['/app/data'])) .rejects.toThrow('outside allowed directories'); }); it('logs audit event when path is blocked', async () => { const fn = getValidateSecurePath(); const auditLogger = { logSecurityEvent: jest.fn() }; await expect(fn('/app/data\0evil', ['/app'], auditLogger)) .rejects.toThrow(); expect(auditLogger.logSecurityEvent).toHaveBeenCalledWith( 'path_traversal_blocked', expect.objectContaining({ reason: 'null_byte_detected', severity: 'high' }) ); }); it('handles ENOENT by checking parent', async () => { const fn = getValidateSecurePath(); mockRealpath .mockRejectedValueOnce(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) .mockResolvedValueOnce('/app/data'); // parent resolves const result = await fn('/app/data/newfile.txt', ['/app/data']); expect(result).toContain('newfile.txt'); }); it('handles EACCES with access denied error', async () => { const fn = getValidateSecurePath(); mockRealpath.mockRejectedValue(Object.assign(new Error('EACCES'), { code: 'EACCES' })); await expect(fn('/secret/file', ['/secret'])) .rejects.toThrow('Access denied'); }); it('rejects when no allowed roots configured', async () => { const fn = getValidateSecurePath(); await expect(fn('/app/file', [])).rejects.toThrow('No allowed roots configured'); }); }); });