import { describe, expect, it } from "vitest"; import { Identity, NostrSecret, newClaimPayload, signClaim, toCompact, } from "@kez/core"; import { ActivityPubChannel, extractActorCandidates, extractActorUrl, parseHandle, stripHtml, webfingerUrl, } from "../src/activitypub.js"; function sign(subject: string) { const secret = NostrSecret.generate(); return signClaim( newClaimPayload(Identity.parse(subject), secret.identity(), new Date()), secret, ); } const BASE = "https://ap.test"; function makeFetch(routes: Record): typeof fetch { return (async (input: string | URL) => { const key = input.toString(); const body = routes[key]; if (body === undefined) return new Response("", { status: 404 }); return new Response(typeof body === "string" ? body : JSON.stringify(body), { status: 200 }); }) as unknown as typeof fetch; } describe("ActivityPubChannel", () => { it("verifies proof in profile attachment", async () => { const signed = sign("ap:@jason@mastodon.social"); const actorUrl = `${BASE}/users/jason`; const wfUrl = `${BASE}/.well-known/webfinger?resource=acct:jason@mastodon.social`; const fetch = makeFetch({ [wfUrl]: { subject: "acct:jason@mastodon.social", links: [{ rel: "self", type: "application/activity+json", href: actorUrl }], }, [actorUrl]: { id: actorUrl, type: "Person", attachment: [ { type: "PropertyValue", name: "site", value: 'x' }, { type: "PropertyValue", name: "kez", value: toCompact(signed) }, ], summary: "

hi

", }, }); const channel = new ActivityPubChannel({ baseOverride: BASE, fetch }); const hit = await channel.fetchAndVerify(Identity.parse("ap:@jason@mastodon.social")); expect(hit.proof).toEqual(signed); }); it("verifies proof embedded in bio", async () => { const signed = sign("ap:@jason@mastodon.social"); const actorUrl = `${BASE}/users/jason`; const wfUrl = `${BASE}/.well-known/webfinger?resource=acct:jason@mastodon.social`; const fetch = makeFetch({ [wfUrl]: { links: [{ rel: "self", type: "application/activity+json", href: actorUrl }], }, [actorUrl]: { summary: `

portable identity: ${toCompact(signed)}

`, attachment: [], }, }); const channel = new ActivityPubChannel({ baseOverride: BASE, fetch }); const hit = await channel.fetchAndVerify(Identity.parse("ap:@jason@mastodon.social")); expect(hit.proof).toEqual(signed); }); it("rejects proof for wrong subject", async () => { const signed = sign("ap:@mallory@mastodon.social"); const actorUrl = `${BASE}/users/jason`; const wfUrl = `${BASE}/.well-known/webfinger?resource=acct:jason@mastodon.social`; const fetch = makeFetch({ [wfUrl]: { links: [{ rel: "self", type: "application/activity+json", href: actorUrl }], }, [actorUrl]: { attachment: [{ type: "PropertyValue", name: "kez", value: toCompact(signed) }], }, }); const channel = new ActivityPubChannel({ baseOverride: BASE, fetch }); await expect( channel.fetchAndVerify(Identity.parse("ap:@jason@mastodon.social")), ).rejects.toMatchObject({ kind: "SubjectMismatch" }); }); it("WebFinger 404 is Unreachable", async () => { const fetch = makeFetch({}); const channel = new ActivityPubChannel({ baseOverride: BASE, fetch }); await expect( channel.fetchAndVerify(Identity.parse("ap:@ghost@mastodon.social")), ).rejects.toMatchObject({ kind: "Unreachable" }); }); it("WebFinger with no self link is NotFound", async () => { const wfUrl = `${BASE}/.well-known/webfinger?resource=acct:jason@mastodon.social`; const fetch = makeFetch({ [wfUrl]: { links: [{ rel: "profile", type: "text/html", href: "https://example/@jason" }], }, }); const channel = new ActivityPubChannel({ baseOverride: BASE, fetch }); await expect( channel.fetchAndVerify(Identity.parse("ap:@jason@mastodon.social")), ).rejects.toMatchObject({ kind: "NotFound" }); }); }); describe("ActivityPub pure helpers", () => { it("parseHandle accepts both forms", () => { expect(parseHandle("@jason@mastodon.social")).toEqual({ user: "jason", server: "mastodon.social", }); expect(parseHandle("jason@mastodon.social")).toEqual({ user: "jason", server: "mastodon.social", }); }); it("parseHandle rejects malformed", () => { expect(() => parseHandle("jason")).toThrow(); expect(() => parseHandle("@@server")).toThrow(); expect(() => parseHandle("@jason@")).toThrow(); }); it("webfingerUrl matches spec shape", () => { expect(webfingerUrl("https://mastodon.social", "jason", "mastodon.social")).toBe( "https://mastodon.social/.well-known/webfinger?resource=acct:jason@mastodon.social", ); }); it("extractActorUrl picks self activity+json", () => { expect( extractActorUrl({ links: [ { rel: "profile", type: "text/html", href: "https://x/@jason" }, { rel: "self", type: "application/activity+json", href: "https://x/users/jason", }, ], }), ).toBe("https://x/users/jason"); }); it("extractActorUrl accepts ld+json", () => { expect( extractActorUrl({ links: [ { rel: "self", type: "application/ld+json; profile=...", href: "https://x/u" }, ], }), ).toBe("https://x/u"); }); it("extractActorCandidates returns attachment then summary", () => { expect( extractActorCandidates({ attachment: [ { value: 'x' }, { value: "kez:z1:abc" }, ], summary: "

kez:z1:def

", }), ).toEqual(["x", "kez:z1:abc", "kez:z1:def"]); }); it("stripHtml drops tags and decodes entities", () => { expect(stripHtml("

hello world

")).toBe("hello world"); expect(stripHtml("a & b <c>")).toBe("a & b "); expect(stripHtml(""quoted"")).toBe('"quoted"'); expect(stripHtml("'apos'")).toBe("'apos'"); }); it("stripHtml preserves compact kez prefix", () => { expect(stripHtml("

my proof: kez:z1:KLUv_QBYabc

")).toBe( "my proof: kez:z1:KLUv_QBYabc", ); }); });