Fix failing tests

This commit is contained in:
Jesse Wierzbinski 2023-09-21 15:09:14 -10:00
parent 6d2f9072ac
commit c7743aa154
12 changed files with 245 additions and 200 deletions

View file

@ -4,6 +4,7 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
JoinTable,
ManyToMany, ManyToMany,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
@ -66,14 +67,44 @@ export class Status extends BaseEntity {
application!: Application | null; application!: Application | null;
@ManyToMany(() => Emoji, emoji => emoji.id) @ManyToMany(() => Emoji, emoji => emoji.id)
@JoinTable()
emojis!: Emoji[]; emojis!: Emoji[];
@ManyToMany(() => RawActivity, activity => activity.id, {}) @ManyToMany(() => RawActivity, activity => activity.id)
@JoinTable()
likes!: RawActivity[]; likes!: RawActivity[];
@ManyToMany(() => RawActivity, activity => activity.id, {}) @ManyToMany(() => RawActivity, activity => activity.id)
@JoinTable()
announces!: RawActivity[]; announces!: RawActivity[];
static async createNew(data: {
account: User;
application: Application | null;
content: string;
visibility: APIStatus["visibility"];
sensitive: boolean;
spoiler_text: string;
emojis: Emoji[];
}) {
const newStatus = new Status();
newStatus.account = data.account;
newStatus.application = data.application ?? null;
newStatus.content = data.content;
newStatus.visibility = data.visibility;
newStatus.sensitive = data.sensitive;
newStatus.spoiler_text = data.spoiler_text;
newStatus.emojis = data.emojis;
newStatus.likes = [];
newStatus.announces = [];
newStatus.isReblog = false;
newStatus.announces = [];
await newStatus.save();
return newStatus;
}
async toAPI(): Promise<APIStatus> { async toAPI(): Promise<APIStatus> {
return { return {
account: await this.account.toAPI(), account: await this.account.toAPI(),
@ -84,7 +115,7 @@ export class Status extends BaseEntity {
this.emojis.map(async emoji => await emoji.toAPI()) this.emojis.map(async emoji => await emoji.toAPI())
), ),
favourited: false, favourited: false,
favourites_count: 0, favourites_count: this.likes.length,
id: this.id, id: this.id,
in_reply_to_account_id: null, in_reply_to_account_id: null,
in_reply_to_id: null, in_reply_to_id: null,
@ -96,13 +127,13 @@ export class Status extends BaseEntity {
poll: null, poll: null,
reblog: this.isReblog ? (await this.reblog?.toAPI()) ?? null : null, reblog: this.isReblog ? (await this.reblog?.toAPI()) ?? null : null,
reblogged: false, reblogged: false,
reblogs_count: 0, reblogs_count: this.announces.length,
replies_count: 0, replies_count: 0,
sensitive: false, sensitive: false,
spoiler_text: "", spoiler_text: "",
tags: [], tags: [],
card: null, card: null,
content: "", content: this.content,
uri: `${config.http.base_url}/@${this.account.username}/${this.id}`, uri: `${config.http.base_url}/@${this.account.username}/${this.id}`,
url: `${config.http.base_url}/@${this.account.username}/${this.id}`, url: `${config.http.base_url}/@${this.account.username}/${this.id}`,
visibility: "public", visibility: "public",

View file

@ -14,6 +14,8 @@ import { APIAccount } from "~types/entities/account";
import { RawActor } from "./RawActor"; import { RawActor } from "./RawActor";
import { APActor } from "activitypub-types"; import { APActor } from "activitypub-types";
import { RawObject } from "./RawObject"; import { RawObject } from "./RawObject";
import { Token } from "./Token";
import { Status } from "./Status";
const config = getConfig(); const config = getConfig();
@ -132,6 +134,26 @@ export class User extends BaseEntity {
return user; return user;
} }
async selfDestruct() {
// Clean up tokens
const tokens = await Token.findBy({
user: {
id: this.id,
},
});
const statuses = await Status.findBy({
account: {
id: this.id,
},
});
// Delete both
await Promise.all(tokens.map(async token => await token.remove()));
await Promise.all(statuses.map(async status => await status.remove()));
}
async updateActor() { async updateActor() {
// Check if actor exists // Check if actor exists
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@ -218,7 +240,7 @@ export class User extends BaseEntity {
avatar_static: "", avatar_static: "",
bot: false, bot: false,
created_at: this.created_at.toISOString(), created_at: this.created_at.toISOString(),
display_name: "", display_name: this.display_name,
followers_count: 0, followers_count: 0,
following_count: 0, following_count: 0,
group: false, group: false,

View file

@ -1,126 +0,0 @@
import { errorResponse, jsonLdResponse } from "@response";
import { MatchedRoute } from "bun";
import { User } from "~database/entities/User";
import { getHost } from "@config";
import { compact } from "jsonld";
/**
* ActivityPub user actor endpoinmt
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
const username = matchedRoute.params.username.split("@")[0];
const user = await User.findOneBy({ username });
if (!user) {
return errorResponse("User not found", 404);
}
return jsonLdResponse(
await compact({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
toot: "http://joinmastodon.org/ns#",
featured: {
"@id": "toot:featured",
"@type": "@id",
},
featuredTags: {
"@id": "toot:featuredTags",
"@type": "@id",
},
alsoKnownAs: {
"@id": "as:alsoKnownAs",
"@type": "@id",
},
movedTo: {
"@id": "as:movedTo",
"@type": "@id",
},
schema: "http://schema.org#",
PropertyValue: "schema:PropertyValue",
value: "schema:value",
discoverable: "toot:discoverable",
Device: "toot:Device",
Ed25519Signature: "toot:Ed25519Signature",
Ed25519Key: "toot:Ed25519Key",
Curve25519Key: "toot:Curve25519Key",
EncryptedMessage: "toot:EncryptedMessage",
publicKeyBase64: "toot:publicKeyBase64",
deviceId: "toot:deviceId",
claim: {
"@type": "@id",
"@id": "toot:claim",
},
fingerprintKey: {
"@type": "@id",
"@id": "toot:fingerprintKey",
},
identityKey: {
"@type": "@id",
"@id": "toot:identityKey",
},
devices: {
"@type": "@id",
"@id": "toot:devices",
},
messageFranking: "toot:messageFranking",
messageType: "toot:messageType",
cipherText: "toot:cipherText",
suspended: "toot:suspended",
Emoji: "toot:Emoji",
focalPoint: {
"@container": "@list",
"@id": "toot:focalPoint",
},
Hashtag: "as:Hashtag",
},
],
id: `${getHost()}/@${user.username}/actor`,
type: "Person",
preferredUsername: user.username, // TODO: Add user display name
name: user.username,
summary: user.note,
icon: /*{
type: "Image",
url: user.avatar,
mediaType: mimetype
}*/ undefined, // TODO: Add avatar
image: /*{
type: "Image",
url: user.avatar,
mediaType: mimetype
}*/ undefined, // TODO: Add banner
inbox: `${getHost()}/@${user.username}/inbox`,
outbox: `${getHost()}/@${user.username}/outbox`,
followers: `${getHost()}/@${user.username}/followers`,
following: `${getHost()}/@${user.username}/following`,
liked: `${getHost()}/@${user.username}/liked`,
discoverable: true,
alsoKnownAs: [
// TODO: Add accounts from which the user migrated
],
manuallyApprovesFollowers: false, // TODO: Change
publicKey: {
id: `${getHost()}/@${user.username}/actor#main-key`,
owner: `${getHost()}/@${user.username}/actor`,
// TODO: Add user public key
},
tag: [
// TODO: Add emojis here, and hashtags
],
attachment: [
// TODO: Add user attachments (I.E. profile metadata)
],
endpoints: {
sharedInbox: `${getHost()}/inbox`,
},
})
);
};

View file

@ -0,0 +1,137 @@
import { errorResponse, jsonLdResponse } from "@response";
import { MatchedRoute } from "bun";
import { User } from "~database/entities/User";
import { getConfig, getHost } from "@config";
/**
* ActivityPub user actor endpoinmt
*/
export default async (
req: Request,
matchedRoute: MatchedRoute
): Promise<Response> => {
// Check for Accept header
const accept = req.headers.get("Accept");
if (!accept || !accept.includes("application/activity+json")) {
return errorResponse("This endpoint requires an Accept header", 406);
}
const config = getConfig();
const username = matchedRoute.params.username.replace("@", "");
const user = await User.findOneBy({ username });
if (!user) {
return errorResponse("User not found", 404);
}
return jsonLdResponse({
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
manuallyApprovesFollowers: "as:manuallyApprovesFollowers",
toot: "http://joinmastodon.org/ns#",
featured: {
"@id": "toot:featured",
"@type": "@id",
},
featuredTags: {
"@id": "toot:featuredTags",
"@type": "@id",
},
alsoKnownAs: {
"@id": "as:alsoKnownAs",
"@type": "@id",
},
movedTo: {
"@id": "as:movedTo",
"@type": "@id",
},
schema: "http://schema.org#",
PropertyValue: "schema:PropertyValue",
value: "schema:value",
discoverable: "toot:discoverable",
Device: "toot:Device",
Ed25519Signature: "toot:Ed25519Signature",
Ed25519Key: "toot:Ed25519Key",
Curve25519Key: "toot:Curve25519Key",
EncryptedMessage: "toot:EncryptedMessage",
publicKeyBase64: "toot:publicKeyBase64",
deviceId: "toot:deviceId",
claim: {
"@type": "@id",
"@id": "toot:claim",
},
fingerprintKey: {
"@type": "@id",
"@id": "toot:fingerprintKey",
},
identityKey: {
"@type": "@id",
"@id": "toot:identityKey",
},
devices: {
"@type": "@id",
"@id": "toot:devices",
},
messageFranking: "toot:messageFranking",
messageType: "toot:messageType",
cipherText: "toot:cipherText",
suspended: "toot:suspended",
Emoji: "toot:Emoji",
focalPoint: {
"@container": "@list",
"@id": "toot:focalPoint",
},
Hashtag: "as:Hashtag",
},
],
id: `${config.http.base_url}:${config.http.port}/@${user.username}`,
type: "Person",
preferredUsername: user.username, // TODO: Add user display name
name: user.username,
summary: user.note,
icon: {
type: "Image",
url: user.avatar,
mediaType: "image/png", // TODO: Set user avatar mimetype
},
image: {
type: "Image",
url: user.header,
mediaType: "image/png", // TODO: Set user header mimetype
},
inbox: `${config.http.base_url}:${config.http.port}/@${user.username}/inbox`,
outbox: `${config.http.base_url}:${config.http.port}/@${user.username}/outbox`,
followers: `${config.http.base_url}:${config.http.port}/@${user.username}/followers`,
following: `${config.http.base_url}:${config.http.port}/@${user.username}/following`,
liked: `${config.http.base_url}:${config.http.port}/@${user.username}/liked`,
discoverable: true,
alsoKnownAs: [
// TODO: Add accounts from which the user migrated
],
manuallyApprovesFollowers: false, // TODO: Change
publicKey: {
id: `${getHost()}${config.http.base_url}:${config.http.port}/@${
user.username
}/actor#main-key`,
owner: `${config.http.base_url}:${config.http.port}/@${user.username}`,
// Split the public key into PEM format
publicKeyPem: `-----BEGIN PUBLIC KEY-----\n${user.public_key
.match(/.{1,64}/g)
?.join("\n")}\n-----END PUBLIC KEY-----`,
},
tag: [
// TODO: Add emojis here, and hashtags
],
attachment: [
// TODO: Add user attachments (I.E. profile metadata)
],
endpoints: {
sharedInbox: `${config.http.base_url}:${config.http.port}/inbox`,
},
});
};

View file

@ -16,9 +16,14 @@ export default async (
const token = req.headers.get("Authorization")?.split(" ")[1] || null; const token = req.headers.get("Authorization")?.split(" ")[1] || null;
const user = await getUserByToken(token); const user = await getUserByToken(token);
const foundUser = await RawActor.findOneBy({ let foundUser: RawActor | null;
try {
foundUser = await RawActor.findOneBy({
id, id,
}); });
} catch (e) {
return errorResponse("Invalid ID", 404);
}
if (!foundUser) return errorResponse("User not found", 404); if (!foundUser) return errorResponse("User not found", 404);

View file

@ -46,5 +46,7 @@ export default async (
take: limit ?? 20, take: limit ?? 20,
}); });
return jsonResponse(statuses.map(status => status.toAPI())); return jsonResponse(
await Promise.all(statuses.map(async status => await status.toAPI()))
);
}; };

