trails/apps/journal/app/lib/federation-outbound.integration.test.ts
Ullrich Schäfer 62b40a25f3 feat(journal): outbound trails-to-trails follows (social-federation §6)
Tasks 6.1–6.6. A local user can follow a user on another trails
instance: WebFinger-resolve the handle, verify the target runs
trails.cool via NodeInfo — checked against the ACTOR IRI's host, never
the handle's domain (split-domain constraint) — record a Pending
follow row, deliver a signed Follow. The §4 inbox listeners already
settle (Accept) or drop (Reject) the row.

- federation-outbound.server.ts: followRemoteActor / cancelRemoteFollow
  (Undo delivery) / listOutgoingRemoteFollows. Network steps (lookup,
  NodeInfo, delivery) injectable for offline integration tests.
  Software allowlist: trails-cool.
- /follows/outgoing: follow-by-handle form + pending/accepted list +
  cancel; linked from the feed empty state; i18n en+de. Remote actors
  have no local profile page, so the remote Pending state lives here
  (the profile Pending button from locked accounts covers local).
- /.well-known/trails-cool now publishes software: trails-cool (6.2).
- Clear 4xx codes: invalid_handle, local_handle, remote_resolve_failed,
  not_trails (6.3).
- Tests: handle-parser + allowlist units; integration suite for the
  Pending lifecycle (create/idempotent/refuse-non-trails/refuse-
  unresolvable/own-host/cancel+Undo) with injected network deps; e2e
  anonymous-redirect guard for the new page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 11:50:35 +02:00

155 lines
5.7 KiB
TypeScript

import { describe, it, expect, beforeAll, afterEach } from "vitest";
import { eq } from "drizzle-orm";
import { randomUUID } from "node:crypto";
import { getDb } from "./db.ts";
import { users, follows, remoteActors } from "@trails-cool/db/schema/journal";
import {
followRemoteActor,
cancelRemoteFollow,
listOutgoingRemoteFollows,
type OutboundFollowDeps,
type ResolvedRemoteActor,
} from "./federation-outbound.server.ts";
import { FollowError } from "./follow.server.ts";
// Opt-in: real Postgres; network steps injected so no instance is
// contacted. Same convention as the other *.integration.test.ts files.
const runIntegration = process.env.FEDERATION_INTEGRATION === "1";
const REMOTE = "https://other-trails.example/users/alice";
const createdUserIds: string[] = [];
async function makeUser(username: string) {
const db = getDb();
const id = randomUUID();
await db.insert(users).values({
id,
email: `${username}@example.test`,
username,
domain: "test.local",
profileVisibility: "public",
});
createdUserIds.push(id);
return id;
}
function fakeDeps(opts?: {
software?: string;
resolveTo?: ResolvedRemoteActor | null;
}): OutboundFollowDeps & { delivered: string[]; undone: string[] } {
const delivered: string[] = [];
const undone: string[] = [];
return {
delivered,
undone,
async resolveActor() {
return opts?.resolveTo !== undefined
? opts.resolveTo
: { iri: REMOTE, inboxUrl: `${REMOTE}/inbox`, username: "alice", displayName: "Alice" };
},
async fetchSoftwareName() {
return opts?.software ?? "trails-cool";
},
async deliverFollow(_u, followId) {
delivered.push(followId);
},
async deliverUndoFollow(_u, followId) {
undone.push(followId);
},
};
}
describe.runIf(runIntegration)("outbound trails-to-trails follows (integration)", () => {
beforeAll(() => {
process.env.ORIGIN ??= "http://localhost:3000";
});
afterEach(async () => {
const db = getDb();
for (const id of createdUserIds.splice(0)) {
await db.delete(follows).where(eq(follows.followerId, id));
await db.delete(users).where(eq(users.id, id));
}
await db.delete(remoteActors).where(eq(remoteActors.actorIri, REMOTE));
});
it("creates a Pending row, caches the actor, and delivers Follow", async () => {
const userId = await makeUser(`out-${Date.now()}`);
const deps = fakeDeps();
const state = await followRemoteActor(userId, "@alice@other-trails.example", deps);
expect(state).toEqual({ pending: true, actorIri: REMOTE });
expect(deps.delivered).toHaveLength(1);
const db = getDb();
const [row] = await db.select().from(follows).where(eq(follows.followerId, userId));
expect(row!.followedActorIri).toBe(REMOTE);
expect(row!.followedUserId).toBeNull();
expect(row!.acceptedAt).toBeNull();
const [cached] = await db.select().from(remoteActors).where(eq(remoteActors.actorIri, REMOTE));
expect(cached!.inboxUrl).toBe(`${REMOTE}/inbox`);
expect(cached!.displayName).toBe("Alice");
});
it("is idempotent — re-following returns the existing state without re-delivering", async () => {
const userId = await makeUser(`out-idem-${Date.now()}`);
const deps = fakeDeps();
await followRemoteActor(userId, "@alice@other-trails.example", deps);
const second = await followRemoteActor(userId, "@alice@other-trails.example", deps);
expect(second.pending).toBe(true);
expect(deps.delivered).toHaveLength(1);
});
it("refuses non-trails instances with a clear code and writes nothing", async () => {
const userId = await makeUser(`out-mast-${Date.now()}`);
const deps = fakeDeps({ software: "mastodon" });
await expect(followRemoteActor(userId, "@alice@other-trails.example", deps)).rejects.toMatchObject({
code: "not_trails",
});
const db = getDb();
expect(await db.select().from(follows).where(eq(follows.followerId, userId))).toHaveLength(0);
});
it("refuses unresolvable handles and local handles", async () => {
const userId = await makeUser(`out-bad-${Date.now()}`);
await expect(
followRemoteActor(userId, "@ghost@other-trails.example", fakeDeps({ resolveTo: null })),
).rejects.toMatchObject({ code: "remote_resolve_failed" });
await expect(followRemoteActor(userId, "not a handle", fakeDeps())).rejects.toMatchObject({
code: "invalid_handle",
});
// Own-host check needs a parseable host (the default localhost:3000
// fails the TLD shape first) — point ORIGIN at a real-looking domain.
const prevOrigin = process.env.ORIGIN;
process.env.ORIGIN = "https://own-instance.example";
try {
await expect(
followRemoteActor(userId, "@someone@own-instance.example", fakeDeps()),
).rejects.toMatchObject({ code: "local_handle" });
} finally {
process.env.ORIGIN = prevOrigin;
}
});
it("lists outgoing follows and cancel removes the row + delivers Undo", async () => {
const userId = await makeUser(`out-cancel-${Date.now()}`);
const deps = fakeDeps();
await followRemoteActor(userId, "@alice@other-trails.example", deps);
const entries = await listOutgoingRemoteFollows(userId);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ actorIri: REMOTE, pending: true, username: "alice" });
await cancelRemoteFollow(userId, REMOTE, deps);
expect(deps.undone).toHaveLength(1);
expect(await listOutgoingRemoteFollows(userId)).toHaveLength(0);
// Idempotent second cancel
await cancelRemoteFollow(userId, REMOTE, deps);
expect(deps.undone).toHaveLength(1);
});
it("FollowError codes are exported for the route layer", () => {
expect(new FollowError("not_trails", "x").code).toBe("not_trails");
});
});