From 8ae4f3815aa53ce9af19a6280c62b2452021cee4 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sat, 19 Apr 2025 13:16:53 +0200 Subject: [PATCH] fix(federation): :ambulance: Fix broken inbound federation and add end-to-end testing for federation --- api/inbox/index.test.ts | 170 ++++++++++++++++++++++++++++++++ api/inbox/index.ts | 5 +- api/users/[uuid]/inbox/index.ts | 4 +- bun.lock | 3 + nix/package.nix | 2 +- nix/update.sh | 21 ---- package.json | 1 + packages/sdk/crypto.ts | 2 +- pnpm-lock.yaml | 13 +++ 9 files changed, 192 insertions(+), 29 deletions(-) create mode 100644 api/inbox/index.test.ts delete mode 100755 nix/update.sh diff --git a/api/inbox/index.test.ts b/api/inbox/index.test.ts new file mode 100644 index 00000000..61bd5013 --- /dev/null +++ b/api/inbox/index.test.ts @@ -0,0 +1,170 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { randomUUIDv7, sleep } from "bun"; +import { + clearMocks, + disableRealRequests, + enableRealRequests, + mock, +} from "bun-bagel"; +import { eq } from "drizzle-orm"; +import { Instance } from "~/classes/database/instance"; +import { Note } from "~/classes/database/note"; +import { User } from "~/classes/database/user"; +import { config } from "~/config"; +import { Notes } from "~/drizzle/schema"; +import { sign } from "~/packages/sdk/crypto"; +import * as VersiaEntities from "~/packages/sdk/entities"; +import { fakeRequest } from "~/tests/utils"; + +const instanceUrl = new URL("https://versia.example.com"); +const noteId = randomUUIDv7(); +const userId = randomUUIDv7(); +const userKeys = await User.generateKeys(); +const privateKey = await crypto.subtle.importKey( + "pkcs8", + Buffer.from(userKeys.private_key, "base64"), + "Ed25519", + false, + ["sign"], +); +const instanceKeys = await User.generateKeys(); +const inboxUrl = new URL("/inbox", config.http.base_url); + +disableRealRequests(); + +mock(new URL("/.well-known/versia", instanceUrl).href, { + response: { + headers: { + "Content-Type": "application/json", + }, + data: new VersiaEntities.InstanceMetadata({ + type: "InstanceMetadata", + name: "Versia", + description: "Versia instance", + created_at: new Date().toISOString(), + host: instanceUrl.hostname, + software: { + name: "Versia", + version: "1.0.0", + }, + compatibility: { + extensions: [], + versions: ["0.5.0"], + }, + public_key: { + algorithm: "ed25519", + key: instanceKeys.public_key, + }, + }).toJSON(), + }, +}); + +mock(new URL(`/users/${userId}`, instanceUrl).href, { + response: { + headers: { + "Content-Type": "application/json", + }, + data: new VersiaEntities.User({ + id: userId, + created_at: "2025-04-18T10:32:01.427Z", + uri: new URL(`/users/${userId}`, instanceUrl).href, + type: "User", + username: "testuser", + public_key: { + algorithm: "ed25519", + key: userKeys.public_key, + actor: new URL(`/users/${userId}`, instanceUrl).href, + }, + inbox: new URL(`/users/${userId}/inbox`, instanceUrl).href, + collections: { + featured: new URL(`/users/${userId}/featured`, instanceUrl) + .href, + followers: new URL(`/users/${userId}/followers`, instanceUrl) + .href, + following: new URL(`/users/${userId}/following`, instanceUrl) + .href, + outbox: new URL(`/users/${userId}/outbox`, instanceUrl).href, + }, + }).toJSON(), + }, +}); + +afterAll(async () => { + // Delete the instance in database + const instance = await Instance.resolve(instanceUrl); + + if (!instance) { + throw new Error("Instance not found"); + } + + await instance.delete(); + clearMocks(); + enableRealRequests(); +}); + +describe("Inbox Tests", () => { + test("should correctly process inbox request", async () => { + const exampleRequest = new VersiaEntities.Note({ + id: noteId, + created_at: "2025-04-18T10:32:01.427Z", + uri: new URL(`/notes/${noteId}`, instanceUrl).href, + type: "Note", + extensions: { + "pub.versia:custom_emojis": { + emojis: [], + }, + }, + attachments: [], + author: new URL(`/users/${userId}`, instanceUrl).href, + content: { + "text/html": { + content: "

Hello!

", + remote: false, + }, + "text/plain": { + content: "Hello!", + remote: false, + }, + }, + collections: { + replies: new URL(`/notes/${noteId}/replies`, instanceUrl).href, + quotes: new URL(`/notes/${noteId}/quotes`, instanceUrl).href, + }, + group: "public", + is_sensitive: false, + mentions: [], + quotes: null, + replies_to: null, + subject: "", + }); + + const signedRequest = await sign( + privateKey, + new URL(exampleRequest.data.author), + new Request(inboxUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + "User-Agent": "Versia/1.0.0", + }, + body: JSON.stringify(exampleRequest.toJSON()), + }), + ); + + const response = await fakeRequest(inboxUrl, { + method: "POST", + headers: signedRequest.headers, + body: signedRequest.body, + }); + + expect(response.status).toBe(200); + + await sleep(500); + + // Check if note was created in the database + const note = await Note.fromSql(eq(Notes.uri, exampleRequest.data.uri)); + + expect(note).not.toBeNull(); + }); +}); diff --git a/api/inbox/index.ts b/api/inbox/index.ts index e9401e54..43567d88 100644 --- a/api/inbox/index.ts +++ b/api/inbox/index.ts @@ -16,7 +16,6 @@ export default apiRoute((app) => }, }, }), - validator("json", z.any(), handleZodError), validator( "header", z.object({ @@ -32,7 +31,7 @@ export default apiRoute((app) => handleZodError, ), async (context) => { - const body = await context.req.valid("json"); + const body = await context.req.json(); const { "versia-signature": signature, "versia-signed-at": signedAt, @@ -53,7 +52,7 @@ export default apiRoute((app) => method: context.req.method, url: context.req.url, }, - ip: context.env.ip ?? null, + ip: context.env?.ip ?? null, }); return context.body( diff --git a/api/users/[uuid]/inbox/index.ts b/api/users/[uuid]/inbox/index.ts index 5761ac01..34106569 100644 --- a/api/users/[uuid]/inbox/index.ts +++ b/api/users/[uuid]/inbox/index.ts @@ -4,7 +4,6 @@ import { z } from "zod"; import { apiRoute, handleZodError } from "@/api"; import { ApiError } from "~/classes/errors/api-error"; import { InboxJobType, inboxQueue } from "~/classes/queues/inbox"; -import type { JSONObject } from "~/packages/sdk/types"; export default apiRoute((app) => app.post( @@ -87,9 +86,8 @@ export default apiRoute((app) => }), handleZodError, ), - validator("json", z.any(), handleZodError), async (context) => { - const body: JSONObject = await context.req.valid("json"); + const body = await context.req.json(); const { "versia-signature": signature, "versia-signed-at": signedAt, diff --git a/bun.lock b/bun.lock index 8d1ab7a6..81c8c467 100644 --- a/bun.lock +++ b/bun.lock @@ -71,6 +71,7 @@ "@types/pg": "^8.11.13", "@types/qs": "^6.9.18", "@types/web-push": "^3.6.4", + "bun-bagel": "^1.2.0", "drizzle-kit": "^0.31.0", "markdown-it-image-figures": "^2.1.1", "ts-prune": "^0.10.3", @@ -660,6 +661,8 @@ "bullmq": ["bullmq@5.49.0", "", { "dependencies": { "cron-parser": "^4.9.0", "ioredis": "^5.4.1", "msgpackr": "^1.11.2", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", "uuid": "^9.0.0" } }, "sha512-qwNOgUfD3kHI33bU2HOsn6YKEoSdUfHTledQUy95V8HogVdgGg/HS3+TQh7Y7YH19h5yK2Oo/FFEnzyXJhgy3w=="], + "bun-bagel": ["bun-bagel@1.2.0", "", { "peerDependencies": { "typescript": "^5.0.0" } }, "sha512-c4S68dNddpnog9nxXp9PAhcep0alOy49jpRlC1yACoxplUvgX22NZxeQUIIov5TCJJDH/snT5R9bMyix7AG0KQ=="], + "bun-types": ["bun-types@1.2.9", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-dk/kOEfQbajENN/D6FyiSgOKEuUi9PWfqKQJEgwKrCMWbjS/S6tEXp178mWvWAcUSYm9ArDlWHZKO3T/4cLXiw=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], diff --git a/nix/package.nix b/nix/package.nix index 905d385c..7f542509 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -22,7 +22,7 @@ in pnpmDeps = pnpm.fetchDeps { inherit (finalAttrs) pname version src pnpmInstallFlags; - hash = "sha256-miwjCxel9mgLcJ8Gwzyr7dLZe18yKZ8PeMlIvduJYwk="; + hash = "sha256-bDgLkz0aT3/jM2inVsfMoJBKZacxqfHFi8GtIg7zc+M="; }; nativeBuildInputs = [ diff --git a/nix/update.sh b/nix/update.sh deleted file mode 100755 index 63e066b5..00000000 --- a/nix/update.sh +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env nix-shell -#! nix-shell -i bash -p nix-prefetch-github - -set -euo pipefail - -SOURCE=$(nix-prefetch-github --nix versia-pub server | tail -n 6) - -cat > ./nix/source.nix << EOF -{ - lib, - fetchFromGitHub, -}: { - outputHash.x86_64-linux = lib.fakeHash; - outputHash.aarch64-linux = lib.fakeHash; - src = fetchFromGitHub { -${SOURCE}; -} -EOF - -echo "Done." -echo "Please update the attributes of 'outputHash' in nix/source.nix." diff --git a/package.json b/package.json index 2d6a4268..f5c672eb 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@types/pg": "^8.11.13", "@types/qs": "^6.9.18", "@types/web-push": "^3.6.4", + "bun-bagel": "^1.2.0", "drizzle-kit": "^0.31.0", "markdown-it-image-figures": "^2.1.1", "ts-prune": "^0.10.3", diff --git a/packages/sdk/crypto.ts b/packages/sdk/crypto.ts index 541cc4fd..7133fc7e 100644 --- a/packages/sdk/crypto.ts +++ b/packages/sdk/crypto.ts @@ -28,7 +28,7 @@ export const sign = async ( const body = await req.clone().text(); const url = new URL(req.url); - const digest = stringToBase64Hash(body); + const digest = await stringToBase64Hash(body); const timestampSecs = Math.floor(timestamp.getTime() / 1000); const signedString = `${req.method.toLowerCase()} ${encodeURI( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7eb7b0e4..ec5291ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,9 @@ importers: '@types/web-push': specifier: ^3.6.4 version: 3.6.4 + bun-bagel: + specifier: ^1.2.0 + version: 1.2.0(typescript@5.8.3) drizzle-kit: specifier: ^0.31.0 version: 0.31.0 @@ -1832,6 +1835,11 @@ packages: bullmq@5.49.0: resolution: {integrity: sha512-qwNOgUfD3kHI33bU2HOsn6YKEoSdUfHTledQUy95V8HogVdgGg/HS3+TQh7Y7YH19h5yK2Oo/FFEnzyXJhgy3w==} + bun-bagel@1.2.0: + resolution: {integrity: sha512-c4S68dNddpnog9nxXp9PAhcep0alOy49jpRlC1yACoxplUvgX22NZxeQUIIov5TCJJDH/snT5R9bMyix7AG0KQ==} + peerDependencies: + typescript: ^5.0.0 + bun-types@1.2.9: resolution: {integrity: sha512-dk/kOEfQbajENN/D6FyiSgOKEuUi9PWfqKQJEgwKrCMWbjS/S6tEXp178mWvWAcUSYm9ArDlWHZKO3T/4cLXiw==} @@ -3105,6 +3113,7 @@ packages: speech-rule-engine@4.1.0: resolution: {integrity: sha512-jRP6QUyvi+C94QvcQR8wBfla2ySD5KXBr6XnmEBy8DcW/R1DN08Yykuwq7xEX90VM5E4Iv7UJoMvhW6U660x8A==} + deprecated: breaking change hasBin: true split2@4.2.0: @@ -4854,6 +4863,10 @@ snapshots: transitivePeerDependencies: - supports-color + bun-bagel@1.2.0(typescript@5.8.3): + dependencies: + typescript: 5.8.3 + bun-types@1.2.9: dependencies: '@types/node': 22.14.1