/** * Tests for the download-filename helpers added in #493. * Covers header-injection defence, RFC 5987 encoding, zip path-traversal * sanitization, and deterministic collision suffixing. */ const { sanitizeForContentDisposition, buildContentDisposition, sanitizeForZipEntry, uniquifyZipNames, generatePhotoFilename, } = require('../../src/utils/filenameSanitizer'); describe('sanitizeForContentDisposition', () => { it('keeps ASCII camera filenames intact', () => { expect(sanitizeForContentDisposition('DSC_1234.jpg')).toBe('DSC_1234.jpg'); }); it('strips CR/LF/NUL to defeat header injection', () => { const out = sanitizeForContentDisposition('file\r\nX-Evil: yes\nname.jpg'); expect(out).not.toMatch(/[\r\n\0]/); expect(out).toContain('X-Evil'); expect(out).toContain('name.jpg'); }); it('replaces path separators and quotes with underscore', () => { // Leading dots get stripped to defeat `..` showing up as a download. expect(sanitizeForContentDisposition('../etc/passwd')).toBe('etc_passwd'); expect(sanitizeForContentDisposition('a"b.jpg')).toBe('a_b.jpg'); }); it('collapses non-ASCII bytes (filename* carries them instead)', () => { const out = sanitizeForContentDisposition('über_照片.jpg'); expect(out).toMatch(/^[\x20-\x7E]+$/); expect(out).toContain('.jpg'); }); it('returns a safe fallback for empty input', () => { expect(sanitizeForContentDisposition('')).toBe('download'); expect(sanitizeForContentDisposition(null)).toBe('download'); }); }); describe('buildContentDisposition', () => { it('emits both filename and filename* parameters', () => { const header = buildContentDisposition('DSC_1234.jpg'); expect(header).toBe('attachment; filename="DSC_1234.jpg"; filename*=UTF-8\'\'DSC_1234.jpg'); }); it('percent-encodes unicode in filename*', () => { const header = buildContentDisposition('über.jpg'); expect(header).toContain('filename="ber.jpg"'); // %C3%BC is UTF-8 for ü. expect(header).toContain("filename*=UTF-8''%C3%BCber.jpg"); }); it('escapes the parens/apostrophe that break RFC 5987 attr-char', () => { const header = buildContentDisposition("a'b(c).jpg"); expect(header).toMatch(/filename\*=UTF-8''/); expect(header).not.toMatch(/filename\*=UTF-8''.*\(/); }); }); describe('sanitizeForZipEntry', () => { it('preserves spaces and parentheses (real camera names use them)', () => { expect(sanitizeForZipEntry('IMG 1234 (2).jpg')).toBe('IMG 1234 (2).jpg'); }); it('neutralises path traversal', () => { // The exact result keeps leading underscores (so `_DSC1234.NEF`-style // Nikon raws survive) — the safety property we care about is that no // path separator escapes into the zip entry. const out = sanitizeForZipEntry('../../etc/passwd'); expect(out).not.toMatch(/[\/\\]/); expect(out).toContain('etc_passwd'); expect(sanitizeForZipEntry('a/b\\c.jpg')).toBe('a_b_c.jpg'); }); it('preserves leading underscore for Nikon-style raw names', () => { expect(sanitizeForZipEntry('_DSC1234.NEF')).toBe('_DSC1234.NEF'); }); it('strips control characters', () => { expect(sanitizeForZipEntry('abc.jpg')).toBe('abc.jpg'); }); }); describe('uniquifyZipNames', () => { it('returns unchanged names when there are no collisions', () => { expect(uniquifyZipNames(['a.jpg', 'b.jpg', 'c.jpg'])).toEqual([ 'a.jpg', 'b.jpg', 'c.jpg', ]); }); it('appends _N before the extension on collision', () => { expect(uniquifyZipNames(['DSC_1.jpg', 'DSC_1.jpg', 'DSC_1.jpg'])).toEqual([ 'DSC_1.jpg', 'DSC_1_1.jpg', 'DSC_1_2.jpg', ]); }); it('handles dotless names by appending at the end', () => { expect(uniquifyZipNames(['raw', 'raw'])).toEqual(['raw', 'raw_1']); }); it('is deterministic for the same input order', () => { const input = ['A.jpg', 'A.jpg', 'B.jpg', 'A.jpg', 'B.jpg']; expect(uniquifyZipNames(input)).toEqual([ 'A.jpg', 'A_1.jpg', 'B.jpg', 'A_2.jpg', 'B_1.jpg', ]); }); it('avoids reusing a synthesized name that already exists explicitly', () => { // The natural `_1` is taken — the next collision must skip to `_2`. expect(uniquifyZipNames(['DSC.jpg', 'DSC_1.jpg', 'DSC.jpg'])).toEqual([ 'DSC.jpg', 'DSC_1.jpg', 'DSC_2.jpg', ]); }); }); describe('generatePhotoFilename', () => { it('keeps the event/category/counter prefix and extension', () => { const name = generatePhotoFilename('Summer Wedding', 'individual', 42, '.jpg'); expect(name).toMatch(/^Summer_Wedding_individual_0042_[0-9a-f]{12}\.jpg$/); }); // #931 — the counter base is count(*)+1 computed per upload request, so // concurrent bulk uploads assign the same counter to different photos. // The random suffix is what keeps the final storage path unique; without // it, the second writer overwrote the first photo's bytes at its // already-recorded path (cross-photo contamination). // // A suffix-less/constant implementation makes ANY two identical-input // calls collide, so a small sample catches the regression every time — // while keeping the birthday-collision odds of a false failure // negligible (C(5,2)/2^48 ≈ 4e-14). it('produces distinct names for identical inputs', () => { const names = new Set( Array.from({ length: 5 }, () => generatePhotoFilename('Event', 'individual', 7, '.jpg') ) ); expect(names.size).toBe(5); }); });