diff --git a/api/config.ts b/api/config.ts index 58505aca..77a35312 100644 --- a/api/config.ts +++ b/api/config.ts @@ -28,10 +28,12 @@ export const PUBLIC_URL = // frontend on a separate host/port (e.g. Vite dev server or a CDN origin). export const FRONTEND_URL = Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() || PUBLIC_URL; -export const DB_PATH = "api/sql/gerbeur.db"; +// Overridable so the test suite can run against a throwaway database and +// uploads directory (see api/test/setup.ts). +export const DB_PATH = Deno.env.get("GERBEUR_DB_PATH") || "api/sql/gerbeur.db"; // Upload/files -export const UPLOADS_DIR = "api/uploads"; +export const UPLOADS_DIR = Deno.env.get("GERBEUR_UPLOADS_DIR") || "api/uploads"; export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`; export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`; export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`; diff --git a/api/lib/jwt.ts b/api/lib/jwt.ts index 1408b707..4f22bba5 100644 --- a/api/lib/jwt.ts +++ b/api/lib/jwt.ts @@ -113,6 +113,9 @@ export function verifyPassword( storedHash: string, ): Promise { const [hash, salt] = storedHash.split("."); + // A malformed stored hash can't match anything; scrypt would throw on the + // missing salt instead of answering. + if (!hash || !salt) return Promise.resolve(false); return new Promise((resolve, reject) => { scrypt(password, salt, 64, (err, derivedKey) => { diff --git a/api/lib/static.ts b/api/lib/static.ts index 5158f697..db63eaee 100644 --- a/api/lib/static.ts +++ b/api/lib/static.ts @@ -39,11 +39,16 @@ export function routeStaticFilesFrom(staticPaths: string[]) { const immutable = IMMUTABLE_PREFIXES.some((prefix) => pathname.startsWith(prefix) ); + // A request without Accept-Encoding technically accepts any coding (RFC + // 9110), but in practice it's curl or a bot that can't decode brotli. + const compress = context.request.headers.has("accept-encoding"); for (const path of staticPaths) { try { // send() picks a prebuilt .br or .gz sibling when the browser accepts it. await send(context, pathname, { root: path, + brotli: compress, + gzip: compress, ...(immutable && { maxage: ONE_YEAR_MS, immutable: true }), }); context.response.headers.append("Vary", "Accept-Encoding"); diff --git a/api/routes/files.ts b/api/routes/files.ts index 4f6e4a29..afb5949b 100644 --- a/api/routes/files.ts +++ b/api/routes/files.ts @@ -105,12 +105,19 @@ router.get("/:dumpId", async (ctx) => { return; } - const start = match[1] - ? parseInt(match[1], 10) - : total - parseInt(match[2], 10); - const end = match[2] - ? Math.min(parseInt(match[2], 10), total - 1) - : total - 1; + // "bytes=a-b", "bytes=a-" (to the end) or "bytes=-n" (the last n bytes). + let start: number; + let end: number; + if (match[1]) { + start = parseInt(match[1], 10); + end = match[2] ? Math.min(parseInt(match[2], 10), total - 1) : total - 1; + } else { + const suffix = parseInt(match[2], 10); + start = Math.max(total - suffix, 0); + end = total - 1; + // "bytes=-" and "bytes=-0" name no bytes at all. + if (!(suffix > 0)) start = total; + } if (start > end || start >= total) { ctx.response.status = 416; diff --git a/api/services/category-service.ts b/api/services/category-service.ts index 75336a7b..dbe47274 100644 --- a/api/services/category-service.ts +++ b/api/services/category-service.ts @@ -182,13 +182,11 @@ export function deleteCategory(id: string): void { } /** - * Replace the set of categories a dump belongs to. Unknown category ids are - * rejected. An empty list clears all category assignments. + * Throws a 400 unless every id names an existing category. Callers that write + * anything else first (a dump row, an uploaded file) run this before those + * writes, so a bad id can't leave them half-done. */ -export function setDumpCategories( - dumpId: string, - categoryIds: string[], -): void { +export function assertCategoriesExist(categoryIds: string[]): void { const unique = [...new Set(categoryIds)]; if (unique.length > 0) { @@ -204,6 +202,18 @@ export function setDumpCategories( ); } } +} + +/** + * Replace the set of categories a dump belongs to. Unknown category ids are + * rejected. An empty list clears all category assignments. + */ +export function setDumpCategories( + dumpId: string, + categoryIds: string[], +): void { + assertCategoriesExist(categoryIds); + const unique = [...new Set(categoryIds)]; db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId); const insert = db.prepare( diff --git a/api/services/dump-service.ts b/api/services/dump-service.ts index 32fd2079..fc6d5284 100644 --- a/api/services/dump-service.ts +++ b/api/services/dump-service.ts @@ -40,7 +40,11 @@ import { } from "../config.ts"; import { linkAttachments } from "./attachment-service.ts"; import { relinkBacklinks } from "./backlink-service.ts"; -import { attachCategoryIds, setDumpCategories } from "./category-service.ts"; +import { + assertCategoriesExist, + attachCategoryIds, + setDumpCategories, +} from "./category-service.ts"; function isAllowedMime(mime: string): boolean { return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) || @@ -81,6 +85,7 @@ export async function createUrlDump( if (!isValidHttpUrl(request.url)) { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL"); } + assertCategoriesExist(request.categoryIds ?? []); const dumpId = crypto.randomUUID(); const createdAt = new Date(); @@ -162,6 +167,7 @@ export async function createFileDump( "File too large (max 50 MB)", ); } + assertCategoriesExist(categoryIds); const dumpId = crypto.randomUUID(); const createdAt = new Date(); @@ -374,6 +380,9 @@ export async function updateDump( request: UpdateDumpRequest, ): Promise { const dump = fetchDump(dumpId); + if (request.categoryIds !== undefined) { + assertCategoriesExist(request.categoryIds); + } const now = new Date(); @@ -534,6 +543,7 @@ export async function replaceFileDump( if (dump.kind !== "file") { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump"); } + if (categoryIds !== undefined) assertCategoriesExist(categoryIds); const data = new Uint8Array(await file.arrayBuffer()); const filePath = `${DUMPS_DIR}/${dumpId}`; diff --git a/api/services/playlist-service.ts b/api/services/playlist-service.ts index 18bb905f..e4d0e32f 100644 --- a/api/services/playlist-service.ts +++ b/api/services/playlist-service.ts @@ -342,6 +342,7 @@ export function reorderPlaylist( const newSet = new Set(dumpIds); if ( + dumpIds.length !== newSet.size || currentSet.size !== newSet.size || !currentIds.every((id) => newSet.has(id)) ) { diff --git a/api/test/auth.test.ts b/api/test/auth.test.ts new file mode 100644 index 00000000..0d6f9acf --- /dev/null +++ b/api/test/auth.test.ts @@ -0,0 +1,458 @@ +// Tokens, passwords and the checks that turn a token into a user. +import { count, createUser, db, get, PASSWORD, post, test } from "./helpers.ts"; +import { + assert, + assertEquals, + assertFalse, + assertNotEquals, +} from "@std/assert"; +import { SignJWT } from "@panva/jose"; +import { + createJWT, + createMediaToken, + createPasswordResetToken, + hashPassword, + verifyJWT, + verifyMediaToken, + verifyPassword, + verifyPasswordResetToken, +} from "../lib/jwt.ts"; +import { authenticate, authenticateMedia } from "../lib/auth.ts"; + +const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret"); + +Deno.test("hashPassword/verifyPassword round-trip with a random salt", async () => { + const a = await hashPassword("hunter22"); + const b = await hashPassword("hunter22"); + assertNotEquals(a, b); + assert(await verifyPassword("hunter22", a)); + assertFalse(await verifyPassword("hunter23", a)); + // A malformed stored hash never verifies. + assertFalse(await verifyPassword("hunter22", "not-a-hash")); + assertFalse(await verifyPassword("hunter22", "")); +}); + +Deno.test("session, media and reset tokens don't stand in for each other", async () => { + const userId = crypto.randomUUID(); + const session = await createJWT({ userId, username: "u", role: "user" }); + const media = await createMediaToken(userId); + const reset = await createPasswordResetToken(userId); + + assertEquals((await verifyJWT(session))?.userId, userId); + assertEquals(await verifyJWT(media), null); + assertEquals(await verifyJWT(reset), null); + + assertEquals((await verifyMediaToken(media))?.userId, userId); + assertEquals(await verifyMediaToken(session), null); + assertEquals(await verifyMediaToken(reset), null); + + assertEquals((await verifyPasswordResetToken(reset))?.userId, userId); + assertEquals(await verifyPasswordResetToken(session), null); + assertEquals(await verifyPasswordResetToken(media), null); +}); + +Deno.test("verifyJWT: refuses a bad signature, an expired token, garbage", async () => { + const forged = await new SignJWT({ + userId: "x", + username: "x", + role: "admin", + }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(new TextEncoder().encode("some-other-secret")); + assertEquals(await verifyJWT(forged), null); + + const expired = await new SignJWT({ + userId: "x", + username: "x", + role: "user", + }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime(Math.floor(Date.now() / 1000) - 10) + .sign(KEY); + assertEquals(await verifyJWT(expired), null); + + const badRole = await new SignJWT({ userId: "x", username: "x", role: "god" }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("1h") + .sign(KEY); + assertEquals(await verifyJWT(badRole), null); + + assertEquals(await verifyJWT("not.a.jwt"), null); +}); + +test("authenticate: the role comes from the database, not the token", async () => { + const user = await createUser({ role: "user" }); + db.prepare(`UPDATE users SET role = 'admin' WHERE id = ?;`).run(user.id); + assertEquals((await authenticate(user.token))?.role, "admin"); +}); + +test("authenticate: a deleted user's token is refused", async () => { + const user = await createUser(); + db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id); + assertEquals(await authenticate(user.token), null); + assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); +}); + +test("authenticate: tokens issued before tokens_valid_after are revoked", async () => { + const user = await createUser(); + const future = new Date(Date.now() + 60_000).toISOString(); + db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run( + future, + user.id, + ); + assertEquals(await authenticate(user.token), null); + assertEquals(await authenticateMedia(user.mediaToken), null); + // A token issued at the cutoff second or later passes. + const past = new Date(Date.now() - 60_000).toISOString(); + db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run( + past, + user.id, + ); + assertEquals((await authenticate(user.token))?.userId, user.id); +}); + +test("authenticateMedia: takes a media token or a legacy session token", async () => { + const user = await createUser(); + assertEquals(await authenticateMedia(user.mediaToken), user.id); + assertEquals(await authenticateMedia(user.token), user.id); + assertEquals(await authenticateMedia("garbage"), null); +}); + +test("optional auth: a missing or placeholder token is anonymous, a bad one is 401", async () => { + const owner = await createUser(); + // A public listing that reads optional auth. + const path = `/api/users/${owner.username}/dumps`; + assertEquals((await get(path)).status, 200); + for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) { + assertEquals( + (await get(path, { headers: { Authorization: placeholder } })).status, + 200, + placeholder, + ); + } + assertEquals( + (await get(path, { headers: { Authorization: "Basic abc" } })).status, + 200, + ); + const bad = await get(path, { headers: { Authorization: "Bearer nope" } }); + assertEquals(bad.status, 401); + assertEquals(bad.body.error.code, "UNAUTHORIZED"); +}); + +test("required auth: missing, malformed and forged tokens are 401", async () => { + assertEquals((await get("/api/users/me")).status, 401); + assertEquals( + (await get("/api/users/me", { headers: { Authorization: "Token x" } })) + .status, + 401, + ); + const media = (await createUser()).mediaToken; + assertEquals( + (await get("/api/users/me", { + headers: { Authorization: `Bearer ${media}` }, + })).status, + 401, + ); +}); + +// ── Login and register ─────────────────────────────────────────────────────── + +test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => { + const user = await createUser(); + const ok = await post("/api/users/login", { + json: { username: user.username, password: PASSWORD }, + }); + assertEquals(ok.status, 200); + assertEquals(ok.body.data.user.id, user.id); + assertEquals(ok.body.data.user.passwordHash, undefined); + assertEquals((await verifyJWT(ok.body.data.token))?.userId, user.id); + assertEquals( + (await verifyMediaToken(ok.body.data.mediaToken))?.userId, + user.id, + ); + + const wrong = await post("/api/users/login", { + json: { username: user.username, password: "nope" }, + }); + const unknown = await post("/api/users/login", { + json: { username: "no_such_user_here", password: "nope" }, + }); + assertEquals(wrong.status, 401); + assertEquals(unknown.status, 401); + assertEquals(wrong.body.error.message, unknown.body.error.message); + + assertEquals( + (await post("/api/users/login", { json: { username: user.username } })) + .status, + 400, + ); +}); + +test("POST /api/users/login: rate limited per account, case-insensitively", async () => { + const user = await createUser(); + const statuses: number[] = []; + for (let i = 0; i < 11; i++) { + const username = i % 2 ? user.username.toUpperCase() : user.username; + statuses.push( + (await post("/api/users/login", { + json: { username, password: "wrong" }, + })).status, + ); + } + assertEquals(statuses.slice(0, 10), Array(10).fill(401)); + assertEquals(statuses[10], 429); + // Even the right password is held back now. + assertEquals( + (await post("/api/users/login", { + json: { username: user.username, password: PASSWORD }, + })).status, + 429, + ); +}); + +function invite(inviterId: string, createdAt = new Date()): string { + const token = crypto.randomUUID(); + db.prepare( + `INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`, + ).run(token, inviterId, createdAt.toISOString()); + return token; +} + +test("POST /api/users/register: an invite opens one account", async () => { + const inviter = await createUser(); + const inviteToken = invite(inviter.id); + const res = await post("/api/users/register", { + json: { + username: "newbie_reg", + password: "longenough", + email: "newbie@example.test", + inviteToken, + }, + }); + assertEquals(res.status, 201); + assertEquals(res.body.data.user.username, "newbie_reg"); + assertEquals(res.body.data.user.role, "user"); + assertEquals(res.body.data.user.passwordHash, undefined); + assert(res.body.data.token); + const row = db.prepare( + `SELECT invited_by FROM users WHERE username = 'newbie_reg';`, + ).get() as { invited_by: string }; + assertEquals(row.invited_by, inviter.id); + + const again = await post("/api/users/register", { + json: { + username: "second_reg", + password: "longenough", + email: "second@example.test", + inviteToken, + }, + }); + assertEquals(again.status, 409); +}); + +test("POST /api/users/register: validation and unknown invites", async () => { + const inviter = await createUser(); + const inviteToken = invite(inviter.id); + const base = { + username: "valid_name", + password: "longenough", + email: "v@example.test", + inviteToken, + }; + const cases: [Record, RegExp][] = [ + [{ ...base, email: "nope" }, /email/], + [{ ...base, username: "has space" }, /Username/], + [{ ...base, username: "x".repeat(33) }, /Username/], + [{ ...base, password: "short" }, /at least 8/], + [{ ...base, password: "x".repeat(129) }, /at most 128/], + [{ ...base, inviteToken: 42 }, /Invalid request/], + ]; + for (const [json, message] of cases) { + const res = await post("/api/users/register", { json }); + assertEquals(res.status, 400, JSON.stringify(json)); + assert(message.test(res.body.error.message), res.body.error.message); + } + const unknown = await post("/api/users/register", { + json: { ...base, inviteToken: "made-up" }, + }); + assertEquals(unknown.status, 404); + // None of the failures used the invite up. + assertEquals( + count(`SELECT COUNT(*) as n FROM invites WHERE used_at IS NULL;`), + 1, + ); +}); + +test("POST /api/users/register: a taken name or email hands the invite back", async () => { + const inviter = await createUser(); + const taken = await createUser(); + const inviteToken = invite(inviter.id); + const byName = await post("/api/users/register", { + json: { + username: taken.username, + password: "longenough", + email: "fresh@example.test", + inviteToken, + }, + }); + assertEquals(byName.status, 400); + const byEmail = await post("/api/users/register", { + json: { + username: "fresh_name", + password: "longenough", + email: taken.email.toUpperCase(), + inviteToken, + }, + }); + assertEquals(byEmail.status, 409); + assertEquals( + count( + `SELECT COUNT(*) as n FROM invites WHERE token = ? AND used_at IS NULL;`, + inviteToken, + ), + 1, + ); +}); + +test("POST /api/users/register: an invite older than 7 days is refused", async () => { + const inviter = await createUser(); + const inviteToken = invite( + inviter.id, + new Date(Date.now() - 8 * 24 * 60 * 60 * 1000), + ); + const res = await post("/api/users/register", { + json: { + username: "late_comer", + password: "longenough", + email: "late@example.test", + inviteToken, + }, + }); + assertEquals(res.status, 404); +}); + +test("POST /api/users/register: parallel registrations can't share an invite", async () => { + const inviter = await createUser(); + const inviteToken = invite(inviter.id); + const results = await Promise.all( + [1, 2, 3].map((i) => + post("/api/users/register", { + json: { + username: `racer_${i}`, + password: "longenough", + email: `racer${i}@example.test`, + inviteToken, + }, + }) + ), + ); + assertEquals(results.filter((r) => r.status === 201).length, 1); + assertEquals( + count(`SELECT COUNT(*) as n FROM users WHERE invited_by = ?;`, inviter.id), + 1, + ); +}); + +// ── Password changes ───────────────────────────────────────────────────────── + +test("change-password: needs the current one, revokes old tokens, returns new ones", async () => { + const user = await createUser(); + const wrong = await post("/api/users/me/change-password", { + auth: user.auth, + json: { currentPassword: "nope", newPassword: "brand-new-pass" }, + }); + assertEquals(wrong.status, 401); + const short = await post("/api/users/me/change-password", { + auth: user.auth, + json: { currentPassword: PASSWORD, newPassword: "short" }, + }); + assertEquals(short.status, 400); + + // iat has one-second resolution: make sure the old token is from an earlier + // second than the change. + await new Promise((r) => setTimeout(r, 1100)); + const ok = await post("/api/users/me/change-password", { + auth: user.auth, + json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" }, + }); + assertEquals(ok.status, 200); + assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401); + assertEquals(await authenticateMedia(user.mediaToken), null); + const fresh = { Authorization: `Bearer ${ok.body.data.token}` }; + assertEquals((await get("/api/users/me", { auth: fresh })).status, 200); + assertEquals(await authenticateMedia(ok.body.data.mediaToken), user.id); + + const login = await post("/api/users/login", { + json: { username: user.username, password: "brand-new-pass" }, + }); + assertEquals(login.status, 200); +}); + +test("request-password-reset: always 200, stores nothing without SMTP", async () => { + const user = await createUser(); + for (const email of [user.email, "nobody@example.test", "", undefined]) { + const res = await post("/api/users/request-password-reset", { + json: { email }, + }); + assertEquals(res.status, 200); + } + assertEquals(count(`SELECT COUNT(*) as n FROM password_reset_tokens;`), 0); +}); + +test("reset-password: a stored token works once", async () => { + const user = await createUser(); + const token = await createPasswordResetToken(user.id); + db.prepare( + `INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`, + ).run(token, user.id, new Date(Date.now() + 3600_000).toISOString()); + + const short = await post("/api/users/reset-password", { + json: { token, newPassword: "short" }, + }); + assertEquals(short.status, 400); + + const ok = await post("/api/users/reset-password", { + json: { token, newPassword: "reset-password-1" }, + }); + assertEquals(ok.status, 200); + const login = await post("/api/users/login", { + json: { username: user.username, password: "reset-password-1" }, + }); + assertEquals(login.status, 200); + + const reuse = await post("/api/users/reset-password", { + json: { token, newPassword: "reset-password-2" }, + }); + assertEquals(reuse.status, 409); +}); + +test("reset-password: an unstored, forged or wrong-purpose token fails", async () => { + const user = await createUser(); + const unstored = await createPasswordResetToken(user.id); + assertEquals( + (await post("/api/users/reset-password", { + json: { token: unstored, newPassword: "whatever-123" }, + })).status, + 404, + ); + for (const token of ["garbage", user.token, user.mediaToken]) { + assertEquals( + (await post("/api/users/reset-password", { + json: { token, newPassword: "whatever-123" }, + })).status, + 400, + ); + } + assertEquals( + (await post("/api/users/reset-password", { json: { token: 1 } })).status, + 400, + ); +}); + +test("GET /api/users/me/media-token issues a media token for the caller", async () => { + const user = await createUser(); + const res = await get("/api/users/me/media-token", { auth: user.auth }); + assertEquals(res.status, 200); + assertEquals(await authenticateMedia(res.body.data.mediaToken), user.id); +}); diff --git a/api/test/canonical-url.test.ts b/api/test/canonical-url.test.ts new file mode 100644 index 00000000..32bd478f --- /dev/null +++ b/api/test/canonical-url.test.ts @@ -0,0 +1,104 @@ +import "./setup.ts"; +import { assertEquals } from "@std/assert"; +import { canonicalizeUrl } from "../lib/canonical-url.ts"; +import { up as up0013 } from "../db/migrations/0013_dump_url_canonical.ts"; +import { DatabaseSync } from "node:sqlite"; + +Deno.test("canonicalizeUrl: scheme, www and trailing slash collapse", () => { + const want = "https://example.com/a"; + for ( + const url of [ + "http://example.com/a", + "https://www.example.com/a", + "https://example.com/a/", + "https://EXAMPLE.com/a//", + ] + ) { + assertEquals(canonicalizeUrl(url), want, url); + } + assertEquals(canonicalizeUrl("https://example.com/"), "https://example.com"); +}); + +Deno.test("canonicalizeUrl: tracking parameters dropped, the rest sorted", () => { + assertEquals( + canonicalizeUrl( + "https://example.com/p?utm_source=x&b=2&fbclid=y&a=1&UTM_Medium=z&si=q", + ), + "https://example.com/p?a=1&b=2", + ); + assertEquals( + canonicalizeUrl("https://example.com/p?b=1&a=2"), + canonicalizeUrl("https://example.com/p?a=2&b=1"), + ); +}); + +Deno.test("canonicalizeUrl: section anchors dropped, hash routes kept", () => { + assertEquals( + canonicalizeUrl("https://example.com/doc#intro"), + "https://example.com/doc", + ); + assertEquals( + canonicalizeUrl("https://app.example.com/#/inbox"), + "https://app.example.com#/inbox", + ); + assertEquals( + canonicalizeUrl("https://app.example.com/#!/inbox"), + "https://app.example.com#!/inbox", + ); +}); + +Deno.test("canonicalizeUrl: every YouTube video shape is one key", () => { + const want = "https://youtube.com/watch?v=dQw4w9WgXcQ"; + for ( + const url of [ + "https://youtu.be/dQw4w9WgXcQ", + "https://youtu.be/dQw4w9WgXcQ?t=42", + "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL1&t=1m", + "https://m.youtube.com/watch?v=dQw4w9WgXcQ", + "https://music.youtube.com/watch?v=dQw4w9WgXcQ", + "https://www.youtube.com/embed/dQw4w9WgXcQ", + "https://youtube.com/shorts/dQw4w9WgXcQ", + "https://youtube.com/live/dQw4w9WgXcQ", + "https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ", + ] + ) { + assertEquals(canonicalizeUrl(url), want, url); + } + assertEquals( + canonicalizeUrl("https://www.youtube.com/playlist?list=PLx&si=abc"), + "https://youtube.com/playlist?list=PLx", + ); +}); + +Deno.test("canonicalizeUrl: non-http and unparseable input", () => { + assertEquals(canonicalizeUrl("ftp://example.com/a"), null); + assertEquals(canonicalizeUrl("javascript:alert(1)"), null); + assertEquals(canonicalizeUrl("not a url"), null); + assertEquals(canonicalizeUrl(""), null); +}); + +// Migration 0013 carries a frozen copy of the canonicalizer to backfill old +// rows. Until the rules change on purpose (with a new backfill migration), the +// two must agree, or rows from before and after the migration won't match. +Deno.test("migration 0013 backfills the same keys canonicalizeUrl computes", () => { + const urls = [ + "http://www.example.com/a/?utm_source=x&b=2&a=1#top", + "https://youtu.be/abc123?t=5", + "https://www.youtube.com/playlist?list=PLx", + "https://app.example.com/#/route", + ]; + const db = new DatabaseSync(":memory:"); + db.exec(`CREATE TABLE dumps (id TEXT PRIMARY KEY, url TEXT);`); + urls.forEach((url, i) => + db.prepare(`INSERT INTO dumps (id, url) VALUES (?, ?);`).run(String(i), url) + ); + up0013(db); + up0013(db); // idempotent + const rows = db.prepare(`SELECT id, url_canonical FROM dumps ORDER BY id;`) + .all() as { id: string; url_canonical: string }[]; + assertEquals( + rows.map((r) => r.url_canonical), + urls.map(canonicalizeUrl), + ); + db.close(); +}); diff --git a/api/test/categories-search.test.ts b/api/test/categories-search.test.ts new file mode 100644 index 00000000..f6631876 --- /dev/null +++ b/api/test/categories-search.test.ts @@ -0,0 +1,186 @@ +import { + createUser, + db, + del, + get, + insertCategory, + insertDump, + insertPlaylist, + post, + put, + test, +} from "./helpers.ts"; +import { assertEquals, assertStringIncludes } from "@std/assert"; + +test("categories: listed by position, then slug; found by slug", async () => { + insertCategory({ slug: "zeta", position: 0 }); + insertCategory({ slug: "alpha", position: 1 }); + insertCategory({ slug: "beta", position: 0 }); + const list = await get("/api/categories"); + assertEquals(list.body.data.map((c: { slug: string }) => c.slug), [ + "beta", + "zeta", + "alpha", + ]); + assertEquals((await get("/api/categories/alpha")).body.data.slug, "alpha"); + assertEquals((await get("/api/categories/nope")).status, 404); +}); + +test("categories: only category:manage may create, update or delete", async () => { + const admin = await createUser({ role: "admin" }); + const mod = await createUser({ role: "moderator" }); + const json = { slug: "music", name: "Music" }; + assertEquals((await post("/api/categories", { json })).status, 401); + assertEquals( + (await post("/api/categories", { auth: mod.auth, json })).status, + 403, + ); + const created = await post("/api/categories", { auth: admin.auth, json }); + assertEquals(created.status, 201); + const id = created.body.data.id; + assertEquals( + (await put(`/api/categories/${id}`, { + auth: mod.auth, + json: { name: "X" }, + })).status, + 403, + ); + assertEquals( + (await del(`/api/categories/${id}`, { auth: mod.auth })).status, + 403, + ); + assertEquals( + (await del(`/api/categories/${id}`, { auth: admin.auth })).status, + 200, + ); + assertEquals( + (await del(`/api/categories/${id}`, { auth: admin.auth })).status, + 404, + ); +}); + +test("categories: slug and name rules, and friendly conflicts", async () => { + const admin = await createUser({ role: "admin" }); + const create = (json: unknown) => + post("/api/categories", { auth: admin.auth, json }); + for ( + const [json, message] of [ + [{ slug: "Bad Slug", name: "x" }, "lowercase"], + [{ slug: "users", name: "x" }, "reserved"], + [{ slug: "a".repeat(51), name: "x" }, "at most 50"], + [{ slug: "ok", name: " " }, "Name is required"], + [{ slug: "ok", name: "x".repeat(51) }, "at most 50"], + ] as const + ) { + const res = await create(json); + assertEquals(res.status, 400, JSON.stringify(json)); + assertStringIncludes(res.body.error.message, message); + } + assertEquals((await create({ slug: "ok" })).status, 400); + + const first = await create({ slug: "music", name: " Music " }); + assertEquals(first.body.data.name, "Music"); + const dupSlug = await create({ slug: "music", name: "Other" }); + assertEquals(dupSlug.status, 409); + assertStringIncludes(dupSlug.body.error.message, "Slug"); + const dupName = await create({ slug: "other", name: "Music" }); + assertEquals(dupName.status, 409); + assertStringIncludes(dupName.body.error.message, "Name"); + + const second = await create({ slug: "films", name: "Films" }); + const rename = await put(`/api/categories/${second.body.data.id}`, { + auth: admin.auth, + json: { slug: "music" }, + }); + assertEquals(rename.status, 409); + const ok = await put(`/api/categories/${second.body.data.id}`, { + auth: admin.auth, + json: { name: "Movies", position: 3 }, + }); + assertEquals(ok.body.data.slug, "films"); + assertEquals(ok.body.data.name, "Movies"); + assertEquals(ok.body.data.position, 3); +}); + +test("GET /api/categories/:slug/dumps: members only, private for the owner", async () => { + const owner = await createUser(); + const cat = insertCategory({ slug: "music" }); + const inCat = insertDump({ userId: owner.id, title: "in" }); + const priv = insertDump({ + userId: owner.id, + title: "private", + isPrivate: true, + }); + insertDump({ userId: owner.id, title: "out" }); + for (const d of [inCat, priv]) { + db.prepare( + `INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`, + ) + .run(d.id, cat); + } + const anon = await get("/api/categories/music/dumps"); + assertEquals(anon.body.data.items.map((d: { title: string }) => d.title), [ + "in", + ]); + assertEquals(anon.body.data.items[0].categoryIds, [cat]); + assertEquals( + (await get("/api/categories/music/dumps", { auth: owner.auth })).body.data + .total, + 2, + ); + assertEquals((await get("/api/categories/nope/dumps")).status, 404); + + // Deleting the category unlinks its dumps without deleting them. + const admin = await createUser({ role: "admin" }); + await del(`/api/categories/${cat}`, { auth: admin.auth }); + assertEquals((await get(`/api/dumps/${inCat.id}`)).body.data.categoryIds, []); +}); + +test("GET /api/search: dumps, users and playlists, privacy-filtered", async () => { + const owner = await createUser({ username: "searcher_a" }); + const other = await createUser(); + db.prepare( + `UPDATE users SET description = 'loves banana bread' WHERE id = ?;`, + ).run(other.id); + insertDump({ userId: owner.id, title: "Banana split" }); + insertDump({ + userId: owner.id, + title: "x", + comment: "a banana in the comment", + }); + insertDump({ + userId: owner.id, + title: "y", + richContent: { type: "generic", url: "u", description: "banana page" }, + }); + insertDump({ userId: owner.id, title: "Secret banana", isPrivate: true }); + insertDump({ userId: owner.id, title: "Apple" }); + insertPlaylist({ userId: owner.id, title: "Banana hits" }); + insertPlaylist({ + userId: owner.id, + title: "Private bananas", + isPublic: false, + }); + + const anon = (await get("/api/search?q=banana")).body.data; + assertEquals(anon.dumps.total, 3); + assertEquals(anon.users.map((u: { id: string }) => u.id), [other.id]); + assertEquals(anon.playlists.map((p: { title: string }) => p.title), [ + "Banana hits", + ]); + + const mine = + (await get("/api/search?q=banana", { auth: owner.auth })).body.data; + assertEquals(mine.dumps.total, 4); + assertEquals(mine.playlists.length, 2); + + const paged = (await get("/api/search?q=banana&limit=2")).body.data.dumps; + assertEquals(paged.items.length, 2); + assertEquals(paged.hasMore, true); + + assertEquals((await get("/api/search?q=%20%20")).body.data, { + dumps: { items: [], total: 0, hasMore: false }, + users: [], + playlists: [], + }); +}); diff --git a/api/test/chat.test.ts b/api/test/chat.test.ts new file mode 100644 index 00000000..14cd3382 --- /dev/null +++ b/api/test/chat.test.ts @@ -0,0 +1,183 @@ +// Chat history, cursors and read markers. The live side (send, edit, delete +// over the socket) is covered in ws.test.ts. +import { createUser, db, get, test } from "./helpers.ts"; +import { assertEquals, assertThrows } from "@std/assert"; +import { + createChatMessage, + deleteChatMessage, + getChatMessagesAfter, + getChatReadState, + getRecentChatMessages, + markChatRead, + updateChatMessage, +} from "../services/chat-service.ts"; +import { APIException } from "../model/interfaces.ts"; + +function insertMessage( + userId: string, + body: string, + createdAt: string, + id?: string, +) { + const messageId = id ?? crypto.randomUUID(); + db.prepare( + `INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`, + ).run(messageId, userId, body, createdAt); + return messageId; +} + +const T = (n: number) => new Date(Date.UTC(2024, 0, 1, 0, 0, n)).toISOString(); + +test("createChatMessage: trims, bounds, and drops a dangling reply target", async () => { + const user = await createUser(); + const m = createChatMessage(user.id, " hello "); + assertEquals(m.body, "hello"); + assertEquals(m.authorUsername, user.username); + assertThrows(() => createChatMessage(user.id, " "), APIException, "empty"); + assertThrows( + () => createChatMessage(user.id, "x".repeat(2001)), + APIException, + "2000", + ); + createChatMessage(user.id, "x".repeat(2000)); + + const dangling = createChatMessage(user.id, "re", crypto.randomUUID()); + assertEquals(dangling.replyToId, undefined); +}); + +test("replies carry a snippet of their target, which goes when it's deleted", async () => { + const user = await createUser(); + const long = createChatMessage(user.id, "a".repeat(500)); + const reply = createChatMessage(user.id, "re", long.id); + assertEquals(reply.replyToId, long.id); + assertEquals(reply.replyToAuthor, user.username); + assertEquals(reply.replyToBody, "a".repeat(300)); + deleteChatMessage(long.id, user.id, false); + const [after] = getRecentChatMessages(); + assertEquals(after.replyToId, long.id); + assertEquals(after.replyToBody, undefined); +}); + +test("edit and delete: the author or a moderator", async () => { + const author = await createUser(); + const other = await createUser(); + const m = createChatMessage(author.id, "orig"); + assertThrows( + () => updateChatMessage(m.id, "x", other.id, false), + APIException, + "Not authorized", + ); + assertThrows( + () => deleteChatMessage(m.id, other.id, false), + APIException, + "Not authorized", + ); + assertThrows( + () => updateChatMessage(m.id, " ", author.id, false), + APIException, + "empty", + ); + const edited = updateChatMessage(m.id, " edited ", author.id, false); + assertEquals(edited.body, "edited"); + assertEquals(typeof edited.updatedAt?.getTime(), "number"); + assertEquals(updateChatMessage(m.id, "mod", other.id, true).body, "mod"); + deleteChatMessage(m.id, other.id, true); + assertThrows( + () => deleteChatMessage(m.id, author.id, false), + APIException, + "not found", + ); +}); + +test("history pages: newest page in ascending order, older pages by cursor", async () => { + const user = await createUser(); + // Two messages share a timestamp; the id breaks the tie. + const ids = [ + insertMessage(user.id, "m1", T(1)), + insertMessage(user.id, "m2a", T(2), "00000000-0000-4000-8000-00000000000a"), + insertMessage(user.id, "m2b", T(2), "00000000-0000-4000-8000-00000000000b"), + insertMessage(user.id, "m3", T(3)), + ]; + const bodies = (ms: { body: string }[]) => ms.map((m) => m.body); + assertEquals(bodies(getRecentChatMessages()), ["m1", "m2a", "m2b", "m3"]); + assertEquals(bodies(getRecentChatMessages(2)), ["m2b", "m3"]); + // Older than m2b: the page boundary falls between the two tied messages. + assertEquals( + bodies(getRecentChatMessages(10, { createdAt: T(2), id: ids[2] })), + ["m1", "m2a"], + ); + // No id: everything strictly before the timestamp. + assertEquals(bodies(getRecentChatMessages(10, { createdAt: T(2) })), ["m1"]); + + assertEquals( + bodies(getChatMessagesAfter({ createdAt: T(2), id: ids[1] })), + ["m2b", "m3"], + ); + // No id: inclusive of the timestamp. + assertEquals(bodies(getChatMessagesAfter({ createdAt: T(2) })), [ + "m2a", + "m2b", + "m3", + ]); + assertEquals(bodies(getChatMessagesAfter({ createdAt: T(0) }, 1)), ["m1"]); + // Limits are clamped to 1..100. + assertEquals(getRecentChatMessages(0).length, 1); + assertEquals(getRecentChatMessages(-5).length, 1); +}); + +test("GET /api/chat: members only, with limit and both cursors", async () => { + const user = await createUser(); + const ids = [1, 2, 3].map((n) => insertMessage(user.id, `m${n}`, T(n))); + assertEquals((await get("/api/chat")).status, 401); + const bodies = async (q: string) => + (await get(`/api/chat${q}`, { auth: user.auth })).body.data.map( + (m: { body: string }) => m.body, + ); + assertEquals(await bodies(""), ["m1", "m2", "m3"]); + assertEquals(await bodies("?limit=1"), ["m3"]); + assertEquals(await bodies("?limit=abc"), ["m1", "m2", "m3"]); + assertEquals(await bodies(`?before=${T(3)}&beforeId=${ids[2]}`), [ + "m1", + "m2", + ]); + assertEquals(await bodies(`?after=${T(1)}&afterId=${ids[0]}`), ["m2", "m3"]); +}); + +test("read state: unread counts others' messages since the marker", async () => { + const me = await createUser({ createdAt: T(0) }); + const other = await createUser(); + insertMessage(other.id, "a", T(1)); + insertMessage(me.id, "mine", T(2)); + insertMessage(other.id, "b", T(3)); + // No marker yet: everything since the account was created. + assertEquals(getChatReadState(me.id), { readAt: T(0), unread: 2 }); + assertEquals(markChatRead(me.id, T(1)), { readAt: T(1), unread: 1 }); + // Never backward. + assertEquals(markChatRead(me.id, T(0)).readAt, T(1)); + // Never past the newest message. + assertEquals(markChatRead(me.id, "2999-01-01T00:00:00Z"), { + readAt: T(3), + unread: 0, + }); + assertThrows( + () => markChatRead(me.id, "not a date"), + APIException, + "Invalid", + ); + assertThrows( + () => getChatReadState(crypto.randomUUID()), + APIException, + "not found", + ); +}); + +test("read state: with no messages at all the marker can't be moved", async () => { + const me = await createUser({ createdAt: T(0) }); + assertEquals(markChatRead(me.id, "2999-01-01T00:00:00Z"), { + readAt: T(0), + unread: 0, + }); + const other = await createUser(); + insertMessage(other.id, "later", T(5)); + assertEquals(getChatReadState(me.id).unread, 1); +}); diff --git a/api/test/comments.test.ts b/api/test/comments.test.ts new file mode 100644 index 00000000..94710382 --- /dev/null +++ b/api/test/comments.test.ts @@ -0,0 +1,260 @@ +import { + count, + createUser, + db, + del, + get, + insertComment, + insertDump, + patch, + post, + test, +} from "./helpers.ts"; +import { assertEquals, assertExists } from "@std/assert"; +import { + castCommentLike, + removeCommentLike, +} from "../services/comment-like-service.ts"; +import { APIException } from "../model/interfaces.ts"; + +test("comments: post, list in order, count on the dump", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id } = insertDump({ userId: owner.id }); + const first = await post(`/api/dumps/${id}/comments`, { + auth: other.auth, + json: { body: " first " }, + }); + assertEquals(first.status, 201); + assertEquals(first.body.data.body, "first"); + assertEquals(first.body.data.authorUsername, other.username); + const reply = await post(`/api/dumps/${id}/comments`, { + auth: owner.auth, + json: { body: "reply", parentId: first.body.data.id }, + }); + assertEquals(reply.body.data.parentId, first.body.data.id); + const list = await get(`/api/dumps/${id}/comments`); + assertEquals(list.body.data.map((c: { body: string }) => c.body), [ + "first", + "reply", + ]); + assertEquals((await get(`/api/dumps/${id}`)).body.data.commentCount, 2); +}); + +test("comments: validation, auth and unknown dumps", async () => { + const user = await createUser(); + const { id } = insertDump({ userId: user.id }); + for (const body of ["", " ", "x".repeat(5001)]) { + assertEquals( + (await post(`/api/dumps/${id}/comments`, { + auth: user.auth, + json: { body }, + })) + .status, + 400, + ); + } + assertEquals( + (await post(`/api/dumps/${id}/comments`, { json: { body: "hi" } })).status, + 401, + ); + assertEquals( + (await post(`/api/dumps/nope/comments`, { + auth: user.auth, + json: { body: "hi" }, + })) + .status, + 404, + ); +}); + +test("comments on a private dump: owner only", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id } = insertDump({ userId: owner.id, isPrivate: true }); + assertEquals( + (await post(`/api/dumps/${id}/comments`, { + auth: other.auth, + json: { body: "x" }, + })) + .status, + 404, + ); + assertEquals( + (await post(`/api/dumps/${id}/comments`, { + auth: owner.auth, + json: { body: "x" }, + })) + .status, + 201, + ); + assertEquals( + (await get(`/api/dumps/${id}/comments`, { auth: other.auth })).status, + 404, + ); +}); + +test("comments: the dump's owner is notified, the commenter isn't", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id } = insertDump({ userId: owner.id, title: "Mine" }); + await post(`/api/dumps/${id}/comments`, { + auth: owner.auth, + json: { body: "self" }, + }); + assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0); + const c = await post(`/api/dumps/${id}/comments`, { + auth: other.auth, + json: { body: "nice" }, + }); + const note = db.prepare(`SELECT user_id, type, data FROM notifications;`) + .get() as { + user_id: string; + type: string; + data: string; + }; + assertEquals(note.user_id, owner.id); + assertEquals(note.type, "dump_commented"); + assertEquals(JSON.parse(note.data).commentId, c.body.data.id); + assertEquals(JSON.parse(note.data).dumpTitle, "Mine"); +}); + +test("PATCH /api/comments/:id: author and moderator edit, others can't", async () => { + const author = await createUser(); + const other = await createUser(); + const mod = await createUser({ role: "moderator" }); + const { id: dumpId } = insertDump({ userId: author.id }); + const commentId = insertComment({ dumpId, userId: author.id, body: "orig" }); + assertEquals( + (await patch(`/api/comments/${commentId}`, { + auth: other.auth, + json: { body: "x" }, + })) + .status, + 401, + ); + const edited = await patch(`/api/comments/${commentId}`, { + auth: author.auth, + json: { body: " edited " }, + }); + assertEquals(edited.status, 200); + assertEquals(edited.body.data.body, "edited"); + assertExists(edited.body.data.updatedAt); + assertEquals( + (await patch(`/api/comments/${commentId}`, { + auth: mod.auth, + json: { body: "m" }, + })) + .status, + 200, + ); + assertEquals( + (await patch(`/api/comments/${commentId}`, { + auth: author.auth, + json: { body: "" }, + })) + .status, + 400, + ); + assertEquals( + (await patch(`/api/comments/${crypto.randomUUID()}`, { + auth: author.auth, + json: { body: "x" }, + })).status, + 404, + ); +}); + +test("DELETE /api/comments/:id soft-deletes, and a deleted comment can't be edited", async () => { + const author = await createUser(); + const other = await createUser(); + const { id: dumpId } = insertDump({ userId: author.id }); + const commentId = insertComment({ + dumpId, + userId: author.id, + body: "gone soon", + }); + assertEquals( + (await del(`/api/comments/${commentId}`, { auth: other.auth })).status, + 401, + ); + assertEquals( + (await del(`/api/comments/${commentId}`, { auth: author.auth })).status, + 200, + ); + const [c] = (await get(`/api/dumps/${dumpId}/comments`)).body.data; + assertEquals(c.deleted, true); + assertEquals(c.body, ""); + assertEquals((await get(`/api/dumps/${dumpId}`)).body.data.commentCount, 0); + assertEquals( + (await patch(`/api/comments/${commentId}`, { + auth: author.auth, + json: { body: "back" }, + })) + .status, + 400, + ); +}); + +test("comment likes: count, conflicts, likers list and notifications", async () => { + const author = await createUser(); + const liker = await createUser(); + const { id: dumpId } = insertDump({ userId: author.id }); + const commentId = insertComment({ dumpId, userId: author.id }); + + assertEquals(castCommentLike(commentId, liker.id).likeCount, 1); + assertEquals(castCommentLike(commentId, author.id).likeCount, 2); + try { + castCommentLike(commentId, liker.id); + throw new Error("expected a conflict"); + } catch (err) { + assertEquals((err as APIException).status, 409); + } + // Only the other user's like notifies the author. + assertEquals( + count( + `SELECT COUNT(*) as n FROM notifications WHERE type = 'comment_liked';`, + ), + 1, + ); + + const likers = await get(`/api/comments/${commentId}/likers`); + assertEquals(likers.body.data.total, 2); + assertEquals(likers.body.data.items[0].email, undefined); + + assertEquals(removeCommentLike(commentId, liker.id).likeCount, 1); + try { + removeCommentLike(commentId, liker.id); + throw new Error("expected a 404"); + } catch (err) { + assertEquals((err as APIException).status, 404); + } + assertEquals( + (db.prepare(`SELECT like_count FROM comments WHERE id = ?;`).get( + commentId, + ) as { + like_count: number; + }).like_count, + 1, + ); +}); + +test("comment likes on a private dump: owner only", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id: dumpId } = insertDump({ userId: owner.id, isPrivate: true }); + const commentId = insertComment({ dumpId, userId: owner.id }); + try { + castCommentLike(commentId, other.id); + throw new Error("expected a 404"); + } catch (err) { + assertEquals((err as APIException).status, 404); + } + assertEquals(castCommentLike(commentId, owner.id).isPrivate, true); + assertEquals((await get(`/api/comments/${commentId}/likers`)).status, 404); + assertEquals( + (await get(`/api/comments/${commentId}/likers`, { auth: owner.auth })) + .status, + 200, + ); +}); diff --git a/api/test/dumps.test.ts b/api/test/dumps.test.ts new file mode 100644 index 00000000..0c89740f --- /dev/null +++ b/api/test/dumps.test.ts @@ -0,0 +1,747 @@ +import { + count, + createUser, + db, + del, + formWithFile, + get, + html, + insertCategory, + insertComment, + insertDump, + insertPlaylist, + PNG, + post, + put, + stubNetwork, + test, + TEST_DIR, +} from "./helpers.ts"; +import { + assert, + assertEquals, + assertExists, + assertMatch, + assertNotEquals, +} from "@std/assert"; + +const PAGE = html(`Scraped title + `); + +function readFile(path: string): Uint8Array | null { + try { + return Deno.readFileSync(`${TEST_DIR}/uploads/${path}`); + } catch { + return null; + } +} + +// ── Create ─────────────────────────────────────────────────────────────────── + +test("POST /api/dumps: a URL dump takes its title from the page", async () => { + const user = await createUser(); + using _ = stubNetwork(() => PAGE.clone()); + const res = await post("/api/dumps", { + auth: user.auth, + json: { url: "https://example.com/a?utm_source=x", comment: "look" }, + }); + assertEquals(res.status, 201); + const dump = res.body.data; + assertEquals(dump.kind, "url"); + assertEquals(dump.title, "Scraped title"); + assertMatch(dump.slug, /^scraped-title-[0-9a-f]{8}$/); + assertEquals(dump.userId, user.id); + assertEquals(dump.isPrivate, false); + assertEquals(dump.richContent.thumbnailUrl, "https://example.com/cover.jpg"); + const row = db.prepare(`SELECT url, url_canonical FROM dumps WHERE id = ?;`) + .get(dump.id) as { url: string; url_canonical: string }; + // The exact URL is kept; the canonical key drops the tracking parameter. + assertEquals(row.url, "https://example.com/a?utm_source=x"); + assertEquals(row.url_canonical, "https://example.com/a"); +}); + +test("POST /api/dumps: a typed title wins; an unreachable page still posts", async () => { + const user = await createUser(); + { + using _ = stubNetwork(() => PAGE.clone()); + const res = await post("/api/dumps", { + auth: user.auth, + json: { url: "https://example.com/b", title: " My title " }, + }); + assertEquals(res.body.data.title, "My title"); + } + { + using _ = stubNetwork(() => { + throw new TypeError("offline"); + }); + const res = await post("/api/dumps", { + auth: user.auth, + json: { url: "https://www.offline.example/c" }, + }); + assertEquals(res.status, 201); + assertEquals(res.body.data.title, "offline.example"); + } +}); + +test("POST /api/dumps: refuses bad URLs and bodies, and anonymous callers", async () => { + const user = await createUser(); + using net = stubNetwork(() => PAGE.clone()); + for ( + const url of ["http://localhost/", "http://192.168.0.1/", "ftp://x/", "no"] + ) { + const res = await post("/api/dumps", { auth: user.auth, json: { url } }); + assertEquals(res.status, 400, url); + } + assertEquals( + (await post("/api/dumps", { auth: user.auth, json: { nope: 1 } })).status, + 400, + ); + assertEquals( + (await post("/api/dumps", { json: { url: "https://example.com" } })).status, + 401, + ); + assertEquals(net.calls.length, 0); + assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0); +}); + +test("POST /api/dumps: categories are stored and deduplicated", async () => { + const user = await createUser(); + const music = insertCategory({ slug: "music" }); + using _ = stubNetwork(() => PAGE.clone()); + const res = await post("/api/dumps", { + auth: user.auth, + json: { url: "https://example.com/d", categoryIds: [music, music] }, + }); + assertEquals(res.status, 201); + assertEquals(res.body.data.categoryIds, [music]); + assertEquals( + (await get(`/api/dumps/${res.body.data.id}`)).body.data.categoryIds, + [music], + ); +}); + +test("POST /api/dumps: an unknown category is refused and nothing is left behind", async () => { + const user = await createUser(); + using _ = stubNetwork(() => PAGE.clone()); + const res = await post("/api/dumps", { + auth: user.auth, + json: { url: "https://example.com/e", categoryIds: ["no-such-category"] }, + }); + assertEquals(res.status, 400); + assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0); +}); + +test("POST /api/dumps: a file dump is stored and served back", async () => { + const user = await createUser(); + const res = await post("/api/dumps", { + auth: user.auth, + form: formWithFile(PNG, "pixel.png", "image/png", { comment: "tiny" }), + }); + assertEquals(res.status, 201); + const dump = res.body.data; + assertEquals(dump.kind, "file"); + assertEquals(dump.title, "pixel.png"); + assertEquals(dump.fileMime, "image/png"); + assertEquals(dump.fileSize, PNG.byteLength); + assertEquals(dump.comment, "tiny"); + assertEquals(readFile(`dumps/${dump.id}`), PNG); + const file = await get(`/api/files/${dump.id}`); + assertEquals(file.status, 200); + assertEquals(file.bytes, PNG); +}); + +test("POST /api/dumps: file type, size of text fields, and a missing file", async () => { + const user = await createUser(); + const exe = await post("/api/dumps", { + auth: user.auth, + form: formWithFile(PNG, "x.exe", "application/x-msdownload"), + }); + assertEquals(exe.status, 400); + const longTitle = await post("/api/dumps", { + auth: user.auth, + form: formWithFile(PNG, "x.png", "image/png", { title: "x".repeat(201) }), + }); + assertEquals(longTitle.status, 400); + const longComment = await post("/api/dumps", { + auth: user.auth, + form: formWithFile(PNG, "x.png", "image/png", { + comment: "x".repeat(5001), + }), + }); + assertEquals(longComment.status, 400); + const form = new FormData(); + form.set("comment", "no file"); + assertEquals( + (await post("/api/dumps", { auth: user.auth, form })).status, + 400, + ); + assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0); +}); + +test("POST /api/dumps: a file dump with an unknown category leaves nothing behind", async () => { + const user = await createUser(); + const res = await post("/api/dumps", { + auth: user.auth, + form: formWithFile(PNG, "x.png", "image/png", { + categoryIds: JSON.stringify(["nope"]), + }), + }); + assertEquals(res.status, 400); + assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0); +}); + +test("POST /api/dumps: followers are notified of public dumps, not private ones", async () => { + const poster = await createUser(); + const follower = await createUser(); + db.prepare( + `INSERT INTO follows (id, follower_id, followed_user_id, created_at) VALUES (?, ?, ?, ?);`, + ).run(crypto.randomUUID(), follower.id, poster.id, new Date().toISOString()); + using _ = stubNetwork(() => PAGE.clone()); + await post("/api/dumps", { + auth: poster.auth, + json: { url: "https://example.com/private", isPrivate: true }, + }); + assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0); + const pub = await post("/api/dumps", { + auth: poster.auth, + json: { url: "https://example.com/public" }, + }); + const notes = db.prepare( + `SELECT user_id, type, data FROM notifications;`, + ).all() as { user_id: string; type: string; data: string }[]; + assertEquals(notes.length, 1); + assertEquals(notes[0].user_id, follower.id); + assertEquals(notes[0].type, "user_dump_posted"); + assertEquals(JSON.parse(notes[0].data).dumpId, pub.body.data.id); +}); + +// ── Read ───────────────────────────────────────────────────────────────────── + +test("GET /api/dumps/:id works by id and by slug", async () => { + const user = await createUser(); + const { id, slug } = insertDump({ userId: user.id, title: "Hello" }); + assertEquals((await get(`/api/dumps/${id}`)).body.data.id, id); + assertEquals((await get(`/api/dumps/${slug}`)).body.data.id, id); + assertEquals((await get(`/api/dumps/nope`)).status, 404); + assertEquals((await get(`/api/dumps/${crypto.randomUUID()}`)).status, 404); +}); + +test("GET /api/dumps/:id: a private dump exists only for its owner", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id } = insertDump({ userId: owner.id, isPrivate: true }); + assertEquals( + (await get(`/api/dumps/${id}`, { auth: owner.auth })).status, + 200, + ); + assertEquals( + (await get(`/api/dumps/${id}`, { auth: other.auth })).status, + 404, + ); + assertEquals((await get(`/api/dumps/${id}`)).status, 404); + for (const sub of ["voters", "related", "playlists", "comments"]) { + assertEquals((await get(`/api/dumps/${id}/${sub}`)).status, 404, sub); + assertEquals( + (await get(`/api/dumps/${id}/${sub}`, { auth: owner.auth })).status, + 200, + sub, + ); + } +}); + +test("GET /api/dumps: newest first, paginated, private ones for the owner only", async () => { + const owner = await createUser(); + const other = await createUser(); + for (let i = 0; i < 5; i++) { + insertDump({ + userId: owner.id, + title: `d${i}`, + createdAt: new Date(Date.UTC(2024, 0, i + 1)).toISOString(), + }); + } + insertDump({ + userId: owner.id, + title: "secret", + isPrivate: true, + createdAt: new Date(Date.UTC(2024, 0, 10)).toISOString(), + }); + const page1 = await get("/api/dumps?limit=2"); + assertEquals(page1.body.data.items.map((d: { title: string }) => d.title), [ + "d4", + "d3", + ]); + assertEquals(page1.body.data.total, 5); + assertEquals(page1.body.data.hasMore, true); + const page3 = await get("/api/dumps?limit=2&page=3"); + assertEquals(page3.body.data.items.length, 1); + assertEquals(page3.body.data.hasMore, false); + + const mine = await get("/api/dumps?limit=1", { auth: owner.auth }); + assertEquals(mine.body.data.items[0].title, "secret"); + assertEquals(mine.body.data.total, 6); + assertEquals( + (await get("/api/dumps", { auth: other.auth })).body.data.total, + 5, + ); +}); + +test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", async () => { + const owner = await createUser(); + const other = await createUser(); + insertDump({ + userId: owner.id, + title: "public", + url: "https://youtu.be/abc", + }); + insertDump({ + userId: owner.id, + title: "private", + url: "https://www.youtube.com/watch?v=abc", + isPrivate: true, + }); + const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10"); + const anon = await get(`/api/dumps/by-url?url=${q}`); + assertEquals(anon.body.data.map((d: { title: string }) => d.title), [ + "public", + ]); + assertEquals(anon.body.data[0].username, owner.username); + const mine = await get(`/api/dumps/by-url?url=${q}`, { auth: owner.auth }); + assertEquals(mine.body.data.length, 2); + assertEquals( + (await get(`/api/dumps/by-url?url=${q}`, { auth: other.auth })).body.data + .length, + 1, + ); + assertEquals((await get(`/api/dumps/by-url?url=garbage`)).body.data, []); +}); + +test("GET /api/dumps/:id/voters lists voters without private fields", async () => { + const owner = await createUser(); + const voter = await createUser(); + const { id } = insertDump({ userId: owner.id }); + db.prepare( + `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, + ).run(id, voter.id, new Date().toISOString()); + const res = await get(`/api/dumps/${id}/voters`); + assertEquals(res.body.data.total, 1); + assertEquals(res.body.data.items[0].username, voter.username); + assertEquals(res.body.data.items[0].email, undefined); + assertEquals(res.body.data.items[0].passwordHash, undefined); +}); + +test("GET /api/dumps/:id/related: links in both directions, hiding private ones", async () => { + const user = await createUser(); + const other = await createUser(); + const target = insertDump({ + userId: user.id, + title: "target", + url: "https://x.test/t", + }); + const secret = insertDump({ + userId: other.id, + title: "secret", + isPrivate: true, + }); + using _ = stubNetwork(() => PAGE.clone()); + // A new dump whose comment links the target by its URL and the secret dump by + // its page — and itself, which is ignored. + const src = await post("/api/dumps", { + auth: other.auth, + json: { + url: "https://example.com/src", + comment: + `see https://x.test/t and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`, + }, + }); + const srcId = src.body.data.id; + const fromSrc = await get(`/api/dumps/${srcId}/related`); + assertEquals(fromSrc.body.data.map((d: { title: string }) => d.title), [ + "target", + ]); + const fromSrcAsOwner = await get(`/api/dumps/${srcId}/related`, { + auth: other.auth, + }); + assertEquals(fromSrcAsOwner.body.data.length, 2); + const toTarget = await get(`/api/dumps/${target.id}/related`); + assertEquals(toTarget.body.data.map((d: { id: string }) => d.id), [srcId]); + + // Editing the comment away removes the links. + await put(`/api/dumps/${srcId}`, { auth: other.auth, json: { comment: "" } }); + assertEquals((await get(`/api/dumps/${target.id}/related`)).body.data, []); +}); + +test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private ones", async () => { + const user = await createUser(); + const other = await createUser(); + const { id } = insertDump({ userId: user.id }); + const pub = insertPlaylist({ userId: user.id, title: "Public" }); + const priv = insertPlaylist({ + userId: user.id, + title: "Private", + isPublic: false, + }); + for (const p of [pub, priv]) { + db.prepare( + `INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?);`, + ).run(p.id, id, new Date().toISOString()); + } + assertEquals( + (await get(`/api/dumps/${id}/playlists`, { auth: other.auth })).body.data + .map((p: { title: string }) => p.title), + ["Public"], + ); + assertEquals( + (await get(`/api/dumps/${id}/playlists`, { auth: user.auth })).body.data + .length, + 2, + ); +}); + +// ── Update ─────────────────────────────────────────────────────────────────── + +test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async () => { + const owner = await createUser(); + const other = await createUser(); + const mod = await createUser({ role: "moderator" }); + const { id, slug } = insertDump({ userId: owner.id, title: "Old" }); + assertEquals( + (await put(`/api/dumps/${id}`, { auth: other.auth, json: { title: "X" } })) + .status, + 403, + ); + assertEquals( + (await put(`/api/dumps/${id}`, { json: { title: "X" } })).status, + 401, + ); + const res = await put(`/api/dumps/${slug}`, { + auth: owner.auth, + json: { title: "New title", comment: "hello" }, + }); + assertEquals(res.status, 200); + assertEquals(res.body.data.title, "New title"); + assertMatch(res.body.data.slug, /^new-title-/); + assertNotEquals(res.body.data.slug, slug); + assertExists(res.body.data.updatedAt); + const byMod = await put(`/api/dumps/${id}`, { + auth: mod.auth, + json: { title: "Moderated" }, + }); + assertEquals(byMod.status, 200); + assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Moderated"); +}); + +test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", async () => { + const owner = await createUser(); + const { id } = insertDump({ + userId: owner.id, + title: "Old", + url: "https://a.test/", + }); + assertEquals( + (await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: " " } })) + .status, + 422, + ); + assertEquals( + (await put(`/api/dumps/${id}`, { + auth: owner.auth, + json: { url: "http://127.0.0.1/" }, + })).status, + 400, + ); + using net = stubNetwork(() => PAGE.clone()); + // Same URL: no fetch, title kept. + await put(`/api/dumps/${id}`, { auth: owner.auth, json: { comment: "c" } }); + assertEquals(net.calls.length, 0); + const moved = await put(`/api/dumps/${id}`, { + auth: owner.auth, + json: { url: "https://b.test/?utm_medium=z" }, + }); + assertEquals(moved.body.data.title, "Scraped title"); + assertEquals(net.calls.length, 1); + const row = db.prepare(`SELECT url_canonical FROM dumps WHERE id = ?;`).get( + id, + ) as { + url_canonical: string; + }; + assertEquals(row.url_canonical, "https://b.test"); +}); + +test("PUT /api/dumps/:id: categories are replaced, or kept when omitted", async () => { + const owner = await createUser(); + const a = insertCategory({ slug: "a" }); + const b = insertCategory({ slug: "b" }); + const { id } = insertDump({ userId: owner.id }); + await put(`/api/dumps/${id}`, { + auth: owner.auth, + json: { categoryIds: [a] }, + }); + const kept = await put(`/api/dumps/${id}`, { + auth: owner.auth, + json: { title: "t" }, + }); + assertEquals(kept.body.data.categoryIds, [a]); + const replaced = await put(`/api/dumps/${id}`, { + auth: owner.auth, + json: { categoryIds: [b] }, + }); + assertEquals(replaced.body.data.categoryIds, [b]); + const cleared = await put(`/api/dumps/${id}`, { + auth: owner.auth, + json: { categoryIds: [] }, + }); + assertEquals(cleared.body.data.categoryIds, []); +}); + +test("PUT /api/dumps/:id: an unknown category changes nothing", async () => { + const owner = await createUser(); + const a = insertCategory({ slug: "a" }); + const { id } = insertDump({ userId: owner.id, title: "Before" }); + await put(`/api/dumps/${id}`, { + auth: owner.auth, + json: { categoryIds: [a] }, + }); + const res = await put(`/api/dumps/${id}`, { + auth: owner.auth, + json: { title: "After", categoryIds: [a, "nope"] }, + }); + assertEquals(res.status, 400); + const dump = (await get(`/api/dumps/${id}`)).body.data; + assertEquals(dump.title, "Before"); + assertEquals(dump.categoryIds, [a]); +}); + +test("PUT /api/dumps/:id/file: an unknown category keeps the old file", async () => { + const owner = await createUser(); + const original = new TextEncoder().encode("original"); + const { id } = insertDump({ + userId: owner.id, + title: "orig", + fileName: "a.txt", + fileData: original, + }); + const res = await put(`/api/dumps/${id}/file`, { + auth: owner.auth, + form: formWithFile(PNG, "b.png", "image/png", { + categoryIds: JSON.stringify(["nope"]), + }), + }); + assertEquals(res.status, 400); + assertEquals(readFile(`dumps/${id}`), original); + assertEquals((await get(`/api/dumps/${id}`)).body.data.fileName, "a.txt"); +}); + +test("PUT /api/dumps/:id: going private hides the dump from everyone else", async () => { + const owner = await createUser(); + const { id } = insertDump({ userId: owner.id }); + await put(`/api/dumps/${id}`, { + auth: owner.auth, + json: { isPrivate: true }, + }); + assertEquals((await get(`/api/dumps/${id}`)).status, 404); + await put(`/api/dumps/${id}`, { + auth: owner.auth, + json: { isPrivate: false }, + }); + assertEquals((await get(`/api/dumps/${id}`)).status, 200); +}); + +test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async () => { + const owner = await createUser(); + const { id } = insertDump({ + userId: owner.id, + title: "file", + fileName: "a.txt", + }); + const res = await put(`/api/dumps/${id}`, { + auth: owner.auth, + json: { + title: "Renamed", + comment: "c", + isPrivate: true, + url: "https://ignored.test", + }, + }); + assertEquals(res.status, 200); + assertEquals(res.body.data.title, "Renamed"); + assertEquals(res.body.data.isPrivate, true); + assertEquals(res.body.data.url, undefined); +}); + +test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id } = insertDump({ + userId: owner.id, + title: "orig", + fileName: "a.txt", + }); + const form = () => + formWithFile(PNG, "b.png", "image/png", { title: "Replaced" }); + assertEquals( + (await put(`/api/dumps/${id}/file`, { auth: other.auth, form: form() })) + .status, + 403, + ); + const res = await put(`/api/dumps/${id}/file`, { + auth: owner.auth, + form: form(), + }); + assertEquals(res.status, 200); + assertEquals(res.body.data.fileName, "b.png"); + assertEquals(res.body.data.title, "Replaced"); + assertEquals(readFile(`dumps/${id}`), PNG); + + const urlDump = insertDump({ userId: owner.id }); + assertEquals( + (await put(`/api/dumps/${urlDump.id}/file`, { + auth: owner.auth, + form: form(), + })) + .status, + 400, + ); +}); + +test("custom thumbnails: set, served, removed", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id } = insertDump({ userId: owner.id }); + assertEquals( + (await put(`/api/dumps/${id}/thumbnail`, { + auth: other.auth, + form: formWithFile(PNG), + })).status, + 403, + ); + assertEquals( + (await put(`/api/dumps/${id}/thumbnail`, { + auth: owner.auth, + form: formWithFile( + new TextEncoder().encode(""), + "x.svg", + "image/svg+xml", + ), + })).status, + 400, + ); + const set = await put(`/api/dumps/${id}/thumbnail`, { + auth: owner.auth, + form: formWithFile(PNG), + }); + assertEquals(set.status, 200); + assertEquals(set.body.data.thumbnailMime, "image/png"); + const thumb = await get(`/api/thumbnails/${id}`); + assertEquals(thumb.status, 200); + assertEquals(thumb.headers.get("content-type"), "image/png"); + assertEquals(thumb.bytes, PNG); + + const removed = await del(`/api/dumps/${id}/thumbnail`, { auth: owner.auth }); + assertEquals(removed.body.data.thumbnailMime, undefined); + assertEquals(readFile(`thumbnails/${id}-custom`), null); + // A URL dump without its own thumbnail has none to serve. + assertEquals((await get(`/api/thumbnails/${id}`)).status, 404); +}); + +test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on failure", async () => { + const owner = await createUser(); + const { id } = insertDump({ + userId: owner.id, + title: "Kept", + url: "https://example.com/r", + richContent: { + type: "generic", + url: "https://example.com/r", + title: "Kept", + }, + }); + { + using _ = stubNetwork(() => { + throw new TypeError("down"); + }); + const res = await post(`/api/dumps/${id}/refresh-metadata`, { + auth: owner.auth, + }); + assertEquals(res.status, 502); + assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Kept"); + } + { + using _ = stubNetwork(() => PAGE.clone()); + const res = await post(`/api/dumps/${id}/refresh-metadata`, { + auth: owner.auth, + }); + assertEquals(res.status, 200); + assertEquals(res.body.data.title, "Scraped title"); + } + const file = insertDump({ userId: owner.id, fileName: "f.txt" }); + assertEquals( + (await post(`/api/dumps/${file.id}/refresh-metadata`, { auth: owner.auth })) + .status, + 400, + ); +}); + +// ── Delete ─────────────────────────────────────────────────────────────────── + +test("DELETE /api/dumps/:id removes the dump, its file and what hangs off it", async () => { + const owner = await createUser(); + const other = await createUser(); + const mod = await createUser({ role: "moderator" }); + const { id } = insertDump({ userId: owner.id, fileName: "f.txt" }); + insertComment({ dumpId: id, userId: other.id }); + db.prepare( + `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, + ) + .run(id, other.id, new Date().toISOString()); + + assertEquals( + (await del(`/api/dumps/${id}`, { auth: other.auth })).status, + 403, + ); + assertEquals( + (await del(`/api/dumps/${id}`, { auth: owner.auth })).status, + 200, + ); + assertEquals((await get(`/api/dumps/${id}`)).status, 404); + assertEquals(readFile(`dumps/${id}`), null); + assertEquals(count(`SELECT COUNT(*) as n FROM comments;`), 0); + assertEquals(count(`SELECT COUNT(*) as n FROM votes;`), 0); + + const second = insertDump({ userId: owner.id }); + assertEquals( + (await del(`/api/dumps/${second.id}`, { auth: mod.auth })).status, + 200, + ); + assertEquals( + (await del(`/api/dumps/${second.id}`, { auth: mod.auth })).status, + 404, + ); +}); + +test("GET /api/users/:username/dumps and /votes respect privacy", async () => { + const owner = await createUser(); + const other = await createUser(); + const pub = insertDump({ userId: owner.id, title: "pub" }); + const priv = insertDump({ userId: owner.id, title: "priv", isPrivate: true }); + for (const d of [pub, priv]) { + db.prepare( + `INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`, + ) + .run(d.id, owner.id, new Date().toISOString()); + } + for (const sub of ["dumps", "votes"]) { + const path = `/api/users/${owner.username}/${sub}`; + assertEquals((await get(path)).body.data.total, 1, sub); + assertEquals( + (await get(path, { auth: other.auth })).body.data.total, + 1, + sub, + ); + assertEquals( + (await get(path, { auth: owner.auth })).body.data.total, + 2, + sub, + ); + } + assert((await get(`/api/users/nobody_at_all/dumps`)).status === 404); +}); diff --git a/api/test/helpers.ts b/api/test/helpers.ts new file mode 100644 index 00000000..c50aa475 --- /dev/null +++ b/api/test/helpers.ts @@ -0,0 +1,367 @@ +// 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 "../model/db.ts"; +import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts"; +import { makeSlug } from "../lib/slugify.ts"; +import { canonicalizeUrl } from "../lib/canonical-url.ts"; +import type { Role } from "../model/interfaces.ts"; + +export { app, db, TEST_DIR }; + +// ── Database ───────────────────────────────────────────────────────────────── + +const TABLES = [ + "dump_backlinks", + "dump_categories", + "playlist_dumps", + "comment_likes", + "comments", + "votes", + "follows", + "notifications", + "password_reset_tokens", + "invites", + "attachments", + "chat_messages", + "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; +} + +/** + * Inserts a dump row directly, skipping the metadata fetch the create route + * does. Returns its id and slug. + */ +export function insertDump( + opts: InsertDumpOptions, +): { id: string; slug: string } { + const id = crypto.randomUUID(); + const title = opts.title ?? "A dump"; + const slug = makeSlug(title, id); + 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 }; +} + +export function insertPlaylist( + opts: { userId: string; title?: string; isPublic?: boolean }, +): { id: string; slug: string } { + const id = crypto.randomUUID(); + const title = opts.title ?? "A playlist"; + const slug = makeSlug(title, id); + 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 }; +} + +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; + // deno-lint-ignore no-explicit-any + body: any; + bytes: Uint8Array; +} + +/** + * Sends a request through the full middleware stack in-process — no socket. + * `json` sends a JSON body, `form` a multipart one. + */ +export async function request( + method: string, + path: string, + opts: { + auth?: { Authorization: string }; + json?: unknown; + form?: FormData; + headers?: Record; + } = {}, +): 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: unknown = null; + if ((res.headers.get("content-type") ?? "").includes("json") && text) { + parsed = JSON.parse(text); + } + return { status: res.status, headers: res.headers, body: parsed, bytes }; +} + +export const get = (path: string, opts?: Parameters[2]) => + request("GET", path, opts); +export const post = (path: string, opts?: Parameters[2]) => + request("POST", path, opts); +export const put = (path: string, opts?: Parameters[2]) => + request("PUT", path, opts); +export const patch = (path: string, opts?: Parameters[2]) => + request("PATCH", path, opts); +export const del = (path: string, opts?: Parameters[2]) => + 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[] = []; + 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(); + }, + }; +} + +export function html(body: string, status = 200): Response { + return new Response(body, { + status, + headers: { "content-type": "text/html; charset=utf-8" }, + }); +} diff --git a/api/test/lib.test.ts b/api/test/lib.test.ts new file mode 100644 index 00000000..157942cd --- /dev/null +++ b/api/test/lib.test.ts @@ -0,0 +1,162 @@ +// Small pure helpers: slugs, pagination, permissions, rate limiting, image +// sniffing, self-links and the favicon codepoint. +import "./setup.ts"; +import { + assert, + assertEquals, + assertFalse, + assertMatch, + assertThrows, +} from "@std/assert"; +import { FakeTime } from "@std/testing/time"; +import { makeSlug, slugify, UUID_RE } from "../lib/slugify.ts"; +import { + isReservedSlug, + isSlugAvailable, + isValidSlugFormat, +} from "../lib/reserved-slugs.ts"; +import { parsePagination } from "../lib/pagination.ts"; +import { can, hasPermission, ROLE_PERMISSIONS } from "../lib/permissions.ts"; +import { takeToken } from "../lib/rate-limit.ts"; +import { detectImageMime, validateImageUpload } from "../lib/upload.ts"; +import { isSelfUrl } from "../lib/self-url.ts"; +import { emojiToCodepoint } from "../lib/site-icons.ts"; +import { APIException } from "../model/interfaces.ts"; +import { PNG } from "./helpers.ts"; + +Deno.test("slugify: lowercases, strips accents, dashes the rest", () => { + assertEquals(slugify("Hello, World!"), "hello-world"); + assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris"); + assertEquals(slugify(" --already--dashed-- "), "already-dashed"); + assertEquals(slugify("日本語"), "untitled"); + assertEquals(slugify(""), "untitled"); + assertEquals(slugify("x".repeat(100)).length, 60); +}); + +Deno.test("makeSlug: suffixes the first 8 characters of the id", () => { + const id = "0123abcd-0000-4000-8000-000000000000"; + assertEquals(makeSlug("My Song", id), "my-song-0123abcd"); + assert(UUID_RE.test(id)); + assertFalse(UUID_RE.test("my-song-0123abcd")); +}); + +Deno.test("category slugs: format and reserved words", () => { + assert(isValidSlugFormat("music")); + assert(isValidSlugFormat("lo-fi-2")); + for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) { + assertFalse(isValidSlugFormat(bad), bad); + } + assert(isReservedSlug("dumps")); + assert(isReservedSlug("api")); + assertFalse(isReservedSlug("music")); + assert(isSlugAvailable("music")); + assertFalse(isSlugAvailable("users")); + assertFalse(isSlugAvailable("Nope")); +}); + +Deno.test("parsePagination: defaults, bounds and garbage", () => { + const p = (q: string, def?: number) => + parsePagination(new URLSearchParams(q), def); + assertEquals(p(""), { page: 1, limit: 20 }); + assertEquals(p("page=3&limit=5"), { page: 3, limit: 5 }); + assertEquals(p("page=0&limit=0"), { page: 1, limit: 20 }); + assertEquals(p("page=-4&limit=-1"), { page: 1, limit: 1 }); + assertEquals(p("limit=1000"), { page: 1, limit: 100 }); + assertEquals(p("page=abc&limit=xyz"), { page: 1, limit: 20 }); + assertEquals(p("", 7), { page: 1, limit: 7 }); +}); + +Deno.test("permissions: admin has all, moderator moderates, user nothing", () => { + for (const perm of ROLE_PERMISSIONS.admin) { + assert(hasPermission("admin", perm)); + } + assert(hasPermission("moderator", "dump:moderate")); + assert(hasPermission("moderator", "chat:moderate")); + assertFalse(hasPermission("moderator", "user:manage")); + assertFalse(hasPermission("moderator", "category:manage")); + assertEquals(ROLE_PERMISSIONS.user, []); + assert( + can({ userId: "x", username: "x", role: "admin", exp: 0 }, "user:manage"), + ); + assertFalse( + can({ userId: "x", username: "x", role: "user", exp: 0 }, "dump:moderate"), + ); +}); + +Deno.test("takeToken: bursts to capacity, then refills over time", () => { + using time = new FakeTime(); + const key = `test:${crypto.randomUUID()}`; + const limit = { capacity: 3, refill: 1 }; // one token a second + assert(takeToken(key, limit)); + assert(takeToken(key, limit)); + assert(takeToken(key, limit)); + assertFalse(takeToken(key, limit)); + time.tick(500); + assertFalse(takeToken(key, limit)); + time.tick(500); + assert(takeToken(key, limit)); + assertFalse(takeToken(key, limit)); + // Refill never exceeds capacity. + time.tick(60_000); + assert(takeToken(key, limit)); + assert(takeToken(key, limit)); + assert(takeToken(key, limit)); + assertFalse(takeToken(key, limit)); + // Keys are independent. + assert(takeToken(`${key}:other`, limit)); +}); + +Deno.test("detectImageMime: sniffs magic bytes, not names", () => { + assertEquals(detectImageMime(PNG), "image/png"); + assertEquals( + detectImageMime(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])), + "image/jpeg", + ); + assertEquals( + detectImageMime(new TextEncoder().encode("GIF89a")), + "image/gif", + ); + assertEquals( + detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")), + "image/webp", + ); + assertEquals( + detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WAVEfmt ")), + null, + ); + assertEquals( + detectImageMime(new TextEncoder().encode("")), + null, + ); + assertEquals(detectImageMime(new Uint8Array()), null); +}); + +Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () => { + assertEquals(validateImageUpload(PNG), "image/png"); + assertThrows( + () => validateImageUpload(new TextEncoder().encode("")), + APIException, + "not a recognised image", + ); + const big = new Uint8Array(5 * 1024 * 1024 + 1); + big.set(PNG); + assertThrows(() => validateImageUpload(big), APIException, "too large"); +}); + +Deno.test("isSelfUrl: this site's dump, user and playlist pages only", () => { + // GERBEUR_PUBLIC_URL is http://gerbeur.test in the suite. + assert(isSelfUrl(new URL("http://gerbeur.test/dumps/abc"))); + assert(isSelfUrl(new URL("https://GERBEUR.test/users/bob"))); + assert(isSelfUrl(new URL("http://gerbeur.test/playlists/p-1"))); + assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc/edit"))); + assertFalse(isSelfUrl(new URL("http://gerbeur.test/search"))); + assertFalse(isSelfUrl(new URL("http://elsewhere.test/users/bob"))); +}); + +Deno.test("emojiToCodepoint: Twemoji file naming", () => { + assertEquals(emojiToCodepoint("🚚"), "1f69a"); + // A lone variation selector is dropped… + assertEquals(emojiToCodepoint("❤️"), "2764"); + // …but kept inside a ZWJ sequence. + assertMatch(emojiToCodepoint("🏳️‍🌈"), /^1f3f3-fe0f-200d-1f308$/); +}); diff --git a/api/test/middleware.test.ts b/api/test/middleware.test.ts new file mode 100644 index 00000000..768e4fdc --- /dev/null +++ b/api/test/middleware.test.ts @@ -0,0 +1,255 @@ +// The app-wide middleware: error rendering, CORS, OG tags for crawlers, and +// static file serving with its cache rules. +import { + BASE_URL, + createUser, + get, + insertDump, + insertPlaylist, + request, + test, + TEST_DIR, +} from "./helpers.ts"; +import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert"; +import { Application } from "@oak/oak"; +import { errorMiddleware } from "../middleware/error.ts"; +import { ogMiddleware } from "../middleware/og.ts"; +import { routeStaticFilesFrom } from "../lib/static.ts"; +import { APIErrorCode, APIException } from "../model/interfaces.ts"; + +async function handle( + app: Application, + path: string, + headers: HeadersInit = {}, +) { + const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers })); + if (!res) throw new Error("no response"); + return { res, text: await res.text() }; +} + +Deno.test("errorMiddleware: APIExceptions keep their status, anything else is a bare 500", async () => { + const app = new Application(); + app.use(errorMiddleware); + app.use((ctx) => { + if (ctx.request.url.pathname === "/api-error") { + throw new APIException(APIErrorCode.NOT_FOUND, 404, "Nope"); + } + throw new Error("secret internals"); + }); + const known = await handle(app, "/api-error"); + assertEquals(known.res.status, 404); + assertEquals(JSON.parse(known.text), { + success: false, + error: { code: "NOT_FOUND", message: "Nope" }, + }); + const unknown = await handle(app, "/boom"); + assertEquals(unknown.res.status, 500); + assertEquals(JSON.parse(unknown.text).error, { + code: "SERVER_ERROR", + message: "Unexpected server error", + }); +}); + +test("CORS: allowed origins get the headers, others don't", async () => { + const allowed = await get("/api/categories", { + headers: { Origin: "http://allowed.test" }, + }); + assertEquals( + allowed.headers.get("access-control-allow-origin"), + "http://allowed.test", + ); + // The public URL is always allowed. + const self = await get("/api/categories", { headers: { Origin: BASE_URL } }); + assertEquals(self.headers.get("access-control-allow-origin"), BASE_URL); + const evil = await get("/api/categories", { + headers: { Origin: "http://evil.test" }, + }); + assertEquals(evil.status, 200); + assertEquals(evil.headers.get("access-control-allow-origin"), null); + + const preflight = await request("OPTIONS", "/api/dumps", { + headers: { + Origin: "http://allowed.test", + "Access-Control-Request-Method": "POST", + "Access-Control-Request-Headers": "authorization,content-type", + }, + }); + assertEquals(preflight.status, 204); + assertEquals( + preflight.headers.get("access-control-allow-origin"), + "http://allowed.test", + ); +}); + +test("GET /ws: refuses other origins, and plain requests from allowed ones", async () => { + assertEquals( + (await get("/ws", { headers: { Origin: "http://evil.test" } })).status, + 403, + ); + assertEquals((await get("/ws")).status, 403); + assertEquals( + (await get("/ws", { headers: { Origin: "http://allowed.test" } })).status, + 426, + ); +}); + +function ogApp() { + const app = new Application(); + app.use(ogMiddleware); + app.use((ctx) => { + ctx.response.body = "fallthrough"; + }); + return app; +} + +test("ogMiddleware: dump pages get escaped OG tags", async () => { + const user = await createUser(); + const dump = insertDump({ + userId: user.id, + title: `Tom & "Jerry" `, + ); + assertStringIncludes( + text, + `Tom & "Jerry" <script>`, + ); + assertStringIncludes( + text, + ``, + ); + assertStringIncludes( + text, + ``, + ); + assertStringIncludes(text, `content="summary_large_image"`); + assertStringIncludes( + text, + ``, + ); + // The index's own title is replaced, not duplicated. + assertEquals(text.match(//g)?.length, 1); + assertFalse(text.includes("__SITE_NAME__")); +}); + +test("ogMiddleware: no artwork falls back to the site icon as a small card", async () => { + const user = await createUser(); + const dump = insertDump({ userId: user.id, title: "Plain" }); + const { text } = await handle(ogApp(), `/dumps/${dump.id}`); + assertStringIncludes(text, `content="${BASE_URL}/apple-touch-icon.png"`); + assertStringIncludes(text, `<meta name="twitter:card" content="summary" />`); + const file = insertDump({ + userId: user.id, + fileName: "a.png", + fileMime: "image/png", + }); + assertStringIncludes( + (await handle(ogApp(), `/dumps/${file.id}`)).text, + `content="${BASE_URL}/api/files/${file.id}"`, + ); +}); + +test("ogMiddleware: users and public playlists; private things fall through", async () => { + const user = await createUser(); + const app = ogApp(); + assertStringIncludes( + (await handle(app, `/users/${user.username}/~/votes`)).text, + `<meta property="og:title" content="${user.username}" />`, + ); + const pub = insertPlaylist({ userId: user.id, title: "Open list" }); + assertStringIncludes( + (await handle(app, `/playlists/${pub.slug}`)).text, + "Open list", + ); + const priv = insertPlaylist({ + userId: user.id, + title: "Closed", + isPublic: false, + }); + assertEquals( + (await handle(app, `/playlists/${priv.slug}`)).text, + "fallthrough", + ); + const secret = insertDump({ + userId: user.id, + title: "Secret", + isPrivate: true, + }); + assertEquals((await handle(app, `/dumps/${secret.id}`)).text, "fallthrough"); + assertEquals((await handle(app, `/dumps/nope`)).text, "fallthrough"); + assertEquals( + (await handle(app, `/api/dumps/${secret.id}`)).text, + "fallthrough", + ); + assertEquals((await handle(app, `/favicon.ico`)).text, "fallthrough"); +}); + +Deno.test("routeStaticFilesFrom: index, SPA fallback, cache rules, precompressed files", async () => { + const root = `${TEST_DIR}/static-${crypto.randomUUID()}`; + await Deno.mkdir(`${root}/assets`, { recursive: true }); + await Deno.writeTextFile( + `${root}/index.html`, + `<html><head><title>__SITE_NAME____BANDCAMP_PLAYER__`, + ); + const js = "console.log('app');".repeat(50); + await Deno.writeTextFile(`${root}/assets/app-abc123.js`, js); + const br = new Uint8Array([1, 2, 3]); + await Deno.writeFile(`${root}/assets/app-abc123.js.br`, br); + await Deno.writeTextFile(`${root}/robots.txt`, "User-agent: *"); + + const app = new Application(); + app.use(routeStaticFilesFrom([root])); + + const index = await handle(app, "/"); + assertEquals(index.res.headers.get("cache-control"), "no-cache"); + assertEquals( + index.text, + "gerbeurembed", + ); + + const spa = await handle(app, "/users/someone"); + assertEquals(spa.res.status, 200); + assertEquals(spa.text, index.text); + + // No Accept-Encoding header: the plain file, not the .br sibling. + const asset = await handle(app, "/assets/app-abc123.js"); + assertEquals(asset.text, js); + assertEquals(asset.res.headers.get("content-encoding"), null); + assertStringIncludes(asset.res.headers.get("cache-control")!, "immutable"); + assertStringIncludes( + asset.res.headers.get("cache-control")!, + "max-age=31536000", + ); + assertStringIncludes(asset.res.headers.get("vary")!, "Accept-Encoding"); + + const compressed = await handle(app, "/assets/app-abc123.js", { + "Accept-Encoding": "br, gzip", + }); + assertEquals(compressed.res.headers.get("content-encoding"), "br"); + assertEquals(compressed.text, "\x01\x02\x03"); // the .br file's bytes + + const plain = await handle(app, "/robots.txt"); + assertEquals(plain.text, "User-agent: *"); + assertFalse( + (plain.res.headers.get("cache-control") ?? "").includes("immutable"), + ); + + // A chunk from an older build 404s rather than being answered with the index. + const stale = await handle(app, "/assets/app-old999.js"); + assertEquals(stale.res.status, 404); + assertFalse(stale.text.includes("")); + assertEquals( + (await handle(app, "/emojibase/17.0.0/missing.json")).res.status, + 404, + ); +}); diff --git a/api/test/migrations.test.ts b/api/test/migrations.test.ts new file mode 100644 index 00000000..5f9547f8 --- /dev/null +++ b/api/test/migrations.test.ts @@ -0,0 +1,182 @@ +// Migrations run against fresh databases (built from schema.sql) and old ones; +// each must be idempotent, and the data rewrites must touch only what they +// mean to. +import "./setup.ts"; +import { assertEquals } from "@std/assert"; +import { DatabaseSync } from "node:sqlite"; +import { runMigrations } from "../db/migrate.ts"; +import { up as up0004 } from "../db/migrations/0004_user_roles.ts"; +import { up as up0005 } from "../db/migrations/0005_drop_is_admin.ts"; +import { up as up0010 } from "../db/migrations/0010_youtube_embed_start.ts"; +import { up as up0011 } from "../db/migrations/0011_split_favicon_thumbnail.ts"; +import { up as up0012 } from "../db/migrations/0012_fix_favicon_reclassification.ts"; + +const SCHEMA = Deno.readTextFileSync( + new URL("../db/schema.sql", import.meta.url), +); + +function freshDb(): DatabaseSync { + const db = new DatabaseSync(":memory:"); + db.exec(SCHEMA); + return db; +} + +function schemaOf(db: DatabaseSync): string[] { + return (db.prepare( + `SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name != 'schema_migrations' ORDER BY name;`, + ).all() as { sql: string }[]).map((r) => r.sql); +} + +Deno.test("runMigrations: a fresh database is left as schema.sql made it", () => { + const db = freshDb(); + const before = schemaOf(db); + runMigrations(db); + assertEquals(schemaOf(db), before); + const applied = db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`) + .get() as { + n: number; + }; + assertEquals(applied.n, 15); + // Running again applies nothing. + runMigrations(db); + assertEquals( + (db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as { + n: number; + }).n, + 15, + ); + db.close(); +}); + +Deno.test("0004/0005: is_admin becomes role, then goes", () => { + const db = new DatabaseSync(":memory:"); + db.exec( + `CREATE TABLE users (id TEXT PRIMARY KEY, is_admin INTEGER NOT NULL DEFAULT 0);`, + ); + db.exec(`INSERT INTO users (id, is_admin) VALUES ('a', 1), ('b', 0);`); + up0004(db); + up0004(db); + up0005(db); + up0005(db); + assertEquals( + db.prepare(`SELECT id, role FROM users ORDER BY id;`).all().map((r) => ({ + ...r, + })), + [{ id: "a", role: "admin" }, { id: "b", role: "user" }], + ); + const cols = + (db.prepare(`PRAGMA table_info(users);`).all() as { name: string }[]) + .map((c) => c.name); + assertEquals(cols, ["id", "role"]); + db.close(); +}); + +function insertRich(db: DatabaseSync, id: string, url: string, rich: unknown) { + db.prepare( + `INSERT INTO users (id, username, password_hash, created_at, email) + VALUES ('u', 'u', 'x', 'now', 'u@x') ON CONFLICT DO NOTHING;`, + ).run(); + db.prepare( + `INSERT INTO dumps (id, kind, title, user_id, created_at, url, rich_content) + VALUES (?, 'url', 't', 'u', 'now', ?, ?);`, + ).run(id, url, typeof rich === "string" ? rich : JSON.stringify(rich)); +} + +function richOf(db: DatabaseSync, id: string): Record { + return JSON.parse( + (db.prepare(`SELECT rich_content FROM dumps WHERE id = ?;`).get(id) as { + rich_content: string; + }).rich_content, + ); +} + +Deno.test("0010: adds the start offset to YouTube embeds that lost it", () => { + const db = freshDb(); + const embed = "https://www.youtube.com/embed/abc?rel=0"; + insertRich(db, "hms", "https://youtu.be/abc?t=1h2m3s", { + type: "youtube", + embedUrl: embed, + }); + insertRich(db, "plain", "https://youtu.be/abc?start=42", { + type: "youtube", + embedUrl: embed, + }); + insertRich(db, "none", "https://youtu.be/abc", { + type: "youtube", + embedUrl: embed, + }); + insertRich(db, "has", "https://youtu.be/abc?t=5", { + type: "youtube", + embedUrl: `${embed}&start=9`, + }); + insertRich(db, "other", "https://example.com/?t=5", { + type: "generic", + embedUrl: embed, + }); + insertRich(db, "bad", "https://youtu.be/abc?t=5", "{not json"); + up0010(db); + up0010(db); + assertEquals(richOf(db, "hms").embedUrl, `${embed}&start=3723`); + assertEquals(richOf(db, "plain").embedUrl, `${embed}&start=42`); + assertEquals(richOf(db, "none").embedUrl, embed); + assertEquals(richOf(db, "has").embedUrl, `${embed}&start=9`); + assertEquals(richOf(db, "other").embedUrl, embed); + db.close(); +}); + +Deno.test("0011 then 0012: icons move to faviconUrl, artwork stays or comes back", () => { + const db = freshDb(); + const cases: Record< + string, + [string, string | undefined, string | undefined] + > = { + // id: [thumbnailUrl before, thumbnailUrl after, faviconUrl after] + ico: [ + "https://a.test/favicon.ico", + undefined, + "https://a.test/favicon.ico", + ], + touch: [ + "https://a.test/apple-touch-icon.png", + undefined, + "https://a.test/apple-touch-icon.png", + ], + art: ["https://a.test/cover.jpg", "https://a.test/cover.jpg", undefined], + // 0011 mistook these for icons; 0012 puts them back. + logoFavicon: [ + "https://a.test/uploads/mir-logo-favicon.png", + "https://a.test/uploads/mir-logo-favicon.png", + undefined, + ], + svgArt: [ + "https://a.test/art/poster.svg", + "https://a.test/art/poster.svg", + undefined, + ], + // 0011 missed this one; 0012 catches it. + iconName: [ + "https://a.test/img/icon_SEARCH.png", + undefined, + "https://a.test/img/icon_SEARCH.png", + ], + }; + for (const [id, [thumbnailUrl]] of Object.entries(cases)) { + insertRich(db, id, "https://a.test/", { + type: "generic", + url: "u", + thumbnailUrl, + }); + } + up0011(db); + up0011(db); + up0012(db); + up0012(db); + for (const [id, [, thumbnailUrl, faviconUrl]] of Object.entries(cases)) { + const rich = richOf(db, id); + assertEquals([rich.thumbnailUrl, rich.faviconUrl], [ + thumbnailUrl, + faviconUrl, + ], id); + } + db.close(); +}); diff --git a/api/test/players.test.ts b/api/test/players.test.ts new file mode 100644 index 00000000..275b2782 --- /dev/null +++ b/api/test/players.test.ts @@ -0,0 +1,204 @@ +// Play-time resolution: Bandcamp tracklists and YouTube playlist tracklists, +// their parsers and their routes. All network access is stubbed. +import { get, html, stubNetwork } from "./helpers.ts"; +import { assert, assertEquals, assertFalse } from "@std/assert"; +import { parseTralbum } from "../services/bandcamp-tralbum.ts"; +import { parsePlaylistPage } from "../services/youtube-playlist-service.ts"; + +function escapeAttr(s: string): string { + return s.replaceAll("&", "&").replaceAll('"', """); +} + +function tralbumPage(data: unknown): string { + return `
`; +} + +const STREAM = "https://t4.bcbits.com/stream/abc/mp3-128/1?p=0&ts=1&t=x"; + +const ALBUM = { + item_type: "album", + artist: "Artist", + art_id: 12345, + current: { title: 'The "Album" & more' }, + trackinfo: [ + { + track_num: 1, + title: "One", + duration: 180.5, + streaming: 1, + file: { "mp3-128": STREAM }, + }, + { + track_num: 2, + title: "Preorder", + streaming: 1, + unreleased_track: true, + file: null, + }, + { + track_num: 3, + title: "", + streaming: 1, + file: { "mp3-128": "https://evil.test/x.mp3" }, + }, + { + track_num: 4, + title: "Capped", + streaming: 1, + is_capped: true, + file: { "mp3-128": STREAM }, + }, + ], +}; + +Deno.test("parseTralbum: tracks, streamability and art", () => { + const album = parseTralbum( + tralbumPage(ALBUM), + "https://a.bandcamp.com/album/x", + ); + assert(album); + assertEquals(album.itemType, "album"); + assertEquals(album.artist, "Artist"); + // A title containing a literal & survives the single-pass decode. + assertEquals(album.title, 'The "Album" & more'); + assertEquals(album.artworkUrl, "https://f4.bcbits.com/img/a12345_16.jpg"); + assertEquals(album.tracks.map((t) => t.streamable), [ + true, + false, + false, + true, + ]); + assertEquals(album.tracks[0].streamUrl, STREAM); + assertEquals(album.tracks[0].duration, 180.5); + // A non-bcbits stream host is never relayed. + assertEquals(album.tracks[2].streamUrl, null); + assertEquals(album.tracks[2].title, "Track 3"); + assert(album.tracks[3].capped); +}); + +Deno.test("parseTralbum: pages it can't use", () => { + assertEquals(parseTralbum("", "u"), null); + assertEquals(parseTralbum(`
`, "u"), null); + assertEquals( + parseTralbum(tralbumPage({ ...ALBUM, hasAudio: false }), "u"), + null, + ); + assertEquals( + parseTralbum( + tralbumPage({ ...ALBUM, trackinfo: [ALBUM.trackinfo[1]] }), + "u", + ), + null, + ); +}); + +function ytPage(data: unknown): string { + return ``; +} + +const YT_DATA = { + metadata: { playlistMetadataRenderer: { title: "My mix" } }, + contents: { + list: [ + { + lockupViewModel: { + contentType: "LOCKUP_CONTENT_TYPE_VIDEO", + contentId: "vid1", + metadata: { + lockupMetadataViewModel: { title: { content: "First" } }, + }, + contentImage: { + overlays: [{ thumbnailBadgeViewModel: { text: "1:02:03" } }], + }, + }, + }, + { + lockupViewModel: { + contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST", + contentId: "ignored", + }, + }, + { + playlistVideoRenderer: { + videoId: "vid2", + title: { runs: [{ text: "Second" }] }, + lengthSeconds: "235", + }, + }, + ], + }, +}; + +Deno.test("parsePlaylistPage: current and legacy rows", () => { + assertEquals(parsePlaylistPage(ytPage(YT_DATA), "PLx"), { + listId: "PLx", + title: "My mix", + tracks: [ + { videoId: "vid1", title: "First", duration: 3723 }, + { videoId: "vid2", title: "Second", duration: 235 }, + ], + }); + assertEquals(parsePlaylistPage("", "PLx"), null); + assertEquals(parsePlaylistPage(ytPage({ contents: {} }), "PLx"), null); +}); + +Deno.test("GET /api/bandcamp/tracks: resolves, caches, and forces a refetch", async () => { + using net = stubNetwork(() => html(tralbumPage(ALBUM))); + const url = "https://cached.bandcamp.com/album/x"; + const res = await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`); + assertEquals(res.status, 200); + assertEquals(res.headers.get("cache-control"), "private, max-age=3600"); + assertEquals(res.body.data.tracks.length, 4); + // Same page (different query/case/trailing slash) → cache hit. + await get( + `/api/bandcamp/tracks?url=${ + encodeURIComponent("https://CACHED.bandcamp.com/album/x/?from=y") + }`, + ); + assertEquals(net.calls.length, 1); + await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}&force=1`); + assertEquals(net.calls.length, 2); +}); + +Deno.test("GET /api/bandcamp/tracks: refusals and upstream failures", async () => { + using _ = stubNetwork((url) => { + if (url.hostname.startsWith("down")) { + return new Response("", { status: 503 }); + } + if (url.hostname.startsWith("json")) return Response.json({}); + return html("no tralbum"); + }); + const status = async (url: string) => + (await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`)).status; + assertEquals(await status("https://example.com/album/x"), 400); + assertEquals(await status("https://bandcamp.com.evil.test/album/x"), 400); + assertEquals(await status("http://localhost/album/x"), 400); + assertEquals(await status("https://down.bandcamp.com/album/x"), 502); + assertEquals(await status("https://json.bandcamp.com/album/x"), 502); + assertEquals(await status("https://empty.bandcamp.com/album/x"), 404); +}); + +Deno.test("GET /api/youtube/playlist: resolves and validates the id", async () => { + let cookie: string | null = null; + using net = stubNetwork((url, init) => { + cookie = new Headers(init?.headers).get("cookie"); + return url.searchParams.get("list") === "PLgone" + ? new Response("", { status: 404 }) + : html(ytPage(YT_DATA)); + }); + const ok = await get("/api/youtube/playlist?list=PLgood"); + assertEquals(ok.status, 200); + assertEquals(ok.body.data.tracks.length, 2); + assertEquals(ok.headers.get("cache-control"), "public, max-age=3600"); + // The pre-answered consent cookie keeps EU requests off the consent page. + assertEquals(cookie, "SOCS=CAI"); + await get("/api/youtube/playlist?list=PLgood"); // cached + assertEquals(net.calls.length, 1); + + assertEquals((await get("/api/youtube/playlist?list=bad%20id")).status, 400); + assertEquals((await get("/api/youtube/playlist")).status, 400); + assertEquals((await get("/api/youtube/playlist?list=PLgone")).status, 502); + assertFalse(net.calls.some((u) => u.searchParams.get("list") === "bad id")); +}); diff --git a/api/test/playlists.test.ts b/api/test/playlists.test.ts new file mode 100644 index 00000000..2d828f67 --- /dev/null +++ b/api/test/playlists.test.ts @@ -0,0 +1,303 @@ +import { + count, + createUser, + db, + del, + formWithFile, + get, + insertDump, + insertPlaylist, + PNG, + post, + put, + test, +} from "./helpers.ts"; +import { assertEquals, assertMatch } from "@std/assert"; + +function ids(res: { body: { data: { dumps: { id: string }[] } } }): string[] { + return res.body.data.dumps.map((d) => d.id); +} + +test("POST /api/playlists creates; GET finds it by id or slug", async () => { + const user = await createUser(); + const res = await post("/api/playlists", { + auth: user.auth, + json: { title: "Road Trip", description: "songs", isPublic: true }, + }); + assertEquals(res.status, 201); + const { id, slug } = res.body.data; + assertMatch(slug, /^road-trip-/); + assertEquals( + (await get(`/api/playlists/${id}`)).body.data.title, + "Road Trip", + ); + const bySlug = await get(`/api/playlists/${slug}`); + assertEquals(bySlug.body.data.id, id); + assertEquals(bySlug.body.data.ownerUsername, user.username); + assertEquals(bySlug.body.data.dumps, []); + assertEquals( + (await post("/api/playlists", { auth: user.auth, json: { title: "" } })) + .status, + 400, + ); + assertEquals( + (await post("/api/playlists", { json: { title: "x", isPublic: true } })) + .status, + 401, + ); +}); + +test("private playlists are the owner's alone", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id } = insertPlaylist({ userId: owner.id, isPublic: false }); + assertEquals((await get(`/api/playlists/${id}`)).status, 404); + assertEquals( + (await get(`/api/playlists/${id}`, { auth: other.auth })).status, + 404, + ); + assertEquals( + (await get(`/api/playlists/${id}`, { auth: owner.auth })).status, + 200, + ); + insertPlaylist({ userId: owner.id }); + const path = `/api/users/${owner.username}/playlists`; + assertEquals((await get(path)).body.data.total, 1); + assertEquals((await get(path, { auth: owner.auth })).body.data.total, 2); +}); + +test("adding dumps: newest on top, no duplicates, only dumps you can see", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id } = insertPlaylist({ userId: owner.id }); + const a = insertDump({ userId: owner.id, title: "a" }); + const b = insertDump({ userId: other.id, title: "b" }); + const theirs = insertDump({ userId: other.id, isPrivate: true }); + + assertEquals( + (await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth })) + .status, + 204, + ); + assertEquals( + (await post(`/api/playlists/${id}/dumps/${b.id}`, { auth: owner.auth })) + .status, + 204, + ); + assertEquals(ids(await get(`/api/playlists/${id}`)), [b.id, a.id]); + assertEquals( + (await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth })) + .status, + 409, + ); + assertEquals( + (await post(`/api/playlists/${id}/dumps/${theirs.id}`, { + auth: owner.auth, + })).status, + 404, + ); + assertEquals( + (await post(`/api/playlists/${id}/dumps/${crypto.randomUUID()}`, { + auth: owner.auth, + })) + .status, + 404, + ); + assertEquals( + (await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: other.auth })) + .status, + 403, + ); + assertEquals((await get(`/api/playlists/${id}`)).body.data.dumpCount, 2); +}); + +test("a public playlist hides its private dumps from everyone but their owner", async () => { + const owner = await createUser(); + const { id } = insertPlaylist({ userId: owner.id }); + const pub = insertDump({ userId: owner.id }); + const priv = insertDump({ userId: owner.id, isPrivate: true }); + for (const d of [pub, priv]) { + await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth }); + } + assertEquals(ids(await get(`/api/playlists/${id}`)), [pub.id]); + assertEquals( + ids(await get(`/api/playlists/${id}`, { auth: owner.auth })).length, + 2, + ); +}); + +test("reorder: must name exactly the current members", async () => { + const owner = await createUser(); + const { id } = insertPlaylist({ userId: owner.id }); + const [a, b, c] = [1, 2, 3].map(() => insertDump({ userId: owner.id })); + for (const d of [a, b, c]) { + await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth }); + } + const order = [a.id, c.id, b.id]; + assertEquals( + (await put(`/api/playlists/${id}/order`, { + auth: owner.auth, + json: { dumpIds: order }, + })) + .status, + 200, + ); + assertEquals(ids(await get(`/api/playlists/${id}`)), order); + for (const dumpIds of [[a.id, b.id], [a.id, b.id, crypto.randomUUID()]]) { + assertEquals( + (await put(`/api/playlists/${id}/order`, { + auth: owner.auth, + json: { dumpIds }, + })) + .status, + 400, + JSON.stringify(dumpIds), + ); + } + // A dump added after reordering still lands on top. + const d = insertDump({ userId: owner.id }); + await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth }); + assertEquals(ids(await get(`/api/playlists/${id}`))[0], d.id); +}); + +test("reorder: a list naming a member twice is not the current members", async () => { + const owner = await createUser(); + const { id } = insertPlaylist({ userId: owner.id }); + const [a, b] = [1, 2].map(() => insertDump({ userId: owner.id })); + for (const d of [a, b]) { + await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth }); + } + const res = await put(`/api/playlists/${id}/order`, { + auth: owner.auth, + json: { dumpIds: [a.id, b.id, a.id] }, + }); + assertEquals(res.status, 400); +}); + +test("removing a dump; updating; deleting", async () => { + const owner = await createUser(); + const other = await createUser(); + const mod = await createUser({ role: "moderator" }); + const { id } = insertPlaylist({ userId: owner.id, title: "Old" }); + const a = insertDump({ userId: owner.id }); + await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth }); + assertEquals( + (await del(`/api/playlists/${id}/dumps/${a.id}`, { auth: other.auth })) + .status, + 403, + ); + assertEquals( + (await del(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth })) + .status, + 204, + ); + assertEquals(ids(await get(`/api/playlists/${id}`)), []); + + assertEquals( + (await put(`/api/playlists/${id}`, { + auth: other.auth, + json: { title: "x" }, + })).status, + 403, + ); + const updated = await put(`/api/playlists/${id}`, { + auth: owner.auth, + json: { title: "New", description: "d", isPublic: false }, + }); + assertEquals(updated.body.data.title, "New"); + assertMatch(updated.body.data.slug, /^new-/); + assertEquals(updated.body.data.isPublic, false); + const cleared = await put(`/api/playlists/${id}`, { + auth: owner.auth, + json: { description: null }, + }); + assertEquals(cleared.body.data.description, undefined); + assertEquals(cleared.body.data.title, "New"); + + assertEquals( + (await del(`/api/playlists/${id}`, { auth: other.auth })).status, + 403, + ); + assertEquals( + (await del(`/api/playlists/${id}`, { auth: mod.auth })).status, + 204, + ); + assertEquals( + (await get(`/api/playlists/${id}`, { auth: owner.auth })).status, + 404, + ); + assertEquals(count(`SELECT COUNT(*) as n FROM playlist_dumps;`), 0); +}); + +test("memberships: which of my playlists hold this dump", async () => { + const owner = await createUser(); + const other = await createUser(); + const p1 = insertPlaylist({ userId: owner.id, title: "has" }); + insertPlaylist({ userId: owner.id, title: "hasnt" }); + insertPlaylist({ userId: other.id, title: "theirs" }); + const d = insertDump({ userId: owner.id }); + await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth }); + const res = await get(`/api/playlists/by-dump/${d.id}/memberships`, { + auth: owner.auth, + }); + const byTitle = Object.fromEntries( + res.body.data.map(( + m: { playlist: { title: string }; hasDump: boolean }, + ) => [ + m.playlist.title, + m.hasDump, + ]), + ); + assertEquals(byTitle, { has: true, hasnt: false }); +}); + +test("adding to a followed public playlist notifies followers, private dumps excepted", async () => { + const owner = await createUser(); + const follower = await createUser(); + const { id } = insertPlaylist({ userId: owner.id, title: "Mix" }); + await post(`/api/follows/playlists/${id}`, { auth: follower.auth }); + db.exec(`DELETE FROM notifications;`); // the follow notified the owner + const priv = insertDump({ userId: owner.id, isPrivate: true }); + await post(`/api/playlists/${id}/dumps/${priv.id}`, { auth: owner.auth }); + assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0); + const pub = insertDump({ userId: owner.id, title: "Song" }); + await post(`/api/playlists/${id}/dumps/${pub.id}`, { auth: owner.auth }); + const note = db.prepare(`SELECT user_id, type, data FROM notifications;`) + .get() as { + user_id: string; + type: string; + data: string; + }; + assertEquals(note.user_id, follower.id); + assertEquals(note.type, "playlist_dump_added"); + assertEquals(JSON.parse(note.data), { + dumpId: pub.id, + dumpTitle: "Song", + playlistId: id, + playlistTitle: "Mix", + }); +}); + +test("playlist images: upload and serve", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id, slug } = insertPlaylist({ userId: owner.id }); + assertEquals((await get(`/api/playlists/${id}/image`)).status, 404); + assertEquals( + (await post(`/api/playlists/${id}/image`, { + auth: other.auth, + form: formWithFile(PNG), + })) + .status, + 403, + ); + const res = await post(`/api/playlists/${slug}/image`, { + auth: owner.auth, + form: formWithFile(PNG), + }); + assertEquals(res.status, 200); + assertEquals(res.body.data.imageMime, "image/png"); + const served = await get(`/api/playlists/${slug}/image`); + assertEquals(served.status, 200); + assertEquals(served.bytes, PNG); +}); diff --git a/api/test/public-address.test.ts b/api/test/public-address.test.ts new file mode 100644 index 00000000..1ce69e35 --- /dev/null +++ b/api/test/public-address.test.ts @@ -0,0 +1,146 @@ +import "./setup.ts"; +import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert"; +import { + isPrivateAddress, + isPrivateHostname, + resolvePublicAddress, +} from "../lib/public-address.ts"; +import { stubNetwork } from "./helpers.ts"; + +Deno.test("isPrivateAddress: private and reserved IPv4 ranges", () => { + for ( + const ip of [ + "0.0.0.0", + "10.1.2.3", + "127.0.0.1", + "100.64.0.1", + "100.127.255.255", + "169.254.169.254", + "172.16.0.1", + "172.31.255.255", + "192.0.0.8", + "192.168.1.1", + "198.18.0.1", + "224.0.0.1", + "255.255.255.255", + ] + ) { + assert(isPrivateAddress(ip), ip); + } +}); + +Deno.test("isPrivateAddress: public IPv4 addresses", () => { + for ( + const ip of [ + "8.8.8.8", + "93.184.216.34", + "100.63.255.255", + "100.128.0.0", + "172.15.255.255", + "172.32.0.0", + "192.169.0.1", + "223.255.255.255", + ] + ) { + assertFalse(isPrivateAddress(ip), ip); + } +}); + +Deno.test("isPrivateAddress: IPv6, including IPv4 carried inside it", () => { + for ( + const ip of [ + "::", + "::1", + "fc00::1", + "fd12:3456::1", + "fe80::1%eth0", + "fec0::1", + "ff02::1", + "::ffff:127.0.0.1", + "::ffff:7f00:1", + "::ffff:10.0.0.1", + "64:ff9b::a00:1", // NAT64 of 10.0.0.1 + "2002:a00:1::", // 6to4 of 10.0.0.1 + "::10.0.0.1", + ] + ) { + assert(isPrivateAddress(ip), ip); + } + for ( + const ip of [ + "2606:4700:4700::1111", + "::ffff:8.8.8.8", + "64:ff9b::808:808", + "2002:808:808::", + ] + ) { + assertFalse(isPrivateAddress(ip), ip); + } +}); + +Deno.test("isPrivateAddress: anything that isn't an IP is not an address", () => { + for ( + const s of ["example.com", "1.2.3", "1.2.3.4.5", "256.1.1.1", "1::2::3", ""] + ) { + assertFalse(isPrivateAddress(s), s); + } +}); + +Deno.test("isPrivateHostname: localhost names and bracketed literals", () => { + assert(isPrivateHostname(new URL("http://localhost:8000/"))); + assert(isPrivateHostname(new URL("http://app.localhost/"))); + assert(isPrivateHostname(new URL("http://[::1]/"))); + assert(isPrivateHostname(new URL("http://127.0.0.1/"))); + // WHATWG URL parsing normalises these to 127.0.0.1 before we see them. + assert(isPrivateHostname(new URL("http://0x7f000001/"))); + assert(isPrivateHostname(new URL("http://2130706433/"))); + assertFalse(isPrivateHostname(new URL("https://example.com/"))); +}); + +Deno.test("resolvePublicAddress: IP literals skip DNS", async () => { + using net = stubNetwork(() => new Response()); + assertEquals( + await resolvePublicAddress(new URL("https://8.8.8.8/")), + "8.8.8.8", + ); + assertEquals( + await resolvePublicAddress(new URL("https://[2606:4700::1]/")), + "2606:4700::1", + ); + assertEquals(net.calls.length, 0); +}); + +Deno.test("resolvePublicAddress: refuses private literals without resolving", async () => { + await assertRejects( + () => resolvePublicAddress(new URL("http://169.254.169.254/latest")), + Error, + "private", + ); +}); + +Deno.test("resolvePublicAddress: a name resolving anywhere private is refused", async () => { + using _ = stubNetwork(() => new Response(), { + "internal.example": ["10.0.0.5"], + "mixed.example": ["93.184.216.34", "127.0.0.1"], + "nowhere.example": [], + }); + await assertRejects( + () => resolvePublicAddress(new URL("http://internal.example/")), + Error, + "resolves to 10.0.0.5", + ); + await assertRejects( + () => resolvePublicAddress(new URL("http://mixed.example/")), + Error, + "resolves to 127.0.0.1", + ); + await assertRejects( + () => resolvePublicAddress(new URL("http://nowhere.example/")), + Error, + "Could not resolve", + ); + assertEquals( + await resolvePublicAddress(new URL("http://public.example/")), + "93.184.216.34", + ); +}); diff --git a/api/test/rich-content.test.ts b/api/test/rich-content.test.ts new file mode 100644 index 00000000..3c1e56b6 --- /dev/null +++ b/api/test/rich-content.test.ts @@ -0,0 +1,382 @@ +// Link previews: the HTML extractors, the providers, and fetchWithTimeout's +// guard on every hop. All network access is stubbed. +import { createUser, html, insertDump, stubNetwork, test } from "./helpers.ts"; +import { + assert, + assertEquals, + assertFalse, + assertRejects, + assertStringIncludes, +} from "@std/assert"; +import { + extractBestIcon, + extractFirstContentImage, + extractJsonLd, + extractLargeImage, + extractLinkHref, + extractMaskIconColor, + extractMetaName, + extractOgTag, + extractPageTitle, + extractThemeColor, + fetchRichContent, + fetchWithTimeout, + isValidHttpUrl, + normalizeCssColor, + tryFetchRichContent, +} from "../services/rich-content-service.ts"; + +const BASE = "https://example.com/articles/1"; + +Deno.test("extractOgTag / extractMetaName: either attribute order, entities decoded", () => { + const page = ` + + + + `; + assertEquals(extractOgTag(page, "title"), "Tom & Jerry — ❤"); + assertEquals(extractOgTag(page, "description"), "Desc"); + assertEquals(extractOgTag(page, "image"), undefined); + assertEquals(extractMetaName(page, "twitter:title"), "TW"); +}); + +Deno.test("extractOgTag: content never spans into the next tag", () => { + const page = ``; + assertEquals(extractOgTag(page, "title"), undefined); +}); + +Deno.test("extractPageTitle", () => { + assertEquals( + extractPageTitle(" A <b> "), + "A ", + ); + assertEquals(extractPageTitle("

