From 3738c7dda1fb8f9f5876160157c53d672e3c7bd6 Mon Sep 17 00:00:00 2001 From: Dario Piotrowicz Date: Wed, 11 Jan 2023 15:45:07 +0000 Subject: [PATCH] improve be (+functions) linting: - enable no-unsafe-call for be/functions linting - enable backend await-thenable eslint rule - enable backend no-misused-promises eslint rule --- .eslintrc.cjs | 8 +-- backend/src/access/index.ts | 2 +- backend/src/activitypub/activities/handle.ts | 4 +- backend/src/activitypub/objects/index.ts | 2 +- backend/src/cache/index.ts | 4 +- backend/src/mastodon/notification.ts | 16 +++-- backend/src/types/notification.ts | 10 +++ backend/src/types/objects.ts | 6 ++ backend/src/utils/body.ts | 6 +- backend/src/utils/httpsigjs/parser.ts | 56 +++++++++------- backend/test/activitypub/follow.spec.ts | 26 +++++--- backend/test/activitypub/handle.spec.ts | 64 +++++++++++++----- backend/test/mastodon.spec.ts | 17 ++++- backend/test/mastodon/accounts.spec.ts | 40 ++++++------ backend/test/mastodon/media.spec.ts | 7 +- backend/test/mastodon/notifications.spec.ts | 2 +- backend/test/mastodon/oauth.spec.ts | 9 ++- backend/test/mastodon/statuses.spec.ts | 69 +++++++++++--------- backend/test/utils.ts | 6 +- functions/api/v1/apps.ts | 2 +- functions/api/v1/notifications/[id].ts | 4 +- functions/api/v1/push/subscription.ts | 2 +- 22 files changed, 230 insertions(+), 132 deletions(-) create mode 100644 backend/src/types/objects.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index febab33..c25b1c0 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -18,6 +18,10 @@ module.exports = { '@typescript-eslint/no-unused-vars': 'error', 'no-console': 'off', 'no-constant-condition': 'off', + '@typescript-eslint/require-await': 'off', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-misused-promises': 'error', /* Note: the following rules have been set to off so that linting can pass with the current code, but we need to gradually @@ -25,13 +29,9 @@ module.exports = { */ '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-argument': 'off', - '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/restrict-plus-operands': 'off', - '@typescript-eslint/await-thenable': 'off', - '@typescript-eslint/require-await': 'off', '@typescript-eslint/restrict-template-expressions': 'off', - '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/no-unnecessary-type-assertion': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-inferrable-types': 'off', diff --git a/backend/src/access/index.ts b/backend/src/access/index.ts index cd88eca..b3781fb 100644 --- a/backend/src/access/index.ts +++ b/backend/src/access/index.ts @@ -121,7 +121,7 @@ export const generateValidator = const unroundedSecondsSinceEpoch = Date.now() / 1000 - const payloadObj = JSON.parse(textDecoder.decode(base64URLDecode(payload))) + const payloadObj = JSON.parse(textDecoder.decode(base64URLDecode(payload))) as JWTPayload // For testing disable JWT checks. // Ideally we match the production behavior in testing but that diff --git a/backend/src/activitypub/activities/handle.ts b/backend/src/activitypub/activities/handle.ts index 95c97d8..c92768b 100644 --- a/backend/src/activitypub/activities/handle.ts +++ b/backend/src/activitypub/activities/handle.ts @@ -29,7 +29,7 @@ function extractID(domain: string, s: string | URL): string { return s.toString().replace(`https://${domain}/ap/users/`, '') } -export function makeGetObjectAsId(activity: Activity): Function { +export function makeGetObjectAsId(activity: Activity) { return () => { let url: any = null if (activity.object.id !== undefined) { @@ -55,7 +55,7 @@ export function makeGetObjectAsId(activity: Activity): Function { } } -export function makeGetActorAsId(activity: Activity): Function { +export function makeGetActorAsId(activity: Activity) { return () => { let url: any = null if (activity.actor.id !== undefined) { diff --git a/backend/src/activitypub/objects/index.ts b/backend/src/activitypub/objects/index.ts index b8794ad..2ad956d 100644 --- a/backend/src/activitypub/objects/index.ts +++ b/backend/src/activitypub/objects/index.ts @@ -154,7 +154,7 @@ export async function getObjectByMastodonId(db: D1Database, id: UUID): Promise { +export async function getObjectBy(db: D1Database, key: string, value: string) { const query = ` SELECT * FROM objects diff --git a/backend/src/cache/index.ts b/backend/src/cache/index.ts index 2e84738..7763026 100644 --- a/backend/src/cache/index.ts +++ b/backend/src/cache/index.ts @@ -1,3 +1,5 @@ +import type { Env } from 'wildebeest/consumer/src' + const CACHE_DO_NAME = 'cachev1' export interface Cache { @@ -5,7 +7,7 @@ export interface Cache { put(key: string, value: T): Promise } -export function cacheFromEnv(env: any): Cache { +export function cacheFromEnv(env: Env): Cache { return { async get(key: string): Promise { const id = env.DO_CACHE.idFromName(CACHE_DO_NAME) diff --git a/backend/src/mastodon/notification.ts b/backend/src/mastodon/notification.ts index 3d6a8ac..dff15f0 100644 --- a/backend/src/mastodon/notification.ts +++ b/backend/src/mastodon/notification.ts @@ -8,7 +8,11 @@ import { getPersonById } from 'wildebeest/backend/src/activitypub/actors' import type { WebPushInfos, WebPushMessage } from 'wildebeest/backend/src/webpush/webpushinfos' import { WebPushResult } from 'wildebeest/backend/src/webpush/webpushinfos' import type { Actor } from 'wildebeest/backend/src/activitypub/actors' -import type { NotificationType, Notification } from 'wildebeest/backend/src/types/notification' +import type { + NotificationType, + Notification, + NotificationsQueryResult, +} from 'wildebeest/backend/src/types/notification' import { getSubscriptionForAllClients } from 'wildebeest/backend/src/mastodon/subscription' import type { Cache } from 'wildebeest/backend/src/cache' @@ -24,10 +28,10 @@ export async function createNotification( VALUES (?, ?, ?, ?) RETURNING id ` - const row: { id: string } = await db + const row = await db .prepare(query) .bind(type, actor.id.toString(), fromActor.id.toString(), obj.id.toString()) - .first() + .first<{ id: string }>() return row.id } @@ -39,7 +43,7 @@ export async function insertFollowNotification(db: D1Database, actor: Actor, fro VALUES (?, ?, ?) RETURNING id ` - const row: { id: string } = await db.prepare(query).bind(type, actor.id.toString(), fromActor.id.toString()).first() + const row = await db.prepare(query).bind(type, actor.id.toString(), fromActor.id.toString()).first<{ id: string }>() return row.id } @@ -187,7 +191,7 @@ export async function getNotifications(db: D1Database, actor: Actor, domain: str ` const stmt = db.prepare(query).bind(actor.id.toString()) - const { results, success, error } = await stmt.all() + const { results, success, error } = await stmt.all() if (!success) { throw new Error('SQL error: ' + error) } @@ -198,7 +202,7 @@ export async function getNotifications(db: D1Database, actor: Actor, domain: str } for (let i = 0, len = results.length; i < len; i++) { - const result = results[i] as any + const result = results[i] const properties = JSON.parse(result.properties) const notifFromActorId = new URL(result.notif_from_actor_id) diff --git a/backend/src/types/notification.ts b/backend/src/types/notification.ts index ab2959b..38bf08d 100644 --- a/backend/src/types/notification.ts +++ b/backend/src/types/notification.ts @@ -1,5 +1,6 @@ import type { MastodonAccount } from 'wildebeest/backend/src/types/account' import type { MastodonStatus } from 'wildebeest/backend/src/types/status' +import type { ObjectsRow } from './objects' export type NotificationType = | 'mention' @@ -20,3 +21,12 @@ export type Notification = { account: MastodonAccount status?: MastodonStatus } + +export interface NotificationsQueryResult extends ObjectsRow { + type: NotificationType + original_actor_id: URL + notif_from_actor_id: URL + notif_cdate: string + notif_id: URL + from_actor_id: string +} diff --git a/backend/src/types/objects.ts b/backend/src/types/objects.ts new file mode 100644 index 0000000..f538d14 --- /dev/null +++ b/backend/src/types/objects.ts @@ -0,0 +1,6 @@ +export interface ObjectsRow { + properties: string + mastodon_id: string + id: URL + cdate: string +} diff --git a/backend/src/utils/body.ts b/backend/src/utils/body.ts index ef0f643..e05664c 100644 --- a/backend/src/utils/body.ts +++ b/backend/src/utils/body.ts @@ -26,10 +26,8 @@ export async function readBody(request: Request): Promise { // The `key[]` notiation is used when sending an array of values. const key2 = key.replace('[]', '') - if (out[key2] === undefined) { - out[key2] = [] - } - out[key2].push(value) + const outArr: unknown[] = (out[key2] ??= []) + outArr.push(value) } else { out[key] = value } diff --git a/backend/src/utils/httpsigjs/parser.ts b/backend/src/utils/httpsigjs/parser.ts index 940909a..fa39d2b 100644 --- a/backend/src/utils/httpsigjs/parser.ts +++ b/backend/src/utils/httpsigjs/parser.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // Copyright 2012 Joyent, Inc. All rights reserved. import { HEADER, HttpSignatureError, InvalidAlgorithmError, validateAlgorithm } from './utils' @@ -61,6 +60,9 @@ export type ParsedSignature = { keyId: string signingString: string algorithm: string + scheme: string + params: Record + opaque: string } ///--- Exported API @@ -141,10 +143,14 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu let tmpName = '' let tmpValue = '' - const parsed = { + const parsed: ParsedSignature = { scheme: authz === request.headers.get(HEADER.SIG) ? 'Signature' : '', params: {}, signingString: '', + signature: '', + keyId: '', + algorithm: '', + opaque: '', } for (i = 0; i < authz.length; i++) { @@ -234,14 +240,16 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu } } + let parsedHeaders: string[] = [] + if (!parsed.params.headers || parsed.params.headers === '') { if (request.headers.has('x-date')) { - parsed.params.headers = ['x-date'] + parsedHeaders = ['x-date'] } else { - parsed.params.headers = ['date'] + parsedHeaders = ['date'] } - } else { - parsed.params.headers = parsed.params.headers.split(' ') + } else if (typeof parsed.params.headers === 'string') { + parsedHeaders = parsed.params.headers.split(' ') } // Minimally validate the parsed object @@ -253,13 +261,13 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu if (!parsed.params.signature) throw new InvalidHeaderError('signature was not specified') - if (['date', 'x-date', '(created)'].every((hdr) => parsed.params.headers.indexOf(hdr) < 0)) { + if (['date', 'x-date', '(created)'].every((hdr) => parsedHeaders.indexOf(hdr) < 0)) { throw new MissingHeaderError('no signed date header') } // Check the algorithm against the official list try { - validateAlgorithm(parsed.params.algorithm, 'rsa') + validateAlgorithm(parsed.params.algorithm as string, 'rsa') } catch (e) { if (e instanceof InvalidAlgorithmError) throw new InvalidParamsError(parsed.params.algorithm + ' is not ' + 'supported') @@ -267,9 +275,9 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu } // Build the signingString - for (i = 0; i < parsed.params.headers.length; i++) { - const h = parsed.params.headers[i].toLowerCase() - parsed.params.headers[i] = h + for (i = 0; i < parsedHeaders.length; i++) { + const h = parsedHeaders[i].toLowerCase() + parsedHeaders[i] = h if (h === 'request-line') { if (!options.strict) { @@ -292,6 +300,7 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu } else if (h === '(opaque)') { const opaque = parsed.params.opaque if (opaque === undefined) { + //@ts-expect-error -- authzHeaderName doesn't exist TOFIX throw new MissingHeaderError('opaque param was not in the ' + authzHeaderName + ' header') } parsed.signingString += '(opaque): ' + opaque @@ -305,17 +314,17 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu parsed.signingString += h + ': ' + value } - if (i + 1 < parsed.params.headers.length) parsed.signingString += '\n' + if (i + 1 < parsedHeaders.length) parsed.signingString += '\n' } // Check against the constraints let date let skew - if (request.headers.date || request.headers.has('x-date')) { + if (request.headers.get('date') || request.headers.has('x-date')) { if (request.headers.has('x-date')) { date = new Date(request.headers.get('x-date') as string) } else { - date = new Date(request.headers.date) + date = new Date(request.headers.get('date') as string) } const now = new Date() skew = Math.abs(now.getTime() - date.getTime()) @@ -325,7 +334,7 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu } } - if (parsed.params.created) { + if (parsed.params.created && typeof parsed.params.created === 'number') { skew = parsed.params.created - Math.floor(Date.now() / 1000) if (skew > options.clockSkew) { throw new ExpiredRequestError( @@ -340,7 +349,7 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu } } - if (parsed.params.expires) { + if (parsed.params.expires && typeof parsed.params.expires === 'number') { const expiredSince = Math.floor(Date.now() / 1000) - parsed.params.expires if (expiredSince > options.clockSkew) { throw new ExpiredRequestError( @@ -352,14 +361,17 @@ export function parseRequest(request: Request, options?: Options): ParsedSignatu headers.forEach(function (hdr) { // Remember that we already checked any headers in the params // were in the request, so if this passes we're good. - if (parsed.params.headers.indexOf(hdr.toLowerCase()) < 0) + if (parsedHeaders.indexOf(hdr.toLowerCase()) < 0) { throw new MissingHeaderError(hdr + ' was not a signed header') + } }) - parsed.params.algorithm = parsed.params.algorithm.toLowerCase() - parsed.algorithm = parsed.params.algorithm.toUpperCase() - parsed.keyId = parsed.params.keyId - parsed.opaque = parsed.params.opaque - parsed.signature = parsed.params.signature + const algorithm = parsed.params.algorithm as string + parsed.params.algorithm = algorithm.toLowerCase() + parsed.algorithm = algorithm.toUpperCase() + parsed.keyId = parsed.params.keyId as string + parsed.opaque = parsed.params.opaque as string + parsed.signature = parsed.params.signature as string + parsed.params.headers = parsedHeaders return parsed } diff --git a/backend/test/activitypub/follow.spec.ts b/backend/test/activitypub/follow.spec.ts index 410bad8..e133830 100644 --- a/backend/test/activitypub/follow.spec.ts +++ b/backend/test/activitypub/follow.spec.ts @@ -21,16 +21,17 @@ describe('ActivityPub', () => { beforeEach(() => { receivedActivity = null - globalThis.fetch = async (input: any) => { - if (input.url === `https://${domain}/ap/users/sven2/inbox`) { - assert.equal(input.method, 'POST') - const data = await input.json() + globalThis.fetch = async (input: RequestInfo) => { + const request = new Request(input) + if (request.url === `https://${domain}/ap/users/sven2/inbox`) { + assert.equal(request.method, 'POST') + const data = await request.json() receivedActivity = data console.log({ receivedActivity }) return new Response('') } - throw new Error('unexpected request to ' + input.url) + throw new Error('unexpected request to ' + request.url) } }) @@ -51,14 +52,17 @@ describe('ActivityPub', () => { const row = await db .prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`) .bind(actor2.id.toString()) - .first() + .first<{ + target_actor_id: object + state: string + }>() assert(row) assert.equal(row.target_actor_id.toString(), actor.id.toString()) assert.equal(row.state, 'accepted') assert(receivedActivity) assert.equal(receivedActivity.type, 'Accept') - assert.equal(receivedActivity.actor.toString(), actor.id.toString()) + assert.equal((receivedActivity.actor as object).toString(), actor.id.toString()) assert.equal(receivedActivity.object.actor, activity.actor) assert.equal(receivedActivity.object.type, activity.type) }) @@ -144,7 +148,11 @@ describe('ActivityPub', () => { await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const entry = await db.prepare('SELECT * FROM actor_notifications').first() + const entry = await db.prepare('SELECT * FROM actor_notifications').first<{ + type: string + actor_id: object + from_actor_id: object + }>() assert.equal(entry.type, 'follow') assert.equal(entry.actor_id.toString(), actor.id.toString()) assert.equal(entry.from_actor_id.toString(), actor2.id.toString()) @@ -167,7 +175,7 @@ describe('ActivityPub', () => { await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) // Even if we followed multiple times, only one row should be present. - const { count } = await db.prepare(`SELECT count(*) as count FROM actor_following`).first() + const { count } = await db.prepare(`SELECT count(*) as count FROM actor_following`).first<{ count: number }>() assert.equal(count, 1) }) }) diff --git a/backend/test/activitypub/handle.spec.ts b/backend/test/activitypub/handle.spec.ts index a26cbe9..ec856da 100644 --- a/backend/test/activitypub/handle.spec.ts +++ b/backend/test/activitypub/handle.spec.ts @@ -6,6 +6,7 @@ import { cacheObject, getObjectById } from 'wildebeest/backend/src/activitypub/o import { addFollowing } from 'wildebeest/backend/src/mastodon/follow' import * as activityHandler from 'wildebeest/backend/src/activitypub/activities/handle' import { createPerson } from 'wildebeest/backend/src/activitypub/actors' +import { ObjectsRow } from 'wildebeest/backend/src/types/objects' const adminEmail = 'admin@example.com' const domain = 'cloudflare.com' @@ -29,7 +30,10 @@ describe('ActivityPub', () => { } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const entry = await db.prepare('SELECT * FROM actor_reblogs').first() + const entry = await db.prepare('SELECT * FROM actor_reblogs').first<{ + actor_id: URL + object_id: URL + }>() assert.equal(entry.actor_id.toString(), actorB.id.toString()) assert.equal(entry.object_id.toString(), note.id.toString()) }) @@ -48,7 +52,11 @@ describe('ActivityPub', () => { } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const entry = await db.prepare('SELECT * FROM actor_notifications').first() + const entry = await db.prepare('SELECT * FROM actor_notifications').first<{ + type: string + actor_id: URL + from_actor_id: URL + }>() assert(entry) assert.equal(entry.type, 'reblog') assert.equal(entry.actor_id.toString(), actorA.id.toString()) @@ -71,7 +79,7 @@ describe('ActivityPub', () => { } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const entry = await db.prepare('SELECT * FROM actor_favourites').first() + const entry = await db.prepare('SELECT * FROM actor_favourites').first<{ actor_id: URL; object_id: URL }>() assert.equal(entry.actor_id.toString(), actorB.id.toString()) assert.equal(entry.object_id.toString(), note.id.toString()) }) @@ -90,7 +98,11 @@ describe('ActivityPub', () => { } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const entry = await db.prepare('SELECT * FROM actor_notifications').first() + const entry = await db.prepare('SELECT * FROM actor_notifications').first<{ + type: string + actor_id: URL + from_actor_id: URL + }>() assert.equal(entry.type, 'favourite') assert.equal(entry.actor_id.toString(), actorA.id.toString()) assert.equal(entry.from_actor_id.toString(), actorB.id.toString()) @@ -110,7 +122,10 @@ describe('ActivityPub', () => { } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const entry = await db.prepare('SELECT * FROM actor_favourites').first() + const entry = await db.prepare('SELECT * FROM actor_favourites').first<{ + actor_id: URL + object_id: URL + }>() assert.equal(entry.actor_id.toString(), actorB.id.toString()) assert.equal(entry.object_id.toString(), note.id.toString()) }) @@ -145,7 +160,10 @@ describe('ActivityPub', () => { const row = await db .prepare(`SELECT target_actor_id, state FROM actor_following WHERE actor_id=?`) .bind(actor.id.toString()) - .first() + .first<{ + target_actor_id: string + state: string + }>() assert(row) assert.equal(row.target_actor_id, 'https://' + domain + '/ap/users/sven2') assert.equal(row.state, 'accepted') @@ -204,7 +222,7 @@ describe('ActivityPub', () => { const entry = await db .prepare('SELECT objects.* FROM inbox_objects INNER JOIN objects ON objects.id=inbox_objects.object_id') - .first() + .first() const properties = JSON.parse(entry.properties) assert.equal(properties.content, 'test note') }) @@ -241,7 +259,10 @@ describe('ActivityPub', () => { } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const entry = await db.prepare('SELECT * FROM outbox_objects WHERE actor_id=?').bind(remoteActorId).first() + const entry = await db + .prepare('SELECT * FROM outbox_objects WHERE actor_id=?') + .bind(remoteActorId) + .first<{ actor_id: string }>() assert.equal(entry.actor_id, remoteActorId) }) @@ -263,7 +284,11 @@ describe('ActivityPub', () => { } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const entry = await db.prepare('SELECT * FROM actor_notifications').first() + const entry = await db.prepare('SELECT * FROM actor_notifications').first<{ + type: string + actor_id: URL + from_actor_id: URL + }>() assert(entry) assert.equal(entry.type, 'mention') assert.equal(entry.actor_id.toString(), actorA.id.toString()) @@ -303,7 +328,11 @@ describe('ActivityPub', () => { await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) } - const entry = await db.prepare('SELECT * FROM actor_replies').first() + const entry = await db.prepare('SELECT * FROM actor_replies').first<{ + actor_id: string + object_id: string + in_reply_to_object_id: string + }>() assert.equal(entry.actor_id, actor.id.toString().toString()) const obj: any = await getObjectById(db, entry.object_id) @@ -319,7 +348,7 @@ describe('ActivityPub', () => { const db = await makeDB() const actor = await createPerson(domain, db, userKEK, 'sven@cloudflare.com') - const activity: any = { + const activity = { type: 'Create', actor: actor.id.toString(), to: ['some actor'], @@ -332,7 +361,7 @@ describe('ActivityPub', () => { } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const row = await db.prepare('SELECT * FROM outbox_objects').first() + const row = await db.prepare('SELECT * FROM outbox_objects').first<{ target: string }>() assert.equal(row.target, 'some actor') }) @@ -355,7 +384,7 @@ describe('ActivityPub', () => { await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const row = await db.prepare(`SELECT * from objects`).first() + const row = await db.prepare(`SELECT * from objects`).first() const { content, name } = JSON.parse(row.properties) assert.equal( content, @@ -454,7 +483,7 @@ describe('ActivityPub', () => { const updatedObject = await db .prepare('SELECT * FROM objects WHERE original_object_id=?') .bind(object.id) - .first() + .first() assert(updatedObject) assert.equal(JSON.parse(updatedObject.properties).content, newObject.content) }) @@ -500,7 +529,10 @@ describe('ActivityPub', () => { } await activityHandler.handle(domain, activity, db, userKEK, adminEmail, vapidKeys) - const object = await db.prepare('SELECT * FROM objects').first() + const object = await db.prepare('SELECT * FROM objects').first<{ + type: string + original_actor_id: string + }>() assert(object) assert.equal(object.type, 'Note') assert.equal(object.original_actor_id, remoteActorId) @@ -508,7 +540,7 @@ describe('ActivityPub', () => { const outbox_object = await db .prepare('SELECT * FROM outbox_objects WHERE actor_id=?') .bind(remoteActorId) - .first() + .first<{ actor_id: string }>() assert(outbox_object) assert.equal(outbox_object.actor_id, remoteActorId) }) diff --git a/backend/test/mastodon.spec.ts b/backend/test/mastodon.spec.ts index 0609a1d..6d44c72 100644 --- a/backend/test/mastodon.spec.ts +++ b/backend/test/mastodon.spec.ts @@ -26,6 +26,17 @@ async function generateVAPIDKeys(): Promise { describe('Mastodon APIs', () => { describe('instance', () => { + type Data = { + rules: unknown[] + uri: string + title: string + email: string + description: string + version: string + domain: string + contact: { email: string } + } + test('return the instance infos v1', async () => { const env = { INSTANCE_TITLE: 'a', @@ -39,7 +50,7 @@ describe('Mastodon APIs', () => { assertJSON(res) { - const data = await res.json() + const data = await res.json() assert.equal(data.rules.length, 0) assert.equal(data.uri, domain) assert.equal(data.title, 'a') @@ -77,7 +88,7 @@ describe('Mastodon APIs', () => { assertJSON(res) { - const data = await res.json() + const data = await res.json() assert.equal(data.rules.length, 0) assert.equal(data.domain, domain) assert.equal(data.title, 'a') @@ -248,7 +259,7 @@ describe('Mastodon APIs', () => { const res = await subscription.handlePostRequest(db, req, connectedActor, client.id, vapidKeys) assert.equal(res.status, 200) - const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first() + const { count } = await db.prepare('SELECT count(*) as count FROM subscriptions').first<{ count: number }>() assert.equal(count, 1) }) }) diff --git a/backend/test/mastodon/accounts.spec.ts b/backend/test/mastodon/accounts.spec.ts index b8bfbff..bba6bec 100644 --- a/backend/test/mastodon/accounts.spec.ts +++ b/backend/test/mastodon/accounts.spec.ts @@ -169,7 +169,7 @@ describe('Mastodon APIs', () => { globalThis.fetch = async (input: RequestInfo, data: any) => { if (input === 'https://api.cloudflare.com/client/v4/accounts/CF_ACCOUNT_ID/images/v1') { assert.equal(data.method, 'POST') - const file: any = data.body.get('file') + const file: any = (data.body as { get: (str: string) => any }).get('file') return new Response( JSON.stringify({ success: true, @@ -333,7 +333,7 @@ describe('Mastodon APIs', () => { assert.equal(data.following_count, 2) assert.equal(data.statuses_count, 1) assert(isUrlValid(data.url)) - assert(data.url.includes(domain)) + assert((data.url as string).includes(domain)) }) test('get local actor statuses', async () => { @@ -561,7 +561,7 @@ describe('Mastodon APIs', () => { // Statuses were imported locally and once was a reblog of an already // existing local object. - const row = await db.prepare(`SELECT count(*) as count FROM objects`).first() + const row: { count: number } = await db.prepare(`SELECT count(*) as count FROM objects`).first() assert.equal(row.count, 2) }) @@ -640,7 +640,7 @@ describe('Mastodon APIs', () => { const db = await makeDB() const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - globalThis.fetch = async (input: any) => { + globalThis.fetch = async (input: RequestInfo) => { if (input.toString() === 'https://example.com/.well-known/webfinger?resource=acct%3Asven%40example.com') { return new Response( JSON.stringify({ @@ -718,7 +718,7 @@ describe('Mastodon APIs', () => { test('get local actor followers', async () => { globalThis.fetch = async (input: any) => { - if (input.toString() === 'https://' + domain + '/ap/users/sven2') { + if ((input as object).toString() === 'https://' + domain + '/ap/users/sven2') { return new Response( JSON.stringify({ id: 'https://example.com/actor', @@ -746,7 +746,7 @@ describe('Mastodon APIs', () => { test('get local actor following', async () => { globalThis.fetch = async (input: any) => { - if (input.toString() === 'https://' + domain + '/ap/users/sven2') { + if ((input as object).toString() === 'https://' + domain + '/ap/users/sven2') { return new Response( JSON.stringify({ id: 'https://example.com/foo', @@ -776,7 +776,7 @@ describe('Mastodon APIs', () => { const db = await makeDB() const actorA = await createPerson(domain, db, userKEK, 'a@cloudflare.com') - globalThis.fetch = async (input: any) => { + globalThis.fetch = async (input: RequestInfo) => { if (input.toString() === 'https://example.com/.well-known/webfinger?resource=acct%3Asven%40example.com') { return new Response( JSON.stringify({ @@ -952,11 +952,9 @@ describe('Mastodon APIs', () => { beforeEach(() => { receivedActivity = null - globalThis.fetch = async (input: any) => { - if ( - input.toString() === - 'https://' + domain + '/.well-known/webfinger?resource=acct%3Aactor%40' + domain + '' - ) { + globalThis.fetch = async (input: RequestInfo) => { + const request = new Request(input) + if (request.url === 'https://' + domain + '/.well-known/webfinger?resource=acct%3Aactor%40' + domain + '') { return new Response( JSON.stringify({ links: [ @@ -970,7 +968,7 @@ describe('Mastodon APIs', () => { ) } - if (input.toString() === 'https://social.com/sven') { + if (request.url === 'https://social.com/sven') { return new Response( JSON.stringify({ id: `https://${domain}/ap/users/actor`, @@ -980,13 +978,13 @@ describe('Mastodon APIs', () => { ) } - if (input.url === 'https://example.com/inbox') { - assert.equal(input.method, 'POST') - receivedActivity = await input.json() + if (request.url === 'https://example.com/inbox') { + assert.equal(request.method, 'POST') + receivedActivity = await request.json() return new Response('') } - throw new Error('unexpected request to ' + input) + throw new Error('unexpected request to ' + request.url) } }) @@ -1005,7 +1003,11 @@ describe('Mastodon APIs', () => { assert(receivedActivity) assert.equal(receivedActivity.type, 'Follow') - const row = await db + const row: { + target_actor_acct: string + target_actor_id: string + state: string + } = await db .prepare(`SELECT target_actor_acct, target_actor_id, state FROM actor_following WHERE actor_id=?`) .bind(actor.id.toString()) .first() @@ -1036,7 +1038,7 @@ describe('Mastodon APIs', () => { const row = await db .prepare(`SELECT count(*) as count FROM actor_following WHERE actor_id=?`) .bind(actor.id.toString()) - .first() + .first<{ count: number }>() assert(row) assert.equal(row.count, 0) }) diff --git a/backend/test/mastodon/media.spec.ts b/backend/test/mastodon/media.spec.ts index b3972f1..bd150af 100644 --- a/backend/test/mastodon/media.spec.ts +++ b/backend/test/mastodon/media.spec.ts @@ -12,8 +12,9 @@ const domain = 'cloudflare.com' describe('Mastodon APIs', () => { describe('media', () => { test('upload image creates object', async () => { - globalThis.fetch = async (input: any) => { - if (input.url.toString() === 'https://api.cloudflare.com/client/v4/accounts/testaccountid/images/v1') { + globalThis.fetch = async (input: RequestInfo) => { + const request = new Request(input) + if (request.url.toString() === 'https://api.cloudflare.com/client/v4/accounts/testaccountid/images/v1') { return new Response( JSON.stringify({ success: true, @@ -24,7 +25,7 @@ describe('Mastodon APIs', () => { }) ) } - throw new Error('unexpected request to ' + input.url) + throw new Error('unexpected request to ' + request.url) } const db = await makeDB() diff --git a/backend/test/mastodon/notifications.spec.ts b/backend/test/mastodon/notifications.spec.ts index 0b9647a..3c1a858 100644 --- a/backend/test/mastodon/notifications.spec.ts +++ b/backend/test/mastodon/notifications.spec.ts @@ -117,7 +117,7 @@ describe('Mastodon APIs', () => { globalThis.fetch = async (input: RequestInfo, data: any) => { if (input === 'https://push.com') { - assert(data.headers['Authorization'].includes('WebPush')) + assert((data.headers['Authorization'] as string).includes('WebPush')) const cryptoKeyHeader = parseCryptoKey(data.headers['Crypto-Key']) assert(cryptoKeyHeader.dh) diff --git a/backend/test/mastodon/oauth.spec.ts b/backend/test/mastodon/oauth.spec.ts index 45f19ad..029a35f 100644 --- a/backend/test/mastodon/oauth.spec.ts +++ b/backend/test/mastodon/oauth.spec.ts @@ -5,6 +5,7 @@ import * as oauth_token from 'wildebeest/functions/oauth/token' import { isUrlValid, makeDB, assertCORS, assertJSON, createTestClient } from '../utils' import { TEST_JWT, ACCESS_CERTS } from '../test-data' import { strict as assert } from 'node:assert/strict' +import { Actor } from 'wildebeest/backend/src/activitypub/actors' const userKEK = 'test_kek3' const accessDomain = 'access.com' @@ -100,7 +101,7 @@ describe('Mastodon APIs', () => { ) // actor isn't created yet - const { count } = await db.prepare('SELECT count(*) as count FROM actors').first() + const { count } = await db.prepare('SELECT count(*) as count FROM actors').first<{ count: number }>() assert.equal(count, 0) }) @@ -126,7 +127,9 @@ describe('Mastodon APIs', () => { const location = res.headers.get('location') assert.equal(location, 'https://redirect.com/a') - const actor = await db.prepare('SELECT * FROM actors').first() + const actor = await db + .prepare('SELECT * FROM actors') + .first<{ properties: string; email: string; id: string } & Actor>() const properties = JSON.parse(actor.properties) assert.equal(actor.email, 'a@cloudflare.com') @@ -134,7 +137,7 @@ describe('Mastodon APIs', () => { assert.equal(properties.name, 'name') assert(isUrlValid(actor.id)) // ensure that we generate a correct key pairs for the user - assert((await getSigningKey(userKEK, db, actor)) instanceof CryptoKey) + assert((await getSigningKey(userKEK, db, actor as Actor)) instanceof CryptoKey) }) test('token error on unknown client', async () => { diff --git a/backend/test/mastodon/statuses.spec.ts b/backend/test/mastodon/statuses.spec.ts index 1d2e83f..615f367 100644 --- a/backend/test/mastodon/statuses.spec.ts +++ b/backend/test/mastodon/statuses.spec.ts @@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert/strict' import { insertReply } from 'wildebeest/backend/src/mastodon/reply' import { getMentions } from 'wildebeest/backend/src/mastodon/status' import { addObjectInOutbox } from 'wildebeest/backend/src/activitypub/actors/outbox' -import { createPublicNote } from 'wildebeest/backend/src/activitypub/objects/note' +import { createPublicNote, Note } from 'wildebeest/backend/src/activitypub/objects/note' import { createImage } from 'wildebeest/backend/src/activitypub/objects/image' import * as statuses from 'wildebeest/functions/api/v1/statuses' import * as statuses_get from 'wildebeest/functions/api/v1/statuses/[id]' @@ -16,6 +16,7 @@ import { isUrlValid, makeDB, assertJSON, streamToArrayBuffer, makeQueue, makeCac import * as activities from 'wildebeest/backend/src/activitypub/activities' import { addFollowing, acceptFollowing } from 'wildebeest/backend/src/mastodon/follow' import { MessageType } from 'wildebeest/backend/src/types/queue' +import { MastodonStatus } from 'wildebeest/backend/src/types' const userKEK = 'test_kek4' const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) @@ -60,9 +61,9 @@ describe('Mastodon APIs', () => { assert.equal(res.status, 200) assertJSON(res) - const data = await res.json() - assert(data.uri.includes('example.com')) - assert(data.uri.includes(data.id)) + const data = await res.json() + assert((data.uri as unknown as string).includes('example.com')) + assert((data.uri as unknown as string).includes(data.id)) // Required fields from https://github.com/mastodon/mastodon-android/blob/master/mastodon/src/main/java/org/joinmastodon/android/model/Status.java assert(data.created_at !== undefined) assert(data.account !== undefined) @@ -84,7 +85,7 @@ describe('Mastodon APIs', () => { FROM objects ` ) - .first() + .first<{ content: string; original_actor_id: URL; original_object_id: unknown }>() assert.equal(row.content, 'my status

evil

') // note the sanitization assert.equal(row.original_actor_id.toString(), actor.id.toString()) assert.equal(row.original_object_id, null) @@ -138,7 +139,7 @@ describe('Mastodon APIs', () => { const res = await statuses.handleRequest(req, db, connectedActor, userKEK, queue, cache) assert.equal(res.status, 200) - const row = await db.prepare(`SELECT count(*) as count FROM outbox_objects`).first() + const row = await db.prepare(`SELECT count(*) as count FROM outbox_objects`).first<{ count: number }>() assert.equal(row.count, 1) }) @@ -180,7 +181,7 @@ describe('Mastodon APIs', () => { }) test('create new status with mention delivers ActivityPub Note', async () => { - let deliveredNote: any = null + let deliveredNote: Note | null = null globalThis.fetch = async (input: RequestInfo, data: any) => { if (input.toString() === 'https://remote.com/.well-known/webfinger?resource=acct%3Asven%40remote.com') { @@ -246,12 +247,15 @@ describe('Mastodon APIs', () => { assert.equal(res.status, 200) assert(deliveredNote) - assert.equal(deliveredNote.type, 'Create') - assert.equal(deliveredNote.actor, `https://${domain}/ap/users/sven`) - assert.equal(deliveredNote.object.attributedTo, `https://${domain}/ap/users/sven`) - assert.equal(deliveredNote.object.type, 'Note') - assert(deliveredNote.object.to.includes(activities.PUBLIC_GROUP)) - assert.equal(deliveredNote.object.cc.length, 1) + assert.equal((deliveredNote as { type: string }).type, 'Create') + assert.equal((deliveredNote as { actor: string }).actor, `https://${domain}/ap/users/sven`) + assert.equal( + (deliveredNote as { object: { attributedTo: string } }).object.attributedTo, + `https://${domain}/ap/users/sven` + ) + assert.equal((deliveredNote as { object: { type: string } }).object.type, 'Note') + assert((deliveredNote as { object: { to: string[] } }).object.to.includes(activities.PUBLIC_GROUP)) + assert.equal((deliveredNote as { object: { cc: string[] } }).object.cc.length, 1) }) test('create new status with image', async () => { @@ -302,15 +306,16 @@ describe('Mastodon APIs', () => { ) .run() - globalThis.fetch = async (input: any) => { - if (input.url === actor.id.toString() + '/inbox') { - assert.equal(input.method, 'POST') - const body = await input.json() + globalThis.fetch = async (input: RequestInfo) => { + const request = new Request(input) + if (request.url === actor.id.toString() + '/inbox') { + assert.equal(request.method, 'POST') + const body = await request.json() deliveredActivity = body return new Response() } - throw new Error('unexpected request to ' + JSON.stringify(input)) + throw new Error('unexpected request to ' + request.url) } const connectedActor: any = actor @@ -336,7 +341,7 @@ describe('Mastodon APIs', () => { const data = await res.json() assert.equal(data.favourited, true) - const row = await db.prepare(`SELECT * FROM actor_favourites`).first() + const row = await db.prepare(`SELECT * FROM actor_favourites`).first<{ actor_id: string; object_id: string }>() assert.equal(row.actor_id, actor.id.toString()) assert.equal(row.object_id, note.id.toString()) }) @@ -469,7 +474,7 @@ describe('Mastodon APIs', () => { const data = await res.json() assert.equal(data.reblogged, true) - const row = await db.prepare(`SELECT * FROM actor_reblogs`).first() + const row = await db.prepare(`SELECT * FROM actor_reblogs`).first<{ actor_id: string; object_id: string }>() assert.equal(row.actor_id, actor.id.toString()) assert.equal(row.object_id, note.id.toString()) }) @@ -486,7 +491,7 @@ describe('Mastodon APIs', () => { const res = await statuses_reblog.handleRequest(db, note.mastodonId!, connectedActor, userKEK, queue, domain) assert.equal(res.status, 200) - const row = await db.prepare(`SELECT * FROM outbox_objects`).first() + const row = await db.prepare(`SELECT * FROM outbox_objects`).first<{ actor_id: string; object_id: string }>() assert.equal(row.actor_id, actor.id.toString()) assert.equal(row.object_id, note.id.toString()) }) @@ -513,15 +518,16 @@ describe('Mastodon APIs', () => { ) .run() - globalThis.fetch = async (input: any) => { - if (input.url === 'https://cloudflare.com/ap/users/sven/inbox') { - assert.equal(input.method, 'POST') - const body = await input.json() + globalThis.fetch = async (input: RequestInfo) => { + const request = new Request(input) + if (request.url === 'https://cloudflare.com/ap/users/sven/inbox') { + assert.equal(request.method, 'POST') + const body = await request.json() deliveredActivity = body return new Response() } - throw new Error('unexpected request to ' + JSON.stringify(input)) + throw new Error('unexpected request to ' + request.url) } const connectedActor: any = actor @@ -588,13 +594,16 @@ describe('Mastodon APIs', () => { ` ) .bind(data.id) - .first() + .first<{ inReplyTo: string }>() assert(row !== undefined) assert.equal(row.inReplyTo, note.id.toString()) } { - const row = await db.prepare('select * from actor_replies').first() + const row = await db.prepare('select * from actor_replies').first<{ + actor_id: string + in_reply_to_object_id: string + }>() assert(row !== undefined) assert.equal(row.actor_id, actor.id.toString()) assert.equal(row.in_reply_to_object_id, note.id.toString()) @@ -619,7 +628,7 @@ describe('Mastodon APIs', () => { const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache) assert.equal(res.status, 400) - const data = await res.json() + const data = await res.json<{ error: string }>() assert(data.error.includes('Limit exceeded')) }) @@ -644,7 +653,7 @@ describe('Mastodon APIs', () => { const res = await statuses.handleRequest(req, db, actor, userKEK, queue, cache) assert.equal(res.status, 400) - const data = await res.json() + const data = await res.json<{ error: string }>() assert(data.error.includes('Limit exceeded')) }) }) diff --git a/backend/test/utils.ts b/backend/test/utils.ts index 6159ab0..c033a24 100644 --- a/backend/test/utils.ts +++ b/backend/test/utils.ts @@ -18,7 +18,7 @@ export function isUrlValid(s: string) { return url.protocol === 'https:' } -export async function makeDB(): Promise { +export async function makeDB(): Promise { const db = new Database(':memory:') const db2 = new BetaDatabase(db)! @@ -27,10 +27,10 @@ export async function makeDB(): Promise { for (let i = 0, len = migrations.length; i < len; i++) { const content = await fs.readFile(path.join('migrations', migrations[i]), 'utf-8') - await db.exec(content) + db.exec(content) } - return db2 + return db2 as unknown as D1Database } export function assertCORS(response: Response) { diff --git a/functions/api/v1/apps.ts b/functions/api/v1/apps.ts index 905dd7a..46ace81 100644 --- a/functions/api/v1/apps.ts +++ b/functions/api/v1/apps.ts @@ -1,7 +1,7 @@ import { ContextData } from 'wildebeest/backend/src/types/context' import { cors } from 'wildebeest/backend/src/utils/cors' import type { JWK } from 'wildebeest/backend/src/webpush/jwk' -import { Env } from 'wildebeest/backend/src/types/env' +import type { Env } from 'wildebeest/backend/src/types/env' import { createClient } from 'wildebeest/backend/src/mastodon/client' import { VAPIDPublicKey } from 'wildebeest/backend/src/mastodon/subscription' import { getVAPIDKeys } from 'wildebeest/backend/src/config' diff --git a/functions/api/v1/notifications/[id].ts b/functions/api/v1/notifications/[id].ts index f4770be..1e6d2af 100644 --- a/functions/api/v1/notifications/[id].ts +++ b/functions/api/v1/notifications/[id].ts @@ -1,6 +1,6 @@ // https://docs.joinmastodon.org/methods/notifications/#get-one -import type { Notification } from 'wildebeest/backend/src/types/notification' +import type { Notification, NotificationsQueryResult } from 'wildebeest/backend/src/types/notification' import { urlToHandle } from 'wildebeest/backend/src/utils/handle' import { getPersonById } from 'wildebeest/backend/src/activitypub/actors' import { loadExternalMastodonAccount } from 'wildebeest/backend/src/mastodon/account' @@ -36,7 +36,7 @@ export async function handleRequest( WHERE actor_notifications.id=? AND actor_notifications.actor_id=? ` - const row: any = await db.prepare(query).bind(id, connectedActor.id.toString()).first() + const row = await db.prepare(query).bind(id, connectedActor.id.toString()).first() const from_actor_id = new URL(row.from_actor_id) const fromActor = await getPersonById(db, from_actor_id) diff --git a/functions/api/v1/push/subscription.ts b/functions/api/v1/push/subscription.ts index d39c79a..fe0ea8a 100644 --- a/functions/api/v1/push/subscription.ts +++ b/functions/api/v1/push/subscription.ts @@ -6,7 +6,7 @@ import type { Actor } from 'wildebeest/backend/src/activitypub/actors' import { createSubscription, getSubscription } from 'wildebeest/backend/src/mastodon/subscription' import type { CreateRequest } from 'wildebeest/backend/src/mastodon/subscription' import { ContextData } from 'wildebeest/backend/src/types/context' -import { Env } from 'wildebeest/backend/src/types/env' +import type { Env } from 'wildebeest/backend/src/types/env' import * as errors from 'wildebeest/backend/src/errors' import { VAPIDPublicKey } from 'wildebeest/backend/src/mastodon/subscription'