/** * DC-137: route integration tests for the shipdeck engine branch. * * Route-level pins (the unit tests in catalog-engine-dc137.test.js cover * gating + payload mapping): * - POST /apps/deploy with config.engine='shipdeck' + compatible template: * calls bridge /api/image/install, NEVER calls Docker create, skips * panel DNS + Caddy writes, registers the service with an engine=shipdeck * manifest, responds engine:'shipdeck'. * - Bridge failure: 502 with stage detail, Docker create never invoked * (no silent fallback), no service registration. * - DELETE /apps/:appId for an engine-installed service: runs shipdeck rm * through the bridge instead of Docker container removal. */ 'use strict'; const path = require('path'); const fs = require('fs'); const os = require('os'); const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc137-route-')); const TOKEN_FILE = path.join(TMP_DIR, 'bridge-token'); fs.writeFileSync(TOKEN_FILE, 'tok-789'); process.env.SHIPDECK_BRIDGE_URL = 'http://127.0.0.1:8977'; process.env.SHIPDECK_BRIDGE_TOKEN_FILE = TOKEN_FILE; const express = require('express'); const request = require('supertest'); const uptimeTemplate = { id: 'uptime-kuma', name: 'Uptime Kuma', category: 'Monitoring', defaultPort: 3002, subdomain: 'uptime', logo: '/assets/uptime-kuma.png', docker: { image: 'louislam/uptime-kuma:latest', ports: ['{{PORT}}:3001'], volumes: ['/opt/uptime/data:/app/data'], environment: { SOME_FLAG: '1' }, }, healthCheck: '/web/index.html', subpathSupport: 'strip', }; function buildDeps(overrides = {}) { return Object.assign({ docker: { client: { getContainer: () => { throw new Error('DOCKER MUST NOT BE CALLED'); }, listImages: () => { throw new Error('DOCKER MUST NOT BE CALLED'); }, pruneImages: () => { throw new Error('DOCKER MUST NOT BE CALLED'); }, }, }, caddy: { generateConfig: () => { throw new Error('CADDY GENERATE MUST NOT BE CALLED FOR ENGINE INSTALLS'); }, modify: () => { throw new Error('CADDY MODIFY MUST NOT BE CALLED FOR ENGINE INSTALLS'); }, }, credentialManager: { retrieve: async () => null }, servicesStateManager: { read: async () => [], update: async (fn) => fn([]), }, portLockManager: { acquire: async () => () => {}, release: () => {} }, asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next), errorResponse: (res, code, msg, extra = {}) => res.status(code).json({ success: false, error: msg, ...extra }), log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} }, helpers: undefined, // wired below (real helpers need too much ctx) APP_TEMPLATES: { 'uptime-kuma': uptimeTemplate }, siteConfig: { routingMode: 'subdomain', domain: 'sami', dnsServerIp: '127.0.0.1' }, buildDomain: (sub) => `${sub}.sami`, buildServiceUrl: (sub) => `https://${sub}.sami`, addServiceToConfig: async (svc) => svc, dns: { universalCreateRecord: () => { throw new Error('PANEL DNS MUST NOT BE CALLED FOR ENGINE INSTALLS'); }, getToken: () => null, }, notification: { send: () => {} }, safeErrorMessage: (m) => m, SERVICES_FILE: path.join(TMP_DIR, 'services.json'), }, overrides); } function buildApp(deps) { const factory = require('../routes/apps/deploy'); const router = factory(deps); const app = express(); app.use(express.json()); app.use('/api/v1/apps', router); return app; } describe('DC-137 routes: engine install via POST /apps/deploy', () => { test('engine install: bridge called, Docker/DNS/Caddy untouched, manifest recorded', async () => { const calls = []; const deps = buildDeps({ servicesStateManager: { read: async () => [], update: async () => [], }, addServiceToConfig: async (svc) => { calls.push(['register', svc]); return svc; }, }); // real helpers from the apps module (processTemplateVariables etc.) const initHelpers = require('../routes/apps/helpers'); deps.helpers = initHelpers({ ...deps, ctx: { siteConfig: deps.siteConfig, docker: deps.docker } }); // engine module uses DI-free bridge client; intercept at the HTTP seam const origFetch = global.fetch; global.fetch = async (url, opts) => { calls.push(['bridge', url, JSON.parse(opts.body)]); return { ok: true, status: 200, json: async () => ({ ok: true, service: { name: 'uptime', image: 'louislam/uptime-kuma:latest@sha256:aa', shipdeckfile: '/var/lib/shipdeck/services/uptime/Shipdeckfile' }, output: 'deployed' }) }; }; try { const app = buildApp(deps); const res = await request(app) .post('/api/v1/apps/deploy') .send({ appId: 'uptime-kuma', config: { subdomain: 'uptime', port: 3002, engine: 'shipdeck', createDns: false } }); expect(res.status).toBe(200); expect(res.body.engine).toBe('shipdeck'); expect(calls.some(c => c[0] === 'bridge' && String(c[1]).includes('/api/image/install'))).toBe(true); // container-side port (3001) won over host-selected 3002 const bridgeCall = calls.find(c => c[0] === 'bridge'); expect(bridgeCall[2].port).toBe(3001); // service registered with engine manifest const reg = calls.find(c => c[0] === 'register'); expect(reg).toBeDefined(); expect(reg[1].deploymentManifest.engine).toBe('shipdeck'); expect(reg[1].deploymentManifest.shipdeck.shipdeckfile).toContain('/uptime/'); } finally { global.fetch = origFetch; } }); test('bridge failure: 502 with stage detail, no Docker fallback, no registration', async () => { const calls = []; let registered = false; const deps = buildDeps({ addServiceToConfig: async (svc) => { registered = true; return svc; }, }); const initHelpers = require('../routes/apps/helpers'); deps.helpers = initHelpers({ ...deps, ctx: { siteConfig: deps.siteConfig, docker: deps.docker } }); const origFetch = global.fetch; global.fetch = async (url) => { calls.push(['bridge', url]); return { ok: false, status: 500, json: async () => ({ ok: false, error: 'image deploy failed', output: 'verify FAIL http-tailnet' }) }; }; try { const app = buildApp(deps); const res = await request(app) .post('/api/v1/apps/deploy') .send({ appId: 'uptime-kuma', config: { subdomain: 'uptime', port: 3002, engine: 'shipdeck' } }); expect(res.status).toBe(502); expect(JSON.stringify(res.body)).toContain('verify FAIL'); expect(calls.filter(c => c[0] === 'bridge').length).toBe(1); // single attempt expect(registered).toBe(false); } finally { global.fetch = origFetch; } }); });