Files
dashcaddy/dashcaddy-api/__tests__/e2e-node/fleet-gate-composition.e2e.test.js
T
Hermes 9ed7fe7c5d [pending-judge] feat(DC-165 rev2): product/site uniformity — fleet gate proven at composition root, single-source capabilities manifest
Site-vs-product audit (2026-10-09, owner decisions: "advertise as Premium"
and "It should be LLM readable") reconciles the entitlement triangle:
marketing copy <-> PREMIUM_FEATURES <-> license-server grant list.

r1 judge blockers/polish addressed:
- B2 (synthetic-router tests): NEW e2e fleet-gate-composition.e2e.test.js
  boots the REAL createApp() on an ephemeral port with a stdin control
  channel to mutate the live LicenseManager instance returned by createApp,
  proving: no activation -> 403 premiumRequired feature=fleet
  currentTier=free; activation WITHOUT fleet -> still 403 (per-feature,
  not tier-only); activation WITH fleet -> gate cleared; prefix inheritance
  across /fleet/hosts, /fleet/status, /fleet/deploy (4 real-boot tests).
- B0 (hand-typed constants in the manifest): MCP transport identity moved
  to MCP_RUNTIME in routes/mcp.js (the transport implementation's own
  module), exported; /info responses use MCP_RUNTIME.transport, and
  capabilities.js reads transport/protocolVersion/protocolVersions from
  the SAME object — nothing re-typed. New assertion: manifest == MCP_RUNTIME.
- B1 (OpenAPI 401 on a public route): capabilities block documents only
  200 with an explicit public-route note.
- P0 (migration replaced the whole array): now APPEND-ONLY union — current
  PREMIUM_FEATURES first, unknown legacy entries preserved verbatim.
  Dedicated test pins shape + persistence.
- P1 (eslint exit suppressed): verifier [5] hard-asserts rc==0.
- P2 (brief counts): brief quotes exact `git diff --stat` (below).

Product behavior:
- 'fleet' in PREMIUM_FEATURES; requirePremium('fleet') mounted at the
  /fleet ROUTER prefix in app.js BEFORE fleetRoutes -> every sub-route,
  present and future, inherits the gate.
- 'workflows' + 'auto-backup' (gated in routes forever, missing from the
  constant — offline grants could never include them) listed with
  descriptions quoted from src/recipes/bundled-workflows.js (5 ids) and
  routes/backups.js schedule schema (interval/retention/maxStorageBytes/
  destination/runImmediately). Manual one-click backup + Disaster Recovery
  stay free; only the schedule trio is gated (as coded).
- Offline activation forward-migration on load(); online untouched (server
  authoritative across the network boundary).
- GET /api/v1/capabilities: public ground truth derived from runtime
  modules only — APP_TEMPLATES, mcp-core TOOLS, mcp.js MCP_RUNTIME,
  PREMIUM_FEATURES, i18n, version.js. In PUBLIC_ROUTES (exact GET),
  covered by public-routes-drift directMounts; no-secrets negative test.
- openapi.yaml documents the endpoint.

diff --stat 4143bff..HEAD: 9 files, +558/-6:
  __tests__/dc165-uniformity.test.js        +201 (9 tests)
  __tests__/e2e-node/fleet-gate-composition +179 (4 real-boot tests)
  __tests__/public-routes-drift.test.js       +1
  openapi.yaml                               +20
  routes/capabilities.js                     +77 (NEW)
  routes/mcp.js                              +24/-4 (MCP_RUNTIME single source)
  src/app.js                                 +11 (fleet gate + capabilities mount)
  src/managers/license-manager.js            +50 (3 feature entries + migration)
  src/utilities/middleware.js                 +1 (PUBLIC_ROUTES)

Tests: 9 dc165 + 4 fleet-e2e + 76 license-manager + 21 tier-enforcement
+ 14 drift = 124 targeted; 73 MCP route/core tests unchanged green.
2026-10-09 02:25:35 -07:00

180 lines
7.2 KiB
JavaScript

