@fedify/fedify
Version:
An ActivityPub server framework
682 lines (681 loc) • 22.3 kB
JavaScript
import { Temporal } from "@js-temporal/polyfill";
import "urlpattern-polyfill";
globalThis.addEventListener = () => {};
import { r as createRequestContext } from "../context-DVoTs_wM.mjs";
import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs";
import "../std__assert-BBjXFNOb.mjs";
import { t as assertNotEquals } from "../assert_not_equals-DkVK8oqV.mjs";
import { t as MemoryKvStore } from "../kv-rV3vodCc.mjs";
import { o as createFederation, s as handleWebFinger } from "../middleware-WU_5fbGS.mjs";
import { Image, Link, Person, Tombstone } from "@fedify/vocab";
import { createTestMeterProvider, test } from "@fedify/fixture";
//#region src/federation/webfinger.test.ts
test("handleWebFinger()", async (t) => {
const url = new URL("https://example.com/.well-known/webfinger");
function createContext(url) {
const context = createRequestContext({
federation: createFederation({ kv: new MemoryKvStore() }),
url,
data: void 0,
getActorUri(identifier) {
return new URL(`${url.origin}/users/${identifier}`);
},
async getActor(handle) {
const actor = await actorDispatcher(context, handle);
return actor instanceof Tombstone ? null : actor;
},
parseUri(uri) {
if (uri == null) return null;
if (uri.protocol === "acct:") return null;
if (!uri.pathname.startsWith("/users/")) return null;
const paths = uri.pathname.split("/");
return {
type: "actor",
identifier: paths[paths.length - 1]
};
}
});
return context;
}
const actorDispatcher = (ctx, identifier) => {
if (identifier === "gone") return new Tombstone({
id: ctx.getActorUri(identifier),
deleted: Temporal.Instant.from("2024-01-15T00:00:00Z")
});
if (identifier !== "someone" && identifier !== "someone2") return null;
const actorUri = ctx.getActorUri(identifier);
return new Person({
id: actorUri,
name: identifier === "someone" ? "Someone" : "Someone 2",
preferredUsername: identifier === "someone" ? null : identifier === "someone2" ? "bar" : null,
icon: new Image({
url: new URL(`${actorUri.origin}/icon.jpg`),
mediaType: "image/jpeg"
}),
urls: [new URL(`${actorUri.origin}/{identifier}`), new Link({
href: new URL(`${actorUri.origin}/{identifier}`),
rel: "alternate",
mediaType: "text/html"
})]
});
};
let onNotFoundCalled = null;
const onNotFound = (request) => {
onNotFoundCalled = request;
return new Response("Not found", { status: 404 });
};
await t.step("no actor dispatcher", async () => {
const context = createContext(url);
const request = context.request;
assertEquals((await handleWebFinger(request, {
context,
onNotFound
})).status, 404);
assertEquals(onNotFoundCalled, request);
});
onNotFoundCalled = null;
await t.step("no resource", async () => {
const context = createContext(url);
const request = context.request;
const response = await handleWebFinger(request, {
context,
actorDispatcher,
onNotFound
});
assertEquals(response.status, 400);
assertEquals(await response.text(), "Missing resource parameter.");
assertEquals(onNotFoundCalled, null);
});
await t.step("invalid resource", async () => {
const u = new URL(url);
u.searchParams.set("resource", " invalid ");
const context = createContext(u);
const response = await handleWebFinger(new Request(u), {
context,
actorDispatcher,
onNotFound
});
assertEquals(response.status, 400);
assertEquals(await response.text(), "Invalid resource URL.");
assertEquals(onNotFoundCalled, null);
});
const expected = {
subject: "acct:someone@example.com",
aliases: ["https://example.com/users/someone"],
links: [
{
href: "https://example.com/users/someone",
rel: "self",
type: "application/activity+json"
},
{
href: "https://example.com/@someone",
rel: "http://webfinger.net/rel/profile-page"
},
{
href: "https://example.com/@someone",
rel: "alternate",
type: "text/html"
},
{
href: "https://example.com/icon.jpg",
rel: "http://webfinger.net/rel/avatar",
type: "image/jpeg"
}
]
};
await t.step("ok: resource=acct:...", async () => {
const u = new URL(url);
u.searchParams.set("resource", "acct:someone@example.com");
const context = createContext(u);
const request = context.request;
const response = await handleWebFinger(request, {
context,
actorDispatcher,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(response.headers.get("Content-Type"), "application/jrd+json");
assertEquals(response.headers.get("Access-Control-Allow-Origin"), "*");
assertEquals(await response.json(), expected);
});
const expected2 = {
subject: "https://example.com/users/someone2",
aliases: ["acct:bar@example.com"],
links: [
{
href: "https://example.com/users/someone2",
rel: "self",
type: "application/activity+json"
},
{
href: "https://example.com/@someone2",
rel: "http://webfinger.net/rel/profile-page"
},
{
href: "https://example.com/@someone2",
rel: "alternate",
type: "text/html"
},
{
href: "https://example.com/icon.jpg",
rel: "http://webfinger.net/rel/avatar",
type: "image/jpeg"
}
]
};
await t.step("ok: resource=https:...", async () => {
const u = new URL(url);
u.searchParams.set("resource", "https://example.com/users/someone");
let context = createContext(u);
let request = context.request;
let response = await handleWebFinger(request, {
context,
actorDispatcher,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(await response.json(), {
...expected,
aliases: [],
subject: "https://example.com/users/someone"
});
u.searchParams.set("resource", "https://example.com/users/someone2");
context = createContext(u);
request = context.request;
response = await handleWebFinger(request, {
context,
actorDispatcher,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(await response.json(), expected2);
});
await t.step("gone: resource=acct:...", async () => {
const u = new URL(url);
u.searchParams.set("resource", "acct:gone@example.com");
const context = createContext(u);
const request = context.request;
const response = await handleWebFinger(request, {
context,
actorDispatcher,
onNotFound
});
assertEquals(response.status, 410);
assertEquals(response.headers.get("Access-Control-Allow-Origin"), "*");
assertEquals(onNotFoundCalled, null);
});
await t.step("gone: resource=https:...", async () => {
const u = new URL(url);
u.searchParams.set("resource", "https://example.com/users/gone");
const context = createContext(u);
const request = context.request;
const response = await handleWebFinger(request, {
context,
actorDispatcher,
onNotFound
});
assertEquals(response.status, 410);
assertEquals(response.headers.get("Access-Control-Allow-Origin"), "*");
assertEquals(onNotFoundCalled, null);
});
await t.step("not found: resource=acct:...", async () => {
const u = new URL(url);
u.searchParams.set("resource", "acct:no-one@example.com");
const context = createContext(u);
const request = context.request;
assertEquals((await handleWebFinger(request, {
context,
actorDispatcher,
onNotFound
})).status, 404);
assertEquals(onNotFoundCalled, request);
});
onNotFoundCalled = null;
await t.step("not found: resource=http:...", async () => {
const u = new URL(url);
u.searchParams.set("resource", "https://example.com/users/no-one");
let context = createContext(u);
let request = context.request;
let response = await handleWebFinger(request, {
context,
actorDispatcher,
onNotFound
});
assertEquals(response.status, 404);
assertEquals(onNotFoundCalled, request);
onNotFoundCalled = null;
u.searchParams.set("resource", "https://google.com/");
context = createContext(u);
request = context.request;
response = await handleWebFinger(request, {
context,
actorDispatcher,
onNotFound
});
assertEquals(response.status, 404);
assertEquals(onNotFoundCalled, request);
});
onNotFoundCalled = null;
const actorHandleMapper = (_ctx, username) => {
return username === "foo" ? "someone" : username === "bar" ? "someone2" : null;
};
await t.step("handle mapper", async () => {
const u = new URL(url);
u.searchParams.set("resource", "acct:foo@example.com");
let context = createContext(u);
let request = context.request;
let response = await handleWebFinger(request, {
context,
actorDispatcher,
actorHandleMapper,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(await response.json(), {
...expected,
aliases: ["https://example.com/users/someone"],
subject: "acct:foo@example.com"
});
u.searchParams.set("resource", "acct:bar@example.com");
context = createContext(u);
request = context.request;
response = await handleWebFinger(request, {
context,
actorDispatcher,
actorHandleMapper,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(await response.json(), {
...expected2,
aliases: ["https://example.com/users/someone2"],
subject: "acct:bar@example.com"
});
u.searchParams.set("resource", "https://example.com/users/someone");
context = createContext(u);
request = context.request;
response = await handleWebFinger(request, {
context,
actorDispatcher,
actorHandleMapper,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(await response.json(), {
...expected,
aliases: [],
subject: "https://example.com/users/someone"
});
u.searchParams.set("resource", "acct:baz@example.com");
context = createContext(u);
request = context.request;
response = await handleWebFinger(request, {
context,
actorDispatcher,
actorHandleMapper,
onNotFound
});
assertEquals(response.status, 404);
});
const actorAliasMapper = (_ctx, resource) => {
if (resource.protocol !== "https:") return null;
if (resource.host !== "example.com") return null;
const m = /^\/@(\w+)$/.exec(resource.pathname);
if (m == null) return null;
return { username: m[1] };
};
await t.step("alias mapper", async () => {
const u = new URL(url);
u.searchParams.set("resource", "https://example.com/@someone");
let context = createContext(u);
let request = context.request;
let response = await handleWebFinger(request, {
context,
actorDispatcher,
actorAliasMapper,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(await response.json(), {
...expected,
aliases: ["https://example.com/users/someone"],
subject: "https://example.com/@someone"
});
u.searchParams.set("resource", "https://example.com/@bar");
context = createContext(u);
request = context.request;
response = await handleWebFinger(request, {
context,
actorDispatcher,
actorHandleMapper,
actorAliasMapper,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(await response.json(), {
...expected2,
aliases: ["acct:bar@example.com", "https://example.com/users/someone2"],
subject: "https://example.com/@bar"
});
u.searchParams.set("resource", "https://example.com/@no-one");
context = createContext(u);
request = context.request;
response = await handleWebFinger(request, {
context,
actorDispatcher,
actorAliasMapper,
onNotFound
});
assertEquals(response.status, 404);
u.searchParams.set("resource", "https://example.com/@no-one");
context = createContext(u);
request = context.request;
response = await handleWebFinger(request, {
context,
actorDispatcher,
actorHandleMapper,
actorAliasMapper,
onNotFound
});
assertEquals(response.status, 404);
});
await t.step("handleHost", async () => {
const u = new URL(url);
u.searchParams.set("resource", "acct:someone@example.com");
let context = createContext(u);
let request = context.request;
let response = await handleWebFinger(request, {
context,
host: "handle.example.com",
actorDispatcher,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(await response.json(), {
...expected,
aliases: [...expected.aliases, "acct:someone@handle.example.com"]
});
u.searchParams.set("resource", "acct:someone@handle.example.com");
context = createContext(u);
request = context.request;
response = await handleWebFinger(request, {
context,
host: "handle.example.com",
actorDispatcher,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(await response.json(), {
...expected,
subject: "acct:someone@handle.example.com"
});
u.searchParams.set("resource", "https://example.com/users/someone2");
context = createContext(u);
request = context.request;
response = await handleWebFinger(request, {
context,
host: "handle.example.com",
actorDispatcher,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(await response.json(), {
...expected2,
aliases: ["acct:bar@handle.example.com", "acct:bar@example.com"],
subject: "https://example.com/users/someone2"
});
});
const expectedForLocalhostWithPort = {
subject: "acct:someone@localhost:8000",
aliases: ["https://localhost:8000/users/someone"],
links: [
{
href: "https://localhost:8000/users/someone",
rel: "self",
type: "application/activity+json"
},
{
href: "https://localhost:8000/@someone",
rel: "http://webfinger.net/rel/profile-page"
},
{
href: "https://localhost:8000/@someone",
rel: "alternate",
type: "text/html"
},
{
href: "https://localhost:8000/icon.jpg",
rel: "http://webfinger.net/rel/avatar",
type: "image/jpeg"
}
]
};
await t.step("on localhost with port, ok: resource=acct:...", async () => {
const u = new URL("https://localhost:8000/.well-known/webfinger");
u.searchParams.set("resource", "acct:someone@localhost:8000");
const context = createContext(u);
const request = context.request;
const response = await handleWebFinger(request, {
context,
actorDispatcher,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(response.headers.get("Content-Type"), "application/jrd+json");
assertEquals(response.headers.get("Access-Control-Allow-Origin"), "*");
assertEquals(await response.json(), expectedForLocalhostWithPort);
});
const expectedForHostnameWithPort = {
subject: "acct:someone@example.com:8000",
aliases: ["http://example.com:8000/users/someone"],
links: [
{
href: "http://example.com:8000/users/someone",
rel: "self",
type: "application/activity+json"
},
{
href: "http://example.com:8000/@someone",
rel: "http://webfinger.net/rel/profile-page"
},
{
href: "http://example.com:8000/@someone",
rel: "alternate",
type: "text/html"
},
{
href: "http://example.com:8000/icon.jpg",
rel: "http://webfinger.net/rel/avatar",
type: "image/jpeg"
}
]
};
await t.step("on hostname with port, ok: resource=acct:...", async () => {
const u = new URL("http://example.com:8000/.well-known/webfinger");
u.searchParams.set("resource", "acct:someone@example.com:8000");
const context = createContext(u);
const request = context.request;
const response = await handleWebFinger(request, {
context,
actorDispatcher,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(response.headers.get("Content-Type"), "application/jrd+json");
assertEquals(response.headers.get("Access-Control-Allow-Origin"), "*");
assertEquals(await response.json(), expectedForHostnameWithPort);
});
await t.step("webFingerLinksDispatcher", async () => {
const webFingerLinksDispatcher = (_ctx) => {
return [{
rel: "http://ostatus.org/schema/1.0/subscribe",
template: "https://example.com/follow?acct={uri}"
}];
};
const u = new URL(url);
u.searchParams.set("resource", "acct:someone@example.com");
const context = createContext(u);
const request = context.request;
const response = await handleWebFinger(request, {
context,
actorDispatcher,
webFingerLinksDispatcher,
onNotFound
});
assertEquals(response.status, 200);
assertEquals(await response.json(), {
...expected,
links: [...expected.links, {
rel: "http://ostatus.org/schema/1.0/subscribe",
template: "https://example.com/follow?acct={uri}"
}]
});
});
});
test("handleWebFinger() records webfinger.handle counter and duration", async (t) => {
const url = new URL("https://example.com/.well-known/webfinger");
const actorDispatcher = (ctx, identifier) => {
if (identifier === "gone") return new Tombstone({ id: ctx.getActorUri(identifier) });
if (identifier !== "someone") return null;
return new Person({
id: ctx.getActorUri(identifier),
preferredUsername: "someone"
});
};
const onNotFound = () => new Response("Not found", { status: 404 });
function createContext(u) {
return createRequestContext({
federation: createFederation({ kv: new MemoryKvStore() }),
url: u,
data: void 0,
getActorUri(identifier) {
return new URL(`${u.origin}/users/${identifier}`);
},
async getActor(handle) {
const actor = await actorDispatcher(this, handle);
return actor instanceof Tombstone ? null : actor;
},
parseUri(uri) {
if (uri == null) return null;
if (uri.protocol === "acct:") return null;
if (!uri.pathname.startsWith("/users/")) return null;
return {
type: "actor",
identifier: uri.pathname.split("/").pop()
};
}
});
}
await t.step("records result=resolved for a 200 response", async () => {
const u = new URL(url);
u.searchParams.set("resource", "acct:someone@example.com");
const context = createContext(u);
const [meterProvider, recorder] = createTestMeterProvider();
assertEquals((await handleWebFinger(context.request, {
context,
actorDispatcher,
onNotFound,
meterProvider
})).status, 200);
const counter = recorder.getMeasurement("webfinger.handle");
assertNotEquals(counter, void 0);
assertEquals(counter?.type, "counter");
assertEquals(counter?.value, 1);
assertEquals(counter?.attributes["webfinger.handle.result"], "resolved");
assertEquals(counter?.attributes["webfinger.resource.scheme"], "acct");
assertEquals(counter?.attributes["http.response.status_code"], 200);
const duration = recorder.getMeasurement("webfinger.handle.duration");
assertNotEquals(duration, void 0);
assertEquals(duration?.type, "histogram");
assertEquals(duration?.attributes["webfinger.handle.result"], "resolved");
});
await t.step("records result=invalid for a 400 response", async () => {
const context = createContext(new URL(url));
const [meterProvider, recorder] = createTestMeterProvider();
assertEquals((await handleWebFinger(context.request, {
context,
actorDispatcher,
onNotFound,
meterProvider
})).status, 400);
const counter = recorder.getMeasurement("webfinger.handle");
assertEquals(counter?.attributes["webfinger.handle.result"], "invalid");
assertEquals(counter?.attributes["http.response.status_code"], 400);
assertEquals("webfinger.resource.scheme" in (counter?.attributes ?? {}), false, "missing resource has no scheme attribute");
});
await t.step("records result=not_found for a 404 response", async () => {
const u = new URL(url);
u.searchParams.set("resource", "acct:absent@example.com");
const context = createContext(u);
const [meterProvider, recorder] = createTestMeterProvider();
assertEquals((await handleWebFinger(context.request, {
context,
actorDispatcher,
onNotFound,
meterProvider
})).status, 404);
const counter = recorder.getMeasurement("webfinger.handle");
assertEquals(counter?.attributes["webfinger.handle.result"], "not_found");
assertEquals(counter?.attributes["http.response.status_code"], 404);
assertEquals(counter?.attributes["webfinger.resource.scheme"], "acct");
});
await t.step("records result=tombstoned for a 410 response", async () => {
const u = new URL(url);
u.searchParams.set("resource", "acct:gone@example.com");
const context = createContext(u);
const [meterProvider, recorder] = createTestMeterProvider();
assertEquals((await handleWebFinger(context.request, {
context,
actorDispatcher,
onNotFound,
meterProvider
})).status, 410);
const counter = recorder.getMeasurement("webfinger.handle");
assertEquals(counter?.attributes["webfinger.handle.result"], "tombstoned");
assertEquals(counter?.attributes["http.response.status_code"], 410);
});
await t.step("records result=not_found when onNotFound returns 200", async () => {
const u = new URL(url);
u.searchParams.set("resource", "acct:absent@example.com");
const context = createContext(u);
const [meterProvider, recorder] = createTestMeterProvider();
assertEquals((await handleWebFinger(context.request, {
context,
actorDispatcher,
onNotFound: () => new Response("custom fallback", { status: 200 }),
meterProvider
})).status, 200);
const counter = recorder.getMeasurement("webfinger.handle");
assertEquals(counter?.attributes["webfinger.handle.result"], "not_found");
assertEquals(counter?.attributes["http.response.status_code"], 200);
});
await t.step("buckets unknown resource schemes as 'other' to keep metric cardinality bounded", async () => {
const u = new URL(url);
u.searchParams.set("resource", "ssh:nobody@example.com");
const context = createContext(u);
const [meterProvider, recorder] = createTestMeterProvider();
await handleWebFinger(context.request, {
context,
actorDispatcher,
onNotFound,
meterProvider
});
assertEquals(recorder.getMeasurement("webfinger.handle")?.attributes["webfinger.resource.scheme"], "other");
});
await t.step("omits measurements when no meterProvider is provided", async () => {
const u = new URL(url);
u.searchParams.set("resource", "acct:someone@example.com");
const context = createContext(u);
const [_unused, recorder] = createTestMeterProvider();
await handleWebFinger(context.request, {
context,
actorDispatcher,
onNotFound
});
assertEquals(recorder.getMeasurements("webfinger.handle").length, 0);
assertEquals(recorder.getMeasurements("webfinger.handle.duration").length, 0);
});
});
//#endregion
export {};