Files
dashcaddy/dashcaddy-api/__tests__/input-validator.test.js
Sami ea5acfa9a2 test: build comprehensive test suite reaching 80%+ coverage threshold
Add 22 test files (~700 tests) covering security-critical modules, core
infrastructure, API routes, and error handling. Final coverage: 86.73%
statements / 80.57% branches / 85.57% functions / 87.42% lines, all above
the 80% threshold enforced by jest.config.js.

Highlights:
- Unit tests for crypto-utils, credential-manager, auth-manager, csrf,
  input-validator, state-manager, health-checker, backup-manager,
  update-manager, resource-monitor, app-templates, platform-paths,
  port-lock-manager, errors, error-handler, pagination, url-resolver
- Route tests for health, services, and containers (supertest + mocked deps)
- Shared test-utils helper for mock factories and Express app builder
- npm scripts for CI: test:ci, test:unit, test:routes, test:security,
  test:changed, test:debug
- jest.config.js: expand coverage targets, add 80% threshold gate
- routes/services.js: import ValidationError and NotFoundError from errors
- .gitignore: exclude coverage/, *.bak, *.log

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 21:36:46 -07:00

554 lines
19 KiB
JavaScript

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('<script>"alert(\'xss\')"</script>')).toBe(
'&lt;script&gt;&quot;alert(&#39;xss&#39;)&quot;&lt;/script&gt;'
);
});
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');
});
});
});