Files
agents-marketplace/tests/unit/supplier-routes-ocr.test.ts
FilipBl4gojevic 2b2295b7ed fix(ocr): review pass — min-ada floor regression test, auth-before-parse, failure-path reconciliation, disclosure label coverage
Fixes 2 critical, 2 important, and 5 minor findings from review of 069edcb:

- C1: add a happy-path test that reaches postOcrEscrow.ts's escrowLockFloor
  call and asserts the locked amount; verified it fails against a bare
  economicTotal and passes against the real code.
- C2: gate /v1/ocr/extract (auth + rate limits) before the 16mb body parser
  so an unauthenticated caller can no longer force a full buffer/JSON.parse.
- I2: hoist result/used/escrowRef in the OCR one-shot handler so the failure
  path records escrow_ref/supplier/cost when an escrow was actually posted,
  and emit an ocr_failed structured log line mirroring ocr_settled.
- I3: export venueAttribution and cover its sanitization directly; add a
  stubbed happy-path route test asserting the first-party-brokered
  disclosure label reaches the response.
- Minor: drop unused hfWeightsSource end to end, rename a misleading test,
  make the OCR capability template's pinning language conditional on the
  self-hosted upstream, tighten the /v1/ocr path-prefix guard, and make
  preflight's required_lovelace use the same escrowLockFloor the builder
  locks (exported from the shared tx barrel for gateway use).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:12:12 +02:00

177 lines
7.2 KiB
TypeScript

