Files
dashcaddy/dashcaddy-api/__tests__/redact-log-pii.test.js
T
2026-08-23 07:28:10 -07:00

214 lines
8.1 KiB
JavaScript

/**
* DC-098 — redact-log-pii.js (one-shot PII redaction for pre-DC-095 logs)
*
* Verifies:
* 1. Raw emails in a log file are rewritten with the canonical mask shape.
* 2. Idempotence — second run leaves the file byte-identical (no rewrite).
* 3. Clean file is untouched (mtime + content preserved).
* 4. --dry-run changes nothing on disk but reports the hit.
* 5. Exit 2 when the post-verify finds remaining raw addresses (simulated).
* 6. Canonical masker export round-trip matches the live logger's shape.
* 7. --keep-raw writes <file>.raw-<epoch> alongside the redacted file.
* 8. Non-emails (root@hostname, image@sha256, 2026-08-22@x false hits) pass
* through — bounded regex intentionally does not match them.
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync } = require('child_process');
const SCRIPT = path.join(__dirname, '..', 'scripts', 'redact-log-pii.js');
const {
EMAIL_RE,
maskEmailAddress,
maskEmailsInString,
} = require('../src/utils/logging');
function run(args) {
return execFileSync('node', [SCRIPT, ...args], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
}
let tmpRoot;
beforeAll(() => {
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dc098-'));
});
afterAll(() => {
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
describe('DC-098 canonical masker exports (src/utils/logging.js)', () => {
test('mask shape matches live logger ("sa****@example.com")', () => {
expect(maskEmailAddress('sami@example.com')).toBe('sa****@example.com');
// local <= 2 chars keeps only the first char (canonical shape)
expect(maskEmailAddress('ab@x.io')).toBe('a****@x.io');
expect(maskEmailAddress('a@x.io')).toBe('a****@x.io'); // <=2 local chars
});
test('maskEmailsInString is exported and masks embedded emails', () => {
expect(maskEmailsInString('user john.doe@corp.com here')).toBe(
'user jo****@corp.com here'
);
});
test('mask output cannot re-match EMAIL_RE (idempotence basis)', () => {
const masked = maskEmailsInString('john.doe@corp.com');
const re = new RegExp(EMAIL_RE.source, EMAIL_RE.flags);
expect(re.test(masked)).toBe(false);
});
});
describe('DC-098 redact-log-pii.js end-to-end', () => {
test('redacts raw emails in a file with the canonical shape', () => {
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case1-'));
const f = path.join(dir, 'error.log');
fs.writeFileSync(f, 'line1 clean\nemail: jane.doe@example.com\nline3\n');
const out = run([f]);
expect(out).toContain('redacted: ');
expect(out).toContain('1 addresses');
const after = fs.readFileSync(f, 'utf8');
expect(after).toContain('ja****@example.com');
expect(after).not.toContain('jane.doe@example.com');
});
test('second run is a no-op (idempotent, byte-identical, no rewrite)', () => {
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case2-'));
const f = path.join(dir, 'error.log');
fs.writeFileSync(f, 'x sami@example.com y\n');
run([f]);
const after1 = fs.readFileSync(f, 'utf8');
const mtime1 = fs.statSync(f).mtimeMs;
const out = run([f]);
expect(out).toContain('clean (nothing to redact)');
expect(fs.readFileSync(f, 'utf8')).toBe(after1);
expect(fs.statSync(f).mtimeMs).toBe(mtime1);
});
test('clean file untouched (content + mtime preserved)', () => {
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case3-'));
const f = path.join(dir, 'error.log');
fs.writeFileSync(f, 'no addresses here\n');
const mtime0 = fs.statSync(f).mtimeMs;
const out = run([f]);
expect(out).toContain('clean (nothing to redact)');
expect(fs.readFileSync(f, 'utf8')).toBe('no addresses here\n');
expect(fs.statSync(f).mtimeMs).toBe(mtime0);
});
test('--dry-run reports the hit but changes nothing on disk', () => {
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case4-'));
const f = path.join(dir, 'error.log');
const original = 'user kofi@example.org\n';
fs.writeFileSync(f, original);
const mtime0 = fs.statSync(f).mtimeMs;
const out = run(['--dry-run', f]);
expect(out).toContain('would redact: ');
expect(fs.readFileSync(f, 'utf8')).toBe(original);
expect(fs.statSync(f).mtimeMs).toBe(mtime0);
});
test('--keep-raw writes <file>.raw-<epoch> alongside the redacted file', () => {
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case5-'));
const f = path.join(dir, 'error.log');
fs.writeFileSync(f, 'raw op@example.net\n');
run(['--keep-raw', f]);
const files = fs.readdirSync(dir);
const rawCopy = files.find((x) => /^error\.log\.raw-\d+$/.test(x));
expect(rawCopy).toBeDefined();
expect(fs.readFileSync(path.join(dir, rawCopy), 'utf8')).toContain(
'op@example.net'
);
expect(fs.readFileSync(f, 'utf8')).toContain('o****@example.net');
});
test('directory walk skips node_modules/.git/coverage/__tests__/dist/build', () => {
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case6-'));
fs.writeFileSync(path.join(dir, 'error.log'), 'a b@example.com\n');
for (const skip of ['node_modules', '.git', 'coverage', '__tests__', 'dist', 'build']) {
fs.mkdirSync(path.join(dir, skip));
fs.writeFileSync(path.join(dir, skip, 'secret.log'), 'leak me@example.com\n');
}
const out = run([dir]);
expect(out).toContain('redacted: ');
expect(out).not.toContain('secret.log');
expect(
fs.readFileSync(path.join(dir, 'node_modules', 'secret.log'), 'utf8')
).toBe('leak me@example.com\n'); // untouched
expect(fs.readFileSync(path.join(dir, 'error.log'), 'utf8')).toContain(
'b****@example.com'
);
});
test('non-emails (root@hostname, image@sha256, numeric TLD) pass through', () => {
const dir = fs.mkdtempSync(path.join(tmpRoot, 'case7-'));
const f = path.join(dir, 'error.log');
const content = 'root@web-1 pulled image@sha256:abcd pkg@1.2.3 done\n';
fs.writeFileSync(f, content);
const out = [f].length && run([f]);
expect(out).toContain('clean (nothing to redact)');
expect(fs.readFileSync(f, 'utf8')).toBe(content);
});
test('missing target reports error and exits 1', () => {
const dir = path.join(tmpRoot, 'nope-does-not-exist');
let code = 0;
let stderr = '';
try {
execFileSync('node', [SCRIPT, dir], { stdio: ['ignore', 'pipe', 'pipe'] });
} catch (e) {
code = e.status;
stderr = e.stderr ? e.stderr.toString() : '';
}
expect(code).toBe(1);
expect(stderr).toContain('error:');
});
});
describe('DC-109 quoted local-part mask edge (maskEmailAddress)', () => {
test('quoted local-part: quotes stripped, 2 REAL chars kept, no stray quote', () => {
expect(maskEmailAddress('"john doe"@example.com')).toBe('jo****@example.com');
expect(maskEmailAddress('"a"@example.com')).toBe('a****@example.com');
expect(maskEmailAddress('ab"cd@e.f"@example.com')).toBe('ab****@example.com'); // mixed, no strip
});
test('quoted local-part containing "@" splits on LAST @ (real domain boundary)', () => {
expect(maskEmailAddress('"a@b"@example.com')).toBe('a@****@example.com');
});
test('empty quoted local-part masks to bare ****@domain', () => {
expect(maskEmailAddress('""@example.com')).toBe('****@example.com');
});
test('plain addresses unchanged by DC-109 (canonical shape preserved)', () => {
expect(maskEmailAddress('sami@example.com')).toBe('sa****@example.com');
expect(maskEmailAddress('ab@x.io')).toBe('a****@x.io');
expect(maskEmailAddress('a@x.io')).toBe('a****@x.io');
});
test('masked quoted output cannot re-match EMAIL_RE (idempotence on splice line)', () => {
const line = 'contact "john.doe@x"@example.com or root@web-1 ok';
const masked = maskEmailsInString(line);
const re = new RegExp(EMAIL_RE.source, EMAIL_RE.flags);
// After one pass nothing that still contains the original address OR a
// fresh matchable email-shaped token may remain.
expect(masked).not.toContain('john.doe');
expect(re.test(masked)).toBe(false);
const twice = maskEmailsInString(masked);
expect(twice).toBe(masked); // fully idempotent
});
});