Files
gerbeur/api/test/services/federation-service.test.ts

430 lines
14 KiB
TypeScript

import {
count,
createUser,
db,
del,
type FakeInstance,
fakeInstance,
federationOff,
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<FederationPeer>("/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<FederationLoginResponse>("/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<AuthResponse>("/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<FederationLoginResponse>("/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<SelfUser>("/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<SelfUser>("/api/users/@alice@home.test")).data.id,
user.id,
);
// The client encodes the handle's `@`.
assertEquals(
(await get<SelfUser>("/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<FederationLoginResponse>("/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<FederationPeer[]>("/api/federation/peers", {
auth: admin.auth,
});
assertEquals(peers.data.map((p) => p.status), ["blocked"]);
assertEquals((await get<FederationPeer[]>("/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: with sign-ins from other instances off, none starts or finishes, and those in are out until it is on", 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}` };
const started = await post<FederationLoginResponse>(
"/api/federation/logins",
{ json: { instance: "home.test" } },
);
const state = new URL(started.data.authorizeUrl).searchParams.get("state");
const code = home.issue(started.data.authorizeUrl, ALICE);
{
using _off = federationOff("incoming");
assertEquals((await get("/api/me", { auth })).status, 401);
assertEquals(
(await post("/api/federation/logins", {
json: { instance: "home.test" },
})).status,
403,
);
assertEquals(
(await post("/api/federation/sessions", { json: { code, state } }))
.status,
403,
);
}
assertEquals((await get("/api/me", { auth })).status, 200);
});
test("federation: a home whose users don't sign in elsewhere is refused before the user leaves", async () => {
const home = await fakeInstance(HOME, { gerbeur_outgoing: false });
using _net = network(home);
await trust(HOME);
const start = await post("/api/federation/logins", {
json: { instance: "home.test" },
});
assertEquals(start.status, 403);
assertEquals(count(`SELECT COUNT(*) AS n FROM federation_logins;`), 0);
});
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<SelfUser>("/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<SelfUser>("/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<string, unknown>) =>
(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<SelfUser>("/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<SelfUser>("/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));
});