const express = require('express'); function fetcher(fixtures) { return jest.fn(async (url, opts = {}) => { const key = `${opts.method || 'GET'} ${url.replace(/^https?:\/\/[^/]+/, '')}`; const hit = fixtures[key] || { status: 404, body: { ok: false, error: 'missing fixture' } }; return { status: hit.status, json: async () => hit.body }; }); } function appFor(fixtures = {}, initial = []) { process.env.SHIPDECK_BRIDGE_URL = 'http://127.0.0.1:8977'; process.env.SHIPDECK_BRIDGE_TOKEN_FILE = ''; jest.resetModules(); const make = require('../../routes/shipdeck-fleet'); let services = initial.slice(); const router = make({ asyncHandler: (fn) => async (req, res, next) => { try { await fn(req, res, next); } catch (e) { next(e); } }, log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, auditLogger: { log: jest.fn(async () => {}) }, fetchT: fetcher(fixtures), servicesStateManager: { read: async () => services, update: async (fn) => { services = await fn(services); } }, }); const app = express(); app.use(express.json()); app.use('/api/v1/fleet', router); app.use((err, req, res, next) => res.status(500).json({ success: false, error: err.message })); return { app, services: () => services }; } async function request(app, path, options) { const server = app.listen(0); const port = server.address().port; try { const response = await fetch(`http://127.0.0.1:${port}${path}`, options); return { response, body: await response.json() }; } finally { server.close(); } } describe('Shipdeck fleet routes', () => { test('from-git rejects privileged inputs before bridge', async () => { const { app } = appFor(); const { response } = await request(app, '/api/v1/fleet/from-git', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo_url: 'https://github.com/a/b;id', name: '../bad', subdomain: 'bad', port: 80 }) }); expect(response.status).toBe(400); }); test('from-git persists the card server-side while never returning or storing the token', async () => { const fixtures = { 'POST /api/install': { status: 200, body: { ok: true, service: { logo: '', host: 'localhost', shipdeckfile: '/var/lib/shipdeck/services/demo/Shipdeckfile', journal_row_id: 'demo:1' } } } }; const { app, services } = appFor(fixtures); const secret = 'ghp_private_secret'; const { response, body } = await request(app, '/api/v1/fleet/from-git', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ repo_url: 'https://github.com/acme/demo', name: 'demo', subdomain: 'demo', port: 8080, token: secret }) }); expect(response.status).toBe(200); expect(body.success).toBe(true); expect(JSON.stringify(body)).not.toContain(secret); expect(JSON.stringify(services())).not.toContain(secret); expect(services()[0].managedBy).toBe('shipdeck'); expect(services()[0].shipdeckfile).toBe('/var/lib/shipdeck/services/demo/Shipdeckfile'); }); test('from-image validates mounts and registry refs', async () => { const { app } = appFor(); const { response } = await request(app, '/api/v1/fleet/from-image', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ image: 'alpine;id', name: 'demo', subdomain: 'demo', port: 8080, mounts: [{ source: '/tmp/../etc', target: '/data' }] }) }); expect(response.status).toBe(400); }); test('lifecycle validates service and proxies argv-shaped action', async () => { const { app } = appFor({ 'POST /api/restart': { status: 200, body: { ok: true, output: 'RESTART demo' } } }); const { response, body } = await request(app, '/api/v1/fleet/restart', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'demo' }) }); expect(response.status).toBe(200); expect(body.action).toBe('restart'); }); test('shipdeckfile requires registered canonical path', async () => { const { app } = appFor({}, [{ id: 'demo', managedBy: 'shipdeck', shipdeckfile: '/tmp/evil' }]); const { response } = await request(app, '/api/v1/fleet/shipdeckfile?id=demo'); expect(response.status).toBe(404); }); });