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.
202 lines
8.3 KiB
JavaScript
202 lines
8.3 KiB
JavaScript
/**
|
|
* DC-165: site/product uniformity bundle — tests.
|
|
* 1. fleet is a gated premium feature (403 free / pass premium, and the
|
|
* gate is mounted at ROUTER level so new sub-routes inherit it).
|
|
* 2. offline activations minted before 'fleet' existed are forward-
|
|
* migrated on load (creator lifetime key case).
|
|
* 3. GET /api/v1/capabilities returns the running build's ground truth
|
|
* derived from source modules (no drift by construction).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const express = require('express');
|
|
|
|
function buildGatedRouter(licenseLike) {
|
|
const { LicenseManager } = require('../src/managers/license-manager');
|
|
// Real requirePremium() against a duck-typed instance: own properties
|
|
// (hasFeature/isExpired/log) win over prototype defaults.
|
|
const lm = Object.assign(Object.create(LicenseManager.prototype), {
|
|
log: { warn() {} },
|
|
isExpired: () => false,
|
|
}, licenseLike);
|
|
const router = express.Router();
|
|
router.use(lm.requirePremium('fleet'));
|
|
router.get('/fleet/hosts', (_req, res) => res.json({ ok: true }));
|
|
return router;
|
|
}
|
|
|
|
describe('DC-165 fleet premium gate', () => {
|
|
test('PREMIUM_FEATURES includes fleet (site entitlement contract)', () => {
|
|
const { PREMIUM_FEATURES } = require('../src/managers/license-manager');
|
|
expect(PREMIUM_FEATURES.fleet).toBeDefined();
|
|
expect(PREMIUM_FEATURES.fleet.name).toMatch(/Fleet/i);
|
|
});
|
|
|
|
test('free license: GET /fleet/hosts -> 403 premiumRequired fleet', async () => {
|
|
const app = express();
|
|
app.use('/api/v1', buildGatedRouter({
|
|
hasFeature: () => false,
|
|
isExpired: () => false,
|
|
log: { warn() {} },
|
|
}));
|
|
const srv = await new Promise((r) => { const s = app.listen(0, () => r(s)); });
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/v1/fleet/hosts`);
|
|
expect(res.status).toBe(403);
|
|
const body = await res.json();
|
|
expect(body.premiumRequired).toBe(true);
|
|
expect(body.feature).toBe('fleet');
|
|
} finally { srv.close(); }
|
|
});
|
|
|
|
test('premium license with fleet: request passes the gate', async () => {
|
|
const app = express();
|
|
app.use('/api/v1', buildGatedRouter({
|
|
hasFeature: (f) => f === 'fleet',
|
|
isExpired: () => false,
|
|
log: { warn() {} },
|
|
}));
|
|
const srv = await new Promise((r) => { const s = app.listen(0, () => r(s)); });
|
|
try {
|
|
const res = await fetch(`http://127.0.0.1:${srv.address().port}/api/v1/fleet/hosts`);
|
|
expect(res.status).toBe(200);
|
|
} finally { srv.close(); }
|
|
});
|
|
|
|
test('hasFeature fallback covers fleet for activations without explicit features list', () => {
|
|
const { LicenseManager, PREMIUM_FEATURES } = require('../src/managers/license-manager');
|
|
const lm = Object.create(LicenseManager.prototype);
|
|
lm.activation = { code: 'DC-X', expiresAt: '2099-01-01T00:00:00Z', features: undefined };
|
|
lm.isExpired = () => false;
|
|
expect(lm.hasFeature('fleet')).toBe(true);
|
|
expect(Object.keys(PREMIUM_FEATURES)).toContain('fleet');
|
|
});
|
|
});
|
|
|
|
describe('DC-165 offline activation forward-migration', () => {
|
|
test('load() adds missing premium features to an offline activation and persists', async () => {
|
|
const { LicenseManager, PREMIUM_FEATURES } = require('../src/managers/license-manager');
|
|
const stored = {
|
|
code: 'DC-OLD-OLD-OLD-OLD-OLD',
|
|
codeId: 1,
|
|
durationDays: 0,
|
|
lifetime: true,
|
|
activatedAt: '2026-01-01T00:00:00.000Z',
|
|
expiresAt: '2099-12-31T23:59:59.999Z',
|
|
machineId: 'deadbeefdeadbeef',
|
|
validationMethod: 'offline',
|
|
features: ['sso', 'recipes', 'swarm'], // pre-fleet snapshot
|
|
};
|
|
let persisted = null;
|
|
const lm = new LicenseManager(
|
|
{
|
|
retrieve: async () => JSON.stringify(stored),
|
|
store: async (key, value) => { persisted = { key, value }; },
|
|
delete: async () => {},
|
|
},
|
|
'/tmp/dc165-nonexistent-config.json', // no config fallback path
|
|
{ info() {}, warn() {}, error() {} }
|
|
);
|
|
await lm.load();
|
|
expect(lm.activation.features).toEqual(Object.keys(PREMIUM_FEATURES));
|
|
expect(persisted).not.toBeNull();
|
|
expect(persisted.key).toBe('license.activation');
|
|
expect(JSON.parse(persisted.value).features).toContain('fleet');
|
|
});
|
|
|
|
test('migration is APPEND-ONLY: unknown legacy entries are preserved (judge P0)', async () => {
|
|
const { LicenseManager, PREMIUM_FEATURES } = require('../src/managers/license-manager');
|
|
const stored = {
|
|
code: 'DC-LEG-LEG-LEG-LEG-LEG', codeId: 3, durationDays: 0, lifetime: true,
|
|
activatedAt: '2026-01-01T00:00:00.000Z',
|
|
expiresAt: '2099-12-31T23:59:59.999Z',
|
|
machineId: 'cafe0000deadbeef',
|
|
validationMethod: 'offline',
|
|
features: ['sso', 'recipes', 'swarm', 'legacy-beta-widget'], // extra rides along
|
|
};
|
|
let persisted = null;
|
|
const lm = new LicenseManager(
|
|
{ retrieve: async () => JSON.stringify(stored), store: async (key, value) => { persisted = { key, value }; }, delete: async () => {} },
|
|
'/tmp/dc165-nonexistent-config.json',
|
|
{ info() {}, warn() {}, error() {} }
|
|
);
|
|
await lm.load();
|
|
expect(lm.activation.features).toEqual(Object.keys(PREMIUM_FEATURES).concat(['legacy-beta-widget']));
|
|
expect(JSON.parse(persisted.value).features).toContain('legacy-beta-widget');
|
|
});
|
|
|
|
test('online activations are NOT migrated (server is authoritative)', async () => {
|
|
const { LicenseManager } = require('../src/managers/license-manager');
|
|
const activatedAt = new Date(Date.now() - 24 * 3600e3).toISOString(); // yesterday
|
|
const stored = {
|
|
code: 'DC-ONLINE', codeId: 2, durationDays: 365,
|
|
activatedAt,
|
|
expiresAt: new Date(Date.now() + 340 * 864e5).toISOString(), // within activatedAt+366d bound
|
|
lastOnlineValidatedAt: activatedAt,
|
|
machineId: 'aabbccddeeff0011',
|
|
validationMethod: 'online',
|
|
features: ['sso', 'recipes', 'swarm'],
|
|
serverToken: 'tok',
|
|
};
|
|
let storedCount = 0;
|
|
const lm = new LicenseManager(
|
|
{
|
|
retrieve: async () => JSON.stringify(stored),
|
|
store: async () => { storedCount++; },
|
|
delete: async () => {},
|
|
},
|
|
'/tmp/dc165-nonexistent-config.json',
|
|
{ info() {}, warn() {}, error() {} }
|
|
);
|
|
// No LICENSE_SERVER_URL => _validateLoadedActivationForServerMode returns true immediately.
|
|
await lm.load();
|
|
expect(lm.activation.features).toEqual(['sso', 'recipes', 'swarm']); // untouched
|
|
expect(storedCount).toBe(0); // nothing persisted
|
|
});
|
|
});
|
|
|
|
describe('DC-165 capabilities endpoint', () => {
|
|
let srv;
|
|
let base;
|
|
beforeAll(async () => {
|
|
const cap = require('../routes/capabilities');
|
|
const app = express();
|
|
app.use('/api/v1', cap.buildRouter());
|
|
srv = await new Promise((r) => { const s = app.listen(0, () => r(s)); });
|
|
base = `http://127.0.0.1:${srv.address().port}/api/v1`;
|
|
});
|
|
afterAll(() => srv && srv.close());
|
|
|
|
test('returns source-derived ground truth with zero drift from modules', async () => {
|
|
const res = await fetch(`${base}/capabilities`);
|
|
expect(res.status).toBe(200);
|
|
const j = await res.json();
|
|
const { APP_TEMPLATES } = require('../src/docker/app-templates');
|
|
const { TOOLS } = require('../src/mcp/mcp-core');
|
|
const { PREMIUM_FEATURES } = require('../src/managers/license-manager');
|
|
expect(j.success).toBe(true);
|
|
expect(j.product).toBe('dashcaddy');
|
|
expect(j.templates.count).toBe(Object.keys(APP_TEMPLATES).length);
|
|
expect(j.templates.count).toBeGreaterThanOrEqual(78);
|
|
const { MCP_RUNTIME } = require('../routes/mcp');
|
|
expect(j.mcp.transport).toBe(MCP_RUNTIME.transport);
|
|
expect(j.mcp.protocolVersion).toBe(MCP_RUNTIME.protocolVersion);
|
|
expect(j.mcp.protocolVersions).toEqual(MCP_RUNTIME.supportedProtocolVersions);
|
|
expect(j.mcp.tools).toEqual(TOOLS.map((t) => t.name).sort());
|
|
expect(j.premiumFeatures).toEqual(Object.keys(PREMIUM_FEATURES).sort());
|
|
expect(j.premiumFeatures).toContain('fleet');
|
|
expect(j.i18n.languageCount).toBe(31);
|
|
expect(j.version).toMatch(/^\d+\.\d+\.\d+/);
|
|
});
|
|
|
|
test('no secrets or host-specific data in the payload', async () => {
|
|
const res = await fetch(`${base}/capabilities`);
|
|
const text = await res.text();
|
|
expect(text).not.toMatch(/license\/(activation|secret)/i);
|
|
expect(text).not.toMatch(/sami/i);
|
|
expect(text).not.toMatch(/100\.|192\.168|dashcaddy\.net|status\.sami/i);
|
|
expect(text).not.toMatch(/"token"|"api_?key"|"password"|"secret"/i);
|
|
});
|
|
});
|