none

"), undefined); +}); + +Deno.test("extractThemeColor: unscoped, then light, then any", () => { + assertEquals( + extractThemeColor(` + + `), + "#fff", + ); + assertEquals( + extractThemeColor(` + + `), + "#123", + ); + assertEquals( + extractThemeColor( + ``, + ), + "#000", + ); + assertEquals( + extractMaskIconColor( + ``, + ), + "#5bbad5", + ); +}); + +Deno.test("normalizeCssColor", () => { + assertEquals(normalizeCssColor("#ABC"), "#aabbcc"); + assertEquals(normalizeCssColor("#abcd"), "#aabbcc"); + assertEquals(normalizeCssColor("#112233"), "#112233"); + assertEquals(normalizeCssColor("#11223380"), "#112233"); + assertEquals(normalizeCssColor(" Navy "), "#000080"); + assertEquals(normalizeCssColor("rgb(255, 0, 128)"), "#ff0080"); + assertEquals(normalizeCssColor("rgba(300 -5 50% / .5)"), "#ff0080"); + assertEquals(normalizeCssColor("#12345"), undefined); + assertEquals(normalizeCssColor("hsl(0 100% 50%)"), undefined); + assertEquals(normalizeCssColor("rgb(1,2)"), undefined); + assertEquals(normalizeCssColor(undefined), undefined); +}); + +Deno.test("extractJsonLd: @graph, arrays and image shapes", () => { + const ld = (data: unknown) => + ``; + assertEquals( + extractJsonLd( + ld({ + "@graph": [{ "@type": "WebSite" }, { + headline: "Head", + image: { url: "https://i/1.jpg" }, + }], + }), + ), + { title: "Head", description: undefined, thumbnailUrl: "https://i/1.jpg" }, + ); + assertEquals( + extractJsonLd(ld({ name: ["N"], image: ["https://i/2.jpg"] })).thumbnailUrl, + "https://i/2.jpg", + ); + assertEquals( + extractJsonLd(ld({ name: "N", image: "/relative.jpg" })).thumbnailUrl, + undefined, + ); + // Invalid JSON is skipped for the next block. + assertEquals( + extractJsonLd( + `${ + ld({ name: "Second" }) + }`, + ).title, + "Second", + ); + assertEquals(extractJsonLd("

