UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

338 lines (337 loc) • 18.6 kB
import "@js-temporal/polyfill"; import "urlpattern-polyfill"; globalThis.addEventListener = () => {}; import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs"; import { r as assertExists } from "../std__assert-BBjXFNOb.mjs"; import { t as assertThrows } from "../assert_throws-BOkhLGYc.mjs"; import { t as MemoryKvStore } from "../kv-rV3vodCc.mjs"; import { r as createFederationBuilder } from "../builder-hivpmhlD.mjs"; import { DisallowedOperatorError, DisallowedVarSpecModifierError, DuplicateRouteVariableError, RouteTemplateOptionsNotMatchedError, RouterError } from "@fedify/uri-template"; import { Activity, Note, Person } from "@fedify/vocab"; import { test } from "@fedify/fixture"; //#region src/federation/builder.test.ts test("FederationBuilder", async (t) => { await t.step("should build a Federation object with registered components", async () => { const builder = createFederationBuilder(); const kv = new MemoryKvStore(); const actorDispatcher = (_ctx, _identifier) => { return null; }; assertThrows(() => createFederationBuilder().setActorDispatcher("/users/{identifier}", actorDispatcher).mapActorAlias("/actor/{id}", "instance"), RouterError, "Path for actor alias must have no variables."); assertThrows(() => createFederationBuilder().setActorDispatcher("/users/{identifier}", actorDispatcher).mapActorAlias("/actor", "instance").mapActorAlias("/bot", "instance"), RouterError, "Actor alias for \"instance\" already set."); assertThrows(() => createFederationBuilder().setActorDispatcher("/users/{identifier}", actorDispatcher).mapActorAlias("/actor", "instance").mapActorAlias("/actor", "bot"), RouterError, "Actor alias path \"/actor\" conflicts with existing route \"actorAlias:instance\"."); assertThrows(() => createFederationBuilder().setActorDispatcher("/users/{identifier}", actorDispatcher).mapActorAlias("/actor", ""), RouterError, "Identifier cannot be empty."); builder.setActorDispatcher("/users/{identifier}", actorDispatcher).mapActorAlias("/actor", "instance"); const inboxListener = (_ctx, _activity) => {}; builder.setInboxListeners("/users/{identifier}/inbox").on(Activity, inboxListener); const outboxListener = (_ctx, _activity) => {}; builder.setOutboxListeners("/users/{identifier}/outbox").on(Activity, outboxListener); const objectDispatcher = (_ctx, _values) => { return null; }; builder.setObjectDispatcher(Note, "/notes/{id}", objectDispatcher); const nodeInfo = { version: "2.1", software: { name: "test", version: "1.0.0" }, protocols: ["activitypub"], services: { inbound: [], outbound: [] }, openRegistrations: false, usage: { users: {}, localPosts: 0, localComments: 0 }, metadata: {} }; const nodeInfoDispatcher = (_ctx) => nodeInfo; builder.setNodeInfoDispatcher("/nodeinfo", nodeInfoDispatcher); const federation = await builder.build({ kv }); assertExists(federation); const impl = federation; assertEquals(impl.router.route("/.well-known/webfinger")?.name, "webfinger"); assertEquals(impl.router.route("/users/test123")?.name, "actor"); assertEquals(impl.router.route("/actor")?.name, "actorAlias:instance"); assertEquals(impl.router.route("/users/test123/inbox")?.name, "inbox"); assertEquals(impl.router.route("/users/test123/outbox")?.name, "outbox"); assertEquals(impl.router.route("/notes/456")?.name, `object:${Note.typeId.href}`); assertEquals(impl.router.route("/nodeinfo")?.name, "nodeInfo"); const actorCallbacksDispatcher = impl.actorCallbacks?.dispatcher; assertExists(actorCallbacksDispatcher); const inboxListeners = impl.inboxListeners; assertExists(inboxListeners); const outboxListeners = impl.outboxListeners; assertExists(outboxListeners); assertExists(impl.objectCallbacks[Note.typeId.href]); assertExists(impl.nodeInfoDispatcher); assertEquals(impl.router.build(`object:${Note.typeId.href}`, { id: "123" }), "/notes/123"); assertEquals(impl.router.build("actor", { identifier: "user1" }), "/users/user1"); assertEquals(impl.router.build("inbox", { identifier: "user1" }), "/users/user1/inbox"); await builder.build({ kv }); }); await t.step("passes benchmarkMode to the built federation", async () => { const builder = createFederationBuilder(); const impl = await builder.build({ kv: new MemoryKvStore(), benchmarkMode: true }); assertEquals(impl.benchmarkMode, true); assertEquals(impl.allowPrivateAddress, true); assertEquals(impl.signatureTimeWindow, false); const overriddenImpl = await builder.build({ kv: new MemoryKvStore(), benchmarkMode: true, allowPrivateAddress: false, signatureTimeWindow: { minutes: 10 } }); assertEquals(overriddenImpl.benchmarkMode, true); assertEquals(overriddenImpl.allowPrivateAddress, false); assertEquals(overriddenImpl.signatureTimeWindow, { minutes: 10 }); }); await t.step("should snapshot router state on build", async () => { const builder = createFederationBuilder(); const kv = new MemoryKvStore(); const noteRouteName = `object:${Note.typeId.href}`; builder.setActorDispatcher("/users/{identifier}", () => null); const impl1 = await builder.build({ kv }); builder.setObjectDispatcher(Note, "/notes/{id}", () => null); assertEquals(impl1.router.route("/notes/1"), null); const impl2 = await builder.build({ kv }); assertEquals(impl2.router.route("/notes/1")?.name, noteRouteName); impl1.router.add("/leaked/{id}", "leaked"); assertEquals(impl1.router.route("/leaked/1")?.name, "leaked"); assertEquals(impl2.router.route("/leaked/1"), null); assertEquals((await builder.build({ kv })).router.route("/leaked/1"), null); }); await t.step("should build with default options", async () => { const builder = createFederationBuilder(); const kv = new MemoryKvStore(); const federation = await builder.build({ kv }); assertExists(federation); assertEquals(federation.kv, kv); }); await t.step("should validate outbox listener paths", () => { const builder = createFederationBuilder(); builder.setOutboxDispatcher("/users/{identifier}/outbox", () => ({ items: [] })); assertThrows(() => builder.setOutboxListeners("/actors/{identifier}/outbox"), RouterError); assertThrows(() => builder.setOutboxListeners("/users/outbox"), RouterError); assertThrows(() => builder.setOutboxListeners("/users/{identifier}/outbox/{extra}"), RouterError); assertThrows(() => builder.setOutboxListeners("/users/{identifier}/outbox/{identifier}"), RouterError); const builderAfterInvalid = createFederationBuilder(); assertThrows(() => builderAfterInvalid.setOutboxListeners("/users/{identifier}/outbox/{extra}"), RouteTemplateOptionsNotMatchedError); assertThrows(() => builderAfterInvalid.setOutboxListeners("/users/{identifier:3}/outbox"), DisallowedVarSpecModifierError); assertThrows(() => builderAfterInvalid.setOutboxListeners("/users/{identifier*}/outbox"), DisallowedVarSpecModifierError); assertThrows(() => builderAfterInvalid.setOutboxListeners("/users/{identifier,identifier}/outbox"), DuplicateRouteVariableError); builderAfterInvalid.setOutboxListeners("/users/{identifier}/outbox"); const builder2 = createFederationBuilder(); builder2.setOutboxListeners("/users/{identifier}/outbox"); assertThrows(() => builder2.setOutboxDispatcher("/actors/{identifier}/outbox", () => ({ items: [] })), RouterError); const builder3 = createFederationBuilder(); assertThrows(() => builder3.setOutboxListeners("/users{?identifier}/outbox"), DisallowedOperatorError); const builder3a = createFederationBuilder(); assertThrows(() => builder3a.setOutboxListeners("/users{;identifier}/outbox"), DisallowedOperatorError); const builder3b = createFederationBuilder(); assertThrows(() => builder3b.setOutboxListeners("/users{.identifier}/outbox"), DisallowedOperatorError); const builder4 = createFederationBuilder(); assertThrows(() => builder4.setOutboxDispatcher("/users{?identifier}/outbox", () => ({ items: [] })), DisallowedOperatorError); const builder5 = createFederationBuilder(); assertThrows(() => builder5.setOutboxDispatcher("/users/{identifier:3}/outbox", () => ({ items: [] })), DisallowedVarSpecModifierError); }); await t.step("rejects non-segment-boundary identifier operators at registration for required-identifier routes", () => { assertThrows(() => createFederationBuilder().setActorDispatcher("/users{/identifier}", () => null), DisallowedOperatorError); assertThrows(() => createFederationBuilder().setActorDispatcher("{/identifier}", () => null), DisallowedOperatorError); assertThrows(() => createFederationBuilder().setActorDispatcher("/users/{/identifier}", () => null), DisallowedOperatorError); assertThrows(() => createFederationBuilder().setActorDispatcher("/users{?identifier}", () => null), DisallowedOperatorError); assertThrows(() => createFederationBuilder().setInboxListeners("/users{/identifier}/inbox"), DisallowedOperatorError); assertThrows(() => createFederationBuilder().setOutboxListeners("/users{/identifier}/outbox"), DisallowedOperatorError); assertThrows(() => createFederationBuilder().setActorDispatcher("/users/{identifier:3}", () => null), DisallowedVarSpecModifierError); assertThrows(() => createFederationBuilder().setActorDispatcher("/users/{identifier*}", () => null), DisallowedVarSpecModifierError); createFederationBuilder().setActorDispatcher("/users/{identifier}", () => null); createFederationBuilder().setInboxListeners("/users/{identifier}/inbox"); }); await t.step("every required-identifier setter rejects every omissible operator", () => { const omissibleExprs = [ "{/identifier}", "{?identifier}", "{;identifier}", "{.identifier}" ]; const registrars = [ ["setActorDispatcher", (expr) => createFederationBuilder().setActorDispatcher(`/users${expr}`, () => null)], ["setInboxListeners", (expr) => createFederationBuilder().setInboxListeners(`/users${expr}/inbox`)], ["setOutboxListeners", (expr) => createFederationBuilder().setOutboxListeners(`/users${expr}/outbox`)], ["setOutboxDispatcher", (expr) => createFederationBuilder().setOutboxDispatcher(`/users${expr}/outbox`, () => ({ items: [] }))], ["setFollowingDispatcher", (expr) => createFederationBuilder().setFollowingDispatcher(`/users${expr}/following`, () => ({ items: [] }))], ["setFollowersDispatcher", (expr) => createFederationBuilder().setFollowersDispatcher(`/users${expr}/followers`, () => ({ items: [] }))], ["setLikedDispatcher", (expr) => createFederationBuilder().setLikedDispatcher(`/users${expr}/liked`, () => ({ items: [] }))], ["setFeaturedDispatcher", (expr) => createFederationBuilder().setFeaturedDispatcher(`/users${expr}/featured`, () => ({ items: [] }))], ["setFeaturedTagsDispatcher", (expr) => createFederationBuilder().setFeaturedTagsDispatcher(`/users${expr}/tags`, () => ({ items: [] }))] ]; for (const [name, register] of registrars) { for (const expr of omissibleExprs) assertThrows(() => register(expr), DisallowedOperatorError, void 0, `${name} must reject ${expr} at registration`); register("{identifier}"); } }); await t.step("empty or missing identifier segments produce a runtime no-match", async () => { const kv = new MemoryKvStore(); const builder = createFederationBuilder(); builder.setActorDispatcher("/users/{identifier}", () => null); builder.setInboxListeners("/users/{identifier}/inbox"); builder.setOutboxDispatcher("/users/{identifier}/outbox", () => ({ items: [] })); builder.setObjectDispatcher(Note, "/notes/{id}", () => null); const impl = await builder.build({ kv }); assertEquals(impl.router.route("/users/alice")?.name, "actor"); assertEquals(impl.router.route("/users/alice/inbox")?.name, "inbox"); assertEquals(impl.router.route("/users/alice/outbox")?.name, "outbox"); assertEquals(impl.router.route("/notes/1")?.name, `object:${Note.typeId.href}`); assertEquals(impl.router.route("/users/"), null); assertEquals(impl.router.route("/users//inbox"), null); assertEquals(impl.router.route("/users//outbox"), null); assertEquals(impl.router.route("/notes/"), null); }); await t.step("object dispatcher optional-operator routes no-match when unbound", async () => { const kv = new MemoryKvStore(); const objectName = `object:${Note.typeId.href}`; const query = createFederationBuilder(); query.setObjectDispatcher(Note, "/notes{?id}", () => null); const queryImpl = await query.build({ kv }); assertEquals(queryImpl.router.route("/notes"), null); assertEquals(queryImpl.router.route("/notes?id=1")?.name, objectName); const matrix = createFederationBuilder(); matrix.setObjectDispatcher(Note, "/notes{;id}", () => null); const matrixImpl = await matrix.build({ kv }); assertEquals(matrixImpl.router.route("/notes"), null); assertEquals(matrixImpl.router.route("/notes;id=1")?.name, objectName); const label = createFederationBuilder(); label.setObjectDispatcher(Note, "/notes{.id}", () => null); const labelImpl = await label.build({ kv }); assertEquals(labelImpl.router.route("/notes"), null); assertEquals(labelImpl.router.route("/notes.1")?.name, objectName); }); await t.step("custom collection routes no-match empty or unbound variables", async () => { const kv = new MemoryKvStore(); const builder = createFederationBuilder(); builder.setCollectionDispatcher("samples", Note, "/groups/{id}", () => ({ items: [] })); builder.setCollectionDispatcher("optionals", Note, "/optional-groups{?id}", () => ({ items: [] })); builder.setCollectionDispatcher("matrixOptionals", Note, "/matrix-groups{;id}", () => ({ items: [] })); builder.setCollectionDispatcher("labelOptionals", Note, "/label-groups{.id}", () => ({ items: [] })); const impl = await builder.build({ kv }); assertEquals(impl.router.route("/groups/1"), { name: "collection:samples", values: { id: "1" }, template: "/groups/{id}" }); assertEquals(impl.router.route("/optional-groups?id=1"), { name: "collection:optionals", values: { id: "1" }, template: "/optional-groups{?id}" }); assertEquals(impl.router.route("/matrix-groups;id=1"), { name: "collection:matrixOptionals", values: { id: "1" }, template: "/matrix-groups{;id}" }); assertEquals(impl.router.route("/label-groups.1"), { name: "collection:labelOptionals", values: { id: "1" }, template: "/label-groups{.id}" }); assertEquals(impl.router.route("/groups/"), null); assertEquals(impl.router.route("/optional-groups"), null); assertEquals(impl.router.route("/matrix-groups"), null); assertEquals(impl.router.route("/label-groups"), null); }); await t.step("should pass build options correctly", async () => { const builder = createFederationBuilder(); const kv = new MemoryKvStore(); const federation = await builder.build({ kv, kvPrefixes: { activityIdempotence: ["custom", "idem"] }, allowPrivateAddress: true, trailingSlashInsensitive: true, origin: "https://example.com" }); assertExists(federation); const impl = federation; assertEquals(impl.kv, kv); assertEquals(impl.kvPrefixes.activityIdempotence, ["custom", "idem"]); assertEquals(impl.allowPrivateAddress, true); assertEquals(impl.router.trailingSlashInsensitive, true); assertEquals(impl.origin?.webOrigin, "https://example.com"); }); await t.step("should handle firstKnock option", async () => { const builder = createFederationBuilder(); const kv = new MemoryKvStore(); const federationDefault = await builder.build({ kv }); assertExists(federationDefault); assertEquals(federationDefault.firstKnock, void 0); const federationCustom = await builder.build({ kv, firstKnock: "draft-cavage-http-signatures-12" }); assertExists(federationCustom); assertEquals(federationCustom.firstKnock, "draft-cavage-http-signatures-12"); const federationRfc = await builder.build({ kv, firstKnock: "rfc9421" }); assertExists(federationRfc); assertEquals(federationRfc.firstKnock, "rfc9421"); }); await t.step("should copy unverified activity handler into built federation", async () => { const builder = createFederationBuilder(); const kv = new MemoryKvStore(); const handler = (_ctx, _activity, _reason) => {}; builder.setInboxListeners("/users/{identifier}/inbox").onUnverifiedActivity(handler); assertEquals((await builder.build({ kv })).unverifiedActivityHandler, handler); }); await t.step("should register multiple object dispatchers and verify them", async () => { const builder = createFederationBuilder(); const kv = new MemoryKvStore(); const noteDispatcher = (_ctx, _values) => { return null; }; const personDispatcher = (_ctx, _values) => { return null; }; builder.setObjectDispatcher(Note, "/notes/{id}", noteDispatcher); builder.setObjectDispatcher(Person, "/people/{id}", personDispatcher); const impl = await builder.build({ kv }); assertExists(impl.objectCallbacks[Note.typeId.href]); assertExists(impl.objectCallbacks[Person.typeId.href]); assertEquals(impl.router.build(`object:${Note.typeId.href}`, { id: "123" }), "/notes/123"); assertEquals(impl.router.build(`object:${Person.typeId.href}`, { id: "456" }), "/people/456"); const noteRoute = impl.router.route("/notes/789"); assertEquals(noteRoute?.name, `object:${Note.typeId.href}`); assertEquals(noteRoute?.values.id, "789"); const personRoute = impl.router.route("/people/abc"); assertEquals(personRoute?.name, `object:${Person.typeId.href}`); assertEquals(personRoute?.values.id, "abc"); }); await t.step("should handle symbol names uniquely in custom collection dispatchers", () => { const builder = createFederationBuilder(); const unnamedSymbol1 = Symbol(); const unnamedSymbol2 = Symbol(); const namedSymbol1 = Symbol.for(""); const namedSymbol2 = Symbol.for(""); const strId = String(unnamedSymbol1); const dispatcher = (_ctx, _params) => ({ items: [] }); builder.setCollectionDispatcher(unnamedSymbol1, Note, "/unnamed-symbol1/{id}", dispatcher); assertThrows(() => { builder.setCollectionDispatcher(unnamedSymbol1, Note, "/unnamed-symbol1-duplicate/{id}", dispatcher); }, Error, "Collection dispatcher for Symbol() already set."); builder.setCollectionDispatcher(unnamedSymbol2, Note, "/unnamed-symbol2/{id}", dispatcher); builder.setCollectionDispatcher(namedSymbol1, Note, "/named-symbol/{id}", dispatcher); assertThrows(() => { builder.setCollectionDispatcher(namedSymbol2, Note, "/named-symbol/{id}", dispatcher); }); builder.setCollectionDispatcher(strId, Note, "/string-id/{id}", dispatcher); }); }); //#endregion export {};