[pending-judge] feat(deploys): catalog deploy via shipdeck bridge (DC-CATALOG-3)
GET /deploys/catalog/apps -> bridge /api/catalog/apps (73 embedded apps,
single source of truth). POST /deploys/catalog/deploy {app,port,env} ->
validated (service-id charset, port 1024-65535, env name/value rules) then
proxied to bridge /api/catalog/deploy which runs the shipdeck CLI catalog
deploy (OCI image -> hardened systemd service; NO Docker daemon). Audited
as deploy.catalog with ok/seconds. 11 table-driven route tests incl.
validation rejections never reaching the bridge + bridge-failure 502 shape.
App Selector submit rewired from /apps/deploy (Docker chain) to
/deploys/catalog/deploy — same modal, same fields (port, mediaPath, plex
claim token as env), no new UI screens. Full suite 3510/3510 (156 suites).
This commit is contained in:
2 files changed
+167
No files matched your search
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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 = (action, meta) => audits.push({ action, meta });
|
||||
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' } });
|
||||
expect(audits[0].action).toBe('deploy.catalog');
|
||||
expect(audits[0].meta.ok).toBe(true);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user