/**
* supplier-routes-ocr.test.ts — coverage for the model-scoped OCR supplier
* route (`ocr.page.extract.<model-slug>.v1`).
*
* Focus (mirrors supplier-routes-tts.test.ts): the NEW validation logic in
* `makeOcrHandler` plus capability-routed mounting and the enlarged body
* limit. The shared lifecycle pieces (lock, claim tx, runner spawn) reuse
* the chat-handler implementation and are covered by the chat route tests.
*
* End-to-end happy-path (escrow seed → claim → run → submit) is exercised
* by the post-deploy smoke against the real testnet, not here.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import request from "supertest";
import type { Application } from "express";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { SupplierState } from "../../supplier/src/state.js";
import { JobStore } from "../../supplier/src/jobs.js";
import { createApp } from "../../supplier/src/server.js";
import type { SupplierConfig } from "../../supplier/src/config.js";
import {
buildSampleConfig,
buildSampleOcrConfig,
} from "../fixtures/supplier-side/sample-config.js";
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
const ESCROW_REF_HEADER = `${"f".repeat(64)}#0`;
const IMG = Buffer.from("page image bytes").toString("base64");
function makeApp(config?: SupplierConfig): Application {
return createApp({
chain: new MockChainProvider(),
state: new SupplierState(),
config: config ?? buildSampleOcrConfig(),
supplierKey: buildSupplierWalletKey(),
jobs: new JobStore(),
});
}
function validBody() {
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("Capability-routed mounting", () => {
it("ocr supplier exposes /v1/ocr/extract and NOT the chat/tts routes", async () => {
const app = makeApp();
const ocr = await request(app).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
expect(ocr.status).not.toBe(404);
const chat = await request(app).post("/v1/chat/completions")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ messages: [{ role: "user", content: "hi" }] });
expect(chat.status).toBe(404);
const tts = await request(app).post("/v1/audio/synthesize")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ text: "x", voice: "nova", format: "mp3", speed: 1 });
expect(tts.status).toBe(404);
});
it("chat supplier does NOT expose /v1/ocr/extract", async () => {
const app = makeApp(buildSampleConfig());
const res = await request(app).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
expect(res.status).toBe(404);
});
});
describe("POST /v1/ocr/extract — header + body validation", () => {
it("400 escrow_ref_required when X-Escrow-Ref header missing", async () => {
const res = await request(makeApp()).post("/v1/ocr/extract").send(validBody());
expect(res.status).toBe(400);
expect(res.body.reason).toBe("escrow_ref_required");
});
it("400 escrow_ref_malformed when header is not <64hex>#<int>", async () => {
const res = await request(makeApp()).post("/v1/ocr/extract")
.set("X-Escrow-Ref", "not-a-ref").send(validBody());
expect(res.status).toBe(400);
expect(res.body.reason).toBe("escrow_ref_malformed");
});
it("400 image_required when image_b64 missing or empty", async () => {
const app = makeApp();
for (const body of [{}, { image_b64: "" }, { mime: "image/png", output_format: "markdown" }]) {
const res = await request(app).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(body);
expect(res.status).toBe(400);
expect(res.body.reason).toBe("image_required");
}
});
it("400 image_not_base64 for data-URL or whitespace payloads", async () => {
const app = makeApp();
for (const image_b64 of [`data:image/png;base64,${IMG}`, `${IMG} `, "has space"]) {
const res = await request(app).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ ...validBody(), image_b64 });
expect(res.status).toBe(400);
expect(res.body.reason).toBe("image_not_base64");
}
});
it("400 mime_invalid for unsupported mime", async () => {
const res = await request(makeApp()).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ ...validBody(), mime: "image/tiff" });
expect(res.status).toBe(400);
expect(res.body.reason).toBe("mime_invalid");
});
it("400 output_format_invalid for unsupported format", async () => {
const res = await request(makeApp()).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ ...validBody(), output_format: "yaml" });
expect(res.status).toBe(400);
expect(res.body.reason).toBe("output_format_invalid");
});
it("accepts a 2MB body (over the 1mb global limit — OCR route has its own parser)", async () => {
// 2M chars of base64 ≈ 1.5 MB binary; global express.json would 413 this.
const big = "A".repeat(2_000_000);
const res = await request(makeApp()).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER)
.send({ ...validBody(), image_b64: big });
// Passes body parsing + validation, then fails on the unseeded chain
// (advert_unavailable 503) — anything but 413 proves the parser took it.
expect(res.status).not.toBe(413);
expect([403, 404, 503]).toContain(res.status);
});
});
describe("POST /v1/ocr/extract — revision-probe gate", () => {
it("503 advert_unavailable — chain validation runs before the revision probe fires", async () => {
// Advert/escrow would resolve AFTER the probe? No — probe runs at step
// 7.5, after chain checks. Seeding a full escrow in the mock provider is
// the chat-route suite's job; here we assert the config gate wiring by
// hitting the earlier chain-side failure with probing configured — the
// probe must NOT fire before chain validation (unseeded chain 503s
// first), keeping probe cost off the hot invalid path.
vi.stubGlobal("fetch", async () => { throw new Error("probe must not be called yet"); });
const config: SupplierConfig = {
...buildSampleOcrConfig(),
revisionProbeUrl: "http://vllm.fake:8000/info",
revisionProbeMode: "per_job",
hfModelRevision: "a".repeat(40),
};
const res = await request(makeApp(config)).post("/v1/ocr/extract")
.set("X-Escrow-Ref", ESCROW_REF_HEADER).send(validBody());
// Unseeded chain → advert_unavailable (503) BEFORE any probe fetch.
expect(res.status).toBe(503);
expect(res.body.reason).toBe("advert_unavailable");
});
});
describe("GET /v1/ocr/extract/:jobId", () => {
it("400 invalid_job_id for non-UUID", async () => {
const res = await request(makeApp()).get("/v1/ocr/extract/not-a-uuid");
expect(res.status).toBe(400);
expect(res.body.reason).toBe("invalid_job_id");
});
it("404 job_not_found for unknown UUID", async () => {
const res = await request(makeApp())
.get("/v1/ocr/extract/123e4567-e89b-42d3-a456-426614174000");
expect(res.status).toBe(404);
expect(res.body.reason).toBe("job_not_found");
});
});