Files
dashcaddy/dashcaddy-api/__tests__/notification-config-writeback-dc097.test.js
T
Hermes 80a82c4cae
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
refactor(persistence): canonical atomic file writer + notifications.json crash-safety (DC-099) [glm-grade=B]
- src/utils/atomic-write.js: single shared tmp+fsync+rename writer
  (exclusive-create 0600, unique tmp names, cleanup-on-failure,
  best-effort parent-dir fsync after rename for swap durability)
- notification-manager: both write paths (load-time canonicalization
  write-back + saveConfig) converted from plain writeFileSync — a
  crash mid-write can no longer truncate notifications.json
- DC-097/098 test seams migrated to the atomic path; new suite pins
  syscall discipline (order, wx flags, tmp naming, error cleanup,
  dir-fsync swallow)
- 121 suites / 2768 tests green
- Judge: GLM-5.3 cold read grade B/ship (deleg_23f7abad); polish items
  folded: dir-fsync added, header copy-count corrected. Remaining:
  migrate invite/user/share-store private _atomicWriteJSON copies as
  they are touched (queued).

URN: pending (recorded post-commit)
2026-08-22 23:28:36 -07:00

169 lines
6.3 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(),
// DC-099 atomic write path (open tmp → write → fsync → close → rename).
openSync: jest.fn().mockReturnValue(3),
writeSync: jest.fn(),
fsyncSync: jest.fn(),
closeSync: jest.fn(),
renameSync: jest.fn(),
unlinkSync: 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 atomic write (DC-099: write tmp → fsync → rename).
expect(fs.renameSync).toHaveBeenCalledTimes(1);
expect(fs.renameSync.mock.calls[0][1]).toBe(NOTIF_FILE);
const contentsArg = fs.writeSync.mock.calls[0][1];
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.renameSync).toHaveBeenCalledTimes(1);
const canonicalContents = fs.writeSync.mock.calls[0][1];
first.stopHealthDaemon && first.stopHealthDaemon();
fs.renameSync.mockClear();
fs.writeSync.mockClear();
// Second load against the canonical bytes: no write.
const second = loadWithFile(canonicalContents, log);
expect(fs.renameSync).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.writeSync.mock.calls[0][1];
nm.stopHealthDaemon && nm.stopHealthDaemon();
fs.renameSync.mockClear();
fs.writeSync.mockClear();
const again = loadWithFile(canonicalContents, log);
expect(fs.renameSync).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.openSync.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.renameSync).not.toHaveBeenCalled();
nm.stopHealthDaemon && nm.stopHealthDaemon();
});
});