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>
This commit is contained in:
553
dashcaddy-api/__tests__/input-validator.test.js
Normal file
553
dashcaddy-api/__tests__/input-validator.test.js
Normal file
@@ -0,0 +1,553 @@
|
||||
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(
|
||||
'<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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user