diff --git a/dashcaddy-api/__tests__/notification-config-writeback-dc097.test.js b/dashcaddy-api/__tests__/notification-config-writeback-dc097.test.js new file mode 100644 index 0000000..70ca478 --- /dev/null +++ b/dashcaddy-api/__tests__/notification-config-writeback-dc097.test.js @@ -0,0 +1,159 @@ +/** + * DC-097: notification-manager `_loadConfig` write-back. + * _canonicalizeLegacyKeys (DC-092) fixed legacy spellings in memory only; + * the on-disk notifications.json kept `email.user`/`email.pass`, camelCase + * event keys, and string `secure` until the next explicit UI save. These + * tests pin the new behavior: the canonical form is persisted right after + * load, the write is idempotent, and a failed write never blocks startup. + */ + +jest.mock('fs', () => ({ + existsSync: jest.fn().mockReturnValue(false), + readFileSync: jest.fn().mockReturnValue('{}'), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), +})); + +jest.mock('nodemailer', () => ({ + createTransport: jest.fn(() => ({ + sendMail: jest.fn().mockResolvedValue({ messageId: 'mock' }), + })), +})); + +const fs = require('fs'); +const NotificationManager = require('../src/managers/notification-manager'); + +const NOTIF_FILE = '/tmp/dc097-notif-test.json'; + +function makeCtx(log) { + return { + NOTIFICATIONS_FILE: NOTIF_FILE, + log, + }; +} + +// Serializes exactly like the manager does (2-space indent). +const ser = (obj) => JSON.stringify(obj, null, 2); + +function loadWithFile(contents, log) { + fs.existsSync.mockReturnValue(true); + fs.readFileSync.mockReturnValue(contents); + return new NotificationManager(makeCtx(log)); +} + +describe('DC-097 notification config canonicalization write-back', () => { + let log; + + beforeEach(() => { + jest.clearAllMocks(); + fs.existsSync.mockReturnValue(false); + fs.readFileSync.mockReturnValue('{}'); + fs.writeFileSync.mockClear(); + log = { error: jest.fn(), info: jest.fn(), warn: jest.fn() }; + }); + + afterEach(() => { + try { NotificationManager.prototype.stopHealthDaemon && undefined; } catch (_) {} + jest.clearAllMocks(); + }); + + test('legacy file (user/pass, camelCase events, string secure) is rewritten on disk in canonical form', () => { + const legacy = { + enabled: true, + providers: { + email: { + enabled: true, + host: 'smtp.test', + port: 465, + secure: 'false', + to: 'me@test', + from: 'from@test', + user: 'legacy-user', + pass: 'legacy-pass', + }, + }, + events: { + containerDown: false, + deploymentSuccess: false, + }, + }; + const nm = loadWithFile(ser(legacy), log); + + // In-memory: canonical (pinned by DC-092 tests, re-pinned here). + expect(nm.config.providers.email.username).toBe('legacy-user'); + expect(nm.config.providers.email.password).toBe('legacy-pass'); + expect(nm.config.providers.email.secure).toBe(false); + expect(nm.config.events['container-down']).toBe(false); + expect(nm.config.events['deploy-success']).toBe(false); + + // On-disk write-back: exactly one write of the full canonical config. + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + const [pathArg, contentsArg] = fs.writeFileSync.mock.calls[0]; + expect(pathArg).toBe(NOTIF_FILE); + const written = JSON.parse(contentsArg); + expect(written.providers.email.username).toBe('legacy-user'); + expect(written.providers.email.password).toBe('legacy-pass'); + expect(written.providers.email.user).toBeUndefined(); + expect(written.providers.email.pass).toBeUndefined(); + expect(written.providers.email.secure).toBe(false); + expect(written.events['container-down']).toBe(false); + written.events && expect(Object.keys(written.events)).not.toContain('containerDown'); + nm.stopHealthDaemon && nm.stopHealthDaemon(); + }); + + test('write-back is idempotent: an already-canonical file is not rewritten', () => { + // First load performs the write-back; capture what it wrote. + const legacy = ser({ + providers: { email: { user: 'u', pass: 'p', secure: 'false' } }, + events: { containerDown: true }, + }); + const first = loadWithFile(legacy, log); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); + const canonicalContents = fs.writeFileSync.mock.calls[0][1]; + first.stopHealthDaemon && first.stopHealthDaemon(); + fs.writeFileSync.mockClear(); + + // Second load against the canonical bytes: no write. + const second = loadWithFile(canonicalContents, log); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + expect(second.config.providers.email.username).toBe('u'); + second.stopHealthDaemon && second.stopHealthDaemon(); + }); + + test('legacy keys absent → no write at all (clean file untouched)', () => { + // Fully canonical: matches the merged config after serialization. + // Build it by round-tripping: write-back from a minimal legacy file + // produces the canonical full shape; feed those exact bytes back. + const nm = loadWithFile(ser({ enabled: true }), log); // 1 write (defaults fill-in) + const canonicalContents = fs.writeFileSync.mock.calls[0][1]; + nm.stopHealthDaemon && nm.stopHealthDaemon(); + fs.writeFileSync.mockClear(); + const again = loadWithFile(canonicalContents, log); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + again.stopHealthDaemon && again.stopHealthDaemon(); + }); + + test('write failure (EACCES) does not throw out of the constructor and in-memory config stays correct', () => { + const legacy = ser({ + providers: { email: { user: 'u2', pass: 'p2' } }, + events: { workflowDone: true }, + }); + fs.writeFileSync.mockImplementation(() => { throw new Error('EACCES: permission denied'); }); + let nm; + expect(() => { nm = loadWithFile(legacy, log); }).not.toThrow(); + expect(nm.config.providers.email.username).toBe('u2'); + expect(nm.config.events['workflow']).toBe(true); + // Warn surfaced, no error-level log (load itself succeeded). + expect(log.warn).toHaveBeenCalled(); + expect(log.error).not.toHaveBeenCalled(); + nm.stopHealthDaemon && nm.stopHealthDaemon(); + }); + + test('no file on disk → no read, no write (fresh install untouched)', () => { + fs.existsSync.mockReturnValue(false); + const nm = new NotificationManager(makeCtx(log)); + expect(fs.readFileSync).not.toHaveBeenCalled(); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + nm.stopHealthDaemon && nm.stopHealthDaemon(); + }); +}); diff --git a/dashcaddy-api/src/managers/notification-manager.js b/dashcaddy-api/src/managers/notification-manager.js index fcb88c0..fef300e 100644 --- a/dashcaddy-api/src/managers/notification-manager.js +++ b/dashcaddy-api/src/managers/notification-manager.js @@ -98,15 +98,41 @@ class NotificationManager extends EventEmitter { _loadConfig() { try { if (fs.existsSync(this.NOTIFICATIONS_FILE)) { - const data = JSON.parse(fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8')); + const raw = fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8'); + const data = JSON.parse(raw); this._canonicalizeLegacyKeys(data); this.config = this._mergeConfig(DEFAULT_CONFIG, data); + this._persistCanonicalForm(raw); } } catch (error) { this.log.error('notification', error, null, { note: 'Failed to load config' }); } } + /** + * DC-097: _canonicalizeLegacyKeys only fixed the file in memory — the + * on-disk file kept its legacy spellings (email user/pass, camelCase + * event keys, string `secure`) until the next explicit UI save, so any + * pre-DC-092 file stayed stale forever on installs that never touch the + * settings page. After the defaults merge, persist the canonical form + * whenever it differs from what is on disk. Best-effort: the config is + * already correct in memory, so a write failure (read-only mount, EACCES) + * must never block startup — warn and continue. Idempotent: once written, + * the re-serialized form matches the file byte-for-byte and no further + * writes happen on subsequent loads. + */ + _persistCanonicalForm(rawFileContents) { + try { + const canonical = JSON.stringify(this.config, null, 2); + if (canonical !== rawFileContents) { + fs.writeFileSync(this.NOTIFICATIONS_FILE, canonical); + this.log.info?.('notification', 'Notification config canonicalized on disk (legacy keys normalized)', {}); + } + } catch (writeError) { + this.log.warn?.('notification', 'Failed to persist canonicalized notification config; continuing with in-memory config', { error: writeError?.message || String(writeError) }); + } + } + /** * DC-092: configs saved by older clients may contain the legacy spellings * the old POST /config merged verbatim — email.user/email.pass instead of