/**
* DC-165 rev2 (judge r1 B2): composition-root integration proof for the
* fleet premium gate. Boots the REAL createApp() (src/app.js) on an
* ephemeral port — no synthetic router — and probes /api/v1/fleet/hosts:
*
* 1. no activation -> 403 premiumRequired, feature 'fleet'
* 2. activation WITHOUT fleet -> still 403 (proves the check is per-feature)
* 3. activation WITH fleet -> NOT the 403 premium envelope (200 ok or
* a downstream error, e.g. 500 from a
* missing hosts file in the temp data dir —
* anything except the gate rejection)
*
* The gate middleware (requirePremium) closes over the same LicenseManager
* instance that createApp returns (src/app.js line ~184/1399), so mutating
* `.activation` on that instance after boot exercises exactly what a loaded
* activation would do at request time. The 403 envelope shape is asserted
* byte-identical to requirePremium's errorResponse contract
* (premiumRequired/feature/featureName/currentTier/upgradeUrl).
*/
'use strict';
const { spawn } = require('child_process');
const path = require('path');
const os = require('os');
const fs = require('fs');
const ROOT = path.join(__dirname, '..', '..'); // dashcaddy-api/
const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc165-fleet-e2e-'));
const BOOT_CODE = `
process.env.DATA_DIR = ${JSON.stringify(path.join(DATA_DIR, 'data'))};
process.env.SERVICES_FILE = ${JSON.stringify(path.join(DATA_DIR, 'services.json'))};
process.env.TOTP_CONFIG_FILE = ${JSON.stringify(path.join(DATA_DIR, 'totp-config.json'))};
const { createApp } = require(${JSON.stringify(path.join(ROOT, 'src', 'app'))});
createApp().then(async (created) => {
const app = created.app || created;
globalThis.__licenseManager = created.licenseManager;
const authManager = require(${JSON.stringify(path.join(ROOT, 'src', 'managers', 'auth-manager'))});
let jwt = '';
try {
jwt = await authManager.generateJWT({ sub: 'e2e-test', scope: ['admin'] }, '1h');
} catch (e) {
jwt = '';
}
const server = app.listen(0, '127.0.0.1', () => {
console.log('JWT:' + jwt);
console.log('PORT:' + server.address().port);
console.log('READY');
});
// control channel on stdout: JSON lines the test writes to stdin
require('readline').createInterface({ input: process.stdin }).on('line', (line) => {
let msg; try { msg = JSON.parse(line); } catch (_) { return; }
if (msg.op === 'setActivation') {
if (msg.activation === null) { globalThis.__licenseManager.activation = null; }
else { globalThis.__licenseManager.activation = msg.activation; }
console.log('ACK:setActivation');
}
});
process.on('SIGTERM', () => server.close(() => process.exit(0)));
process.on('SIGINT', () => server.close(() => process.exit(0)));
}).catch((e) => { console.error('BOOT-FAIL', e && e.message); process.exit(1); });
`;
let child;
let PORT;
let JWT;
function boot() {
return new Promise((resolve, reject) => {
const c = spawn(process.execPath, ['-e', BOOT_CODE], { cwd: ROOT, stdio: ['pipe', 'pipe', 'pipe'] });
let buf = '';
const to = setTimeout(() => { try { c.kill('SIGKILL'); } catch (_) {} reject(new Error('boot timeout')); }, 30000);
c.stdout.on('data', (d) => {
buf += String(d);
const mp = buf.match(/PORT:(\d+)/);
const mj = buf.match(/JWT:(\S*)/);
if (mp && buf.includes('READY')) {
clearTimeout(to);
c.port = Number(mp[1]);
c.jwt = mj ? mj[1] : '';
resolve(c);
}
});
c.stderr.on('data', (d) => process.stderr.write('[boot] ' + d));
c.on('exit', (code) => { if (!c.port) { clearTimeout(to); reject(new Error('boot exited ' + code)); } });
});
}
function probeFleet() {
return fetch(`http://127.0.0.1:${PORT}/api/v1/fleet/hosts`, {
redirect: 'manual',
headers: JWT ? { authorization: `Bearer ${JWT}` } : {},
}).then(async (r) => ({ status: r.status, body: await r.text() }));
}
function send(msg) {
return new Promise((resolve) => {
const onData = (d) => {
if (String(d).includes('ACK:')) { child.stdout.removeListener('data', onData); resolve(); }
};
child.stdout.on('data', onData);
child.stdin.write(JSON.stringify(msg) + '\n');
});
}
const BASE_ACT = {
code: 'DC-TEST-TEST-TEST-TEST-TEST',
codeId: 99,
durationDays: 365,
activatedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 300 * 864e5).toISOString(),
lastOnlineValidatedAt: new Date().toISOString(),
machineId: 'e2e00000000000aa',
validationMethod: 'online',
features: [],
serverToken: 'tok',
};
describe('DC-165 fleet gate through the real composition root', () => {
beforeAll(async () => {
child = await boot();
PORT = child.port;
JWT = child.jwt || '';
if (!Number.isInteger(PORT) || PORT <= 0) throw new Error('no bound port');
}, 45000);
afterAll(() => {
if (child) { try { child.kill('SIGTERM'); } catch (_) {} }
fs.rmSync(DATA_DIR, { recursive: true, force: true });
});
test('1) no activation -> 403 premiumRequired fleet', async () => {
await send({ op: 'setActivation', activation: null });
const r = await probeFleet();
expect(r.status).toBe(403);
const j = JSON.parse(r.body);
expect(j.premiumRequired).toBe(true);
expect(j.feature).toBe('fleet');
expect(j.featureName).toMatch(/Fleet/);
expect(j.currentTier).toBe('free');
});
test('2) premium activation WITHOUT fleet -> still 403 fleet (per-feature)', async () => {
await send({ op: 'setActivation', activation: { ...BASE_ACT, features: ['sso', 'recipes'] } });
const r = await probeFleet();
expect(r.status).toBe(403);
expect(JSON.parse(r.body).feature).toBe('fleet');
});
test('3) premium activation WITH fleet -> gate passes (not the 403 envelope)', async () => {
await send({ op: 'setActivation', activation: { ...BASE_ACT, features: ['sso', 'recipes', 'fleet'] } });
const r = await probeFleet();
// gate cleared; downstream handler may 200 (empty hosts file gets created)
// or surface an unrelated data-dir issue — it must NOT be the gate 403.
const gateReject = r.status === 403 && /premiumRequired/.test(r.body);
expect(gateReject).toBe(false);
expect([200, 500]).toContain(r.status);
if (r.status === 200) {
const j = JSON.parse(r.body);
expect(Array.isArray(j.hosts)).toBe(true);
}
});
test('4) gate is inherited by the whole /fleet prefix, not just /hosts', async () => {
await send({ op: 'setActivation', activation: null });
for (const p of ['/api/v1/fleet/status', '/api/v1/fleet/deploy']) {
const r = await fetch(`http://127.0.0.1:${PORT}${p}`, {
method: p.endsWith('/deploy') ? 'POST' : 'GET',
headers: { 'Content-Type': 'application/json', ...(JWT ? { authorization: `Bearer ${JWT}` } : {}) },
body: p.endsWith('/deploy') ? '{}' : undefined,
redirect: 'manual',
});
const body = await r.text();
expect(r.status).toBe(403);
expect(JSON.parse(body).feature).toBe('fleet');
}
});
});