mirror of https://github.com/garrytan/gstack.git
137 lines
5.4 KiB
TypeScript
137 lines
5.4 KiB
TypeScript
/**
|
|
* receipted-fetch — egress receipts for the design binary's OpenAI calls.
|
|
*
|
|
* Pins the sink contract (fail-OPEN polarity, amendment T3/C8):
|
|
* - receipt is written BEFORE the send (ordering observable via the
|
|
* ledger's existence at fetch time)
|
|
* - the sha256 recorded is the hash of the JSON body; the body itself is
|
|
* never stored
|
|
* - streaming response bodies pass through the wrapper intact
|
|
* - an unwritable ledger warns on stderr and the call still proceeds
|
|
*/
|
|
|
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
import fs from "fs";
|
|
import os from "os";
|
|
import path from "path";
|
|
import { receiptedFetch } from "../src/receipted-fetch";
|
|
import { egressLedgerPath, listReceipts, sha256Hex } from "../../lib/egress-receipt";
|
|
|
|
let home: string;
|
|
let savedHome: string | undefined;
|
|
|
|
beforeEach(() => {
|
|
home = fs.mkdtempSync(path.join(os.tmpdir(), "design-receipt-"));
|
|
savedHome = process.env.GSTACK_HOME;
|
|
process.env.GSTACK_HOME = home;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedHome === undefined) delete process.env.GSTACK_HOME;
|
|
else process.env.GSTACK_HOME = savedHome;
|
|
try { fs.chmodSync(path.join(home, "security"), 0o700); } catch {}
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("receiptedFetch", () => {
|
|
test("writes the receipt BEFORE the send; sha256 is the hash of the JSON body", async () => {
|
|
const body = JSON.stringify({ model: "gpt-4o", input: "a prompt" });
|
|
let receiptsAtFetchTime = -1;
|
|
const stub = (async (_url: any, init?: any) => {
|
|
// Receipt-before-send: by the time fetch runs, the receipt exists.
|
|
receiptsAtFetchTime = listReceipts(home).length;
|
|
expect(init.body).toBe(body); // body passes through untouched
|
|
return new Response("{}", { status: 200 });
|
|
}) as typeof globalThis.fetch;
|
|
|
|
const response = await receiptedFetch("generate-image-request", "https://api.openai.com/v1/responses", {
|
|
method: "POST",
|
|
body,
|
|
}, stub);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(receiptsAtFetchTime).toBe(1);
|
|
const receipts = listReceipts(home);
|
|
expect(receipts.length).toBe(1);
|
|
expect(receipts[0].sink).toBe("design-openai");
|
|
expect(receipts[0].host).toBe("api.openai.com");
|
|
expect(receipts[0].payload_class).toBe("generate-image-request");
|
|
expect(receipts[0].sha256).toBe(sha256Hex(body));
|
|
expect(receipts[0].bytes).toBe(Buffer.byteLength(body));
|
|
// Hash only — the ledger never contains the body text.
|
|
const raw = fs.readFileSync(egressLedgerPath(home), "utf-8");
|
|
expect(raw).not.toContain("a prompt");
|
|
});
|
|
|
|
test("streaming response body arrives intact through the wrapper", async () => {
|
|
const chunks = ["data: one\n", "data: two\n", "data: [DONE]\n"];
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
for (const c of chunks) controller.enqueue(new TextEncoder().encode(c));
|
|
controller.close();
|
|
},
|
|
});
|
|
const stub = (async () => new Response(stream, { status: 200 })) as typeof globalThis.fetch;
|
|
|
|
const response = await receiptedFetch("evolve-image-request", "https://api.openai.com/v1/responses", {
|
|
method: "POST",
|
|
body: JSON.stringify({ stream: true }),
|
|
}, stub);
|
|
|
|
expect(response.body).toBeInstanceOf(ReadableStream);
|
|
expect(await response.text()).toBe(chunks.join(""));
|
|
});
|
|
|
|
test("non-string request body (ReadableStream) is receipted as sha256:null, not consumed", async () => {
|
|
const requestStream = new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
controller.enqueue(new TextEncoder().encode("streamed-bytes"));
|
|
controller.close();
|
|
},
|
|
});
|
|
let receivedBody: any = null;
|
|
const stub = (async (_url: any, init?: any) => {
|
|
receivedBody = init.body;
|
|
return new Response("{}", { status: 200 });
|
|
}) as typeof globalThis.fetch;
|
|
|
|
await receiptedFetch("stream-upload", "https://api.openai.com/v1/responses", {
|
|
method: "POST",
|
|
body: requestStream,
|
|
}, stub);
|
|
|
|
expect(receivedBody).toBe(requestStream); // same stream object, untouched
|
|
const receipts = listReceipts(home);
|
|
expect(receipts[0].sha256).toBeNull();
|
|
// The stream is still readable by the consumer (was not drained to hash).
|
|
expect(await new Response(receivedBody).text()).toBe("streamed-bytes");
|
|
});
|
|
|
|
test("fail-open: unwritable ledger warns on stderr and the call proceeds", async () => {
|
|
if (process.platform === "win32" || process.getuid?.() === 0) return;
|
|
fs.mkdirSync(path.join(home, "security"), { recursive: true, mode: 0o500 });
|
|
let fetched = false;
|
|
const stub = (async () => { fetched = true; return new Response("{}", { status: 200 }); }) as typeof globalThis.fetch;
|
|
|
|
const captured: string[] = [];
|
|
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
(process.stderr as any).write = (chunk: string) => { captured.push(String(chunk)); return true; };
|
|
let response: Response;
|
|
try {
|
|
response = await receiptedFetch("check-screenshot-request", "https://api.openai.com/v1/chat/completions", {
|
|
method: "POST",
|
|
body: "{}",
|
|
}, stub);
|
|
} finally {
|
|
(process.stderr as any).write = originalWrite;
|
|
}
|
|
|
|
expect(fetched).toBe(true); // the call proceeded
|
|
expect(response.status).toBe(200);
|
|
const warning = captured.join("");
|
|
expect(warning).toContain("egress receipt could not be written");
|
|
expect(warning).toContain("fail-open");
|
|
expect(warning).toContain("gstack-egress");
|
|
});
|
|
});
|