/** * DC-108 — redact-on-rotate tests * * When error.log crosses MAX_ERROR_LOG_SIZE, the rotation renames it to * error.log.1 and (new in DC-108) scrubs the archive with the canonical * email mask. DC-095 masks at every live sink; this is the belt-and-braces * backstop for any future sink that forgets. * * Covers: * - rotation scrubs raw emails out of the archive (canonical sa****@ form) * - already-clean archive is never rewritten (inode + mtime preserved) * - scrub failure does NOT lose the new error line (append still runs) * - archive mode is preserved across the atomic rewrite * - no .redact- temp file is left behind on success */ const path = require('path'); const fs = require('fs'); const fsp = require('fs').promises; const os = require('os'); // Isolated temp dir + env BEFORE the module capture (logging.test.js pattern) const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc108-rotate-test-')); process.env.AUDIT_LOG_FILE = path.join(TMP_DIR, 'audit-log.json'); process.env.ERROR_LOG_FILE = path.join(TMP_DIR, 'error.log'); process.env.NODE_ENV = 'production'; // JSON output mode (stable, parseable) const { log, ERROR_LOG_FILE, MAX_ERROR_LOG_SIZE } = require('../src/utils/logging'); const ROTATED = ERROR_LOG_FILE + '.1'; const RAW_EMAIL = 'someone.example@example.com'; // Seed error.log past the rotation threshold. `extra` is appended raw to // simulate pre-DC-095-style unmasked content (the backstop's threat model). async function seedOversized(extra) { const padding = 'x'.repeat(MAX_ERROR_LOG_SIZE + 64); await fsp.writeFile(ERROR_LOG_FILE, padding + (extra || ''), 'utf8'); } // log.error flushes to the file awaited; one call is one append+rotate. async function triggerAppend() { await log.error('dc108-test', 'rotation trigger', { seq: Math.random() }); } afterAll(async () => { try { await fsp.rm(TMP_DIR, { recursive: true, force: true }); } catch (_) {} }); beforeEach(async () => { await fsp.writeFile(ERROR_LOG_FILE, '', 'utf8'); try { await fsp.rm(ROTATED, { force: true }); } catch (_) {} // Sweep any stale temp files from failed assertions for (const f of fs.readdirSync(TMP_DIR)) { if (f.includes('.redact-')) await fsp.rm(path.join(TMP_DIR, f), { force: true }); } jest.restoreAllMocks(); }); describe('DC-108 redact-on-rotate', () => { test('rotation scrubs raw emails from the archive', async () => { await seedOversized(`user contact: ${RAW_EMAIL}\n`); await triggerAppend(); const arch = await fsp.readFile(ROTATED, 'utf8'); // Raw PII is gone; canonical masked form is present expect(arch).not.toContain(RAW_EMAIL); expect(arch).toContain('so****@example.com'); // New line landed in the fresh error.log const fresh = await fsp.readFile(ERROR_LOG_FILE, 'utf8'); expect(fresh).toContain('rotation trigger'); // No temp residue const leftovers = fs.readdirSync(TMP_DIR).filter((f) => f.includes('.redact-')); expect(leftovers).toEqual([]); }); test('clean archive keeps the rename inode; PII archive is atomically rewritten', async () => { // Clean case: rotation renames error.log → archive; scrub finds nothing // to do → archive KEEPS the original error.log inode (rename, not rewrite). await seedOversized('no PII here, fully clean\n'); const cleanInode = fs.statSync(ERROR_LOG_FILE).ino; await triggerAppend(); expect(fs.statSync(ROTATED).ino).toBe(cleanInode); const arch1 = await fsp.readFile(ROTATED, 'utf8'); expect(arch1).toContain('fully clean'); expect(arch1).not.toContain('****'); // PII case: scrub rewrites via temp+rename → archive inode DIFFERS from // the pre-rotation error.log inode. await seedOversized(`user contact: ${RAW_EMAIL}\n`); const piiInode = fs.statSync(ERROR_LOG_FILE).ino; await triggerAppend(); expect(fs.statSync(ROTATED).ino).not.toBe(piiInode); const arch2 = await fsp.readFile(ROTATED, 'utf8'); expect(arch2).toContain('so****@example.com'); expect(arch2).not.toContain(RAW_EMAIL); }); test('scrub failure does not lose the new error line', async () => { await seedOversized(`user contact: ${RAW_EMAIL}\n`); const errSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); // Make ONLY the archive read fail — rotation itself must still succeed. const realReadFile = fsp.readFile.bind(fsp); const spy = jest.spyOn(fsp, 'readFile').mockImplementation(async (p, ...rest) => { if (typeof p === 'string' && p === ROTATED) { throw new Error('EACCES: permission denied, scrub boom'); } return realReadFile(p, ...rest); }); await triggerAppend(); // Scrub failure was contained + reported expect(errSpy).toHaveBeenCalledWith( '[logger] Failed to redact rotated error.log archive:', expect.stringContaining('scrub boom') ); // Rotation still committed and the new line was still appended expect(fs.existsSync(ROTATED)).toBe(true); const fresh = await fsp.readFile(ERROR_LOG_FILE, 'utf8'); expect(fresh).toContain('rotation trigger'); spy.mockRestore(); }); test('archive file mode is preserved across the atomic rewrite', async () => { await seedOversized(`user contact: ${RAW_EMAIL}\n`); await fs.promises.chmod(ERROR_LOG_FILE, 0o640); await triggerAppend(); const mode = fs.statSync(ROTATED).mode & 0o777; expect(mode).toBe(0o640); // And the rewrite actually happened (PII scrubbed) const arch = await fsp.readFile(ROTATED, 'utf8'); expect(arch).not.toContain(RAW_EMAIL); }); test('stale crash-leftover .redact- temps are swept on rotation', async () => { // Simulate a prior hard crash: abandoned temp sibling still on disk const stale = path.join(TMP_DIR, 'error.log.1.redact-999999'); await fsp.writeFile(stale, 'half-scrubbed partial write', 'utf8'); await seedOversized('clean rotation content\n'); await triggerAppend(); const leftovers = fs.readdirSync(TMP_DIR).filter((f) => f.includes('.redact-')); expect(leftovers).toEqual([]); // swept, archive + fresh log intact expect(fs.existsSync(ROTATED)).toBe(true); const fresh = await fsp.readFile(ERROR_LOG_FILE, 'utf8'); expect(fresh).toContain('rotation trigger'); }); });