Some checks failed
Quality gates / quality (pull_request) Failing after 2m39s
Closes the PR 63 hostile-review blockers: 1. Immutable pinned Python/Pillow runtime (TIMMY_PYTHON absolute, --verify-pin), deployment/runtime re-encode smoke gate in build_release + deploy_staging. 2. Header-only width/height/total-pixel/bomb rejection before full decode; proves 6000x6000 and 12000x12000 stay resource bounded (RSS + address-space caps). 3. Fail-fast decoder concurrency ceiling; tests count actual spawned children. 4. Rate-limiter key cardinality hard-bounded under 20k+ unexpired identities, trusted-loopback-proxy identity, no spoofable forwarded headers, fixed-window boundary burst smoothed by two-window sliding count. 5. build_release explicitly syntax/gates every new JS module + Python re-encoder + production-runtime smoke; CI runs reencode-image test and the runtime pin smoke. 6. Robust polyglot contract via canonical container parsing; rejects uppercase/mixed script, appended HTML, ZIP local/EOCD and archive tails, data after canonical JPEG/PNG/WebP end; no naive compressed-byte scans (false-positive controls pass). 7. Canonical base64 data-URL grammar with byte-exact round trip; rejects missing/excess padding, whitespace/CRLF, malformed and noncanonical encodings; exact MIME policy. 8. Missing Pillow / runtime-unavailable maps to sanitized 503 + manual fallback. 9. Inbound body-read timeout and stop-on-oversize; preserves sanitized 413, base path, provider suppression, and temp cleanup; socket torn down on rejection. Audited prior partial edits: reused the sound source modules, re-wired new tests into the unit/syntax gates, fixed a non-canonical readJson that destroyed the socket before delivering 413/408, and hardened test flakiness (port reuse, EPIPE, startup races).
155 lines
7.1 KiB
JavaScript
155 lines
7.1 KiB
JavaScript
// 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');
|
|
});
|
|
});
|
|
|