import { afterEach, describe, expect, it, vi } from "vitest"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { ClientResponseError } from "@atcute/client"; import oauthMetadata from "#lib/oauth-client-metadata.ts"; import { missingPermissions } from "#lib/auth/scopes.ts"; import { atUriOf, cidOf, didOf, knotOf, nsidOf, recordKeyOf } from "#lib/api/syntax.ts"; import type * as Cob from "#lib/api/cob.ts"; const agent = { sub: "did:plc:limpet" } as unknown as OAuthUserAgent; const knot = knotOf("knot.example.com"); const repo = didOf("did:plc:barnacle"); const collection = nsidOf("org.tangled.track.ticket"); const record = { $type: "org.tangled.track.ticket", title: { text: "kelp" } }; const uri = atUriOf("at://did:plc:barnacle/org.tangled.track.ticket/3lk"); const readCid = "bafyreiread" + "a".repeat(48); const storedCid = "bafyreistored" + "a".repeat(46); const written = { uri, cid: "bafyreiknot" + "a".repeat(48) }; const from = { uri, cid: cidOf(readCid) }; const change = "5d1f0a3c9b8e47d2a1c6f4e8b3d7a2c5e9f1b6d8"; const load = async (...responses: (() => Response)[]) => { vi.resetModules(); const minted: { aud: string; lxm: string }[] = []; const attested: { agent: unknown; subject: unknown; knot: unknown; change: unknown }[] = []; vi.doMock("$app/env", () => ({ browser: true })); vi.doMock("#lib/api/attestation.ts", () => ({ attest: (agent: unknown, subject: unknown, knot: unknown, change: unknown) => attested.push({ agent, subject, knot, change }) })); vi.doMock("#lib/auth/agent.ts", () => ({ mintServiceAuth: async (_agent: unknown, { aud, lxm }: { aud: string; lxm: string }) => { minted.push({ aud, lxm }); return `token-for-${aud}`; } })); const cob = (await import("#lib/api/cob.ts")) as typeof Cob; const cache = await import("#lib/api/cache.ts"); const calls: { url: string; init: RequestInit }[] = []; vi.stubGlobal("fetch", async (input: URL | RequestInfo, init?: RequestInit) => { calls.push({ url: String(input), init: init ?? {} }); return (responses.shift() ?? (() => Response.json(written)))(); }); const sent = (index = 0) => JSON.parse(String(calls[index].init.body)); return { cob, cache, minted, calls, sent, attested }; }; afterEach(() => { vi.unstubAllGlobals(); }); describe("cob writes go to the repo's knot", () => { it("creates without an rkey and returns what the knot minted", async () => { const { cob, minted, calls, sent } = await load(); const result = await cob.createCob(agent, knot, repo, collection, record); expect(result).toEqual(written); expect(calls[0].url).toBe(`https://${knot}/xrpc/com.atproto.repo.createRecord`); expect(sent()).toEqual({ repo, collection, record }); expect(minted).toEqual([{ aud: repo, lxm: "com.atproto.repo.createRecord" }]); expect(new Headers(calls[0].init.headers).get("authorization")).toBe( `Bearer token-for-${repo}` ); }); it("sends the rkey when the caller picks one", async () => { const { cob, sent } = await load(); const label = { $type: "org.tangled.label.definition", name: "bug" }; await cob.createCob( agent, knot, repo, nsidOf("org.tangled.label.definition"), label, recordKeyOf("bug") ); expect(sent()).toEqual({ repo, collection: "org.tangled.label.definition", rkey: "bug", record: label }); }); it("edits the record the ref names, against its cid", async () => { const { cob, minted, sent } = await load(); await cob.editCob(agent, knot, from, record); expect(sent()).toEqual({ repo, collection, rkey: "3lk", swapRecord: readCid, record }); expect(minted[0]).toEqual({ aud: repo, lxm: "com.atproto.repo.putRecord" }); }); it("erases the record the ref names, against its cid", async () => { const { cob, minted, sent } = await load(() => Response.json({ commit: {} })); await cob.eraseCob(agent, knot, from); expect(sent()).toEqual({ repo, collection, rkey: "3lk", swapRecord: readCid }); expect(minted[0].lxm).toBe("com.atproto.repo.deleteRecord"); }); it("attests the version the knot made, for creates and edits, not erases", async () => { const edited = { uri, cid: cidOf("bafyreiedit" + "a".repeat(48)) }; const { cob, attested } = await load( () => Response.json({ ...written, "x-tngl-change": change }), () => Response.json(edited), () => Response.json({ commit: {} }) ); await cob.createCob(agent, knot, repo, collection, record); await cob.editCob(agent, knot, from, record); await cob.eraseCob(agent, knot, from); expect(attested).toStrictEqual([ { agent, subject: written, knot, change }, { agent, subject: edited, knot, change: undefined } ]); }); it("still returns the write when the knot's change id is malformed", async () => { const { cob, attested } = await load(() => Response.json({ ...written, "x-tngl-change": "not-an-oid" }) ); const result = await cob.createCob(agent, knot, repo, collection, record); expect(result).toEqual(written); expect(attested).toStrictEqual([{ agent, subject: written, knot, change: undefined }]); }); it("attests nothing when the knot refuses the write", async () => { const { cob, attested } = await load(() => Response.json({ error: "Forbidden" }, { status: 403 }) ); await cob.createCob(agent, knot, repo, collection, record).catch(() => {}); expect(attested).toEqual([]); }); it("drops cached reads after a write lands", async () => { const { cob, cache } = await load(); const read = vi.fn().mockResolvedValue("stale"); await cache.cached("issues:did:plc:barnacle:open:", 30_000, read); await cob.createCob(agent, knot, repo, collection, record); await cache.cached("issues:did:plc:barnacle:open:", 30_000, read); expect(read).toHaveBeenCalledTimes(2); }); it("surfaces the knot's refusal and keeps the cache", async () => { const { cob, cache } = await load(() => Response.json({ error: "Forbidden", message: "policy leaves you out" }, { status: 403 }) ); const read = vi.fn().mockResolvedValue("fresh"); await cache.cached("issues:did:plc:barnacle:open:", 30_000, read); const thrown = await cob.createCob(agent, knot, repo, collection, record).catch((e) => e); await cache.cached("issues:did:plc:barnacle:open:", 30_000, read); expect(thrown).toBeInstanceOf(ClientResponseError); expect((thrown as ClientResponseError).error).toBe("Forbidden"); expect(read).toHaveBeenCalledTimes(1); }); it("won't make a ref from a view that's missing its cid", async () => { const { cob } = await load(); expect(() => cob.cobRefOf({ uri })).toThrow(); expect(cob.cobRefOf({ uri, cid: readCid })).toEqual(from); }); it("won't build refs from views whose CIDs aren't valid CIDs", async () => { const { cob } = await load(); expect(() => cob.cobRefOf({ uri, cid: "not-a-cid" })).toThrow( '"not-a-cid" is not a valid CID.' ); }); it("uploads blobs with caller-supplied content type and parses returned blobjects", async () => { const { cob, minted, calls } = await load(() => Response.json({ blob: { $type: "blob", ref: { $link: "bafyreiblobaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, mimeType: "image/png", size: 9 } }) ); const receipt = await cob.uploadBlobToKnot( agent, knot, repo, new Uint8Array([1, 2, 3]), "image/png" ); expect(calls[0].url).toBe(`https://${knot}/xrpc/com.atproto.repo.uploadBlob`); expect(calls[0].init.body).toEqual(new Uint8Array([1, 2, 3])); expect(new Headers(calls[0].init.headers).get("content-type")).toBe("image/png"); expect(minted).toEqual([{ aud: repo, lxm: "com.atproto.repo.uploadBlob" }]); expect(receipt).toEqual({ $type: "blob", ref: { $link: "bafyreiblob" + "a".repeat(48) }, mimeType: "image/png", size: 9 }); }); it("accepts validated origin strings in place of URLs", async () => { const { cob, calls } = await load(); await cob.createCob(agent, knotOf("knot.example.com"), repo, collection, record); expect(calls[0].url).toBe("https://knot.example.com/xrpc/com.atproto.repo.createRecord"); }); }); describe("updateCob edits the version the knot holds", () => { const stored = { uri, cid: storedCid, value: { ...record, body: "old", "x-tngl-editor": "did:plc:limpet" } }; it("reads from the knot, drops the editor field and swaps against the read cid", async () => { const { cob, calls, sent } = await load( () => Response.json(stored), () => Response.json(written) ); const result = await cob.updateCob( agent, knot, uri, (current: typeof record & { body?: string }) => ({ ...current, body: "new" }) ); expect(calls[0].url).toBe( `https://${knot}/xrpc/com.atproto.repo.getRecord?repo=did%3Aplc%3Abarnacle&collection=org.tangled.track.ticket&rkey=3lk` ); expect(calls[0].init.headers).not.toHaveProperty("authorization"); expect(sent(1)).toEqual({ repo, collection, rkey: "3lk", swapRecord: storedCid, record: { ...record, body: "new" } }); expect(result).toEqual({ ...written, value: { ...record, body: "new" } }); }); const loaded = { uri, cid: cidOf(storedCid) }; const touch = (current: typeof record) => current; it("swaps against the version the page loaded", async () => { const { cob, sent } = await load(() => Response.json(stored)); await cob.updateCob(agent, knot, loaded, touch); expect(sent(1).swapRecord).toBe(storedCid); }); it("sends nothing when the knot moved past the loaded version", async () => { const { cob, calls } = await load(() => Response.json(stored)); await expect(cob.updateCob(agent, knot, from, touch)).rejects.toBeInstanceOf( cob.StaleEditError ); expect(calls).toHaveLength(1); }); it("maps a 409 InvalidSwap to a stale edit and passes other refusals through", async () => { const refusal = (error: string) => () => Response.json({ error, message: "no" }, { status: 409 }); const swapped = await load(() => Response.json(stored), refusal("InvalidSwap")); await expect(swapped.cob.updateCob(agent, knot, loaded, touch)).rejects.toBeInstanceOf( swapped.cob.StaleEditError ); const conflict = await load(() => Response.json(stored), refusal("Conflict")); await expect(conflict.cob.updateCob(agent, knot, loaded, touch)).rejects.toMatchObject({ status: 409, error: "Conflict" }); }); }); describe("the client metadata covers writes addressed to a repo did", () => { it.each(["createRecord", "putRecord", "deleteRecord"])("grants %s", (method) => { const wanted = [ { resource: "rpc", lxm: nsidOf(`com.atproto.repo.${method}`), aud: repo } ] as const; expect(missingPermissions(oauthMetadata.scope, wanted)).toEqual([]); }); });