import test from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { MAX_IMAGE_BYTES, MAX_IMAGE_DIMENSION, sniffImageFormat, validateImageIngress, } from '../src/image-ingress.js'; import { analyzePhoto } from '../src/vision-service.js'; const fixture = (name) => fileURLToPath(new URL(`../tests/fixtures/${name}`, import.meta.url)); const readFixture = async (name) => await readFile(fixture(name)); function dataUrl(bytes, mime) { return `data:${mime};base64,${bytes.toString('base64')}`; } async function ingestFixture(name, mime = 'image/jpeg', overrides = {}) { const bytes = await readFixture(name); return validateImageIngress({ imageDataUrl: dataUrl(bytes, mime), consent: true, ...overrides, }); } test('clean synthetic JPEG passes ingress and is re-encoded without metadata', async () => { const result = await ingestFixture('ingress-clean.jpg'); assert.equal(result.format, 'jpeg'); assert.ok(result.bytes <= MAX_IMAGE_BYTES); assert.match(result.imageDataUrl, /^data:image\/jpeg;base64,/); const exifCount = execFileSync('python3', [ '-c', 'from PIL import Image;import sys,base64;print(len(Image.open(__import__("io").BytesIO(base64.b64decode(sys.argv[1]))).getexif()))', result.imageDataUrl.split(',')[1], ], { encoding: 'utf8' }); assert.equal(exifCount.trim(), '0'); }); test('magic bytes are verified independent of declared MIME', async () => { // HTML payload wearing a JPEG content type must be rejected. await assert.rejects( () => ingestFixture('ingress-spoofed.html'), /not a supported image/i, ); // A real PNG declared as JPEG must still be accepted by sniffing, not by MIME. const png = await ingestFixture('ingress-metadata.png', 'image/jpeg'); assert.equal(png.originalFormat, 'png'); assert.equal(png.format, 'jpeg'); }); test('polyglot payloads are rejected', async () => { await assert.rejects(() => ingestFixture('ingress-polyglot.gif'), /not a supported image|rejected|unsafe/i); await assert.rejects(() => ingestFixture('ingress-zip-polyglot.jpg'), /rejected|unsafe|corrupt|malformed|not a supported image/i); }); test('malformed and truncated images fail closed with sanitized errors', async () => { for (const [name, mime] of [['ingress-garbage.jpg', 'image/jpeg'], ['ingress-empty.jpg', 'image/jpeg']]) { await assert.rejects(() => ingestFixture(name, mime), (error) => { assert.match(error.message, /upload a jpeg, png, or webp photo/i); return true; }); } await assert.rejects(() => ingestFixture('ingress-truncated.jpg'), /corrupt or malformed/i); }); test('decompression bombs and oversized dimensions fail before provider work', async () => { await assert.rejects(() => ingestFixture('ingress-bomb.png'), /too large|dimensions/i); await assert.rejects(() => ingestFixture('ingress-oversized.jpg'), /too large|dimensions/i); assert.equal(MAX_IMAGE_DIMENSION <= 4096, true); }); test('EXIF and GPS metadata are stripped from the re-encoded image', async () => { const result = await ingestFixture('ingress-exif.jpg'); assert.equal(result.metadataStripped, true); const check = execFileSync('python3', ['-c', 'from PIL import Image;import sys,base64,io;' + 'im=Image.open(io.BytesIO(base64.b64decode(sys.argv[1])));' + 'ex=im.getexif();' + 'gps=ex.get_ifd(0x8825);' + 'print("MAKE" if ex.get(0x010F) else "CLEAN", "GPS" if gps else "CLEAN")', result.imageDataUrl.split(',')[1], ], { encoding: 'utf8' }); assert.equal(check.trim(), 'CLEAN CLEAN'); const pngResult = await ingestFixture('ingress-metadata.png'); const pngCheck = execFileSync('python3', ['-c', 'from PIL import Image;import sys,base64,io;' + 'im=Image.open(io.BytesIO(base64.b64decode(sys.argv[1])));' + 'info=getattr(im,"text",{}) or {};' + 'print("TEXT" if info else "CLEAN", im.format.lower())', pngResult.imageDataUrl.split(',')[1], ], { encoding: 'utf8' }); assert.equal(pngCheck.trim(), 'CLEAN jpeg'); }); test('body limit rejects oversized base64 bodies before decoding', async () => { const huge = Buffer.alloc(MAX_IMAGE_BYTES + 1024, 65); await assert.rejects( () => validateImageIngress({ imageDataUrl: dataUrl(huge, 'image/jpeg'), consent: true }), /under \d+ mb/i, ); }); test('consent is still required after hardening', async () => { const bytes = await readFixture('ingress-clean.jpg'); await assert.rejects( () => validateImageIngress({ imageDataUrl: dataUrl(bytes, 'image/jpeg') }), /consent/i, ); }); test('SVG uploads never pass ingress regardless of extension', async () => { await assert.rejects( () => ingestFixture('ingress-script.svg', 'image/svg+xml'), /not a supported image/i, ); }); test('errors are sanitized: no image bytes, no base64, no stack in messages', async () => { const cases = []; for (const name of ['ingress-spoofed.html', 'ingress-polyglot.gif', 'ingress-truncated.jpg', 'ingress-bomb.png']) { try { await ingestFixture(name); } catch (error) { cases.push(error.message); } } for (const message of cases) { assert.doesNotMatch(message, /[A-Za-z0-9+/]{40,}/); assert.doesNotMatch(message, /at\s+\S+\s+\(/); assert.ok(message.length < 200); } }); test('rate limiter allows a bounded burst then fails closed with sanitized retry message', async () => { const { createRateLimiter } = await import('../src/rate-limiter.js'); const limiter = createRateLimiter(); const first = limiter.take('client-a', Date.now()); assert.equal(first.allowed, true); for (let i = 0; i < limiter.limit - 1; i += 1) { assert.equal(limiter.take('client-a', Date.now()).allowed, true); } const blocked = limiter.take('client-a', Date.now()); assert.equal(blocked.allowed, false); assert.match(blocked.reason, /try again later|slow down/i); assert.ok(blocked.retryAfterMs > 0); assert.doesNotMatch(blocked.reason, /image|payload|byte/i); // A different client key is unaffected. assert.equal(limiter.take('client-b', Date.now()).allowed, true); }); test('analyzePhoto routes through hardened ingress before provider fetch', async () => { const bytes = await readFixture('ingress-polyglot.gif'); let providerCalled = false; await assert.rejects( () => analyzePhoto({ payload: { consent: true, imageDataUrl: dataUrl(bytes, 'image/gif') }, config: { model: 'm', baseUrl: 'http://127.0.0.1:9/v1' }, fetchImpl: async () => { providerCalled = true; throw new Error('provider reached'); }, }), /not a supported image|rejected|unsafe/i, ); assert.equal(providerCalled, false); });