fix(federation): 🚑 Fix broken inbound federation and add end-to-end testing for federation

This commit is contained in:
Jesse Wierzbinski 2025-04-19 13:16:53 +02:00
parent 85ef96fc7f
commit 8ae4f3815a
No known key found for this signature in database
9 changed files with 192 additions and 29 deletions

170
api/inbox/index.test.ts Normal file
View file

@ -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: "<p>Hello!</p>",
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();
});
});

View file

@ -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(

View file

@ -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,