fix(notifications): persist canonicalized config on load — legacy keys no longer stale on disk (DC-097) [glm-grade=B]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

_loadConfig canonicalized legacy spellings in memory only (DC-092); the
on-disk notifications.json kept email user/pass, camelCase event keys and
string secure until the next explicit UI save — i.e. forever on installs
that never open the settings page.

- _persistCanonicalForm(): after the defaults merge, re-serialize and write
  back only when the bytes differ; idempotent on subsequent loads.
- Best-effort: write failures (read-only mount, EACCES) warn and continue —
  the in-memory config is already correct; constructor never throws.
- No secrets in new log lines; JSON.stringify(this.config) same as saveConfig.

Judge notes (non-blocking, GLM-5.3 cold read): unknown top-level keys are
now dropped from disk at boot (pre-existing merge-drop semantics, previously
deferred to next UI save); write is non-atomic, matching saveConfig.
Verdict: urn:ump:rchawiu427idev5mrettlxkyw2rcnwqpegiolqmzbc5ygc277u5q

Tests: +5 (__tests__/notification-config-writeback-dc097.test.js) —
canonical rewrite, idempotence, clean-file-untouched, EACCES no-throw,
fresh-install no-write. Full suite 119 suites / 2740 tests green.
This commit is contained in:
Hermes
2026-08-22 21:52:19 -07:00
parent bb59595d6d
commit 3ccd00d1a1
2 changed files with 186 additions and 1 deletions
@@ -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();
});
});
@@ -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