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([]);
});
});