Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266import { 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 type { DidRkey, NotificationOffer } from "$lib/components/notifications/types";import type * as Accept from "$lib/api/accept";
const agent = { sub: "did:plc:limpet" } as unknown as OAuthUserAgent;const KNOT = "knot.oyster.cafe";const KNOT_DID = "did:web:knot.oyster.cafe";const REPO_DID = "did:plc:scallop";
const offers: Record<"membership" | "collaboration", NotificationOffer> = { membership: { kind: "membership", knot: new URL(`https://${KNOT}`), subject: KNOT_DID as DidRkey }, collaboration: { kind: "collaboration", knot: new URL(`https://${KNOT}`), subject: REPO_DID as DidRkey }};
const WRITES = { membership: { collection: "sh.tangled.knot.memberAcceptance", procedure: "sh.tangled.knot.acceptMembership", key: KNOT_DID }, collaboration: { collection: "sh.tangled.repo.collaboratorAcceptance", procedure: "sh.tangled.repo.acceptCollaboration", key: REPO_DID }};
interface Write { repo: string; collection: string; rkey: string; record: { $type: string; createdAt: string };}
interface Faults { record?: unknown; token?: unknown;}
const load = async (faults: Faults = {}) => { vi.resetModules(); const writes: Write[] = []; const minted: { aud: string; lxm: string; signal?: AbortSignal }[] = []; const steps: string[] = []; vi.doMock("$lib/auth/agent", () => ({ createClient: () => ({ call: async (_schema: unknown, { input }: { input: Write }) => { steps.push("record"); if (faults.record) throw faults.record; writes.push(input); return { ok: true, data: { uri: `at://${input.repo}/${input.collection}/${input.rkey}`, cid: "bafy" } }; } }), mintServiceAuth: async ( _agent: unknown, { aud, lxm, signal }: { aud: string; lxm: string; signal?: AbortSignal } ) => { steps.push("token"); if (faults.token) throw faults.token; minted.push({ aud, lxm, signal }); return `token-for-${aud}`; } })); const calls: { url: string; init: RequestInit }[] = []; const knot = vi.fn<typeof globalThis.fetch>(async (input, init) => { steps.push("call"); calls.push({ url: String(input), init: init ?? {} }); return new Response(null, { status: 200 }); }); return { accept: await import("./accept"), writes, minted, steps, calls, knot };};
const refusing = async (faults: Faults = {}, knot?: typeof globalThis.fetch) => { const harness = await load(faults); const thrown = await harness.accept .acceptOffer(agent, offers.membership, knot ?? harness.knot) .catch((cause: unknown) => cause); expect(thrown).toBeInstanceOf(harness.accept.OfferRefused); return { refused: thrown as Accept.OfferRefused, ...harness };};
describe("accepting an offer", () => { it.each(["membership", "collaboration"] as const)( "a %s acceptance lands under its own subject, and the knot takes the uri under a fresh token", async (kind) => { const { accept, writes, minted, calls, steps, knot } = await load(); const { collection, procedure, key } = WRITES[kind];
await accept.acceptOffer(agent, offers[kind], knot);
expect(steps).toEqual(["record", "token", "call"]); expect(writes[0]).toMatchObject({ repo: agent.sub, collection, rkey: key, record: { $type: collection } }); expect(Number.isFinite(Date.parse(writes[0].record.createdAt))).toBe(true); expect(minted[0]).toMatchObject({ aud: key, lxm: procedure }); expect(minted[0].signal).toBeInstanceOf(AbortSignal); expect(calls[0].url).toBe(`https://${KNOT}/xrpc/${procedure}`); expect(JSON.parse(String(calls[0].init.body))).toEqual({ acceptance: `at://${agent.sub}/${collection}/${key}` }); expect(new Headers(calls[0].init.headers).get("authorization")).toBe( `Bearer token-for-${key}` ); expect(calls[0].init.signal).toBeInstanceOf(AbortSignal); } );
it("xrpc url keeps the knot's port but drops its path", async () => { const { accept, calls, knot } = await load();
await accept.acceptOffer( agent, { ...offers.membership, knot: new URL(`https://${KNOT}:8443/ignored`) }, knot );
expect(calls[0].url).toBe(`https://${KNOT}:8443/xrpc/sh.tangled.knot.acceptMembership`); });
it("a second accept rewrites the same record key", async () => { const { accept, writes, knot } = await load();
await accept.acceptOffer(agent, offers.membership, knot); await accept.acceptOffer(agent, offers.membership, knot);
expect(writes.map((write) => write.rkey)).toEqual([KNOT_DID, KNOT_DID]); });});
describe("OfferRefused owns the step that failed", () => { it("record write fails first, knot never hears about it", async () => { const { refused, steps } = await refusing({ record: new Error("pds is down") });
expect(refused.stage).toBe("record"); expect(steps).toEqual(["record"]); });
it("token mint fails with the acceptance already published", async () => { const { refused, writes, steps } = await refusing({ token: new Error("no such scope") });
expect(refused.stage).toBe("token"); expect(writes).toHaveLength(1); expect(steps).toEqual(["record", "token"]); });
it("the knot's 403 arrives as the cause when the call fails", async () => { const message = "no membership offer for you on this knot"; const knot = vi.fn<typeof globalThis.fetch>( async () => new Response(JSON.stringify({ error: "Forbidden", message }), { status: 403, headers: { "content-type": "application/json" } }) );
const { refused } = await refusing({}, knot);
expect(refused.stage).toBe("call"); expect(refused.cause).toBeInstanceOf(ClientResponseError); expect((refused.cause as ClientResponseError).status).toBe(403); expect((refused.cause as ClientResponseError).description).toBe(message); });
it("a silent knot fails the call with no description", async () => { const offline = new TypeError("Failed to fetch"); const knot = vi.fn<typeof globalThis.fetch>(() => Promise.reject(offline));
const { refused } = await refusing({}, knot);
expect(refused.stage).toBe("call"); expect(refused.cause).toBe(offline); expect(refused.description).toBeNull(); });
it("error code stands in where the service answers no sentence", async () => { const { refused } = await refusing({ record: new ClientResponseError({ status: 400, data: { error: "InvalidRequest" } }) });
expect(refused.description).toBe("InvalidRequest"); });});
describe("OfferRefused's sentence, stage by stage", () => { it.each([ ["record", null, "Your account wouldn't store this acceptance. Nothing was granted."], [ "record", "Account is over quota", "Your account stored nothing, so nothing was granted: Account is over quota" ], ["token", null, `Your acceptance is stored, but signing the call to ${KNOT} failed.`], [ "token", "Account is over quota", `Your acceptance is stored, but your account wouldn't sign the call to ${KNOT}: Account is over quota` ], ["call", null, `${KNOT} didn't answer. Your acceptance is stored, so try again.`], [ "call", "no membership offer for you on this knot", "no membership offer for you on this knot" ] ] as const)("%s stage against %s", async (stage, description, expected) => { const { accept } = await load(); const cause = description === null ? new Error("silent") : new ClientResponseError({ status: 403, data: { error: "Forbidden", message: description } });
expect(new accept.OfferRefused(stage, cause).sentence(offers.membership.knot)).toBe( expected ); });});describe("permissions an offer needs", () => { it("all live in the client metadata, so fresh logins can accept either kind", async () => { const { accept } = await load();
expect( missingPermissions(oauthMetadata.scope, accept.permissionsFor(offers.membership)) ).toEqual([]); expect( missingPermissions(oauthMetadata.scope, accept.permissionsFor(offers.collaboration)) ).toEqual([]); });
it.each(["membership", "collaboration"] as const)( "a %s writes one collection and calls one procedure", async (kind) => { const { accept } = await load(); const { collection, procedure, key } = WRITES[kind];
expect(accept.permissionsFor(offers[kind])).toEqual([ { resource: "repo", collection, actions: ["create", "update"] }, { resource: "rpc", lxm: procedure, aud: key } ]); } );});