/// /** * Whose cells a builtin's abandoned-request ending writes. * * The ending runs on cells a request may already hold, so it reads the store * as it stands and decides there. Two answers, and the store says which: a * request that is running holds its cells and the ending leaves them alone; a * request that finished holds nothing, and the ending replaces its answer, * which no longer describes the inputs the pattern is asking about. * * The refusal is arranged the way `builtin-abandoned-request.test.ts` arranges * it, with a caveat the sink's declared ceiling does not admit. What differs * here is that the refused request is not the node's first, so there is * committed state for the ending to decide against. */ import { expect } from "@std/expect"; import { afterEach, beforeEach, describe, it } from "@std/testing/bdd"; import { Identity } from "@commonfabric/identity"; import { table } from "@commonfabric/memory/sqlite/schema"; import type { SqliteDbRef, SqliteParamsWire } from "@commonfabric/memory/v2"; import { StorageManager } from "@commonfabric/runner/storage/cache.deno"; import { waitForCellValue } from "@commonfabric/integration/wait-for-cell-value"; import { createBuilder } from "../src/builder/factory.ts"; import { getPatternEnvironment, setPatternEnvironment } from "../src/env.ts"; import { Runtime } from "../src/runtime.ts"; import { MAX_ENFORCEMENT_CFC_OPTIONS, MAX_ENFORCEMENT_SINK_CEILINGS, } from "../src/runtime-presets.ts"; import type { IExtendedStorageTransaction } from "../src/storage/interface.ts"; import { createTrustedBuilder } from "./support/trusted-builder.ts"; const signer = await Identity.fromPassphrase("test operator"); const space = signer.did(); const PROMPT_INFLUENCE = { type: "https://commonfabric.org/cfc/atom/Caveat", kind: "https://commonfabric.org/cfc/concepts/prompt-influence", source: "of:hostile", } as const; /** A caveated string, as a cell an input slot can be pointed at later. */ const HOSTILE_STRING_SCHEMA = { type: "string", ifc: { confidentiality: [PROMPT_INFLUENCE] }, } as const; interface Deferred { promise: Promise; resolve: (value: T) => void; } function deferred(): Deferred { let resolve!: (value: T) => void; const promise = new Promise((res) => resolve = res); return { promise, resolve }; } describe("whose cells an abandoned request's ending writes", () => { let storageManager: ReturnType; let runtime: Runtime; let tx: IExtendedStorageTransaction; let commonfabric: ReturnType["commonfabric"]; let originalFetch: typeof globalThis.fetch; let originalPatternEnvironment: ReturnType; beforeEach(() => { originalFetch = globalThis.fetch; originalPatternEnvironment = getPatternEnvironment(); setPatternEnvironment({ apiUrl: new URL("http://mock-test-server.local") }); storageManager = StorageManager.emulate({ as: signer }); runtime = new Runtime({ apiUrl: new URL(import.meta.url), storageManager, ...MAX_ENFORCEMENT_CFC_OPTIONS, cfcEnforcementMode: "enforce-strict", // The bundle declares a ceiling for the fetch and streamData sinks and // none for the LLM ones, so an LLM request is ungated on // confidentiality there. These cases are about the ENDING a refused // request leaves behind, so they declare the ceiling the refusal comes // from. Before the runtime's own stores declared what flowed into them, // the refusal arrived by accident instead: the builtin's result store // could not hold the caveat, so the transaction staging the request was // refused for a reason about the store rather than about the request. cfcSinkMaxConfidentiality: { ...MAX_ENFORCEMENT_SINK_CEILINGS, llm: [], llmDialog: [], generateText: [], generateObject: [], // The `sqliteQuery` sink is ungated under the bundle for a reason of // its own — the bound a read wants is the database's space, which a // clause list cannot hold — so the query case here declares the // ceiling its refusal comes from, as the llm cases above do. sqliteQuery: [], }, }); tx = runtime.edit(); ({ commonfabric } = createTrustedBuilder(runtime)); }); afterEach(async () => { globalThis.fetch = originalFetch; setPatternEnvironment(originalPatternEnvironment); await tx.commit().settled; await runtime.idle(); await runtime.dispose(); await storageManager.close(); }); /** * Resolve once a commit is refused by CFC enforcement. The scheduler reports * a terminal rejection and then, in the same turn, tells the work staged on * that transaction that no further attempt is coming — so a caller that * resumes on this has an ending that has already decided. */ function waitForRefusal(): Promise { const refused = deferred(); runtime.scheduler.onError((error: Error) => { if (error.message.includes("CFC enforcement rejected commit")) { refused.resolve(); } }); return refused.promise; } it("replaces a finished fetch's answer when the next request is refused", async () => { globalThis.fetch = (input: string | URL | Request) => Promise.resolve( new Response(JSON.stringify({ from: String(input) }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); const { pattern, fetchJson } = commonfabric; const testPattern = pattern<{ url: string }>(({ url }) => // deno-lint-ignore no-explicit-any fetchJson({ url } as any) ); const hostileUrl = runtime.getCell( space, "finished-fetch-hostile-url", HOSTILE_STRING_SCHEMA, tx, ); hostileUrl.set("http://mock-test-server.local/api/hostile"); const inputs = runtime.getCell<{ url: string }>( space, "finished-fetch-inputs", undefined, tx, ); inputs.set({ url: "http://mock-test-server.local/api/first" }); const resultCell = runtime.getCell( space, "finished-fetch-result", undefined, tx, ); const result = runtime.run(tx, testPattern, inputs, resultCell); runtime.prepareTxForCommit(tx); await tx.commit().settled; const answered = await waitForCellValue<{ result?: { from?: string } }>( runtime, result, (value) => value?.result !== undefined, ); expect(answered.result?.from).toContain("/api/first"); // The finished request left its claim id standing with the pending flag // down. Pointing the input at the caveated url stages a request whose // commit is refused, so the ending decides against exactly that state. await runtime.editWithRetry((edit) => { // deno-lint-ignore no-explicit-any inputs.withTx(edit).key("url").set(hostileUrl as any); }); const settled = await waitForCellValue<{ error?: string }>( runtime, result, (value) => typeof value?.error === "string" && value.error.length > 0, ); await runtime.settled(); expect(settled.error).toContain("was refused before it started"); expect(settled.error).not.toContain(PROMPT_INFLUENCE.source); expect(result.withTx().key("pending").get()).toBe(false); // The answer the pattern was left holding described the url it no longer // asks about, so the ending clears it along with the flag. expect(result.withTx().key("result").get()).toBeUndefined(); }); it("leaves a running fetch's claim alone when the next request is refused", async () => { const issued = deferred(); const held = deferred(); globalThis.fetch = () => { issued.resolve(); return held.promise; }; const { pattern, fetchJson } = commonfabric; const testPattern = pattern<{ url: string }>(({ url }) => // deno-lint-ignore no-explicit-any fetchJson({ url } as any) ); const hostileUrl = runtime.getCell( space, "running-fetch-hostile-url", HOSTILE_STRING_SCHEMA, tx, ); hostileUrl.set("http://mock-test-server.local/api/hostile"); const inputs = runtime.getCell<{ url: string }>( space, "running-fetch-inputs", undefined, tx, ); inputs.set({ url: "http://mock-test-server.local/api/held" }); const resultCell = runtime.getCell( space, "running-fetch-result", undefined, tx, ); const result = runtime.run(tx, testPattern, inputs, resultCell); runtime.prepareTxForCommit(tx); await tx.commit().settled; // A reader has to demand the fetch before it is issued; the runtime's // disposal ends the subscription. result.sink(() => {}); // The held response keeps the claim standing: the pending flag stays up // and the claim id stays that request's, which is the state the ending // must leave alone. await issued.promise; await waitForCellValue<{ pending?: boolean }>( runtime, result, (value) => value?.pending === true, ); const refused = waitForRefusal(); await runtime.editWithRetry((edit) => { // deno-lint-ignore no-explicit-any inputs.withTx(edit).key("url").set(hostileUrl as any); }); // The re-run is what stages the refused request, and the builtin's own // scheduling decides when it happens; settling the clock runs it. Awaiting // the refusal after that is what says the ending ran at all, so the two // assertions below cannot pass on a request that was never staged. await result.pull(); await clock.settle(); await refused; // Asserted here, while the claim is still standing, so what holds the // pending flag up is the request the ending stepped around rather than // anything later in the case. expect(result.withTx().key("pending").get()).toBe(true); expect(result.withTx().key("error").get()).toBeUndefined(); // Releasing the response settles the ending's own writeback, which is what // a commit the guard did not stop would ride. The released response writes // nothing itself: the run that changed the inputs aborted this request, and // its writeback compares against the inputs as they stand, which have moved. held.resolve( new Response(JSON.stringify({ from: "held" }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); await runtime.settled(); expect(result.withTx().key("pending").get()).toBe(true); expect(result.withTx().key("error").get()).toBeUndefined(); }); it("replaces a finished query's rows when the next query is refused", async () => { const { pattern, sqliteQuery } = commonfabric; // A database of this case's own: the emulated engine keeps a file per // handle id, so a fixed id would carry one run's rows into the next. const db: SqliteDbRef = { id: `of:finished-query-${crypto.randomUUID()}`, tables: { notes: table({ id: "integer primary key", body: "text" }) }, }; const seed = async (sql: string, params?: SqliteParamsWire) => { const seedTx = runtime.edit(); seedTx.recordSqliteWrite!(space, { op: "sqlite", db, sql, params }); runtime.prepareTxForCommit(seedTx); const seeded = await seedTx.commit().settled; if (seeded.error) throw seeded.error; }; await seed("INSERT INTO notes (body) VALUES (?)", ["one"]); const hostileSql = runtime.getCell( space, "finished-query-hostile-sql", HOSTILE_STRING_SCHEMA, tx, ); hostileSql.set("SELECT body FROM notes"); const inputs = runtime.getCell<{ sql: string }>( space, "finished-query-inputs", undefined, tx, ); inputs.set({ sql: "SELECT id FROM notes" }); const testPattern = pattern<{ sql: string }>(({ sql }) => // deno-lint-ignore no-explicit-any sqliteQuery({ db, sql, reactOn: db } as any) ); const resultCell = runtime.getCell( space, "finished-query-result", undefined, tx, ); const result = runtime.run(tx, testPattern, inputs, resultCell); runtime.prepareTxForCommit(tx); await tx.commit().settled; const answered = await waitForCellValue< { result?: Array> } >( runtime, result, (value) => Array.isArray(value?.result), ); expect(answered.result?.[0]).toEqual({ id: 1 }); // The finished query left its request hash standing with the pending flag // down, and the statement the pattern asks about has moved on. await runtime.editWithRetry((edit) => { // deno-lint-ignore no-explicit-any inputs.withTx(edit).key("sql").set(hostileSql as any); }); const settled = await waitForCellValue<{ error?: string }>( runtime, result, (value) => typeof value?.error === "string" && value.error.length > 0, ); await runtime.settled(); expect(settled.error).toContain("was refused before it started"); expect(settled.error).not.toContain(PROMPT_INFLUENCE.source); expect(result.withTx().key("pending").get()).toBe(false); expect(result.withTx().key("result").get()).toBeUndefined(); }); });