Files
agents-marketplace/tests/unit/tx-post-ocr-escrow.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

201 lines
7.7 KiB
TypeScript

/**
* tx-post-ocr-escrow.test.ts — coverage for the OCR escrow builder added
* for model-scoped `ocr.page.extract.<model-slug>.v1` capabilities.
*
* Focus: the NEW validation + hash logic in postOcrEscrow.ts. The shared
* escrow-construction pieces (advert resolution, datum assembly, mock tx
* encoding) mirror postTtsEscrow.ts and are covered by tx-post-escrow.test.ts
* for the chat shape; here we pin:
* - ocrPromptHash canonicalisation (golden hash — buyer/supplier contract)
* - envelope validation failures throw BEFORE any chain access
* - the escrow datum carries the OCR prompt_hash on the happy path
*/
import { describe, it, expect } from "vitest";
import { createHash } from "crypto";
import {
ocrPromptHash,
validateOcrRequest,
buildPostOcrEscrowTx,
MAX_OCR_IMAGE_B64_CHARS,
TxConstructionError,
type OcrRequest,
} from "../../packages/shared/src/tx/index.js";
import { canonicalize } from "../../packages/shared/src/cbor/canonical.js";
import { MockChainProvider } from "../../packages/shared/src/chain/MockChainProvider.js";
import { encodeAdvertDatum } from "../../packages/shared/src/cbor/AdvertDatum.js";
import { decodeEscrowDatum } from "../../packages/shared/src/cbor/EscrowDatum.js";
import { escrowLockFloor } from "../../packages/shared/src/tx/internal/minAdaFloor.js";
import type { AdvertDatum } from "../../packages/shared/src/cbor/types.js";
import type { Utxo, OutputReference } from "../../packages/shared/src/chain/ChainProvider.js";
import { buildBuyerWalletKey } from "../fixtures/buyer-side/wallet-keys.js";
import { buildSupplierWalletKey } from "../fixtures/supplier-side/wallet-keys.js";
const IMG = Buffer.from("not really a png but bytes are bytes").toString("base64");
function validRequest(): OcrRequest {
return { image_b64: IMG, mime: "image/png", output_format: "markdown" };
}
describe("ocrPromptHash — canonicalisation contract", () => {
it("equals sha256(canonicalize({image_b64, mime, output_format}))", () => {
const req = validRequest();
const expected = createHash("sha256")
.update(canonicalize({
image_b64: req.image_b64,
mime: req.mime,
output_format: req.output_format,
}), "utf8")
.digest("hex");
expect(ocrPromptHash(req)).toBe(expected);
});
it("is key-order independent (JCS canonicalisation)", () => {
const a = ocrPromptHash({ image_b64: IMG, mime: "image/png", output_format: "json" });
const b = ocrPromptHash({ output_format: "json", mime: "image/png", image_b64: IMG } as OcrRequest);
expect(a).toBe(b);
});
it("changes when any field changes", () => {
const base = ocrPromptHash(validRequest());
expect(ocrPromptHash({ ...validRequest(), mime: "image/jpeg" })).not.toBe(base);
expect(ocrPromptHash({ ...validRequest(), output_format: "html" })).not.toBe(base);
expect(ocrPromptHash({ ...validRequest(), image_b64: IMG + "AA" })).not.toBe(base);
});
});
describe("validateOcrRequest — envelope gates", () => {
it("rejects empty image", () => {
expect(() => validateOcrRequest({ ...validRequest(), image_b64: "" }))
.toThrowError(TxConstructionError);
});
it("rejects oversized image", () => {
const big = "A".repeat(MAX_OCR_IMAGE_B64_CHARS + 4);
expect(() => validateOcrRequest({ ...validRequest(), image_b64: big }))
.toThrow(/exceeds/);
});
it("rejects data-URL prefixes and whitespace (plain base64 only)", () => {
for (const bad of [`data:image/png;base64,${IMG}`, `${IMG} `, "with space AA"]) {
expect(() => validateOcrRequest({ ...validRequest(), image_b64: bad }))
.toThrow(/base64/);
}
});
it("rejects unknown mime and output_format", () => {
expect(() => validateOcrRequest({ ...validRequest(), mime: "image/tiff" }))
.toThrow(/mime/);
expect(() => validateOcrRequest({ ...validRequest(), output_format: "yaml" }))
.toThrow(/output_format/);
});
});
describe("buildPostOcrEscrowTx — validation precedes chain access", () => {
it("throws the envelope error against an unseeded chain (no advert query first)", async () => {
const chain = new MockChainProvider();
const buyerKey = {
pubKeyHash: "ab".repeat(28),
pubKeyHex: "cd".repeat(32),
privateKeyHex: "ef".repeat(32),
address: "addr_test1fake",
};
await expect(buildPostOcrEscrowTx({
chain,
buyerKey,
advertRef: { txHash: "b".repeat(64), index: 0 },
request: { ...validRequest(), mime: "image/gif" },
payment_lovelace: 200000n,
})).rejects.toThrow(/mime/);
});
it("unseeded advert ref → 'advert ref not on chain' after a valid envelope", async () => {
const chain = new MockChainProvider();
const buyerKey = {
pubKeyHash: "ab".repeat(28),
pubKeyHex: "cd".repeat(32),
privateKeyHex: "ef".repeat(32),
address: "addr_test1fake",
};
await expect(buildPostOcrEscrowTx({
chain,
buyerKey,
advertRef: { txHash: "b".repeat(64), index: 0 },
request: validRequest(),
payment_lovelace: 200000n,
})).rejects.toMatchObject({ reason: "advert ref not on chain" });
});
});
describe("buildPostOcrEscrowTx — happy path locks the min-ada floor (C1 regression)", () => {
// Guards packages/shared/src/tx/escrow/postOcrEscrow.ts:205
// (`escrowLockFloor(escrowDatum, economicTotal)`). Without that call the
// escrow locks only the raw economic total, the Submit continuing output
// gets bumped to min-ada, value_equal fails, and the validator errors with
// an empty trace — the 2026-08-07 mainnet incident. Neither existing
// `buildPostOcrEscrowTx` test above reaches line 205 (both throw first),
// so this is the only test that would catch a regression back to a bare
// `economicTotal` assignment.
const ADVERT_SCRIPT_ADDRESS = "addr_test1wrqq9qqjzf3uh4w9hm0kqzrpvt60r4ryjp5rjf5epd3nptq7yscm6";
const ADVERT_REF: OutputReference = { txHash: "c".repeat(64), index: 0 };
const PRICE = 200_000n;
const BUYER_BOND = 1_000_000n;
const SUPPLIER_BOND = 1_000_000n;
const ECONOMIC_TOTAL = PRICE + BUYER_BOND + SUPPLIER_BOND; // 2_200_000n
function makeOcrAdvert(): AdvertDatum {
const supplier = buildSupplierWalletKey();
return {
supplier_pkh: supplier.pubKeyHash,
capability_id: "ocr.page.extract.chandra-ocr-2.v1",
model: "chandra-ocr-2",
max_output_tokens: 4096,
max_processing_ms: 60_000,
price_lovelace: PRICE,
supplier_bond_lovelace: SUPPLIER_BOND,
buyer_bond_lovelace: BUYER_BOND,
endpoint_url: "https://supplier.example.com/v1",
detail_uri: "ipfs://Qm000",
detail_hash: "a".repeat(64),
advertised_at: 1_745_500_000_000,
status: "Active",
};
}
function seedOcrAdvert(chain: MockChainProvider): void {
const utxo: Utxo = {
ref: ADVERT_REF,
address: ADVERT_SCRIPT_ADDRESS,
lovelace: 2_000_000n,
assets: {},
datumHex: encodeAdvertDatum(makeOcrAdvert()),
scriptRef: null,
};
chain.seed(utxo);
}
it("locks escrowLockFloor(datum, economicTotal), strictly above the raw economic total", async () => {
const chain = new MockChainProvider();
chain.advanceSlot(1_745_500_000);
seedOcrAdvert(chain);
const buyer = buildBuyerWalletKey();
const result = await buildPostOcrEscrowTx({
chain,
buyerKey: buyer,
advertRef: ADVERT_REF,
request: validRequest(),
payment_lovelace: PRICE,
});
const escrowUtxo = await chain.queryUtxo(result.escrowOutputRef);
expect(escrowUtxo).not.toBeNull();
const datum = decodeEscrowDatum(escrowUtxo!.datumHex!);
const expectedFloor = escrowLockFloor(datum, ECONOMIC_TOTAL);
expect(escrowUtxo!.lovelace).toBe(expectedFloor);
expect(escrowUtxo!.lovelace).toBeGreaterThan(ECONOMIC_TOTAL);
});
});