trails/apps/journal/app/lib/federation.server.test.ts
Ullrich Schäfer b17685d58c feat(journal): federation inbox — Mastodon follows land here
social-federation tasks 3.1–3.4, 4.1–4.8. With this, a Mastodon user
can follow a public trails user: WebFinger → actor fetch → signed
Follow → recorded + Accept(Follow) pushed back.

Identity surface (section 3):
- Actor objects now carry the user's public key (publicKey +
  assertionMethods via Fedify key pairs dispatcher; keys generated
  lazily as a fallback to the backfill) and an inbox IRI; url uses
  localActorIri (3.1).
- Software discovery shipped as standard NodeInfo
  (/.well-known/nodeinfo + /nodeinfo/2.1, software.name trails-cool)
  instead of the originally-sketched custom AS actor field — Fedify's
  typed vocab can't emit arbitrary actor props and NodeInfo is what
  the fediverse reads. Artifacts updated accordingly (3.4).

Inbox (section 4):
- /users/:username/inbox resource route; HTTP Signatures verified by
  Fedify before any listener runs (4.1). Rate-limited 60 req/5 min per
  source instance (host from Signature keyId) BEFORE verification so
  hostile instances can't burn CPU on key fetches (4.8).
- Listeners: Follow → auto-accept for public profiles + Accept pushed
  back (4.2); Undo(Follow) → row removed (4.3); Accept(Follow) →
  Pending settled + first outbox poll enqueued (4.4); Reject(Follow) →
  Pending dropped (4.5; UI notice deferred to 6.6). Unhandled types
  are acknowledged + dropped by Fedify (4.6).
- Replay protection via Fedify's KvStore, now Postgres-backed
  (journal.federation_kv + daily sweep job) so dedupe survives
  restarts (4.7).

Schema (discovered requirement):
- follows.follower_id relaxed to nullable + follows.follower_actor_iri
  for inbound remote followers — the proposal's 'follows is already
  federation-ready' only held for outbound. Check constraint enforces
  exactly one follower identity; partial unique index dedupes remote
  follows. Notification fan-out + approve flow now filter local
  followers explicitly. Design/proposal updated.

Tests: 7 inbox integration tests (accept/refuse/idempotence/undo/
settle/reject/check-constraint), 6 KvStore integration tests, 2 unit
tests for source-host extraction. All against real Postgres, gated on
FEDERATION_INTEGRATION=1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 14:33:43 +02:00

156 lines
5.1 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
// Spike validation (social-federation task 1.1): exercise Fedify's URL
// dispatcher the way a remote AP client would — WebFinger lookup, then
// actor fetch with an ActivityPub Accept header — without a running
// server or database.
const dbUsers: Array<Record<string, unknown>> = [];
vi.mock("./db.ts", () => ({
getDb: () => ({
select: () => ({
from: () => ({
where: () => ({
limit: async () => dbUsers,
}),
}),
}),
}),
}));
const { handleFederationRequest, wantsActivityJson, federationSourceHost } = await import(
"./federation.server.ts"
);
const PUBLIC_USER = {
id: "u1",
username: "bruno",
displayName: "Bruno",
bio: "Riding bikes",
domain: "localhost",
profileVisibility: "public",
};
function webfingerRequest(handle: string): Request {
return new Request(
`http://localhost:3000/.well-known/webfinger?resource=${encodeURIComponent(handle)}`,
{ headers: { accept: "application/jrd+json" } },
);
}
function actorRequest(username: string): Request {
return new Request(`http://localhost:3000/users/${username}`, {
headers: { accept: "application/activity+json" },
});
}
beforeEach(() => {
process.env.FEDERATION_ENABLED = "true";
dbUsers.length = 0;
});
describe("federation flag", () => {
it("404s every federation request when FEDERATION_ENABLED is off", async () => {
process.env.FEDERATION_ENABLED = "false";
dbUsers.push(PUBLIC_USER);
const res = await handleFederationRequest(webfingerRequest("acct:bruno@localhost:3000"));
expect(res.status).toBe(404);
});
});
describe("WebFinger", () => {
it("resolves a public user's handle to their actor IRI", async () => {
dbUsers.push(PUBLIC_USER);
const res = await handleFederationRequest(webfingerRequest("acct:bruno@localhost:3000"));
expect(res.status).toBe(200);
const jrd = await res.json();
expect(jrd.subject).toBe("acct:bruno@localhost:3000");
const self = jrd.links.find((l: { rel: string }) => l.rel === "self");
expect(self.href).toBe("http://localhost:3000/users/bruno");
expect(self.type).toContain("application/activity+json");
});
it("404s for a private user without leaking existence", async () => {
dbUsers.push({ ...PUBLIC_USER, profileVisibility: "private" });
const res = await handleFederationRequest(webfingerRequest("acct:bruno@localhost:3000"));
expect(res.status).toBe(404);
});
it("404s for an unknown user", async () => {
const res = await handleFederationRequest(webfingerRequest("acct:ghost@localhost:3000"));
expect(res.status).toBe(404);
});
});
describe("actor object", () => {
it("serves a Person actor for a public user", async () => {
dbUsers.push(PUBLIC_USER);
const res = await handleFederationRequest(actorRequest("bruno"));
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("application/activity+json");
const actor = await res.json();
expect(actor.type).toBe("Person");
expect(actor.id).toBe("http://localhost:3000/users/bruno");
expect(actor.preferredUsername).toBe("bruno");
expect(actor.name).toBe("Bruno");
expect(actor.summary).toBe("Riding bikes");
});
it("404s the actor for a private user", async () => {
dbUsers.push({ ...PUBLIC_USER, profileVisibility: "private" });
const res = await handleFederationRequest(actorRequest("bruno"));
expect(res.status).toBe(404);
});
});
describe("federationSourceHost", () => {
it("extracts the host from the HTTP Signature keyId", () => {
const req = new Request("http://localhost:3000/users/bruno/inbox", {
method: "POST",
headers: {
signature:
'keyId="https://mastodon.social/users/alice#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="abc=="',
},
});
expect(federationSourceHost(req)).toBe("mastodon.social");
});
it("buckets unsigned or malformed requests as unknown", () => {
expect(
federationSourceHost(new Request("http://localhost:3000/users/bruno/inbox", { method: "POST" })),
).toBe("unknown");
expect(
federationSourceHost(
new Request("http://localhost:3000/users/bruno/inbox", {
method: "POST",
headers: { signature: 'keyId="not a url",signature="x"' },
}),
),
).toBe("unknown");
});
});
describe("wantsActivityJson", () => {
it("matches AP client Accept headers, not browsers", () => {
expect(wantsActivityJson(actorRequest("bruno"))).toBe(true);
expect(
wantsActivityJson(
new Request("http://localhost:3000/users/bruno", {
// Mastodon's exact Accept header
headers: {
accept:
'application/activity+json, application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
},
}),
),
).toBe(true);
expect(
wantsActivityJson(
new Request("http://localhost:3000/users/bruno", {
headers: { accept: "text/html,application/xhtml+xml" },
}),
),
).toBe(false);
});
});