diff --git a/database/entities/Application.ts b/database/entities/Application.ts index cd44f80b..8268c14a 100644 --- a/database/entities/Application.ts +++ b/database/entities/Application.ts @@ -1,5 +1,6 @@ import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { APIApplication } from "~types/entities/application"; +import { Token } from "./Token"; /** * Applications from clients @@ -36,6 +37,16 @@ export class Application extends BaseEntity { @Column("varchar") redirect_uris = "urn:ietf:wg:oauth:2.0:oob"; + static async getFromToken(token: string) { + const dbToken = await Token.findOne({ + where: { + access_token: token, + }, + }); + + return dbToken?.application || null; + } + // eslint-disable-next-line @typescript-eslint/require-await async toAPI(): Promise { return { diff --git a/database/entities/RawActivity.ts b/database/entities/RawActivity.ts index 498b3c43..3510e709 100644 --- a/database/entities/RawActivity.ts +++ b/database/entities/RawActivity.ts @@ -12,9 +12,6 @@ import { RawActor } from "./RawActor"; import { getConfig } from "@config"; import { errorResponse } from "@response"; -/** - * Stores an ActivityPub activity as raw JSON-LD data - */ @Entity({ name: "activities", }) @@ -25,25 +22,19 @@ export class RawActivity extends BaseEntity { @Column("jsonb") data!: APActivity; - // Any associated objects (there is typically only one) - @ManyToMany(() => RawObject, object => object.id) + @ManyToMany(() => RawObject) @JoinTable() objects!: RawObject[]; - @ManyToMany(() => RawActor, actor => actor.id) + @ManyToMany(() => RawActor) @JoinTable() actors!: RawActor[]; static async getByObjectId(id: string) { return await RawActivity.createQueryBuilder("activity") - // Objects is a many-to-many relationship .leftJoinAndSelect("activity.objects", "objects") .leftJoinAndSelect("activity.actors", "actors") - .where("objects.data @> :data", { - data: JSON.stringify({ - id, - }), - }) + .where("objects.data @> :data", { data: JSON.stringify({ id }) }) .getMany(); } @@ -51,21 +42,15 @@ export class RawActivity extends BaseEntity { return await RawActivity.createQueryBuilder("activity") .leftJoinAndSelect("activity.objects", "objects") .leftJoinAndSelect("activity.actors", "actors") - .where("activity.data->>'id' = :id", { - id, - }) + .where("activity.data->>'id' = :id", { id }) .getOne(); } static async getLatestById(id: string) { return await RawActivity.createQueryBuilder("activity") - // Where id is part of the jsonb column 'data' - .where("activity.data->>'id' = :id", { - id, - }) + .where("activity.data->>'id' = :id", { id }) .leftJoinAndSelect("activity.objects", "objects") .leftJoinAndSelect("activity.actors", "actors") - // Sort by most recent .orderBy("activity.data->>'published'", "DESC") .getOne(); } @@ -77,24 +62,26 @@ export class RawActivity extends BaseEntity { static async updateObjectIfExists(object: APObject) { const rawObject = await RawObject.getById(object.id ?? ""); - if (rawObject) { - rawObject.data = object; - - // Check if object body contains any filtered terms - if (await rawObject.isObjectFiltered()) - return errorResponse("Object filtered", 409); - - await rawObject.save(); - return rawObject; - } else { + if (!rawObject) { return errorResponse("Object does not exist", 404); } + + rawObject.data = object; + + if (await rawObject.isObjectFiltered()) { + return errorResponse("Object filtered", 409); + } + + await rawObject.save(); + return rawObject; } static async deleteObjectIfExists(object: APObject) { const dbObject = await RawObject.getById(object.id ?? ""); - if (!dbObject) return errorResponse("Object does not exist", 404); + if (!dbObject) { + return errorResponse("Object does not exist", 404); + } const config = getConfig(); @@ -110,16 +97,12 @@ export class RawActivity extends BaseEntity { } else { const activities = await RawActivity.getByObjectId(object.id ?? ""); - activities.forEach( - activity => - (activity.objects = activity.objects.filter( - o => o.id !== object.id - )) - ); - - await Promise.all( - activities.map(async activity => await activity.save()) - ); + for (const activity of activities) { + activity.objects = activity.objects.filter( + o => o.id !== object.id + ); + await activity.save(); + } await dbObject.remove(); } @@ -127,23 +110,27 @@ export class RawActivity extends BaseEntity { return dbObject; } - static async addIfNotExists(activity: APActivity) { - if (!(await RawActivity.exists(activity.id ?? ""))) { - const rawActivity = new RawActivity(); - rawActivity.data = { - ...activity, - object: undefined, - actor: undefined, - }; + static async addIfNotExists(activity: APActivity, addObject?: RawObject) { + if (await RawActivity.exists(activity.id ?? "")) { + return errorResponse("Activity already exists", 409); + } - const actor = await rawActivity.addActorIfNotExists( - activity.actor as APActor - ); + const rawActivity = new RawActivity(); + rawActivity.data = { ...activity, object: undefined, actor: undefined }; + rawActivity.actors = []; + rawActivity.objects = []; - if (actor instanceof Response) { - return actor; - } + const actor = await rawActivity.addActorIfNotExists( + activity.actor as APActor + ); + if (actor instanceof Response) { + return actor; + } + + if (addObject) { + rawActivity.objects.push(addObject); + } else { const object = await rawActivity.addObjectIfNotExists( activity.object as APObject ); @@ -151,67 +138,77 @@ export class RawActivity extends BaseEntity { if (object instanceof Response) { return object; } - - await rawActivity.save(); - return rawActivity; - } else { - return errorResponse("Activity already exists", 409); } + + await rawActivity.save(); + return rawActivity; + } + + makeActivityPubRepresentation() { + return { + ...this.data, + object: this.objects[0].data, + actor: this.actors[0].data, + }; } async addObjectIfNotExists(object: APObject) { - if (!this.objects.some(o => o.data.id === object.id)) { - const rawObject = new RawObject(); - rawObject.data = object; - - // Check if object body contains any filtered terms - if (await rawObject.isObjectFiltered()) - return errorResponse("Object filtered", 409); - - await rawObject.save(); - - this.objects.push(rawObject); - - return rawObject; - } else { + if (this.objects.some(o => o.data.id === object.id)) { return errorResponse("Object already exists", 409); } + + const rawObject = new RawObject(); + rawObject.data = object; + + if (await rawObject.isObjectFiltered()) { + return errorResponse("Object filtered", 409); + } + + await rawObject.save(); + this.objects.push(rawObject); + return rawObject; } async addActorIfNotExists(actor: APActor) { - if (!this.actors.some(a => a.data.id === actor.id)) { - const rawActor = new RawActor(); - rawActor.data = actor; + const dbActor = await RawActor.getByActorId(actor.id ?? ""); - const config = getConfig(); + if (dbActor) { + this.actors.push(dbActor); + return dbActor; + } - if ( - config.activitypub.discard_avatars.find( - instance => actor.id?.includes(instance) - ) - ) { - rawActor.data.icon = undefined; - } - - if ( - config.activitypub.discard_banners.find( - instance => actor.id?.includes(instance) - ) - ) { - rawActor.data.image = undefined; - } - - if (await rawActor.isObjectFiltered()) { - return errorResponse("Actor filtered", 409); - } - - await rawActor.save(); - - this.actors.push(rawActor); - - return rawActor; - } else { + if (this.actors.some(a => a.data.id === actor.id)) { return errorResponse("Actor already exists", 409); } + + const rawActor = new RawActor(); + rawActor.data = actor; + rawActor.followers = []; + + const config = getConfig(); + + if ( + config.activitypub.discard_avatars.find( + instance => actor.id?.includes(instance) + ) + ) { + rawActor.data.icon = undefined; + } + + if ( + config.activitypub.discard_banners.find( + instance => actor.id?.includes(instance) + ) + ) { + rawActor.data.image = undefined; + } + + if (await rawActor.isObjectFiltered()) { + return errorResponse("Actor filtered", 409); + } + + await rawActor.save(); + this.actors.push(rawActor); + return rawActor; } } diff --git a/database/entities/RawActor.ts b/database/entities/RawActor.ts index dcaa7b0e..f6db5acf 100644 --- a/database/entities/RawActor.ts +++ b/database/entities/RawActor.ts @@ -18,7 +18,9 @@ export class RawActor extends BaseEntity { @Column("jsonb") data!: APActor; - @Column("jsonb") + @Column("jsonb", { + default: [], + }) followers!: string[]; static async getByActorId(id: string) { @@ -33,6 +35,7 @@ export class RawActor extends BaseEntity { if (!(await RawActor.exists(data.id ?? ""))) { const actor = new RawActor(); actor.data = data; + actor.followers = []; const config = getConfig(); diff --git a/database/entities/User.ts b/database/entities/User.ts index cc6bed9b..99e16e40 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -122,6 +122,9 @@ export class User extends BaseEntity { user.avatar = data.avatar ?? config.defaults.avatar; user.header = data.header ?? config.defaults.avatar; + user.followers = []; + user.following = []; + await user.generateKeys(); await user.updateActor(); diff --git a/server/api/[username]/inbox/index.ts b/server/api/[username]/inbox/index.ts index 7cec2341..d6f639fa 100644 --- a/server/api/[username]/inbox/index.ts +++ b/server/api/[username]/inbox/index.ts @@ -65,9 +65,10 @@ export default async ( return object; } - const activity = await RawActivity.addIfNotExists(body); + const activity = await RawActivity.addIfNotExists(body, object); if (activity instanceof Response) { + console.log(await activity.text()); return activity; } @@ -78,7 +79,13 @@ export default async ( // Delete the object from database // TODO: Add authentication - await RawActivity.deleteObjectIfExists(body.object as APObject); + const response = await RawActivity.deleteObjectIfExists( + body.object as APObject + ); + + if (response instanceof Response) { + return response; + } // Store the Delete event in the database const activity = await RawActivity.addIfNotExists(body); diff --git a/server/api/v1/accounts/[id]/index.ts b/server/api/api/v1/accounts/[id]/index.ts similarity index 100% rename from server/api/v1/accounts/[id]/index.ts rename to server/api/api/v1/accounts/[id]/index.ts diff --git a/server/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts similarity index 100% rename from server/api/v1/accounts/[id]/statuses.ts rename to server/api/api/v1/accounts/[id]/statuses.ts diff --git a/server/api/v1/accounts/index.ts b/server/api/api/v1/accounts/index.ts similarity index 100% rename from server/api/v1/accounts/index.ts rename to server/api/api/v1/accounts/index.ts diff --git a/server/api/v1/accounts/update_credentials/index.ts b/server/api/api/v1/accounts/update_credentials/index.ts similarity index 100% rename from server/api/v1/accounts/update_credentials/index.ts rename to server/api/api/v1/accounts/update_credentials/index.ts diff --git a/server/api/v1/apps/index.ts b/server/api/api/v1/apps/index.ts similarity index 100% rename from server/api/v1/apps/index.ts rename to server/api/api/v1/apps/index.ts diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts new file mode 100644 index 00000000..d42e383c --- /dev/null +++ b/server/api/api/v1/statuses/index.ts @@ -0,0 +1,116 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { getUserByToken } from "@auth"; +import { getConfig } from "@config"; +import { parseRequest } from "@request"; +import { errorResponse, jsonResponse } from "@response"; +import { Application } from "~database/entities/Application"; +import { Status } from "~database/entities/Status"; + +/** + * Post new status + */ +export default async (req: Request): Promise => { + // Check if request is a PATCH request + if (req.method !== "POST") + return errorResponse("This method requires a POST request", 405); + + // Check auth token + const token = req.headers.get("Authorization")?.split(" ")[1] || null; + + if (!token) + return errorResponse("This method requires an authenticated user", 422); + + const user = await getUserByToken(token); + const application = await Application.getFromToken(token); + + if (!user) return errorResponse("Unauthorized", 401); + + const config = getConfig(); + + const { + status, + media_ids, + "poll[expires_in]": expires_in, + "poll[hide_totals]": hide_totals, + "poll[multiple]": multiple, + "poll[options]": options, + in_reply_to_id, + language, + scheduled_at, + sensitive, + spoiler_text, + visibility, + } = await parseRequest<{ + status: string; + media_ids?: string[]; + "poll[options]"?: string[]; + "poll[expires_in]"?: number; + "poll[multiple]"?: boolean; + "poll[hide_totals]"?: boolean; + in_reply_to_id?: string; + sensitive?: boolean; + spoiler_text?: string; + visibility?: "public" | "unlisted" | "private" | "direct"; + language?: string; + scheduled_at?: string; + }>(req); + + // Validate status + if (!status) { + return errorResponse("Status is required", 422); + } + + if (status.length > config.validation.max_note_size) { + return errorResponse( + `Status must be less than ${config.validation.max_note_size} characters`, + 400 + ); + } + + // Validate media_ids + if (media_ids && !Array.isArray(media_ids)) { + return errorResponse("Media IDs must be an array", 422); + } + + // Validate poll options + if (options && !Array.isArray(options)) { + return errorResponse("Poll options must be an array", 422); + } + + if (options && options.length > 4) { + return errorResponse("Poll options must be less than 5", 422); + } + + // Validate poll expires_in + if (expires_in && (expires_in < 60 || expires_in > 604800)) { + return errorResponse( + "Poll expires_in must be between 60 and 604800", + 422 + ); + } + + // Validate visibility + if ( + visibility && + !["public", "unlisted", "private", "direct"].includes(visibility) + ) { + return errorResponse("Invalid visibility", 422); + } + + // Create status + const newStatus = new Status(); + newStatus.account = user; + newStatus.application = application; + newStatus.content = status; + newStatus.spoiler_text = spoiler_text || ""; + newStatus.sensitive = sensitive || false; + newStatus.visibility = visibility || "public"; + newStatus.likes = []; + newStatus.isReblog = false; + + // TODO: add database jobs to deliver the post + + await newStatus.save(); + + return jsonResponse(newStatus); +}; diff --git a/tests/actor.test.ts b/tests/actor.test.ts new file mode 100644 index 00000000..c70965ad --- /dev/null +++ b/tests/actor.test.ts @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { getConfig } from "@config"; +import { APActor } from "activitypub-types"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { AppDataSource } from "~database/datasource"; +import { RawActivity } from "~database/entities/RawActivity"; +import { Token } from "~database/entities/Token"; +import { User } from "~database/entities/User"; + +const config = getConfig(); + +beforeAll(async () => { + if (!AppDataSource.isInitialized) await AppDataSource.initialize(); + + // Initialize test user + await User.createNew({ + email: "test@test.com", + username: "test", + password: "test", + display_name: "", + }); +}); + +describe("POST /@test", () => { + test("should return a valid ActivityPub Actor when querying an existing user", async () => { + const response = await fetch( + `${config.http.base_url}:${config.http.port}/@test`, + { + method: "GET", + headers: { + Accept: "application/activity+json", + }, + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe( + "application/activity+json" + ); + + const actor: APActor = await response.json(); + + expect(actor.type).toBe("Person"); + expect(actor.id).toBe( + `${config.http.base_url}:${config.http.port}/@test` + ); + expect(actor.preferredUsername).toBe("test"); + expect(actor.inbox).toBe( + `${config.http.base_url}:${config.http.port}/@test/inbox` + ); + expect(actor.outbox).toBe( + `${config.http.base_url}:${config.http.port}/@test/outbox` + ); + expect(actor.followers).toBe( + `${config.http.base_url}:${config.http.port}/@test/followers` + ); + expect(actor.following).toBe( + `${config.http.base_url}:${config.http.port}/@test/following` + ); + expect((actor as any).publicKey).toBeDefined(); + expect((actor as any).publicKey.id).toBeDefined(); + expect((actor as any).publicKey.owner).toBe( + `${config.http.base_url}:${config.http.port}/@test` + ); + expect((actor as any).publicKey.publicKeyPem).toBeDefined(); + }); +}); + +afterAll(async () => { + // Clean up user + const user = await User.findOneBy({ + username: "test", + }); + + // Clean up tokens + const tokens = await Token.findBy({ + user: { + username: "test", + }, + }); + + const activities = await RawActivity.createQueryBuilder("activity") + .where("activity.data->>'actor' = :actor", { + actor: `${config.http.base_url}:${config.http.port}/@test`, + }) + .leftJoinAndSelect("activity.objects", "objects") + .getMany(); + + // Delete all created objects and activities as part of testing + await Promise.all( + activities.map(async activity => { + await Promise.all( + activity.objects.map(async object => await object.remove()) + ); + await activity.remove(); + }) + ); + + await Promise.all(tokens.map(async token => await token.remove())); + + if (user) await user.remove(); +}); diff --git a/tests/api.test.ts b/tests/api.test.ts new file mode 100644 index 00000000..49ecebd0 --- /dev/null +++ b/tests/api.test.ts @@ -0,0 +1,189 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { getConfig } from "@config"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { AppDataSource } from "~database/datasource"; +import { Application } from "~database/entities/Application"; +import { RawActivity } from "~database/entities/RawActivity"; +import { Token, TokenType } from "~database/entities/Token"; +import { User } from "~database/entities/User"; +import { APIAccount } from "~types/entities/account"; +import { APIStatus } from "~types/entities/status"; + +const config = getConfig(); + +let token: Token; +let user: User; + +beforeAll(async () => { + if (!AppDataSource.isInitialized) await AppDataSource.initialize(); + + // Initialize test user + user = await User.createNew({ + email: "test@test.com", + username: "test", + password: "test", + display_name: "", + }); + + const app = new Application(); + + app.name = "Test Application"; + app.website = "https://example.com"; + app.client_id = "test"; + app.redirect_uris = "https://example.com"; + app.scopes = "read write"; + app.secret = "test"; + app.vapid_key = null; + + await app.save(); + + // Initialize test token + token = new Token(); + + token.access_token = "test"; + token.application = app; + token.code = "test"; + token.scope = "read write"; + token.token_type = TokenType.BEARER; + token.user = user; + + await token.save(); +}); + +describe("POST /api/v1/accounts/:id", () => { + test("should return a 404 error when trying to update a non-existent user", async () => { + const response = await fetch( + `${config.http.base_url}:${config.http.port}/api/v1/accounts/999999`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + } + ); + + expect(response.status).toBe(404); + expect(response.headers.get("content-type")).toBe("application/json"); + }); +}); + +describe("POST /api/v1/statuses", () => { + test("should create a new status and return an APIStatus object", async () => { + const response = await fetch( + `${config.http.base_url}:${config.http.port}/api/v1/statuses`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + status: "Hello, world!", + visibility: "public", + }), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const status: APIStatus = await response.json(); + + expect(status.content).toBe("Hello, world!"); + expect(status.visibility).toBe("public"); + expect(status.account.id).toBe( + `${config.http.base_url}:${config.http.port}/@test` + ); + expect(status.replies_count).toBe(0); + expect(status.favourites_count).toBe(0); + expect(status.reblogged).toBe(false); + expect(status.favourited).toBe(false); + expect(status.reblog?.content).toBe("Hello, world!"); + expect(status.reblog?.visibility).toBe("public"); + expect(status.reblog?.account.id).toBe( + `${config.http.base_url}:${config.http.port}/@test` + ); + expect(status.reblog?.replies_count).toBe(0); + expect(status.reblog?.favourites_count).toBe(0); + expect(status.reblog?.reblogged).toBe(false); + expect(status.reblog?.favourited).toBe(false); + expect(status.media_attachments).toEqual([]); + expect(status.mentions).toEqual([]); + expect(status.tags).toEqual([]); + expect(status.application).toBeNull(); + expect(status.sensitive).toBe(false); + expect(status.spoiler_text).toBe(""); + expect(status.language).toBeNull(); + expect(status.pinned).toBe(false); + expect(status.visibility).toBe("public"); + expect(status.card).toBeNull(); + expect(status.poll).toBeNull(); + expect(status.emojis).toEqual([]); + expect(status.in_reply_to_id).toBeNull(); + expect(status.in_reply_to_account_id).toBeNull(); + expect(status.reblog?.in_reply_to_id).toBeNull(); + expect(status.reblog?.in_reply_to_account_id).toBeNull(); + }); +}); + +describe("POST /api/v1/accounts/update_credentials", () => { + test("should update the authenticated user's display name", async () => { + const response = await fetch( + `${config.http.base_url}:${config.http.port}/api/v1/accounts/update_credentials`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token.access_token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + display_name: "New Display Name", + }), + } + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("application/json"); + + const user: APIAccount = await response.json(); + + expect(user.display_name).toBe("New Display Name"); + }); +}); + +afterAll(async () => { + // Clean up user + const user = await User.findOneBy({ + username: "test", + }); + + // Clean up tokens + const tokens = await Token.findBy({ + user: { + username: "test", + }, + }); + + const activities = await RawActivity.createQueryBuilder("activity") + .where("activity.data->>'actor' = :actor", { + actor: `${config.http.base_url}:${config.http.port}/@test`, + }) + .leftJoinAndSelect("activity.objects", "objects") + .getMany(); + + // Delete all created objects and activities as part of testing + await Promise.all( + activities.map(async activity => { + await Promise.all( + activity.objects.map(async object => await object.remove()) + ); + await activity.remove(); + }) + ); + + await Promise.all(tokens.map(async token => await token.remove())); + + if (user) await user.remove(); +}); diff --git a/tests/inbox.test.ts b/tests/inbox.test.ts index 4532ae23..ad9d0ab8 100644 --- a/tests/inbox.test.ts +++ b/tests/inbox.test.ts @@ -11,21 +11,18 @@ beforeAll(async () => { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); // Initialize test user - const user = new User(); - - user.email = "test@test.com"; - user.username = "test"; - user.password = await Bun.password.hash("test"); - user.display_name = ""; - user.note = ""; - - await user.generateKeys(); - - await user.save(); + await User.createNew({ + email: "test@test.com", + username: "test", + password: "test", + display_name: "", + }); }); describe("POST /@test/inbox", () => { test("should store a new Note object", async () => { + const activityId = `https://example.com/objects/${crypto.randomUUID()}`; + const response = await fetch( `${config.http.base_url}:${config.http.port}/@test/inbox/`, { @@ -36,8 +33,12 @@ describe("POST /@test/inbox", () => { body: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Create", - id: "https://example.com/notes/1/activity", - actor: `${config.http.base_url}:${config.http.port}/@test`, + id: activityId, + actor: { + id: `${config.http.base_url}:${config.http.port}/@test`, + type: "Person", + preferredUsername: "test", + }, to: ["https://www.w3.org/ns/activitystreams#Public"], cc: [], published: "2021-01-01T00:00:00.000Z", @@ -57,16 +58,13 @@ describe("POST /@test/inbox", () => { expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("application/json"); - const activity = await RawActivity.getLatestById( - "https://example.com/notes/1/activity" - ); + const activity = await RawActivity.getLatestById(activityId); expect(activity).not.toBeUndefined(); expect(activity?.data).toEqual({ "@context": "https://www.w3.org/ns/activitystreams", type: "Create", - id: "https://example.com/notes/1/activity", - actor: `${config.http.base_url}:${config.http.port}/@test`, + id: activityId, to: ["https://www.w3.org/ns/activitystreams#Public"], cc: [], published: "2021-01-01T00:00:00.000Z", @@ -85,6 +83,8 @@ describe("POST /@test/inbox", () => { }); test("should try to update that Note object", async () => { + const activityId = `https://example.com/objects/${crypto.randomUUID()}`; + const response = await fetch( `${config.http.base_url}:${config.http.port}/@test/inbox/`, { @@ -95,8 +95,12 @@ describe("POST /@test/inbox", () => { body: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Update", - id: "https://example.com/notes/1/activity", - actor: `${config.http.base_url}:${config.http.port}/@test`, + id: activityId, + actor: { + id: `${config.http.base_url}:${config.http.port}/@test`, + type: "Person", + preferredUsername: "test", + }, to: ["https://www.w3.org/ns/activitystreams#Public"], cc: [], published: "2021-01-02T00:00:00.000Z", @@ -116,16 +120,13 @@ describe("POST /@test/inbox", () => { expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("application/json"); - const activity = await RawActivity.getLatestById( - "https://example.com/notes/1/activity" - ); + const activity = await RawActivity.getLatestById(activityId); expect(activity).not.toBeUndefined(); expect(activity?.data).toEqual({ "@context": "https://www.w3.org/ns/activitystreams", type: "Update", - id: "https://example.com/notes/1/activity", - actor: `${config.http.base_url}:${config.http.port}/@test`, + id: activityId, to: ["https://www.w3.org/ns/activitystreams#Public"], cc: [], published: "2021-01-02T00:00:00.000Z", @@ -144,6 +145,7 @@ describe("POST /@test/inbox", () => { }); test("should delete the Note object", async () => { + const activityId = `https://example.com/objects/${crypto.randomUUID()}`; const response = await fetch( `${config.http.base_url}:${config.http.port}/@test/inbox/`, { @@ -154,8 +156,12 @@ describe("POST /@test/inbox", () => { body: JSON.stringify({ "@context": "https://www.w3.org/ns/activitystreams", type: "Delete", - id: "https://example.com/notes/1/activity", - actor: `${config.http.base_url}:${config.http.port}/@test`, + id: activityId, + actor: { + id: `${config.http.base_url}:${config.http.port}/@test`, + type: "Person", + preferredUsername: "test", + }, to: ["https://www.w3.org/ns/activitystreams#Public"], cc: [], published: "2021-01-03T00:00:00.000Z", @@ -175,21 +181,25 @@ describe("POST /@test/inbox", () => { expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("application/json"); - const activity = await RawActivity.getLatestById( - "https://example.com/notes/1/activity" - ); + const activity = await RawActivity.getLatestById(activityId); expect(activity).not.toBeUndefined(); expect(activity?.data).toEqual({ "@context": "https://www.w3.org/ns/activitystreams", type: "Delete", - id: "https://example.com/notes/1/activity", - actor: `${config.http.base_url}:${config.http.port}/@test`, + id: activityId, to: ["https://www.w3.org/ns/activitystreams#Public"], cc: [], published: "2021-01-03T00:00:00.000Z", }); + expect(activity?.actors).toHaveLength(1); + expect(activity?.actors[0].data).toEqual({ + preferredUsername: "test", + id: `${config.http.base_url}:${config.http.port}/@test`, + type: "Person", + }); + // Can be 0 or 1 length depending on whether config.activitypub.use_tombstone is true or false if (config.activitypub.use_tombstones) { expect(activity?.objects).toHaveLength(1); @@ -197,6 +207,41 @@ describe("POST /@test/inbox", () => { expect(activity?.objects).toHaveLength(0); } }); + + test("should return a 404 error when trying to delete a non-existent Note object", async () => { + const activityId = `https://example.com/objects/${crypto.randomUUID()}`; + + const response = await fetch( + `${config.http.base_url}:${config.http.port}/@test/inbox/`, + { + method: "POST", + headers: { + "Content-Type": "application/activity+json", + }, + body: JSON.stringify({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Delete", + id: activityId, + actor: { + id: `${config.http.base_url}:${config.http.port}/@test`, + type: "Person", + preferredUsername: "test", + }, + to: ["https://www.w3.org/ns/activitystreams#Public"], + cc: [], + published: "2021-01-03T00:00:00.000Z", + object: { + "@context": "https://www.w3.org/ns/activitystreams", + id: "https://example.com/notes/2345678909876543", + type: "Note", + }, + }), + } + ); + + expect(response.status).toBe(404); + expect(response.headers.get("content-type")).toBe("application/json"); + }); }); afterAll(async () => { @@ -213,10 +258,16 @@ afterAll(async () => { }); const activities = await RawActivity.createQueryBuilder("activity") - .where("activity.data->>'actor' = :actor", { - actor: `${config.http.base_url}:${config.http.port}/@test`, - }) + // Join objects .leftJoinAndSelect("activity.objects", "objects") + .leftJoinAndSelect("activity.actors", "actors") + // activity.actors is a many-to-many relationship with Actor objects (it is an array of Actor objects) + // Get the actors of the activity that have data.id as `${config.http.base_url}:${config.http.port}/@test` + .where("actors.data @> :data", { + data: JSON.stringify({ + id: `${config.http.base_url}:${config.http.port}/@test`, + }), + }) .getMany(); // Delete all created objects and activities as part of testing diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index 95c3b80a..fe024f8b 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -15,17 +15,12 @@ beforeAll(async () => { if (!AppDataSource.isInitialized) await AppDataSource.initialize(); // Initialize test user - const user = new User(); - - user.email = "test@test.com"; - user.username = "test"; - user.password = await Bun.password.hash("test"); - user.display_name = ""; - user.note = ""; - - await user.generateKeys(); - - await user.save(); + await User.createNew({ + email: "test@test.com", + username: "test", + password: "test", + display_name: "", + }); }); describe("POST /v1/apps/", () => {