import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("@/db/index.js", async () => { const { createTestDb } = await import("../test/db.js"); return { db: await createTestDb() }; }); vi.mock("@/automations/pds.js", () => ({ patchArbitraryRecord: vi.fn(), })); vi.mock("@/pds/resolver.js", async (importOriginal) => { const original = await importOriginal(); return { ...original, fetchRecord: vi.fn(), }; }); import { executePatchRecord } from "./patch-record.js"; import { patchArbitraryRecord } from "../automations/pds.js"; import { fetchRecord } from "../pds/resolver.js"; import { db } from "../db/index.js"; import { automations, deliveryLogs } from "../db/schema.js"; import { makeMatch, makeAutomation, makePatchRecordAction } from "../test/fixtures.js"; import { isRetryable } from "./delivery.js"; const mockPatchRecord = vi.mocked(patchArbitraryRecord); const mockFetchRecord = vi.mocked(fetchRecord); const ALICE_DID = "did:plc:testuser123"; const ALICE_DOC_URI = `at://${ALICE_DID}/site.standard.document/abc`; function makeFetchResult(record: Record, cid = "cid-base") { return { found: true as const, uri: ALICE_DOC_URI, cid, did: ALICE_DID, collection: "site.standard.document", rkey: "abc", record: { $type: "site.standard.document", ...record }, }; } function makeMatchFor(action: ReturnType) { return makeMatch({ automation: { actions: [action] }, event: { did: ALICE_DID, time_us: 1700000000000000, kind: "commit", commit: { rev: "r", operation: "create", collection: "site.standard.document", rkey: "abc", record: { text: "trigger" }, }, }, }); } describe("executePatchRecord — collection mismatch guard", () => { beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); mockPatchRecord.mockReset(); mockFetchRecord.mockReset(); await db.delete(deliveryLogs); await db.delete(automations); await db.insert(automations).values(makeAutomation()); }); afterEach(() => { vi.useRealTimers(); }); it("rejects with 400 when the resolved URI's collection differs from targetCollection", async () => { // The action targets `run.airglow.automation` (its default in the fixture // is `site.standard.document`, but we override here). The resolved URI // points at `app.bsky.feed.post`, simulating an attacker-injected item // whose uri redirects the patch to a different collection. const action = makePatchRecordAction({ targetCollection: "run.airglow.automation", baseRecordUri: "at://{{event.did}}/app.bsky.feed.post/abc123", }); const match = makeMatch({ automation: { actions: [action] } }); const result = await executePatchRecord(match, 0); expect(result.statusCode).toBe(400); expect(result.error).toMatch(/does not match targetCollection/); // PDS write was never attempted — the attack is fully blocked. expect(mockPatchRecord).not.toHaveBeenCalled(); }); it("proceeds normally when the URI's collection matches", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ title: "old" })); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" }); // Default fixture: targetCollection is "site.standard.document" and // baseRecordUri uses `{{event.commit.collection}}`. We construct a match // whose event.commit.collection is also "site.standard.document" so the // resolved URI's collection matches. const action = makePatchRecordAction({ recordTemplate: '{"title":"new"}', }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(200); expect(mockPatchRecord).toHaveBeenCalledOnce(); }); }); describe("executePatchRecord: swapRecord (CID precondition)", () => { beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); mockPatchRecord.mockReset(); mockFetchRecord.mockReset(); await db.delete(deliveryLogs); await db.delete(automations); await db.insert(automations).values(makeAutomation()); }); afterEach(() => { vi.useRealTimers(); }); it("passes the fetched CID as swapRecord on the shallow path", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ title: "old" }, "cid-at-fetch")); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" }); const action = makePatchRecordAction({ recordTemplate: '{"title":"new"}' }); await executePatchRecord(makeMatchFor(action), 0); expect(mockPatchRecord).toHaveBeenCalledWith( ALICE_DID, "site.standard.document", "abc", expect.any(Object), "cid-at-fetch", ); }); it("passes the fetched CID as swapRecord on the deep path", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ title: "old" }, "cid-deep-fetch")); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" }); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/title","value":"new"}]', }); await executePatchRecord(makeMatchFor(action), 0); expect(mockPatchRecord).toHaveBeenCalledWith( ALICE_DID, "site.standard.document", "abc", expect.any(Object), "cid-deep-fetch", ); }); it("surfaces a 409 from a stale CID as a non-retryable failure", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ title: "old" }, "cid-stale")); mockPatchRecord.mockRejectedValueOnce( new Error("InvalidSwap: Record was at another value (409)"), ); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/title","value":"new"}]', }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(409); expect(result.error).toMatch(/InvalidSwap/); expect(isRetryable(result.statusCode)).toBe(false); }); }); describe("executePatchRecord: deep mode (JSON Patch)", () => { beforeEach(async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); mockPatchRecord.mockReset(); mockFetchRecord.mockReset(); await db.delete(deliveryLogs); await db.delete(automations); await db.insert(automations).values(makeAutomation()); }); afterEach(() => { vi.useRealTimers(); }); it("updates a nested object field without disturbing siblings", async () => { mockFetchRecord.mockResolvedValueOnce( makeFetchResult({ profile: { displayName: "old", bio: "preserved" } }), ); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" }); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/profile/displayName","value":"new"}]', }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(200); const written = mockPatchRecord.mock.calls[0]![3]; expect(written).toEqual({ profile: { displayName: "new", bio: "preserved" }, }); }); it("toggles a single field on a specific array index (the driving use case)", async () => { const layers = Array.from({ length: 9 }, () => ({ equipped: false })); mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ layers })); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" }); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/layers/8/equipped","value":true}]', }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(200); const written = mockPatchRecord.mock.calls[0]![3] as { layers: { equipped: boolean }[] }; expect(written.layers).toHaveLength(9); expect(written.layers[8]).toEqual({ equipped: true }); for (let i = 0; i < 8; i++) { expect(written.layers[i]).toEqual({ equipped: false }); } }); it("applies a multi-op patch touching both nested and top-level fields", async () => { mockFetchRecord.mockResolvedValueOnce( makeFetchResult({ title: "t", profile: { displayName: "old" } }), ); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" }); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: JSON.stringify([ { op: "replace", path: "/title", value: "t2" }, { op: "replace", path: "/profile/displayName", value: "new" }, ]), }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(200); expect(mockPatchRecord.mock.calls[0]![3]).toEqual({ title: "t2", profile: { displayName: "new" }, }); }); it("returns a deterministic failure when an op targets an out-of-range index", async () => { const layers = Array.from({ length: 9 }, () => ({ equipped: false })); mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ layers })); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/layers/99/equipped","value":true}]', }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(0); expect(result.error).toMatch(/op 0/); expect(result.error).toMatch(/out of range/); expect(mockPatchRecord).not.toHaveBeenCalled(); }); it("respects the test op as a precondition (passes)", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ v: 1 })); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" }); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: JSON.stringify([ { op: "test", path: "/v", value: 1 }, { op: "replace", path: "/v", value: 2 }, ]), }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(200); expect(mockPatchRecord.mock.calls[0]![3]).toEqual({ v: 2 }); }); it("respects the test op as a precondition (fails before write)", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ v: 5 })); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: JSON.stringify([ { op: "test", path: "/v", value: 1 }, { op: "replace", path: "/v", value: 2 }, ]), }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(0); expect(result.error).toMatch(/test failed/); expect(mockPatchRecord).not.toHaveBeenCalled(); }); it("rejects a template that doesn't render to an op array", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ v: 1 })); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '{"v":2}', }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(0); expect(result.error).toMatch(/Template error/); expect(mockPatchRecord).not.toHaveBeenCalled(); }); it("resolves {{placeholders}} inside op values", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ lastFiredBy: "old" })); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" }); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/lastFiredBy","value":"{{event.did}}"}]', }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(200); expect(mockPatchRecord.mock.calls[0]![3]).toEqual({ lastFiredBy: ALICE_DID }); }); it("rejects an op array that would replace the root with a non-object", async () => { // A `replace` with path "" swaps the whole document. If the value is a // scalar (or array), the result can't be written as a PDS record. The // execute layer guards against this independently of applyJsonPatch. mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ v: 1 })); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"","value":42}]', }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(0); expect(result.error).toMatch(/non-object result/); expect(mockPatchRecord).not.toHaveBeenCalled(); }); it("resolves {{placeholders}} embedded inside an op path", async () => { // Driving variant of the layers case where the array index is itself // event-derived. Confirms the save-time validator doesn't choke on the // placeholder substitution sentinel and that render-time resolution // produces a well-formed JSON Pointer. const layers = Array.from({ length: 4 }, () => ({ equipped: false })); mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ layers })); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" }); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"/layers/{{event.commit.record.text}}/equipped","value":true}]', }); const match = makeMatch({ automation: { actions: [action] }, event: { did: ALICE_DID, time_us: 1700000000000000, kind: "commit", commit: { rev: "r", operation: "create", collection: "site.standard.document", rkey: "abc", record: { text: "2" }, }, }, }); const result = await executePatchRecord(match, 0); expect(result.statusCode).toBe(200); const written = mockPatchRecord.mock.calls[0]![3] as { layers: { equipped: boolean }[] }; expect(written.layers[2]).toEqual({ equipped: true }); expect(written.layers[1]).toEqual({ equipped: false }); }); it("applies a JSONPath filter to flip the matched object's field", async () => { mockFetchRecord.mockResolvedValueOnce( makeFetchResult({ users: [ { id: "a", active: false }, { id: "b", active: false }, ], }), ); mockPatchRecord.mockResolvedValueOnce({ uri: ALICE_DOC_URI, cid: "cid-new" }); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"$.users[?@.id==\'b\'].active","value":true}]', }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(200); expect(mockPatchRecord.mock.calls[0]![3]).toEqual({ users: [ { id: "a", active: false }, { id: "b", active: true }, ], }); }); it("returns an informative failure when a JSONPath matches nothing", async () => { mockFetchRecord.mockResolvedValueOnce(makeFetchResult({ users: [{ id: "a", active: false }] })); const action = makePatchRecordAction({ patchMode: "deep", recordTemplate: '[{"op":"replace","path":"$.users[?@.id==\'b\'].active","value":true}]', }); const result = await executePatchRecord(makeMatchFor(action), 0); expect(result.statusCode).toBe(0); expect(result.error).toMatch(/JSON Patch op 0: JSONPath .* matched nothing/); expect(mockPatchRecord).not.toHaveBeenCalled(); }); });