@fedify/fedify
Version:
An ActivityPub server framework
338 lines (337 loc) • 18.6 kB
JavaScript
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 {};