Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1328cfda6b | ||
|
|
df55677bd1 | ||
|
|
ddbea0a040 | ||
|
|
1d1cd5c95e | ||
|
|
88f1d4a414 | ||
|
|
dd1110ef52 | ||
|
|
6732a1e1df | ||
|
|
ea96abe95a | ||
|
|
3ccf66754a | ||
|
|
c71b794ccc |
@@ -237,4 +237,49 @@ describe('DC-085: link-first admin invites', () => {
|
|||||||
.send({ email: 'a@x.com', ttlHours: 1 });
|
.send({ email: 'a@x.com', ttlHours: 1 });
|
||||||
expect(res.body.shareText).toContain('expires in 1h');
|
expect(res.body.shareText).toContain('expires in 1h');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('DC-089: SMTP-unconfigured warn log masks the invite email (no raw PII)', async () => {
|
||||||
|
mockEmailSender.isConfigured.mockReturnValueOnce(false);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'friend@example.com', role: 'operator', sendEmail: true });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.deliveredVia).toBe('failed');
|
||||||
|
const warn = logCalls.find(c =>
|
||||||
|
c.level === 'warn' && c.topic === 'auth-invite-send'
|
||||||
|
);
|
||||||
|
expect(warn).toBeDefined();
|
||||||
|
// The raw address must not appear; the masked form must.
|
||||||
|
expect(JSON.stringify(warn.meta)).not.toContain('friend@example.com');
|
||||||
|
expect(warn.meta.email).toBe('fr****@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-089: invite-accepted info log masks the created user email (no raw PII)', async () => {
|
||||||
|
// Pre-authorize the email (POST /admin/users) so userStore.login doesn't
|
||||||
|
// reject with not_authorized — bootstrap already happened in beforeEach.
|
||||||
|
const preauth = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/users')
|
||||||
|
.send({ email: 'newfriend@example.com' });
|
||||||
|
expect(preauth.status).toBe(200);
|
||||||
|
|
||||||
|
const issue = await request(app)
|
||||||
|
.post('/api/v1/auth/admin/invites')
|
||||||
|
.send({ email: 'newfriend@example.com', role: 'viewer' });
|
||||||
|
expect(issue.status).toBe(200);
|
||||||
|
const token = issue.body.acceptUrl.match(/invites\/([^/]+)\/accept/)[1];
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/v1/auth/invites/${token}/accept`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const info = logCalls.find(c =>
|
||||||
|
c.level === 'info' && c.msg === 'invite accepted, user created'
|
||||||
|
);
|
||||||
|
expect(info).toBeDefined();
|
||||||
|
expect(JSON.stringify(info.meta)).not.toContain('newfriend@example.com');
|
||||||
|
expect(info.meta.email).toBe('ne****@example.com');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for config-schema.js KNOWN_KEYS — DC-091.
|
||||||
|
*
|
||||||
|
* Bug: license-manager.js persists config.licenseBackup (activation
|
||||||
|
* restore-on-restart) and src/config/migrations.js stamps config._version,
|
||||||
|
* but neither key was in KNOWN_KEYS — so every startup logged
|
||||||
|
* `Unknown config key "licenseBackup" / "_version" — possible typo?`
|
||||||
|
* false positives (verified in live dashcaddy-api container logs,
|
||||||
|
* 2026-08-22T23:53:54Z restart).
|
||||||
|
*
|
||||||
|
* These tests pin: (1) the live production config key set validates with
|
||||||
|
* zero unknown-key warnings, (2) genuine typos still warn, (3) the schema
|
||||||
|
* stays in sync with the first-party writer keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { validateConfig } = require('../src/utilities/config-schema');
|
||||||
|
|
||||||
|
describe('config-schema KNOWN_KEYS vs first-party writers (DC-091)', () => {
|
||||||
|
// Exact key set of the live production config.json (DNS2, verified
|
||||||
|
// 2026-08-23). If a new key appears here, teach KNOWN_KEYS about it —
|
||||||
|
// or fix the writer if it's a typo.
|
||||||
|
const LIVE_CONFIG_KEYS = [
|
||||||
|
'_version', 'configurationType', 'customFavicon', 'customLogo',
|
||||||
|
'dashboardHost', 'dashboardTitle', 'dns', 'dnsServers', 'language',
|
||||||
|
'license', 'licenseBackup', 'logoPosition', 'pylon', 'setupComplete',
|
||||||
|
'timestamp', 'tld', 'updatedAt'
|
||||||
|
];
|
||||||
|
|
||||||
|
test('live production config key set produces zero unknown-key warnings', () => {
|
||||||
|
const config = {};
|
||||||
|
for (const key of LIVE_CONFIG_KEYS) {
|
||||||
|
// Minimal valid-ish values; validateConfig only cares about shape
|
||||||
|
// for these keys, and unknown-key detection is the target here.
|
||||||
|
config[key] = key === '_version' ? 2 : (key === 'dnsServers' ? {} : 'x');
|
||||||
|
}
|
||||||
|
const result = validateConfig(config);
|
||||||
|
const unknownWarnings = result.warnings.filter((w) => w.includes('Unknown config key'));
|
||||||
|
expect(unknownWarnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('licenseBackup and _version (first-party writer keys) do not warn', () => {
|
||||||
|
const result = validateConfig({ licenseBackup: { code: 'DC-...' }, _version: 2 });
|
||||||
|
expect(result.warnings).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('genuine typos still warn (guard against over-allowing)', () => {
|
||||||
|
const result = validateConfig({ dashboadTitle: 'typo' });
|
||||||
|
expect(result.warnings).toEqual([
|
||||||
|
'Unknown config key "dashboadTitle" — possible typo?'
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('KNOWN_KEYS stays in sync with license-manager writer keys', () => {
|
||||||
|
// license-manager writes config.licenseBackup and config.license — both
|
||||||
|
// must be recognized. We assert via validateConfig (public surface)
|
||||||
|
// rather than importing the private KNOWN_KEYS array.
|
||||||
|
const result = validateConfig({ license: { code: 'DC-...' }, licenseBackup: { code: 'DC-...' } });
|
||||||
|
expect(result.warnings.filter((w) => w.includes('Unknown config key'))).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('config-schema sync guard: migrations writer', () => {
|
||||||
|
test('_version is recognized at every migration version value', () => {
|
||||||
|
// migrations.js bumps _version 0→1→2; the key itself must never warn.
|
||||||
|
for (const v of [0, 1, 2, 99]) {
|
||||||
|
const result = validateConfig({ _version: v });
|
||||||
|
expect(result.warnings).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/**
|
||||||
|
* Tests for DC-090: outage incidents follow the DISPLAYED (post-hysteresis)
|
||||||
|
* status — the same signal that flips the dashboard badge.
|
||||||
|
*
|
||||||
|
* - A single raw "down" blip that hysteresis suppresses opens NO outage
|
||||||
|
* incident (the DC-089-noted raw-transition bug).
|
||||||
|
* - A suppressed blip does not resolve a real open outage (UP_THRESHOLD=2).
|
||||||
|
* - DOWN_THRESHOLD consecutive downs open exactly ONE outage incident.
|
||||||
|
* - The incident payload carries the displayed snapshot, not the raw probe.
|
||||||
|
* - Direct callers without hysteresis state keep legacy raw semantics.
|
||||||
|
*
|
||||||
|
* The probe() helper replicates checkService's exact call order: capture the
|
||||||
|
* pre-probe raw + displayed state, recordStatus (updates both maps), then
|
||||||
|
* checkForIncidents with both previous states.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Use an isolated data dir so test history doesn't pollute the real one.
|
||||||
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-incpar-'));
|
||||||
|
process.env.HEALTH_DATA_DIR = tmpDir;
|
||||||
|
process.env.HEALTH_CONFIG_FILE = path.join(tmpDir, 'health-config.json');
|
||||||
|
process.env.HEALTH_HISTORY_FILE = path.join(tmpDir, 'health-history.json');
|
||||||
|
|
||||||
|
// Module exports a singleton instance, not a class. Reset per-test state by
|
||||||
|
// replacing the relevant maps on the singleton in beforeEach.
|
||||||
|
const healthCheckerSingleton = require('../src/monitoring/health-checker');
|
||||||
|
const originalDownThreshold = process.env.HEALTH_DOWN_THRESHOLD;
|
||||||
|
const originalUpThreshold = process.env.HEALTH_UP_THRESHOLD;
|
||||||
|
|
||||||
|
function restoreEnv(name, value) {
|
||||||
|
if (value === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUp(serviceId = 'svc1') {
|
||||||
|
return {
|
||||||
|
serviceId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'up',
|
||||||
|
responseTime: 50,
|
||||||
|
statusCode: 200,
|
||||||
|
message: 'Service is healthy',
|
||||||
|
details: { headers: {}, bodyLength: 12 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDown(serviceId = 'svc1') {
|
||||||
|
return {
|
||||||
|
serviceId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
status: 'down',
|
||||||
|
responseTime: 50,
|
||||||
|
statusCode: 500,
|
||||||
|
message: 'fail',
|
||||||
|
details: { headers: {}, bodyLength: 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DC-090: outage incidents follow the displayed (hysteresis) status', () => {
|
||||||
|
let hc;
|
||||||
|
let incidentCreatedSpy;
|
||||||
|
let incidentResolvedSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
healthCheckerSingleton.displayedStatus = new Map();
|
||||||
|
healthCheckerSingleton.consecutiveSinceChange = new Map();
|
||||||
|
healthCheckerSingleton.currentStatus = new Map();
|
||||||
|
healthCheckerSingleton.history = {};
|
||||||
|
healthCheckerSingleton.incidents = [];
|
||||||
|
healthCheckerSingleton.removeAllListeners('incident-created');
|
||||||
|
healthCheckerSingleton.removeAllListeners('incident-resolved');
|
||||||
|
incidentCreatedSpy = jest.fn();
|
||||||
|
incidentResolvedSpy = jest.fn();
|
||||||
|
healthCheckerSingleton.on('incident-created', incidentCreatedSpy);
|
||||||
|
healthCheckerSingleton.on('incident-resolved', incidentResolvedSpy);
|
||||||
|
hc = healthCheckerSingleton;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreEnv('HEALTH_DOWN_THRESHOLD', originalDownThreshold);
|
||||||
|
restoreEnv('HEALTH_UP_THRESHOLD', originalUpThreshold);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Replicates checkService's record+incident sequence for one raw probe.
|
||||||
|
function probe(status, config = {}) {
|
||||||
|
const previousStatus = hc.currentStatus.get(status.serviceId);
|
||||||
|
const previousDisplayed = hc.displayedStatus.get(status.serviceId) || null;
|
||||||
|
hc.recordStatus(status.serviceId, status);
|
||||||
|
hc.checkForIncidents(status.serviceId, status, config, previousStatus, previousDisplayed);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a single down blip between two ups opens NO outage incident', () => {
|
||||||
|
probe(makeUp()); // baseline: displayed up
|
||||||
|
probe(makeDown()); // blip — hysteresis keeps displayed up
|
||||||
|
probe(makeUp()); // recovered
|
||||||
|
expect(hc.incidents).toHaveLength(0);
|
||||||
|
expect(incidentCreatedSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DOWN_THRESHOLD consecutive downs open exactly one outage incident (critical)', () => {
|
||||||
|
probe(makeUp());
|
||||||
|
probe(makeDown()); // counter=1, displayed still up
|
||||||
|
probe(makeDown()); // counter=2 → displayed flips down → incident
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
const incident = hc.incidents[0];
|
||||||
|
expect(incident.type).toBe('outage');
|
||||||
|
expect(incident.severity).toBe('critical');
|
||||||
|
expect(incident.status).toBe('open');
|
||||||
|
expect(incidentCreatedSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
probe(makeDown()); // still down — no new transition, no second incident
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
expect(incident.occurrences).toBe(1); // occurrences count displayed flips, not raw probes
|
||||||
|
expect(incidentCreatedSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the outage incident payload carries the displayed snapshot, not the raw blip', () => {
|
||||||
|
probe(makeUp());
|
||||||
|
const blip = makeDown();
|
||||||
|
blip.statusCode = 599;
|
||||||
|
probe(blip); // suppressed blip — must not appear in any incident
|
||||||
|
probe(makeDown()); // flip
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
// The incident's details snapshot is the probe that FLIPPED the displayed
|
||||||
|
// state (the second down), not the earlier suppressed blip.
|
||||||
|
expect(hc.incidents[0].details.statusCode).not.toBe(599);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a suppressed up blip does not resolve a real open outage (UP_THRESHOLD=2)', () => {
|
||||||
|
process.env.HEALTH_UP_THRESHOLD = '2';
|
||||||
|
jest.resetModules();
|
||||||
|
const hc2 = require('../src/monitoring/health-checker');
|
||||||
|
hc2.displayedStatus = new Map();
|
||||||
|
hc2.consecutiveSinceChange = new Map();
|
||||||
|
hc2.currentStatus = new Map();
|
||||||
|
hc2.history = {};
|
||||||
|
hc2.incidents = [];
|
||||||
|
hc2.removeAllListeners('incident-created');
|
||||||
|
hc2.removeAllListeners('incident-resolved');
|
||||||
|
|
||||||
|
const p2 = (status) => {
|
||||||
|
const prevRaw = hc2.currentStatus.get(status.serviceId);
|
||||||
|
const prevDisp = hc2.displayedStatus.get(status.serviceId) || null;
|
||||||
|
hc2.recordStatus(status.serviceId, status);
|
||||||
|
hc2.checkForIncidents(status.serviceId, status, {}, prevRaw, prevDisp);
|
||||||
|
};
|
||||||
|
|
||||||
|
p2(makeUp());
|
||||||
|
p2(makeDown());
|
||||||
|
p2(makeDown()); // displayed down → outage opens
|
||||||
|
expect(hc2.incidents).toHaveLength(1);
|
||||||
|
expect(hc2.incidents[0].status).toBe('open');
|
||||||
|
|
||||||
|
p2(makeUp()); // counter=1 < UP_THRESHOLD=2 → displayed still down
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('down');
|
||||||
|
expect(hc2.incidents[0].status).toBe('open'); // NOT resolved by the blip
|
||||||
|
|
||||||
|
p2(makeUp()); // counter=2 → displayed up → incident resolves
|
||||||
|
expect(hc2.displayedStatus.get('svc1').status).toBe('up');
|
||||||
|
expect(hc2.incidents[0].status).toBe('resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('legacy direct callers (no displayed state) keep raw transition semantics', () => {
|
||||||
|
hc.currentStatus.set('svc1', { status: 'up' });
|
||||||
|
const status = { status: 'down', timestamp: new Date().toISOString(), responseTime: 100 };
|
||||||
|
hc.checkForIncidents('svc1', status, {}); // 4-arg call, no previousDisplayed
|
||||||
|
expect(hc.incidents).toHaveLength(1);
|
||||||
|
expect(hc.incidents[0].type).toBe('outage');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slow-response detection still fires per-probe regardless of hysteresis', () => {
|
||||||
|
const slowUp = makeUp();
|
||||||
|
slowUp.responseTime = 6000;
|
||||||
|
probe(slowUp, { slowResponseThreshold: 5000 });
|
||||||
|
expect(hc.incidents.some(i => i.type === 'slow-response')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -204,15 +204,22 @@ describe('HealthChecker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('opens and resolves an outage incident across real checkService transitions', async () => {
|
it('opens and resolves an outage incident across real checkService transitions', async () => {
|
||||||
|
// DC-090: incidents follow the DISPLAYED (post-hysteresis) status.
|
||||||
|
// DOWN_THRESHOLD defaults to 2, so it takes two consecutive failed
|
||||||
|
// probes to flip displayed down and open the outage; one up probe
|
||||||
|
// (UP_THRESHOLD=1) resolves it.
|
||||||
healthChecker._doRequest = jest.fn()
|
healthChecker._doRequest = jest.fn()
|
||||||
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} })
|
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} })
|
||||||
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
|
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
|
||||||
|
.mockResolvedValueOnce({ healthy: false, statusCode: 500, message: 'down', details: {} })
|
||||||
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} });
|
.mockResolvedValueOnce({ healthy: true, statusCode: 200, message: 'ok', details: {} });
|
||||||
|
|
||||||
const config = { url: 'http://test.local' };
|
const config = { url: 'http://test.local' };
|
||||||
await healthChecker.checkService('svc1', config);
|
await healthChecker.checkService('svc1', config);
|
||||||
await healthChecker.checkService('svc1', config);
|
await healthChecker.checkService('svc1', config);
|
||||||
|
expect(healthChecker.incidents).toHaveLength(0); // one down alone: suppressed blip
|
||||||
|
|
||||||
|
await healthChecker.checkService('svc1', config); // second down flips displayed → open
|
||||||
expect(healthChecker.incidents).toHaveLength(1);
|
expect(healthChecker.incidents).toHaveLength(1);
|
||||||
expect(healthChecker.incidents[0]).toMatchObject({
|
expect(healthChecker.incidents[0]).toMatchObject({
|
||||||
serviceId: 'svc1',
|
serviceId: 'svc1',
|
||||||
@@ -220,7 +227,7 @@ describe('HealthChecker', () => {
|
|||||||
status: 'open'
|
status: 'open'
|
||||||
});
|
});
|
||||||
|
|
||||||
await healthChecker.checkService('svc1', config);
|
await healthChecker.checkService('svc1', config); // up resolves
|
||||||
expect(healthChecker.incidents[0].status).toBe('resolved');
|
expect(healthChecker.incidents[0].status).toBe('resolved');
|
||||||
expect(healthChecker.incidents[0].resolvedAt).toBeDefined();
|
expect(healthChecker.incidents[0].resolvedAt).toBeDefined();
|
||||||
});
|
});
|
||||||
@@ -591,6 +598,113 @@ describe('HealthChecker', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DC-088: removeService generation tombstones + incident closure', () => {
|
||||||
|
it('does not leak a serviceGenerations entry and records a tombstone', () => {
|
||||||
|
healthChecker.configureService('svc1', { url: 'http://test.local' });
|
||||||
|
expect(healthChecker.serviceGenerations.has('svc1')).toBe(true);
|
||||||
|
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
|
||||||
|
expect(healthChecker.serviceGenerations.has('svc1')).toBe(false);
|
||||||
|
const tomb = healthChecker.removedGenerations.get('svc1');
|
||||||
|
expect(tomb).toBeDefined();
|
||||||
|
expect(tomb.generation).toBeGreaterThan(0);
|
||||||
|
expect(tomb.removedAt).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-added service gets a strictly higher generation (no ABA)', () => {
|
||||||
|
healthChecker.configureService('svc1', { url: 'http://test.local' });
|
||||||
|
const gen1 = healthChecker.serviceGenerations.get('svc1');
|
||||||
|
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
healthChecker.configureService('svc1', { url: 'http://test.local/v2' });
|
||||||
|
|
||||||
|
const gen2 = healthChecker.serviceGenerations.get('svc1');
|
||||||
|
expect(gen2).toBeGreaterThan(gen1);
|
||||||
|
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes open incidents for the removed service as resolved', () => {
|
||||||
|
healthChecker.saveConfig = jest.fn();
|
||||||
|
healthChecker.incidents.push({
|
||||||
|
id: 'incident-test-1',
|
||||||
|
serviceId: 'svc1',
|
||||||
|
type: 'outage',
|
||||||
|
status: 'open',
|
||||||
|
createdAt: new Date(Date.now() - 60_000).toISOString()
|
||||||
|
});
|
||||||
|
healthChecker.incidents.push({
|
||||||
|
id: 'incident-other',
|
||||||
|
serviceId: 'svc2',
|
||||||
|
type: 'outage',
|
||||||
|
status: 'open',
|
||||||
|
createdAt: new Date(Date.now() - 60_000).toISOString()
|
||||||
|
});
|
||||||
|
const resolvedSpy = jest.fn();
|
||||||
|
healthChecker.on('incident-resolved', resolvedSpy);
|
||||||
|
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
|
||||||
|
const closed = healthChecker.incidents.find(i => i.id === 'incident-test-1');
|
||||||
|
expect(closed.status).toBe('resolved');
|
||||||
|
expect(closed.resolvedBy).toBe('service-removed');
|
||||||
|
expect(closed.resolvedAt).toBeDefined();
|
||||||
|
expect(closed.duration).toBeGreaterThan(0);
|
||||||
|
expect(healthChecker.incidents.find(i => i.id === 'incident-other').status).toBe('open');
|
||||||
|
expect(resolvedSpy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('in-flight probe captured before removal is discarded via tombstone', async () => {
|
||||||
|
let resolveProbe;
|
||||||
|
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
||||||
|
healthChecker._doRequest = jest.fn(() => new Promise(resolve => {
|
||||||
|
resolveProbe = resolve;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
||||||
|
healthChecker.saveConfig = jest.fn();
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
resolveProbe({ healthy: true, statusCode: 200, message: 'late', details: {} });
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a rejected in-flight probe after removal does not re-create failure state', async () => {
|
||||||
|
let rejectProbe;
|
||||||
|
healthChecker.config.services.svc1 = { url: 'http://test.local' };
|
||||||
|
healthChecker._doRequest = jest.fn(() => new Promise((resolve, reject) => {
|
||||||
|
rejectProbe = reject;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pending = healthChecker.checkService('svc1', healthChecker.config.services.svc1);
|
||||||
|
healthChecker.saveConfig = jest.fn();
|
||||||
|
healthChecker.removeService('svc1');
|
||||||
|
rejectProbe(new Error('late failure'));
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
expect(healthChecker.consecutiveFailures.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.currentStatus.has('svc1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sweeps expired tombstones in cleanupHistory', () => {
|
||||||
|
healthChecker.removedGenerations.set('svc1', {
|
||||||
|
generation: 1,
|
||||||
|
removedAt: Date.now() - 60 * 60 * 1000 // 1h ago, TTL default 10m
|
||||||
|
});
|
||||||
|
healthChecker.removedGenerations.set('svc2', {
|
||||||
|
generation: 2,
|
||||||
|
removedAt: Date.now() // fresh
|
||||||
|
});
|
||||||
|
|
||||||
|
healthChecker.cleanupHistory();
|
||||||
|
|
||||||
|
expect(healthChecker.removedGenerations.has('svc1')).toBe(false);
|
||||||
|
expect(healthChecker.removedGenerations.has('svc2')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('cleanupHistory', () => {
|
describe('cleanupHistory', () => {
|
||||||
it('removes entries older than retention period', () => {
|
it('removes entries older than retention period', () => {
|
||||||
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago
|
const old = new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(); // 35 days ago
|
||||||
|
|||||||
@@ -214,4 +214,99 @@ describe('NotificationManager', () => {
|
|||||||
nm.stopHealthDaemon();
|
nm.stopHealthDaemon();
|
||||||
expect(nm.healthDaemonInterval).toBeNull();
|
expect(nm.healthDaemonInterval).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── DC-092: event alias folding + legacy config canonicalization ──────────
|
||||||
|
|
||||||
|
test('DC-092: send() folds camelCase aliases onto canonical kebab keys', async () => {
|
||||||
|
// deploymentSuccess (emitted by routes/apps/deploy.js) previously hit a
|
||||||
|
// gate miss (no such key in events) and the notification was dropped.
|
||||||
|
const result = await nm.send('deploymentSuccess', { text: 'deployed' });
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(nm.getHistory()[0].event).toBe('deploy-success');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-092: send() accepts the canonical kebab spelling too', async () => {
|
||||||
|
const result = await nm.send('deploy-success', { text: 'deployed' });
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(nm.getHistory()[0].event).toBe('deploy-success');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DC-092: send('test') bypasses the events gate (Test button works)", async () => {
|
||||||
|
const result = await nm.send('test', { text: 'Test Notification' });
|
||||||
|
// No providers are enabled in the default config, so results is empty —
|
||||||
|
// but the gate must NOT return 'Event test not enabled' like it used to.
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(nm.getHistory()[0].event).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-092: send() still gates unknown and disabled events', async () => {
|
||||||
|
const unknown = await nm.send('some-unknown-event', { text: 'x' });
|
||||||
|
expect(unknown.success).toBe(false);
|
||||||
|
expect(unknown.error).toMatch(/not enabled/i);
|
||||||
|
|
||||||
|
nm.config.events['container-down'] = false;
|
||||||
|
const disabled = await nm.send('container-down', { text: 'x' });
|
||||||
|
expect(disabled.success).toBe(false);
|
||||||
|
expect(disabled.error).toMatch(/not enabled/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-092: DEFAULT_CONFIG includes deploy/auto-restart events', () => {
|
||||||
|
// Regression pin: these were absent entirely, so deploy notifications
|
||||||
|
// were dropped for every install regardless of UI toggles.
|
||||||
|
expect(nm.config.events['deploy-success']).toBe(true);
|
||||||
|
expect(nm.config.events['deploy-failed']).toBe(true);
|
||||||
|
expect(nm.config.events['auto-restart']).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-092: legacy config with user/pass and camelCase events canonicalizes on load', () => {
|
||||||
|
fs.existsSync.mockReturnValue(true);
|
||||||
|
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
providers: {
|
||||||
|
email: {
|
||||||
|
enabled: true,
|
||||||
|
host: 'smtp.test',
|
||||||
|
port: 465,
|
||||||
|
secure: 'false', // legacy string — must normalize to boolean false
|
||||||
|
to: 'me@test',
|
||||||
|
from: 'from@test',
|
||||||
|
user: 'legacy-user',
|
||||||
|
pass: 'legacy-pass',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
containerDown: false,
|
||||||
|
deploymentSuccess: false,
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
const loaded = new NotificationManager({
|
||||||
|
NOTIFICATIONS_FILE: NOTIF_FILE,
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
});
|
||||||
|
const email = loaded.getConfig().providers.email;
|
||||||
|
expect(email.username).toBe('legacy-user');
|
||||||
|
expect(email.password).toBe('legacy-pass');
|
||||||
|
expect(email.user).toBeUndefined();
|
||||||
|
expect(email.pass).toBeUndefined();
|
||||||
|
expect(email.secure).toBe(false);
|
||||||
|
const events = loaded.getConfig().events;
|
||||||
|
expect(events['container-down']).toBe(false);
|
||||||
|
expect(events['deploy-success']).toBe(false);
|
||||||
|
expect(events.containerDown).toBeUndefined();
|
||||||
|
expect(events.deploymentSuccess).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DC-092: canonical keys win when both spellings exist in a legacy file', () => {
|
||||||
|
fs.existsSync.mockReturnValue(true);
|
||||||
|
fs.readFileSync.mockReturnValue(JSON.stringify({
|
||||||
|
providers: { email: { user: 'legacy', username: 'canonical' } },
|
||||||
|
events: { containerDown: false, 'container-down': true },
|
||||||
|
}));
|
||||||
|
const loaded = new NotificationManager({
|
||||||
|
NOTIFICATIONS_FILE: NOTIF_FILE,
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
});
|
||||||
|
expect(loaded.getConfig().providers.email.username).toBe('canonical');
|
||||||
|
expect(loaded.getConfig().events['container-down']).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/**
|
||||||
|
* DC-092: notifications config contract tests (route level).
|
||||||
|
*
|
||||||
|
* The settings UI and the backend drifted apart in three ways, all of which
|
||||||
|
* made user-facing features silently dead:
|
||||||
|
* 1. UI sent email.user/email.pass; backend read username/password →
|
||||||
|
* SMTP auth never applied for UI-saved configs.
|
||||||
|
* 2. UI sent camelCase event keys (containerDown); the send() gate read
|
||||||
|
* kebab-case keys (container-down) → event toggles were cosmetic.
|
||||||
|
* 3. deploy-success/deploy-failed/auto-restart were missing from DEFAULT
|
||||||
|
* events → deploy + auto-restart notifications always dropped, and
|
||||||
|
* 'test' was gated too → the Test button was a no-op.
|
||||||
|
* 4. Non-boolean enabled/secure values (string "false") persisted as-is and
|
||||||
|
* coerced truthy (!!secure) — silently forcing TLS.
|
||||||
|
* 5. UI password field roundtrip: GET /config omitted port/secure/to/
|
||||||
|
* username, and an empty password on save clobbered the stored one.
|
||||||
|
*
|
||||||
|
* These tests pin the FIXED contract: alias normalization, strict booleans,
|
||||||
|
* event-key folding, non-destructive credential merge, redacted GET fields.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
// Stub notification manager: in-memory config object, real merge semantics
|
||||||
|
// are exercised through the route; manager-level canonicalization has its
|
||||||
|
// own tests in notification-manager.test.js.
|
||||||
|
function makeStubNotification(initial) {
|
||||||
|
const nm = {
|
||||||
|
config: initial,
|
||||||
|
getConfig() { return this.config; },
|
||||||
|
async saveConfig() { this.saved = JSON.parse(JSON.stringify(this.config)); return true; },
|
||||||
|
startHealthDaemon: jest.fn(),
|
||||||
|
stopHealthDaemon: jest.fn(),
|
||||||
|
};
|
||||||
|
return nm;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildApp(notification) {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
const notificationRoutes = require('../../routes/notifications');
|
||||||
|
app.use('/api/v1/notifications', notificationRoutes({
|
||||||
|
notification,
|
||||||
|
asyncHandler: (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next),
|
||||||
|
ok: (res, data) => res.json({ success: true, ...data }),
|
||||||
|
}));
|
||||||
|
// Inline error handler (same pattern as sites-dc074.routes.test.js): maps
|
||||||
|
// AppError.statusCode to the HTTP status and surfaces err.message.
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
const status = err.statusCode || 500;
|
||||||
|
res.status(status).json({
|
||||||
|
error: err.message || 'Internal Server Error',
|
||||||
|
code: err.code || null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
enabled: true,
|
||||||
|
providers: {
|
||||||
|
discord: { enabled: false, webhookUrl: '' },
|
||||||
|
telegram: { enabled: false, botToken: '', chatId: '' },
|
||||||
|
ntfy: { enabled: false, topic: '', serverUrl: 'https://ntfy.sh' },
|
||||||
|
email: { enabled: false, host: '', port: 587, to: '', from: '', username: '', password: '' },
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
'container-down': true,
|
||||||
|
'container-up': false,
|
||||||
|
'alert': true,
|
||||||
|
'backup-complete': true,
|
||||||
|
'backup-failed': true,
|
||||||
|
'update-available': true,
|
||||||
|
'deploy-success': true,
|
||||||
|
'deploy-failed': true,
|
||||||
|
'auto-restart': true,
|
||||||
|
},
|
||||||
|
healthCheck: { enabled: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
function freshConfig() {
|
||||||
|
return JSON.parse(JSON.stringify(DEFAULTS));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DC-092: POST /config field aliases and typing', () => {
|
||||||
|
test('UI spelling email.user/email.pass normalizes onto username/password', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { user: 'svc@example.com', pass: 'app-secret' } } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nm.config.providers.email.username).toBe('svc@example.com');
|
||||||
|
expect(nm.config.providers.email.password).toBe('app-secret');
|
||||||
|
expect(nm.config.providers.email.user).toBeUndefined();
|
||||||
|
expect(nm.config.providers.email.pass).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explicit username/password wins over user/pass aliases', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { user: 'legacy@x.com', pass: 'old', username: 'modern@x.com', password: 'new' } } });
|
||||||
|
expect(nm.config.providers.email.username).toBe('modern@x.com');
|
||||||
|
expect(nm.config.providers.email.password).toBe('new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('string "false" for secure is rejected, not coerced truthy', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { secure: 'false' } } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(nm.config.providers.email.secure).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('string enabled for any provider is rejected', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
for (const prov of ['discord', 'telegram', 'ntfy', 'email']) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { [prov]: { enabled: 'true' } } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
}
|
||||||
|
const top = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ enabled: 'true' });
|
||||||
|
expect(top.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('real booleans pass and persist', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ enabled: false, providers: { email: { secure: true } } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nm.config.enabled).toBe(false);
|
||||||
|
expect(nm.config.providers.email.secure).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SMTP port bounds enforced (0, 65536, non-integer rejected)', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
for (const bad of [0, 65536, 58.5, 'abc']) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { port: bad } } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
}
|
||||||
|
const good = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { port: 465 } } });
|
||||||
|
expect(good.status).toBe(200);
|
||||||
|
expect(nm.config.providers.email.port).toBe(465);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DC-092: POST /config event-key folding', () => {
|
||||||
|
test('camelCase event keys fold onto canonical kebab keys', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ events: { containerDown: false, deploymentSuccess: false, resourceAlert: false } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nm.config.events['container-down']).toBe(false);
|
||||||
|
expect(nm.config.events['deploy-success']).toBe(false);
|
||||||
|
expect(nm.config.events['alert']).toBe(false);
|
||||||
|
// legacy camelCase keys must NOT be stored
|
||||||
|
expect(nm.config.events.containerDown).toBeUndefined();
|
||||||
|
expect(nm.config.events.deploymentSuccess).toBeUndefined();
|
||||||
|
expect(nm.config.events.resourceAlert).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonical kebab keys accepted directly', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ events: { 'container-down': false, 'auto-restart': false } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nm.config.events['container-down']).toBe(false);
|
||||||
|
expect(nm.config.events['auto-restart']).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-boolean event values rejected', async () => {
|
||||||
|
const nm = makeStubNotification(freshConfig());
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ events: { 'container-down': 'yes' } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DC-092: POST /config non-destructive credential merge', () => {
|
||||||
|
test('empty password does not clobber stored password', async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
cfg.providers.email.username = 'svc@example.com';
|
||||||
|
cfg.providers.email.password = 'stored-secret';
|
||||||
|
const nm = makeStubNotification(cfg);
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { host: 'smtp.example.com', password: '' } } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(nm.config.providers.email.password).toBe('stored-secret');
|
||||||
|
expect(nm.config.providers.email.host).toBe('smtp.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty username does not clobber stored username', async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
cfg.providers.email.username = 'svc@example.com';
|
||||||
|
const nm = makeStubNotification(cfg);
|
||||||
|
const app = buildApp(nm);
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { username: '' } } });
|
||||||
|
expect(nm.config.providers.email.username).toBe('svc@example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('non-empty password overwrites', async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
cfg.providers.email.password = 'old';
|
||||||
|
const nm = makeStubNotification(cfg);
|
||||||
|
const app = buildApp(nm);
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v1/notifications/config')
|
||||||
|
.send({ providers: { email: { password: 'rotated' } } });
|
||||||
|
expect(nm.config.providers.email.password).toBe('rotated');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DC-092: GET /config redaction and roundtrip fields', () => {
|
||||||
|
test('returns port/secure/to/username/hasPassword but never the password', async () => {
|
||||||
|
const cfg = freshConfig();
|
||||||
|
cfg.providers.email = {
|
||||||
|
enabled: true,
|
||||||
|
host: 'smtp.example.com',
|
||||||
|
port: 465,
|
||||||
|
secure: true,
|
||||||
|
to: 'admin@example.com',
|
||||||
|
from: 'DashCaddy <noreply@example.com>',
|
||||||
|
username: 'svc@example.com',
|
||||||
|
password: 'super-secret',
|
||||||
|
};
|
||||||
|
const nm = makeStubNotification(cfg);
|
||||||
|
const app = buildApp(nm);
|
||||||
|
const res = await request(app).get('/api/v1/notifications/config');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const email = res.body.config.providers.email;
|
||||||
|
expect(email.port).toBe(465);
|
||||||
|
expect(email.secure).toBe(true);
|
||||||
|
expect(email.to).toBe('admin@example.com');
|
||||||
|
expect(email.username).toBe('svc@example.com');
|
||||||
|
expect(email.hasPassword).toBe(true);
|
||||||
|
expect(JSON.stringify(res.body)).not.toContain('super-secret');
|
||||||
|
expect(res.body.config.providers.email.password).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ const platformPaths = require('../../platform-paths');
|
|||||||
const { createUserStore } = require('../../src/security/user-store');
|
const { createUserStore } = require('../../src/security/user-store');
|
||||||
const { createInviteStore } = require('../../src/security/invite-store');
|
const { createInviteStore } = require('../../src/security/invite-store');
|
||||||
const emailSender = require('../../src/auth/providers/email-sender');
|
const emailSender = require('../../src/auth/providers/email-sender');
|
||||||
|
const AuthProvider = require('../../src/auth/providers/base');
|
||||||
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
|
const { ValidationError, NotFoundError, ForbiddenError, PaymentRequiredError } = require('../../src/utilities/errors');
|
||||||
const { ok, successMessage } = require('../../src/utils/responses');
|
const { ok, successMessage } = require('../../src/utils/responses');
|
||||||
|
|
||||||
@@ -254,7 +255,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
|||||||
// server log on every unconfigured-install invite.
|
// server log on every unconfigured-install invite.
|
||||||
log.warn && log.warn('auth-invite-send',
|
log.warn && log.warn('auth-invite-send',
|
||||||
'invite send skipped: SMTP not configured (operator opted in)',
|
'invite send skipped: SMTP not configured (operator opted in)',
|
||||||
{ inviteId: issued.id, email: issued.email });
|
{ inviteId: issued.id, email: AuthProvider.maskEmail(issued.email) || '[unmaskable-email]' });
|
||||||
deliveredVia = 'failed';
|
deliveredVia = 'failed';
|
||||||
}
|
}
|
||||||
} catch (sendErr) {
|
} catch (sendErr) {
|
||||||
@@ -369,7 +370,7 @@ module.exports = function({ asyncHandler, errorResponse, log, session, dataDir }
|
|||||||
|
|
||||||
log.info && log.info('auth', 'invite accepted, user created', {
|
log.info && log.info('auth', 'invite accepted, user created', {
|
||||||
userId: userResult.user.id,
|
userId: userResult.user.id,
|
||||||
email: userResult.user.email,
|
email: AuthProvider.maskEmail(userResult.user.email) || '[unmaskable-email]',
|
||||||
role: userResult.user.role,
|
role: userResult.user.role,
|
||||||
inviteId: invite.id,
|
inviteId: invite.id,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,15 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
enabled: notificationConfig.providers.email?.enabled || false,
|
enabled: notificationConfig.providers.email?.enabled || false,
|
||||||
configured: !!(notificationConfig.providers.email?.host && notificationConfig.providers.email?.to),
|
configured: !!(notificationConfig.providers.email?.host && notificationConfig.providers.email?.to),
|
||||||
host: notificationConfig.providers.email?.host || '',
|
host: notificationConfig.providers.email?.host || '',
|
||||||
from: notificationConfig.providers.email?.from || ''
|
from: notificationConfig.providers.email?.from || '',
|
||||||
|
// DC-092: the settings UI needs these to roundtrip the form.
|
||||||
|
// Password is NEVER returned; hasPassword lets the UI show a
|
||||||
|
// "leave blank to keep" hint instead of an empty-looking field.
|
||||||
|
port: notificationConfig.providers.email?.port || 587,
|
||||||
|
secure: notificationConfig.providers.email?.secure === true,
|
||||||
|
to: notificationConfig.providers.email?.to || '',
|
||||||
|
username: notificationConfig.providers.email?.username || '',
|
||||||
|
hasPassword: !!notificationConfig.providers.email?.password
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
events: notificationConfig.events,
|
events: notificationConfig.events,
|
||||||
@@ -54,6 +62,39 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
const { enabled, providers, events, healthCheck } = req.body;
|
const { enabled, providers, events, healthCheck } = req.body;
|
||||||
const notificationConfig = notification.getConfig();
|
const notificationConfig = notification.getConfig();
|
||||||
|
|
||||||
|
// DC-092: clients have historically sent at least three field spellings:
|
||||||
|
// the settings UI sends email.user/email.pass (its input ids are
|
||||||
|
// email-user/email-pass) while the manager/route read username/password.
|
||||||
|
// Normalize aliases onto the canonical keys BEFORE the merge so SMTP auth
|
||||||
|
// actually applies for UI-saved configs.
|
||||||
|
if (providers?.email) {
|
||||||
|
if (providers.email.user !== undefined && providers.email.username === undefined) {
|
||||||
|
providers.email.username = providers.email.user;
|
||||||
|
}
|
||||||
|
if (providers.email.pass !== undefined && providers.email.password === undefined) {
|
||||||
|
providers.email.password = providers.email.pass;
|
||||||
|
}
|
||||||
|
delete providers.email.user;
|
||||||
|
delete providers.email.pass;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DC-092 strict boolean contract: enabled/secure must be actual
|
||||||
|
// booleans. `"false"` (string) is truthy — !!"false" === true — and
|
||||||
|
// previously persisted as-is, silently forcing TLS on the next send.
|
||||||
|
// Reject instead of coercing.
|
||||||
|
const boolOrThrow = (val, label) => {
|
||||||
|
if (val === undefined) return;
|
||||||
|
if (typeof val !== 'boolean') {
|
||||||
|
throw new ValidationError(`${label} must be a boolean (got ${typeof val})`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
boolOrThrow(enabled, 'enabled');
|
||||||
|
boolOrThrow(providers?.discord?.enabled, 'providers.discord.enabled');
|
||||||
|
boolOrThrow(providers?.telegram?.enabled, 'providers.telegram.enabled');
|
||||||
|
boolOrThrow(providers?.ntfy?.enabled, 'providers.ntfy.enabled');
|
||||||
|
boolOrThrow(providers?.email?.enabled, 'providers.email.enabled');
|
||||||
|
boolOrThrow(providers?.email?.secure, 'providers.email.secure');
|
||||||
|
|
||||||
// Validate provider webhook URLs and tokens
|
// Validate provider webhook URLs and tokens
|
||||||
if (providers) {
|
if (providers) {
|
||||||
if (providers.discord?.webhookUrl) {
|
if (providers.discord?.webhookUrl) {
|
||||||
@@ -96,6 +137,12 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
throw new ValidationError('Invalid SMTP host');
|
throw new ValidationError('Invalid SMTP host');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (providers.email?.port !== undefined) {
|
||||||
|
const p = Number(providers.email.port);
|
||||||
|
if (!Number.isInteger(p) || p < 1 || p > 65535) {
|
||||||
|
throw new ValidationError('SMTP port must be an integer 1-65535');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update enabled state
|
// Update enabled state
|
||||||
@@ -124,16 +171,50 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (providers.email) {
|
if (providers.email) {
|
||||||
|
// Non-destructive merge: an empty-string username/password from the
|
||||||
|
// UI (password field is intentionally left blank to keep stored
|
||||||
|
// credentials) must NOT clobber the stored credential.
|
||||||
|
const stored = notificationConfig.providers.email;
|
||||||
|
const incoming = { ...providers.email };
|
||||||
|
if (incoming.password === '') delete incoming.password;
|
||||||
|
if (incoming.username === '') delete incoming.username;
|
||||||
notificationConfig.providers.email = {
|
notificationConfig.providers.email = {
|
||||||
...notificationConfig.providers.email,
|
...stored,
|
||||||
...providers.email
|
...incoming
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update events
|
// Update events. DC-092: the UI sends camelCase keys (containerDown);
|
||||||
|
// the canonical store/gate keys are kebab-case (container-down). Fold
|
||||||
|
// before merging so UI toggles actually reach the keys the send() gate
|
||||||
|
// reads. Values must be booleans; unknown keys pass through unchanged
|
||||||
|
// (canonicalized if known alias) and merge over defaults.
|
||||||
if (events) {
|
if (events) {
|
||||||
notificationConfig.events = { ...notificationConfig.events, ...events };
|
const EVENT_KEY_ALIASES = {
|
||||||
|
containerDown: 'container-down',
|
||||||
|
containerUp: 'container-up',
|
||||||
|
deploymentSuccess: 'deploy-success',
|
||||||
|
deploymentFailed: 'deploy-failed',
|
||||||
|
deploySuccess: 'deploy-success',
|
||||||
|
deployFailed: 'deploy-failed',
|
||||||
|
resourceAlert: 'alert',
|
||||||
|
updateAvailable: 'update-available',
|
||||||
|
backupComplete: 'backup-complete',
|
||||||
|
backupFailed: 'backup-failed',
|
||||||
|
autoRestart: 'auto-restart',
|
||||||
|
};
|
||||||
|
const folded = {};
|
||||||
|
for (const [k, v] of Object.entries(events)) {
|
||||||
|
const canonicalKey = EVENT_KEY_ALIASES[k] || k;
|
||||||
|
folded[canonicalKey] = v;
|
||||||
|
}
|
||||||
|
for (const [k, v] of Object.entries(folded)) {
|
||||||
|
if (typeof v !== 'boolean') {
|
||||||
|
throw new ValidationError(`events.${k} must be a boolean (got ${typeof v})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
notificationConfig.events = { ...notificationConfig.events, ...folded };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update health check settings
|
// Update health check settings
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
|
// Canonical event names are kebab-case ('container-down'). Emitters and the
|
||||||
|
// settings UI historically send camelCase ('containerDown', 'deploymentSuccess')
|
||||||
|
// and the alias map below folds every known spelling onto the canonical key.
|
||||||
|
// DC-092: before this map, the events gate looked up the RAW event name, so
|
||||||
|
// 'deploymentSuccess' (routes/apps/deploy.js, routes/recipes/deploy.js) and
|
||||||
|
// 'auto-restart' (resource-monitor.js) were absent from DEFAULT events and
|
||||||
|
// silently dropped, and UI camelCase toggles never reached the kebab keys the
|
||||||
|
// gate reads — the toggles were cosmetic.
|
||||||
|
const EVENT_ALIASES = {
|
||||||
|
containerDown: 'container-down',
|
||||||
|
containerUp: 'container-up',
|
||||||
|
deploymentSuccess: 'deploy-success',
|
||||||
|
deploymentFailed: 'deploy-failed',
|
||||||
|
deploySuccess: 'deploy-success',
|
||||||
|
deployFailed: 'deploy-failed',
|
||||||
|
resourceAlert: 'alert',
|
||||||
|
updateAvailable: 'update-available',
|
||||||
|
backupComplete: 'backup-complete',
|
||||||
|
backupFailed: 'backup-failed',
|
||||||
|
autoRestart: 'auto-restart',
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
providers: {
|
providers: {
|
||||||
@@ -21,7 +43,13 @@ const DEFAULT_CONFIG = {
|
|||||||
'alert': true,
|
'alert': true,
|
||||||
'backup-complete': true,
|
'backup-complete': true,
|
||||||
'backup-failed': true,
|
'backup-failed': true,
|
||||||
'update-available': true
|
'update-available': true,
|
||||||
|
// DC-092: emitters (apps/recipes deploy routes) fire these; they were
|
||||||
|
// missing from defaults entirely, so every deploy notification was
|
||||||
|
// silently dropped before this fix.
|
||||||
|
'deploy-success': true,
|
||||||
|
'deploy-failed': true,
|
||||||
|
'auto-restart': true
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,6 +76,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
if (fs.existsSync(this.NOTIFICATIONS_FILE)) {
|
if (fs.existsSync(this.NOTIFICATIONS_FILE)) {
|
||||||
const data = JSON.parse(fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8'));
|
const data = JSON.parse(fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8'));
|
||||||
|
this._canonicalizeLegacyKeys(data);
|
||||||
this.config = this._mergeConfig(DEFAULT_CONFIG, data);
|
this.config = this._mergeConfig(DEFAULT_CONFIG, data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -55,6 +84,40 @@ class NotificationManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-092: configs saved by older clients may contain the legacy spellings
|
||||||
|
* the old POST /config merged verbatim — email.user/email.pass instead of
|
||||||
|
* username/password, and camelCase event keys instead of kebab-case. Fold
|
||||||
|
* them onto the canonical keys BEFORE the defaults merge (after the merge
|
||||||
|
* the canonical keys always exist from defaults, so the alias guards would
|
||||||
|
* never fire) so a config file written before this fix keeps working: SMTP
|
||||||
|
* auth applies and event toggles gate correctly.
|
||||||
|
*/
|
||||||
|
_canonicalizeLegacyKeys(data) {
|
||||||
|
// Email credentials: user/pass → username/password (only when the
|
||||||
|
// canonical key is absent in the raw data; canonical wins on conflict).
|
||||||
|
const email = data?.providers?.email;
|
||||||
|
if (email && typeof email === 'object') {
|
||||||
|
if (email.user !== undefined && email.username === undefined) email.username = email.user;
|
||||||
|
if (email.pass !== undefined && email.password === undefined) email.password = email.pass;
|
||||||
|
delete email.user;
|
||||||
|
delete email.pass;
|
||||||
|
// secure must be a real boolean: legacy string values (e.g. "false"
|
||||||
|
// from hand-edited JSON) are truthy under !! and would force TLS.
|
||||||
|
if (email.secure !== undefined) email.secure = email.secure === true;
|
||||||
|
}
|
||||||
|
// Event keys: camelCase → kebab-case canonical.
|
||||||
|
if (data?.events && typeof data.events === 'object') {
|
||||||
|
for (const [k, v] of Object.entries(data.events)) {
|
||||||
|
const canonicalKey = EVENT_ALIASES[k];
|
||||||
|
if (canonicalKey) {
|
||||||
|
if (data.events[canonicalKey] === undefined) data.events[canonicalKey] = v;
|
||||||
|
delete data.events[k];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge loaded config with defaults
|
* Merge loaded config with defaults
|
||||||
*/
|
*/
|
||||||
@@ -130,9 +193,15 @@ class NotificationManager extends EventEmitter {
|
|||||||
return { success: false, error: 'Notifications disabled' };
|
return { success: false, error: 'Notifications disabled' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if event is enabled
|
// Fold legacy/camelCase spellings onto canonical kebab-case keys (DC-092).
|
||||||
if (event && this.config.events && !this.config.events[event]) {
|
const canonical = EVENT_ALIASES[event] || event;
|
||||||
return { success: false, error: `Event ${event} not enabled` };
|
|
||||||
|
// Check if event is enabled. 'test' bypasses the gate: it is the settings
|
||||||
|
// UI "Send Test" flow and is not an operator-togglable event (there is no
|
||||||
|
// 'test' key in events; gating on it made the Test button a no-op).
|
||||||
|
const gated = canonical !== 'test';
|
||||||
|
if (gated && this.config.events && this.config.events[canonical] !== true) {
|
||||||
|
return { success: false, error: `Event ${canonical} not enabled` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const results = [];
|
const results = [];
|
||||||
@@ -141,7 +210,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
// Discord
|
// Discord
|
||||||
if (providers.discord?.enabled && providers.discord?.webhookUrl) {
|
if (providers.discord?.enabled && providers.discord?.webhookUrl) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendDiscord(this._formatText(data, event), this._formatEmbed(data, event, type));
|
const result = await this.sendDiscord(this._formatText(data, canonical), this._formatEmbed(data, canonical, type));
|
||||||
results.push({ provider: 'discord', ...result });
|
results.push({ provider: 'discord', ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.push({ provider: 'discord', success: false, error: error.message });
|
results.push({ provider: 'discord', success: false, error: error.message });
|
||||||
@@ -151,7 +220,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
// Telegram
|
// Telegram
|
||||||
if (providers.telegram?.enabled && providers.telegram?.botToken && providers.telegram?.chatId) {
|
if (providers.telegram?.enabled && providers.telegram?.botToken && providers.telegram?.chatId) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendTelegram(this._formatText(data, event));
|
const result = await this.sendTelegram(this._formatText(data, canonical));
|
||||||
results.push({ provider: 'telegram', ...result });
|
results.push({ provider: 'telegram', ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.push({ provider: 'telegram', success: false, error: error.message });
|
results.push({ provider: 'telegram', success: false, error: error.message });
|
||||||
@@ -161,7 +230,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
// ntfy
|
// ntfy
|
||||||
if (providers.ntfy?.enabled && providers.ntfy?.topic) {
|
if (providers.ntfy?.enabled && providers.ntfy?.topic) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendNtfy(this._formatText(data, event), this._formatTitle(event));
|
const result = await this.sendNtfy(this._formatText(data, canonical), this._formatTitle(canonical));
|
||||||
results.push({ provider: 'ntfy', ...result });
|
results.push({ provider: 'ntfy', ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.push({ provider: 'ntfy', success: false, error: error.message });
|
results.push({ provider: 'ntfy', success: false, error: error.message });
|
||||||
@@ -172,8 +241,8 @@ class NotificationManager extends EventEmitter {
|
|||||||
if (providers.email?.enabled && providers.email?.host && providers.email?.to) {
|
if (providers.email?.enabled && providers.email?.host && providers.email?.to) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendEmail(
|
const result = await this.sendEmail(
|
||||||
this._formatTitle(event),
|
this._formatTitle(canonical),
|
||||||
this._formatText(data, event)
|
this._formatText(data, canonical)
|
||||||
);
|
);
|
||||||
results.push({ provider: 'email', ...result });
|
results.push({ provider: 'email', ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -183,9 +252,9 @@ class NotificationManager extends EventEmitter {
|
|||||||
|
|
||||||
const allSucceeded = results.every(r => r.success);
|
const allSucceeded = results.every(r => r.success);
|
||||||
this._addToHistory({
|
this._addToHistory({
|
||||||
title: this._formatTitle(event),
|
title: this._formatTitle(canonical),
|
||||||
type,
|
type,
|
||||||
event,
|
event: canonical,
|
||||||
results
|
results
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -290,7 +359,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host,
|
host,
|
||||||
port: parseInt(port) || 587,
|
port: parseInt(port) || 587,
|
||||||
secure: !!secure,
|
secure: secure === true,
|
||||||
auth: username ? {
|
auth: username ? {
|
||||||
user: username,
|
user: username,
|
||||||
pass: password
|
pass: password
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10
|
|||||||
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
||||||
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
|
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
|
||||||
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
||||||
|
// DC-088: how long a removal tombstone outlives the removal itself. Only needs
|
||||||
|
// to cover the max in-flight probe lifetime (timeout + scheduling headroom);
|
||||||
|
// swept by cleanupHistory so removed services cannot accumulate map entries.
|
||||||
|
const REMOVED_GENERATION_TTL_MS = parseInt(process.env.HEALTH_REMOVED_GEN_TTL || '600000', 10);
|
||||||
|
|
||||||
// DC-086: hysteresis thresholds for badge display.
|
// DC-086: hysteresis thresholds for badge display.
|
||||||
// The raw probe result can flap on a single transient blip (Caddy reload,
|
// The raw probe result can flap on a single transient blip (Caddy reload,
|
||||||
@@ -72,6 +76,14 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff
|
this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff
|
||||||
// Invalidate probe completions that race with removal/reconfiguration.
|
// Invalidate probe completions that race with removal/reconfiguration.
|
||||||
this.serviceGenerations = new Map(); // serviceId -> configuration generation
|
this.serviceGenerations = new Map(); // serviceId -> configuration generation
|
||||||
|
// DC-088: monotonically increasing sequence so generation numbers can never
|
||||||
|
// repeat across remove -> re-add cycles (prevents ABA on the stale check).
|
||||||
|
this.generationSeq = 0;
|
||||||
|
// DC-088: serviceId -> { generation, removedAt } tombstones. A live entry in
|
||||||
|
// serviceGenerations means the service is (re)configured; a tombstone with a
|
||||||
|
// HIGHER generation than the captured one marks the capture as stale. Entry
|
||||||
|
// is deleted when the service is removed, so the live map cannot leak.
|
||||||
|
this.removedGenerations = new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,6 +151,21 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.cleanupHistory();
|
this.cleanupHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DC-088: true when a probe's captured generation no longer matches the
|
||||||
|
* service's current configuration state. A live serviceGenerations entry
|
||||||
|
* must match exactly. With no live entry the service was never configured
|
||||||
|
* in this process (disk-loaded / direct callers) — stale only if a removal
|
||||||
|
* tombstone with a HIGHER generation exists.
|
||||||
|
*/
|
||||||
|
_isStaleCapture(serviceId, generation) {
|
||||||
|
if (this.serviceGenerations.has(serviceId)) {
|
||||||
|
return this.serviceGenerations.get(serviceId) !== generation;
|
||||||
|
}
|
||||||
|
const tomb = this.removedGenerations.get(serviceId);
|
||||||
|
return Boolean(tomb && tomb.generation > generation);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check a single service
|
* Check a single service
|
||||||
*/
|
*/
|
||||||
@@ -160,7 +187,7 @@ class HealthChecker extends EventEmitter {
|
|||||||
details: result.details
|
details: result.details
|
||||||
};
|
};
|
||||||
|
|
||||||
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
|
if (this._isStaleCapture(serviceId, generation)) {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,16 +199,14 @@ class HealthChecker extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previousStatus = this.currentStatus.get(serviceId);
|
const previousStatus = this.currentStatus.get(serviceId);
|
||||||
|
const previousDisplayed = this.displayedStatus.get(serviceId);
|
||||||
this.recordStatus(serviceId, status);
|
this.recordStatus(serviceId, status);
|
||||||
this.checkForIncidents(serviceId, status, config, previousStatus);
|
this.checkForIncidents(serviceId, status, config, previousStatus, previousDisplayed);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
|
|
||||||
// Increment failure count for backoff
|
|
||||||
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
|
||||||
|
|
||||||
const status = {
|
const status = {
|
||||||
serviceId,
|
serviceId,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -190,13 +215,18 @@ class HealthChecker extends EventEmitter {
|
|||||||
error: error.message
|
error: error.message
|
||||||
};
|
};
|
||||||
|
|
||||||
if ((this.serviceGenerations.get(serviceId) || 0) !== generation) {
|
if (this._isStaleCapture(serviceId, generation)) {
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Increment failure count for backoff — only after the result is known
|
||||||
|
// to be non-stale, so a removed service cannot re-create map entries.
|
||||||
|
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
|
||||||
|
|
||||||
const previousStatus = this.currentStatus.get(serviceId);
|
const previousStatus = this.currentStatus.get(serviceId);
|
||||||
|
const previousDisplayed = this.displayedStatus.get(serviceId);
|
||||||
this.recordStatus(serviceId, status);
|
this.recordStatus(serviceId, status);
|
||||||
this.checkForIncidents(serviceId, status, config, previousStatus);
|
this.checkForIncidents(serviceId, status, config, previousStatus, previousDisplayed);
|
||||||
|
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
@@ -425,10 +455,27 @@ class HealthChecker extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* Check for incidents (downtime, slow response, etc.)
|
* Check for incidents (downtime, slow response, etc.)
|
||||||
*/
|
*/
|
||||||
checkForIncidents(serviceId, status, config, previous = this.currentStatus.get(serviceId)) {
|
checkForIncidents(serviceId, status, config, previous = this.currentStatus.get(serviceId), previousDisplayed = null) {
|
||||||
|
|
||||||
// Check for status change (up -> down or down -> up)
|
// DC-090: outage incidents follow the DISPLAYED (post-hysteresis) status —
|
||||||
if (previous && previous.status !== status.status) {
|
// the same signal that flips the dashboard badge. A single raw "down"
|
||||||
|
// blip that hysteresis suppresses must not open a critical outage
|
||||||
|
// incident (and a suppressed blip must not resolve a real one). When the
|
||||||
|
// caller supplies the pre-probe displayed state (checkService always
|
||||||
|
// does), transitions are evaluated displayed-vs-displayed using the
|
||||||
|
// post-recordStatus state in this.displayedStatus. Direct callers with
|
||||||
|
// no hysteresis state (previousDisplayed === null) keep the legacy
|
||||||
|
// raw-probe transition semantics.
|
||||||
|
if (previousDisplayed) {
|
||||||
|
const displayed = this.displayedStatus.get(serviceId);
|
||||||
|
if (displayed && displayed.status !== previousDisplayed.status) {
|
||||||
|
if (displayed.status === 'down') {
|
||||||
|
this.createIncident(serviceId, 'outage', 'Service is down', displayed);
|
||||||
|
} else if (displayed.status === 'up') {
|
||||||
|
this.resolveIncident(serviceId, 'outage', displayed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (previous && previous.status !== status.status) {
|
||||||
if (status.status === 'down') {
|
if (status.status === 'down') {
|
||||||
this.createIncident(serviceId, 'outage', 'Service is down', status);
|
this.createIncident(serviceId, 'outage', 'Service is down', status);
|
||||||
} else if (status.status === 'up') {
|
} else if (status.status === 'up') {
|
||||||
@@ -660,7 +707,13 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.config.services = {};
|
this.config.services = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
|
// DC-088: monotonic instance-wide sequence — a re-added service can never
|
||||||
|
// recycle a previous generation number, and any older in-flight capture is
|
||||||
|
// invalidated by definition.
|
||||||
|
this.generationSeq += 1;
|
||||||
|
this.serviceGenerations.set(serviceId, this.generationSeq);
|
||||||
|
// Re-configuration supersedes any prior removal tombstone.
|
||||||
|
this.removedGenerations.delete(serviceId);
|
||||||
this.config.services[serviceId] = {
|
this.config.services[serviceId] = {
|
||||||
enabled: config.enabled !== false,
|
enabled: config.enabled !== false,
|
||||||
name: config.name || serviceId,
|
name: config.name || serviceId,
|
||||||
@@ -683,12 +736,35 @@ class HealthChecker extends EventEmitter {
|
|||||||
* Remove service configuration
|
* Remove service configuration
|
||||||
*/
|
*/
|
||||||
removeService(serviceId) {
|
removeService(serviceId) {
|
||||||
this.serviceGenerations.set(serviceId, (this.serviceGenerations.get(serviceId) || 0) + 1);
|
// DC-088: tombstone the captured generation instead of leaking an entry.
|
||||||
|
// The live map entry is deleted; an in-flight probe captured BEFORE this
|
||||||
|
// point sees no live entry but a higher tombstone generation, so it is
|
||||||
|
// discarded. configureService clears the tombstone on re-add.
|
||||||
|
this.generationSeq += 1;
|
||||||
|
this.serviceGenerations.delete(serviceId);
|
||||||
|
this.removedGenerations.set(serviceId, {
|
||||||
|
generation: this.generationSeq,
|
||||||
|
removedAt: Date.now()
|
||||||
|
});
|
||||||
if (this.config.services) {
|
if (this.config.services) {
|
||||||
delete this.config.services[serviceId];
|
delete this.config.services[serviceId];
|
||||||
this.saveConfig();
|
this.saveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-088: open incidents for a removed service must not linger forever.
|
||||||
|
// Close them through the same resolve path a recovery would, annotated so
|
||||||
|
// history shows why (dashboard renders resolved incidents green + duration).
|
||||||
|
for (const incident of this.incidents) {
|
||||||
|
if (incident.serviceId === serviceId && incident.status === 'open') {
|
||||||
|
incident.status = 'resolved';
|
||||||
|
incident.resolvedAt = new Date().toISOString();
|
||||||
|
incident.duration = new Date(incident.resolvedAt) - new Date(incident.createdAt);
|
||||||
|
incident.resolvedBy = 'service-removed';
|
||||||
|
this.emit('incident-resolved', incident);
|
||||||
|
this.emit('log', 'info', `Incident closed by service removal: ${incident.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.currentStatus.delete(serviceId);
|
this.currentStatus.delete(serviceId);
|
||||||
this.displayedStatus.delete(serviceId);
|
this.displayedStatus.delete(serviceId);
|
||||||
this.consecutiveSinceChange.delete(serviceId);
|
this.consecutiveSinceChange.delete(serviceId);
|
||||||
@@ -714,6 +790,18 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DC-088: sweep expired removal tombstones. After the TTL no probe that
|
||||||
|
// captured a pre-removal generation can still be in flight (timeout is
|
||||||
|
// bounded by performHealthCheck), so the tombstone has done its job.
|
||||||
|
if (this.removedGenerations.size > 0) {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [serviceId, tomb] of this.removedGenerations) {
|
||||||
|
if (now - tomb.removedAt > REMOVED_GENERATION_TTL_MS) {
|
||||||
|
this.removedGenerations.delete(serviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ const KNOWN_KEYS = [
|
|||||||
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
'configurationType', 'defaults', 'customLogo', 'customFavicon',
|
||||||
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
'dashboardTitle', 'tailscale', 'license', 'skipped',
|
||||||
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
|
||||||
'customLogoDark', 'customLogoLight', 'language'
|
'customLogoDark', 'customLogoLight', 'language',
|
||||||
|
// license-manager.js persists the last activation to config.licenseBackup
|
||||||
|
// (restore-on-restart path); src/config/migrations.js stamps _version.
|
||||||
|
// Both are first-party writes — see DC-091.
|
||||||
|
'licenseBackup', '_version'
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Vendored
+60
-60
File diff suppressed because one or more lines are too long
@@ -240,9 +240,23 @@
|
|||||||
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
|
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// email fields
|
// email fields — DC-092: prefill the FULL form so a save doesn't
|
||||||
|
// silently wipe fields the GET response previously omitted. Password
|
||||||
|
// is never returned; when one is stored the field shows a keep-hint
|
||||||
|
// and an empty submit preserves the stored credential server-side.
|
||||||
if (config.providers?.email?.host) document.getElementById('email-host').value = config.providers.email.host;
|
if (config.providers?.email?.host) document.getElementById('email-host').value = config.providers.email.host;
|
||||||
if (config.providers?.email?.from) document.getElementById('email-from').value = config.providers.email.from;
|
if (config.providers?.email?.from) document.getElementById('email-from').value = config.providers.email.from;
|
||||||
|
if (config.providers?.email?.to) document.getElementById('email-to').value = config.providers.email.to;
|
||||||
|
if (config.providers?.email?.port) document.getElementById('email-port').value = config.providers.email.port;
|
||||||
|
if (config.providers?.email?.secure !== undefined) document.getElementById('email-secure').checked = config.providers.email.secure === true;
|
||||||
|
if (config.providers?.email?.username) document.getElementById('email-user').value = config.providers.email.username;
|
||||||
|
const emailPassEl = document.getElementById('email-pass');
|
||||||
|
if (config.providers?.email?.hasPassword) {
|
||||||
|
emailPassEl.value = '';
|
||||||
|
emailPassEl.placeholder = 'saved — leave blank to keep';
|
||||||
|
} else {
|
||||||
|
emailPassEl.placeholder = 'app password';
|
||||||
|
}
|
||||||
|
|
||||||
// Health check
|
// Health check
|
||||||
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
|
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
|
||||||
@@ -254,12 +268,14 @@
|
|||||||
`Last check: ${new Date(config.healthCheck.lastCheck).toLocaleString()}`;
|
`Last check: ${new Date(config.healthCheck.lastCheck).toLocaleString()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Events
|
// Events — canonical kebab-case keys, matching the backend store
|
||||||
document.getElementById('event-container-down').checked = config.events?.containerDown !== false;
|
// (DC-092: previously read camelCase keys that never existed, so
|
||||||
document.getElementById('event-container-up').checked = config.events?.containerUp !== false;
|
// every toggle re-rendered as 'checked' regardless of stored state).
|
||||||
document.getElementById('event-deploy-success').checked = config.events?.deploymentSuccess !== false;
|
document.getElementById('event-container-down').checked = config.events?.['container-down'] !== false;
|
||||||
document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false;
|
document.getElementById('event-container-up').checked = config.events?.['container-up'] === true;
|
||||||
document.getElementById('event-resource-alert').checked = config.events?.resourceAlert !== false;
|
document.getElementById('event-deploy-success').checked = config.events?.['deploy-success'] !== false;
|
||||||
|
document.getElementById('event-deploy-failed').checked = config.events?.['deploy-failed'] !== false;
|
||||||
|
document.getElementById('event-resource-alert').checked = config.events?.['alert'] !== false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' });
|
errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' });
|
||||||
@@ -325,18 +341,18 @@
|
|||||||
host: document.getElementById('email-host').value.trim(),
|
host: document.getElementById('email-host').value.trim(),
|
||||||
port: parseInt(document.getElementById('email-port').value) || 587,
|
port: parseInt(document.getElementById('email-port').value) || 587,
|
||||||
secure: document.getElementById('email-secure').checked,
|
secure: document.getElementById('email-secure').checked,
|
||||||
user: document.getElementById('email-user').value.trim(),
|
username: document.getElementById('email-user').value.trim(),
|
||||||
pass: document.getElementById('email-pass').value.trim(),
|
password: document.getElementById('email-pass').value.trim(),
|
||||||
from: document.getElementById('email-from').value.trim(),
|
from: document.getElementById('email-from').value.trim(),
|
||||||
to: document.getElementById('email-to').value.trim()
|
to: document.getElementById('email-to').value.trim()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
containerDown: document.getElementById('event-container-down').checked,
|
'container-down': document.getElementById('event-container-down').checked,
|
||||||
containerUp: document.getElementById('event-container-up').checked,
|
'container-up': document.getElementById('event-container-up').checked,
|
||||||
deploymentSuccess: document.getElementById('event-deploy-success').checked,
|
'deploy-success': document.getElementById('event-deploy-success').checked,
|
||||||
deploymentFailed: document.getElementById('event-deploy-failed').checked,
|
'deploy-failed': document.getElementById('event-deploy-failed').checked,
|
||||||
resourceAlert: document.getElementById('event-resource-alert').checked
|
'alert': document.getElementById('event-resource-alert').checked
|
||||||
},
|
},
|
||||||
healthCheck: {
|
healthCheck: {
|
||||||
enabled: document.getElementById('health-check-enabled').checked,
|
enabled: document.getElementById('health-check-enabled').checked,
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-497e1f671c';
|
const CACHE = 'dashcaddy-shell-3354f5fd96';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user