Site-vs-product audit (2026-10-09, owner decision: "advertise as Premium")
reconciles the entitlement triangle: marketing copy <-> PREMIUM_FEATURES
<-> license-server grant list.
- Fleet: the pricing page has always sold Multi-Host Fleet Management as
Premium; code never gated it. Added fleet to PREMIUM_FEATURES and mounted
requirePremium('fleet') at the ROUTER level (app.js) so every current AND
future /fleet/* sub-route inherits the gate (fail-closed for new endpoints).
License-server side: plans.js grants fleet for new activations; existing
premium license records backfilled (ops, DNS-license-server).
- Workflows + auto-backup: these were requirePremium-gated in routes since
forever but absent from PREMIUM_FEATURES — the offline path could never
grant them and the site never advertised them. Listed them (descriptions
taken from bundled-workflows.js + backups/schedule schema fields) so the
single source of truth now covers every gated feature; offline migration
(below) grants them to existing activations; server grant list updated.
- Offline activation forward-migration: load() now refreshes an OFFLINE
activation's features snapshot to the current PREMIUM_FEATURES set when
entries are missing (exactly what re-activating the same code today would
store). ONLINE activations are never touched — the license server stays
authoritative across the network boundary; this is the creator's local
build-to-build coherence rule, not an entitlement widening over an
external service. Persisted via credential store; in-memory state is
correct even if persistence fails (warn, not fail).
- GET /api/v1/capabilities (public, like /version): machine-readable ground
truth of the RUNNING build — version, template count + categories, MCP
tool names, protocol revisions, premium features, language count. Every
number derived from the same modules the product uses (no drift by
construction). Registered in PUBLIC_ROUTES (exact:true, GET), added to
public-routes-drift.test.js directMounts, guarded like /version.
No secrets, no host data (tested).
- 8 new executed tests: fleet 403-on-free / pass-on-premium through the REAL
requirePremium middleware (duck-typed LicenseManager instance, prototype
preserved), hasFeature fallback, offline migration (persisted + in-memory),
online NOT migrated (valid bounded cache stub incl. lastOnlineValidatedAt),
capabilities payload == module-derived values (78 templates/17 cats/31
langs/fleet present), no-secrets scan of the payload.
Tests: dc165 8/8; license-manager 76; tier-enforcement 21; public-routes-
drift + composition-root unchanged green (119 combined on the targeted run).
177 lines
7.0 KiB
JavaScript
177 lines
7.0 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('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);
|
|
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);
|
|
});
|
|
});
|