/** * Integration tests for server.js input validation * Tests that routes properly reject invalid input before reaching business logic */ const request = require('supertest'); const app = require('../server'); describe('POST /api/assets/upload - directory traversal prevention', () => { test('rejects filename with path separators', async () => { const res = await request(app) .post('/api/assets/upload') .send({ filename: '../../../etc/passwd', data: 'data:image/png;base64,iVBOR' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/path separator/i); }); test('rejects filename with backslash', async () => { const res = await request(app) .post('/api/assets/upload') .send({ filename: '..\\..\\config.json', data: 'data:image/png;base64,iVBOR' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/path separator/i); }); test('rejects filename with dot-dot', async () => { const res = await request(app) .post('/api/assets/upload') .send({ filename: '..evil.png', data: 'data:image/png;base64,iVBOR' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/path separator/i); }); test('rejects missing fields', async () => { const res = await request(app) .post('/api/assets/upload') .send({ filename: 'test.png' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/required/i); }); }); describe('POST /api/site - Caddyfile injection prevention', () => { test('rejects invalid domain format', async () => { const res = await request(app) .post('/api/site') .send({ domain: 'evil;rm -rf /', upstream: '127.0.0.1:8080' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid domain/i); }); test('rejects domain with spaces', async () => { const res = await request(app) .post('/api/site') .send({ domain: 'evil domain', upstream: '127.0.0.1:8080' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid domain/i); }); test('rejects invalid upstream format', async () => { const res = await request(app) .post('/api/site') .send({ domain: 'test.sami', upstream: 'not a valid upstream' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid upstream/i); }); test('rejects missing fields', async () => { const res = await request(app) .post('/api/site') .send({ domain: 'test.sami' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/required/i); }); }); describe('POST /api/site/external - URL and subdomain validation', () => { test('rejects invalid subdomain', async () => { const res = await request(app) .post('/api/site/external') .send({ subdomain: '-invalid', externalUrl: 'https://example.com' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid subdomain/i); }); test('rejects subdomain with special chars', async () => { const res = await request(app) .post('/api/site/external') .send({ subdomain: 'test;evil', externalUrl: 'https://example.com' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid subdomain/i); }); test('rejects invalid URL', async () => { const res = await request(app) .post('/api/site/external') .send({ subdomain: 'myapp', externalUrl: 'not-a-url' }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); }); test('rejects missing fields', async () => { const res = await request(app) .post('/api/site/external') .send({ subdomain: 'myapp' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/required/i); }); }); // DNS routes require a token to bypass the 401 token check and reach validation const FAKE_TOKEN = 'aaaa1111bbbb2222cccc3333dddd4444'; describe('POST /api/dns/record - DNS injection prevention', () => { test('rejects invalid domain format', async () => { const res = await request(app) .post('/api/dns/record') .send({ domain: 'evil;command', ip: '10.0.0.1', token: FAKE_TOKEN }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid domain/i); }); test('rejects invalid IP address', async () => { const res = await request(app) .post('/api/dns/record') .send({ domain: 'test.sami', ip: 'not-an-ip', token: FAKE_TOKEN }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid ip/i); }); test('rejects TTL out of range (too low)', async () => { const res = await request(app) .post('/api/dns/record') .send({ domain: 'test.sami', ip: '10.0.0.1', ttl: 5, token: FAKE_TOKEN }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/ttl/i); }); test('rejects TTL out of range (too high)', async () => { const res = await request(app) .post('/api/dns/record') .send({ domain: 'test.sami', ip: '10.0.0.1', ttl: 100000, token: FAKE_TOKEN }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/ttl/i); }); test('rejects invalid server IP', async () => { const res = await request(app) .post('/api/dns/record') .send({ domain: 'test.sami', ip: '10.0.0.1', server: 'not-an-ip', token: FAKE_TOKEN }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid dns server/i); }); test('rejects missing fields', async () => { const res = await request(app) .post('/api/dns/record') .send({ domain: 'test.sami', token: FAKE_TOKEN }); expect(res.status).toBe(400); }); }); describe('DELETE /api/dns/record - DNS injection prevention', () => { test('rejects invalid domain', async () => { const res = await request(app) .delete('/api/dns/record') .query({ domain: 'evil;drop table', token: 'abc123def456' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid domain/i); }); test('rejects invalid record type', async () => { const res = await request(app) .delete('/api/dns/record') .query({ domain: 'test.sami', type: 'INVALID', token: 'abc123def456' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid dns record type/i); }); test('rejects invalid ipAddress', async () => { const res = await request(app) .delete('/api/dns/record') .query({ domain: 'test.sami', ipAddress: 'not-ip', token: 'abc123def456' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid ip/i); }); }); describe('GET /api/dns/resolve - DNS injection prevention', () => { test('rejects invalid domain', async () => { const res = await request(app) .get('/api/dns/resolve') .query({ domain: 'evil;command', token: FAKE_TOKEN }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid domain/i); }); test('rejects invalid server IP', async () => { const res = await request(app) .get('/api/dns/resolve') .query({ domain: 'test.sami', server: 'not-an-ip', token: FAKE_TOKEN }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid dns server/i); }); }); describe('POST /api/apps/deploy - deployment validation', () => { test('rejects invalid subdomain', async () => { const res = await request(app) .post('/api/apps/deploy') .send({ appId: 'plex', config: { subdomain: '-bad-sub' } }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid subdomain/i); }); test('rejects invalid port', async () => { const res = await request(app) .post('/api/apps/deploy') .send({ appId: 'plex', config: { subdomain: 'test', port: 99999 } }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid port/i); }); test('rejects invalid IP', async () => { const res = await request(app) .post('/api/apps/deploy') .send({ appId: 'plex', config: { subdomain: 'test', ip: 'not-ip' } }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid ip/i); }); test('rejects unknown template', async () => { const res = await request(app) .post('/api/apps/deploy') .send({ appId: 'nonexistent-app-xyz', config: { subdomain: 'test' } }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid app template/i); }); }); describe('POST /api/dns/credentials - credential validation', () => { test('rejects missing fields', async () => { const res = await request(app) .post('/api/dns/credentials') .send({ username: 'admin' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/required/i); }); test('rejects username exceeding max length', async () => { const res = await request(app) .post('/api/dns/credentials') .send({ username: 'a'.repeat(101), password: 'secret' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/maximum length/i); }); test('rejects username with injection chars', async () => { const res = await request(app) .post('/api/dns/credentials') .send({ username: 'admin;rm -rf /', password: 'secret' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid characters/i); }); test('rejects username with pipe', async () => { const res = await request(app) .post('/api/dns/credentials') .send({ username: 'admin|evil', password: 'secret' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid characters/i); }); test('rejects invalid server IP', async () => { const res = await request(app) .post('/api/dns/credentials') .send({ username: 'admin', password: 'secret', server: 'not-ip' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid dns server/i); }); }); describe('POST /api/services - service config validation', () => { test('rejects missing fields', async () => { const res = await request(app) .post('/api/services') .send({ id: 'test' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/required/i); }); test('rejects invalid service id format', async () => { const res = await request(app) .post('/api/services') .send({ id: 'invalid id with spaces!', name: 'Test' }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); }); }); describe('PUT /api/services - bulk import validation', () => { test('rejects non-array body', async () => { const res = await request(app) .put('/api/services') .send({ id: 'test', name: 'Test' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/array/i); }); test('rejects service with invalid id', async () => { const res = await request(app) .put('/api/services') .send([{ id: 'invalid id!', name: 'Test' }]); expect(res.status).toBe(400); expect(res.body.success).toBe(false); }); }); describe('POST /api/services/update - service update validation', () => { test('rejects missing subdomains', async () => { const res = await request(app) .post('/api/services/update') .send({ oldSubdomain: 'test' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/required/i); }); test('rejects invalid subdomain format', async () => { const res = await request(app) .post('/api/services/update') .send({ oldSubdomain: '-bad', newSubdomain: 'good' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid subdomain/i); }); test('rejects invalid port', async () => { const res = await request(app) .post('/api/services/update') .send({ oldSubdomain: 'old', newSubdomain: 'new', port: 70000 }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid port/i); }); test('rejects invalid IP', async () => { const res = await request(app) .post('/api/services/update') .send({ oldSubdomain: 'old', newSubdomain: 'new', ip: 'not-ip' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/invalid ip/i); }); }); describe('POST /api/arr/test-connection - SSRF prevention', () => { test('rejects invalid URL', async () => { const res = await request(app) .post('/api/arr/test-connection') .send({ service: 'radarr', url: 'not-a-url', apiKey: 'abc123def456' }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); }); test('rejects invalid API key format', async () => { const res = await request(app) .post('/api/arr/test-connection') .send({ service: 'radarr', url: 'http://localhost:7878', apiKey: 'a;b' }); expect(res.status).toBe(400); expect(res.body.success).toBe(false); }); }); describe('POST /api/notifications/config - notification provider validation', () => { test('rejects invalid Discord webhook URL', async () => { const res = await request(app) .post('/api/notifications/config') .send({ providers: { discord: { webhookUrl: 'not-a-url' } } }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/discord webhook/i); }); test('rejects invalid ntfy server URL', async () => { const res = await request(app) .post('/api/notifications/config') .send({ providers: { ntfy: { serverUrl: 'ftp://bad' } } }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/ntfy server/i); }); test('rejects invalid ntfy topic', async () => { const res = await request(app) .post('/api/notifications/config') .send({ providers: { ntfy: { topic: 'has spaces and $pecial!' } } }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/ntfy topic/i); }); test('accepts valid config', async () => { const res = await request(app) .post('/api/notifications/config') .send({ enabled: true }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); }); }); describe('Rate limiting headers', () => { test('returns rate limit headers on API responses', async () => { const res = await request(app).get('/api/health'); // Health endpoint is skipped by rate limiter, but general endpoints should have headers expect(res.status).toBe(200); }); test('general API endpoint has rate limiting configured', async () => { const res = await request(app).get('/api/services'); // Rate limiting is skipped in test env, so verify the endpoint is accessible expect(res.status).toBe(200); }); });