/** * 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 ', 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(); }); });