From dd9943efdf123a899619c8e716cf0a6b7674515e Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 22 Sep 2026 12:19:23 +0900 Subject: [PATCH 1/4] Use intercepted IPv6 inboxes for relay fixtures Move Alice's inbox fixtures to separate IPv6 documentation ranges and keep the document loader and stored actor fixtures consistent. Add module-level fetch interceptors, which this checkout lacks, so fixture deliveries receive a local response without DNS or network I/O. This implements the fixture portion of: https://github.com/fedify-dev/fedify/issues/1046 Validated with mise run check-each relay and mise run test-each relay (Deno, Node.js, and Bun). Changelog: none Assisted-by: Codex:gpt-6-astra --- packages/relay/src/litepub.test.ts | 17 +++++++++-------- packages/relay/src/mastodon.test.ts | 25 +++++++++++++------------ 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index 8404c8201..b73b1a4ed 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -39,7 +39,7 @@ const mockDocumentLoader = async (url: string): Promise => { id: url, type: "Person", preferredUsername: "alice", - inbox: "https://remote.example.com/users/alice/inbox", + inbox: aliceInbox, publicKey: { id: "https://remote.example.com/users/alice#main-key", owner: url.replace(/#main-key$/, ""), @@ -119,6 +119,7 @@ const davePublicKey = { // before the request is made. mastodon.test.ts uses a disjoint /48 so that // neither file's interceptor can swallow the other's deliveries. const INBOX_PREFIX = "https://[2001:db8:1::"; +const aliceInbox = `${INBOX_PREFIX}4]/users/alice/inbox`; const daveInbox = `${INBOX_PREFIX}1]/users/dave/inbox`; const pendingInbox = `${INBOX_PREFIX}2]/users/bob/inbox`; const acceptedInbox = `${INBOX_PREFIX}3]/users/carol/inbox`; @@ -434,7 +435,7 @@ describe("LitePubRelay", () => { const follower = new Person({ id: new URL("https://remote.example.com/users/alice"), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); const followActivity = new Follow({ @@ -483,7 +484,7 @@ describe("LitePubRelay", () => { const follower = new Person({ id: new URL("https://remote.example.com/users/alice"), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); // Public follow activity @@ -639,7 +640,7 @@ describe("LitePubRelay", () => { const follower = new Person({ id: new URL("https://remote.example.com/users/alice"), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); await kv.set( ["follower", "https://remote.example.com/users/alice"], @@ -685,7 +686,7 @@ describe("LitePubRelay", () => { const follower = new Person({ id: new URL("https://remote.example.com/users/alice"), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); // Pre-populate with pending follower @@ -805,7 +806,7 @@ describe("LitePubRelay", () => { const follower = new Person({ id: new URL(followerId), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); await kv.set( @@ -864,7 +865,7 @@ describe("LitePubRelay", () => { const follower = new Person({ id: new URL(followerId), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); await kv.set( @@ -1314,7 +1315,7 @@ describe("LitePubRelay", () => { id: new URL(followerId), preferredUsername: "alice", name: "Alice Wonderland", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); await kv.set( diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts index 23fe1b51b..7106d4070 100644 --- a/packages/relay/src/mastodon.test.ts +++ b/packages/relay/src/mastodon.test.ts @@ -38,7 +38,7 @@ const mockDocumentLoader = async (url: string): Promise => { id: url, type: "Person", preferredUsername: "alice", - inbox: "https://remote.example.com/users/alice/inbox", + inbox: aliceInbox, publicKey: { id: "https://remote.example.com/users/alice#main-key", owner: url.replace(/#main-key$/, ""), @@ -87,6 +87,7 @@ const rsaPublicKey = { // before the request is made. litepub.test.ts uses a disjoint /48 so that // neither file's interceptor can swallow the other's deliveries. const INBOX_PREFIX = "https://[2001:db8:2::"; +const aliceInbox = `${INBOX_PREFIX}2]/users/alice/inbox`; const followerInbox = `${INBOX_PREFIX}1]/users/bob/inbox`; interface DeliveredRequest { @@ -280,7 +281,7 @@ describe("MastodonRelay", () => { const follower = new Person({ id: new URL(followerId), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); // Simulate the relay's internal logic @@ -302,7 +303,7 @@ describe("MastodonRelay", () => { const follower = new Person({ id: new URL(followerId), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); await kv.set( @@ -401,7 +402,7 @@ describe("MastodonRelay", () => { const follower = new Person({ id: new URL("https://remote.example.com/users/alice"), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); const followActivity = new Follow({ @@ -457,7 +458,7 @@ describe("MastodonRelay", () => { const follower = new Person({ id: new URL("https://remote.example.com/users/alice"), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); const followActivity = new Follow({ @@ -500,7 +501,7 @@ describe("MastodonRelay", () => { const follower = new Person({ id: new URL(followerId), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); const followActivityId = "https://remote.example.com/activities/follow/1"; @@ -560,7 +561,7 @@ describe("MastodonRelay", () => { const follower = new Person({ id: new URL(followerId), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); await kv.set( @@ -848,7 +849,7 @@ describe("MastodonRelay", () => { const follower = new Person({ id: new URL("https://remote.example.com/users/alice"), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); // Public follow activity @@ -976,7 +977,7 @@ describe("MastodonRelay", () => { id: new URL(followerId), preferredUsername: "alice", name: "Alice Wonderland", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); await kv.set( @@ -1009,7 +1010,7 @@ describe("MastodonRelay", () => { const follower1 = new Person({ id: new URL("https://remote.example.com/users/alice"), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); const follower2 = new Person({ id: new URL("https://remote.example.com/users/bob"), @@ -1075,7 +1076,7 @@ describe("MastodonRelay", () => { const follower = new Person({ id: new URL("https://remote.example.com/users/alice"), preferredUsername: "alice", - inbox: new URL("https://remote.example.com/users/alice/inbox"), + inbox: new URL(aliceInbox), }); await kv.set( @@ -1094,7 +1095,7 @@ describe("MastodonRelay", () => { strictEqual(result.actor.preferredUsername, "alice"); strictEqual( result.actor.inboxId?.href, - "https://remote.example.com/users/alice/inbox", + aliceInbox, ); // Test non-existent follower From c83fe40209da14d687693ed89eda5fec2dd1a491 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 22 Sep 2026 12:39:45 +0900 Subject: [PATCH 2/4] Assert relay inbox response statuses Check for 202 Accepted at all 14 previously discarded inbox responses in the LitePub and Mastodon relay tests. This prevents follower-store assertions from hiding errors returned by the inbox handler. https://github.com/fedify-dev/fedify/issues/1046 Validated with mise run test-each relay, including code checks and Deno, Node.js, and Bun tests. Changelog: none Assisted-by: Codex:gpt-6-astra --- packages/relay/src/litepub.test.ts | 29 +++++++++++++++++------------ packages/relay/src/mastodon.test.ts | 15 ++++++++++----- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index b73b1a4ed..57f0bc097 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -384,10 +384,7 @@ describe("LitePubRelay", () => { const deliveredActivities = recordInbox(daveInbox); const response = await relay.fetch(request); - ok( - response.status === 200 || response.status === 202, - `Unexpected inbox response status: ${response.status}`, - ); + strictEqual(response.status, 202); // Verify handler was called strictEqual(handlerCalled, true); @@ -460,7 +457,8 @@ describe("LitePubRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); // Verify follower was NOT stored const followerData = await kv.get([ @@ -510,7 +508,8 @@ describe("LitePubRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); // Verify follower was stored with "pending" state const followerData = await kv.get([ @@ -554,7 +553,8 @@ describe("LitePubRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); // Verify follower was NOT stored const followerData = await kv.get([ @@ -611,7 +611,8 @@ describe("LitePubRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); strictEqual(handlerCallCount, 1); const follower = await relay.getFollower(followerId); @@ -668,7 +669,8 @@ describe("LitePubRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); strictEqual(handlerCallCount, 0); const followerData = await kv.get([ @@ -731,7 +733,8 @@ describe("LitePubRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); // Verify follower state changed to "accepted" const followerData = await kv.get([ @@ -792,7 +795,8 @@ describe("LitePubRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); deepStrictEqual(await kv.get(["follower", followerId]), invalidRow); } @@ -850,7 +854,8 @@ describe("LitePubRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); const followerData = await kv.get(["follower", followerId]); strictEqual(followerData, undefined); diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts index 7106d4070..3b324b714 100644 --- a/packages/relay/src/mastodon.test.ts +++ b/packages/relay/src/mastodon.test.ts @@ -427,7 +427,8 @@ describe("MastodonRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); // Verify handler was called strictEqual(handlerCalled, true); @@ -483,7 +484,8 @@ describe("MastodonRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); // Verify follower was NOT stored const followerData = await kv.get([ @@ -546,7 +548,8 @@ describe("MastodonRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); // Verify follower was removed const followerData = await kv.get(["follower", followerId]); @@ -825,7 +828,8 @@ describe("MastodonRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); // Verify follower was NOT stored const followerData = await kv.get([ @@ -875,7 +879,8 @@ describe("MastodonRelay", () => { rsaPublicKey.id, ); - await relay.fetch(request); + const response = await relay.fetch(request); + strictEqual(response.status, 202); // Verify follower was stored const followerData = await kv.get([ From 74cab844cfbcd375765314c59c28086a59a55393 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Tue, 22 Sep 2026 12:54:08 +0900 Subject: [PATCH 3/4] Verify Accept delivery in relay approval tests Record deliveries to Alice's inbox and assert that approved follows produce exactly one Accept POST from the relay for the expected Follow. Cover subscription approval, public follows, and malformed follower rows. Use unique Follow IDs to isolate concurrent tests and loop iterations. https://github.com/fedify-dev/fedify/issues/1046 Validated with mise run test-each relay, including code checks and Deno, Node.js, and Bun tests. Changelog: none Assisted-by: Codex:gpt-6-astra --- packages/relay/src/litepub.test.ts | 37 ++++++++++++++++++++++++++--- packages/relay/src/mastodon.test.ts | 31 ++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index 57f0bc097..bf987b6ce 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -142,6 +142,25 @@ function recordInbox(inbox: string): DeliveredRequest[] { return recorded; } +const aliceDeliveries = recordInbox(aliceInbox); + +function assertAcceptDelivered( + follow: Follow, + deliveries: readonly DeliveredRequest[] = aliceDeliveries, +): void { + const accepts = deliveries.filter(({ body }) => + body.type === "Accept" && body.object?.id === follow.id?.href + ); + strictEqual(accepts.length, 1, "Expected exactly one Accept for this Follow"); + strictEqual(accepts[0].method, "POST"); + strictEqual(accepts[0].body.actor, "https://relay.example.com/users/relay"); + strictEqual(accepts[0].body.object.type, "Follow"); + strictEqual( + accepts[0].body.object.actor?.id ?? accepts[0].body.object.actor, + follow.actorId?.href, + ); +} + const nextFetch = globalThis.fetch; globalThis.fetch = (async (input: URL | RequestInfo, init?: RequestInit) => { const request = input instanceof Request ? input : new Request(input, init); @@ -361,7 +380,10 @@ describe("LitePubRelay", () => { }); const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/1"), + // Isolate delivery assertions from other tests and loop iterations. + id: new URL( + `https://remote.example.com/activities/follow/${crypto.randomUUID()}`, + ), actor: follower.id, object: new URL("https://relay.example.com/users/relay"), }); @@ -385,6 +407,7 @@ describe("LitePubRelay", () => { const deliveredActivities = recordInbox(daveInbox); const response = await relay.fetch(request); strictEqual(response.status, 202); + assertAcceptDelivered(followActivity, deliveredActivities); // Verify handler was called strictEqual(handlerCalled, true); @@ -487,7 +510,10 @@ describe("LitePubRelay", () => { // Public follow activity const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/1"), + // Isolate delivery assertions from other tests and loop iterations. + id: new URL( + `https://remote.example.com/activities/follow/${crypto.randomUUID()}`, + ), actor: follower.id, object: new URL("https://www.w3.org/ns/activitystreams#Public"), }); @@ -510,6 +536,7 @@ describe("LitePubRelay", () => { const response = await relay.fetch(request); strictEqual(response.status, 202); + assertAcceptDelivered(followActivity); // Verify follower was stored with "pending" state const followerData = await kv.get([ @@ -594,7 +621,10 @@ describe("LitePubRelay", () => { strictEqual(await relay.getFollower(followerId), null); const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/1"), + // Isolate delivery assertions from other tests and loop iterations. + id: new URL( + `https://remote.example.com/activities/follow/${crypto.randomUUID()}`, + ), actor: new URL(followerId), object: new URL("https://relay.example.com/users/relay"), }); @@ -613,6 +643,7 @@ describe("LitePubRelay", () => { const response = await relay.fetch(request); strictEqual(response.status, 202); + assertAcceptDelivered(followActivity); strictEqual(handlerCallCount, 1); const follower = await relay.getFollower(followerId); diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts index 3b324b714..a8089d13a 100644 --- a/packages/relay/src/mastodon.test.ts +++ b/packages/relay/src/mastodon.test.ts @@ -108,6 +108,25 @@ function recordInbox(inbox: string): DeliveredRequest[] { return recorded; } +const aliceDeliveries = recordInbox(aliceInbox); + +function assertAcceptDelivered( + follow: Follow, + deliveries: readonly DeliveredRequest[] = aliceDeliveries, +): void { + const accepts = deliveries.filter(({ body }) => + body.type === "Accept" && body.object?.id === follow.id?.href + ); + strictEqual(accepts.length, 1, "Expected exactly one Accept for this Follow"); + strictEqual(accepts[0].method, "POST"); + strictEqual(accepts[0].body.actor, "https://relay.example.com/users/relay"); + strictEqual(accepts[0].body.object.type, "Follow"); + strictEqual( + accepts[0].body.object.actor?.id ?? accepts[0].body.object.actor, + follow.actorId?.href, + ); +} + const nextFetch = globalThis.fetch; globalThis.fetch = (async (input: URL | RequestInfo, init?: RequestInit) => { const request = input instanceof Request ? input : new Request(input, init); @@ -406,7 +425,10 @@ describe("MastodonRelay", () => { }); const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/1"), + // Isolate delivery assertions from other tests and loop iterations. + id: new URL( + `https://remote.example.com/activities/follow/${crypto.randomUUID()}`, + ), actor: follower.id, object: new URL("https://relay.example.com/users/relay"), }); @@ -429,6 +451,7 @@ describe("MastodonRelay", () => { const response = await relay.fetch(request); strictEqual(response.status, 202); + assertAcceptDelivered(followActivity); // Verify handler was called strictEqual(handlerCalled, true); @@ -858,7 +881,10 @@ describe("MastodonRelay", () => { // Public follow activity const followActivity = new Follow({ - id: new URL("https://remote.example.com/activities/follow/1"), + // Isolate delivery assertions from other tests and loop iterations. + id: new URL( + `https://remote.example.com/activities/follow/${crypto.randomUUID()}`, + ), actor: follower.id, object: new URL("https://www.w3.org/ns/activitystreams#Public"), }); @@ -881,6 +907,7 @@ describe("MastodonRelay", () => { const response = await relay.fetch(request); strictEqual(response.status, 202); + assertAcceptDelivered(followActivity); // Verify follower was stored const followerData = await kv.get([ From 1ce0eb80089d25920a30bed8beb34cbe82acfb20 Mon Sep 17 00:00:00 2001 From: Jiwon Kwon Date: Wed, 23 Sep 2026 15:44:37 +0900 Subject: [PATCH 4/4] Tighten relay fixtures and actor assertions Move Bob's store-only inbox onto a distinct intercepted IPv6 address to keep the fixture convention consistent without sharing the destination recorded by the forwarding test. Require the embedded actor's id in both Accept delivery helpers. Removing the URL-string fallback makes serialization-shape regressions fail the assertion instead of accepting either representation. Addresses these review comments: https://github.com/fedify-dev/fedify/pull/1047#discussion_r4074135976 https://github.com/fedify-dev/fedify/pull/1047#discussion_r4074135989 Validated formatting and all 58 relay Deno test steps. Node.js and Bun were not rerun for these edits; the preceding fresh-worktree attempt was blocked by dependency-download timeouts at npm.jsr.io. Changelog: none Assisted-by: Codex:gpt-6-astra --- packages/relay/src/litepub.test.ts | 2 +- packages/relay/src/mastodon.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/relay/src/litepub.test.ts b/packages/relay/src/litepub.test.ts index bf987b6ce..a0751770c 100644 --- a/packages/relay/src/litepub.test.ts +++ b/packages/relay/src/litepub.test.ts @@ -156,7 +156,7 @@ function assertAcceptDelivered( strictEqual(accepts[0].body.actor, "https://relay.example.com/users/relay"); strictEqual(accepts[0].body.object.type, "Follow"); strictEqual( - accepts[0].body.object.actor?.id ?? accepts[0].body.object.actor, + accepts[0].body.object.actor.id, follow.actorId?.href, ); } diff --git a/packages/relay/src/mastodon.test.ts b/packages/relay/src/mastodon.test.ts index a8089d13a..ffd5d57b2 100644 --- a/packages/relay/src/mastodon.test.ts +++ b/packages/relay/src/mastodon.test.ts @@ -122,7 +122,7 @@ function assertAcceptDelivered( strictEqual(accepts[0].body.actor, "https://relay.example.com/users/relay"); strictEqual(accepts[0].body.object.type, "Follow"); strictEqual( - accepts[0].body.object.actor?.id ?? accepts[0].body.object.actor, + accepts[0].body.object.actor.id, follow.actorId?.href, ); } @@ -1047,7 +1047,7 @@ describe("MastodonRelay", () => { const follower2 = new Person({ id: new URL("https://remote.example.com/users/bob"), preferredUsername: "bob", - inbox: new URL("https://remote.example.com/users/bob/inbox"), + inbox: new URL(`${INBOX_PREFIX}3]/users/bob/inbox`), }); await kv.set(