UNPKG

@fedify/fedify

Version:

An ActivityPub server framework

558 lines (557 loc) 20 kB
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 {};