/** * DC-100: Service discovery + DC-107: Disaster recovery endpoint tests */ const express = require('express'); const request = require('supertest'); const fs = require('fs'); const path = require('path'); const os = require('os'); function createDiscoverApp(docker, servicesStateManager) { const app = express(); app.use(express.json()); const routes = require('../../routes/discover'); const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); app.use('/api/v1', routes({ docker, servicesStateManager, asyncHandler: wrap })); return app; } function createDisasterApp(platformPaths, log) { const app = express(); // Match the production body-parser limit (1 MiB) so the in-handler // DC-079 cap (512 KiB) is actually reachable from tests. The default // express.json() limit is 100 KiB, which would short-circuit the test // with a 413 before the route's defense-in-depth check runs. app.use(express.json({ limit: '1mb' })); const routes = require('../../routes/disaster-recovery'); const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); app.use('/api/v1', routes({ platformPaths, log: log || { info: jest.fn(), error: jest.fn() }, asyncHandler: wrap })); return app; } describe('DC-100: Service Discovery', () => { it('returns 503 when Docker is not available', async () => { const app = createDiscoverApp(null, null); const res = await request(app).get('/api/v1/discover'); expect(res.status).toBe(503); expect(res.body.success).toBe(false); }); it('discovers running containers with pattern matching', async () => { const mockDocker = { client: { listContainers: jest.fn().mockResolvedValue([ { Id: 'abc123def456', Names: ['/plex-server'], Image: 'plexinc/pms-docker:latest', State: 'running', Ports: [{ IP: '0.0.0.0', PrivatePort: 32400, PublicPort: 32400, Type: 'tcp' }], Labels: {}, }, ]), }, }; const app = createDiscoverApp(mockDocker, { read: jest.fn().mockResolvedValue([]) }); const res = await request(app).get('/api/v1/discover'); expect(res.status).toBe(200); expect(res.body.total).toBe(1); expect(res.body.discovered[0].suggested.type).toBe('plex'); }); it('handles empty container list', async () => { const app = createDiscoverApp({ client: { listContainers: jest.fn().mockResolvedValue([]) } }, null); const res = await request(app).get('/api/v1/discover'); expect(res.status).toBe(200); expect(res.body.total).toBe(0); }); it('returns 500 on Docker error', async () => { const app = createDiscoverApp({ client: { listContainers: jest.fn().mockRejectedValue(new Error('fail')) } }, null); const res = await request(app).get('/api/v1/discover'); expect(res.status).toBe(500); }); }); describe('DC-107: Disaster Recovery', () => { let tmpDir; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-dr-')); }); afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); it('GET /disaster/status returns empty status initially', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const res = await request(app).get('/api/v1/disaster/status'); expect(res.status).toBe(200); expect(res.body.lastBackup).toBeTruthy(); expect(res.body.lastBackup.status).toBeNull(); }); it('POST /disaster/backup creates snapshot', async () => { // Create a services.json so backup has data fs.writeFileSync(path.join(tmpDir, 'services.json'), JSON.stringify([{ id: 'test' }])); fs.writeFileSync(path.join(tmpDir, 'config.json'), JSON.stringify({ tld: '.sami' })); const app = createDisasterApp({ dataDir: tmpDir }); const res = await request(app).post('/api/v1/disaster/backup'); expect(res.status).toBe(200); expect(res.body.version).toBe('1.0'); expect(res.body.files.services).toBeTruthy(); expect(res.body.files.config).toBeTruthy(); expect(res.body.checksum).toBeTruthy(); }); it('POST /disaster/restore rejects invalid snapshot', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const res = await request(app) .post('/api/v1/disaster/restore') .send({ foo: 'bar' }); expect(res.status).toBe(400); }); it('POST /disaster/restore restores files', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', files: { services: [{ id: 'restored-svc' }], config: { tld: '.test' }, }, }); expect(res.status).toBe(200); expect(res.body.status).toBe('success'); expect(res.body.restored).toContain('services.json'); expect(res.body.restored).toContain('config.json'); // Verify files were written const svc = JSON.parse(fs.readFileSync(path.join(tmpDir, 'services.json'), 'utf8')); expect(svc[0].id).toBe('restored-svc'); }); // DC-079: Caddyfile restore hardening — the live Caddyfile path must // NEVER be written from the disaster-recovery endpoint. The endpoint // stages the candidate file under dataDir/disaster-staged/Caddyfile.candidate // and surfaces a warning that `caddy-apply` is required to apply it. it('DC-079: POST /disaster/restore with caddyfile STAGES instead of writing the live Caddyfile', async () => { // The env var CADDYFILE_PATH is read by the route. Use a sentinel // path that we can prove was NOT written. The route must instead // create /disaster-staged/Caddyfile.candidate. const liveSentinel = path.join(tmpDir, 'LIVE_CADDYFILE_SENTINEL.txt'); fs.writeFileSync(liveSentinel, 'do-not-overwrite'); const candidateCaddyfile = '# staged candidate\n' + 'example.com {\n' + ' respond "ok"\n' + '}\n'; const app = createDisasterApp({ dataDir: tmpDir, caddyfilePath: liveSentinel, // route reads env or fallback; this is just for the response }); // Override process.env.CADDYFILE_PATH so the route picks up our sentinel const prev = process.env.CADDYFILE_PATH; process.env.CADDYFILE_PATH = liveSentinel; try { const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', caddyfile: candidateCaddyfile, }); expect(res.status).toBe(200); expect(res.body.status).toBe('success'); expect(res.body.caddyfileStaged).toBeTruthy(); expect(res.body.caddyfileStaged).toHaveLength(1); expect(res.body.caddyfileStaged[0].file).toBe('Caddyfile'); expect(res.body.caddyfileStaged[0].action).toBe('awaiting caddy-apply'); expect(res.body.caddyfileStaged[0].stagedPath).toBe( path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate') ); expect(res.body.caddyfileStaged[0].livePath).toBe(liveSentinel); expect(res.body.warning).toMatch(/DC-079/); // The live sentinel file is UNTOUCHED — still has its original content. const liveContents = fs.readFileSync(liveSentinel, 'utf8'); expect(liveContents).toBe('do-not-overwrite'); // The candidate file IS staged at the staging path. const stagedContents = fs.readFileSync( path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'), 'utf8' ); expect(stagedContents).toBe(candidateCaddyfile); } finally { if (prev === undefined) delete process.env.CADDYFILE_PATH; else process.env.CADDYFILE_PATH = prev; } }); it('DC-079: POST /disaster/restore rejects non-string caddyfile content', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', caddyfile: { evil: 'object' }, }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/Caddyfile content must be a string/); }); it('DC-079: POST /disaster/restore rejects explicit empty caddyfile string', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', caddyfile: '', // explicit empty payload — rejected }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/Caddyfile content is empty/); }); it('DC-079: POST /disaster/restore rejects oversized caddyfile content', async () => { const app = createDisasterApp({ dataDir: tmpDir }); // 512 KiB + 1 byte — over the in-handler cap, under the 1 MB body limit const huge = 'a'.repeat(512 * 1024 + 1); const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', caddyfile: huge, }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/exceeds 524288 bytes/); }); it('DC-079: POST /disaster/restore rejects forbidden `import` directive (absolute path)', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const evil = '# malicious snapshot\n' + 'import /etc/caddy/external.caddy\n' + 'example.com { respond "ok" }\n'; const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', caddyfile: evil, }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/forbidden `import` directive/); // No staging file should have been created — fail closed. expect(fs.existsSync(path.join(tmpDir, 'disaster-staged', 'Caddyfile.candidate'))).toBe(false); }); it('DC-079: POST /disaster/restore rejects forbidden `import` with relative-path escape', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const evil = '# malicious snapshot\n' + 'import ../../../etc/passwd\n' + 'example.com { respond "ok" }\n'; const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', caddyfile: evil, }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/forbidden `import` directive/); }); it('DC-079: POST /disaster/restore rejects URL-encoded import payload', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const evil = 'import %2fetc%2fcaddy%2fevil.caddy\n' + 'example.com { respond "ok" }\n'; const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', caddyfile: evil, }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/forbidden `import` directive/); }); it('DC-079: POST /disaster/restore without caddyfile field succeeds and stages nothing', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', files: { services: [{ id: 'no-caddy' }], }, }); expect(res.status).toBe(200); expect(res.body.caddyfileStaged).toBeUndefined(); expect(res.body.warning).toBeUndefined(); }); // DC-079 follow-up (GLM round-2 BLOCKING): assets/themes path traversal. // Without the assertSafeAssetKey / assertSafeThemeName + path.resolve // checks, an attacker can POST `{assets: {"../../etc/caddy/Caddyfile": // ""}}` and overwrite the live Caddyfile via the dataDir // bind-mount. These tests prove the fix. it('DC-079: POST /disaster/restore rejects assets with path-traversal key', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', assets: { '../../etc/caddy/Caddyfile': Buffer.from('EVIL_BASE64_PAYLOAD').toString('base64'), 'custom-logo.png': Buffer.from('legit-logo').toString('base64'), }, }); // The traversal key is rejected (added to errors), the legit key // still works. Status is success-or-partial, never 500. expect(res.status).toBe(200); expect(res.body.status).toBe('partial'); // one error const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../etc/caddy/Caddyfile')); expect(erroredFile).toBeTruthy(); expect(erroredFile.error).toMatch(/forbidden characters or path segments/); // The legit logo DID get written. const legitPath = path.join(tmpDir, 'assets', 'custom-logo.png'); expect(fs.existsSync(legitPath)).toBe(true); // The traversal target was NEVER written. const escapePath = path.join(tmpDir, 'assets', '../../etc/caddy/Caddyfile'); // Resolve to absolute path — should be outside tmpDir/assets. const resolvedEsc = path.resolve(escapePath); expect(fs.existsSync(resolvedEsc)).toBe(false); }); it('DC-079: POST /disaster/restore rejects assets with absolute path key', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', assets: { '/etc/passwd': Buffer.from('evil').toString('base64'), }, }); expect(res.status).toBe(200); expect(res.body.status).toBe('partial'); const erroredFile = res.body.errors.find(e => e.file && e.file.includes('/etc/passwd')); expect(erroredFile).toBeTruthy(); }); it('DC-079: POST /disaster/restore rejects themes with path-traversal name', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', themes: { '../../../etc/caddy/evil.json': { evil: true }, 'legit-theme.json': { ok: true }, }, }); expect(res.status).toBe(200); expect(res.body.status).toBe('partial'); const erroredFile = res.body.errors.find(e => e.file && e.file.includes('../../../etc/caddy/evil.json')); expect(erroredFile).toBeTruthy(); expect(erroredFile.error).toMatch(/must match/); // The legit theme DID get written. expect(fs.existsSync(path.join(tmpDir, 'themes', 'legit-theme.json'))).toBe(true); }); it('DC-079: POST /disaster/restore rejects themes without .json extension', async () => { const app = createDisasterApp({ dataDir: tmpDir }); const res = await request(app) .post('/api/v1/disaster/restore') .send({ version: '1.0', themes: { 'no-extension': { ok: true }, }, }); expect(res.status).toBe(200); expect(res.body.status).toBe('partial'); const erroredFile = res.body.errors.find(e => e.file && e.file.includes('no-extension')); expect(erroredFile).toBeTruthy(); expect(erroredFile.error).toMatch(/must match/); }); });