Some checks failed
Quality gates / quality (pull_request) Failing after 1m38s
- src/image-ingress.js: sniff magic bytes independent of declared MIME, reject polyglots (embedded ZIP/script payloads), enforce 4 MB body cap and 4096 px dimension cap before decode, re-encode via fixed-argv Pillow subprocess with 15s timeout, strip EXIF/GPS/XMP/tEXt metadata, fail closed with sanitized short errors and manual fallback. - scripts/reencode_image.py: defensive decoder; decompression-bomb guarded; prints one JSON verdict line, never image bytes. - src/rate-limiter.js + server.mjs /api/analyze: bounded fixed-window per-client limiter, 429 with retry-after, no payload retention. - tests/image-ingress.test.js: hostile synthetic fixtures only (spoofed MIME, GIF/ZIP polyglots, truncated/garbage/empty images, 12000x12000 bomb, 6000x6000 oversized, EXIF+GPS, SVG-with-script); asserts provider is never reached on rejection and error messages contain no bytes/base64/stacks. Closes #16
55 lines
2.4 KiB
JavaScript
55 lines
2.4 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { analyzePhoto } from '../src/vision-service.js';
|
|
|
|
const cleanJpeg = await readFile(fileURLToPath(new URL('./fixtures/ingress-clean.jpg', import.meta.url)));
|
|
const imageDataUrl = `data:image/jpeg;base64,${cleanJpeg.toString('base64')}`;
|
|
|
|
test('sends a bounded structured request to the configured provider and validates its response', async () => {
|
|
let captured;
|
|
const fetchImpl = async (url, options) => {
|
|
captured = { url, options };
|
|
return {
|
|
ok: true,
|
|
json: async () => ({ choices: [{ message: { content: JSON.stringify({
|
|
isStool: true, bristolType: 4, color: 'brown', confidence: 0.81,
|
|
imageQuality: 'good', observations: 'Smooth and formed.'
|
|
}) } }] }),
|
|
};
|
|
};
|
|
const result = await analyzePhoto({
|
|
payload: { imageDataUrl, consent: true },
|
|
fetchImpl,
|
|
config: { baseUrl: 'http://127.0.0.1:8645/v1', apiKey: 'secret', model: 'vision-model' },
|
|
});
|
|
assert.equal(result.status, 'suggestion');
|
|
assert.equal(result.bristolType, 4);
|
|
assert.equal(captured.url, 'http://127.0.0.1:8645/v1/chat/completions');
|
|
assert.equal(captured.options.headers.authorization, 'Bearer secret');
|
|
assert.doesNotMatch(JSON.stringify(result), /secret/);
|
|
});
|
|
|
|
test('fails closed when the provider is unavailable or malformed', async () => {
|
|
await assert.rejects(() => analyzePhoto({
|
|
payload: { imageDataUrl, consent: true },
|
|
fetchImpl: async () => ({ ok: false, status: 503, text: async () => 'upstream detail' }),
|
|
config: { baseUrl: 'http://localhost/v1', apiKey: 'secret', model: 'm' },
|
|
}), /temporarily unavailable/i);
|
|
await assert.rejects(() => analyzePhoto({
|
|
payload: { imageDataUrl, consent: true },
|
|
fetchImpl: async () => ({ ok: true, json: async () => ({ choices: [] }) }),
|
|
config: { baseUrl: 'http://localhost/v1', apiKey: 'secret', model: 'm' },
|
|
}), /invalid/i);
|
|
});
|
|
|
|
test('requires an http(s) provider URL and never accepts credentials from the browser payload', async () => {
|
|
await assert.rejects(() => analyzePhoto({
|
|
payload: { imageDataUrl, consent: true, apiKey: 'injected' },
|
|
fetchImpl: async () => { throw new Error('must not call'); },
|
|
config: { baseUrl: 'file:///tmp/provider', apiKey: 'secret', model: 'm' },
|
|
}), /provider URL/i);
|
|
});
|