// Must stay first: see setup.ts. import { TEST_DIR } from "./setup.ts"; import { stub } from "@std/testing/mock"; import { app } from "../main.ts"; import { db } from "../db/connection.ts"; import { createJWT, createMediaToken, generateInstanceKey, importInstanceKey, signInstanceToken, } from "../lib/jwt.ts"; import { hashPassword } from "../lib/password.ts"; import { uniqueSlug } from "../lib/slugify.ts"; import { slugify } from "../../shared/slug.ts"; import { dumpPath, playlistPath } from "../../shared/paths.ts"; import { canonicalizeUrl } from "../lib/canonical-url.ts"; import { clearRichContentCache } from "../services/rich-content-service.ts"; import { clearImageCache } from "../remote/image-proxy.ts"; import { clearInstanceCaches } from "../remote/gerbeur-instance.ts"; import { FEDERATION } from "../config.ts"; import { type OutgoingEmail, setEmailTransport, } from "../services/email-service.ts"; import type { APIError, APIResponse } from "../../shared/api.ts"; import type { Role } from "../../shared/user.ts"; export { app, db, TEST_DIR }; // ── Database ───────────────────────────────────────────────────────────────── const TABLES = [ "user_identities", "federation_peers", "federation_logins", "oauth_codes", "federation_grants", "federation_outbox", "dump_backlinks", "dump_categories", "playlist_dumps", "comment_likes", "comments", "votes", "follows", "notifications", "password_reset_tokens", "invites", "attachments", "chat_messages", "user_settings", "digest_schedule", "dump_slug_aliases", "playlist_slug_aliases", "user_aliases", "categories", "playlists", "dumps", "users", ]; /** Empties every table, the default admin included. */ export function resetDb(): void { db.exec("PRAGMA foreign_keys = OFF;"); for (const table of TABLES) db.exec(`DELETE FROM ${table};`); db.exec("PRAGMA foreign_keys = ON;"); } /** * Wraps a test body so it starts from an empty database. Usernames and emails * still have to be unique across the whole run where the rate limiter keys on * them (login, password reset), since its buckets live for the process. */ export function test( name: string, fn: (t: Deno.TestContext) => void | Promise, options: Omit = {}, ): void { Deno.test({ name, ...options, async fn(t) { resetDb(); await fn(t); }, }); } // ── Users ──────────────────────────────────────────────────────────────────── export const PASSWORD = "correct-horse"; // scrypt is deliberately slow; hash the shared password once. let passwordHash: Promise | undefined; let seq = 0; /** A name no other test in this process has used. */ export function uniqueName(prefix = "user"): string { return `${prefix}_${++seq}_${crypto.randomUUID().slice(0, 6)}`; } export interface TestUser { id: string; username: string; email: string; role: Role; token: string; mediaToken: string; /** Authorization header for this user. */ auth: { Authorization: string }; } export async function createUser( opts: { username?: string; role?: Role; email?: string; invitedBy?: string; createdAt?: string; } = {}, ): Promise { const id = crypto.randomUUID(); const username = opts.username ?? uniqueName(); const email = opts.email ?? `${username}@example.test`; const role = opts.role ?? "user"; passwordHash ??= hashPassword(PASSWORD); db.prepare( `INSERT INTO users (id, username, password_hash, role, created_at, email, invited_by) VALUES (?, ?, ?, ?, ?, ?, ?);`, ).run( id, username, await passwordHash, role, opts.createdAt ?? new Date().toISOString(), email, opts.invitedBy ?? null, ); const token = await createJWT({ userId: id, username, role }); return { id, username, email, role, token, mediaToken: await createMediaToken(id), auth: { Authorization: `Bearer ${token}` }, }; } // ── Content ────────────────────────────────────────────────────────────────── export interface InsertDumpOptions { userId: string; title?: string; kind?: "url" | "file"; url?: string; comment?: string; isPrivate?: boolean; createdAt?: string; fileName?: string; fileMime?: string; /** Bytes written to the uploads directory for a file dump. */ fileData?: Uint8Array; richContent?: Record; } /** Returns the name of user `userId`, for the page paths of their things. */ function usernameOf(userId: string): string { return (db.prepare(`SELECT username FROM users WHERE id = ?;`).get( userId, ) as { username: string }).username; } /** * Inserts a dump row directly, skipping the metadata fetch the create route * does, with a slug made from its title as the service would. Returns its id, * slug and page path. */ export function insertDump( opts: InsertDumpOptions, ): { id: string; slug: string; path: string } { const id = crypto.randomUUID(); const title = opts.title ?? "A dump"; const slug = uniqueSlug(db, "dump", opts.userId, slugify(title)); const kind = opts.kind ?? (opts.fileName ? "file" : "url"); const url = kind === "url" ? (opts.url ?? `https://example.com/${id}`) : null; const data = opts.fileData ?? new TextEncoder().encode("hello"); db.prepare( `INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical, rich_content, file_name, file_mime, file_size, is_private) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, ).run( id, kind, title, slug, opts.comment ?? null, opts.userId, opts.createdAt ?? new Date().toISOString(), url, url ? canonicalizeUrl(url) : null, opts.richContent ? JSON.stringify(opts.richContent) : null, kind === "file" ? (opts.fileName ?? "file.txt") : null, kind === "file" ? (opts.fileMime ?? "text/plain") : null, kind === "file" ? data.byteLength : null, opts.isPrivate ? 1 : 0, ); if (kind === "file") { Deno.mkdirSync(`${TEST_DIR}/uploads/dumps`, { recursive: true }); Deno.writeFileSync(`${TEST_DIR}/uploads/dumps/${id}`, data); } return { id, slug, path: dumpPath({ ownerUsername: usernameOf(opts.userId), slug }), }; } /** Inserts a playlist row as {@link insertDump} does a dump. */ export function insertPlaylist( opts: { userId: string; title?: string; isPublic?: boolean }, ): { id: string; slug: string; path: string } { const id = crypto.randomUUID(); const title = opts.title ?? "A playlist"; const slug = uniqueSlug(db, "playlist", opts.userId, slugify(title)); db.prepare( `INSERT INTO playlists (id, user_id, title, slug, is_public, created_at) VALUES (?, ?, ?, ?, ?, ?);`, ).run( id, opts.userId, title, slug, opts.isPublic === false ? 0 : 1, new Date().toISOString(), ); return { id, slug, path: playlistPath({ ownerUsername: usernameOf(opts.userId), slug }), }; } export function insertComment( opts: { dumpId: string; userId: string; body?: string; parentId?: string; createdAt?: string; }, ): string { const id = crypto.randomUUID(); db.prepare( `INSERT INTO comments (id, dump_id, user_id, parent_id, body, created_at) VALUES (?, ?, ?, ?, ?, ?);`, ).run( id, opts.dumpId, opts.userId, opts.parentId ?? null, opts.body ?? "A comment", opts.createdAt ?? new Date().toISOString(), ); return id; } export function insertCategory( opts: { slug: string; name?: string; position?: number }, ): string { const id = crypto.randomUUID(); db.prepare( `INSERT INTO categories (id, slug, name, position, created_at) VALUES (?, ?, ?, ?, ?);`, ).run( id, opts.slug, opts.name ?? opts.slug, opts.position ?? 0, new Date().toISOString(), ); return id; } export function count(sql: string, ...params: (string | number)[]): number { return (db.prepare(sql).get(...params) as { n: number }).n; } // ── HTTP ───────────────────────────────────────────────────────────────────── export const BASE_URL = "http://gerbeur.test"; export interface ApiResponse { status: number; headers: Headers; /** The parsed body when it's JSON, null otherwise. */ body: APIResponse | null; bytes: Uint8Array; /** The body's `data`; throws if the request didn't succeed. */ readonly data: T; /** The body's `error`; throws if the request didn't fail. */ readonly error: APIError; } type RequestOptions = { auth?: { Authorization: string }; json?: unknown; form?: FormData; headers?: Record; }; /** * Sends a request through the full middleware stack in-process — no socket. * `json` sends a JSON body, `form` a multipart one. `T` is what the endpoint * answers with in `data`. */ export async function request( method: string, path: string, opts: RequestOptions = {}, ): Promise> { const headers = new Headers({ ...opts.auth, ...opts.headers }); let body: BodyInit | undefined; if (opts.json !== undefined) { headers.set("Content-Type", "application/json"); body = JSON.stringify(opts.json); } else if (opts.form) { body = opts.form; } const res = await app.handle( new Request(`${BASE_URL}${path}`, { method, headers, body }), ); if (!res) throw new Error(`No response for ${method} ${path}`); const bytes = new Uint8Array(await res.arrayBuffer()); const text = new TextDecoder().decode(bytes); let parsed: APIResponse | null = null; if ((res.headers.get("content-type") ?? "").includes("json") && text) { parsed = JSON.parse(text); } const what = `${method} ${path} (${res.status})`; return { status: res.status, headers: res.headers, body: parsed, bytes, get data() { if (!parsed?.success) { throw new Error(`${what} failed: ${parsed?.error.message ?? text}`); } return parsed.data; }, get error() { if (parsed?.success !== false) { throw new Error(`${what} didn't fail`); } return parsed.error; }, }; } export const get = (path: string, opts?: RequestOptions) => request("GET", path, opts); export const post = (path: string, opts?: RequestOptions) => request("POST", path, opts); export const put = (path: string, opts?: RequestOptions) => request("PUT", path, opts); export const patch = (path: string, opts?: RequestOptions) => request("PATCH", path, opts); export const del = (path: string, opts?: RequestOptions) => request("DELETE", path, opts); // ── Files ──────────────────────────────────────────────────────────────────── /** Smallest valid PNG: a 1×1 transparent pixel. */ export const PNG: Uint8Array = Uint8Array.from( atob( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", ), (c) => c.charCodeAt(0), ); export function formWithFile( bytes: Uint8Array, name = "file.png", type = "image/png", fields: Record = {}, ): FormData { const form = new FormData(); form.set("file", new File([bytes], name, { type })); for (const [key, value] of Object.entries(fields)) form.set(key, value); return form; } // ── Network ────────────────────────────────────────────────────────────────── /** * Replaces outbound fetches and DNS for the life of the returned handle * (`using net = stubNetwork(...)`). Every hostname resolves to a public * address unless `dns` says otherwise; `handler` answers every fetch. */ export function stubNetwork( handler: (url: URL, init?: RequestInit) => Response | Promise, dns: Record = {}, ): { calls: URL[] } & Disposable { const calls: URL[] = []; // A new network: metadata cached from the last one no longer describes it. clearRichContentCache(); clearImageCache(); clearInstanceCaches(); const fetchStub = stub( globalThis, "fetch", (input: string | URL | Request, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : input); calls.push(url); return Promise.resolve(handler(url, init)); }, ); const dnsStub = stub( Deno, "resolveDns", ((host: string, type: string) => Promise.resolve( dns[host] ?? (type === "A" ? ["93.184.216.34"] : []), )) as typeof Deno.resolveDns, ); return { calls, [Symbol.dispose]() { fetchStub.restore(); dnsStub.restore(); }, }; } // ── Federation ─────────────────────────────────────────────────────────────── /** A user of a {@link FakeInstance}, as its identity tokens describe them. */ export interface FakeIdentity { sub: string; preferred_username: string; email: string; picture?: string; } /** * Another gerbeur instance at `origin`, answering through {@link stubNetwork} * (`stubNetwork((url, init) => fake.handle(url, init) ?? ...)`): its metadata, * its keys, a token endpoint that trades the codes the test `issue`s, an * events endpoint recording what it receives, and avatars. */ export interface FakeInstance { origin: string; /** * Makes a code for `identity`, answering the authorize URL `authorizeUrl` * (this instance's request) as the home would after consent. */ issue(authorizeUrl: string, identity: FakeIdentity): string; /** Signs an event about `sub` for this instance, as its outbox would. */ event(sub: string, payload: Record): Promise; /** The events posted to it, as their JWTs. */ received: string[]; /** Answers a request to it, or null when `url` isn't on it. */ handle(url: URL, init?: RequestInit): Promise | null; } /** * Makes a {@link FakeInstance} at `origin` with a fresh key, its metadata * with `metadata` over the defaults. */ export async function fakeInstance( origin: string, metadata: Record = {}, ): Promise { const { kid, privateJwk, publicJwk } = await generateInstanceKey(); const key = await importInstanceKey(kid, privateJwk); const codes = new Map< string, { identity: FakeIdentity; nonce: string; challenge: string; client: string } >(); const received: string[] = []; const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" }, }); const handle = async (url: URL, init?: RequestInit): Promise => { switch (url.pathname) { case "/.well-known/openid-configuration": return json({ issuer: origin, authorization_endpoint: `${origin}/authorize`, token_endpoint: `${origin}/api/oauth/tokens`, jwks_uri: `${origin}/.well-known/jwks.json`, response_types_supported: ["code"], grant_types_supported: ["authorization_code"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["EdDSA"], code_challenge_methods_supported: ["S256"], scopes_supported: ["openid", "profile", "email"], gerbeur_federation: 1, gerbeur_site_name: `Fake ${new URL(origin).host}`, gerbeur_redirect_uri: `${origin}/federation/callback`, gerbeur_events_endpoint: `${origin}/api/federation/events`, ...metadata, }); case "/.well-known/jwks.json": return json({ keys: [publicJwk] }); case "/api/oauth/tokens": { const form = new URLSearchParams(String(init?.body)); const entry = codes.get(form.get("code") ?? ""); codes.delete(form.get("code") ?? ""); const verifier = form.get("code_verifier") ?? ""; const hash = new Uint8Array( await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(verifier), ), ); const challenge = btoa(String.fromCharCode(...hash)) .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); if ( !entry || entry.challenge !== challenge || entry.client !== form.get("client_id") ) { return json({ error: "invalid_grant" }, 400); } const { sub, ...claims } = entry.identity; return json({ id_token: await signInstanceToken( { ...claims, nonce: entry.nonce }, key, { issuer: origin, audience: entry.client, subject: sub }, "5m", ), }); } case "/api/federation/events": received.push(JSON.parse(String(init?.body)).event); return new Response(null, { status: 204 }); default: if (url.pathname.endsWith("/avatar")) { return new Response(PNG, { headers: { "content-type": "image/png" }, }); } return json({ error: "not found" }, 404); } }; return { origin, received, issue(authorizeUrl, identity) { const q = new URL(authorizeUrl).searchParams; const code = crypto.randomUUID(); codes.set(code, { identity, nonce: q.get("nonce") ?? "", challenge: q.get("code_challenge") ?? "", client: q.get("client_id") ?? "", }); return code; }, event: (sub, payload) => signInstanceToken(payload, key, { issuer: origin, audience: BASE_URL, subject: sub, }, "1h"), handle: (url, init) => url.origin === origin ? handle(url, init) : null, }; } /** * Turns one way of federated sign-in off for the life of the returned handle * (`using _off = federationOff("incoming")`), as `GERBEUR_FEDERATION_*=0` * would for the server's life. */ export function federationOff(way: keyof typeof FEDERATION): Disposable { FEDERATION[way] = false; return { [Symbol.dispose]() { FEDERATION[way] = true; }, }; } // ── Email ──────────────────────────────────────────────────────────────────── /** * Turns email on for the life of the returned handle * (`using mail = captureEmails()`), recording each message instead of sending * it. Sends that aren't awaited by their route land on the next tick. */ export function captureEmails(): { sent: OutgoingEmail[] } & Disposable { const sent: OutgoingEmail[] = []; setEmailTransport({ sendMail(message) { sent.push(message); return Promise.resolve(); }, }); return { sent, [Symbol.dispose]() { setEmailTransport(null); }, }; } export function html(body: string, status = 200): Response { return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" }, }); }