import { describe, expect, it } from "vitest"; import { createReadOnlyProxy } from "../lib/read-only-prisma.js"; /** * Ticket #47 — read-only proxy must survive the scoped-caller indirection. * * assistant-tools.ts::executeTool swaps `ctx.db` for a read-only proxy when * dispatching non-mutation tools. Tool executors then call * `createScopedCallerContext(ctx)` which forwards `ctx.db` to a tRPC caller. * If the proxy were not preserved through that forwarding, an LLM-invoked * "read" tool could smuggle writes via the caller path. * * This suite asserts the proxy is not unwrapped on forwarding, and that * every write-flavoured client method (model writes, raw SQL, interactive * transactions, runCommandRaw) is still blocked after forwarding. */ describe("read-only proxy survives scoped-caller forwarding (#47)", () => { function makeFakeClient() { // Minimal shape that passes the Proxy's model detection (has findMany). const user = { findUnique: async () => ({ id: "u1" }), findMany: async () => [], create: async () => ({ id: "u1" }), update: async () => ({ id: "u1" }), }; return { user, $queryRaw: async () => [], $queryRawUnsafe: async () => [], $executeRaw: async () => 0, $executeRawUnsafe: async () => 0, $transaction: async () => [], $runCommandRaw: async () => ({ ok: 1 }), }; } // Simulate what createScopedCallerContext does: construct a NEW object // whose `db` key is assigned from the incoming ctx.db. This is the exact // forwarding pattern used by helpers.ts::createScopedCallerContext. function forwardToCaller(ctx: { db: unknown }): { db: unknown } { return { db: ctx.db }; } it("ctx.db retains proxy identity after forwarding", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const client = makeFakeClient() as any; const proxied = createReadOnlyProxy(client); const forwarded = forwardToCaller({ db: proxied }); // Writes through the forwarded db must still throw. // eslint-disable-next-line @typescript-eslint/no-explicit-any expect(() => (forwarded.db as any).user.create({ data: {} })).toThrow( /not permitted on read-only/, ); }); it("raw/tx escape hatches still blocked after forwarding", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const client = makeFakeClient() as any; const proxied = createReadOnlyProxy(client); const forwarded = forwardToCaller({ db: proxied }) as { db: Record }; expect(() => forwarded.db.$executeRaw!`DELETE FROM users`).toThrow( /Raw\/escape operation "\$executeRaw" not permitted/, ); expect(() => forwarded.db.$executeRawUnsafe!("DELETE FROM users")).toThrow( /Raw\/escape operation "\$executeRawUnsafe" not permitted/, ); expect(() => forwarded.db.$queryRawUnsafe!("SELECT 1")).toThrow( /Raw\/escape operation "\$queryRawUnsafe" not permitted/, ); expect(() => forwarded.db.$transaction!([])).toThrow( /Raw\/escape operation "\$transaction" not permitted/, ); expect(() => forwarded.db.$runCommandRaw!({})).toThrow( /Raw\/escape operation "\$runCommandRaw" not permitted/, ); }); it("reads still succeed after forwarding (positive control)", async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const client = makeFakeClient() as any; const proxied = createReadOnlyProxy(client); const forwarded = forwardToCaller({ db: proxied }) as { db: { user: { findUnique: (a: unknown) => Promise } }; }; await expect(forwarded.db.user.findUnique({ where: { id: "u1" } })).resolves.toEqual({ id: "u1", }); }); });