/** * DC-CATALOG-3 route tests: /deploys/catalog/apps + /deploys/catalog/deploy. * Table-driven over validation cases with a stubbed bridge fetch. */ const path = require('path'); const express = require('express'); const request = require('supertest'); // The route module captures SHIPDECK_BRIDGE_URL at require time — set it // BEFORE requiring deploys.js (this file's require order is the fixture). process.env.SHIPDECK_BRIDGE_URL = 'http://127.0.0.1:19999'; process.env.SHIPDECK_BRIDGE_TOKEN_FILE = ''; const ROOT = path.resolve(__dirname, '..', '..'); const buildDeploys = require(path.join(ROOT, 'routes', 'deploys')); const BRIDGE_URL = 'http://127.0.0.1:19999'; function makeApp(bridgeResponses) { const calls = []; const fetchT = async (url, opts) => { const key = `${opts.method} ${url.replace(BRIDGE_URL, '')}`; calls.push({ key, body: opts.body ? JSON.parse(opts.body) : null }); const r = bridgeResponses[key] || { status: 404, body: { ok: false, error: 'no stub' } }; return { status: r.status, json: async () => r.body, }; }; const log = { info() {}, error() {}, warn() {} }; const audits = []; const auditLogger = { log: async (entry) => { audits.push(entry); } }; const e = express(); e.use(express.json()); const asyncHandler = (fn) => async (req, res, next) => { try { await fn(req, res, next); } catch (e) { next(e); } }; e.use('/api/v1/deploys', buildDeploys({ asyncHandler, log, auditLogger, fetchT })); return { app: e, calls, audits }; } describe('DC-CATALOG-3: /deploys/catalog endpoints', () => { it('GET /catalog/apps proxies the bridge list', async () => { const { app, calls } = makeApp({ 'GET /api/catalog/apps': { status: 200, body: { ok: true, apps: [{ id: 'plex' }, { id: 'jellyfin' }] } }, }); const res = await request(app).get('/api/v1/deploys/catalog/apps').expect(200); expect(res.body.apps).toHaveLength(2); expect(calls[0].key).toBe('GET /api/catalog/apps'); }); it('POST /catalog/deploy with valid body proxies and audits', async () => { const { app, calls, audits } = makeApp({ 'POST /api/catalog/deploy': { status: 200, body: { ok: true, output: 'DEPLOYED plex', subdomain: 'plex' } }, }); const res = await request(app) .post('/api/v1/deploys/catalog/deploy') .send({ app: 'plex', port: 32400, env: { PLEX_CLAIM: 'claim-x' } }) .expect(200); expect(res.body.app).toBe('plex'); expect(res.body.port).toBe(32400); expect(calls[0].key).toBe('POST /api/catalog/deploy'); expect(calls[0].body).toMatchObject({ app: 'plex', port: 32400, env: { PLEX_CLAIM: 'claim-x' } }); // r4: audit via the real contract auditLogger.log({action,...}) expect(audits[0].action).toBe('deploy.catalog'); expect(audits[0].details.ok).toBe(true); expect(audits[0].outcome).toBe('success'); }); it.each([ ['missing app', { port: 32400 }, 400], ['bad app id', { app: '../etc', port: 32400 }, 400], ['missing port', { app: 'plex' }, 400], ['privileged port', { app: 'plex', port: 80 }, 400], ['port too high', { app: 'plex', port: 70000 }, 400], ['port not integer', { app: 'plex', port: 'abc' }, 400], ['bad env name', { app: 'plex', port: 32400, env: { 'bad-key': 'x' } }, 400], ['env value not string', { app: 'plex', port: 32400, env: { PLEX_CLAIM: 5 } }, 400], ])('validation: %s -> %i', async (_label, body, want) => { const { app, calls } = makeApp({}); const res = await request(app).post('/api/v1/deploys/catalog/deploy').send(body).expect(want); expect(calls).toHaveLength(0); // rejected before the bridge }); it('bridge failure maps to 502 with output detail', async () => { const { app } = makeApp({ 'POST /api/catalog/deploy': { status: 400, body: { ok: false, error: 'app "x" requires env PLEX_CLAIM', output: 'detail' } }, }); const res = await request(app) .post('/api/v1/deploys/catalog/deploy') .send({ app: 'plex', port: 32400 }) .expect(502); expect(res.body.error).toContain('PLEX_CLAIM'); }); });