Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,18 @@ To be released.

### @fedify/testing

- Fixed `createFederation()` ignoring `allowPrivateAddress: true`. Received
activities and federation-created contexts now use document loaders that
allow local servers, so calls such as `follow.getActor()` can resolve
localhost actors in inbox listeners. [[#1302], [#1306]]
- Fixed missing runtime dependencies in the npm package. `@fedify/vocab`
and `@js-temporal/polyfill` are now installed automatically so importing
`@fedify/testing` works even when dependency hoisting is disabled on
Node.js and Bun. [[#1305], [#1307]]

[#1302]: https://github.com/fedify-dev/fedify/issues/1302
[#1305]: https://github.com/fedify-dev/fedify/issues/1305
[#1306]: https://github.com/fedify-dev/fedify/pull/1306
[#1307]: https://github.com/fedify-dev/fedify/pull/1307


Expand Down
9 changes: 9 additions & 0 deletions changes.d/testing/allow-private-address.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
links:
'#1302': https://github.com/fedify-dev/fedify/issues/1302
'#1306': https://github.com/fedify-dev/fedify/pull/1306
---
- Fixed `createFederation()` ignoring `allowPrivateAddress: true`. Received
activities and federation-created contexts now use document loaders that
allow local servers, so calls such as `follow.getActor()` can resolve
localhost actors in inbox listeners. [[#1302], [#1306]]
34 changes: 34 additions & 0 deletions docs/manual/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,40 @@ await federation.receiveActivity(activity);
console.log("Sent activities:", federation.sentActivities);
~~~~

To load actors or other referenced objects from a local HTTP server, pass
`allowPrivateAddress: true`. This enables HTTP document loading for received
activities and contexts created by the mock federation, including addresses
such as `localhost`:

~~~~ typescript twoslash
import { createFederation } from "@fedify/testing";
import { Follow, Undo } from "@fedify/vocab";

const federation = createFederation({
contextData: {},
allowPrivateAddress: true
});

federation
.setInboxListeners("/users/{identifier}/inbox")
.on(Follow, async (ctx, follow) => {
const actor = await follow.getActor();
})
.on(Undo, async (ctx, undo) => {
const object = await undo.getObject();
if (object instanceof Follow) {
const actor = await object.getActor(ctx);
}
});
~~~~

*The `allowPrivateAddress` option is available since Fedify 2.0.35.*

Received activities are cloned with the configured loaders. Embedded objects
remain shared, so pass the listener context to their getters, as in
`object.getActor(ctx)` above. If the option is `false` or omitted, contexts
keep their no-op mock loaders and received activities keep their own loaders.

### Creating mock contexts

You can create mock contexts using the `createContext()` method,
Expand Down
3 changes: 2 additions & 1 deletion packages/testing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,13 @@
},
"dependencies": {
"@fedify/vocab": "workspace:^",
"@fedify/vocab-runtime": "workspace:^",
"@js-temporal/polyfill": "catalog:",
"@opentelemetry/api": "catalog:",
"es-toolkit": "catalog:"
},
"devDependencies": {
"@fedify/fixture": "workspace:^",
"@fedify/vocab-runtime": "workspace:^",
"@std/assert": "catalog:",
"@std/async": "catalog:",
"tsdown": "catalog:",
Expand Down
260 changes: 258 additions & 2 deletions packages/testing/src/mock.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import type { InboxContext } from "@fedify/fedify/federation";
import { test } from "@fedify/fixture";
import { Create, Note, Person } from "@fedify/vocab";
import { assertEquals, assertRejects, assertStrictEquals } from "@std/assert";
import { Create, Follow, Note, Person, Undo } from "@fedify/vocab";
import { getDocumentLoader, UrlError } from "@fedify/vocab-runtime";
import {
assertEquals,
assertInstanceOf,
assertNotStrictEquals,
assertRejects,
assertStrictEquals,
} from "@std/assert";
import { createFederation } from "./mock.ts";

test("MockFederation actor setters support chaining in any order", () => {
Expand Down Expand Up @@ -599,3 +606,252 @@ test("MockContext.getActorKeyPairs() returns empty array when no dispatcher regi
const keyPairs = await context.getActorKeyPairs("alice");
assertEquals(keyPairs, []);
});

const localActorUrl = "http://localhost:9999/users/alicia";

function createNetworkFederation() {
const options = { contextData: {}, allowPrivateAddress: true };
return createFederation(options);
}

function createLocalFollow(): Follow {
return new Follow({
id: new URL("http://localhost:9999/activities/follow"),
actor: new URL(localActorUrl),
object: new URL("https://example.com/users/alice"),
});
}

async function withActorFetch(fn: () => Promise<void>): Promise<string[]> {
const originalFetch = globalThis.fetch;
const requests: string[] = [];
globalThis.fetch = (input) => {
const url = input instanceof Request ? input.url : String(input);
requests.push(url);
return Promise.resolve(
new Response(
JSON.stringify({
"@context": "https://www.w3.org/ns/activitystreams",
id: url,
type: "Person",
preferredUsername: "alicia",
}),
{ headers: { "Content-Type": "application/activity+json" } },
),
);
};
try {
await fn();
} finally {
globalThis.fetch = originalFetch;
}
return requests;
}

for (const passContext of [false, true]) {
test(`receiveActivity allows private actor lookup (pass context: ${passContext})`, async () => {
const federation = createNetworkFederation();
const activity = createLocalFollow();
const received: Follow[] = [];
let actor: unknown;
federation.setInboxListeners("/users/{identifier}/inbox").on(
Follow,
async (ctx: InboxContext<Record<string, never>>, follow: Follow) => {
received.push(follow);
actor = await follow.getActor(passContext ? ctx : {});
},
).on(
Follow,
(_ctx: InboxContext<Record<string, never>>, follow: Follow) => {
received.push(follow);
return Promise.resolve();
},
);
const requests = await withActorFetch(() =>
federation.receiveActivity(activity)
);
assertEquals(requests, [localActorUrl]);
assertInstanceOf(actor, Person);
assertEquals(actor.id?.href, localActorUrl);
assertInstanceOf(received[0], Follow);
assertNotStrictEquals(received[0], activity);
assertStrictEquals(received[0], received[1]);
assertEquals(activity.actorId?.href, localActorUrl);
// The listener's lazy lookup must not populate the caller's actor cache.
await assertRejects(() => activity.getActor(), UrlError, "Localhost");
});
}

for (const allowPrivateAddress of [undefined, false]) {
test(`receiveActivity retains offline defaults (allowPrivateAddress: ${allowPrivateAddress})`, async () => {
const options = { contextData: {}, allowPrivateAddress };
const federation = createFederation(options);
const activity = createLocalFollow();
let received: Follow | undefined;
let document: unknown;
let contextDocument: unknown;
federation.setInboxListeners("/users/{identifier}/inbox").on(
Follow,
async (ctx: InboxContext<Record<string, never>>, follow: Follow) => {
received = follow;
document = (await ctx.documentLoader(localActorUrl)).document;
contextDocument = (await ctx.contextLoader(localActorUrl)).document;
await assertRejects(() => follow.getActor(), UrlError, "Localhost");
},
);
const requests = await withActorFetch(async () => {
await federation.receiveActivity(activity);
const ctx = federation.createContext(new URL("https://example.com"), {});
await ctx.documentLoader(localActorUrl);
await ctx.contextLoader(localActorUrl);
});
assertEquals(requests, []);
assertStrictEquals(received, activity);
assertEquals(document, {});
assertEquals(contextDocument, {});
});
}

test("createContext allows private actor lookup", async () => {
const federation = createNetworkFederation();
const ctx = federation.createContext(new URL("https://example.com"), {});
const activity = createLocalFollow();
let actor: unknown;
const requests = await withActorFetch(async () => {
actor = await activity.getActor(ctx);
});
assertEquals(requests, [localActorUrl]);
assertInstanceOf(actor, Person);
assertEquals(actor.id?.href, localActorUrl);
});

test("receiveActivity preserves embedded objects and supports lookup with context", async () => {
const federation = createNetworkFederation();
const follow = createLocalFollow();
// A different origin must not cause a trusted embedded object to be fetched.
const activity = new Undo({
id: new URL("https://example.com/activities/undo"),
object: follow,
});
let embedded: unknown;
let actor: unknown;
federation.setInboxListeners("/users/{identifier}/inbox").on(
Undo,
async (ctx: InboxContext<Record<string, never>>, undo: Undo) => {
const object = await undo.getObject();
embedded = object;
if (object instanceof Follow) actor = await object.getActor(ctx);
},
);
const requests = await withActorFetch(() =>
federation.receiveActivity(activity)
);
assertEquals(requests, [localActorUrl]);
assertStrictEquals(embedded, follow);
assertInstanceOf(actor, Person);
assertEquals(actor.id?.href, localActorUrl);
});

test("receiveActivity respects an explicit getter document loader", async () => {
const federation = createNetworkFederation();
let actor: unknown;
const loaderRequests: string[] = [];
federation.setInboxListeners("/users/{identifier}/inbox").on(
Follow,
async (_ctx: InboxContext<Record<string, never>>, follow: Follow) => {
actor = await follow.getActor({
documentLoader: (url) => {
loaderRequests.push(url);
return Promise.resolve({
contextUrl: null,
documentUrl: url,
document: {
"@context": "https://www.w3.org/ns/activitystreams",
id: url,
type: "Person",
},
});
},
});
},
);
const requests = await withActorFetch(() =>
federation.receiveActivity(createLocalFollow())
);
assertEquals(requests, []);
assertEquals(loaderRequests, [localActorUrl]);
assertInstanceOf(actor, Person);
assertEquals(actor.id?.href, localActorUrl);
});

test("receiveActivity preserves an unconfigured activity's own loaders", async () => {
const federation = createFederation({ contextData: {} });
const loader = getDocumentLoader({ allowPrivateAddress: true });
const activity = createLocalFollow().clone({}, {
documentLoader: loader,
contextLoader: loader,
});
let actor: unknown;
federation.setInboxListeners("/users/{identifier}/inbox").on(
Follow,
async (_ctx: InboxContext<Record<string, never>>, follow: Follow) => {
actor = await follow.getActor();
},
);
const requests = await withActorFetch(() =>
federation.receiveActivity(activity)
);
assertEquals(requests, [localActorUrl]);
assertInstanceOf(actor, Person);
});

test("receiveActivity does not clone without listeners or context data", async () => {
const options = { contextData: undefined, allowPrivateAddress: true };
const federation = createFederation(options);
const activity = createLocalFollow();
let clones = 0;
activity.clone = () => {
clones++;
return activity;
};
const requests = await withActorFetch(async () => {
await federation.receiveActivity(activity);
federation.setInboxListeners("/users/{identifier}/inbox").on(
Follow,
() => Promise.resolve(),
);
await assertRejects(
() => federation.receiveActivity(activity),
Error,
"contextData",
);
});
assertEquals(requests, []);
assertEquals(clones, 0);
});

test("receiveActivity preserves parsed JSON-LD extension properties", async () => {
const federation = createNetworkFederation();
const json = {
"@context": "https://www.w3.org/ns/activitystreams",
id: "http://localhost:9999/activities/follow",
type: "Follow",
actor: localActorUrl,
object: "https://example.com/users/alice",
"https://example.com/ns#custom": "preserved",
};
const activity = await Follow.fromJsonLd(json);
let receivedJson: unknown;
federation.setInboxListeners("/users/{identifier}/inbox").on(
Follow,
async (_ctx: InboxContext<Record<string, never>>, follow: Follow) => {
receivedJson = await follow.toJsonLd();
},
);
const requests = await withActorFetch(() =>
federation.receiveActivity(activity)
);
assertEquals(requests, []);
assertEquals(receivedJson, json);
assertEquals(await activity.toJsonLd(), json);
});
Loading
Loading