import { createUser, db, del, type FakeInstance, fakeInstance, formWithFile, get, patch, PNG, post, put, stubNetwork, test, } from "../helpers.ts"; import { assert, assertEquals, assertMatch } from "@std/assert"; import { clearInstanceCaches } from "../../remote/gerbeur-instance.ts"; import type { FederationLoginResponse, FederationPeer, } from "../../../shared/federation.ts"; import type { AuthResponse, SelfUser } from "../../../shared/user.ts"; const HOME = "https://home.test"; /** Puts `fake` behind the network, every other host answering 404. */ function network(...fakes: FakeInstance[]) { return stubNetwork((url, init) => { for (const fake of fakes) { const res = fake.handle(url, init); if (res) return res; } return new Response("", { status: 404 }); }); } async function trust(issuer: string, status = "trusted") { const admin = await createUser({ role: "admin" }); return await put("/api/federation/peers", { auth: admin.auth, json: { instance: issuer, status }, }); } /** Signs in through `home` as `identity`, as the pages would. */ async function signIn( home: FakeInstance, identity: { sub: string; preferred_username: string; email: string; picture?: string; }, inviteToken?: string, ) { const start = await post("/api/federation/logins", { json: { instance: new URL(home.origin).host, inviteToken }, }); if (start.status !== 200) return start; const authorizeUrl = start.data.authorizeUrl; const code = home.issue(authorizeUrl, identity); return await post("/api/federation/sessions", { json: { code, state: new URL(authorizeUrl).searchParams.get("state") }, }); } const ALICE = { sub: "alice-id", preferred_username: "alice", email: "alice@home.example", picture: `${HOME}/api/users/alice-id/avatar?v=1`, }; test("federation: a trusted instance's user signs in as name@host, once made", async () => { const home = await fakeInstance(HOME); using net = network(home); assertEquals((await trust("home.test")).data.name, "Fake home.test"); const start = await post("/api/federation/logins", { json: { instance: "home.test" }, }); const url = new URL(start.data.authorizeUrl); assertEquals(url.origin + url.pathname, `${HOME}/authorize`); assertEquals(url.searchParams.get("client_id"), "http://gerbeur.test"); assertEquals( url.searchParams.get("redirect_uri"), "http://gerbeur.test/federation/callback", ); assertEquals(url.searchParams.get("code_challenge_method"), "S256"); const first = await signIn(home, ALICE); const { user, token } = first.data as AuthResponse; assertEquals(user.username, "alice@home.test"); assertEquals(user.email, "alice@home.example"); assertEquals(user.role, "user"); assertEquals(user.avatarMime, "image/png"); assert(net.calls.some((u) => u.pathname === "/api/users/alice-id/avatar")); const me = await get("/api/me", { auth: { Authorization: `Bearer ${token}` }, }); assertEquals(me.data.id, user.id); // The same identity signs in to the same account. const again = await signIn(home, ALICE); assertEquals((again.data as AuthResponse).user.id, user.id); // It has no password here. const login = await post("/api/sessions", { json: { username: "alice@home.test", password: "" }, }); assertEquals(login.status, 401); assertEquals( (await get("/api/users/@alice@home.test")).data.id, user.id, ); // The client encodes the handle's `@`. assertEquals( (await get("/api/users/@alice%40home.test")).data.id, user.id, ); }); test("federation: a login works once, with the code the home made for it", async () => { const home = await fakeInstance(HOME); using _net = network(home); await trust(HOME); const start = await post("/api/federation/logins", { json: { instance: HOME }, }); const state = new URL(start.data.authorizeUrl).searchParams.get("state"); const wrong = await post("/api/federation/sessions", { json: { code: "made-up", state }, }); assertEquals(wrong.status, 502); // The failed try used the login up. const code = home.issue(start.data.authorizeUrl, ALICE); const replay = await post("/api/federation/sessions", { json: { code, state }, }); assertEquals(replay.status, 400); }); test("federation: an instance on neither list needs an invite, claimed on joining", async () => { const home = await fakeInstance(HOME); using _net = network(home); const refused = await signIn(home, ALICE); assertEquals(refused.status, 403); const inviter = await createUser(); const { token } = (await post<{ token: string }>("/api/invites", { auth: inviter.auth, })).data; const joined = await signIn(home, ALICE, token); const { user } = joined.data as AuthResponse; assertEquals(user.username, "alice@home.test"); const row = db.prepare(`SELECT invited_by FROM users WHERE id = ?;`).get( user.id, ) as { invited_by: string }; assertEquals(row.invited_by, inviter.id); assertEquals((await get(`/api/invites/${token}`)).status, 404); // Once here, no invite is needed; another user of that instance still // needs one. assertEquals((await signIn(home, ALICE)).status, 200); const bob = { ...ALICE, sub: "bob-id", preferred_username: "bob" }; assertEquals((await signIn(home, bob)).status, 403); }); test("federation: blocking an instance refuses its users and ends their sessions", async () => { const home = await fakeInstance(HOME); using _net = network(home); await trust(HOME); const { token } = (await signIn(home, ALICE)).data as AuthResponse; const auth = { Authorization: `Bearer ${token}` }; assertEquals((await get("/api/me", { auth })).status, 200); await trust(HOME, "blocked"); assertEquals((await get("/api/me", { auth })).status, 401); assertEquals((await signIn(home, ALICE)).status, 403); const admin = await createUser({ role: "admin" }); const peers = await get("/api/federation/peers", { auth: admin.auth, }); assertEquals(peers.data.map((p) => p.status), ["blocked"]); assertEquals((await get("/api/federation/peers")).data, []); assertEquals( (await del("/api/federation/peers/home.test", { auth: admin.auth })) .status, 200, ); assertEquals((await get("/api/me", { auth })).status, 200); }); test("federation: only admins edit the peers, which must answer as gerbeurs", async () => { const home = await fakeInstance(HOME); using _net = network(home); const user = await createUser(); const admin = await createUser({ role: "admin" }); assertEquals( (await put("/api/federation/peers", { auth: user.auth, json: { instance: HOME, status: "trusted" }, })).status, 403, ); for (const instance of ["elsewhere.test", "https://home.test/path"]) { const res = await put("/api/federation/peers", { auth: admin.auth, json: { instance, status: "trusted" }, }); assert(res.status >= 400, instance); } assertEquals( (await put("/api/federation/peers", { auth: admin.auth, json: { instance: "http://gerbeur.test", status: "trusted" }, })).status, 400, ); }); test("federation: the home's events rename, update and sign out its users", async () => { const home = await fakeInstance(HOME); const other = await fakeInstance("https://other.test"); using _net = network(home, other); await trust(HOME); const { user, token } = (await signIn(home, ALICE)).data as AuthResponse; const auth = { Authorization: `Bearer ${token}` }; const update = await home.event(ALICE.sub, { type: "update", claims: { ...ALICE, preferred_username: "alicia", email: "a@new.example" }, }); assertEquals( (await post("/api/federation/events", { json: { event: update } })) .status, 204, ); const renamed = await get("/api/me", { auth }); assertEquals(renamed.data.username, "alicia@home.test"); assertEquals(renamed.data.email, "a@new.example"); // The old handle still leads to them. assertEquals( (await get("/api/users/@alice@home.test")).data.id, user.id, ); // Another instance can't speak for this one's users. const forged = await other.event(ALICE.sub, { type: "revoke" }); assertEquals( (await post("/api/federation/events", { json: { event: forged } })) .status, 400, ); // Revoking a little later than the token was issued, as it would be. await new Promise((r) => setTimeout(r, 1100)); const revoke = await home.event(ALICE.sub, { type: "revoke" }); assertEquals( (await post("/api/federation/events", { json: { event: revoke } })) .status, 204, ); assertEquals((await get("/api/me", { auth })).status, 401); }); test("federation: a home's events are refused from a blocked home, with an avatar elsewhere, and put off when its keys fail", async () => { const home = await fakeInstance(HOME); let keysDown = false; using net = stubNetwork((url, init) => keysDown && url.pathname === "/.well-known/jwks.json" ? new Response("", { status: 503 }) : home.handle(url, init) ?? new Response("", { status: 404 }) ); await trust(HOME); await signIn(home, ALICE); const send = async (payload: Record) => (await post("/api/federation/events", { json: { event: await home.event(ALICE.sub, payload) }, })).status; // Answered 5xx, so that the home tries again rather than give up. keysDown = true; clearInstanceCaches(); assertEquals(await send({ type: "revoke" }), 502); keysDown = false; const avatars = net.calls.filter((u) => u.pathname.endsWith("/avatar")) .length; assertEquals( await send({ type: "update", claims: { ...ALICE, picture: "https://elsewhere.test/a.png" }, }), 400, ); assertEquals( net.calls.filter((u) => u.pathname.endsWith("/avatar")).length, avatars, ); await trust(HOME, "blocked"); assertEquals( await send({ type: "update", claims: { ...ALICE, preferred_username: "mallory" }, }), 403, ); assertEquals( (await get("/api/users/@alice@home.test")).data.username, "alice@home.test", ); }); test("federation: an instance that doesn't answer can be blocked, not trusted", async () => { using _net = network(); assertEquals((await trust("https://down.test")).status, 502); const blocked = await trust("https://down.test", "blocked"); assertEquals(blocked.status, 200); assertEquals(blocked.data.status, "blocked"); assertEquals(blocked.data.name, null); }); test("federation: an account from another instance changes its password, email, avatar and name there", async () => { const home = await fakeInstance(HOME); using _net = network(home); await trust(HOME); const { user, token } = (await signIn(home, ALICE)).data as AuthResponse; const auth = { Authorization: `Bearer ${token}` }; const admin = await createUser({ role: "admin" }); assertEquals( (await put("/api/me/password", { auth, json: { currentPassword: "", newPassword: "long-enough" }, })).status, 400, ); assertEquals( (await patch("/api/me", { auth, json: { email: "x@example.test" } })) .status, 400, ); assertEquals( (await patch("/api/me", { auth, json: { description: "hi" } })) .data.description, "hi", ); assertEquals( (await put("/api/me/avatar", { auth, form: formWithFile(PNG) })).status, 400, ); assertEquals( (await patch(`/api/users/${user.id}/username`, { auth: admin.auth, json: { username: "bob" }, })).status, 400, ); }); test("federation: an email may be both a local account's and a remote one's", async () => { const home = await fakeInstance(HOME); using _net = network(home); await trust(HOME); const local = await createUser({ email: ALICE.email }); const res = await signIn(home, ALICE); assertMatch((res.data as AuthResponse).user.username, /@home\.test$/); // A reset by that address reaches the local account only. const row = db.prepare( `SELECT COUNT(*) AS n FROM users WHERE lower(email) = lower(?);`, ).get(ALICE.email) as { n: number }; assertEquals(row.n, 2); assertEquals( (await post("/api/password-resets", { json: { email: ALICE.email } })) .status, 200, ); const tokens = db.prepare(`SELECT user_id FROM password_reset_tokens;`) .all() as { user_id: string }[]; assert(tokens.every((t) => t.user_id === local.id)); });