Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2e2a12eb8 | ||
|
|
c01a011d47 | ||
|
|
74fe35d969 | ||
|
|
678a0160c4 | ||
|
|
9779feae70 | ||
|
|
30d5fdbb2c | ||
|
|
71d20ceef3 | ||
|
|
87f76aef66 | ||
|
|
8105bed3fb | ||
|
|
2f76b83565 | ||
|
|
0714bf2334 | ||
|
|
23922923a5 |
@@ -336,32 +336,77 @@ describe('AutoRestartManager', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('_resolveContainerId', () => {
|
describe('_resolveContainerId', () => {
|
||||||
test('returns containerId from status.details when present', () => {
|
test('returns containerId from status.details when present', async () => {
|
||||||
const { manager } = makeManager();
|
const { manager } = makeManager();
|
||||||
const cid = manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
|
const cid = await manager._resolveContainerId('svc-1', { details: { containerId: 'cid-details' } });
|
||||||
expect(cid).toBe('cid-details');
|
expect(cid).toBe('cid-details');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('falls back to healthChecker.config.services[serviceId].containerId', () => {
|
test('falls back to healthChecker.config.services[serviceId].containerId', async () => {
|
||||||
const { manager, healthChecker } = makeManager();
|
const { manager, healthChecker } = makeManager();
|
||||||
healthChecker.config = { services: { 'svc-1': { containerId: 'cid-hc' } } };
|
healthChecker.config = { services: { 'svc-1': { containerId: 'cid-hc' } } };
|
||||||
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
const cid = await manager._resolveContainerId('svc-1', { details: {} });
|
||||||
expect(cid).toBe('cid-hc');
|
expect(cid).toBe('cid-hc');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('falls back to servicesStateManager.read when sync list is returned', () => {
|
test('DC-060: awaits async servicesStateManager.read() and resolves containerId', async () => {
|
||||||
|
// Regression test for the auto-restart silently no-op bug:
|
||||||
|
// _resolveContainerId used to fire servicesStateManager.read() via
|
||||||
|
// .then(...) and discard the result. Callers gated on the return
|
||||||
|
// value, so a healthy→unhealthy transition whose only containerId
|
||||||
|
// source was the async state manager never triggered handleContainerDown.
|
||||||
const { manager, servicesStateManager } = makeManager();
|
const { manager, servicesStateManager } = makeManager();
|
||||||
servicesStateManager.read.mockReturnValue([
|
servicesStateManager.read.mockResolvedValue([
|
||||||
{ id: 'svc-1', containerId: 'cid-state' },
|
{ id: 'svc-1', containerId: 'cid-state' },
|
||||||
]);
|
]);
|
||||||
const cid = manager._resolveContainerId('svc-1', { details: {} });
|
const cid = await manager._resolveContainerId('svc-1', { details: {} });
|
||||||
expect(cid).toBe('cid-state');
|
expect(cid).toBe('cid-state');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('returns null when no source has a containerId', () => {
|
test('returns null when no source has a containerId', async () => {
|
||||||
const { manager } = makeManager();
|
const { manager } = makeManager();
|
||||||
const cid = manager._resolveContainerId('svc-unknown', { details: {} });
|
const cid = await manager._resolveContainerId('svc-unknown', { details: {} });
|
||||||
|
expect(cid).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('swallows servicesStateManager.read() rejection', async () => {
|
||||||
|
const { manager, servicesStateManager } = makeManager();
|
||||||
|
servicesStateManager.read.mockRejectedValue(new Error('disk gone'));
|
||||||
|
const cid = await manager._resolveContainerId('svc-1', { details: {} });
|
||||||
expect(cid).toBeNull();
|
expect(cid).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DC-060: healthy→unhealthy transitions trigger restart via async lookup', () => {
|
||||||
|
test('handleContainerDown is invoked with containerId from async state-manager lookup', async () => {
|
||||||
|
// End-to-end: containerId comes ONLY from servicesStateManager.read()
|
||||||
|
// (the production path for services.json-backed deployments).
|
||||||
|
const { manager, docker, servicesStateManager } = makeManager();
|
||||||
|
docker.client.getContainer.mockReturnValue({
|
||||||
|
start: jest.fn().mockResolvedValue(undefined),
|
||||||
|
});
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||||
|
manager._previousHealth.set('svc-1', 'up');
|
||||||
|
servicesStateManager.read.mockResolvedValue([
|
||||||
|
{ id: 'svc-1', containerId: 'cid-from-state' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||||
|
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'down' });
|
||||||
|
expect(handleDownSpy).toHaveBeenCalledWith('svc-1', 'cid-from-state');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handleContainerDown is NOT invoked when async lookup returns no containerId', async () => {
|
||||||
|
const { manager, servicesStateManager } = makeManager();
|
||||||
|
await manager.setPolicy('svc-1', { maxRetries: 3, retryIntervalMs: 0 });
|
||||||
|
manager._previousHealth.set('svc-1', 'up');
|
||||||
|
servicesStateManager.read.mockResolvedValue([
|
||||||
|
{ id: 'svc-1' /* no containerId */ },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDownSpy = jest.spyOn(manager, 'handleContainerDown');
|
||||||
|
await manager._handleStatusCheck({ serviceId: 'svc-1', status: 'down' });
|
||||||
|
expect(handleDownSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -322,6 +322,89 @@ describe('CSRF Protection', () => {
|
|||||||
|
|
||||||
process.env.NODE_ENV = origEnv;
|
process.env.NODE_ENV = origEnv;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// DC-058: differentiate "browser auto-retry" from "real probe" by the
|
||||||
|
// presence of the X-CSRF-Token header. The 403 response is identical in
|
||||||
|
// both branches; only the stderr log tag changes.
|
||||||
|
describe('DC-058: browser-auto-retry vs real-probe log tagging', () => {
|
||||||
|
let stderrSpy;
|
||||||
|
let origEnv;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
origEnv = process.env.NODE_ENV;
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
stderrSpy = jest.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.NODE_ENV = origEnv;
|
||||||
|
stderrSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags missing-cookie with [CSRF-debug] when X-CSRF-Token header also present (browser auto-retry)', () => {
|
||||||
|
const { req, res, next } = createMockReqRes({
|
||||||
|
method: 'POST', path: '/api/v1/backups/schedule',
|
||||||
|
headers: { cookie: '', 'x-csrf-token': 'some-signature-attempt' }
|
||||||
|
});
|
||||||
|
csrfValidationMiddleware(req, res, next);
|
||||||
|
|
||||||
|
// 403 response unchanged
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ error: expect.stringContaining('DC-100') })
|
||||||
|
);
|
||||||
|
// Log tag is [CSRF-debug]
|
||||||
|
expect(stderrSpy).toHaveBeenCalled();
|
||||||
|
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
|
||||||
|
expect(lastWrite).toContain('[CSRF-debug]');
|
||||||
|
expect(lastWrite).toContain('browser auto-retry');
|
||||||
|
expect(lastWrite).not.toMatch(/^\[CSRF\][^-]/); // not bare [CSRF]
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tags missing-cookie with [CSRF] when no X-CSRF-Token header present (real probe)', () => {
|
||||||
|
const { req, res, next } = createMockReqRes({
|
||||||
|
method: 'POST', path: '/api/v1/backups/schedule',
|
||||||
|
headers: { cookie: '' }
|
||||||
|
});
|
||||||
|
csrfValidationMiddleware(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(stderrSpy).toHaveBeenCalled();
|
||||||
|
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
|
||||||
|
expect(lastWrite).toContain('[CSRF]');
|
||||||
|
expect(lastWrite).not.toContain('[CSRF-debug]');
|
||||||
|
expect(lastWrite).not.toContain('browser auto-retry');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps [CSRF] tag when cookie present but header missing (curl probe with manual cookie)', () => {
|
||||||
|
const nonce = generateToken();
|
||||||
|
const { req, res, next } = createMockReqRes({
|
||||||
|
method: 'POST', path: '/api/v1/backups/schedule',
|
||||||
|
headers: { cookie: `${CSRF_COOKIE_NAME}=${nonce}` }
|
||||||
|
});
|
||||||
|
csrfValidationMiddleware(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
expect(stderrSpy).toHaveBeenCalled();
|
||||||
|
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
|
||||||
|
expect(lastWrite).toContain('[CSRF]');
|
||||||
|
expect(lastWrite).not.toContain('[CSRF-debug]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('headerToken is read from x-csrf-token (lowercased by Express) — lowercase header triggers [CSRF-debug]', () => {
|
||||||
|
// Express/Node lowercases all incoming header keys, so production code
|
||||||
|
// only ever sees lowercase. We test the exact code path here.
|
||||||
|
const { req, res, next } = createMockReqRes({
|
||||||
|
method: 'POST', path: '/api/v1/backups/schedule',
|
||||||
|
headers: { cookie: '', 'x-csrf-token': 'some-signature-attempt' }
|
||||||
|
});
|
||||||
|
csrfValidationMiddleware(req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
const lastWrite = stderrSpy.mock.calls[stderrSpy.mock.calls.length - 1][0];
|
||||||
|
expect(lastWrite).toContain('[CSRF-debug]');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('renewCSRFToken', () => {
|
describe('renewCSRFToken', () => {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* DC-057: dead-shadow /backups/schedule handler removed.
|
||||||
|
*
|
||||||
|
* The duplicate `router.post('/backups/schedule', ...)` previously registered
|
||||||
|
* far below the canonical one was unreachable (Express matches the first
|
||||||
|
* registered handler per METHOD+PATH). It bypassed `premiumGating` and
|
||||||
|
* `validateBody` and used a `name`-keyed schema that would have corrupted the
|
||||||
|
* backup config if it ever ran. The canonical handler uses the error code
|
||||||
|
* `backups-schedule-update`; the dead handler used `backups-schedule-legacy`.
|
||||||
|
* This test proves:
|
||||||
|
*
|
||||||
|
* 1. The router registers exactly ONE POST /backups/schedule handler
|
||||||
|
* (the canonical, appId-keyed one).
|
||||||
|
* 2. No handler references the legacy "backups-schedule-legacy" error code.
|
||||||
|
* 3. The legacy "name"-keyed schema now produces a 400 ValidationError
|
||||||
|
* from the canonical Joi schema (dead handler is gone).
|
||||||
|
* 4. The canonical appId-keyed schema still succeeds (200).
|
||||||
|
* 5. premiumGating is enforced on the canonical POST.
|
||||||
|
*
|
||||||
|
* Mirrors the audit-log.routes.test.js pattern.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
function buildFakeBackupManager() {
|
||||||
|
const config = { backups: {}, defaultRetention: { keep: 7 } };
|
||||||
|
return {
|
||||||
|
getConfig: jest.fn(() => config),
|
||||||
|
updateConfig: jest.fn((next) => {
|
||||||
|
config.backups = next.backups || {};
|
||||||
|
}),
|
||||||
|
getHistory: jest.fn(() => []),
|
||||||
|
restoreBackup: jest.fn(async (id) => {
|
||||||
|
// Suppress require-await — keep async shape for parity with the
|
||||||
|
// real backupManager.restoreBackup contract.
|
||||||
|
return Promise.resolve({ id, status: 'restored' });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFakeLicenseManager() {
|
||||||
|
const requirePremium = jest.fn(() => (_req, _res, next) => next());
|
||||||
|
return {
|
||||||
|
requirePremium,
|
||||||
|
isPremium: jest.fn(() => true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouter(licenseManager, backupManager) {
|
||||||
|
// Reset module cache so each test starts fresh
|
||||||
|
jest.resetModules();
|
||||||
|
const mod = require('../../routes/backups');
|
||||||
|
return mod({
|
||||||
|
backupManager,
|
||||||
|
licenseManager,
|
||||||
|
asyncHandler: (fn) => async (req, res, next) => { // eslint-disable-line require-await
|
||||||
|
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(router) {
|
||||||
|
// Catch-all error handler so ValidationError / NotFoundError become JSON
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
// intentionally strip auth — the test does not exercise it
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/', router);
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
const status = err.statusCode || err.status || 500;
|
||||||
|
res.status(status).json({
|
||||||
|
error: err.message,
|
||||||
|
code: err.code || 'ERR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function supertestFetch(app) {
|
||||||
|
// Tiny in-process fetch helper (no need to add supertest dep)
|
||||||
|
const http = require('http');
|
||||||
|
return function (method, path, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = app.listen(0, () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
const data = body ? JSON.stringify(body) : null;
|
||||||
|
const req = http.request({
|
||||||
|
method,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
path,
|
||||||
|
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
|
||||||
|
}, (res) => {
|
||||||
|
let chunks = '';
|
||||||
|
res.on('data', (c) => { chunks += c; });
|
||||||
|
res.on('end', () => {
|
||||||
|
server.close();
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(chunks); } catch { parsed = chunks; }
|
||||||
|
resolve({ status: res.statusCode, body: parsed });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', (e) => { server.close(); reject(e); });
|
||||||
|
if (data) req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('routes/backups POST /backups/schedule (DC-057)', () => {
|
||||||
|
let backupManager, licenseManager, app, fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
backupManager = buildFakeBackupManager();
|
||||||
|
licenseManager = buildFakeLicenseManager();
|
||||||
|
const router = buildRouter(licenseManager, backupManager);
|
||||||
|
app = buildApp(router);
|
||||||
|
fetch = supertestFetch(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registers exactly ONE POST /backups/schedule handler (canonical)', () => {
|
||||||
|
// Inspect the registered router layers and confirm only one POST /backups/schedule
|
||||||
|
// route exists (no shadowed / unreachable duplicate).
|
||||||
|
const router = buildRouter(licenseManager, backupManager);
|
||||||
|
const seen = [];
|
||||||
|
router.stack.forEach((layer) => {
|
||||||
|
if (layer.route && layer.route.path === '/backups/schedule' && layer.route.methods.post) {
|
||||||
|
seen.push(layer.route);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no handler references the legacy "backups-schedule-legacy" error code', () => {
|
||||||
|
// The canonical handler uses error code 'backups-schedule-update'.
|
||||||
|
// Walk the router stack and assert no route uses the legacy error code.
|
||||||
|
const router = buildRouter(licenseManager, backupManager);
|
||||||
|
const handlerStrings = [];
|
||||||
|
function walk(node) {
|
||||||
|
if (!node) return;
|
||||||
|
if (node.stack) node.stack.forEach(walk);
|
||||||
|
if (node.handle) {
|
||||||
|
const code = node.handle.toString();
|
||||||
|
handlerStrings.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(router);
|
||||||
|
const all = handlerStrings.join('\n');
|
||||||
|
expect(all).not.toContain('backups-schedule-legacy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy name-keyed schema is REJECTED with 400 (dead route truly gone)', async () => {
|
||||||
|
// The dead handler accepted { name, schedule, maxStorageBytes, ...backupConfig }.
|
||||||
|
// After removal, the canonical Joi schema (backupScheduleCreate) rejects this
|
||||||
|
// shape because it requires `appId`. So we expect a 400.
|
||||||
|
const res = await fetch('POST', '/backups/schedule', {
|
||||||
|
name: 'mybackup',
|
||||||
|
schedule: 'daily',
|
||||||
|
maxStorageBytes: 1024,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/appId.*required|appId is required/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonical appId-keyed schema SUCCEEDS (200) and writes backup config', async () => {
|
||||||
|
const res = await fetch('POST', '/backups/schedule', {
|
||||||
|
appId: 'plex',
|
||||||
|
schedule: 'daily',
|
||||||
|
retention: { keep: 7 },
|
||||||
|
destination: 'local',
|
||||||
|
destinationPath: '/var/backups/plex',
|
||||||
|
maxStorageBytes: 1024,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(backupManager.updateConfig).toHaveBeenCalledTimes(1);
|
||||||
|
const written = backupManager.updateConfig.mock.calls[0][0];
|
||||||
|
expect(written.backups).toHaveProperty('plex');
|
||||||
|
expect(written.backups.plex.schedule).toBe('daily');
|
||||||
|
expect(written.backups.plex.enabled).toBe(true);
|
||||||
|
expect(written.backups.plex.maxStorageBytes).toBe(1024);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('premium gating is enforced on POST /backups/schedule', async () => {
|
||||||
|
// Replace the premium gate with one that 403s, then verify it runs.
|
||||||
|
licenseManager.requirePremium.mockReturnValueOnce(
|
||||||
|
(_req, res) => res.status(403).json({ error: 'premium required' }),
|
||||||
|
);
|
||||||
|
const router = buildRouter(licenseManager, backupManager);
|
||||||
|
app = buildApp(router);
|
||||||
|
fetch = supertestFetch(app);
|
||||||
|
const res = await fetch('POST', '/backups/schedule', {
|
||||||
|
appId: 'plex',
|
||||||
|
schedule: 'daily',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
expect(backupManager.updateConfig).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /backups/schedule still works (no collateral damage)', async () => {
|
||||||
|
const res = await fetch('GET', '/backups/schedule');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.success).toBe(true);
|
||||||
|
expect(res.body).toHaveProperty('schedules');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DELETE /backups/schedule/:appId still works', async () => {
|
||||||
|
// Seed the config so the delete has something to remove
|
||||||
|
backupManager.getConfig().backups.plex = { schedule: 'daily' };
|
||||||
|
const res = await fetch('DELETE', '/backups/schedule/plex');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(backupManager.updateConfig).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* DC-059: disk-space POST /config threshold-ordering invariant.
|
||||||
|
*
|
||||||
|
* DiskSpaceMonitor._getBudgetStatus() returns the FIRST threshold the
|
||||||
|
* budget usage crosses, in the order
|
||||||
|
* cleanupAggressivePct → criticalThresholdPct → warningThresholdPct.
|
||||||
|
* If a caller writes the three thresholds out of order
|
||||||
|
* (e.g. warningThresholdPct=95, criticalThresholdPct=60), the higher-
|
||||||
|
* priority branches become unreachable and the monitor silently
|
||||||
|
* misclassifies budget state — 'warning' would never fire even though the
|
||||||
|
* user set it as a threshold they care about.
|
||||||
|
*
|
||||||
|
* The fix lives in `routes/disk-space.js`: a `mergeAndCheckOrdering()`
|
||||||
|
* helper validates the *effective* (merged with live baseline) config
|
||||||
|
* against the invariant `warningThresholdPct < criticalThresholdPct <
|
||||||
|
* cleanupAggressivePct` BEFORE the route mutates diskSpaceMonitor.diskConfig.
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* 1. Monotonic ascending order is accepted (happy path).
|
||||||
|
* 2. warningThresholdPct >= criticalThresholdPct is rejected with 400.
|
||||||
|
* 3. criticalThresholdPct >= cleanupAggressivePct is rejected with 400.
|
||||||
|
* 4. Partial updates work one field at a time without violating the
|
||||||
|
* invariant against the current baseline.
|
||||||
|
* 5. Out-of-bounds numeric values are clamped to the same bounds the
|
||||||
|
* original inline Math.min/Math.max chains enforced (50/60/70 → 99).
|
||||||
|
* 6. DiskSpaceMonitor.configure is NEVER called when the request is
|
||||||
|
* rejected (no partial mutation).
|
||||||
|
* 7. The merged config returned to the client is the post-clamp value,
|
||||||
|
* not the raw request body.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
enabled: true,
|
||||||
|
diskBudgetGB: 10,
|
||||||
|
warningThresholdPct: 80,
|
||||||
|
criticalThresholdPct: 90,
|
||||||
|
autoCleanup: true,
|
||||||
|
cleanupAggressivePct: 95,
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildFakeDiskSpaceMonitor(initial = { ...DEFAULT_CONFIG }) {
|
||||||
|
const state = { ...initial };
|
||||||
|
return {
|
||||||
|
configure: jest.fn((updates) => {
|
||||||
|
Object.assign(state, updates);
|
||||||
|
return { ...state };
|
||||||
|
}),
|
||||||
|
getConfig: jest.fn(() => ({ ...state })),
|
||||||
|
getSnapshot: jest.fn(async () => ({})),
|
||||||
|
getDetailedBreakdown: jest.fn(async () => ({})),
|
||||||
|
performCleanup: jest.fn(async () => ({})),
|
||||||
|
// Test-only: peek at the internal state to confirm no mutation on rejection
|
||||||
|
_state: state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRouter(monitor) {
|
||||||
|
// Reset module cache so each test starts fresh
|
||||||
|
jest.resetModules();
|
||||||
|
const mod = require('../../routes/disk-space');
|
||||||
|
return mod({
|
||||||
|
diskSpaceMonitor: monitor,
|
||||||
|
asyncHandler: (fn) => async (req, res, next) => { // eslint-disable-line require-await
|
||||||
|
try { await fn(req, res, next); } catch (e) { next(e); }
|
||||||
|
},
|
||||||
|
log: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(router) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => { next(); }); // strip auth
|
||||||
|
app.use('/', router);
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
const status = err.statusCode || err.status || 500;
|
||||||
|
res.status(status).json({
|
||||||
|
error: err.message,
|
||||||
|
code: err.code || 'ERR',
|
||||||
|
field: err.field || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function supertestFetch(app) {
|
||||||
|
return function (method, path, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = app.listen(0, () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
const data = body ? JSON.stringify(body) : null;
|
||||||
|
const req = http.request({
|
||||||
|
method,
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
path,
|
||||||
|
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
|
||||||
|
}, (res) => {
|
||||||
|
let chunks = '';
|
||||||
|
res.on('data', (c) => { chunks += c; });
|
||||||
|
res.on('end', () => {
|
||||||
|
server.close();
|
||||||
|
let parsed;
|
||||||
|
try { parsed = JSON.parse(chunks); } catch { parsed = chunks; }
|
||||||
|
resolve({ status: res.statusCode, body: parsed });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', (e) => { server.close(); reject(e); });
|
||||||
|
if (data) req.write(data);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('routes/disk-space POST /config (DC-059 threshold ordering)', () => {
|
||||||
|
let monitor, app, fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
monitor = buildFakeDiskSpaceMonitor();
|
||||||
|
const router = buildRouter(monitor);
|
||||||
|
app = buildApp(router);
|
||||||
|
fetch = supertestFetch(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('happy path — strict monotonic ascending order is accepted', async () => {
|
||||||
|
const res = await fetch('POST', '/config', {
|
||||||
|
warningThresholdPct: 75,
|
||||||
|
criticalThresholdPct: 88,
|
||||||
|
cleanupAggressivePct: 95,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.config).toEqual(expect.objectContaining({
|
||||||
|
warningThresholdPct: 75,
|
||||||
|
criticalThresholdPct: 88,
|
||||||
|
cleanupAggressivePct: 95,
|
||||||
|
}));
|
||||||
|
expect(monitor.configure).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('warningThresholdPct >= criticalThresholdPct is rejected with 400', async () => {
|
||||||
|
const res = await fetch('POST', '/config', {
|
||||||
|
warningThresholdPct: 95,
|
||||||
|
criticalThresholdPct: 80,
|
||||||
|
cleanupAggressivePct: 99,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/);
|
||||||
|
expect(res.body.field).toBe('warningThresholdPct');
|
||||||
|
// Critical invariant: monitor.configure was NEVER called.
|
||||||
|
expect(monitor.configure).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('criticalThresholdPct >= cleanupAggressivePct is rejected with 400', async () => {
|
||||||
|
const res = await fetch('POST', '/config', {
|
||||||
|
warningThresholdPct: 60,
|
||||||
|
criticalThresholdPct: 95,
|
||||||
|
cleanupAggressivePct: 80,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/criticalThresholdPct.*strictly less than.*cleanupAggressivePct/);
|
||||||
|
expect(res.body.field).toBe('criticalThresholdPct');
|
||||||
|
expect(monitor.configure).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('equal thresholds are rejected (strict <, not <=)', async () => {
|
||||||
|
const res = await fetch('POST', '/config', {
|
||||||
|
warningThresholdPct: 80,
|
||||||
|
criticalThresholdPct: 80,
|
||||||
|
cleanupAggressivePct: 90,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(monitor.configure).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial update — single field accepted against existing baseline', async () => {
|
||||||
|
// Defaults: warning=80, critical=90, aggressive=95. Raise warning to 85.
|
||||||
|
const res = await fetch('POST', '/config', { warningThresholdPct: 85 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.config.warningThresholdPct).toBe(85);
|
||||||
|
expect(res.body.config.criticalThresholdPct).toBe(90);
|
||||||
|
expect(res.body.config.cleanupAggressivePct).toBe(95);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial update — would violate invariant against baseline, rejected', async () => {
|
||||||
|
// Defaults: warning=80, critical=90, aggressive=95. Setting warning=95
|
||||||
|
// would collide with the existing critical=90 (warning >= critical).
|
||||||
|
const res = await fetch('POST', '/config', { warningThresholdPct: 95 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toMatch(/warningThresholdPct.*strictly less than.*criticalThresholdPct/);
|
||||||
|
expect(monitor.configure).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial update — succeeds after baseline was updated in a prior request', async () => {
|
||||||
|
// First request: bump warning from 80 → 85 (within current critical=90).
|
||||||
|
let res = await fetch('POST', '/config', { warningThresholdPct: 85 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Second request: now bump warning from 85 → 89. Still under critical=90.
|
||||||
|
res = await fetch('POST', '/config', { warningThresholdPct: 89 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(monitor.configure).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial update — would violate against the NEW baseline, rejected', async () => {
|
||||||
|
// Step 1: raise warning to 85.
|
||||||
|
let res = await fetch('POST', '/config', { warningThresholdPct: 85 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
// Step 2: try to raise warning to 95 — would collide with critical=90.
|
||||||
|
res = await fetch('POST', '/config', { warningThresholdPct: 95 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
// monitor.configure should have run exactly once (the accepted request).
|
||||||
|
expect(monitor.configure).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('out-of-bounds values are clamped to documented ranges', async () => {
|
||||||
|
// Note: the three values must produce a valid monotonic ordering AFTER
|
||||||
|
// clamping. Setting warning=20 (→ 50), critical=200 (→ 99), aggressive=70
|
||||||
|
// would produce critical=99 > aggressive=70 which is rejected by the
|
||||||
|
// ordering check. Use values that clamp into a valid range.
|
||||||
|
const res = await fetch('POST', '/config', {
|
||||||
|
warningThresholdPct: 20, // below warning min 50 → clamped to 50
|
||||||
|
criticalThresholdPct: 85, // valid
|
||||||
|
cleanupAggressivePct: 200, // above aggressive max 99 → clamped to 99
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.config).toEqual(expect.objectContaining({
|
||||||
|
warningThresholdPct: 50,
|
||||||
|
criticalThresholdPct: 85,
|
||||||
|
cleanupAggressivePct: 99,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-numeric threshold values are silently dropped (legacy behaviour preserved)', async () => {
|
||||||
|
// Strings are not numbers → unchanged from baseline. Confirms the
|
||||||
|
// ordering check doesn\'t reject legitimate "I didn\'t change this" requests.
|
||||||
|
const res = await fetch('POST', '/config', {
|
||||||
|
warningThresholdPct: '80',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.config.warningThresholdPct).toBe(80); // baseline unchanged
|
||||||
|
expect(monitor.configure).toHaveBeenCalledWith({}); // empty updates
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diskBudgetGB and autoCleanup updates still work alongside threshold validation', async () => {
|
||||||
|
const res = await fetch('POST', '/config', {
|
||||||
|
diskBudgetGB: 50,
|
||||||
|
autoCleanup: false,
|
||||||
|
warningThresholdPct: 81,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.config.diskBudgetGB).toBe(50);
|
||||||
|
expect(res.body.config.autoCleanup).toBe(false);
|
||||||
|
expect(res.body.config.warningThresholdPct).toBe(81);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejected request does NOT mutate the live diskConfig', async () => {
|
||||||
|
const before = { ...monitor._state };
|
||||||
|
const res = await fetch('POST', '/config', {
|
||||||
|
warningThresholdPct: 95, // collides with critical=90
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(monitor._state).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /config with no thresholds in body is a no-op against baseline', async () => {
|
||||||
|
const res = await fetch('POST', '/config', { diskBudgetGB: 25 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.config.diskBudgetGB).toBe(25);
|
||||||
|
expect(res.body.config.warningThresholdPct).toBe(80); // unchanged
|
||||||
|
expect(res.body.config.criticalThresholdPct).toBe(90); // unchanged
|
||||||
|
expect(res.body.config.cleanupAggressivePct).toBe(95); // unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
/**
|
||||||
|
* DC-063: errorResponse arg-order invariant regression suite.
|
||||||
|
*
|
||||||
|
* Three layers of correctness pinned by this test:
|
||||||
|
*
|
||||||
|
* (1) The validator at responses.js:76-98 catches wrong-order callers
|
||||||
|
* with a clear TypeError naming statusCode. Defense-in-depth: any
|
||||||
|
* future swap is caught at the smallest possible blast radius
|
||||||
|
* (one TypeError on the request thread) instead of an HTTP 500 HTML
|
||||||
|
* panic for the operator and client.
|
||||||
|
*
|
||||||
|
* (2) The static trees under dashcaddy-api/routes/ and
|
||||||
|
* dashcaddy-api/src/utilities/ follow ONE of two equivalent
|
||||||
|
* conventions consistently:
|
||||||
|
*
|
||||||
|
* Convention A — canonical import `errorResponse` from responses.js.
|
||||||
|
* Callsite shape: errorResponse(res, statusCode, message, extras?)
|
||||||
|
* statusCode must be an integer 100..599; message must be a string.
|
||||||
|
*
|
||||||
|
* Convention B — alias import `error: errorResponse` from responses.js,
|
||||||
|
* which binds the local `errorResponse` to the message-first
|
||||||
|
* helper `error(res, message, statusCode = 500)`.
|
||||||
|
* Callsite shape: errorResponse(res, message, statusCode)
|
||||||
|
*
|
||||||
|
* Mixing the alias-import with the canonical-shape callsite is the
|
||||||
|
* DC-063 bug class: at runtime, the alias function fires
|
||||||
|
* `res.status('event not found')` → TypeError → HTTP 500 HTML panic,
|
||||||
|
* silently masking the intended 4xx JSON response for the client.
|
||||||
|
* The validator at (1) does NOT help because the alias path skips it.
|
||||||
|
*
|
||||||
|
* (3) End-to-end smoke for one of each fixed-file: live HTTP hits the
|
||||||
|
* endpoint with the malformed input that triggers the fix-callsite
|
||||||
|
* branch, and asserts the wire response is the expected 4xx JSON
|
||||||
|
* (status + content-type + body) — never a 500 HTML panic.
|
||||||
|
*
|
||||||
|
* Origin (DC-062): shipped 2026-08-18 by Hermes loop. Found 4 callsites in
|
||||||
|
* routes/caddy-upstreams.js and added the validator.
|
||||||
|
*
|
||||||
|
* DC-063 (this file): extended the search across the routes tree with
|
||||||
|
* alias-import awareness. Found 18 instances of the alias-imported +
|
||||||
|
* canonical-shape callsite bug class in 2 files (security.js + 3 calls
|
||||||
|
* in services.js). Fixed by switching those imports to canonical and
|
||||||
|
* rewriting the remaining 4 alias-shape callsites in services.js to
|
||||||
|
* canonical-shape. Adding this regression test to prevent the same
|
||||||
|
* swap from being reintroduced in future route file edits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const glob = require('glob');
|
||||||
|
|
||||||
|
const repoRoot = path.join(__dirname, '..', '..'); // dashcaddy-api/
|
||||||
|
const { errorResponse, error: aliasError } = require(
|
||||||
|
path.join(repoRoot, 'src/utils/responses')
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── (1) Type validator (defense-in-depth) ────────────────────────────────
|
||||||
|
describe('DC-063: errorResponse type validator (defense-in-depth)', () => {
|
||||||
|
function makeRes() {
|
||||||
|
return { status: () => makeRes(), json: () => makeRes() };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('canonical (res, statusCode, message) does not throw and JSON is well-formed', () => {
|
||||||
|
expect(() => errorResponse(makeRes(), 400, 'Invalid level')).not.toThrow();
|
||||||
|
expect(() => errorResponse(makeRes(), 503, 'downstream unavailable', { code: 'DC-503' }))
|
||||||
|
.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('swapped canonical-shape throws TypeError naming statusCode', () => {
|
||||||
|
expect(() => errorResponse(makeRes(), 'msg-not-status', 400))
|
||||||
|
.toThrow(TypeError);
|
||||||
|
expect(() => errorResponse(makeRes(), 'msg-not-status', 400))
|
||||||
|
.toThrow(/statusCode must be an integer HTTP status \(100\.\.599\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[0, 'below range'],
|
||||||
|
[99, 'below range'],
|
||||||
|
[600, 'above range'],
|
||||||
|
[3.14, 'non-integer'],
|
||||||
|
[NaN, 'NaN'],
|
||||||
|
[Infinity, 'Infinity'],
|
||||||
|
])('rejects numeric out-of-band statusCode %p (%s)', (bad) => {
|
||||||
|
expect(() => errorResponse(makeRes(), bad, 'msg')).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-string message', () => {
|
||||||
|
expect(() => errorResponse(makeRes(), 400, 42)).toThrow(TypeError);
|
||||||
|
expect(() => errorResponse(makeRes(), 400, null)).toThrow(TypeError);
|
||||||
|
expect(() => errorResponse(makeRes(), 400, { err: 'oops' })).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extras object merges into body and surfaces top-level code (DC-086)', () => {
|
||||||
|
const captured = {};
|
||||||
|
const res = {
|
||||||
|
status(c) { captured.status = c; return res; },
|
||||||
|
json(b) { captured.body = b; return res; },
|
||||||
|
};
|
||||||
|
errorResponse(res, 400, 'Invalid input', { code: 'DC-400', field: 'level' });
|
||||||
|
expect(captured.status).toBe(400);
|
||||||
|
expect(captured.body).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: 'Invalid input',
|
||||||
|
field: 'level',
|
||||||
|
code: 'DC-400',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alias error(res, message, statusCode) still works for backward-compat', () => {
|
||||||
|
expect(() => aliasError(makeRes(), 'msg', 400)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── (2) Static tree: every callsite follows its file's imported convention ─
|
||||||
|
describe('DC-063: routes/ + utilities/ arg-order matches each file\'s import', () => {
|
||||||
|
function isNumericLiteral(s) {
|
||||||
|
return /^\d+$/.test(s);
|
||||||
|
}
|
||||||
|
function isExpressionReturningNumber(s) {
|
||||||
|
return /^(err|error|response)\.status(Code)?\s*\|\|.*\d+/.test(s) ||
|
||||||
|
/^response\.status$/.test(s);
|
||||||
|
}
|
||||||
|
function isStringy(s) {
|
||||||
|
s = s.trim();
|
||||||
|
if (s.startsWith('"') || s.startsWith("'") || s.startsWith('`')) return true;
|
||||||
|
if (/^[a-zA-Z_][a-zA-Z_0-9]*\([^)]*\)$/.test(s)) return true; // safeErrorMessage(err)
|
||||||
|
if (/^[a-zA-Z_][a-zA-Z_0-9]*\.[a-zA-Z_][a-zA-Z_0-9.]*$/.test(s)) return true; // err.message
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function isNumeric(s) {
|
||||||
|
return isNumericLiteral(s.trim()) || isExpressionReturningNumber(s.trim());
|
||||||
|
}
|
||||||
|
// Match `errorResponse(res, ARG1, ARG2)` (allow extras after).
|
||||||
|
const pat = /errorResponse\(\s*res\s*,\s*([^,]+?)\s*,\s*([^,)\s]+)(?:\s*,|\s*\))/g;
|
||||||
|
|
||||||
|
const ROUTES = glob.sync('routes/*.js', { cwd: repoRoot });
|
||||||
|
const UTILS = glob.sync('src/utilities/*.js', { cwd: repoRoot });
|
||||||
|
const ALL = [...ROUTES, ...UTILS];
|
||||||
|
|
||||||
|
function classifyFile(src) {
|
||||||
|
// Filter comments before classification (the comment can mention the alias).
|
||||||
|
const codeOnly = src.split('\n')
|
||||||
|
.filter((l) => !l.trim().startsWith('//') && !l.trim().startsWith('*') && !l.trim().startsWith('/*'))
|
||||||
|
.join('\n');
|
||||||
|
const is_alias = /\berror:\s*errorResponse\b/.test(codeOnly);
|
||||||
|
return { is_alias };
|
||||||
|
}
|
||||||
|
|
||||||
|
test.each(ALL.map((rel) => [rel]))('%s has consistent callsite shape', (rel) => {
|
||||||
|
const abs = path.join(repoRoot, rel);
|
||||||
|
const src = fs.readFileSync(abs, 'utf8');
|
||||||
|
const { is_alias } = classifyFile(src);
|
||||||
|
const bad = [];
|
||||||
|
for (const m of src.matchAll(pat)) {
|
||||||
|
const a1 = m[1].trim();
|
||||||
|
const a2 = m[2].trim();
|
||||||
|
const lineNo = src.slice(0, m.index).split('\n').length;
|
||||||
|
|
||||||
|
if (is_alias) {
|
||||||
|
// Convention B: arg1 = message (string), arg2 = status (number)
|
||||||
|
if (isNumeric(a1) && isStringy(a2)) {
|
||||||
|
bad.push({ lineNo, a1, a2, reason: 'alias-import + canonical-shape (BUG: alias path skips validator)' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Convention A: arg1 = status (number), arg2 = message (string)
|
||||||
|
if (isStringy(a1) && isNumeric(a2)) {
|
||||||
|
bad.push({ lineNo, a1, a2, reason: 'canonical-import + alias-shape (BUG: validator fires TypeError -> 500 HTML)' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bad.length) {
|
||||||
|
throw new Error(
|
||||||
|
`${rel}: ${bad.length} inconsistent callsite(s):\n` +
|
||||||
|
bad.map((b) => ` L${b.lineNo}: (${b.a1}, ${b.a2}) — ${b.reason}`).join('\n')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── (3) End-to-end HTTP smoke — invalid input returns the expected JSON ─
|
||||||
|
describe('DC-063: live HTTP smoke — security.js GET /events/:id returns 404 JSON (not 500)', () => {
|
||||||
|
let server, baseUrl;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
process.env.DASHCADDY_API_TOKEN = process.env.DASHCADDY_API_TOKEN || 'test-token';
|
||||||
|
process.env.DASHCADDY_ENCRYPTION_KEY = process.env.DASHCADDY_ENCRYPTION_KEY || 'k'.repeat(64);
|
||||||
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'jwt-test-secret-32-chars-minimum-len';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Auth shim — bypass host authentication middleware.
|
||||||
|
app.use((_req, _res, next) => next());
|
||||||
|
|
||||||
|
// Shim the security event store with a fake.
|
||||||
|
const fakeStore = {
|
||||||
|
get: () => null,
|
||||||
|
append: () => ({ id: 'fake', accepted: true }),
|
||||||
|
list: () => ({ events: [], total: 0 }),
|
||||||
|
query: () => ({ events: [], total: 0 }),
|
||||||
|
};
|
||||||
|
const fakeRegistry = {
|
||||||
|
list: () => [],
|
||||||
|
register: () => ({ host: {}, api_key: 'x' }),
|
||||||
|
get: () => null,
|
||||||
|
update: () => null,
|
||||||
|
remove: () => true,
|
||||||
|
setEnabled: () => true,
|
||||||
|
authHostByApiKey: () => null,
|
||||||
|
authHostByBearer: () => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inject store + registry via a require-cache swap so security.js's
|
||||||
|
// getStore()/getRegistry() return our fakes.
|
||||||
|
require.cache[path.join(repoRoot, 'src/security/event-store')] = {
|
||||||
|
exports: { getStore: () => fakeStore },
|
||||||
|
id: 'fake-event-store', filename: 'fake', loaded: true,
|
||||||
|
};
|
||||||
|
require.cache[path.join(repoRoot, 'src/security/host-registry')] = {
|
||||||
|
exports: { getRegistry: () => fakeRegistry },
|
||||||
|
id: 'fake-host-registry', filename: 'fake', loaded: true,
|
||||||
|
};
|
||||||
|
// platform-paths is required by security.js — provide a minimal shim.
|
||||||
|
require.cache[path.join(repoRoot, 'platform-paths')] = {
|
||||||
|
exports: { configFile: () => '/tmp/x', dataFile: () => '/tmp/y' },
|
||||||
|
id: 'fake-platform-paths', filename: 'fake', loaded: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const securityRoutes = require(path.join(repoRoot, 'routes/security'));
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.success = (data) => res.json({ success: true, ...data });
|
||||||
|
res.errorResponse = (status, msg, extras) => errorResponse(res, status, msg, extras);
|
||||||
|
res.ok = (data) => res.json({ success: true, ...data });
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/api/security', securityRoutes({
|
||||||
|
store: fakeStore,
|
||||||
|
registry: fakeRegistry,
|
||||||
|
log: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
|
||||||
|
}));
|
||||||
|
|
||||||
|
server = http.createServer(app).listen(0);
|
||||||
|
// .listen(0) synchronously assigns a port; no need to wait.
|
||||||
|
baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
if (server && server.listening) server.close(done);
|
||||||
|
else done();
|
||||||
|
});
|
||||||
|
|
||||||
|
function get(p) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
http.get(`${baseUrl}${p}`, (resp) => {
|
||||||
|
let buf = '';
|
||||||
|
resp.on('data', (c) => { buf += c; });
|
||||||
|
resp.on('end', () => resolve({
|
||||||
|
status: resp.statusCode,
|
||||||
|
body: buf,
|
||||||
|
contentType: resp.headers['content-type'] || '',
|
||||||
|
}));
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GET /api/security/events/nonexistent — pre-fix crashed with 500 HTML, post-fix returns 404 JSON', async () => {
|
||||||
|
const r = await get('/api/security/events/nonexistent');
|
||||||
|
expect(r.status).toBe(404);
|
||||||
|
expect(r.contentType).toMatch(/application\/json/);
|
||||||
|
expect(r.body).toMatch(/event not found/i);
|
||||||
|
expect(r.body).not.toMatch(/<html|<!DOCTYPE|stack/i); // NOT an HTML panic
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/security/hosts/nonexistent — pre-fix crashed with 500 HTML, post-fix returns 404 JSON', async () => {
|
||||||
|
const r = await get('/api/security/hosts/nonexistent');
|
||||||
|
expect(r.status).toBe(404);
|
||||||
|
expect(r.contentType).toMatch(/application\/json/);
|
||||||
|
expect(r.body).toMatch(/host not found/i);
|
||||||
|
expect(r.body).not.toMatch(/<html|<!DOCTYPE|stack/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,14 +34,23 @@ jest.mock('../../src/utilities/pagination', () => ({
|
|||||||
parsePaginationParams: jest.fn(() => null),
|
parsePaginationParams: jest.fn(() => null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../src/utils/responses', () => ({
|
jest.mock('../../src/utils/responses', () => {
|
||||||
success: jest.fn((res, data, statusCode = 200) => {
|
// DC-063: services.js now imports canonical `errorResponse` (statusCode-first),
|
||||||
return res.status(statusCode).json({ success: true, ...data });
|
// so this mock must expose both that AND the legacy `error` alias to keep the
|
||||||
}),
|
// existing fixture working. The canonical validator is bypassed (tests use it
|
||||||
error: jest.fn((res, message, statusCode = 500, extra) => {
|
// as a structured passthrough); the alias preserves call-shape for any
|
||||||
return res.status(statusCode).json({ success: false, error: message, ...extra });
|
// remaining legacy import.
|
||||||
}),
|
const errorResponse = jest.fn((res, statusCode, message, extra) =>
|
||||||
}));
|
res.status(statusCode).json({ success: false, error: message, ...extra })
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: jest.fn((res, data, statusCode = 200) =>
|
||||||
|
res.status(statusCode).json({ success: true, ...data })
|
||||||
|
),
|
||||||
|
errorResponse,
|
||||||
|
error: errorResponse, // alias used by files that import `error: errorResponse`
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError
|
// errors module NOT mocked — used for real ValidationError/NotFoundError/ConflictError
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,331 @@
|
|||||||
|
/**
|
||||||
|
* DC-062: errorResponse arg-order regression test + caddy-upstreams JSON
|
||||||
|
* response guarantees.
|
||||||
|
*
|
||||||
|
* Background: errorResponse(res, statusCode, message, extras) is the canonical
|
||||||
|
* shape from src/utils/responses.js. Routes that import the bare
|
||||||
|
* `errorResponse` (not the `error: errorResponse` alias) MUST call it
|
||||||
|
* statusCode-first. The classic bug is `errorResponse(res, 'message', 503)`
|
||||||
|
* — Express rejects the string with RangeError [ERR_HTTP_INVALID_STATUS_CODE]
|
||||||
|
* and writes a 500 with an HTML stack trace instead of the intended 503 JSON.
|
||||||
|
*
|
||||||
|
* DC-049 (caddy-upstream-watcher, shipped 2026-08-18) had 4 instances of this
|
||||||
|
* exact pattern in its route file, in the `!caddyUpstreamWatcher` defensive
|
||||||
|
* branch. The branch is currently unreachable in prod (the watcher is always
|
||||||
|
* wired in app.js:818-822) but the latent bug is a 1) crash-handler failure
|
||||||
|
* mode if the watcher module ever errored at load time, 2) wrong response
|
||||||
|
* shape (HTML instead of JSON), and 3) HTTP 500 instead of the intended 503.
|
||||||
|
*
|
||||||
|
* Two layers of fix:
|
||||||
|
* 1. routes/caddy-upstreams.js — swap the 4 callsites to (res, 503, msg).
|
||||||
|
* 2. src/utils/responses.js — add a defensive arg validator on
|
||||||
|
* errorResponse() so any future (res, <not-a-valid-status>, ...)
|
||||||
|
* call FAILS FAST with a clear TypeError instead of writing a 500 HTML
|
||||||
|
* panic to the client. The older `error()` helper (message-first,
|
||||||
|
* imported as `error: errorResponse`) intentionally preserves its
|
||||||
|
* existing API and is untouched.
|
||||||
|
*
|
||||||
|
* This test exercises both fixes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Use the repo's deps so the test fails under exactly the same module
|
||||||
|
// resolution as production code (otherwise symlink/path differences can
|
||||||
|
// mask validator-install gaps).
|
||||||
|
// __dirname = /opt/dashcaddy/dashcaddy-api/__tests__
|
||||||
|
// __dirname/../src/utils/responses = the file under test
|
||||||
|
const repoRoot = path.join(__dirname, '..');
|
||||||
|
|
||||||
|
const { errorResponse, error: legacyError } = require(path.join(repoRoot, 'src/utils/responses'));
|
||||||
|
|
||||||
|
function get(port, urlPath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.get(`http://localhost:${port}${urlPath}`, (resp) => {
|
||||||
|
let body = '';
|
||||||
|
resp.on('data', (c) => { body += c; });
|
||||||
|
resp.on('end', () => resolve({ status: resp.statusCode, headers: resp.headers, body }));
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('errorResponse canonical arg-order + type guard (DC-062)', () => {
|
||||||
|
test('correct order — (res, 503, msg) returns 503 JSON', () => {
|
||||||
|
const mockRes = {
|
||||||
|
status(code) { mockRes._code = code; return this; },
|
||||||
|
json(body) { mockRes._body = body; return this; },
|
||||||
|
};
|
||||||
|
errorResponse(mockRes, 503, 'Caddy upstream watcher not initialized');
|
||||||
|
expect(mockRes._code).toBe(503);
|
||||||
|
expect(mockRes._body).toEqual({ success: false, error: 'Caddy upstream watcher not initialized' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('swapped order — (res, msg, statusCode) throws TypeError instead of writing a 500 HTML panic', () => {
|
||||||
|
// Before DC-062: errorResponse would call res.status('string-msg'),
|
||||||
|
// Express throws RangeError, error middleware catches it, writes 500 HTML.
|
||||||
|
// After DC-062: errorResponse itself rejects the call with a clear
|
||||||
|
// TypeError, naming the wrong arg.
|
||||||
|
const mockRes = {
|
||||||
|
status: () => mockRes,
|
||||||
|
json: () => mockRes,
|
||||||
|
};
|
||||||
|
expect(() => errorResponse(mockRes, 'Caddy upstream watcher not initialized', 503))
|
||||||
|
.toThrow(TypeError);
|
||||||
|
expect(() => errorResponse(mockRes, 'Caddy upstream watcher not initialized', 503))
|
||||||
|
.toThrow(/statusCode must be an integer HTTP status/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
['NaN', NaN],
|
||||||
|
['Infinity', Infinity],
|
||||||
|
['string "503"', '503'],
|
||||||
|
['null', null],
|
||||||
|
['undefined', undefined],
|
||||||
|
['underflow 99', 99],
|
||||||
|
['overflow 600', 600],
|
||||||
|
['float 503.5', 503.5],
|
||||||
|
['object', { code: 503 }],
|
||||||
|
['array', [503]],
|
||||||
|
])('rejects invalid statusCode %s', (_name, badStatus) => {
|
||||||
|
const mockRes = {
|
||||||
|
status: () => mockRes,
|
||||||
|
json: () => mockRes,
|
||||||
|
};
|
||||||
|
expect(() => errorResponse(mockRes, badStatus, 'msg')).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-string message', () => {
|
||||||
|
const mockRes = {
|
||||||
|
status: () => mockRes,
|
||||||
|
json: () => mockRes,
|
||||||
|
};
|
||||||
|
expect(() => errorResponse(mockRes, 503, 123)).toThrow(TypeError);
|
||||||
|
expect(() => errorResponse(mockRes, 503, null)).toThrow(TypeError);
|
||||||
|
expect(() => errorResponse(mockRes, 503, undefined)).toThrow(TypeError);
|
||||||
|
expect(() => errorResponse(mockRes, 503, { msg: 'x' })).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves correct callers (DC-086 extras.code propagation still works)', () => {
|
||||||
|
const mockRes = {
|
||||||
|
status: () => mockRes,
|
||||||
|
json: (b) => { mockRes._lastBody = b; return mockRes; },
|
||||||
|
};
|
||||||
|
errorResponse(mockRes, 409, 'Conflict', { code: 'DC-CONF-1', extra: 'detail' });
|
||||||
|
expect(mockRes._lastBody).toEqual({
|
||||||
|
success: false,
|
||||||
|
error: 'Conflict',
|
||||||
|
code: 'DC-CONF-1',
|
||||||
|
extra: 'detail',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy `error()` helper (message, status) is UNCHANGED — still works', () => {
|
||||||
|
// Regression guard for alias-style importers (dns.js, services.js,
|
||||||
|
// ssl-monitor.js, license.js, dependencies.js, errorlogs.js, etc.).
|
||||||
|
// The legacy helper takes (res, message, statusCode) order. Make sure
|
||||||
|
// the validator we added to `errorResponse` doesn't bleed into
|
||||||
|
// `error()`.
|
||||||
|
const mockRes = {
|
||||||
|
status(code) { mockRes._code = code; return this; },
|
||||||
|
json(body) { mockRes._body = body; return this; },
|
||||||
|
};
|
||||||
|
legacyError(mockRes, 'service unavailable', 503);
|
||||||
|
expect(mockRes._code).toBe(503);
|
||||||
|
expect(mockRes._body).toEqual({ success: false, error: 'service unavailable' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('regression: an Express response with res.status(string) emits HTML 500 — proves the bug pre-fix', async () => {
|
||||||
|
// This is the failure mode DC-062 prevents. We still need this to
|
||||||
|
// be true to prove the guard's value: if a call site ever slipped past
|
||||||
|
// the validator (e.g. by sending a non-number disguised as code 0),
|
||||||
|
// the server still doesn't return the intended status as JSON.
|
||||||
|
const server = await new Promise((resolve) => {
|
||||||
|
const app = express();
|
||||||
|
app.get('/probe', (req, res) => {
|
||||||
|
try {
|
||||||
|
res.status('not a status').json({ ok: false });
|
||||||
|
} catch (_) {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const s = app.listen(0, () => resolve({
|
||||||
|
port: s.address().port,
|
||||||
|
close: () => new Promise((r) => s.close(r)),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resp = await get(server.port, '/probe');
|
||||||
|
expect(resp.status).toBe(500);
|
||||||
|
// Express renders an HTML error page (not JSON) — this is the bug
|
||||||
|
// class DC-062 prevents at the helper layer.
|
||||||
|
expect(resp.headers['content-type'] || '').toMatch(/text\/html/);
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mount the real route module and inject a null watcher — proves the
|
||||||
|
// the four `!caddyUpstreamWatcher` paths now respond with the intended
|
||||||
|
// 503 JSON shape, not a 500 HTML panic.
|
||||||
|
describe('caddy-upstreams JSON response shape (route file literal fix)', () => {
|
||||||
|
// The real route module exports a factory `function({ asyncHandler, caddyUpstreamWatcher, healthChecker })`.
|
||||||
|
// We need to provide an asyncHandler shim since the route file uses it.
|
||||||
|
function asyncHandlerShim(fn) { return fn; }
|
||||||
|
// The factory also depends on the asyncHandler resolving rejected
|
||||||
|
// promises to errors. Define a simple one that just calls next(err).
|
||||||
|
function asyncHandler(fn) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountRouter(router) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const app = express();
|
||||||
|
app.use('/api/v1', router);
|
||||||
|
const server = app.listen(0, () => resolve({
|
||||||
|
port: server.address().port,
|
||||||
|
close: () => new Promise((r) => server.close(r)),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRoute(deps) {
|
||||||
|
return require(path.join(repoRoot, 'routes/caddy-upstreams'))(deps);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GET /caddy/upstreams with null watcher — 503 JSON (regression for swap bug)', async () => {
|
||||||
|
const router = loadRoute({
|
||||||
|
asyncHandler,
|
||||||
|
caddyUpstreamWatcher: null,
|
||||||
|
healthChecker: null,
|
||||||
|
});
|
||||||
|
const server = await mountRouter(router);
|
||||||
|
try {
|
||||||
|
const resp = await get(server.port, '/api/v1/caddy/upstreams');
|
||||||
|
expect(resp.status).toBe(503);
|
||||||
|
expect(resp.body).toContain('"success":false');
|
||||||
|
expect(resp.body).toContain('Caddy upstream watcher not initialized');
|
||||||
|
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /caddy/upstreams/:host/mute with null watcher — 503 JSON', async () => {
|
||||||
|
const router = loadRoute({
|
||||||
|
asyncHandler,
|
||||||
|
caddyUpstreamWatcher: null,
|
||||||
|
healthChecker: null,
|
||||||
|
});
|
||||||
|
const server = await mountRouter(router);
|
||||||
|
try {
|
||||||
|
const req = http.request({
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: server.port,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/caddy/upstreams/100.74.102.61:8080/mute',
|
||||||
|
}, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (c) => { body += c; });
|
||||||
|
res.on('end', () => {
|
||||||
|
expect(res.statusCode).toBe(503);
|
||||||
|
expect(body).toContain('"success":false');
|
||||||
|
expect(body).toContain('Caddy upstream watcher not initialized');
|
||||||
|
expect(res.headers['content-type'] || '').toMatch(/application\/json/);
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', (e) => { throw e; });
|
||||||
|
req.end();
|
||||||
|
} finally {
|
||||||
|
// server.close() will run via res.on('end') — defensively guard too.
|
||||||
|
// (Don't double-close if test already returned.)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /caddy/upstreams/mute (bare) with null watcher — 503 JSON', async () => {
|
||||||
|
const router = loadRoute({
|
||||||
|
asyncHandler,
|
||||||
|
caddyUpstreamWatcher: null,
|
||||||
|
healthChecker: null,
|
||||||
|
});
|
||||||
|
const server = await mountRouter(router);
|
||||||
|
try {
|
||||||
|
const resp = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: server.port,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/caddy/upstreams/mute',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
}, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (c) => { body += c; });
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end('{"host":"x","muted":true}');
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(503);
|
||||||
|
expect(resp.body).toContain('Caddy upstream watcher not initialized');
|
||||||
|
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /caddy/upstreams/:host/unmute with null watcher — 503 JSON', async () => {
|
||||||
|
const router = loadRoute({
|
||||||
|
asyncHandler,
|
||||||
|
caddyUpstreamWatcher: null,
|
||||||
|
healthChecker: null,
|
||||||
|
});
|
||||||
|
const server = await mountRouter(router);
|
||||||
|
try {
|
||||||
|
const resp = await new Promise((resolve, reject) => {
|
||||||
|
const req = http.request({
|
||||||
|
hostname: 'localhost',
|
||||||
|
port: server.port,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/caddy/upstreams/100.74.102.61:8080/unmute',
|
||||||
|
}, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', (c) => { body += c; });
|
||||||
|
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
expect(resp.status).toBe(503);
|
||||||
|
expect(resp.body).toContain('Caddy upstream watcher not initialized');
|
||||||
|
expect(resp.headers['content-type'] || '').toMatch(/application\/json/);
|
||||||
|
} finally {
|
||||||
|
await server.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('route file source: no swapped-order patterns remain', () => {
|
||||||
|
// Static scan of the post-fix route file: confirms the 4 swapped calls
|
||||||
|
// are gone. If a future refactor re-introduces the pattern, this scan
|
||||||
|
// catches it at test-time (before it ever lands in prod).
|
||||||
|
const fs = require('fs');
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.join(repoRoot, 'routes/caddy-upstreams.js'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
// Match `errorResponse(res, <quote-or-backtick>, <int>)` — the
|
||||||
|
// swapped-order shape (string literal in the 2nd arg position).
|
||||||
|
const swappedRe = /errorResponse\(res,\s*['"`]/;
|
||||||
|
expect(src).not.toMatch(swappedRe);
|
||||||
|
// And confirm the corrected shape appears at least four times
|
||||||
|
// (the four `!caddyUpstreamWatcher` guards).
|
||||||
|
const canonicalRe = /errorResponse\(res,\s*503,\s*['"]Caddy upstream watcher not initialized['"]/g;
|
||||||
|
const matches = src.match(canonicalRe) || [];
|
||||||
|
expect(matches.length).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* DC-076: Tests for the dashboard WebSocket server
|
* DC-076 / DC-061: Tests for the dashboard WebSocket server
|
||||||
|
*
|
||||||
|
* DC-061 added:
|
||||||
|
* - Real authVerifier injection (no string-presence-only check)
|
||||||
|
* - Rejection of bare cookies / token query params
|
||||||
|
* - close() detaches only OUR listeners (not shared SSE listeners)
|
||||||
|
* - Message size cap (16 KB)
|
||||||
|
* - parseCookieHeader unit coverage
|
||||||
*/
|
*/
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const WebSocket = require('ws');
|
const WebSocket = require('ws');
|
||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
const createDashboardWS = require('../../src/websocket/dashboard-ws');
|
const { createDashboardWS, parseCookieHeader } = require('../../src/websocket/dashboard-ws');
|
||||||
|
|
||||||
function createMockServer() {
|
function createMockServer() {
|
||||||
return http.createServer((req, res) => {
|
return http.createServer((req, res) => {
|
||||||
@@ -13,23 +20,38 @@ function createMockServer() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a stub verifier that mimics the production `session.isValid`
|
||||||
|
* shape: takes an IncomingMessage-ish request, returns true iff the
|
||||||
|
* session cookie value is a non-empty string.
|
||||||
|
*/
|
||||||
|
function cookieValueVerifier() {
|
||||||
|
return (req) => {
|
||||||
|
const parsed = parseCookieHeader(req && req.headers && req.headers.cookie);
|
||||||
|
const raw = parsed.dashcaddy_session;
|
||||||
|
return typeof raw === 'string' && raw.length > 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('DC-076: Dashboard WebSocket', () => {
|
describe('DC-076: Dashboard WebSocket', () => {
|
||||||
let server, wsServer, port;
|
let server, wsServer, port;
|
||||||
|
let resourceMonitor, healthChecker, updateManager;
|
||||||
|
|
||||||
beforeEach((done) => {
|
beforeEach((done) => {
|
||||||
server = createMockServer();
|
server = createMockServer();
|
||||||
server.listen(0, () => {
|
server.listen(0, () => {
|
||||||
port = server.address().port;
|
port = server.address().port;
|
||||||
|
|
||||||
const resourceMonitor = new EventEmitter();
|
resourceMonitor = new EventEmitter();
|
||||||
const healthChecker = new EventEmitter();
|
healthChecker = new EventEmitter();
|
||||||
const updateManager = new EventEmitter();
|
updateManager = new EventEmitter();
|
||||||
|
|
||||||
wsServer = createDashboardWS(server, {
|
wsServer = createDashboardWS(server, {
|
||||||
resourceMonitor,
|
resourceMonitor,
|
||||||
healthChecker,
|
healthChecker,
|
||||||
updateManager,
|
updateManager,
|
||||||
log: { info: jest.fn(), error: jest.fn() },
|
authVerifier: cookieValueVerifier(),
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
});
|
});
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
@@ -40,19 +62,19 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
server.close(done);
|
server.close(done);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts connections at the upgrade path', (done) => {
|
it('accepts connections at the upgrade path with a session cookie', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||||
ws.on('open', () => {
|
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||||
ws.close();
|
|
||||||
});
|
|
||||||
ws.on('close', () => {
|
|
||||||
done();
|
|
||||||
});
|
});
|
||||||
|
ws.on('open', () => ws.close());
|
||||||
|
ws.on('close', () => done());
|
||||||
ws.on('error', done);
|
ws.on('error', done);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends a connected event on join', (done) => {
|
it('sends a connected event on join', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||||
|
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||||
|
});
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
const msg = JSON.parse(raw.toString());
|
const msg = JSON.parse(raw.toString());
|
||||||
if (msg.type === 'connected') {
|
if (msg.type === 'connected') {
|
||||||
@@ -65,7 +87,9 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('responds to ping with pong', (done) => {
|
it('responds to ping with pong', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||||
|
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||||
|
});
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
ws.send(JSON.stringify({ type: 'ping' }));
|
ws.send(JSON.stringify({ type: 'ping' }));
|
||||||
});
|
});
|
||||||
@@ -80,7 +104,9 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('responds to subscribe with subscribed confirmation', (done) => {
|
it('responds to subscribe with subscribed confirmation', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||||
|
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||||
|
});
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] }));
|
ws.send(JSON.stringify({ type: 'subscribe', events: ['resource-alert', 'incident'] }));
|
||||||
});
|
});
|
||||||
@@ -96,7 +122,9 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('responds to client-count request', (done) => {
|
it('responds to client-count request', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||||
|
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||||
|
});
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
ws.send(JSON.stringify({ type: 'client-count' }));
|
ws.send(JSON.stringify({ type: 'client-count' }));
|
||||||
});
|
});
|
||||||
@@ -112,7 +140,9 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns error for invalid JSON', (done) => {
|
it('returns error for invalid JSON', (done) => {
|
||||||
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`);
|
const ws = new WebSocket(`ws://localhost:${port}/api/v1/ws`, {
|
||||||
|
headers: { Cookie: 'dashcaddy_session=valid-session-id' },
|
||||||
|
});
|
||||||
ws.on('open', () => {
|
ws.on('open', () => {
|
||||||
ws.send('not json');
|
ws.send('not json');
|
||||||
});
|
});
|
||||||
@@ -135,3 +165,210 @@ describe('DC-076: Dashboard WebSocket', () => {
|
|||||||
expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow();
|
expect(() => wsServer.broadcast('test', { foo: 'bar' })).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// DC-061 auth gate tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DC-061: WS upgrade auth gate', () => {
|
||||||
|
let server, wsServer, port;
|
||||||
|
|
||||||
|
beforeEach((done) => {
|
||||||
|
server = createMockServer();
|
||||||
|
server.listen(0, () => {
|
||||||
|
port = server.address().port;
|
||||||
|
wsServer = createDashboardWS(server, {
|
||||||
|
resourceMonitor: new EventEmitter(),
|
||||||
|
healthChecker: new EventEmitter(),
|
||||||
|
updateManager: new EventEmitter(),
|
||||||
|
authVerifier: cookieValueVerifier(),
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach((done) => {
|
||||||
|
wsServer.close();
|
||||||
|
server.close(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open a raw socket, send a hand-crafted WS upgrade request, and read
|
||||||
|
* the server's HTTP status line. Avoids the ws library's auto-retry
|
||||||
|
* behaviour so we get a deterministic single response.
|
||||||
|
*/
|
||||||
|
function probeUpgrade({ path, cookie, token } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const net = require('net');
|
||||||
|
const sock = net.createConnection(port, '127.0.0.1');
|
||||||
|
let buf = '';
|
||||||
|
const headers = [
|
||||||
|
`GET ${path || '/api/v1/ws'} HTTP/1.1`,
|
||||||
|
'Host: 127.0.0.1',
|
||||||
|
'Upgrade: websocket',
|
||||||
|
'Connection: Upgrade',
|
||||||
|
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
|
||||||
|
'Sec-WebSocket-Version: 13',
|
||||||
|
];
|
||||||
|
if (cookie) headers.push(`Cookie: ${cookie}`);
|
||||||
|
if (token) {
|
||||||
|
const sep = path && path.includes('?') ? '&' : '?';
|
||||||
|
headers[0] = headers[0].replace(path, `${path || '/api/v1/ws'}${sep}token=${token}`);
|
||||||
|
}
|
||||||
|
sock.on('connect', () => {
|
||||||
|
sock.write(headers.join('\r\n') + '\r\n\r\n');
|
||||||
|
});
|
||||||
|
sock.on('data', (chunk) => {
|
||||||
|
buf += chunk.toString('utf8');
|
||||||
|
if (buf.includes('\r\n\r\n')) {
|
||||||
|
sock.destroy();
|
||||||
|
const statusLine = buf.split('\r\n')[0];
|
||||||
|
const status = parseInt((statusLine.match(/HTTP\/1\.1 (\d+)/) || [])[1], 10);
|
||||||
|
resolve({ status, raw: buf });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sock.on('error', (err) => {
|
||||||
|
// Connection reset is fine — server destroys socket after 401.
|
||||||
|
if (buf) resolve({ status: -1, raw: buf });
|
||||||
|
else reject(err);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!buf) {
|
||||||
|
sock.destroy();
|
||||||
|
reject(new Error('No response within 1s'));
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('rejects WS upgrade with NO cookie', async () => {
|
||||||
|
const res = await probeUpgrade({});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects WS upgrade with empty session cookie value', async () => {
|
||||||
|
const res = await probeUpgrade({ cookie: 'dashcaddy_session=' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects WS upgrade with unrelated cookie (no session cookie)', async () => {
|
||||||
|
const res = await probeUpgrade({ cookie: 'foo=bar; baz=qux' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('NO LONGER accepts `?token=` query param bypass (DC-061 fix)', async () => {
|
||||||
|
// Pre-DC-061: any 11+ char token in ?token=... granted WS access in
|
||||||
|
// production. Post-fix: token query param is ignored entirely; only a
|
||||||
|
// valid session cookie grants access.
|
||||||
|
const res = await probeUpgrade({ token: 'thisstringisdefinitelylongenough' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects WS upgrade with token= AND empty cookie (no bypass combo)', async () => {
|
||||||
|
const res = await probeUpgrade({ cookie: 'dashcaddy_session=', token: 'abcdefghijklmnop' });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts upgrade when verifier returns true', async () => {
|
||||||
|
const res = await probeUpgrade({ cookie: 'dashcaddy_session=valid-session-id' });
|
||||||
|
// 101 Switching Protocols for successful WS handshake
|
||||||
|
expect(res.status).toBe(101);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// DC-061 close() listener detach test (the SSE-poisoning regression)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('DC-061: close() detaches only OUR listeners', () => {
|
||||||
|
it('does NOT remove listeners attached by SSE route to shared emitters', () => {
|
||||||
|
// Set up two "subscribers" on the same EventEmitter, simulating the
|
||||||
|
// real-world shape: SSE route subscribes via `.on('alert', sseHandler)`
|
||||||
|
// and dashboard-ws subscribes via `.on('alert', wsHandler)` to the
|
||||||
|
// SAME resourceMonitor. Calling dashboard-ws.close() must remove
|
||||||
|
// ONLY wsHandler — sseHandler must remain.
|
||||||
|
const server = createMockServer();
|
||||||
|
const resourceMonitor = new EventEmitter();
|
||||||
|
|
||||||
|
// Pre-existing "SSE" listener (registered before dashboard-ws boots)
|
||||||
|
const sseHandler = jest.fn();
|
||||||
|
resourceMonitor.on('alert', sseHandler);
|
||||||
|
|
||||||
|
const wsServer = createDashboardWS(server, {
|
||||||
|
resourceMonitor,
|
||||||
|
healthChecker: new EventEmitter(),
|
||||||
|
updateManager: new EventEmitter(),
|
||||||
|
authVerifier: () => true,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
});
|
||||||
|
|
||||||
|
// dashboard-ws added its own listener — verify it's there
|
||||||
|
const wsHandlerCallsBefore = resourceMonitor.listenerCount('alert');
|
||||||
|
expect(wsHandlerCallsBefore).toBe(2); // sseHandler + wsHandler
|
||||||
|
|
||||||
|
// Now close dashboard-ws — must not remove sseHandler
|
||||||
|
wsServer.close();
|
||||||
|
|
||||||
|
const wsHandlerCallsAfter = resourceMonitor.listenerCount('alert');
|
||||||
|
expect(wsHandlerCallsAfter).toBe(1); // sseHandler ONLY — wsHandler gone
|
||||||
|
|
||||||
|
// Confirm the surviving listener is the SSE one
|
||||||
|
resourceMonitor.emit('alert', { test: true });
|
||||||
|
expect(sseHandler).toHaveBeenCalledWith({ test: true });
|
||||||
|
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is safe to call close() multiple times', () => {
|
||||||
|
const server = createMockServer();
|
||||||
|
const wsServer = createDashboardWS(server, {
|
||||||
|
resourceMonitor: new EventEmitter(),
|
||||||
|
healthChecker: new EventEmitter(),
|
||||||
|
updateManager: new EventEmitter(),
|
||||||
|
authVerifier: () => true,
|
||||||
|
log: { info: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||||
|
});
|
||||||
|
expect(() => {
|
||||||
|
wsServer.close();
|
||||||
|
wsServer.close();
|
||||||
|
wsServer.close();
|
||||||
|
}).not.toThrow();
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// DC-061 parseCookieHeader unit tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseCookieHeader', () => {
|
||||||
|
it('returns empty object for undefined', () => {
|
||||||
|
expect(parseCookieHeader(undefined)).toEqual({});
|
||||||
|
});
|
||||||
|
it('returns empty object for empty string', () => {
|
||||||
|
expect(parseCookieHeader('')).toEqual({});
|
||||||
|
});
|
||||||
|
it('parses a single cookie pair', () => {
|
||||||
|
expect(parseCookieHeader('foo=bar')).toEqual({ foo: 'bar' });
|
||||||
|
});
|
||||||
|
it('parses multiple cookie pairs', () => {
|
||||||
|
expect(parseCookieHeader('a=1; b=2; c=3')).toEqual({ a: '1', b: '2', c: '3' });
|
||||||
|
});
|
||||||
|
it('trims whitespace around names and values', () => {
|
||||||
|
expect(parseCookieHeader(' foo = bar ; baz=qux')).toEqual({ foo: 'bar', baz: 'qux' });
|
||||||
|
});
|
||||||
|
it('preserves dots/dashes in HMAC-shaped session cookie values', () => {
|
||||||
|
// dashcaddy_session cookies are `<b64>.<sig>` — parseCookieHeader
|
||||||
|
// must NOT url-decode (the HMAC verifier reads the raw value).
|
||||||
|
expect(parseCookieHeader('dashcaddy_session=abc.def_123-XYZ')).toEqual({
|
||||||
|
dashcaddy_session: 'abc.def_123-XYZ',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('skips malformed pairs without `=`', () => {
|
||||||
|
expect(parseCookieHeader('foo; bar=baz')).toEqual({ bar: 'baz' });
|
||||||
|
});
|
||||||
|
it('skips empty name parts', () => {
|
||||||
|
expect(parseCookieHeader('=value; foo=bar')).toEqual({ foo: 'bar' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -22,6 +22,13 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// ==================== SCHEDULE ENDPOINTS (PREMIUM) ====================
|
// ==================== SCHEDULE ENDPOINTS (PREMIUM) ====================
|
||||||
|
// NOTE: POST /backups/schedule has a single canonical registration below
|
||||||
|
// (the appId-keyed handler at the top of this section). Earlier versions
|
||||||
|
// registered a duplicate "name"-keyed handler later in the file — Express
|
||||||
|
// only matches the first registered handler per METHOD+PATH, so the
|
||||||
|
// duplicate was unreachable dead code. Do not re-add it; if you need a
|
||||||
|
// different schema, change the canonical Joi schema in
|
||||||
|
// src/utilities/validate.js (backupScheduleCreate) instead.
|
||||||
|
|
||||||
// Apply premium gating to schedule-related routes
|
// Apply premium gating to schedule-related routes
|
||||||
const premiumGating = licenseManager.requirePremium('auto-backup');
|
const premiumGating = licenseManager.requirePremium('auto-backup');
|
||||||
@@ -511,38 +518,6 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
success(res, storageInfo);
|
success(res, storageInfo);
|
||||||
}, 'backups-storage-info'));
|
}, 'backups-storage-info'));
|
||||||
|
|
||||||
// Schedule a backup
|
|
||||||
// LEGACY: this is a duplicate registration of POST /backups/schedule (see also line 60,
|
|
||||||
// which uses the appId-keyed schema and is the route the frontend actually calls).
|
|
||||||
// Express only matches the first registered handler per METHOD+PATH, so this handler
|
|
||||||
// is unreachable. It is preserved for now to avoid removing a route any unknown
|
|
||||||
// integration might still POST to. TODO: audit + remove in a dedicated cleanup PR.
|
|
||||||
router.post('/backups/schedule', asyncHandler(async (req, res) => {
|
|
||||||
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
|
|
||||||
|
|
||||||
if (!name || !schedule) {
|
|
||||||
return res.status(400).json({ error: 'name and schedule are required' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = backupManager.getConfig();
|
|
||||||
|
|
||||||
// Store maxStorageBytes in the backup config (converted to bytes)
|
|
||||||
const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0
|
|
||||||
? maxStorageBytes
|
|
||||||
: (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0);
|
|
||||||
|
|
||||||
config.backups[name] = {
|
|
||||||
...backupConfig,
|
|
||||||
enabled: true,
|
|
||||||
schedule,
|
|
||||||
maxStorageBytes: maxBytes,
|
|
||||||
destinations: backupConfig.destinations || [{ type: 'local' }]
|
|
||||||
};
|
|
||||||
|
|
||||||
backupManager.updateConfig(config);
|
|
||||||
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
|
|
||||||
}, 'backups-schedule-legacy'));
|
|
||||||
|
|
||||||
// Restore from backup
|
// Restore from backup
|
||||||
router.post('/backups/restore/:backupId', validateBody(schemas.backupRestore), asyncHandler(async (req, res) => {
|
router.post('/backups/restore/:backupId', validateBody(schemas.backupRestore), asyncHandler(async (req, res) => {
|
||||||
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
|
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
|
||||||
|
|||||||
@@ -21,7 +21,14 @@ module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker })
|
|||||||
|
|
||||||
router.get('/caddy/upstreams', asyncHandler(async (req, res) => {
|
router.get('/caddy/upstreams', asyncHandler(async (req, res) => {
|
||||||
if (!caddyUpstreamWatcher) {
|
if (!caddyUpstreamWatcher) {
|
||||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
// DC-062: errorResponse(res, statusCode, message) — statusCode-first per
|
||||||
|
// src/utils/responses.js:66. The prior (res, message, statusCode) call
|
||||||
|
// order passed a STRING as the status code, which made
|
||||||
|
// res.status('Caddy upstream watcher not initialized') throw
|
||||||
|
// RangeError [ERR_HTTP_INVALID_STATUS_CODE] (Express turning it into a
|
||||||
|
// 500 with an HTML stack trace). All four `!caddyUpstreamWatcher`
|
||||||
|
// guards had the same latent bug — fixed to canonical order.
|
||||||
|
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
||||||
}
|
}
|
||||||
success(res, caddyUpstreamWatcher.snapshot());
|
success(res, caddyUpstreamWatcher.snapshot());
|
||||||
}, 'caddy-upstreams-list'));
|
}, 'caddy-upstreams-list'));
|
||||||
@@ -54,7 +61,7 @@ module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker })
|
|||||||
// ergonomic depending on caller.
|
// ergonomic depending on caller.
|
||||||
const handleMute = asyncHandler(async (req, res) => {
|
const handleMute = asyncHandler(async (req, res) => {
|
||||||
if (!caddyUpstreamWatcher) {
|
if (!caddyUpstreamWatcher) {
|
||||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
||||||
}
|
}
|
||||||
const host = req.params.host || req.body?.host;
|
const host = req.params.host || req.body?.host;
|
||||||
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||||
@@ -76,7 +83,7 @@ module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker })
|
|||||||
// absent or unparseable; require muted === false explicitly to unmute.
|
// absent or unparseable; require muted === false explicitly to unmute.
|
||||||
router.post('/caddy/upstreams/mute', asyncHandler(async (req, res) => {
|
router.post('/caddy/upstreams/mute', asyncHandler(async (req, res) => {
|
||||||
if (!caddyUpstreamWatcher) {
|
if (!caddyUpstreamWatcher) {
|
||||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
||||||
}
|
}
|
||||||
const { host, muted } = req.body || {};
|
const { host, muted } = req.body || {};
|
||||||
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
if (!host || typeof host !== 'string' || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||||
@@ -95,7 +102,7 @@ module.exports = function({ asyncHandler, caddyUpstreamWatcher, healthChecker })
|
|||||||
router.post('/caddy/upstreams/:host/mute', handleMute);
|
router.post('/caddy/upstreams/:host/mute', handleMute);
|
||||||
router.post('/caddy/upstreams/:host/unmute', asyncHandler(async (req, res) => {
|
router.post('/caddy/upstreams/:host/unmute', asyncHandler(async (req, res) => {
|
||||||
if (!caddyUpstreamWatcher) {
|
if (!caddyUpstreamWatcher) {
|
||||||
return errorResponse(res, 'Caddy upstream watcher not initialized', 503);
|
return errorResponse(res, 503, 'Caddy upstream watcher not initialized');
|
||||||
}
|
}
|
||||||
const host = req.params.host;
|
const host = req.params.host;
|
||||||
if (!host || !/^[a-z0-9._:-]+$/i.test(host)) {
|
if (!host || !/^[a-z0-9._:-]+$/i.test(host)) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||||
|
const { ValidationError } = require('../src/utilities/errors');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disk space management routes
|
* Disk space management routes
|
||||||
@@ -10,6 +11,76 @@ const { success, error: errorResponse } = require('../src/utils/responses');
|
|||||||
* POST /disk/config — update disk budget settings
|
* POST /disk/config — update disk budget settings
|
||||||
* POST /disk/cleanup — trigger manual cleanup (standard|aggressive|logs-only)
|
* POST /disk/cleanup — trigger manual cleanup (standard|aggressive|logs-only)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// DC-059: monotonic-ordering invariant for the three threshold percentages.
|
||||||
|
// DiskSpaceMonitor._getBudgetStatus() walks them in order
|
||||||
|
// (cleanupAggressivePct → criticalThresholdPct → warningThresholdPct) and
|
||||||
|
// returns at the FIRST threshold the usage crosses. If a caller writes
|
||||||
|
// them out of order (e.g. warningThresholdPct=95, criticalThresholdPct=60),
|
||||||
|
// the higher-priority branches become unreachable and the monitor silently
|
||||||
|
// misclassifies budget state. Validate against the *effective* config
|
||||||
|
// (current value + incoming update for each field) so partial updates can
|
||||||
|
// be applied one field at a time without violating the invariant.
|
||||||
|
//
|
||||||
|
// Clamp values to the same ranges the previous inline Math.min/Math.max
|
||||||
|
// chains enforced (warning 50..99, critical 60..99, aggressive 70..99)
|
||||||
|
// so we don't loosen the original bounds while adding the new check.
|
||||||
|
const THRESHOLD_BOUNDS = Object.freeze({
|
||||||
|
warning: { min: 50, max: 99 },
|
||||||
|
critical: { min: 60, max: 99 },
|
||||||
|
aggressive: { min: 70, max: 99 },
|
||||||
|
});
|
||||||
|
|
||||||
|
function clampThreshold(name, value) {
|
||||||
|
const { min, max } = THRESHOLD_BOUNDS[name];
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a candidate update to a baseline config, then verify the three
|
||||||
|
* threshold percentages still satisfy
|
||||||
|
* warningThresholdPct < criticalThresholdPct < cleanupAggressivePct.
|
||||||
|
* The POST /config endpoint accepts partial updates (single field at a
|
||||||
|
* time), so we merge into the live diskSpaceMonitor config first, then test
|
||||||
|
* the merged value. Returns the merged candidate on success; throws
|
||||||
|
* ValidationError if the ordering invariant would be violated.
|
||||||
|
*
|
||||||
|
* @param {Object} baseline - current effective config from diskSpaceMonitor
|
||||||
|
* @param {Object} candidate - the partial update being applied this request
|
||||||
|
* @returns {Object} merged candidate with thresholds clamped to bounds
|
||||||
|
*/
|
||||||
|
function mergeAndCheckOrdering(baseline, candidate) {
|
||||||
|
const next = { ...baseline };
|
||||||
|
if (typeof candidate.warningThresholdPct === 'number') {
|
||||||
|
next.warningThresholdPct = clampThreshold('warning', candidate.warningThresholdPct);
|
||||||
|
}
|
||||||
|
if (typeof candidate.criticalThresholdPct === 'number') {
|
||||||
|
next.criticalThresholdPct = clampThreshold('critical', candidate.criticalThresholdPct);
|
||||||
|
}
|
||||||
|
if (typeof candidate.cleanupAggressivePct === 'number') {
|
||||||
|
next.cleanupAggressivePct = clampThreshold('aggressive', candidate.cleanupAggressivePct);
|
||||||
|
}
|
||||||
|
if (!(next.warningThresholdPct < next.criticalThresholdPct)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`warningThresholdPct (${next.warningThresholdPct}) must be strictly less than criticalThresholdPct (${next.criticalThresholdPct})`,
|
||||||
|
'warningThresholdPct'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!(next.criticalThresholdPct < next.cleanupAggressivePct)) {
|
||||||
|
throw new ValidationError(
|
||||||
|
`criticalThresholdPct (${next.criticalThresholdPct}) must be strictly less than cleanupAggressivePct (${next.cleanupAggressivePct})`,
|
||||||
|
'criticalThresholdPct'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Return only the fields the caller asked to change (preserves partial-
|
||||||
|
// update semantics; diskSpaceMonitor.configure does its own merge).
|
||||||
|
const out = {};
|
||||||
|
if (typeof candidate.warningThresholdPct === 'number') out.warningThresholdPct = next.warningThresholdPct;
|
||||||
|
if (typeof candidate.criticalThresholdPct === 'number') out.criticalThresholdPct = next.criticalThresholdPct;
|
||||||
|
if (typeof candidate.cleanupAggressivePct === 'number') out.cleanupAggressivePct = next.cleanupAggressivePct;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
|
module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -36,9 +107,16 @@ module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
|
|||||||
|
|
||||||
const updates = {};
|
const updates = {};
|
||||||
if (typeof diskBudgetGB === 'number' && diskBudgetGB > 0) updates.diskBudgetGB = Math.min(diskBudgetGB, 1000);
|
if (typeof diskBudgetGB === 'number' && diskBudgetGB > 0) updates.diskBudgetGB = Math.min(diskBudgetGB, 1000);
|
||||||
if (typeof warningThresholdPct === 'number') updates.warningThresholdPct = Math.min(Math.max(warningThresholdPct, 50), 99);
|
// DC-059: threshold percentages must satisfy a strict monotonic order
|
||||||
if (typeof criticalThresholdPct === 'number') updates.criticalThresholdPct = Math.min(Math.max(criticalThresholdPct, 60), 99);
|
// (warning < critical < aggressive) so _getBudgetStatus() reaches the
|
||||||
if (typeof cleanupAggressivePct === 'number') updates.cleanupAggressivePct = Math.min(Math.max(cleanupAggressivePct, 70), 99);
|
// correct branch. mergeAndCheckOrdering() validates against the live
|
||||||
|
// baseline, so partial updates that violate the invariant are rejected
|
||||||
|
// BEFORE we mutate diskSpaceMonitor.diskConfig.
|
||||||
|
const thresholdUpdates = mergeAndCheckOrdering(
|
||||||
|
diskSpaceMonitor.getConfig(),
|
||||||
|
{ warningThresholdPct, criticalThresholdPct, cleanupAggressivePct }
|
||||||
|
);
|
||||||
|
Object.assign(updates, thresholdUpdates);
|
||||||
if (typeof autoCleanup === 'boolean') updates.autoCleanup = autoCleanup;
|
if (typeof autoCleanup === 'boolean') updates.autoCleanup = autoCleanup;
|
||||||
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { ok, error: errorResponse } = require('../src/utils/responses');
|
// DC-063: use the canonical `errorResponse(res, statusCode, message, extras)`
|
||||||
|
// shape — alias `error: errorResponse` used here previously was message-first
|
||||||
|
// which silently mis-called every callsite (15 endpoints surfaced as 500 HTML
|
||||||
|
// panics instead of the intended 4xx JSON).
|
||||||
|
const { ok, errorResponse } = require('../src/utils/responses');
|
||||||
const { getStore } = require('../src/security/event-store');
|
const { getStore } = require('../src/security/event-store');
|
||||||
const { getRegistry } = require('../src/security/host-registry');
|
const { getRegistry } = require('../src/security/host-registry');
|
||||||
const platformPaths = require('../platform-paths');
|
const platformPaths = require('../platform-paths');
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ const { exists } = require('../src/utilities/fs-helpers');
|
|||||||
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||||
const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors');
|
const { ValidationError, NotFoundError, ConflictError } = require('../src/utilities/errors');
|
||||||
const { resolveServiceUrl } = require('../src/utilities/url-resolver');
|
const { resolveServiceUrl } = require('../src/utilities/url-resolver');
|
||||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
// DC-063: use the canonical `errorResponse(res, statusCode, message, extras)`
|
||||||
|
// shape — alias `error: errorResponse` used here previously was message-first
|
||||||
|
// which silently mis-called 3 credential-store callsites (returned 500 HTML
|
||||||
|
// panics for invalid serviceId instead of the intended 400 JSON).
|
||||||
|
const { success, errorResponse } = require('../src/utils/responses');
|
||||||
const platformPaths = require('../platform-paths');
|
const platformPaths = require('../platform-paths');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -398,7 +402,8 @@ module.exports = function({
|
|||||||
try {
|
try {
|
||||||
validateServiceConfig({ id, name });
|
validateServiceConfig({ id, name });
|
||||||
} catch (validationErr) {
|
} catch (validationErr) {
|
||||||
return errorResponse(res, validationErr.message, 400, { errors: validationErr.errors });
|
// DC-063: canonical shape (res, statusCode, message, extras) per responses.js:76.
|
||||||
|
return errorResponse(res, 400, validationErr.message, { errors: validationErr.errors });
|
||||||
}
|
}
|
||||||
|
|
||||||
await servicesStateManager.update(services => {
|
await servicesStateManager.update(services => {
|
||||||
@@ -423,7 +428,8 @@ module.exports = function({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('deploy', error, null, { note: 'Error adding service' });
|
log.error('deploy', error, null, { note: 'Error adding service' });
|
||||||
if (error.message.includes('already exists')) {
|
if (error.message.includes('already exists')) {
|
||||||
errorResponse(res, safeErrorMessage(error), 409);
|
// DC-063: canonical shape per responses.js:76.
|
||||||
|
errorResponse(res, 409, safeErrorMessage(error));
|
||||||
} else {
|
} else {
|
||||||
// Error handled by middleware
|
// Error handled by middleware
|
||||||
}
|
}
|
||||||
@@ -445,7 +451,8 @@ module.exports = function({
|
|||||||
try {
|
try {
|
||||||
validateServiceConfig(service);
|
validateServiceConfig(service);
|
||||||
} catch (validationErr) {
|
} catch (validationErr) {
|
||||||
return errorResponse(res, `Invalid service "${service.id}": ${validationErr.message}`, 400, { errors: validationErr.errors });
|
// DC-063: canonical shape per responses.js:76.
|
||||||
|
return errorResponse(res, 400, `Invalid service "${service.id}": ${validationErr.message}`, { errors: validationErr.errors });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +482,8 @@ module.exports = function({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!found) {
|
if (!found) {
|
||||||
return errorResponse(res, `Service "${id}" not found`, 404);
|
// DC-063: canonical shape per responses.js:76.
|
||||||
|
return errorResponse(res, 404, `Service "${id}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
resyncHealthChecker?.().catch(() => {});
|
resyncHealthChecker?.().catch(() => {});
|
||||||
|
|||||||
+11
-1
@@ -75,7 +75,16 @@ process.on('uncaughtException', (error) => {
|
|||||||
// .on() on a class threw on every boot and silently killed the WS).
|
// .on() on a class threw on every boot and silently killed the WS).
|
||||||
try {
|
try {
|
||||||
const { ctx } = app.locals;
|
const { ctx } = app.locals;
|
||||||
const createDashboardWS = require('./src/websocket/dashboard-ws');
|
const createDashboardWS = require('./src/websocket/dashboard-ws').createDashboardWS;
|
||||||
|
|
||||||
|
// DC-061: WS upgrade bypasses Express middleware, so inject the
|
||||||
|
// real session verifier from the shared context. Without this
|
||||||
|
// the WS would fall back to a presence-only cookie check that
|
||||||
|
// any attacker can satisfy by setting a cookie named
|
||||||
|
// `dashcaddy_session` (verified HMAC required, not just name).
|
||||||
|
const authVerifier = (ctx.session && typeof ctx.session.isValid === 'function')
|
||||||
|
? ctx.session.isValid
|
||||||
|
: null;
|
||||||
|
|
||||||
createDashboardWS(server, {
|
createDashboardWS(server, {
|
||||||
resourceMonitor: ctx.resourceMonitor,
|
resourceMonitor: ctx.resourceMonitor,
|
||||||
@@ -86,6 +95,7 @@ process.on('uncaughtException', (error) => {
|
|||||||
driftDetector: ctx.driftDetector,
|
driftDetector: ctx.driftDetector,
|
||||||
sslMonitor: ctx.sslMonitor,
|
sslMonitor: ctx.sslMonitor,
|
||||||
dnsPropagationChecker: ctx.dnsPropagationChecker,
|
dnsPropagationChecker: ctx.dnsPropagationChecker,
|
||||||
|
authVerifier,
|
||||||
log,
|
log,
|
||||||
});
|
});
|
||||||
log.info('server', 'Dashboard WebSocket attached at /api/v1/ws');
|
log.info('server', 'Dashboard WebSocket attached at /api/v1/ws');
|
||||||
|
|||||||
@@ -406,7 +406,7 @@ class AutoRestartManager extends EventEmitter {
|
|||||||
// Transition: healthy → unhealthy
|
// Transition: healthy → unhealthy
|
||||||
if (previousStatus === 'up' && currentStatus === 'down') {
|
if (previousStatus === 'up' && currentStatus === 'down') {
|
||||||
// Find the containerId from the health checker config or status details
|
// Find the containerId from the health checker config or status details
|
||||||
const containerId = this._resolveContainerId(serviceId, status);
|
const containerId = await this._resolveContainerId(serviceId, status);
|
||||||
if (containerId) {
|
if (containerId) {
|
||||||
try {
|
try {
|
||||||
await this.handleContainerDown(serviceId, containerId);
|
await this.handleContainerDown(serviceId, containerId);
|
||||||
@@ -429,12 +429,19 @@ class AutoRestartManager extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Attempt to find the containerId for a service from various sources.
|
* Attempt to find the containerId for a service from various sources.
|
||||||
*
|
*
|
||||||
|
* DC-060: the previous implementation fired the async lookup via `.then(...)`
|
||||||
|
* but discarded the returned containerId, returning `undefined` from the
|
||||||
|
* function. Callers (`_handleStatusCheck`) gate on the return value, so
|
||||||
|
* every auto-restart whose containerId came from servicesStateManager
|
||||||
|
* silently no-op'd. Now awaits the read() promise so the containerId
|
||||||
|
* actually propagates.
|
||||||
|
*
|
||||||
* @param {string} serviceId
|
* @param {string} serviceId
|
||||||
* @param {Object} status - The status-check event data
|
* @param {Object} status - The status-check event data
|
||||||
* @returns {string|null}
|
* @returns {Promise<string|null>}
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_resolveContainerId(serviceId, status) {
|
async _resolveContainerId(serviceId, status) {
|
||||||
// Check if it's in the status details (some health checks embed it)
|
// Check if it's in the status details (some health checks embed it)
|
||||||
if (status.details?.containerId) return status.details.containerId;
|
if (status.details?.containerId) return status.details.containerId;
|
||||||
|
|
||||||
@@ -442,23 +449,20 @@ class AutoRestartManager extends EventEmitter {
|
|||||||
const hcService = this.healthChecker?.config?.services?.[serviceId];
|
const hcService = this.healthChecker?.config?.services?.[serviceId];
|
||||||
if (hcService?.containerId) return hcService.containerId;
|
if (hcService?.containerId) return hcService.containerId;
|
||||||
|
|
||||||
// Try to look it up from the services state manager
|
// Try to look it up from the services state manager. StateManager.read()
|
||||||
|
// is async (returns a Promise) — must await, not fire-and-forget.
|
||||||
try {
|
try {
|
||||||
const servicesStateManager = this.ctx.servicesStateManager;
|
const servicesStateManager = this.ctx.servicesStateManager;
|
||||||
if (servicesStateManager) {
|
if (!servicesStateManager) return null;
|
||||||
const readResult = servicesStateManager.read();
|
const list = await servicesStateManager.read();
|
||||||
if (readResult && typeof readResult.then === 'function') {
|
|
||||||
// It returns a promise — fire-and-forget lookup
|
|
||||||
readResult.then(list => {
|
|
||||||
const found = (list || []).find(s => s.id === serviceId);
|
const found = (list || []).find(s => s.id === serviceId);
|
||||||
return found?.containerId || null;
|
|
||||||
}).catch(() => null);
|
|
||||||
} else {
|
|
||||||
const found = (readResult || []).find(s => s.id === serviceId);
|
|
||||||
if (found?.containerId) return found.containerId;
|
if (found?.containerId) return found.containerId;
|
||||||
|
} catch (err) {
|
||||||
|
// Best-effort: a state-manager read failure must not break the bridge.
|
||||||
|
// Surface at debug level so an operator hunting "why didn't auto-restart
|
||||||
|
// fire?" can find it without polluting the info-level event stream.
|
||||||
|
this.log?.debug?.('auto-restart', 'containerId resolve failed', { serviceId, error: err?.message });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (_) { /* best effort */ }
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,14 +214,30 @@ function csrfValidationMiddleware(req, res, next) {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate both values exist
|
// DC-058: differentiate "browser auto-retry" from "real probe" using the
|
||||||
|
// X-CSRF-Token header as a signal. The dashboard JS in status/js/globals.js
|
||||||
|
// secureFetch() pre-fetches /api/v1/csrf-token (which sets the CSRF cookie
|
||||||
|
// via csrfCookieMiddleware) before posting; if the GET raced with container
|
||||||
|
// restart OR the user cleared cookies mid-session, the POST can arrive with
|
||||||
|
// a header but no cookie. secureFetch catches the 403 and auto-retries
|
||||||
|
// with a fresh token (lines 225-238 of globals.js). For these "has header
|
||||||
|
// but no cookie" misses, tag the log line [CSRF-debug] — operators can
|
||||||
|
// grep them out as expected noise. A request with NEITHER cookie NOR
|
||||||
|
// header (curl probe, exploit scanner, broken client) keeps the louder
|
||||||
|
// [CSRF] tag.
|
||||||
if (!cookieNonce) {
|
if (!cookieNonce) {
|
||||||
process.stderr.write(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}\n`);
|
const isLikelyBrowserAutoRetry = !!headerToken;
|
||||||
|
const tag = isLikelyBrowserAutoRetry ? '[CSRF-debug]' : '[CSRF]';
|
||||||
|
process.stderr.write(`${tag} Missing CSRF cookie: ${method} ${req.path} from ${req.ip}` +
|
||||||
|
(isLikelyBrowserAutoRetry ? ' (browser auto-retry — header present, expect self-heal)' : '') + '\n');
|
||||||
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
|
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
|
||||||
message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.'
|
message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cookie present but no header — a real browser POST always sends both, so
|
||||||
|
// header-less is suspicious (curl probe with manual cookie, misconfigured
|
||||||
|
// client). Keep WARN level.
|
||||||
if (!headerToken) {
|
if (!headerToken) {
|
||||||
process.stderr.write(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}\n`);
|
process.stderr.write(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}\n`);
|
||||||
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
|
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
|
||||||
|
|||||||
@@ -62,8 +62,34 @@ function noContent(res) {
|
|||||||
*
|
*
|
||||||
* DC-086: If extras.code is set, it's treated as a machine-readable error code
|
* DC-086: If extras.code is set, it's treated as a machine-readable error code
|
||||||
* (e.g. 'DC-CONT-002'). If message looks like a DC code, it's auto-extracted.
|
* (e.g. 'DC-CONT-002'). If message looks like a DC code, it's auto-extracted.
|
||||||
|
*
|
||||||
|
* DC-062: Validate that `statusCode` is a valid HTTP status (integer in
|
||||||
|
* 100..599) BEFORE calling res.status(). Without this guard, a caller who
|
||||||
|
* passes (res, message, statusCode) instead of (res, statusCode, message)
|
||||||
|
* ends up with res.status(<string>), which throws
|
||||||
|
* RangeError [ERR_HTTP_INVALID_STATUS_CODE] — Express catches that and
|
||||||
|
* writes a 500 with an HTML stack trace to the client, which is the worst
|
||||||
|
* possible failure mode (looks like a server crash, breaks CSRF and
|
||||||
|
* content-type expectations, leaks the stack). Failing fast with a clear
|
||||||
|
* TypeError names the call site early in the request lifecycle.
|
||||||
*/
|
*/
|
||||||
function errorResponse(res, statusCode, message, extras = {}) {
|
function errorResponse(res, statusCode, message, extras = {}) {
|
||||||
|
if (
|
||||||
|
typeof statusCode !== 'number'
|
||||||
|
|| !Number.isFinite(statusCode)
|
||||||
|
|| !Number.isInteger(statusCode)
|
||||||
|
|| statusCode < 100
|
||||||
|
|| statusCode > 599
|
||||||
|
) {
|
||||||
|
throw new TypeError(
|
||||||
|
`errorResponse(res, statusCode, message, extras): statusCode must be an integer HTTP status (100..599); received ${JSON.stringify(statusCode)} (message=${JSON.stringify(message)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof message !== 'string') {
|
||||||
|
throw new TypeError(
|
||||||
|
`errorResponse(res, statusCode, message, extras): message must be a string; received ${typeof message} ${JSON.stringify(message)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
const body = { success: false, error: message, ...extras };
|
const body = { success: false, error: message, ...extras };
|
||||||
// DC-086: surface machine-readable code at top level for client handling
|
// DC-086: surface machine-readable code at top level for client handling
|
||||||
if (extras.code) {
|
if (extras.code) {
|
||||||
|
|||||||
@@ -13,6 +13,31 @@
|
|||||||
*/
|
*/
|
||||||
const { WebSocketServer } = require('ws');
|
const { WebSocketServer } = require('ws');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the `Cookie` header into a plain `{name: value}` map.
|
||||||
|
* WS upgrade requests don't go through Express's cookie-parser, so we
|
||||||
|
* do it by hand here. We deliberately do NOT decode the values — the
|
||||||
|
* session-cookie HMAC verifier reads the raw cookie string verbatim
|
||||||
|
* (`payloadB64.sig` shape), so any decoding (e.g. url-decode) would
|
||||||
|
* corrupt the signature. Single cookie-pair per call, no nesting.
|
||||||
|
*
|
||||||
|
* @param {string|undefined} header - Raw Cookie header value
|
||||||
|
* @returns {Object<string, string>} name → value map (empty string for blanks)
|
||||||
|
*/
|
||||||
|
function parseCookieHeader(header) {
|
||||||
|
const out = {};
|
||||||
|
if (!header) return out;
|
||||||
|
for (const part of header.split(';')) {
|
||||||
|
const idx = part.indexOf('=');
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const name = part.slice(0, idx).trim();
|
||||||
|
if (!name) continue;
|
||||||
|
const value = part.slice(idx + 1).trim();
|
||||||
|
out[name] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
function createDashboardWS(server, deps = {}) {
|
function createDashboardWS(server, deps = {}) {
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
|
||||||
@@ -30,9 +55,52 @@ function createDashboardWS(server, deps = {}) {
|
|||||||
log,
|
log,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
|
// ── Auth verifier (injected by server.js from app.locals.ctx.session) ──
|
||||||
|
// The WS upgrade path bypasses Express middleware, so the global
|
||||||
|
// `totpAuthMiddleware` (which calls `isSessionValid(req)`) never runs.
|
||||||
|
// We accept the SAME verifier here so a valid browser session cookie
|
||||||
|
// grants access and nothing else does.
|
||||||
|
//
|
||||||
|
// The injected verifier receives the raw HTTP upgrade request (an
|
||||||
|
// IncomingMessage with `.headers.cookie`). Production wires
|
||||||
|
// `app.locals.ctx.session.isValid` directly — it accepts the same
|
||||||
|
// shape, parses the Cookie header internally, and runs the HMAC
|
||||||
|
// check. Tests inject a stub.
|
||||||
|
//
|
||||||
|
// DC-061 hardening: prior code only checked that the `dashcaddy_session`
|
||||||
|
// SUBSTRING appeared in the Cookie header. That let an attacker set any
|
||||||
|
// cookie named `dashcaddy_session=garbage` (or include the literal text
|
||||||
|
// in another cookie's value) and bypass auth. The injected verifier
|
||||||
|
// runs HMAC validation, so a present-but-invalid cookie now 401s.
|
||||||
|
const authVerifier = typeof deps.authVerifier === 'function'
|
||||||
|
? deps.authVerifier
|
||||||
|
: (req) => {
|
||||||
|
// Last-resort fallback: presence-only check on a non-empty
|
||||||
|
// session-cookie value. Used only when the caller didn't inject
|
||||||
|
// a real verifier (e.g. tests, unusual boot paths). Production
|
||||||
|
// wires the real one from app.locals.ctx.session.isValid.
|
||||||
|
const parsed = parseCookieHeader(req && req.headers && req.headers.cookie);
|
||||||
|
const raw = parsed.dashcaddy_session || parsed.sid;
|
||||||
|
return typeof raw === 'string' && raw.length > 0;
|
||||||
|
};
|
||||||
|
|
||||||
// Track connected clients and their subscriptions
|
// Track connected clients and their subscriptions
|
||||||
const wsClients = new Set();
|
const wsClients = new Set();
|
||||||
|
|
||||||
|
// Track the listener functions we attach to shared EventEmitters so we
|
||||||
|
// can detach exactly OUR listeners on close() — without disturbing the
|
||||||
|
// SSE route's listeners on the same emitters. DC-061 critical fix:
|
||||||
|
// the previous code called `resourceMonitor.removeAllListeners()` which
|
||||||
|
// silently killed the SSE route's `alert`/`status-check`/etc subscribers
|
||||||
|
// whenever close() ran (hot reload, graceful restart).
|
||||||
|
const emitterListeners = [];
|
||||||
|
|
||||||
|
function attachListener(emitter, event, handler) {
|
||||||
|
if (!emitter || typeof emitter.on !== 'function') return;
|
||||||
|
emitter.on(event, handler);
|
||||||
|
emitterListeners.push({ emitter, event, handler });
|
||||||
|
}
|
||||||
|
|
||||||
function broadcast(event, data) {
|
function broadcast(event, data) {
|
||||||
const msg = JSON.stringify({ type: 'event', event, data });
|
const msg = JSON.stringify({ type: 'event', event, data });
|
||||||
for (const client of wsClients) {
|
for (const client of wsClients) {
|
||||||
@@ -49,13 +117,10 @@ function createDashboardWS(server, deps = {}) {
|
|||||||
|
|
||||||
// ── Wire up EventEmitter listeners (same events as SSE) ──
|
// ── Wire up EventEmitter listeners (same events as SSE) ──
|
||||||
|
|
||||||
if (resourceMonitor) {
|
attachListener(resourceMonitor, 'alert', (data) => broadcast('resource-alert', data));
|
||||||
resourceMonitor.on('alert', (data) => broadcast('resource-alert', data));
|
attachListener(resourceMonitor, 'auto-restart', (data) => broadcast('auto-restart', data));
|
||||||
resourceMonitor.on('auto-restart', (data) => broadcast('auto-restart', data));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (healthChecker) {
|
attachListener(healthChecker, 'status-check', (data) => {
|
||||||
healthChecker.on('status-check', (data) => {
|
|
||||||
broadcast('status-change', {
|
broadcast('status-change', {
|
||||||
serviceId: data.serviceId,
|
serviceId: data.serviceId,
|
||||||
name: data.name,
|
name: data.name,
|
||||||
@@ -64,47 +129,34 @@ function createDashboardWS(server, deps = {}) {
|
|||||||
timestamp: data.timestamp,
|
timestamp: data.timestamp,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
healthChecker.on('incident-created', (data) => broadcast('incident', { type: 'created', ...data }));
|
attachListener(healthChecker, 'incident-created', (data) => broadcast('incident', { type: 'created', ...data }));
|
||||||
healthChecker.on('incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data }));
|
attachListener(healthChecker, 'incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data }));
|
||||||
}
|
|
||||||
|
|
||||||
if (updateManager) {
|
attachListener(updateManager, 'update-available', (data) => broadcast('update-available', data));
|
||||||
updateManager.on('update-available', (data) => broadcast('update-available', data));
|
attachListener(updateManager, 'update-start', (data) => broadcast('update-start', data));
|
||||||
updateManager.on('update-start', (data) => broadcast('update-start', data));
|
attachListener(updateManager, 'update-complete', (data) => broadcast('update-complete', data));
|
||||||
updateManager.on('update-complete', (data) => broadcast('update-complete', data));
|
attachListener(updateManager, 'update-failed', (data) => broadcast('update-failed', data));
|
||||||
updateManager.on('update-failed', (data) => broadcast('update-failed', data));
|
attachListener(updateManager, 'auto-update-start', (data) => broadcast('auto-update-start', data));
|
||||||
updateManager.on('auto-update-start', (data) => broadcast('auto-update-start', data));
|
attachListener(updateManager, 'auto-update-complete', (data) => broadcast('auto-update-complete', data));
|
||||||
updateManager.on('auto-update-complete', (data) => broadcast('auto-update-complete', data));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dependencyManager) {
|
attachListener(dependencyManager, 'dependency-restart-start', (data) => broadcast('dependency-restart-start', data));
|
||||||
dependencyManager.on('dependency-restart-start', (data) => broadcast('dependency-restart-start', data));
|
attachListener(dependencyManager, 'dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data));
|
||||||
dependencyManager.on('dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data));
|
attachListener(dependencyManager, 'dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data));
|
||||||
dependencyManager.on('dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data));
|
attachListener(dependencyManager, 'dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data));
|
||||||
dependencyManager.on('dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (autoRestartManager) {
|
attachListener(autoRestartManager, 'auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
|
||||||
autoRestartManager.on('auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
|
attachListener(autoRestartManager, 'auto-restart-success', (data) => broadcast('auto-restart-success', data));
|
||||||
autoRestartManager.on('auto-restart-success', (data) => broadcast('auto-restart-success', data));
|
attachListener(autoRestartManager, 'auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
|
||||||
autoRestartManager.on('auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
|
attachListener(autoRestartManager, 'auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
|
||||||
autoRestartManager.on('auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (driftDetector) {
|
attachListener(driftDetector, 'drift-detected', (data) => broadcast('drift-detected', data));
|
||||||
driftDetector.on('drift-detected', (data) => broadcast('drift-detected', data));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sslMonitor) {
|
attachListener(sslMonitor, 'cert-expiring', (data) => broadcast('cert-expiring', data));
|
||||||
sslMonitor.on('cert-expiring', (data) => broadcast('cert-expiring', data));
|
attachListener(sslMonitor, 'cert-critical', (data) => broadcast('cert-critical', data));
|
||||||
sslMonitor.on('cert-critical', (data) => broadcast('cert-critical', data));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dnsPropagationChecker) {
|
attachListener(dnsPropagationChecker, 'propagation-check', (data) => broadcast('dns-propagation-check', data));
|
||||||
dnsPropagationChecker.on('propagation-check', (data) => broadcast('dns-propagation-check', data));
|
attachListener(dnsPropagationChecker, 'propagation-complete', (data) => broadcast('dns-propagation-complete', data));
|
||||||
dnsPropagationChecker.on('propagation-complete', (data) => broadcast('dns-propagation-complete', data));
|
attachListener(dnsPropagationChecker, 'propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
|
||||||
dnsPropagationChecker.on('propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Handle upgrade requests at /api/v1/ws ──
|
// ── Handle upgrade requests at /api/v1/ws ──
|
||||||
|
|
||||||
@@ -116,16 +168,21 @@ function createDashboardWS(server, deps = {}) {
|
|||||||
return; // Let other upgrade handlers deal with it
|
return; // Let other upgrade handlers deal with it
|
||||||
}
|
}
|
||||||
|
|
||||||
// DC-076: Auth check — extract session/token from query params or cookies
|
// DC-061 auth gate: WS upgrade bypasses Express middleware, so we
|
||||||
// The SSE endpoint is behind auth middleware; WS needs the same gate.
|
// must validate the session here. We accept ONLY a valid signed
|
||||||
// We validate the session cookie or API token before accepting the upgrade.
|
// session cookie (no `token` query-param bypass — that was the
|
||||||
const cookies = (request.headers.cookie || '');
|
// previous footgun, which granted access to any random 11+ char
|
||||||
const hasSession = cookies.includes('dashcaddy_session') || cookies.includes('sid');
|
// string in production). The verifier is injected from
|
||||||
const token = url.searchParams.get('token');
|
// app.locals.ctx.session.isValid in production.
|
||||||
const hasToken = token && token.length > 10;
|
const ok = authVerifier(request);
|
||||||
|
|
||||||
if (!hasSession && !hasToken && process.env.NODE_ENV === 'production') {
|
if (!ok) {
|
||||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
const ip = (request.socket && request.socket.remoteAddress) || 'unknown';
|
||||||
|
if (log && log.warn) {
|
||||||
|
log.warn('websocket', 'WS upgrade rejected — no valid session', { ip, path: url.pathname });
|
||||||
|
}
|
||||||
|
// 401 + Connection: close so the client doesn't retry.
|
||||||
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
||||||
socket.destroy();
|
socket.destroy();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -170,6 +227,17 @@ function createDashboardWS(server, deps = {}) {
|
|||||||
ws.on('pong', () => { ws.isAlive = true; });
|
ws.on('pong', () => { ws.isAlive = true; });
|
||||||
|
|
||||||
ws.on('message', (raw) => {
|
ws.on('message', (raw) => {
|
||||||
|
// Cap message size at 16 KB — defense-in-depth against a malicious
|
||||||
|
// peer that exploits ws's message framing to flood our parser.
|
||||||
|
// The `ws` library already enforces this via its constructor option,
|
||||||
|
// but a second guard at the handler level catches any future
|
||||||
|
// regressions (e.g. someone passing `maxPayload` differently).
|
||||||
|
if (raw.length > 16 * 1024) {
|
||||||
|
ws.send(JSON.stringify({ type: 'error', error: 'Message too large' }));
|
||||||
|
try { ws.close(1009, 'Message too large'); } catch { /* already closed */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let msg;
|
let msg;
|
||||||
try {
|
try {
|
||||||
msg = JSON.parse(raw.toString());
|
msg = JSON.parse(raw.toString());
|
||||||
@@ -248,12 +316,21 @@ function createDashboardWS(server, deps = {}) {
|
|||||||
}
|
}
|
||||||
wsClients.clear();
|
wsClients.clear();
|
||||||
wss.close();
|
wss.close();
|
||||||
// Remove all listeners from the event emitters to prevent leaks on restart
|
// DC-061: detach ONLY the listeners we attached. Previously the
|
||||||
if (resourceMonitor) resourceMonitor.removeAllListeners();
|
// module called `resourceMonitor.removeAllListeners()` (and same
|
||||||
if (healthChecker) healthChecker.removeAllListeners();
|
// for healthChecker / updateManager), which silently wiped the
|
||||||
if (updateManager) updateManager.removeAllListeners();
|
// SSE route's listeners on the same shared emitters — the SSE
|
||||||
|
// stream went dead the moment close() ran (hot reload, restart).
|
||||||
|
for (const { emitter, event, handler } of emitterListeners) {
|
||||||
|
if (emitter && typeof emitter.removeListener === 'function') {
|
||||||
|
emitter.removeListener(event, handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emitterListeners.length = 0;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = createDashboardWS;
|
module.exports = createDashboardWS;
|
||||||
|
module.exports.createDashboardWS = createDashboardWS;
|
||||||
|
module.exports.parseCookieHeader = parseCookieHeader;
|
||||||
|
|||||||
Reference in New Issue
Block a user