_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.
160 lines
6.0 KiB
JavaScript
160 lines
6.0 KiB
JavaScript
/**
|
|
* 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();
|
|
});
|
|
});
|