// Processing-unavailable contract. // // A production interpreter that cannot import Pillow is a server-side runtime // fault, not hostile client input. It must surface as a sanitized // processing-unavailable outcome with the manual-continue fallback (HTTP 503), // never as a 400 that blames the user's photo for being corrupt. import test from 'node:test'; import assert from 'node:assert/strict'; import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { IngressUnavailableError, validateImageIngress, classifyIngressFailure, verifyReencodeRuntime, resolveInterpreter, PINNED_PYTHON_VERSION, PINNED_PILLOW_VERSION, } 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 clean = await readFile(fixture('ingress-clean.jpg')); const cleanDataUrl = `data:image/jpeg;base64,${clean.toString('base64')}`; // A stand-in interpreter that behaves exactly like the real re-encoder running // on a runtime without Pillow: the documented unavailable verdict and exit 3. async function stubInterpreter(name, body) { const dir = join(tmpdir(), `timmy-ingress-stub-${name}`); await mkdir(dir, { recursive: true }); const path = join(dir, 'python3'); await writeFile(path, body); await chmod(path, 0o755); return path; } const withPython = async (path, run) => { const previous = process.env.TIMMY_PYTHON; process.env.TIMMY_PYTHON = path; try { return await run(); } finally { if (previous === undefined) delete process.env.TIMMY_PYTHON; else process.env.TIMMY_PYTHON = previous; } }; test('a runtime without Pillow raises an unavailable ingress error, not a corrupt-input error', async () => { const stub = await stubInterpreter('nopil', '#!/bin/sh\necho \'{"ok": false, "error": "unavailable"}\'\nexit 3\n'); await withPython(stub, async () => { const error = await validateImageIngress({ consent: true, imageDataUrl: cleanDataUrl }) .then(() => null, (caught) => caught); assert.ok(error, 'ingress must fail when the runtime cannot process images'); assert.ok(error instanceof IngressUnavailableError, 'a missing runtime must be classified as unavailable, not as corrupt client input'); assert.match(error.message, /temporarily unavailable/i); assert.match(error.message, /continue manually/i); assert.doesNotMatch(error.message, /corrupt|malformed/i); }); }); test('classifyIngressFailure maps unavailable ingress failures to 503 and corrupt input to 400', () => { assert.equal(classifyIngressFailure(new IngressUnavailableError('Photo processing is temporarily unavailable. Continue manually.')), 503); assert.equal(classifyIngressFailure(new Error('The photo is corrupt or malformed. Try a different photo or continue manually.')), 400); assert.equal(classifyIngressFailure(new Error('Upload a JPEG, PNG, or WebP photo. That file is not a supported image.')), 400); assert.equal(classifyIngressFailure(new Error('Explicit consent is required before AI analysis.')), 400); assert.equal(classifyIngressFailure(new Error('The photo is too large. Use an image under 4 MB.')), 400); }); test('a re-encoder timeout is also unavailable rather than corrupt', async () => { const stub = await stubInterpreter('hang', '#!/bin/sh\nsleep 60\n'); await withPython(stub, async () => { const error = await validateImageIngress({ consent: true, imageDataUrl: cleanDataUrl, reencodeTimeoutMs: 250, }).then(() => null, (caught) => caught); assert.ok(error instanceof IngressUnavailableError); assert.match(error.message, /temporarily unavailable/i); }); }); test('an interpreter that cannot be executed at all is unavailable, not corrupt', async () => { await withPython('/nonexistent/timmy-python-does-not-exist', async () => { const error = await validateImageIngress({ consent: true, imageDataUrl: cleanDataUrl }) .then(() => null, (caught) => caught); assert.ok(error instanceof IngressUnavailableError, 'a missing interpreter is a server fault and must not be reported as corrupt input'); }); }); test('genuinely corrupt input is still classified as client error, not unavailable', async () => { const garbage = await readFile(fixture('ingress-garbage.jpg')); const error = await validateImageIngress({ consent: true, imageDataUrl: `data:image/jpeg;base64,${garbage.toString('base64')}`, }).then(() => null, (caught) => caught); assert.ok(error, 'garbage must be rejected'); assert.equal(error instanceof IngressUnavailableError, false); assert.equal(classifyIngressFailure(error), 400); }); test('the provider is never called when the runtime is unavailable', async () => { const stub = await stubInterpreter('nopil2', '#!/bin/sh\necho \'{"ok": false, "error": "unavailable"}\'\nexit 3\n'); await withPython(stub, async () => { let providerCalled = false; await assert.rejects( () => analyzePhoto({ payload: { consent: true, imageDataUrl: cleanDataUrl }, config: { model: 'm', baseUrl: 'http://127.0.0.1:9/v1' }, fetchImpl: async () => { providerCalled = true; throw new Error('provider reached'); }, }), /temporarily unavailable/i, ); assert.equal(providerCalled, false); }); }); test('the production interpreter path must be absolute, never a PATH lookup', () => { // Development falls back to PATH `python3`. assert.equal(resolveInterpreter({}), 'python3'); assert.equal(resolveInterpreter({ TIMMY_PYTHON: '' }), 'python3'); // Production requires an absolute, immutable interpreter path. assert.equal(resolveInterpreter({ TIMMY_PYTHON: '/usr/local/lib/timmy-staging/python' }), '/usr/local/lib/timmy-staging/python'); assert.throws(() => resolveInterpreter({ TIMMY_PYTHON: 'python3' }), /absolute interpreter path/i); assert.throws(() => resolveInterpreter({ TIMMY_PYTHON: 'relative/python' }), /absolute interpreter path/i); }); test('verifyReencodeRuntime confirms the pinned, immutable production toolchain', async () => { // The real interpreter is the pinned 3.11 / Pillow 12.3.0 toolchain, so the // runtime smoke must succeed and report exactly that pin. const verdict = await verifyReencodeRuntime(); assert.equal(verdict.python, PINNED_PYTHON_VERSION); assert.equal(verdict.pillow, PINNED_PILLOW_VERSION); assert.deepEqual(verdict.pinned, { python: PINNED_PYTHON_VERSION, pillow: PINNED_PILLOW_VERSION }); }); test('verifyReencodeRuntime fails closed when pointed at a wrong interpreter', async () => { // A stub interpreter that exits 3 (unavailable) must be reported as an // ingress-capacity fault, never as corrupt client input. const stub = await stubInterpreter('pinfail', '#!/bin/sh\necho \'{"ok": false, "error": "unavailable"}\'\nexit 3\n'); await withPython(stub, async () => { const error = await verifyReencodeRuntime().then(() => null, (caught) => caught); assert.ok(error instanceof IngressUnavailableError, 'a non-pinned/unavailable runtime must fail closed as processing-unavailable'); }); });