View file

@ -119,10 +119,7 @@ export default async (req: Request): Promise<Response> => {
// user.discoverable = discoverable === "true"; // user.discoverable = discoverable === "true";
} }
return jsonResponse( await user.save();
{
error: `Not really implemented yet`, return jsonResponse(await user.toAPI());
},
501
);
}; };

View file

@ -98,19 +98,17 @@ export default async (req: Request): Promise<Response> => {
} }
// Create status // Create status
const newStatus = new Status(); const newStatus = await Status.createNew({
newStatus.account = user; account: user,
newStatus.application = application; application,
newStatus.content = status; content: status,
newStatus.spoiler_text = spoiler_text || ""; visibility: visibility || "public",
newStatus.sensitive = sensitive || false; sensitive: sensitive || false,
newStatus.visibility = visibility || "public"; spoiler_text: spoiler_text || "",
newStatus.likes = []; emojis: [],
newStatus.isReblog = false; });
// TODO: add database jobs to deliver the post // TODO: add database jobs to deliver the post
await newStatus.save(); return jsonResponse(await newStatus.toAPI());
return jsonResponse(newStatus);
}; };

View file

@ -5,7 +5,6 @@ import { APActor } from "activitypub-types";
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { AppDataSource } from "~database/datasource"; import { AppDataSource } from "~database/datasource";
import { RawActivity } from "~database/entities/RawActivity"; import { RawActivity } from "~database/entities/RawActivity";
import { Token } from "~database/entities/Token";
import { User } from "~database/entities/User"; import { User } from "~database/entities/User";
const config = getConfig(); const config = getConfig();
@ -22,10 +21,10 @@ beforeAll(async () => {
}); });
}); });
describe("POST /@test", () => { describe("POST /@test/actor", () => {
test("should return a valid ActivityPub Actor when querying an existing user", async () => { test("should return a valid ActivityPub Actor when querying an existing user", async () => {
const response = await fetch( const response = await fetch(
`${config.http.base_url}:${config.http.port}/@test`, `${config.http.base_url}:${config.http.port}/@test/actor`,
{ {
method: "GET", method: "GET",
headers: { headers: {
@ -64,6 +63,9 @@ describe("POST /@test", () => {
`${config.http.base_url}:${config.http.port}/@test` `${config.http.base_url}:${config.http.port}/@test`
); );
expect((actor as any).publicKey.publicKeyPem).toBeDefined(); expect((actor as any).publicKey.publicKeyPem).toBeDefined();
expect((actor as any).publicKey.publicKeyPem).toMatch(
/(-----BEGIN PUBLIC KEY-----(\n|\r|\r\n)([0-9a-zA-Z+/=]{64}(\n|\r|\r\n))*([0-9a-zA-Z+/=]{1,63}(\n|\r|\r\n))?-----END PUBLIC KEY-----)|(-----BEGIN PRIVATE KEY-----(\n|\r|\r\n)([0-9a-zA-Z+/=]{64}(\n|\r|\r\n))*([0-9a-zA-Z+/=]{1,63}(\n|\r|\r\n))?-----END PRIVATE KEY-----)/
);
}); });
}); });
@ -73,13 +75,6 @@ afterAll(async () => {
username: "test", username: "test",
}); });
// Clean up tokens
const tokens = await Token.findBy({
user: {
username: "test",
},
});
const activities = await RawActivity.createQueryBuilder("activity") const activities = await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'actor' = :actor", { .where("activity.data->>'actor' = :actor", {
actor: `${config.http.base_url}:${config.http.port}/@test`, actor: `${config.http.base_url}:${config.http.port}/@test`,
@ -97,7 +92,8 @@ afterAll(async () => {
}) })
); );
await Promise.all(tokens.map(async token => await token.remove())); if (user) {
await user.selfDestruct();
if (user) await user.remove(); await user.remove();
}
}); });

