[pending-judge] fix(integration): catalog detail reads app.docker.* (DC-160); Shield committed; composition-root e2e
- DC-160: /catalog/:appId read app.image/ports/env/volumes which live under app.docker.* — every detail response was empty. Now reads docker.* and surfaces defaultPort/subdomain/healthCheck/mediaMount/claimToken/difficulty/ setupInstructions for the deploy form. List/search use the templates own category field (old hand-rolled CATEGORY_MAP mis-categorized apps). - Shield (routes/shield.js + 5-line app.js mount) committed to source after weeks as prod-only drift; regression test asserts the mount exists. - New composition-root e2e suite: boots REAL createApp() on an ephemeral port, probes the historically-broken routes through the REAL mount table (wizard wiring, shield, catalog detail, deploys bridge) — the class of bug all 3 P7 dogfood findings belonged to. 5 tests. - Full suite 3499/3499 (155 suites); table-driven DC-160 test covers all 73 image templates.
This commit is contained in:
6 files changed
+561
-76
No files matched your search
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* DC-160 regression tests: catalog detail endpoint must read app.docker.*
|
||||
* (was app.image/ports/env/volumes -> empty payload for every app) and
|
||||
* surface install-time config fields. Table-driven over real templates.
|
||||
*/
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const APP_TEMPLATES = require(path.resolve(__dirname, '..', 'src', 'docker', 'app-templates'));
|
||||
const buildCatalog = require(path.resolve(__dirname, '..', 'routes', 'catalog'));
|
||||
|
||||
const T = APP_TEMPLATES.APP_TEMPLATES || APP_TEMPLATES;
|
||||
|
||||
// Canonical id derivation MUST mirror routes/catalog.js: templates carry no
|
||||
// `id` field — ids come from the map key / lowercased hyphenated name.
|
||||
function canonicalId(t) {
|
||||
return (t.id || (t.name || '').toLowerCase().replace(/\s+/g, '-')).toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
const all = Object.values(T);
|
||||
// Widget/static templates have no docker.image (they are dashboard widgets,
|
||||
// not container apps) — they return correctly-empty details. The deployable
|
||||
// set for this regression is the 73 templates WITH an image (matches the
|
||||
// shipdeck catalog manifest count).
|
||||
const cases = all
|
||||
.filter((t) => t && t.docker && t.docker.image)
|
||||
.map((t) => [canonicalId(t), t]);
|
||||
|
||||
function buildApp() {
|
||||
const e = express();
|
||||
e.use('/api/v1', buildCatalog({ APP_TEMPLATES: T }));
|
||||
return e;
|
||||
}
|
||||
|
||||
describe('DC-160: catalog detail reads app.docker.*', () => {
|
||||
it.each(cases)(
|
||||
'detail for %s returns non-empty image + real ports/env/volumes',
|
||||
(id, t) => {
|
||||
const d = t.docker || {};
|
||||
return request(buildApp())
|
||||
.get('/api/v1/catalog/' + id)
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const body = res.body.data || res.body;
|
||||
expect(body.image).toBeTruthy();
|
||||
expect(body.image).toBe(d.image);
|
||||
expect(body.ports).toEqual(d.ports || []);
|
||||
expect(body.env).toEqual(d.environment || {});
|
||||
expect(body.volumes).toEqual(d.volumes || []);
|
||||
expect(body.category).toBe(t.category);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it('detail for plex surfaces config fields for the deploy form', () => {
|
||||
return request(buildApp())
|
||||
.get('/api/v1/catalog/plex')
|
||||
.expect(200)
|
||||
.then((res) => {
|
||||
const body = res.body.data || res.body;
|
||||
expect(body.image).toBe('plexinc/pms-docker:latest');
|
||||
expect(body.defaultPort).toBe(32400);
|
||||
expect(body.claimToken).toMatchObject({ envVar: 'PLEX_CLAIM' });
|
||||
expect(body.mediaMount).toBeTruthy();
|
||||
expect(typeof body.mediaMount.containerPath).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
it('unknown app returns 404', () => {
|
||||
return request(buildApp())
|
||||
.get('/api/v1/catalog/definitely-not-an-app')
|
||||
.expect(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Composition-root integration test — the "tests pass, prod breaks" cure.
|
||||
*
|
||||
* WHY: all three P7 dogfood bugs (wizard mount missing providers; coord
|
||||
* client never attached; people deduped by id only) shipped to prod with the
|
||||
* full unit suite green, because unit tests inject their own mocks instead
|
||||
* of exercising the real app.js wiring. This suite boots the REAL createApp()
|
||||
* on an ephemeral port and drives HTTP through the real mount table — the
|
||||
* exact wiring that runs in production — for the routes that historically
|
||||
* broke: /network/wizard, /security/shield, /catalog/:id, /deploys.
|
||||
*
|
||||
* Uses DATA_DIR to point the app at a throwaway data dir; no prod files are
|
||||
* touched.
|
||||
*/
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..', '..');
|
||||
|
||||
// Boot the REAL app via its REAL entry contract (createApp + listen).
|
||||
function boot(port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['-e', `
|
||||
process.env.DATA_DIR = ${JSON.stringify(DATA_DIR)};
|
||||
const { createApp } = require(${JSON.stringify(path.join(ROOT, 'src', 'app'))});
|
||||
// NOTE: the -e script resolves relative requires from CWD semantics;
|
||||
// the absolute path above is required.
|
||||
createApp().then((created) => {
|
||||
const app = created.app || created;
|
||||
const server = app.listen(${port}, '127.0.0.1', () => {
|
||||
console.log('READY');
|
||||
});
|
||||
process.on('SIGTERM', () => server.close(() => process.exit(0)));
|
||||
}).catch((e) => { console.error('BOOT-FAIL', e && e.message); process.exit(1); });
|
||||
`], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
child.stdout.on('data', (d) => {
|
||||
if (String(d).includes('READY')) resolve(child);
|
||||
});
|
||||
child.stderr.on('data', (d) => process.stderr.write('[boot] ' + d));
|
||||
child.on('exit', (code) => reject(new Error('boot exited ' + code)));
|
||||
setTimeout(() => reject(new Error('boot timeout')), 30000);
|
||||
});
|
||||
}
|
||||
|
||||
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-comp-root-'));
|
||||
const PORT = 30000 + Math.floor(Math.random() * 20000);
|
||||
let child;
|
||||
|
||||
async function get(p, expectAuth = true) {
|
||||
const res = await fetch(`http://127.0.0.1:${PORT}${p}`, { redirect: 'manual' });
|
||||
return { status: res.status, body: await res.text() };
|
||||
}
|
||||
|
||||
describe('composition-root e2e (real createApp on ephemeral port)', () => {
|
||||
beforeAll(async () => {
|
||||
child = await boot(PORT);
|
||||
}, 45000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (child) { child.kill('SIGTERM'); await new Promise((r) => child.on('exit', r)); }
|
||||
fs.rmSync(DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('boots the real app without throwing', () => {
|
||||
expect(child.exitCode).toBeNull();
|
||||
});
|
||||
|
||||
it('/network/wizard/state is mounted and wired (finding #1 regression)', async () => {
|
||||
const r = await get('/api/v1/network/wizard/state');
|
||||
// 401 = auth-gated but MOUNTED; the bug shipped 404/503-from-broken-mount.
|
||||
expect([200, 401]).toContain(r.status);
|
||||
});
|
||||
|
||||
it('/security/shield is mounted (prod-drift regression)', async () => {
|
||||
const r = await get('/api/v1/security/shield');
|
||||
// 401 = auth-gated but mounted; 200/503 = auth disabled/with snapshot.
|
||||
// 404 would mean the prod-only drift regressed.
|
||||
expect([200, 401, 503]).toContain(r.status);
|
||||
});
|
||||
|
||||
it('/catalog/plex returns real image via app.docker.* (DC-160)', async () => {
|
||||
const r = await get('/api/v1/catalog/plex');
|
||||
// Auth-gated in the real composition root: assert mounted + gate, not 404.
|
||||
expect([200, 401]).toContain(r.status);
|
||||
if (r.status === 200) {
|
||||
expect(r.body).toContain('plexinc/pms-docker');
|
||||
} else {
|
||||
expect(r.body).toContain('Authentication required');
|
||||
}
|
||||
});
|
||||
|
||||
it('/deploys bridge is mounted (opt-in feature flag)', async () => {
|
||||
const r = await get('/api/v1/deploys/repos');
|
||||
expect([200, 401, 501]).toContain(r.status);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* DC-Shield route tests: /security/shield endpoint.
|
||||
*
|
||||
* The route reads the sec-dashboard snapshot from an absolute path
|
||||
* (/var/lib/sec-dashboard/snapshot.json) and services.json. Those constants
|
||||
* live inside the module, so these tests exercise the REAL module against
|
||||
* the REAL host files — same approach as app-startup-smoke — plus
|
||||
* behavior-level assertions for the 503 no-snapshot case using a bound
|
||||
* copy of the handler with a stubbed fs (dependency seam via require cache).
|
||||
*
|
||||
* On a dev host without the snapshot the 503 path is the testable truth; on
|
||||
* DNS2 the snapshot exists, so both shapes are covered.
|
||||
*/
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const buildShield = require(path.resolve(__dirname, '..', 'routes', 'shield'));
|
||||
|
||||
function buildApp() {
|
||||
const e = express();
|
||||
e.use('/api/v1/security', buildShield({ log: { info() {}, error() {}, warn() {} } }));
|
||||
return e;
|
||||
}
|
||||
|
||||
describe('DC-Shield: GET /api/v1/security/shield', () => {
|
||||
it('mounts and responds without crashing (200 with snapshot, else 503)', async () => {
|
||||
const res = await request(buildApp()).get('/api/v1/security/shield');
|
||||
expect([200, 503]).toContain(res.status);
|
||||
});
|
||||
|
||||
it('response shape matches the frontend contract on 200', async () => {
|
||||
const res = await request(buildApp()).get('/api/v1/security/shield');
|
||||
if (res.status === 503) return; // no snapshot on this host — covered below
|
||||
const b = res.body.data || res.body;
|
||||
expect(b).toHaveProperty('generated_utc');
|
||||
expect(b.fleet).toHaveProperty('verdict');
|
||||
expect(['secure', 'watch', 'incident']).toContain(b.fleet.verdict);
|
||||
expect(b.services).toBeDefined();
|
||||
expect(Array.isArray(b.unmapped_hosts)).toBe(true);
|
||||
});
|
||||
|
||||
it('503 + SNAPSHOT_UNAVAILABLE when snapshot missing (fs stubbed)', async () => {
|
||||
// Stub fs.readFileSync for the snapshot path only, via the module cache.
|
||||
const fsPath = require.resolve('fs');
|
||||
const realFs = require('fs');
|
||||
const orig = realFs.readFileSync;
|
||||
realFs.readFileSync = function (p, enc) {
|
||||
if (String(p).includes('sec-dashboard')) {
|
||||
throw new Error('ENOENT (test stub)');
|
||||
}
|
||||
return orig.call(realFs, p, enc);
|
||||
};
|
||||
try {
|
||||
const res = await request(buildApp()).get('/api/v1/security/shield');
|
||||
expect(res.status).toBe(503);
|
||||
expect(JSON.stringify(res.body)).toContain('SNAPSHOT_UNAVAILABLE');
|
||||
} finally {
|
||||
realFs.readFileSync = orig;
|
||||
}
|
||||
});
|
||||
|
||||
it('route module loads with the composition-root mount (regression: prod-only drift)', () => {
|
||||
// src/app.js must mount routes/shield (was prod-only for weeks).
|
||||
const appjs = require('fs').readFileSync(path.resolve(__dirname, '..', 'src', 'app.js'), 'utf8');
|
||||
expect(appjs).toMatch(/apiRouter\.use\(\x27\/security\x27, require\(\x27\.\.\/routes\/shield\x27\)/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user