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>
40 lines
1.9 KiB
TypeScript
40 lines
1.9 KiB
TypeScript
import { describe, it, expect, vi } from "vitest";
|
|
|
|
vi.mock("./db.ts", () => ({ getDb: () => ({}) }));
|
|
|
|
const { parseRemoteHandle, isTrailsSoftware } = await import("./federation-outbound.server.ts");
|
|
|
|
describe("parseRemoteHandle", () => {
|
|
it("accepts the common handle spellings", () => {
|
|
expect(parseRemoteHandle("@alice@other.example")).toEqual({ username: "alice", host: "other.example" });
|
|
expect(parseRemoteHandle("alice@other.example")).toEqual({ username: "alice", host: "other.example" });
|
|
expect(parseRemoteHandle("acct:alice@other.example")).toEqual({ username: "alice", host: "other.example" });
|
|
expect(parseRemoteHandle(" @alice@other.example ")).toEqual({ username: "alice", host: "other.example" });
|
|
expect(parseRemoteHandle("@a_l-i.ce@sub.other.example")).toEqual({ username: "a_l-i.ce", host: "sub.other.example" });
|
|
});
|
|
|
|
it("lowercases hosts but preserves username case", () => {
|
|
expect(parseRemoteHandle("@Alice@Other.Example")).toEqual({ username: "Alice", host: "other.example" });
|
|
});
|
|
|
|
it("accepts a port (dev instances)", () => {
|
|
expect(parseRemoteHandle("@bob@localhost:3000")).toBeNull(); // bare hostname without TLD is rejected
|
|
expect(parseRemoteHandle("@bob@staging.trails.cool:3000")).toEqual({ username: "bob", host: "staging.trails.cool:3000" });
|
|
});
|
|
|
|
it("rejects everything else", () => {
|
|
expect(parseRemoteHandle("alice")).toBeNull();
|
|
expect(parseRemoteHandle("https://other.example/users/alice")).toBeNull();
|
|
expect(parseRemoteHandle("@@broken@host.example")).toBeNull();
|
|
expect(parseRemoteHandle("")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("isTrailsSoftware", () => {
|
|
it("accepts trails-cool and nothing else", () => {
|
|
expect(isTrailsSoftware("trails-cool")).toBe(true);
|
|
expect(isTrailsSoftware("mastodon")).toBe(false);
|
|
expect(isTrailsSoftware("hollo")).toBe(false);
|
|
expect(isTrailsSoftware(undefined)).toBe(false);
|
|
});
|
|
});
|