618 lines
19 KiB
TypeScript
618 lines
19 KiB
TypeScript
// 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<void>,
|
||
options: Omit<Deno.TestDefinition, "name" | "fn"> = {},
|
||
): 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<string> | 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<TestUser> {
|
||
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<string, unknown>;
|
||
}
|
||
|
||
/** 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<T = unknown> {
|
||
status: number;
|
||
headers: Headers;
|
||
/** The parsed body when it's JSON, null otherwise. */
|
||
body: APIResponse<T> | 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<string, string>;
|
||
};
|
||
|
||
/**
|
||
* 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<T = unknown>(
|
||
method: string,
|
||
path: string,
|
||
opts: RequestOptions = {},
|
||
): Promise<ApiResponse<T>> {
|
||
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<T> | 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 = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||
request<T>("GET", path, opts);
|
||
export const post = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||
request<T>("POST", path, opts);
|
||
export const put = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||
request<T>("PUT", path, opts);
|
||
export const patch = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||
request<T>("PATCH", path, opts);
|
||
export const del = <T = unknown>(path: string, opts?: RequestOptions) =>
|
||
request<T>("DELETE", path, opts);
|
||
|
||
// ── Files ────────────────────────────────────────────────────────────────────
|
||
|
||
/** Smallest valid PNG: a 1×1 transparent pixel. */
|
||
export const PNG: Uint8Array<ArrayBuffer> = Uint8Array.from(
|
||
atob(
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||
),
|
||
(c) => c.charCodeAt(0),
|
||
);
|
||
|
||
export function formWithFile(
|
||
bytes: Uint8Array<ArrayBuffer>,
|
||
name = "file.png",
|
||
type = "image/png",
|
||
fields: Record<string, string> = {},
|
||
): 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<Response>,
|
||
dns: Record<string, string[]> = {},
|
||
): { 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<string, unknown>): Promise<string>;
|
||
/** 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<Response> | 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<string, unknown> = {},
|
||
): Promise<FakeInstance> {
|
||
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<Response> => {
|
||
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" },
|
||
});
|
||
}
|