"), {}); +}); + +Deno.test("image extraction: large images, content images, relative URLs", () => { + const page = ` + + + + + `; + assertEquals( + extractLargeImage(page, BASE), + "https://example.com/photos/cat.jpg", + ); + assertEquals( + extractFirstContentImage(page, BASE), + "https://example.com/small.jpg", + ); + assertEquals( + extractFirstContentImage(``, BASE), + "https://cdn.test/a.jpg", + ); +}); + +Deno.test("extractBestIcon: SVG beats everything, else the largest", () => { + assertEquals( + extractBestIcon( + ` + + `, + BASE, + ), + "https://example.com/180.png", + ); + assertEquals( + extractBestIcon( + ` + `, + BASE, + ), + "https://example.com/icon", + ); + assertEquals(extractBestIcon("

", BASE), undefined); + assertEquals( + extractLinkHref(``, "alternate", BASE), + "https://example.com/feed", + ); +}); + +Deno.test("isValidHttpUrl: http(s) to public hosts, or our own pages", () => { + assert(isValidHttpUrl("https://example.com/x")); + assert(isValidHttpUrl("http://gerbeur.test/dumps/abc")); + assertFalse(isValidHttpUrl("http://localhost:8000/")); + assertFalse(isValidHttpUrl("http://10.0.0.1/")); + assertFalse(isValidHttpUrl("file:///etc/passwd")); + assertFalse(isValidHttpUrl("nope")); +}); + +Deno.test("fetchWithTimeout: follows redirects, checking every hop", async () => { + using net = stubNetwork((url) => { + if (url.pathname === "/start") { + return new Response(null, { status: 302, headers: { location: "/end" } }); + } + if (url.pathname === "/to-private") { + return new Response(null, { + status: 301, + headers: { location: "http://169.254.169.254/latest/meta-data" }, + }); + } + if (url.pathname === "/loop") { + return new Response(null, { + status: 302, + headers: { location: "/loop" }, + }); + } + return new Response("done"); + }); + const res = await fetchWithTimeout("https://example.com/start"); + assertEquals(await res.text(), "done"); + assertEquals(net.calls.map((u) => u.pathname), ["/start", "/end"]); + + await assertRejects( + () => fetchWithTimeout("https://example.com/to-private"), + Error, + "private", + ); + await assertRejects( + () => fetchWithTimeout("https://example.com/loop"), + TypeError, + "Too many redirects", + ); + await assertRejects( + () => fetchWithTimeout("ftp://example.com/"), + TypeError, + "Unsupported protocol", + ); +}); + +Deno.test("generic provider: the full fallback chain", async () => { + using _ = stubNetwork(() => + html(` + Page title + + + + + + + `) + ); + const content = await fetchRichContent(BASE); + assertEquals(content, { + type: "generic", + url: BASE, + title: "OG title", + description: "Plain description", + thumbnailUrl: "https://example.com/og.jpg", + faviconUrl: "https://example.com/favicon-32.png", + accentColor: "#0080ff", + siteName: "Example", + }); +}); + +Deno.test("generic provider: a site-wide og:url means og:title/image are ignored", async () => { + using _ = stubNetwork(() => + html(` + + + + + `) + ); + const content = await fetchRichContent("https://www.example.com/articles/1"); + assertEquals(content?.title, "Article title"); + assertEquals(content?.thumbnailUrl, "https://www.example.com/photo.jpg"); + assertEquals(content?.siteName, "example.com"); + assertEquals(content?.faviconUrl, "https://www.example.com/favicon.ico"); +}); + +Deno.test("generic provider: non-HTML responses carry just the URL", async () => { + using _ = stubNetwork(() => + new Response("{}", { headers: { "content-type": "application/json" } }) + ); + assertEquals(await fetchRichContent(BASE), { type: "generic", url: BASE }); +}); + +Deno.test("tryFetchRichContent: an unreachable page is ok:false with a stub", async () => { + using _ = stubNetwork(() => { + throw new TypeError("connection refused"); + }); + assertEquals(await tryFetchRichContent("https://www.example.com/x"), { + ok: false, + content: { + type: "generic", + url: "https://www.example.com/x", + siteName: "example.com", + }, + }); +}); + +Deno.test("youtube provider: video with start time and playlist", async () => { + using net = stubNetwork((url) => + url.pathname === "/oembed" + ? Response.json({ title: "Never Gonna", thumbnail_url: "ignored" }) + : new Response(null, { status: 404 }) + ); + const content = await fetchRichContent( + "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123&t=1m30s", + ); + assertEquals(content?.type, "youtube"); + assertEquals(content?.videoId, "dQw4w9WgXcQ"); + assertEquals(content?.title, "Never Gonna"); + assertEquals( + content?.thumbnailUrl, + "https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg", + ); + assertEquals( + content?.embedUrl, + "https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0&list=PL123&start=90", + ); + assertEquals(net.calls[0].hostname, "www.youtube.com"); +}); + +Deno.test("youtube provider: youtu.be, shorts, playlists and channels", async () => { + using _ = stubNetwork((url) => + url.pathname === "/oembed" + ? Response.json({ + title: "T", + thumbnail_url: "https://i.ytimg.com/x.jpg", + }) + : html(` + `) + ); + assertEquals( + (await fetchRichContent("https://youtu.be/abc123?t=42"))?.embedUrl, + "https://www.youtube.com/embed/abc123?rel=0&start=42", + ); + assertEquals( + (await fetchRichContent("https://youtube.com/shorts/xyz"))?.videoId, + "xyz", + ); + const list = await fetchRichContent( + "https://www.youtube.com/playlist?list=PLabc", + ); + assertEquals( + list?.embedUrl, + "https://www.youtube.com/embed/videoseries?list=PLabc&rel=0", + ); + assertEquals(list?.thumbnailUrl, "https://i.ytimg.com/x.jpg"); + const channel = await fetchRichContent("https://www.youtube.com/@someone"); + assertEquals(channel?.title, "Channel"); + assertEquals(channel?.embedUrl, undefined); +}); + +Deno.test("bandcamp and soundcloud providers", async () => { + using _ = stubNetwork(() => + html(` + `) + ); + const bc = await fetchRichContent("https://artist.bandcamp.com/album/a"); + assertEquals(bc?.type, "bandcamp"); + assertEquals(bc?.title, "Album"); + assertEquals(bc?.embedUrl, "https://bandcamp.com/EmbeddedPlayer/album=1"); + const sc = await fetchRichContent("https://soundcloud.com/artist/track"); + assertEquals(sc?.type, "soundcloud"); + assertStringIncludes( + sc?.embedUrl ?? "", + encodeURIComponent("https://soundcloud.com/artist/track"), + ); +}); + +test("self provider: our own dump, user and playlist pages come from the DB", async () => { + const user = await createUser(); + const dump = insertDump({ + userId: user.id, + title: "Local dump", + comment: "About it", + richContent: { type: "generic", url: "x", thumbnailUrl: "https://t/x.jpg" }, + }); + using net = stubNetwork(() => { + throw new Error("no network for self links"); + }); + const content = await fetchRichContent( + `http://gerbeur.test/dumps/${dump.slug}`, + ); + assertEquals(content?.title, "Local dump"); + assertEquals(content?.description, "About it"); + assertEquals(content?.thumbnailUrl, "https://t/x.jpg"); + assertEquals(content?.siteName, "gerbeur"); + const profile = await fetchRichContent( + `http://gerbeur.test/users/${user.username}`, + ); + assertEquals(profile?.title, user.username); + assertEquals(net.calls.length, 0); +}); diff --git a/api/test/setup.ts b/api/test/setup.ts new file mode 100644 index 00000000..5daac0b3 --- /dev/null +++ b/api/test/setup.ts @@ -0,0 +1,31 @@ +// Points the app at a throwaway database and uploads directory before any app +// module is evaluated. api/config.ts reads the environment and api/model/db.ts +// opens the database at import time, so this module must be the first import +// of every test file — directly, or as helpers.ts's first import. It imports +// nothing from the app itself, and it is synchronous on purpose: a module with +// a top-level await doesn't hold back the sibling imports that follow it. +import { DatabaseSync } from "node:sqlite"; + +export const TEST_DIR = Deno.makeTempDirSync({ prefix: "gerbeur-test-" }); + +Deno.env.set("GERBEUR_DB_PATH", `${TEST_DIR}/gerbeur.db`); +Deno.env.set("GERBEUR_UPLOADS_DIR", `${TEST_DIR}/uploads`); +Deno.env.set("GERBEUR_JWT_SECRET", "test-secret-test-secret-test-secret"); +Deno.env.set("GERBEUR_PUBLIC_URL", "http://gerbeur.test"); +Deno.env.set("GERBEUR_ALLOWED_ORIGINS", "http://allowed.test"); +// The suite never sends mail; the routes behave as on an instance without SMTP. +Deno.env.delete("GERBEUR_SMTPS_URL"); + +// The migrations, the error middleware and failed fetches all log; a passing +// run shouldn't drown in them. TEST_VERBOSE=1 brings them back for debugging. +if (!Deno.env.get("TEST_VERBOSE")) { + console.log = () => {}; + console.error = () => {}; +} + +// A fresh database is built from schema.sql, exactly as api/db/init.ts does. +const db = new DatabaseSync(`${TEST_DIR}/gerbeur.db`); +db.exec( + Deno.readTextFileSync(new URL("../db/schema.sql", import.meta.url)), +); +db.close(); diff --git a/api/test/social.test.ts b/api/test/social.test.ts new file mode 100644 index 00000000..02579eb1 --- /dev/null +++ b/api/test/social.test.ts @@ -0,0 +1,284 @@ +// Follows, feeds, notifications and invites. +import { + count, + createUser, + db, + del, + get, + insertCategory, + insertDump, + insertPlaylist, + patch, + post, + test, +} from "./helpers.ts"; +import { assertEquals, assertMatch } from "@std/assert"; + +function titles(res: { body: { data: { items: { title: string }[] } } }) { + return res.body.data.items.map((d) => d.title); +} + +test("following users: status, idempotence, no self-follow, one notification", async () => { + const me = await createUser(); + const them = await createUser(); + assertEquals( + (await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status, + 204, + ); + assertEquals( + (await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status, + 204, + ); + assertEquals( + (await post(`/api/follows/users/${me.id}`, { auth: me.auth })).status, + 400, + ); + assertEquals( + (await get("/api/follows/status", { auth: me.auth })).body.data, + { followedUserIds: [them.id], followedPlaylistIds: [] }, + ); + // Unfollow and follow again: still just the one notification. + await del(`/api/follows/users/${them.id}`, { auth: me.auth }); + assertEquals( + (await get("/api/follows/status", { auth: me.auth })).body.data + .followedUserIds, + [], + ); + await post(`/api/follows/users/${them.id}`, { auth: me.auth }); + assertEquals( + count( + `SELECT COUNT(*) as n FROM notifications WHERE user_id = ? AND type = 'user_followed';`, + them.id, + ), + 1, + ); + const list = await get(`/api/users/${me.username}/followed-users`); + assertEquals(list.body.data.items.map((u: { id: string }) => u.id), [ + them.id, + ]); + assertEquals(list.body.data.items[0].email, undefined); +}); + +test("following playlists: public ones only, and not your own notification", async () => { + const owner = await createUser(); + const me = await createUser(); + const pub = insertPlaylist({ userId: owner.id }); + const priv = insertPlaylist({ userId: owner.id, isPublic: false }); + assertEquals( + (await post(`/api/follows/playlists/${pub.id}`, { auth: me.auth })).status, + 204, + ); + assertEquals( + (await post(`/api/follows/playlists/${priv.id}`, { auth: me.auth })).status, + 403, + ); + assertEquals( + (await post(`/api/follows/playlists/${crypto.randomUUID()}`, { + auth: me.auth, + })).status, + 404, + ); + await post(`/api/follows/playlists/${pub.id}`, { auth: owner.auth }); + assertEquals( + count( + `SELECT COUNT(*) as n FROM notifications WHERE type = 'playlist_followed';`, + ), + 1, + ); + assertEquals( + (await get(`/api/users/${me.username}/followed-playlists`)).body.data + .items[0].id, + pub.id, + ); + await del(`/api/follows/playlists/${pub.id}`, { auth: me.auth }); + assertEquals( + (await get("/api/follows/status", { auth: me.auth })).body.data + .followedPlaylistIds, + [], + ); +}); + +test("followed-users feed: public dumps of the people I follow, newest first", async () => { + const me = await createUser(); + const followed = await createUser(); + const stranger = await createUser(); + const music = insertCategory({ slug: "music" }); + insertDump({ + userId: followed.id, + title: "old", + createdAt: "2024-01-01T00:00:00Z", + }); + const tagged = insertDump({ + userId: followed.id, + title: "new", + createdAt: "2024-02-01T00:00:00Z", + }); + db.prepare( + `INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`, + ) + .run(tagged.id, music); + insertDump({ userId: followed.id, title: "private", isPrivate: true }); + insertDump({ userId: stranger.id, title: "stranger" }); + await post(`/api/follows/users/${followed.id}`, { auth: me.auth }); + + const feed = await get("/api/follows/feed/users", { auth: me.auth }); + assertEquals(titles(feed), ["new", "old"]); + assertEquals(feed.body.data.items[0].categoryIds, [music]); + assertEquals( + titles( + await get("/api/follows/feed/users?category=music", { auth: me.auth }), + ), + ["new"], + ); + // An unknown category filters everything out rather than nothing. + assertEquals( + (await get("/api/follows/feed/users?category=nope", { auth: me.auth })).body + .data, + { items: [], total: 0, hasMore: false }, + ); + assertEquals((await get("/api/follows/feed/users")).status, 401); +}); + +test("followed-playlists feed: each dump once, public playlists only", async () => { + const me = await createUser(); + const owner = await createUser(); + const p1 = insertPlaylist({ userId: owner.id }); + const p2 = insertPlaylist({ userId: owner.id }); + const d = insertDump({ userId: owner.id, title: "shared" }); + const priv = insertDump({ + userId: owner.id, + title: "private", + isPrivate: true, + }); + for (const p of [p1, p2]) { + await post(`/api/follows/playlists/${p.id}`, { auth: me.auth }); + await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth }); + } + await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth }); + const feed = await get("/api/follows/feed/playlists", { auth: me.auth }); + assertEquals(titles(feed), ["shared"]); + assertEquals(feed.body.data.total, 1); + + // A playlist gone private drops out of the feed. + db.prepare(`UPDATE playlists SET is_public = 0;`).run(); + assertEquals( + (await get("/api/follows/feed/playlists", { auth: me.auth })).body.data + .total, + 0, + ); +}); + +test("notifications: listed newest first, marked read one or all, never someone else's", async () => { + const me = await createUser(); + const other = await createUser(); + const { id: dumpId } = insertDump({ userId: me.id, title: "Mine" }); + // Two notifications for me, one for other. + await post(`/api/follows/users/${me.id}`, { auth: other.auth }); + await post(`/api/dumps/${dumpId}/comments`, { + auth: other.auth, + json: { body: "hey" }, + }); + await post(`/api/follows/users/${other.id}`, { auth: me.auth }); + + const list = await get("/api/notifications", { auth: me.auth }); + assertEquals(list.body.data.total, 2); + assertEquals( + list.body.data.items.map((n: { type: string }) => n.type), + ["dump_commented", "user_followed"], + ); + assertEquals( + list.body.data.items.every((n: { read: boolean }) => !n.read), + true, + ); + + const theirs = + (await get("/api/notifications", { auth: other.auth })).body.data + .items[0].id; + assertEquals( + (await patch(`/api/notifications/${theirs}/read`, { auth: me.auth })) + .status, + 204, + ); + assertEquals( + (await get("/api/notifications", { auth: other.auth })).body.data.items[0] + .read, + false, + ); + + const first = list.body.data.items[0].id; + await patch(`/api/notifications/${first}/read`, { auth: me.auth }); + const after = + (await get("/api/notifications", { auth: me.auth })).body.data.items; + assertEquals(after.map((n: { read: boolean }) => n.read), [true, false]); + + assertEquals( + (await post("/api/notifications/read-all", { auth: me.auth })).status, + 204, + ); + assertEquals( + count( + `SELECT COUNT(*) as n FROM notifications WHERE user_id = ? AND read = 0;`, + me.id, + ), + 0, + ); + assertEquals((await get("/api/notifications")).status, 401); +}); + +test("mentions: notify the named, once per context, never the author or an email domain", async () => { + const author = await createUser(); + const named = await createUser(); + const { id: dumpId } = insertDump({ userId: author.id, title: "Topic" }); + const body = `hi @${named.username.toUpperCase()} and @${named.username}, ` + + `me @${author.username}, mail x@${named.username}.com, ` + + `[@${named.username}](link) @nobody_known`; + const c = await post(`/api/dumps/${dumpId}/comments`, { + auth: author.auth, + json: { body }, + }); + const notes = db.prepare( + `SELECT user_id, data FROM notifications WHERE type = 'user_mentioned';`, + ).all() as { user_id: string; data: string }[]; + assertEquals(notes.length, 1); + assertEquals(notes[0].user_id, named.id); + assertEquals(JSON.parse(notes[0].data), { + mentionerId: author.id, + mentionerUsername: author.username, + contextType: "comment", + contextId: c.body.data.id, + contextTitle: "Topic", + dumpId, + }); + // Editing the comment doesn't notify a second time. + await patch(`/api/comments/${c.body.data.id}`, { + auth: author.auth, + json: { body: `again @${named.username}` }, + }); + assertEquals( + count( + `SELECT COUNT(*) as n FROM notifications WHERE type = 'user_mentioned';`, + ), + 1, + ); +}); + +test("invites: anyone signed in can make one; it validates until used or stale", async () => { + const user = await createUser(); + assertEquals((await post("/api/invites")).status, 401); + const res = await post("/api/invites", { auth: user.auth }); + assertEquals(res.status, 201); + const token = res.body.data.token; + assertMatch(token, /^[A-Za-z0-9_-]{22}$/); + assertEquals((await get(`/api/invites/${token}`)).status, 200); + assertEquals((await get(`/api/invites/unknown`)).status, 404); + db.prepare(`UPDATE invites SET used_at = ? WHERE token = ?;`).run( + new Date().toISOString(), + token, + ); + assertEquals((await get(`/api/invites/${token}`)).status, 404); + db.prepare( + `UPDATE invites SET used_at = NULL, created_at = ? WHERE token = ?;`, + ) + .run(new Date(Date.now() - 8 * 86400_000).toISOString(), token); + assertEquals((await get(`/api/invites/${token}`)).status, 404); +}); diff --git a/api/test/uploads.test.ts b/api/test/uploads.test.ts new file mode 100644 index 00000000..85fe75c4 --- /dev/null +++ b/api/test/uploads.test.ts @@ -0,0 +1,297 @@ +// Serving uploaded bytes: dump files (headers, ranges, privacy), video +// thumbnails, attachments, avatars. +import { + count, + createUser, + db, + formWithFile, + get, + insertDump, + PNG, + post, + test, + TEST_DIR, +} from "./helpers.ts"; +import { assertEquals, assertStringIncludes } from "@std/assert"; + +const BYTES = new TextEncoder().encode("0123456789"); + +test("GET /api/files/:id: headers for a safe inline type", async () => { + const user = await createUser(); + const { id } = insertDump({ + userId: user.id, + fileName: "notes.txt", + fileMime: "text/plain", + fileData: BYTES, + }); + const res = await get(`/api/files/${id}`); + assertEquals(res.status, 200); + assertEquals(res.bytes, BYTES); + assertEquals(res.headers.get("content-type"), "text/plain"); + assertEquals(res.headers.get("content-length"), "10"); + assertEquals(res.headers.get("accept-ranges"), "bytes"); + assertEquals(res.headers.get("x-content-type-options"), "nosniff"); + assertEquals(res.headers.get("referrer-policy"), "no-referrer"); + assertStringIncludes(res.headers.get("content-security-policy")!, "sandbox"); + assertStringIncludes(res.headers.get("content-disposition")!, "inline;"); + assertEquals(res.headers.get("cache-control"), null); +}); + +test("GET /api/files/:id: active content downloads instead of rendering", async () => { + const user = await createUser(); + for (const mime of ["text/html", "image/svg+xml", "application/xml"]) { + const { id } = insertDump({ + userId: user.id, + fileName: "x", + fileMime: mime, + }); + const res = await get(`/api/files/${id}`); + assertStringIncludes( + res.headers.get("content-disposition")!, + "attachment;", + mime, + ); + } + const pdf = insertDump({ + userId: user.id, + fileName: "a.pdf", + fileMime: "application/pdf", + }); + const res = await get(`/api/files/${pdf.id}`); + assertStringIncludes(res.headers.get("content-disposition")!, "inline;"); + assertEquals(res.headers.get("content-security-policy"), null); +}); + +test("GET /api/files/:id: a non-Latin-1 filename doesn't break the header", async () => { + const user = await createUser(); + const { id } = insertDump({ + userId: user.id, + fileName: 'mix "été" 🎵.mp3', + fileMime: "audio/mpeg", + }); + const res = await get(`/api/files/${id}`); + assertEquals(res.status, 200); + assertEquals( + res.headers.get("content-disposition"), + `inline; filename="mix __t__ __.mp3"; filename*=UTF-8''${ + encodeURIComponent('mix "été" 🎵.mp3') + }`, + ); +}); + +test("GET /api/files/:id: byte ranges", async () => { + const user = await createUser(); + const { id } = insertDump({ + userId: user.id, + fileName: "d", + fileData: BYTES, + }); + const range = (r: string) => + get(`/api/files/${id}`, { headers: { Range: r } }); + + let res = await range("bytes=2-5"); + assertEquals(res.status, 206); + assertEquals(new TextDecoder().decode(res.bytes), "2345"); + assertEquals(res.headers.get("content-range"), "bytes 2-5/10"); + assertEquals(res.headers.get("content-length"), "4"); + + res = await range("bytes=7-"); + assertEquals(new TextDecoder().decode(res.bytes), "789"); + + res = await range("bytes=-3"); + assertEquals(new TextDecoder().decode(res.bytes), "789"); + assertEquals(res.headers.get("content-range"), "bytes 7-9/10"); + + // A suffix longer than the file is the whole file. + res = await range("bytes=-100"); + assertEquals(new TextDecoder().decode(res.bytes), "0123456789"); + assertEquals(res.headers.get("content-range"), "bytes 0-9/10"); + + res = await range("bytes=5-100"); + assertEquals(new TextDecoder().decode(res.bytes), "56789"); + + for ( + const bad of [ + "bytes=10-", + "bytes=6-2", + "bytes=-0", + "bytes=-", + "items=0-1", + "bytes=0-1,3-4", + ] + ) { + res = await range(bad); + assertEquals(res.status, 416, bad); + assertEquals(res.headers.get("content-range"), "bytes */10", bad); + } +}); + +test("GET /api/files/:id: private files need the owner's media token", async () => { + const owner = await createUser(); + const other = await createUser(); + const { id } = insertDump({ + userId: owner.id, + fileName: "p", + isPrivate: true, + }); + assertEquals((await get(`/api/files/${id}`)).status, 404); + assertEquals( + (await get(`/api/files/${id}?token=${other.mediaToken}`)).status, + 404, + ); + assertEquals((await get(`/api/files/${id}?token=garbage`)).status, 404); + const res = await get(`/api/files/${id}?token=${owner.mediaToken}`); + assertEquals(res.status, 200); + assertEquals(res.headers.get("cache-control"), "private"); + // A legacy session token still works in the query string. + assertEquals( + (await get(`/api/files/${id}?token=${owner.token}`)).status, + 200, + ); +}); + +test("GET /api/files/:id: bad ids, URL dumps and missing bytes", async () => { + const user = await createUser(); + assertEquals((await get(`/api/files/..%2F..%2Fetc%2Fpasswd`)).status, 400); + const url = insertDump({ userId: user.id }); + assertEquals((await get(`/api/files/${url.id}`)).status, 404); + const gone = insertDump({ userId: user.id, fileName: "g" }); + Deno.removeSync(`${TEST_DIR}/uploads/dumps/${gone.id}`); + assertEquals((await get(`/api/files/${gone.id}`)).status, 404); +}); + +test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async () => { + const owner = await createUser(); + const video = insertDump({ + userId: owner.id, + fileName: "v.mp4", + fileMime: "video/mp4", + }); + const priv = insertDump({ + userId: owner.id, + fileName: "p.mp4", + fileMime: "video/mp4", + isPrivate: true, + }); + Deno.mkdirSync(`${TEST_DIR}/uploads/thumbnails`, { recursive: true }); + for (const { id } of [video, priv]) { + Deno.writeFileSync(`${TEST_DIR}/uploads/thumbnails/${id}.jpg`, PNG); + } + const res = await get(`/api/thumbnails/${video.id}`); + assertEquals(res.status, 200); + assertEquals(res.headers.get("content-type"), "image/jpeg"); + assertEquals( + res.headers.get("cache-control"), + "public, max-age=31536000, immutable", + ); + assertEquals((await get(`/api/thumbnails/${priv.id}`)).status, 404); + const own = await get(`/api/thumbnails/${priv.id}?token=${owner.mediaToken}`); + assertEquals(own.status, 200); + assertEquals( + own.headers.get("cache-control"), + "private, max-age=31536000, immutable", + ); + + const text = insertDump({ userId: owner.id, fileName: "t.txt" }); + assertEquals((await get(`/api/thumbnails/${text.id}`)).status, 404); + assertEquals((await get(`/api/thumbnails/nope`)).status, 400); +}); + +test("attachments: upload an image, serve it back", async () => { + const user = await createUser(); + const res = await post("/api/attachments", { + auth: user.auth, + form: formWithFile(PNG), + }); + assertEquals(res.status, 201); + const id = res.body.data.id; + const served = await get(`/api/attachments/${id}`); + assertEquals(served.status, 200); + assertEquals(served.bytes, PNG); + assertEquals(served.headers.get("content-type"), "image/png"); + assertEquals(served.headers.get("x-content-type-options"), "nosniff"); + assertEquals(served.headers.get("cache-control"), "public, max-age=3600"); + assertEquals( + (await get(`/api/attachments/${crypto.randomUUID()}`)).status, + 404, + ); +}); + +test("attachments: refuses non-images, non-multipart and anonymous uploads", async () => { + const user = await createUser(); + assertEquals( + (await post("/api/attachments", { + auth: user.auth, + form: formWithFile( + new TextEncoder().encode("