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"; import { missingPermissions } from "$lib/auth/scopes"; import { atUriOf, cidOf, didOf, knotOf, nsidOf, recordKeyOf } from "$lib/api/syntax"; import type * as Cob from "$lib/api/cob"; 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 load = async (...responses: (() => Response)[]) => { vi.resetModules(); const minted: { aud: string; lxm: string }[] = []; vi.doMock("$app/environment", () => ({ browser: true })); vi.doMock("$lib/auth/agent", () => ({ 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")) as typeof Cob; const cache = await import("$lib/api/cache"); 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 }; }; 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("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" } }); }); }); 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([]); }); });