/** * DC-057: dead-shadow /backups/schedule handler removed. * * The duplicate `router.post('/backups/schedule', ...)` previously registered * far below the canonical one was unreachable (Express matches the first * registered handler per METHOD+PATH). It bypassed `premiumGating` and * `validateBody` and used a `name`-keyed schema that would have corrupted the * backup config if it ever ran. The canonical handler uses the error code * `backups-schedule-update`; the dead handler used `backups-schedule-legacy`. * This test proves: * * 1. The router registers exactly ONE POST /backups/schedule handler * (the canonical, appId-keyed one). * 2. No handler references the legacy "backups-schedule-legacy" error code. * 3. The legacy "name"-keyed schema now produces a 400 ValidationError * from the canonical Joi schema (dead handler is gone). * 4. The canonical appId-keyed schema still succeeds (200). * 5. premiumGating is enforced on the canonical POST. * * Mirrors the audit-log.routes.test.js pattern. */ const express = require('express'); function buildFakeBackupManager() { const config = { backups: {}, defaultRetention: { keep: 7 } }; return { getConfig: jest.fn(() => config), updateConfig: jest.fn((next) => { config.backups = next.backups || {}; }), getHistory: jest.fn(() => []), restoreBackup: jest.fn(async (id) => { // Suppress require-await — keep async shape for parity with the // real backupManager.restoreBackup contract. return Promise.resolve({ id, status: 'restored' }); }), }; } function buildFakeLicenseManager() { const requirePremium = jest.fn(() => (_req, _res, next) => next()); return { requirePremium, isPremium: jest.fn(() => true), }; } function buildRouter(licenseManager, backupManager) { // Reset module cache so each test starts fresh jest.resetModules(); const mod = require('../../routes/backups'); return mod({ backupManager, licenseManager, asyncHandler: (fn) => async (req, res, next) => { // eslint-disable-line require-await try { await fn(req, res, next); } catch (e) { next(e); } }, }); } function buildApp(router) { // Catch-all error handler so ValidationError / NotFoundError become JSON const app = express(); app.use(express.json()); app.use((req, res, next) => { // intentionally strip auth — the test does not exercise it next(); }); app.use('/', router); // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { const status = err.statusCode || err.status || 500; res.status(status).json({ error: err.message, code: err.code || 'ERR', }); }); return app; } function supertestFetch(app) { // Tiny in-process fetch helper (no need to add supertest dep) const http = require('http'); return function (method, path, body) { return new Promise((resolve, reject) => { const server = app.listen(0, () => { const { port } = server.address(); const data = body ? JSON.stringify(body) : null; const req = http.request({ method, hostname: '127.0.0.1', port, path, headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}, }, (res) => { let chunks = ''; res.on('data', (c) => { chunks += c; }); res.on('end', () => { server.close(); let parsed; try { parsed = JSON.parse(chunks); } catch { parsed = chunks; } resolve({ status: res.statusCode, body: parsed }); }); }); req.on('error', (e) => { server.close(); reject(e); }); if (data) req.write(data); req.end(); }); }); }; } describe('routes/backups POST /backups/schedule (DC-057)', () => { let backupManager, licenseManager, app, fetch; beforeEach(() => { backupManager = buildFakeBackupManager(); licenseManager = buildFakeLicenseManager(); const router = buildRouter(licenseManager, backupManager); app = buildApp(router); fetch = supertestFetch(app); }); test('registers exactly ONE POST /backups/schedule handler (canonical)', () => { // Inspect the registered router layers and confirm only one POST /backups/schedule // route exists (no shadowed / unreachable duplicate). const router = buildRouter(licenseManager, backupManager); const seen = []; router.stack.forEach((layer) => { if (layer.route && layer.route.path === '/backups/schedule' && layer.route.methods.post) { seen.push(layer.route); } }); expect(seen).toHaveLength(1); }); test('no handler references the legacy "backups-schedule-legacy" error code', () => { // The canonical handler uses error code 'backups-schedule-update'. // Walk the router stack and assert no route uses the legacy error code. const router = buildRouter(licenseManager, backupManager); const handlerStrings = []; function walk(node) { if (!node) return; if (node.stack) node.stack.forEach(walk); if (node.handle) { const code = node.handle.toString(); handlerStrings.push(code); } } walk(router); const all = handlerStrings.join('\n'); expect(all).not.toContain('backups-schedule-legacy'); }); test('legacy name-keyed schema is REJECTED with 400 (dead route truly gone)', async () => { // The dead handler accepted { name, schedule, maxStorageBytes, ...backupConfig }. // After removal, the canonical Joi schema (backupScheduleCreate) rejects this // shape because it requires `appId`. So we expect a 400. const res = await fetch('POST', '/backups/schedule', { name: 'mybackup', schedule: 'daily', maxStorageBytes: 1024, }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/appId.*required|appId is required/i); }); test('canonical appId-keyed schema SUCCEEDS (200) and writes backup config', async () => { const res = await fetch('POST', '/backups/schedule', { appId: 'plex', schedule: 'daily', retention: { keep: 7 }, destination: 'local', destinationPath: '/var/backups/plex', maxStorageBytes: 1024, }); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(backupManager.updateConfig).toHaveBeenCalledTimes(1); const written = backupManager.updateConfig.mock.calls[0][0]; expect(written.backups).toHaveProperty('plex'); expect(written.backups.plex.schedule).toBe('daily'); expect(written.backups.plex.enabled).toBe(true); expect(written.backups.plex.maxStorageBytes).toBe(1024); }); test('premium gating is enforced on POST /backups/schedule', async () => { // Replace the premium gate with one that 403s, then verify it runs. licenseManager.requirePremium.mockReturnValueOnce( (_req, res) => res.status(403).json({ error: 'premium required' }), ); const router = buildRouter(licenseManager, backupManager); app = buildApp(router); fetch = supertestFetch(app); const res = await fetch('POST', '/backups/schedule', { appId: 'plex', schedule: 'daily', }); expect(res.status).toBe(403); expect(backupManager.updateConfig).not.toHaveBeenCalled(); }); test('GET /backups/schedule still works (no collateral damage)', async () => { const res = await fetch('GET', '/backups/schedule'); expect(res.status).toBe(200); expect(res.body.success).toBe(true); expect(res.body).toHaveProperty('schedules'); }); test('DELETE /backups/schedule/:appId still works', async () => { // Seed the config so the delete has something to remove backupManager.getConfig().backups.plex = { schedule: 'daily' }; const res = await fetch('DELETE', '/backups/schedule/plex'); expect(res.status).toBe(200); expect(backupManager.updateConfig).toHaveBeenCalled(); }); });