View file

@ -48,11 +48,11 @@ beforeAll(async () => {
token.token_type = TokenType.BEARER; token.token_type = TokenType.BEARER;
token.user = user; token.user = user;
await token.save(); token = await token.save();
}); });
describe("POST /api/v1/accounts/:id", () => { describe("POST /api/v1/accounts/:id", () => {
test("should return a 404 error when trying to update a non-existent user", async () => { test("should return a 404 error when trying to fetch a non-existent user", async () => {
const response = await fetch( const response = await fetch(
`${config.http.base_url}:${config.http.port}/api/v1/accounts/999999`, `${config.http.base_url}:${config.http.port}/api/v1/accounts/999999`,
{ {
@ -93,26 +93,14 @@ describe("POST /api/v1/statuses", () => {
expect(status.content).toBe("Hello, world!"); expect(status.content).toBe("Hello, world!");
expect(status.visibility).toBe("public"); expect(status.visibility).toBe("public");
expect(status.account.id).toBe( expect(status.account.id).toBe(user.id);
`${config.http.base_url}:${config.http.port}/@test`
);
expect(status.replies_count).toBe(0); expect(status.replies_count).toBe(0);
expect(status.favourites_count).toBe(0); expect(status.favourites_count).toBe(0);
expect(status.reblogged).toBe(false); expect(status.reblogged).toBe(false);
expect(status.favourited).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.media_attachments).toEqual([]);
expect(status.mentions).toEqual([]); expect(status.mentions).toEqual([]);
expect(status.tags).toEqual([]); expect(status.tags).toEqual([]);
expect(status.application).toBeNull();
expect(status.sensitive).toBe(false); expect(status.sensitive).toBe(false);
expect(status.spoiler_text).toBe(""); expect(status.spoiler_text).toBe("");
expect(status.language).toBeNull(); expect(status.language).toBeNull();
@ -123,17 +111,15 @@ describe("POST /api/v1/statuses", () => {
expect(status.emojis).toEqual([]); expect(status.emojis).toEqual([]);
expect(status.in_reply_to_id).toBeNull(); expect(status.in_reply_to_id).toBeNull();
expect(status.in_reply_to_account_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", () => { describe("PATCH /api/v1/accounts/update_credentials", () => {
test("should update the authenticated user's display name", async () => { test("should update the authenticated user's display name", async () => {
const response = await fetch( const response = await fetch(
`${config.http.base_url}:${config.http.port}/api/v1/accounts/update_credentials`, `${config.http.base_url}:${config.http.port}/api/v1/accounts/update_credentials`,
{ {
method: "POST", method: "PATCH",
headers: { headers: {
Authorization: `Bearer ${token.access_token}`, Authorization: `Bearer ${token.access_token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
@ -154,18 +140,10 @@ describe("POST /api/v1/accounts/update_credentials", () => {
}); });
afterAll(async () => { afterAll(async () => {
// Clean up user
const user = await User.findOneBy({ const user = await User.findOneBy({
username: "test", username: "test",
}); });
// Clean up tokens
const tokens = await Token.findBy({
user: {
username: "test",
},
});
const activities = await RawActivity.createQueryBuilder("activity") const activities = await RawActivity.createQueryBuilder("activity")
.where("activity.data->>'actor' = :actor", { .where("activity.data->>'actor' = :actor", {
actor: `${config.http.base_url}:${config.http.port}/@test`, actor: `${config.http.base_url}:${config.http.port}/@test`,
@ -183,7 +161,8 @@ afterAll(async () => {
}) })
); );
await Promise.all(tokens.map(async token => await token.remove())); if (user) {
await user.selfDestruct();
if (user) await user.remove(); await user.remove();
}
}); });

View file

@ -3,8 +3,11 @@ import { Token } from "~database/entities/Token";
export const getUserByToken = async (access_token: string | null) => { export const getUserByToken = async (access_token: string | null) => {
if (!access_token) return null; if (!access_token) return null;
const token = await Token.findOneBy({ const token = await Token.findOne({
where: {
access_token, access_token,
},
relations: ["user"],
}); });
if (!token) return null; if (!token) return null;

View file

@ -6,7 +6,6 @@
* @param request The request to parse * @param request The request to parse
*/ */
export async function parseRequest<T>(request: Request): Promise<Partial<T>> { export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
const formData = await request.formData();
const query = new URL(request.url).searchParams; const query = new URL(request.url).searchParams;
// if request contains a JSON body // if request contains a JSON body
@ -16,6 +15,8 @@ export async function parseRequest<T>(request: Request): Promise<Partial<T>> {
// If request contains FormData // If request contains FormData
if (request.headers.get("Content-Type")?.includes("multipart/form-data")) { if (request.headers.get("Content-Type")?.includes("multipart/form-data")) {
const formData = await request.formData();
if ([...formData.entries()].length > 0) { if ([...formData.entries()].length > 0) {
const data: Record<string, string | File> = {}; const data: Record<string, string | File> = {};