The Notes we emit (outbox + push delivery) use /activities/{id} as
their id, but that URL only served HTML — so Mastodon's search-fetch
of an activity URL failed, and strict instances that re-fetch pushed
objects to verify them would drop our deliveries.
- Fedify object dispatcher for Note at /activities/{id}: serves the
same activityToNote mapping used everywhere else; 404 unless the
activity is public AND the owner's profile is public (private owners
don't federate, mirroring the actor).
- Content-negotiation middleware on the activity detail route, same
pattern as the actor route: AP Accept headers short-circuit to
Fedify, browsers get HTML.
Found during the staging soak (Mastodon showed the outbox post count
but couldn't fetch the posts).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
6.1 KiB
TypeScript
160 lines
6.1 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { randomUUID } from "node:crypto";
|
|
import { getDb } from "./db.ts";
|
|
import { users, activities, follows } from "@trails-cool/db/schema/journal";
|
|
|
|
// Opt-in: talks to real Postgres (and exercises the real Fedify
|
|
// dispatcher stack via handleFederationRequest). Same convention as the
|
|
// other *.integration.test.ts files.
|
|
const runIntegration = process.env.FEDERATION_INTEGRATION === "1";
|
|
|
|
const USERNAME = `outbox-user-${Date.now()}`;
|
|
let userId: string;
|
|
|
|
async function seed() {
|
|
const db = getDb();
|
|
userId = randomUUID();
|
|
await db.insert(users).values({
|
|
id: userId,
|
|
email: `${USERNAME}@example.test`,
|
|
username: USERNAME,
|
|
domain: "test.local",
|
|
profileVisibility: "public",
|
|
});
|
|
// 3 public, 1 unlisted, 1 private — only the 3 public ones federate.
|
|
const mk = (n: number, visibility: "public" | "unlisted" | "private") => ({
|
|
id: randomUUID(),
|
|
ownerId: userId,
|
|
name: `Activity ${n}`,
|
|
visibility,
|
|
distance: 1000 * n,
|
|
createdAt: new Date(Date.now() - n * 60_000),
|
|
});
|
|
await db
|
|
.insert(activities)
|
|
.values([mk(1, "public"), mk(2, "public"), mk(3, "public"), mk(4, "unlisted"), mk(5, "private")]);
|
|
}
|
|
|
|
describe.runIf(runIntegration)("federation outbox (integration)", () => {
|
|
beforeAll(async () => {
|
|
process.env.FEDERATION_ENABLED = "true";
|
|
process.env.ORIGIN ??= "http://localhost:3000";
|
|
await seed();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
const db = getDb();
|
|
await db.delete(activities).where(eq(activities.ownerId, userId));
|
|
await db.delete(follows).where(eq(follows.followedUserId, userId));
|
|
await db.delete(users).where(eq(users.id, userId));
|
|
});
|
|
|
|
// Mirrors routes/users.$username.outbox.ts: route-level visibility
|
|
// gate first (Fedify's collection-level response doesn't consult the
|
|
// page dispatcher), then delegate to Fedify.
|
|
async function fetchOutbox(path: string): Promise<Response> {
|
|
const { handleFederationRequest, isFederatableUser } = await import("./federation.server.ts");
|
|
if (!(await isFederatableUser(USERNAME))) {
|
|
return new Response("Not Found", { status: 404 });
|
|
}
|
|
return handleFederationRequest(
|
|
new Request(`http://localhost:3000${path}`, {
|
|
headers: { accept: "application/activity+json" },
|
|
}),
|
|
);
|
|
}
|
|
|
|
it("serves an OrderedCollection counting only public activities", async () => {
|
|
const res = await fetchOutbox(`/users/${USERNAME}/outbox`);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
expect(body.type).toBe("OrderedCollection");
|
|
expect(body.totalItems).toBe(3);
|
|
expect(body.first).toBeDefined();
|
|
});
|
|
|
|
it("pages contain Create(Note) items, public only, newest first", async () => {
|
|
const res = await fetchOutbox(`/users/${USERNAME}/outbox?cursor=0`);
|
|
expect(res.status).toBe(200);
|
|
const body = await res.json();
|
|
const items = Array.isArray(body.orderedItems) ? body.orderedItems : [body.orderedItems];
|
|
expect(items).toHaveLength(3);
|
|
for (const item of items) {
|
|
expect(item.type).toBe("Create");
|
|
expect(item.object.type).toBe("Note");
|
|
expect(item.object.attributedTo).toContain(`/users/${USERNAME}`);
|
|
}
|
|
const names = items.map((i: { object: { content: string } }) => i.object.content);
|
|
expect(names[0]).toContain("Activity 1"); // newest first
|
|
expect(names.join()).not.toContain("Activity 4"); // unlisted excluded
|
|
expect(names.join()).not.toContain("Activity 5"); // private excluded
|
|
});
|
|
|
|
it("404s the outbox of a private profile", async () => {
|
|
const db = getDb();
|
|
await db.update(users).set({ profileVisibility: "private" }).where(eq(users.id, userId));
|
|
const res = await fetchOutbox(`/users/${USERNAME}/outbox`);
|
|
expect(res.status).toBe(404);
|
|
await db.update(users).set({ profileVisibility: "public" }).where(eq(users.id, userId));
|
|
});
|
|
|
|
it("serves a public activity's Note at its IRI (dereferenceable)", async () => {
|
|
const { handleFederationRequest } = await import("./federation.server.ts");
|
|
const db = getDb();
|
|
const [row] = await db
|
|
.select({ id: activities.id })
|
|
.from(activities)
|
|
.where(and(eq(activities.ownerId, userId), eq(activities.visibility, "public")))
|
|
.limit(1);
|
|
const res = await handleFederationRequest(
|
|
new Request(`http://localhost:3000/activities/${row!.id}`, {
|
|
headers: { accept: "application/activity+json" },
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const note = await res.json();
|
|
expect(note.type).toBe("Note");
|
|
expect(note.id).toBe(`http://localhost:3000/activities/${row!.id}`);
|
|
expect(note.attributedTo).toContain(`/users/${USERNAME}`);
|
|
});
|
|
|
|
it("404s the Note of a private activity", async () => {
|
|
const { handleFederationRequest } = await import("./federation.server.ts");
|
|
const db = getDb();
|
|
const [priv] = await db
|
|
.select({ id: activities.id })
|
|
.from(activities)
|
|
.where(and(eq(activities.ownerId, userId), eq(activities.visibility, "private")))
|
|
.limit(1);
|
|
const res = await handleFederationRequest(
|
|
new Request(`http://localhost:3000/activities/${priv!.id}`, {
|
|
headers: { accept: "application/activity+json" },
|
|
}),
|
|
);
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("lists accepted remote followers as the delivery audience", async () => {
|
|
const db = getDb();
|
|
const { listAcceptedRemoteFollowers } = await import("./federation-delivery.server.ts");
|
|
await db.insert(follows).values([
|
|
{
|
|
id: randomUUID(),
|
|
followerActorIri: "https://other.example/users/accepted",
|
|
followedActorIri: `http://localhost:3000/users/${USERNAME}`,
|
|
followedUserId: userId,
|
|
acceptedAt: new Date(),
|
|
},
|
|
{
|
|
id: randomUUID(),
|
|
followerActorIri: "https://other.example/users/pending",
|
|
followedActorIri: `http://localhost:3000/users/${USERNAME}`,
|
|
followedUserId: userId,
|
|
acceptedAt: null,
|
|
},
|
|
]);
|
|
const audience = await listAcceptedRemoteFollowers(userId);
|
|
expect(audience).toEqual(["https://other.example/users/accepted"]);
|
|
});
|
|
});
|