@fedify/fedify
Version:
An ActivityPub server framework
558 lines (557 loc) • 20 kB
JavaScript
import "@js-temporal/polyfill";
import "urlpattern-polyfill";
globalThis.addEventListener = () => {};
import { t as esm_default } from "../esm-vrlUxr60.mjs";
import { t as assertEquals } from "../assert_equals-C-ZRDbaf.mjs";
import "../std__assert-BBjXFNOb.mjs";
import { n as assertGreaterOrEqual, r as assertFalse, t as assertRejects } from "../assert_rejects-DN60FHPX.mjs";
import { t as assertInstanceOf } from "../assert_instance_of-DBC5X09g.mjs";
import { t as assertNotEquals } from "../assert_not_equals-DkVK8oqV.mjs";
import { t as assert } from "../assert-OguE97r2.mjs";
import { l as verifyRequest } from "../http-PCt0862M.mjs";
import { i as rsaPrivateKey2, n as ed25519PrivateKey, s as rsaPublicKey2, t as ed25519Multikey } from "../keys-DGu1NFwu.mjs";
import { t as doesActorOwnKey } from "../owner-pVJ0Twi7.mjs";
import { n as extractInboxes, r as sendActivity, t as SendActivityError } from "../send-BfAp60BJ.mjs";
import { Activity, Application, Endpoints, Group, Person, Service } from "@fedify/vocab";
import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
import { FetchError } from "@fedify/vocab-runtime";
import { AggregationTemporality, InMemoryMetricExporter, MeterProvider, PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
//#region src/federation/send.test.ts
test("extractInboxes()", () => {
const recipients = [
new Person({
id: new URL("https://example.com/alice"),
inbox: new URL("https://example.com/alice/inbox"),
endpoints: new Endpoints({ sharedInbox: new URL("https://example.com/inbox") })
}),
new Application({
id: new URL("https://example.com/app"),
inbox: new URL("https://example.com/app/inbox"),
endpoints: new Endpoints({ sharedInbox: new URL("https://example.com/inbox") })
}),
new Group({
id: new URL("https://example.org/group"),
inbox: new URL("https://example.org/group/inbox")
}),
new Service({
id: new URL("https://example.net/service"),
inbox: new URL("https://example.net/service/inbox"),
endpoints: new Endpoints({ sharedInbox: new URL("https://example.net/inbox") })
})
];
let inboxes = extractInboxes({ recipients });
assertEquals(inboxes, {
"https://example.com/alice/inbox": {
actorIds: new Set(["https://example.com/alice"]),
sharedInbox: false
},
"https://example.com/app/inbox": {
actorIds: new Set(["https://example.com/app"]),
sharedInbox: false
},
"https://example.org/group/inbox": {
actorIds: new Set(["https://example.org/group"]),
sharedInbox: false
},
"https://example.net/service/inbox": {
actorIds: new Set(["https://example.net/service"]),
sharedInbox: false
}
});
inboxes = extractInboxes({
recipients,
preferSharedInbox: true
});
assertEquals(inboxes, {
"https://example.com/inbox": {
actorIds: new Set(["https://example.com/alice", "https://example.com/app"]),
sharedInbox: true
},
"https://example.org/group/inbox": {
actorIds: new Set(["https://example.org/group"]),
sharedInbox: false
},
"https://example.net/inbox": {
actorIds: new Set(["https://example.net/service"]),
sharedInbox: true
}
});
inboxes = extractInboxes({
recipients,
excludeBaseUris: [new URL("https://foo.bar/")]
});
assertEquals(inboxes, {
"https://example.com/alice/inbox": {
actorIds: new Set(["https://example.com/alice"]),
sharedInbox: false
},
"https://example.com/app/inbox": {
actorIds: new Set(["https://example.com/app"]),
sharedInbox: false
},
"https://example.org/group/inbox": {
actorIds: new Set(["https://example.org/group"]),
sharedInbox: false
},
"https://example.net/service/inbox": {
actorIds: new Set(["https://example.net/service"]),
sharedInbox: false
}
});
inboxes = extractInboxes({
recipients,
excludeBaseUris: [new URL("https://example.com/")]
});
assertEquals(inboxes, {
"https://example.org/group/inbox": {
actorIds: new Set(["https://example.org/group"]),
sharedInbox: false
},
"https://example.net/service/inbox": {
actorIds: new Set(["https://example.net/service"]),
sharedInbox: false
}
});
inboxes = extractInboxes({
recipients,
preferSharedInbox: true,
excludeBaseUris: [new URL("https://example.com/")]
});
assertEquals(inboxes, {
"https://example.org/group/inbox": {
actorIds: new Set(["https://example.org/group"]),
sharedInbox: false
},
"https://example.net/inbox": {
actorIds: new Set(["https://example.net/service"]),
sharedInbox: true
}
});
});
test("sendActivity()", async (t) => {
esm_default.spyGlobal();
let httpSigVerified = null;
let request = null;
esm_default.post("https://example.com/inbox", async (cl) => {
httpSigVerified = false;
request = cl.request.clone();
const options = {
documentLoader: mockDocumentLoader,
contextLoader: mockDocumentLoader
};
const key = await verifyRequest(request, options);
const activity = await Activity.fromJsonLd(await request.json(), options);
if (key != null && await doesActorOwnKey(activity, key, options)) httpSigVerified = true;
if (httpSigVerified) return new Response("", { status: 202 });
return new Response("", { status: 401 });
});
await t.step("success", async () => {
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://example.com/activity",
"actor": "https://example.com/person"
};
await sendActivity({
activity,
keys: [{
privateKey: rsaPrivateKey2,
keyId: rsaPublicKey2.id
}],
inbox: new URL("https://example.com/inbox"),
headers: new Headers({ "X-Test": "test" })
});
assert(httpSigVerified);
assertNotEquals(request, null);
assertEquals(request?.method, "POST");
assertEquals(request?.url, "https://example.com/inbox");
assertEquals(request?.headers.get("Content-Type"), "application/activity+json");
assertEquals(request?.headers.get("X-Test"), "test");
httpSigVerified = null;
await assertRejects(() => sendActivity({
activity: {
...activity,
actor: "https://example.com/person2"
},
keys: [{
privateKey: ed25519PrivateKey,
keyId: ed25519Multikey.id
}],
inbox: new URL("https://example.com/inbox")
}));
assertFalse(httpSigVerified);
assertNotEquals(request, null);
assertEquals(request?.method, "POST");
assertEquals(request?.url, "https://example.com/inbox");
assertEquals(request?.headers.get("Content-Type"), "application/activity+json");
});
esm_default.post("https://example.com/inbox2", {
status: 500,
headers: { "Retry-After": "120" },
body: "something went wrong"
});
await t.step("failure", async () => {
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://example.com/activity",
"actor": "https://example.com/person"
};
await assertRejects(() => sendActivity({
activity,
activityId: "https://example.com/activity",
keys: [{
privateKey: rsaPrivateKey2,
keyId: rsaPublicKey2.id
}],
inbox: new URL("https://example.com/inbox2")
}), Error, "Failed to send activity https://example.com/activity to https://example.com/inbox2 (500 Internal Server Error):\nsomething went wrong");
});
await t.step("failure throws SendActivityError", async () => {
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://example.com/activity",
"actor": "https://example.com/person"
};
try {
await sendActivity({
activity,
activityId: "https://example.com/activity",
keys: [{
privateKey: rsaPrivateKey2,
keyId: rsaPublicKey2.id
}],
inbox: new URL("https://example.com/inbox2")
});
assert(false, "Should have thrown");
} catch (e) {
assertInstanceOf(e, SendActivityError);
assertEquals(e.statusCode, 500);
assertEquals(e.inbox, new URL("https://example.com/inbox2"));
assertEquals(e.responseBody, "something went wrong");
assertEquals(e.responseHeaders.get("Retry-After"), "120");
}
});
await t.step("signed challenge retry transport errors throw FetchError", async () => {
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://example.com/activity",
"actor": "https://example.com/person"
};
const failure = /* @__PURE__ */ new TypeError("challenge retry connection reset");
let requestCount = 0;
esm_default.post("https://example.com/inbox-challenge-reset", () => {
requestCount++;
if (requestCount === 1) return new Response("Unauthorized", {
status: 401,
headers: { "Accept-Signature": "sig1=(\"@method\" \"@target-uri\" \"@authority\" \"content-digest\");created;nonce=\"retry-nonce\"" }
});
throw failure;
});
const error = await assertRejects(() => sendActivity({
activity,
activityId: "https://example.com/activity",
keys: [{
privateKey: rsaPrivateKey2,
keyId: rsaPublicKey2.id
}],
inbox: new URL("https://example.com/inbox-challenge-reset")
}), FetchError, "challenge retry connection reset");
assertEquals(error.url.href, "https://example.com/inbox-challenge-reset");
assertEquals(error.cause, failure);
assertEquals(requestCount, 2);
});
esm_default.post("https://example.com/inbox-gone", {
status: 410,
body: "Gone"
});
await t.step("410 Gone throws SendActivityError", async () => {
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://example.com/activity",
"actor": "https://example.com/person"
};
try {
await sendActivity({
activity,
activityId: "https://example.com/activity",
keys: [{
privateKey: rsaPrivateKey2,
keyId: rsaPublicKey2.id
}],
inbox: new URL("https://example.com/inbox-gone")
});
assert(false, "Should have thrown");
} catch (e) {
assertInstanceOf(e, SendActivityError);
assertEquals(e.statusCode, 410);
assertEquals(e.inbox, new URL("https://example.com/inbox-gone"));
assertEquals(e.responseBody, "Gone");
}
});
esm_default.post("https://example.com/inbox-notfound", {
status: 404,
body: "Not Found"
});
await t.step("404 Not Found throws SendActivityError", async () => {
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://example.com/activity",
"actor": "https://example.com/person"
};
try {
await sendActivity({
activity,
activityId: "https://example.com/activity",
keys: [{
privateKey: rsaPrivateKey2,
keyId: rsaPublicKey2.id
}],
inbox: new URL("https://example.com/inbox-notfound")
});
assert(false, "Should have thrown");
} catch (e) {
assertInstanceOf(e, SendActivityError);
assertEquals(e.statusCode, 404);
assertEquals(e.inbox, new URL("https://example.com/inbox-notfound"));
assertEquals(e.responseBody, "Not Found");
}
});
const longErrorBody = "x".repeat(1500);
esm_default.post("https://example.com/inbox-long-error", {
status: 500,
body: longErrorBody
});
await t.step("long error response body is truncated", async () => {
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://example.com/activity",
"actor": "https://example.com/person"
};
try {
await sendActivity({
activity,
activityId: "https://example.com/activity",
keys: [{
privateKey: rsaPrivateKey2,
keyId: rsaPublicKey2.id
}],
inbox: new URL("https://example.com/inbox-long-error")
});
assert(false, "Should have thrown");
} catch (e) {
assertInstanceOf(e, SendActivityError);
assertEquals(e.statusCode, 500);
assertEquals(e.inbox, new URL("https://example.com/inbox-long-error"));
const expectedTruncated = "x".repeat(1024) + "… (truncated)";
assertEquals(e.responseBody, expectedTruncated);
assertEquals(e.message.includes("… (truncated)"), true);
}
});
await t.step("short error response body is not truncated", async () => {
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://example.com/activity",
"actor": "https://example.com/person"
};
try {
await sendActivity({
activity,
activityId: "https://example.com/activity",
keys: [{
privateKey: rsaPrivateKey2,
keyId: rsaPublicKey2.id
}],
inbox: new URL("https://example.com/inbox2")
});
assert(false, "Should have thrown");
} catch (e) {
assertInstanceOf(e, SendActivityError);
assertEquals(e.responseBody, "something went wrong");
assertEquals(e.message.includes("… (truncated)"), false);
}
});
const exactLimitBody = "y".repeat(1024);
esm_default.post("https://example.com/inbox-exact-limit", {
status: 500,
body: exactLimitBody
});
await t.step("error response body exactly at limit is not truncated", async () => {
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Create",
"id": "https://example.com/activity",
"actor": "https://example.com/person"
};
try {
await sendActivity({
activity,
activityId: "https://example.com/activity",
keys: [{
privateKey: rsaPrivateKey2,
keyId: rsaPublicKey2.id
}],
inbox: new URL("https://example.com/inbox-exact-limit")
});
assert(false, "Should have thrown");
} catch (e) {
assertInstanceOf(e, SendActivityError);
assertEquals(e.responseBody, exactLimitBody);
assertEquals(e.message.includes("… (truncated)"), false);
}
});
esm_default.hardReset();
});
test("sendActivity() records OpenTelemetry span events", async (t) => {
const [tracerProvider, exporter] = createTestTracerProvider();
esm_default.spyGlobal();
await t.step("successful send", async () => {
esm_default.get("https://example.com/", { status: 404 });
esm_default.post("https://example.com/inbox", { status: 202 });
await sendActivity({
activity: {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Create",
id: "https://example.com/activity",
actor: "https://example.com/person"
},
activityId: "https://example.com/activity",
activityType: "https://www.w3.org/ns/activitystreams#Create",
keys: [{
keyId: new URL("https://example.com/person#key"),
privateKey: rsaPrivateKey2
}],
inbox: new URL("https://example.com/inbox"),
tracerProvider
});
const spans = exporter.getSpans("activitypub.send_activity");
assertEquals(spans.length, 1);
const span = spans[0];
assertEquals(span.attributes["activitypub.activity.id"], "https://example.com/activity");
assertEquals(span.attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
const events = exporter.getEvents("activitypub.send_activity", "activitypub.activity.sent");
assertEquals(events.length, 1);
const event = events[0];
assert(event.attributes != null);
assertEquals(event.attributes["activitypub.inbox.url"], "https://example.com/inbox");
assertEquals(event.attributes["activitypub.activity.id"], "https://example.com/activity");
assertEquals(event.attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
assertEquals(event.attributes["activitypub.actor.id"], "https://example.com/person");
assertEquals(event.attributes["activitypub.activity.json"], void 0);
exporter.clear();
esm_default.hardReset();
});
});
test("sendActivity() records OpenTelemetry delivery metrics", async (t) => {
const [meterProvider, recorder] = createTestMeterProvider();
esm_default.spyGlobal();
await t.step("successful send", async () => {
esm_default.post("https://metrics.example:8443/inbox/path?x=1", { status: 202 });
await sendActivity({
activity: {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Create",
id: "https://example.com/activity",
actor: "https://example.com/person"
},
activityId: "https://example.com/activity",
activityType: "https://www.w3.org/ns/activitystreams#Create",
keys: [],
inbox: new URL("https://metrics.example:8443/inbox/path?x=1"),
meterProvider
});
const sent = recorder.getMeasurements("activitypub.delivery.sent");
assertEquals(sent.length, 1);
assertEquals(sent[0].type, "counter");
assertEquals(sent[0].value, 1);
assertEquals(sent[0].attributes["activitypub.remote.host"], "metrics.example:8443");
assertEquals(sent[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
assertEquals(sent[0].attributes["activitypub.delivery.success"], true);
const durations = recorder.getMeasurements("activitypub.delivery.duration");
assertEquals(durations.length, 1);
assertEquals(durations[0].type, "histogram");
assertGreaterOrEqual(durations[0].value, 0);
assertEquals(durations[0].attributes["activitypub.remote.host"], "metrics.example:8443");
assertEquals(durations[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Create");
assertEquals(durations[0].attributes["activitypub.delivery.success"], true);
recorder.clear();
esm_default.hardReset();
});
await t.step("failed HTTP response", async () => {
esm_default.spyGlobal();
esm_default.post("https://metrics.example/inbox", {
status: 500,
body: "failed"
});
const activity = {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Follow",
id: "https://example.com/follow",
actor: "https://example.com/person"
};
await assertRejects(() => sendActivity({
activity,
activityId: "https://example.com/follow",
activityType: "https://www.w3.org/ns/activitystreams#Follow",
keys: [],
inbox: new URL("https://metrics.example/inbox"),
meterProvider
}), SendActivityError);
const sent = recorder.getMeasurements("activitypub.delivery.sent");
assertEquals(sent.length, 1);
assertEquals(sent[0].attributes["activitypub.delivery.success"], false);
assertEquals(sent[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Follow");
assertEquals(sent[0].attributes["activitypub.remote.host"], "metrics.example");
const durations = recorder.getMeasurements("activitypub.delivery.duration");
assertEquals(durations.length, 1);
assertGreaterOrEqual(durations[0].value, 0);
assertEquals(durations[0].attributes["activitypub.remote.host"], "metrics.example");
assertEquals(durations[0].attributes["activitypub.activity.type"], "https://www.w3.org/ns/activitystreams#Follow");
assertEquals(durations[0].attributes["activitypub.delivery.success"], false);
recorder.clear();
esm_default.hardReset();
});
});
test("sendActivity() exports delivery metrics through OpenTelemetry SDK", async () => {
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
const meterProvider = new MeterProvider({ readers: [new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 6e4
})] });
esm_default.spyGlobal();
esm_default.post("https://sdk-metrics.example/inbox", { status: 202 });
try {
await sendActivity({
activity: {
"@context": "https://www.w3.org/ns/activitystreams",
type: "Create",
id: "https://example.com/activity",
actor: "https://example.com/person"
},
activityId: "https://example.com/activity",
activityType: "https://www.w3.org/ns/activitystreams#Create",
keys: [],
inbox: new URL("https://sdk-metrics.example/inbox"),
meterProvider
});
await meterProvider.forceFlush();
const sent = exporter.getMetrics().flatMap((resourceMetrics) => resourceMetrics.scopeMetrics).flatMap((scopeMetrics) => scopeMetrics.metrics).find((metric) => metric.descriptor.name === "activitypub.delivery.sent");
assert(sent != null);
assertEquals(sent.dataPoints.length, 1);
assertEquals(sent.dataPoints[0].attributes["activitypub.remote.host"], "sdk-metrics.example");
} finally {
try {
await meterProvider.shutdown();
} finally {
esm_default.hardReset();
}
}
});
//#endregion
export {};