269 lines
10 KiB
JavaScript
269 lines
10 KiB
JavaScript
/**
|
|
* Routes tests for /api/v1/deploys (DC-130 — shipdeck bridge proxy).
|
|
*
|
|
* Pattern: build the router with stub deps, hit it via a tiny express app,
|
|
* assert response shapes and the exact proxy interactions with fetchT.
|
|
* The feature gate (SHIPDECK_BRIDGE_URL) is exercised in both states.
|
|
*/
|
|
|
|
const express = require('express');
|
|
|
|
const FIXTURE_REPOS = { ok: true, repos: [{ dir: '/root/helloworld', name: 'helloworld' }] };
|
|
const FIXTURE_SERVICES = {
|
|
ok: true,
|
|
services: [{ name: 'helloworld', host: null, last_action: 'deploy', last_time: '2026-09-14T05:00:00Z', last_epoch: 1789360000 }],
|
|
};
|
|
const FIXTURE_ROWS = {
|
|
ok: true,
|
|
rows: [{ time: '2026-09-14T05:00:00Z', service: 'helloworld', action: 'deploy', epoch: 1789360000, duration: '30.0s' }],
|
|
};
|
|
|
|
function buildApp(fetchT, env = {}) {
|
|
return buildAppWithLogCapture(fetchT, env, { info: jest.fn(), warn: jest.fn(), error: jest.fn() });
|
|
}
|
|
|
|
function buildAppWithLogCapture(fetchT, env = {}, log) {
|
|
process.env.SHIPDECK_BRIDGE_URL = env.url !== undefined ? env.url : 'http://172.17.0.1:8977';
|
|
process.env.SHIPDECK_BRIDGE_TOKEN_FILE = env.tokenFile !== undefined ? env.tokenFile : '';
|
|
jest.resetModules();
|
|
const mod = require('../../routes/deploys');
|
|
const router = mod({
|
|
asyncHandler: (fn) => async (req, res, next) => {
|
|
try { await fn(req, res, next); } catch (e) { next(e); }
|
|
},
|
|
log,
|
|
auditLogger: { log: jest.fn(async () => {}) },
|
|
fetchT,
|
|
});
|
|
const app = express();
|
|
app.use(express.json());
|
|
app.use('/api/v1/deploys', router);
|
|
// express error middleware → json shape like production
|
|
app.use((err, req, res, next) => {
|
|
res.status(500).json({ success: false, error: err.message });
|
|
});
|
|
return app;
|
|
}
|
|
|
|
// expose log on the fetcher wrapper for log-capture assertions
|
|
function jsonFetcherWithLog(responses) {
|
|
const calls = [];
|
|
const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
|
return { calls, log, fetchT: null };
|
|
}
|
|
|
|
function jsonFetcher(responses) {
|
|
// responses: map of "METHOD path" -> {status, body}
|
|
const calls = [];
|
|
const log = { info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
|
const wrapper = {
|
|
calls,
|
|
log,
|
|
fetchT: jest.fn(async (url, opts) => {
|
|
const key = `${(opts && opts.method) || 'GET'} ${url.replace(/^https?:\/\/[^/]+/, '')}`;
|
|
calls.push({ key, opts });
|
|
const r = responses[key] || { status: 404, body: { ok: false, error: 'no fixture' } };
|
|
return {
|
|
status: r.status,
|
|
json: async () => r.body,
|
|
};
|
|
}),
|
|
};
|
|
return wrapper;
|
|
}
|
|
|
|
describe('routes/deploys — feature gate', () => {
|
|
test('501 with clear message when SHIPDECK_BRIDGE_URL unset', async () => {
|
|
const app = buildApp(jsonFetcher({}).fetchT, { url: '' });
|
|
const res = await app.inject ? null : null; // supertest absent; use fetch via server
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/repos`);
|
|
const body = await r.json();
|
|
server.close();
|
|
expect(r.status).toBe(501);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toMatch(/SHIPDECK_BRIDGE_URL/);
|
|
});
|
|
});
|
|
|
|
describe('routes/deploys — proxied endpoints', () => {
|
|
test('GET /repos proxies and unwraps bridge payload', async () => {
|
|
const f = jsonFetcher({ 'GET /api/repos': { status: 200, body: FIXTURE_REPOS } });
|
|
const app = buildApp(f.fetchT);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/repos`);
|
|
const body = await r.json();
|
|
server.close();
|
|
expect(r.status).toBe(200);
|
|
expect(body.success).toBe(true);
|
|
expect(body.repos).toHaveLength(1);
|
|
expect(body.repos[0].name).toBe('helloworld');
|
|
expect(f.calls[0].opts.headers['X-Shipdeck-Token']).toBeDefined();
|
|
});
|
|
|
|
test('GET /services proxies inventory', async () => {
|
|
const f = jsonFetcher({ 'GET /api/services': { status: 200, body: FIXTURE_SERVICES } });
|
|
const app = buildApp(f.fetchT);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/services`);
|
|
const body = await r.json();
|
|
server.close();
|
|
expect(r.status).toBe(200);
|
|
expect(body.services[0].name).toBe('helloworld');
|
|
});
|
|
|
|
test('GET /journal rejects invalid service names (400, no proxy call)', async () => {
|
|
const f = jsonFetcher({});
|
|
const app = buildApp(f.fetchT);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/journal?service=..%2Fetc`);
|
|
server.close();
|
|
expect(r.status).toBe(400);
|
|
expect(f.calls).toHaveLength(0);
|
|
});
|
|
|
|
test('GET /journal passes valid service filter', async () => {
|
|
const f = jsonFetcher({ 'GET /api/journal?service=helloworld': { status: 200, body: FIXTURE_ROWS } });
|
|
const app = buildApp(f.fetchT);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/journal?service=helloworld`);
|
|
const body = await r.json();
|
|
server.close();
|
|
expect(r.status).toBe(200);
|
|
expect(body.rows[0].action).toBe('deploy');
|
|
});
|
|
|
|
test('GET /status returns ok:false with output when probe fails (no throw)', async () => {
|
|
const f = jsonFetcher({
|
|
'GET /api/status?service=helloworld': { status: 500, body: { ok: false, output: '[FAIL] http-tailnet' } },
|
|
});
|
|
const app = buildApp(f.fetchT);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
|
|
const body = await r.json();
|
|
server.close();
|
|
expect(r.status).toBe(200);
|
|
expect(body.success).toBe(true);
|
|
expect(body.ok).toBe(false);
|
|
expect(body.output).toContain('[FAIL]');
|
|
});
|
|
|
|
test('GET /status maps bridge auth failure (401) to 502, not a probe result', async () => {
|
|
const f = jsonFetcher({
|
|
'GET /api/status?service=helloworld': { status: 401, body: { ok: false, error: 'invalid token' } },
|
|
});
|
|
const app = buildApp(f.fetchT);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
|
|
const body = await r.json();
|
|
server.close();
|
|
expect(r.status).toBe(502);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toMatch(/bridge/i);
|
|
});
|
|
|
|
test('GET /status maps unexpected bridge statuses to 502 with error logging', async () => {
|
|
const f = jsonFetcher({
|
|
'GET /api/status?service=helloworld': { status: 404, body: { ok: false, error: 'not found' } },
|
|
});
|
|
const app = buildAppWithLogCapture(f.fetchT, {}, f.log);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
|
|
server.close();
|
|
expect(r.status).toBe(502);
|
|
expect(f.log.error).toHaveBeenCalledWith(
|
|
'deploys',
|
|
'status probe: unexpected bridge response',
|
|
expect.objectContaining({ service: 'helloworld', status: 404 }),
|
|
);
|
|
});
|
|
|
|
test('GET /status maps bridge connection failure to 502', async () => {
|
|
const fetchT = jest.fn(async () => { throw new Error('ECONNREFUSED'); });
|
|
const app = buildApp(fetchT);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/status?service=helloworld`);
|
|
const body = await r.json();
|
|
server.close();
|
|
expect(r.status).toBe(502);
|
|
expect(body.error).toMatch(/unreachable/);
|
|
});
|
|
|
|
test('POST /deploy proxies dir and audits', async () => {
|
|
const f = jsonFetcher({
|
|
'POST /api/deploy': { status: 200, body: { ok: true, exit: 0, output: 'DEPLOYED helloworld in 30.0s' } },
|
|
});
|
|
const app = buildApp(f.fetchT);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/deploy`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ dir: '/root/helloworld' }),
|
|
});
|
|
const body = await r.json();
|
|
server.close();
|
|
expect(r.status).toBe(200);
|
|
expect(body.success).toBe(true);
|
|
expect(body.output).toContain('DEPLOYED');
|
|
expect(JSON.parse(f.calls[0].opts.body).dir).toBe('/root/helloworld');
|
|
});
|
|
|
|
test('POST /deploy rejects missing dir (400, no proxy call)', async () => {
|
|
const f = jsonFetcher({});
|
|
const app = buildApp(f.fetchT);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/deploy`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({}),
|
|
});
|
|
server.close();
|
|
expect(r.status).toBe(400);
|
|
expect(f.calls).toHaveLength(0);
|
|
});
|
|
|
|
test('POST /rollback rejects invalid service name', async () => {
|
|
const f = jsonFetcher({});
|
|
const app = buildApp(f.fetchT);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/rollback`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ service: 'bad name; rm' }),
|
|
});
|
|
server.close();
|
|
expect(r.status).toBe(400);
|
|
expect(f.calls).toHaveLength(0);
|
|
});
|
|
|
|
test('bridge 500 surfaces as 502 with output excerpt', async () => {
|
|
const f = jsonFetcher({
|
|
'POST /api/deploy': { status: 500, body: { ok: false, exit: 8, output: 'service did not listen' } },
|
|
});
|
|
const app = buildApp(f.fetchT);
|
|
const server = app.listen(0);
|
|
const port = server.address().port;
|
|
const r = await fetch(`http://127.0.0.1:${port}/api/v1/deploys/deploy`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ dir: '/root/helloworld' }),
|
|
});
|
|
const body = await r.json();
|
|
server.close();
|
|
expect(r.status).toBe(502);
|
|
expect(body.success).toBe(false);
|
|
});
|
|
});
|