v3: api/ gets one layout rule and keeps to it, and whatever crosses the wire is defined once, in shared/ — api/model/interfaces.ts and src/model.ts declared the same 40 types by hand and had drifted: the error envelope, email required on one side and optional on the other, Invitee/InviteTreeEntry and LinkPreview/UrlPreviewResponse for one shape each. The validation limits, the role permissions, the chat length cap, the @mention pattern and the Bandcamp and YouTube player responses were copied too, behind "keep in sync" comments. shared/ now holds them once, one file per domain (api, category, chat, comment, dump, follow, mention, notification, permissions, player, playlist, search, user, validation, ws), and imports nothing from either half. The server sends Dates; the client reads Wire<T>, the same type with every Date an ISO string, and turns it back with src/utils/shared/deserialize.ts. APIErrorCode becomes a const object, since the frontend's erasableSyntaxOnly rejects an enum. The shared types showed two real gaps: the avatar upload and GET /api/users/me return the full self user and the frontend read them as public users, and a stored session may predate media tokens, so the client keeps it as its own Session type. The backend rule: each folder holds one kind of module, each file is named after its domain. routes/ parse and answer; services/ hold the logic and every SQL query (lib/auth.ts's token check moves to user-service); remote/ is the only place that fetches from other sites — the guarded fetch and the HTML scraping out of rich-content-service, public-address, the Bandcamp and YouTube resolvers, the link providers, the image proxy (fetch, checks and cache, out of routes/preview.ts) and the Twemoji glyphs (out of lib/site-icons.ts). model/ holds per table a row type, its guard and its mappers; validators/ the request guards; db/connection.ts the handle and transaction(), db/startup.ts the purges and default admin that db.ts ran on import, called at the same moment. Tests mirror the source one file per module, test/<layer>/<module>.test.ts, with shared/'s under shared/test/ (deno task test runs both); the test that covered the bandcamp and soundcloud providers together is two now, and the backend can() has its own, so 202 tests. deno lint src/ is clean too: window.* becomes globalThis.*, and test bodies and act() callbacks that never awaited lose their async. The README states the rule. No behaviour changes; the catalogues only move their source references.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 20:19:48 +00:00
parent e9f6a1f23e
commit d392be7cb8
269 changed files with 6712 additions and 7122 deletions

View File

@@ -0,0 +1,41 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { runMigrations } from "../../db/migrate.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, 16);
// Running again applies nothing.
runMigrations(db);
assertEquals(
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
n: number;
}).n,
16,
);
db.close();
});

View File

@@ -0,0 +1,28 @@
import "../../setup.ts";
import { assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { up as up0004 } from "../../../db/migrations/0004_user_roles.ts";
import { up as up0005 } from "../../../db/migrations/0005_drop_is_admin.ts";
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();
});

View File

@@ -0,0 +1,67 @@
import "../../setup.ts";
import { assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
import { up as up0010 } from "../../../db/migrations/0010_youtube_embed_start.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 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<string, unknown> {
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();
});

View File

@@ -0,0 +1,91 @@
import "../../setup.ts";
import { assertEquals } from "@std/assert";
import { DatabaseSync } from "node:sqlite";
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 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<string, unknown> {
return JSON.parse(
(db.prepare(`SELECT rich_content FROM dumps WHERE id = ?;`).get(id) as {
rich_content: string;
}).rich_content,
);
}
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();
});

View File

@@ -0,0 +1,31 @@
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";
// 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();
});

View File

@@ -3,13 +3,14 @@ import { TEST_DIR } from "./setup.ts";
import { stub } from "@std/testing/mock";
import { app } from "../main.ts";
import { db } from "../model/db.ts";
import { db } from "../db/connection.ts";
import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts";
import { makeSlug } from "../lib/slugify.ts";
import { canonicalizeUrl } from "../lib/canonical-url.ts";
import { clearRichContentCache } from "../services/rich-content-service.ts";
import { clearImageCache } from "../routes/preview.ts";
import type { APIError, APIResponse, Role } from "../model/interfaces.ts";
import { clearImageCache } from "../remote/image-proxy.ts";
import type { APIError, APIResponse } from "../../shared/api.ts";
import type { Role } from "../../shared/user.ts";
export { app, db, TEST_DIR };

View File

@@ -1,162 +0,0 @@
// 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("<svg xmlns=...>")),
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("<html>")),
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$/);
});

62
api/test/lib/auth.test.ts Normal file
View File

@@ -0,0 +1,62 @@
import { createUser, db, get, test } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import { authenticate, authenticateMedia } from "../../lib/auth.ts";
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.error.code, "UNAUTHORIZED");
});

View File

@@ -1,8 +1,6 @@
import "./setup.ts";
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";
import { canonicalizeUrl } from "../../lib/canonical-url.ts";
Deno.test("canonicalizeUrl: scheme, www and trailing slash collapse", () => {
const want = "https://example.com/a";
@@ -76,29 +74,3 @@ Deno.test("canonicalizeUrl: non-http and unparseable input", () => {
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();
});

80
api/test/lib/jwt.test.ts Normal file
View File

@@ -0,0 +1,80 @@
import "../setup.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";
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);
});

View File

@@ -0,0 +1,15 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { parsePagination } from "../../lib/pagination.ts";
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 });
});

View File

@@ -0,0 +1,12 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import { can } from "../../lib/permissions.ts";
Deno.test("can: checks the token's role", () => {
assert(
can({ userId: "x", username: "x", role: "admin", exp: 0 }, "user:manage"),
);
assertFalse(
can({ userId: "x", username: "x", role: "user", exp: 0 }, "dump:moderate"),
);
});

View File

@@ -0,0 +1,27 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import { FakeTime } from "@std/testing/time";
import { takeToken } from "../../lib/rate-limit.ts";
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));
});

View File

@@ -0,0 +1,21 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import {
isReservedSlug,
isSlugAvailable,
isValidSlugFormat,
} from "../../lib/reserved-slugs.ts";
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"));
});

View File

@@ -0,0 +1,13 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import { isSelfUrl } from "../../lib/self-url.ts";
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")));
});

View File

@@ -0,0 +1,11 @@
import "../setup.ts";
import { assertEquals, assertMatch } from "@std/assert";
import { emojiToCodepoint } from "../../lib/site-icons.ts";
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$/);
});

View File

@@ -0,0 +1,19 @@
import "../setup.ts";
import { assert, assertEquals, assertFalse } from "@std/assert";
import { makeSlug, slugify, UUID_RE } from "../../lib/slugify.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"));
});

View File

@@ -0,0 +1,74 @@
import { BASE_URL, TEST_DIR } from "../helpers.ts";
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
import { Application } from "@oak/oak";
import { routeStaticFilesFrom } from "../../lib/static.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("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__</title></head><body>__BANDCAMP_PLAYER__</body></html>`,
);
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,
"<html><head><title>gerbeur</title></head><body>embed</body></html>",
);
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("<html>"));
assertEquals(
(await handle(app, "/emojibase/17.0.0/missing.json")).res.status,
404,
);
});

View File

@@ -0,0 +1,42 @@
import "../setup.ts";
import { PNG } from "../helpers.ts";
import { assertEquals, assertThrows } from "@std/assert";
import { detectImageMime, validateImageUpload } from "../../lib/upload.ts";
import { APIException } from "../../lib/api-exception.ts";
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("<svg xmlns=...>")),
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("<html>")),
APIException,
"not a recognised image",
);
const big = new Uint8Array(5 * 1024 * 1024 + 1);
big.set(PNG);
assertThrows(() => validateImageUpload(big), APIException, "too large");
});

33
api/test/main.test.ts Normal file
View File

@@ -0,0 +1,33 @@
import { BASE_URL, get, request, test } from "./helpers.ts";
import { assertEquals } from "@std/assert";
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",
);
});

View File

@@ -1,255 +0,0 @@
// 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" <script>`,
comment: "A comment",
richContent: {
type: "generic",
url: "x",
thumbnailUrl: "https://img.test/a.jpg",
},
});
const { res, text } = await handle(ogApp(), `/dumps/${dump.slug}`);
assertEquals(res.headers.get("content-type"), "text/html; charset=utf-8");
assertStringIncludes(
text,
`<meta property="og:title" content="Tom &amp; &quot;Jerry&quot; &lt;script&gt;" />`,
);
assertStringIncludes(
text,
`<title>Tom &amp; &quot;Jerry&quot; &lt;script&gt;</title>`,
);
assertStringIncludes(
text,
`<meta property="og:description" content="A comment" />`,
);
assertStringIncludes(
text,
`<meta property="og:image" content="https://img.test/a.jpg" />`,
);
assertStringIncludes(text, `content="summary_large_image"`);
assertStringIncludes(
text,
`<meta property="og:url" content="${BASE_URL}/dumps/${dump.slug}" />`,
);
// The index's own title is replaced, not duplicated.
assertEquals(text.match(/<title>/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__</title></head><body>__BANDCAMP_PLAYER__</body></html>`,
);
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,
"<html><head><title>gerbeur</title></head><body>embed</body></html>",
);
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("<html>"));
assertEquals(
(await handle(app, "/emojibase/17.0.0/missing.json")).res.status,
404,
);
});

View File

@@ -0,0 +1,18 @@
import { createUser, get, test } from "../helpers.ts";
import { assertEquals } from "@std/assert";
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,
);
});

View File

@@ -0,0 +1,39 @@
import { BASE_URL } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import { Application } from "@oak/oak";
import { errorMiddleware } from "../../middleware/error.ts";
import { APIErrorCode } from "../../../shared/api.ts";
import { APIException } from "../../lib/api-exception.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",
});
});

View File

@@ -0,0 +1,121 @@
import {
BASE_URL,
createUser,
insertDump,
insertPlaylist,
test,
} from "../helpers.ts";
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
import { Application } from "@oak/oak";
import { ogMiddleware } from "../../middleware/og.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() };
}
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" <script>`,
comment: "A comment",
richContent: {
type: "generic",
url: "x",
thumbnailUrl: "https://img.test/a.jpg",
},
});
const { res, text } = await handle(ogApp(), `/dumps/${dump.slug}`);
assertEquals(res.headers.get("content-type"), "text/html; charset=utf-8");
assertStringIncludes(
text,
`<meta property="og:title" content="Tom &amp; &quot;Jerry&quot; &lt;script&gt;" />`,
);
assertStringIncludes(
text,
`<title>Tom &amp; &quot;Jerry&quot; &lt;script&gt;</title>`,
);
assertStringIncludes(
text,
`<meta property="og:description" content="A comment" />`,
);
assertStringIncludes(
text,
`<meta property="og:image" content="https://img.test/a.jpg" />`,
);
assertStringIncludes(text, `content="summary_large_image"`);
assertStringIncludes(
text,
`<meta property="og:url" content="${BASE_URL}/dumps/${dump.slug}" />`,
);
// The index's own title is replaced, not duplicated.
assertEquals(text.match(/<title>/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");
});

View File

@@ -1,182 +0,0 @@
// 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, 16);
// Running again applies nothing.
runMigrations(db);
assertEquals(
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
n: number;
}).n,
16,
);
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<string, unknown> {
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();
});

View File

@@ -0,0 +1,13 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import { isAuthPayload, isPasswordResetPayload } from "../../model/auth.ts";
Deno.test("isAuthPayload refuses purpose-scoped tokens", () => {
const base = { userId: "u", username: "n", role: "user", exp: 1 };
assert(isAuthPayload(base));
assertFalse(isAuthPayload({ ...base, purpose: "media" }));
assertFalse(isAuthPayload({ ...base, role: "root" }));
assertFalse(isAuthPayload({ ...base, exp: "1" }));
assert(isPasswordResetPayload({ purpose: "password-reset", userId: "u" }));
assertFalse(isPasswordResetPayload({ purpose: "media", userId: "u" }));
});

View File

@@ -0,0 +1,30 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { dumpApiToRow, type DumpRow, dumpRowToApi } from "../../model/dump.ts";
Deno.test("dump rows round-trip through the API shape", () => {
const row: DumpRow = {
id: "id",
kind: "url",
title: "t",
slug: "t-id",
comment: null,
user_id: "u",
created_at: "2024-01-01T00:00:00.000Z",
updated_at: null,
url: "https://x",
rich_content: JSON.stringify({ type: "generic", url: "https://x" }),
file_name: null,
file_mime: null,
file_size: null,
vote_count: 3,
comment_count: 2,
is_private: 1,
custom_thumbnail_mime: null,
};
const dump = dumpRowToApi(row);
assertEquals(dump.isPrivate, true);
assertEquals(dump.richContent?.url, "https://x");
assertEquals(dump.comment, undefined);
assertEquals(dumpApiToRow(dump), row);
});

View File

@@ -1,209 +0,0 @@
// 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, type Tralbum } from "../services/bandcamp-tralbum.ts";
import {
parsePlaylistPage,
type YouTubePlaylist,
} from "../services/youtube-playlist-service.ts";
function escapeAttr(s: string): string {
return s.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
}
function tralbumPage(data: unknown): string {
return `<div id="player" data-tralbum="${
escapeAttr(JSON.stringify(data))
}"></div>`;
}
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" &amp; 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 &amp; survives the single-pass decode.
assertEquals(album.title, 'The "Album" &amp; 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("<html></html>", "u"), null);
assertEquals(parseTralbum(`<div data-tralbum="{oops"></div>`, "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 `<script>var ytInitialData = ${JSON.stringify(data)};</script>`;
}
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("<html></html>", "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<Tralbum>(
`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`,
);
assertEquals(res.status, 200);
assertEquals(res.headers.get("cache-control"), "private, max-age=3600");
assertEquals(res.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("<html>no tralbum</html>");
});
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<YouTubePlaylist>("/api/youtube/playlist?list=PLgood");
assertEquals(ok.status, 200);
assertEquals(ok.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"));
});

View File

@@ -0,0 +1,92 @@
import "../setup.ts";
import { assert, assertEquals } from "@std/assert";
import { parseTralbum } from "../../remote/bandcamp-tralbum.ts";
function escapeAttr(s: string): string {
return s.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
}
function tralbumPage(data: unknown): string {
return `<div id="player" data-tralbum="${
escapeAttr(JSON.stringify(data))
}"></div>`;
}
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" &amp; 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 &amp; survives the single-pass decode.
assertEquals(album.title, 'The "Album" &amp; 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("<html></html>", "u"), null);
assertEquals(parseTralbum(`<div data-tralbum="{oops"></div>`, "u"), null);
assertEquals(
parseTralbum(tralbumPage({ ...ALBUM, hasAudio: false }), "u"),
null,
);
assertEquals(
parseTralbum(
tralbumPage({ ...ALBUM, trackinfo: [ALBUM.trackinfo[1]] }),
"u",
),
null,
);
});

View File

@@ -0,0 +1,91 @@
import { stubNetwork } from "../helpers.ts";
import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert";
import {
fetchWithTimeout,
isValidHttpUrl,
readTextCapped,
} from "../../remote/fetch.ts";
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: the deadline covers a body that never ends", async () => {
// Headers at once, then a trickle that never finishes: the read must fail
// at the deadline rather than hold the request open.
using _net = stubNetwork((_url, init) =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("<html>"));
init?.signal?.addEventListener(
"abort",
() => controller.error(new DOMException("aborted", "AbortError")),
);
},
}),
{ headers: { "content-type": "text/html" } },
)
);
const res = await fetchWithTimeout("https://example.com/slow", 50);
await assertRejects(() => res.text());
});
Deno.test("readTextCapped: stops reading at the cap", async () => {
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
pulled++;
controller.enqueue(new TextEncoder().encode("abcdefghij"));
},
});
const text = await readTextCapped(new Response(body), 25);
assertEquals(text, "abcdefghijabcdefghijabcde");
assert(pulled < 10);
assertEquals(await readTextCapped(new Response("short")), "short");
});
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",
);
});

View File

@@ -0,0 +1,163 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import {
extractBestIcon,
extractFirstContentImage,
extractJsonLd,
extractLargeImage,
extractLinkHref,
extractMaskIconColor,
extractMetaName,
extractOgTag,
extractPageTitle,
extractThemeColor,
normalizeCssColor,
} from "../../remote/html.ts";
const BASE = "https://example.com/articles/1";
Deno.test("extractOgTag / extractMetaName: either attribute order, entities decoded", () => {
const page = `
<meta property="og:title" content="Tom &amp; Jerry &#8212; &#x2764;">
<meta content='Desc' property='og:description'>
<meta name="twitter:title" content="TW">
<meta name="og:title" content="wrong attribute">`;
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 = `<meta property="og:title"><meta name="x" content="leak">`;
assertEquals(extractOgTag(page, "title"), undefined);
});
Deno.test("extractPageTitle", () => {
assertEquals(
extractPageTitle("<title lang=en> A &lt;b&gt; </title>"),
"A <b>",
);
assertEquals(extractPageTitle("<p>none</p>"), undefined);
});
Deno.test("extractThemeColor: unscoped, then light, then any", () => {
assertEquals(
extractThemeColor(`
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff">`),
"#fff",
);
assertEquals(
extractThemeColor(`
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">
<meta name="theme-color" content="#123">`),
"#123",
);
assertEquals(
extractThemeColor(
`<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">`,
),
"#000",
);
assertEquals(
extractMaskIconColor(
`<link rel="mask-icon" href="/m.svg" color="#5bbad5">`,
),
"#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) =>
`<script type="application/ld+json">${JSON.stringify(data)}</script>`;
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(
`<script type="application/ld+json">{oops</script>${
ld({ name: "Second" })
}`,
).title,
"Second",
);
assertEquals(extractJsonLd("<p></p>"), {});
});
Deno.test("image extraction: large images, content images, relative URLs", () => {
const page = `
<img src="data:image/png;base64,xx" width="500" height="500">
<img src="/small.jpg" width="50" height="50">
<img src="/img/site-logo.png">
<img src="/icons/sprite.svg">
<img src="/photos/cat.jpg" width="640" height="480">`;
assertEquals(
extractLargeImage(page, BASE),
"https://example.com/photos/cat.jpg",
);
assertEquals(
extractFirstContentImage(page, BASE),
"https://example.com/small.jpg",
);
assertEquals(
extractFirstContentImage(`<img src="//cdn.test/a.jpg">`, BASE),
"https://cdn.test/a.jpg",
);
});
Deno.test("extractBestIcon: SVG beats everything, else the largest", () => {
assertEquals(
extractBestIcon(
`<link rel="icon" href="/16.png" sizes="16x16">
<link rel="apple-touch-icon" href="/180.png" sizes="180x180">
<link rel="stylesheet" href="/x.css">`,
BASE,
),
"https://example.com/180.png",
);
assertEquals(
extractBestIcon(
`<link rel="icon" href="/180.png" sizes="180x180">
<link rel="icon" type="image/svg+xml" href="/icon">`,
BASE,
),
"https://example.com/icon",
);
assertEquals(extractBestIcon("<p></p>", BASE), undefined);
assertEquals(
extractLinkHref(`<link href="/feed" rel="alternate">`, "alternate", BASE),
"https://example.com/feed",
);
});

View File

@@ -0,0 +1,14 @@
import { html, stubNetwork } from "../../helpers.ts";
import { assertEquals } from "@std/assert";
import { fetchRichContent } from "../../../services/rich-content-service.ts";
Deno.test("bandcamp provider", async () => {
using _ = stubNetwork(() =>
html(`<meta property="og:title" content="Album">
<meta property="og:video" content="https://bandcamp.com/EmbeddedPlayer/album=1">`)
);
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");
});

View File

@@ -0,0 +1,53 @@
import { html, stubNetwork } from "../../helpers.ts";
import { assertEquals } from "@std/assert";
import { fetchRichContent } from "../../../services/rich-content-service.ts";
const BASE = "https://example.com/articles/1";
Deno.test("generic provider: the full fallback chain", async () => {
using _ = stubNetwork(() =>
html(`<html><head>
<title>Page title</title>
<meta property="og:site_name" content="Example">
<meta property="og:title" content="OG title">
<meta property="og:image" content="https://example.com/og.jpg">
<meta name="description" content="Plain description">
<meta name="theme-color" content="rgb(0, 128, 255)">
<link rel="icon" href="/favicon-32.png" sizes="32x32">
</head></html>`)
);
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(`
<meta property="og:url" content="https://example.com/">
<meta property="og:title" content="Site name">
<meta property="og:image" content="https://example.com/site.jpg">
<meta name="twitter:title" content="Article title">
<img src="/photo.jpg">`)
);
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 });
});

View File

@@ -0,0 +1,28 @@
import { createUser, insertDump, stubNetwork, test } from "../../helpers.ts";
import { assertEquals } from "@std/assert";
import { fetchRichContent } from "../../../services/rich-content-service.ts";
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);
});

View File

@@ -0,0 +1,16 @@
import { html, stubNetwork } from "../../helpers.ts";
import { assertEquals, assertStringIncludes } from "@std/assert";
import { fetchRichContent } from "../../../services/rich-content-service.ts";
Deno.test("soundcloud provider", async () => {
using _ = stubNetwork(() =>
html(`<meta property="og:title" content="Album">
<meta property="og:video" content="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"),
);
});

View File

@@ -0,0 +1,57 @@
import { html, stubNetwork } from "../../helpers.ts";
import { assertEquals } from "@std/assert";
import { fetchRichContent } from "../../../services/rich-content-service.ts";
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(`<meta property="og:title" content="Channel">
<meta property="og:image" content="https://yt3/ch.jpg">`)
);
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);
});

View File

@@ -1,11 +1,11 @@
import "./setup.ts";
import "../setup.ts";
import { stubNetwork } from "../helpers.ts";
import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert";
import {
isPrivateAddress,
isPrivateHostname,
resolvePublicAddress,
} from "../lib/public-address.ts";
import { stubNetwork } from "./helpers.ts";
} from "../../remote/public-address.ts";
Deno.test("isPrivateAddress: private and reserved IPv4 ranges", () => {
for (

View File

@@ -0,0 +1,53 @@
import "../setup.ts";
import { assertEquals } from "@std/assert";
import { parsePlaylistPage } from "../../remote/youtube-playlist.ts";
function ytPage(data: unknown): string {
return `<script>var ytInitialData = ${JSON.stringify(data)};</script>`;
}
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("<html></html>", "PLx"), null);
assertEquals(parsePlaylistPage(ytPage({ contents: {} }), "PLx"), null);
});

View File

@@ -1,477 +0,0 @@
// Link previews: the HTML extractors, the providers, and fetchWithTimeout's
// guard on every hop. All network access is stubbed.
import {
createUser,
get,
html,
insertDump,
PNG,
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,
readTextCapped,
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 = `
<meta property="og:title" content="Tom &amp; Jerry &#8212; &#x2764;">
<meta content='Desc' property='og:description'>
<meta name="twitter:title" content="TW">
<meta name="og:title" content="wrong attribute">`;
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 = `<meta property="og:title"><meta name="x" content="leak">`;
assertEquals(extractOgTag(page, "title"), undefined);
});
Deno.test("extractPageTitle", () => {
assertEquals(
extractPageTitle("<title lang=en> A &lt;b&gt; </title>"),
"A <b>",
);
assertEquals(extractPageTitle("<p>none</p>"), undefined);
});
Deno.test("extractThemeColor: unscoped, then light, then any", () => {
assertEquals(
extractThemeColor(`
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff">`),
"#fff",
);
assertEquals(
extractThemeColor(`
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">
<meta name="theme-color" content="#123">`),
"#123",
);
assertEquals(
extractThemeColor(
`<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">`,
),
"#000",
);
assertEquals(
extractMaskIconColor(
`<link rel="mask-icon" href="/m.svg" color="#5bbad5">`,
),
"#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) =>
`<script type="application/ld+json">${JSON.stringify(data)}</script>`;
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(
`<script type="application/ld+json">{oops</script>${
ld({ name: "Second" })
}`,
).title,
"Second",
);
assertEquals(extractJsonLd("<p></p>"), {});
});
Deno.test("image extraction: large images, content images, relative URLs", () => {
const page = `
<img src="data:image/png;base64,xx" width="500" height="500">
<img src="/small.jpg" width="50" height="50">
<img src="/img/site-logo.png">
<img src="/icons/sprite.svg">
<img src="/photos/cat.jpg" width="640" height="480">`;
assertEquals(
extractLargeImage(page, BASE),
"https://example.com/photos/cat.jpg",
);
assertEquals(
extractFirstContentImage(page, BASE),
"https://example.com/small.jpg",
);
assertEquals(
extractFirstContentImage(`<img src="//cdn.test/a.jpg">`, BASE),
"https://cdn.test/a.jpg",
);
});
Deno.test("extractBestIcon: SVG beats everything, else the largest", () => {
assertEquals(
extractBestIcon(
`<link rel="icon" href="/16.png" sizes="16x16">
<link rel="apple-touch-icon" href="/180.png" sizes="180x180">
<link rel="stylesheet" href="/x.css">`,
BASE,
),
"https://example.com/180.png",
);
assertEquals(
extractBestIcon(
`<link rel="icon" href="/180.png" sizes="180x180">
<link rel="icon" type="image/svg+xml" href="/icon">`,
BASE,
),
"https://example.com/icon",
);
assertEquals(extractBestIcon("<p></p>", BASE), undefined);
assertEquals(
extractLinkHref(`<link href="/feed" rel="alternate">`, "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: the deadline covers a body that never ends", async () => {
// Headers at once, then a trickle that never finishes: the read must fail
// at the deadline rather than hold the request open.
using _net = stubNetwork((_url, init) =>
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("<html>"));
init?.signal?.addEventListener(
"abort",
() => controller.error(new DOMException("aborted", "AbortError")),
);
},
}),
{ headers: { "content-type": "text/html" } },
)
);
const res = await fetchWithTimeout("https://example.com/slow", 50);
await assertRejects(() => res.text());
});
Deno.test("tryFetchRichContent: a preview and the post that follows share one fetch", async () => {
using net = stubNetwork(() => html(`<title>Once</title>`));
const first = await tryFetchRichContent("https://example.com/once");
const second = await tryFetchRichContent("https://example.com/once");
assertEquals(second, first);
assertEquals(net.calls.length, 1);
// A refresh always goes to the page.
await tryFetchRichContent("https://example.com/once", { fresh: true });
assertEquals(net.calls.length, 2);
});
Deno.test("tryFetchRichContent: failures aren't kept", async () => {
let up = false;
using net = stubNetwork(() => {
if (!up) throw new TypeError("connection refused");
return html(`<title>Up</title>`);
});
const url = "https://example.com/flaky";
assertFalse((await tryFetchRichContent(url)).ok);
up = true;
const { ok, content } = await tryFetchRichContent(url);
assert(ok);
assertEquals(content?.title, "Up");
assertEquals(net.calls.length, 2);
});
Deno.test("readTextCapped: stops reading at the cap", async () => {
let pulled = 0;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
pulled++;
controller.enqueue(new TextEncoder().encode("abcdefghij"));
},
});
const text = await readTextCapped(new Response(body), 25);
assertEquals(text, "abcdefghijabcdefghijabcde");
assert(pulled < 10);
assertEquals(await readTextCapped(new Response("short")), "short");
});
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(`<html><head>
<title>Page title</title>
<meta property="og:site_name" content="Example">
<meta property="og:title" content="OG title">
<meta property="og:image" content="https://example.com/og.jpg">
<meta name="description" content="Plain description">
<meta name="theme-color" content="rgb(0, 128, 255)">
<link rel="icon" href="/favicon-32.png" sizes="32x32">
</head></html>`)
);
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(`
<meta property="og:url" content="https://example.com/">
<meta property="og:title" content="Site name">
<meta property="og:image" content="https://example.com/site.jpg">
<meta name="twitter:title" content="Article title">
<img src="/photo.jpg">`)
);
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(`<meta property="og:title" content="Channel">
<meta property="og:image" content="https://yt3/ch.jpg">`)
);
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(`<meta property="og:title" content="Album">
<meta property="og:video" content="https://bandcamp.com/EmbeddedPlayer/album=1">`)
);
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);
});
Deno.test("GET /api/proxy-image: rasters only, fetched once for everyone", async () => {
using net = stubNetwork((url) =>
url.pathname.endsWith(".svg")
? new Response("<svg/>", { headers: { "content-type": "image/svg+xml" } })
: new Response(PNG, { headers: { "content-type": "image/png" } })
);
const path = `/api/proxy-image?url=${
encodeURIComponent("https://cdn.example.com/a.png")
}`;
const first = await get(path);
assertEquals(first.status, 200);
assertEquals(first.headers.get("content-type"), "image/png");
assertEquals(first.bytes, PNG);
const second = await get(path);
assertEquals(second.bytes, PNG);
assertEquals(net.calls.length, 1);
const svg = await get(
`/api/proxy-image?url=${
encodeURIComponent("https://cdn.example.com/a.svg")
}`,
);
assertEquals(svg.status, 400);
});

View File

@@ -0,0 +1,90 @@
import {
count,
createUser,
db,
formWithFile,
get,
insertDump,
PNG,
post,
test,
} from "../helpers.ts";
import type { AttachmentResponse } from "../../../shared/dump.ts";
import type { Comment } from "../../../shared/comment.ts";
import { assertEquals } from "@std/assert";
test("attachments: upload an image, serve it back", async () => {
const user = await createUser();
const res = await post<AttachmentResponse>("/api/attachments", {
auth: user.auth,
form: formWithFile(PNG),
});
assertEquals(res.status, 201);
const id = res.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("<script>"),
"x.png",
"image/png",
),
})).status,
400,
);
assertEquals(
(await post("/api/attachments", { auth: user.auth, json: {} })).status,
400,
);
const empty = new FormData();
empty.set("other", "x");
assertEquals(
(await post("/api/attachments", { auth: user.auth, form: empty })).status,
400,
);
assertEquals(
(await post("/api/attachments", { form: formWithFile(PNG) })).status,
401,
);
assertEquals(count(`SELECT COUNT(*) as n FROM attachments;`), 0);
});
test("attachments are claimed by the first text that links them", async () => {
const user = await createUser();
const { id: dumpId } = insertDump({ userId: user.id });
const up = await post<AttachmentResponse>("/api/attachments", {
auth: user.auth,
form: formWithFile(PNG),
});
const attachmentId = up.data.id;
const link = `![img](/api/attachments/${attachmentId})`;
const comment = await post<Comment>(`/api/dumps/${dumpId}/comments`, {
auth: user.auth,
json: { body: link },
});
const owner = () =>
(db.prepare(`SELECT resource_id FROM attachments WHERE id = ?;`).get(
attachmentId,
) as { resource_id: string | null }).resource_id;
assertEquals(owner(), comment.data.id);
// A second text can't take it over.
await post(`/api/dumps/${dumpId}/comments`, {
auth: user.auth,
json: { body: link },
});
assertEquals(owner(), comment.data.id);
});

View File

@@ -0,0 +1,26 @@
import { createUser, formWithFile, get, PNG, post, test } from "../helpers.ts";
import type { SelfUser } from "../../../shared/user.ts";
import { assertEquals, assertFalse } from "@std/assert";
test("avatars: upload, serve, and 404 when there is none", async () => {
const user = await createUser();
assertEquals((await get(`/api/avatars/${user.id}`)).status, 404);
assertEquals((await get(`/api/avatars/${crypto.randomUUID()}`)).status, 404);
const res = await post<SelfUser>("/api/avatars/me", {
auth: user.auth,
form: formWithFile(PNG),
});
assertEquals(res.status, 200);
assertEquals(res.data.avatarMime, "image/png");
assertFalse("passwordHash" in res.data);
const served = await get(`/api/avatars/${user.id}`);
assertEquals(served.status, 200);
assertEquals(served.bytes, PNG);
assertEquals(
(await post("/api/avatars/me", {
auth: user.auth,
form: formWithFile(new Uint8Array([1, 2, 3])),
})).status,
400,
);
});

View File

@@ -0,0 +1,89 @@
import { get, html, stubNetwork } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import type { Tralbum } from "../../../shared/player.ts";
function escapeAttr(s: string): string {
return s.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
}
function tralbumPage(data: unknown): string {
return `<div id="player" data-tralbum="${
escapeAttr(JSON.stringify(data))
}"></div>`;
}
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" &amp; 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("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<Tralbum>(
`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`,
);
assertEquals(res.status, 200);
assertEquals(res.headers.get("cache-control"), "private, max-age=3600");
assertEquals(res.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("<html>no tralbum</html>");
});
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);
});

View File

@@ -5,18 +5,14 @@ import {
get,
insertCategory,
insertDump,
insertPlaylist,
post,
put,
test,
} from "./helpers.ts";
import type {
Category,
Dump,
PaginatedData,
SearchResults,
} from "../model/interfaces.ts";
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
} from "../helpers.ts";
import type { Category } from "../../../shared/category.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import { assertEquals, assertStringIncludes } from "@std/assert";
test("categories: listed by position, then slug; found by slug", async () => {
insertCategory({ slug: "zeta", position: 0 });
@@ -152,57 +148,3 @@ test("GET /api/categories/:slug/dumps: members only, private for the owner", asy
[],
);
});
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<SearchResults>("/api/search?q=banana")).data;
assertEquals(anon.dumps.total, 3);
assertEquals(anon.users.map((u) => u.id), [other.id]);
// Profiles only: a search must never hand out hashes or emails.
assertFalse("passwordHash" in anon.users[0]);
assertFalse("email" in anon.users[0]);
assertEquals(anon.playlists.map((p) => p.title), [
"Banana hits",
]);
const mine =
(await get<SearchResults>("/api/search?q=banana", { auth: owner.auth }))
.data;
assertEquals(mine.dumps.total, 4);
assertEquals(mine.playlists.length, 2);
const paged =
(await get<SearchResults>("/api/search?q=banana&limit=2")).data.dumps;
assertEquals(paged.items.length, 2);
assertEquals(paged.hasMore, true);
assertEquals((await get<SearchResults>("/api/search?q=%20%20")).data, {
dumps: { items: [], total: 0, hasMore: false },
users: [],
playlists: [],
});
});

View File

@@ -0,0 +1,36 @@
import { createUser, db, get, test } from "../helpers.ts";
import { assertEquals } from "@std/assert";
import type { ChatMessage } from "../../../shared/chat.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("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<ChatMessage[]>(`/api/chat${q}`, { auth: user.auth })).data.map(
(m) => 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"]);
});

View File

@@ -9,19 +9,17 @@ import {
patch,
post,
test,
} from "./helpers.ts";
} from "../helpers.ts";
import { assertEquals, assertExists, assertFalse } from "@std/assert";
import {
castCommentLike,
removeCommentLike,
} from "../services/comment-like-service.ts";
import {
APIException,
type Comment,
type Dump,
type PaginatedData,
type PublicUser,
} from "../model/interfaces.ts";
} from "../../services/comment-like-service.ts";
import { APIException } from "../../lib/api-exception.ts";
import type { Comment } from "../../../shared/comment.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import type { PublicUser } from "../../../shared/user.ts";
test("comments: post, list in order, count on the dump", async () => {
const owner = await createUser();

View File

@@ -16,16 +16,12 @@ import {
stubNetwork,
test,
TEST_DIR,
} from "./helpers.ts";
import type {
Dump,
DumpUrlMatch,
PaginatedData,
Playlist,
PublicUser,
} from "../model/interfaces.ts";
} from "../helpers.ts";
import type { Dump, DumpUrlMatch } from "../../../shared/dump.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import type { PublicUser } from "../../../shared/user.ts";
import {
assert,
assertEquals,
assertExists,
assertFalse,
@@ -769,29 +765,3 @@ test("DELETE /api/dumps/:id removes the dump, its file and what hangs off it", a
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}`;
const total = async (opts?: { auth: { Authorization: string } }) =>
(await get<PaginatedData<Dump>>(path, opts)).data.items.length;
assertEquals(await total(), 1, sub);
assertEquals(await total({ auth: other.auth }), 1, sub);
assertEquals(
await total({ auth: owner.auth }),
2,
sub,
);
}
assert((await get(`/api/users/nobody_at_all/dumps`)).status === 404);
});

View File

@@ -0,0 +1,191 @@
import { createUser, db, get, insertDump, test, TEST_DIR } from "../helpers.ts";
import { assertEquals, assertStringIncludes } from "@std/assert";
const BYTES = new TextEncoder().encode("0123456789");
function createdAtOf(id: string): string {
return (db.prepare(`SELECT created_at FROM dumps WHERE id = ?;`).get(id) as {
created_at: string;
}).created_at;
}
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;");
// No version in the URL: kept, but asked about again each time.
assertEquals(res.headers.get("cache-control"), "public, no-cache");
const etag = res.headers.get("etag")!;
assertStringIncludes(etag, 'W/"');
// The client already has it: a 304, no bytes.
const again = await get(`/api/files/${id}`, {
headers: { "If-None-Match": etag },
});
assertEquals(again.status, 304);
assertEquals(again.bytes.byteLength, 0);
// The app's URL names the file's current version, which never changes.
const v = `${BYTES.byteLength}-${Date.parse(createdAtOf(id))}`;
assertEquals(
(await get(`/api/files/${id}?v=${v}`)).headers.get("cache-control"),
"public, max-age=31536000, immutable",
);
// A stale version is revalidated like no version at all.
assertEquals(
(await get(`/api/files/${id}?v=10-0`)).headers.get("cache-control"),
"public, no-cache",
);
});
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=-",
]
) {
res = await range(bad);
assertEquals(res.status, 416, bad);
assertEquals(res.headers.get("content-range"), "bytes */10", bad);
}
// Several ranges, or a unit other than bytes, aren't refused: the header is
// ignored and the whole file sent, as RFC 9110 allows.
for (
const unsupported of [
"bytes=0-0,5-6",
"bytes=0-1, 3-4",
"items=0-1",
"bytes=zero-one",
]
) {
res = await range(unsupported);
assertEquals(res.status, 200, unsupported);
assertEquals(new TextDecoder().decode(res.bytes), "0123456789");
assertEquals(res.headers.get("content-range"), null, unsupported);
assertEquals(res.headers.get("content-length"), "10", unsupported);
}
});
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, no-cache");
// 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);
});

View File

@@ -1,4 +1,3 @@
// Follows, feeds, notifications and invites.
import {
type ApiResponse,
count,
@@ -9,21 +8,15 @@ import {
insertCategory,
insertDump,
insertPlaylist,
patch,
post,
test,
} from "./helpers.ts";
import type {
Comment,
Dump,
FollowStatus,
InviteResponse,
Notification,
PaginatedData,
Playlist,
PublicUser,
} from "../model/interfaces.ts";
import { assertEquals, assertFalse, assertMatch } from "@std/assert";
} from "../helpers.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { FollowStatus } from "../../../shared/follow.ts";
import type { PublicUser } from "../../../shared/user.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import type { Playlist } from "../../../shared/playlist.ts";
import { assertEquals, assertFalse } from "@std/assert";
function titles(res: ApiResponse<PaginatedData<Dump>>): string[] {
return res.data.items.map((d) => d.title);
@@ -189,124 +182,3 @@ test("followed-playlists feed: each dump once, public playlists only", async ()
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<PaginatedData<Notification>>("/api/notifications", {
auth: me.auth,
});
assertEquals(list.data.items.length, 2);
assertEquals(
list.data.items.map((n) => n.type),
["dump_commented", "user_followed"],
);
assertEquals(
list.data.items.every((n) => !n.read),
true,
);
const theirs = (await get<PaginatedData<Notification>>("/api/notifications", {
auth: other.auth,
})).data
.items[0].id;
assertEquals(
(await patch(`/api/notifications/${theirs}/read`, { auth: me.auth }))
.status,
204,
);
assertEquals(
(await get<PaginatedData<Notification>>("/api/notifications", {
auth: other.auth,
})).data.items[0]
.read,
false,
);
const first = list.data.items[0].id;
await patch(`/api/notifications/${first}/read`, { auth: me.auth });
const after = (await get<PaginatedData<Notification>>("/api/notifications", {
auth: me.auth,
})).data.items;
assertEquals(after.map((n) => 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<Comment>(`/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.data.id,
contextTitle: "Topic",
dumpId,
});
// Editing the comment doesn't notify a second time.
await patch(`/api/comments/${c.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<InviteResponse>("/api/invites", { auth: user.auth });
assertEquals(res.status, 201);
const token = res.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);
});

View File

@@ -0,0 +1,24 @@
import { createUser, db, get, post, test } from "../helpers.ts";
import type { InviteResponse } from "../../../shared/user.ts";
import { assertEquals, assertMatch } from "@std/assert";
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<InviteResponse>("/api/invites", { auth: user.auth });
assertEquals(res.status, 201);
const token = res.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);
});

View File

@@ -0,0 +1,75 @@
import {
count,
createUser,
get,
insertDump,
patch,
post,
test,
} from "../helpers.ts";
import type { Notification } from "../../../shared/notification.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import { assertEquals } from "@std/assert";
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<PaginatedData<Notification>>("/api/notifications", {
auth: me.auth,
});
assertEquals(list.data.items.length, 2);
assertEquals(
list.data.items.map((n) => n.type),
["dump_commented", "user_followed"],
);
assertEquals(
list.data.items.every((n) => !n.read),
true,
);
const theirs = (await get<PaginatedData<Notification>>("/api/notifications", {
auth: other.auth,
})).data
.items[0].id;
assertEquals(
(await patch(`/api/notifications/${theirs}/read`, { auth: me.auth }))
.status,
204,
);
assertEquals(
(await get<PaginatedData<Notification>>("/api/notifications", {
auth: other.auth,
})).data.items[0]
.read,
false,
);
const first = list.data.items[0].id;
await patch(`/api/notifications/${first}/read`, { auth: me.auth });
const after = (await get<PaginatedData<Notification>>("/api/notifications", {
auth: me.auth,
})).data.items;
assertEquals(after.map((n) => 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);
});

View File

@@ -13,13 +13,13 @@ import {
put,
test,
TEST_DIR,
} from "./helpers.ts";
} from "../helpers.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import type {
PaginatedData,
Playlist,
PlaylistMembership,
PlaylistWithDumps,
} from "../model/interfaces.ts";
} from "../../../shared/playlist.ts";
import { assert, assertEquals, assertMatch } from "@std/assert";
function ids(res: ApiResponse<PlaylistWithDumps>): string[] {

View File

@@ -0,0 +1,27 @@
import { get, PNG, stubNetwork } from "../helpers.ts";
import { assertEquals } from "@std/assert";
Deno.test("GET /api/proxy-image: rasters only, fetched once for everyone", async () => {
using net = stubNetwork((url) =>
url.pathname.endsWith(".svg")
? new Response("<svg/>", { headers: { "content-type": "image/svg+xml" } })
: new Response(PNG, { headers: { "content-type": "image/png" } })
);
const path = `/api/proxy-image?url=${
encodeURIComponent("https://cdn.example.com/a.png")
}`;
const first = await get(path);
assertEquals(first.status, 200);
assertEquals(first.headers.get("content-type"), "image/png");
assertEquals(first.bytes, PNG);
const second = await get(path);
assertEquals(second.bytes, PNG);
assertEquals(net.calls.length, 1);
const svg = await get(
`/api/proxy-image?url=${
encodeURIComponent("https://cdn.example.com/a.svg")
}`,
);
assertEquals(svg.status, 400);
});

View File

@@ -0,0 +1,64 @@
import {
createUser,
db,
get,
insertDump,
insertPlaylist,
test,
} from "../helpers.ts";
import type { SearchResults } from "../../../shared/search.ts";
import { assertEquals, assertFalse } from "@std/assert";
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<SearchResults>("/api/search?q=banana")).data;
assertEquals(anon.dumps.total, 3);
assertEquals(anon.users.map((u) => u.id), [other.id]);
// Profiles only: a search must never hand out hashes or emails.
assertFalse("passwordHash" in anon.users[0]);
assertFalse("email" in anon.users[0]);
assertEquals(anon.playlists.map((p) => p.title), [
"Banana hits",
]);
const mine =
(await get<SearchResults>("/api/search?q=banana", { auth: owner.auth }))
.data;
assertEquals(mine.dumps.total, 4);
assertEquals(mine.playlists.length, 2);
const paged =
(await get<SearchResults>("/api/search?q=banana&limit=2")).data.dumps;
assertEquals(paged.items.length, 2);
assertEquals(paged.hasMore, true);
assertEquals((await get<SearchResults>("/api/search?q=%20%20")).data, {
dumps: { items: [], total: 0, hasMore: false },
users: [],
playlists: [],
});
});

View File

@@ -0,0 +1,133 @@
import {
createUser,
db,
get,
insertDump,
PNG,
test,
TEST_DIR,
} from "../helpers.ts";
import { assertEquals } from "@std/assert";
const BYTES = new TextEncoder().encode("0123456789");
function createdAtOf(id: string): string {
return (db.prepare(`SELECT created_at FROM dumps WHERE id = ?;`).get(id) as {
created_at: string;
}).created_at;
}
async function hasFfmpeg(): Promise<boolean> {
try {
return (await new Deno.Command("ffmpeg", {
args: ["-version"],
stdout: "null",
stderr: "null",
}).output()).success;
} catch {
return false;
}
}
function readDir(path: string): string[] {
try {
return [...Deno.readDirSync(path)].map((e) => e.name);
} catch {
return [];
}
}
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, no-cache");
const v = (id: string) => `5-${Date.parse(createdAtOf(id))}`;
assertEquals(
(await get(`/api/thumbnails/${video.id}?v=${v(video.id)}`)).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}?v=${v(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("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", async () => {
const owner = await createUser();
const gif = new TextEncoder().encode("GIF89a-not-really");
const animated = insertDump({
userId: owner.id,
fileName: "a.gif",
fileMime: "image/gif",
fileData: gif,
});
const res = await get(`/api/thumbnails/${animated.id}`);
assertEquals(res.status, 200);
assertEquals(res.headers.get("content-type"), "image/gif");
assertEquals(res.bytes, gif);
// A real PNG is scaled into a preview, kept beside the video stills.
const photo = insertDump({
userId: owner.id,
fileName: "p.png",
fileMime: "image/png",
fileData: PNG,
});
const preview = await get(`/api/thumbnails/${photo.id}`);
assertEquals(preview.status, 200);
assertEquals(preview.headers.get("content-type"), "image/png");
if (await hasFfmpeg()) {
assertEquals(
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
n.startsWith(photo.id)
),
[`${photo.id}.preview.png`],
);
}
// Not an image ffmpeg can read (or no ffmpeg at all): the original goes out.
const broken = insertDump({
userId: owner.id,
fileName: "b.png",
fileMime: "image/png",
fileData: BYTES,
});
const fallback = await get(`/api/thumbnails/${broken.id}`);
assertEquals(fallback.status, 200);
assertEquals(fallback.headers.get("content-type"), "image/png");
assertEquals(fallback.bytes, BYTES);
assertEquals(
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
n.startsWith(broken.id)
),
[],
);
});

View File

@@ -1,164 +1,216 @@
// Tokens, passwords and the checks that turn a token into a user.
import { count, createUser, db, get, PASSWORD, post, test } from "./helpers.ts";
import {
count,
createUser,
db,
del,
get,
insertDump,
PASSWORD,
patch,
post,
test,
} from "../helpers.ts";
import type {
AuthResponse,
AuthTokens,
Invitee,
MediaTokenResponse,
} from "../model/interfaces.ts";
PublicUser,
SelfUser,
UserSuggestion,
} from "../../../shared/user.ts";
import { assert, assertEquals, assertFalse } from "@std/assert";
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";
} from "../../lib/jwt.ts";
import { authenticateMedia } from "../../lib/auth.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData } from "../../../shared/api.ts";
const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret");
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;
}
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 () => {
test("public profile: by username or id, without private fields", 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,
);
for (
const path of [`/api/users/${user.username}`, `/api/users/by-id/${user.id}`]
) {
const res = await get<PublicUser>(path);
assertEquals(res.status, 200, path);
assertEquals(res.data.username, user.username);
assertFalse("email" in res.data);
assertFalse("passwordHash" in res.data);
}
assertEquals((await get(`/api/users/no_one_here`)).status, 404);
const me = await get<SelfUser>("/api/users/me", { auth: user.auth });
assertEquals(me.data.email, user.email);
assertFalse("passwordHash" in me.data);
});
test("PATCH /api/users/me: description and email only", async () => {
const user = await createUser();
const taken = await createUser();
const res = await patch<PublicUser>("/api/users/me", {
auth: user.auth,
json: { description: "about me", email: "new@example.test" },
});
assertEquals(res.status, 200);
assertEquals(res.data.description, "about me");
assertFalse("email" in res.data);
assertEquals(
(await get(path, { headers: { Authorization: "Basic abc" } })).status,
(await get<SelfUser>("/api/users/me", { auth: user.auth })).data.email,
"new@example.test",
);
// A role, username or password in the body is ignored, not applied.
await patch("/api/users/me", {
auth: user.auth,
json: { role: "admin", username: "hijack", password: "whatever-long" },
});
const row = db.prepare(`SELECT role, username FROM users WHERE id = ?;`).get(
user.id,
) as {
role: string;
username: string;
};
assertEquals(row, { role: "user", username: user.username });
assertEquals(
(await patch("/api/users/me", {
auth: user.auth,
json: { email: taken.email },
})).status,
409,
);
assertEquals(
(await patch("/api/users/me", { auth: user.auth, json: { email: "bad" } }))
.status,
400,
);
assertEquals(
(await patch("/api/users/me", {
auth: user.auth,
json: { description: "x".repeat(2001) },
})).status,
400,
);
});
test("PATCH /api/users/:id/role: admins only, never their own", async () => {
const admin = await createUser({ role: "admin" });
const mod = await createUser({ role: "moderator" });
const user = await createUser();
assertEquals(
(await patch(`/api/users/${user.id}/role`, {
auth: mod.auth,
json: { role: "admin" },
}))
.status,
403,
);
assertEquals(
(await patch(`/api/users/${admin.id}/role`, {
auth: admin.auth,
json: { role: "user" },
}))
.status,
400,
);
assertEquals(
(await patch(`/api/users/${user.id}/role`, {
auth: admin.auth,
json: { role: "god" },
}))
.status,
400,
);
const res = await patch<PublicUser>(`/api/users/${user.id}/role`, {
auth: admin.auth,
json: { role: "moderator" },
});
assertEquals(res.status, 200);
assertEquals(res.data.role, "moderator");
assertFalse("email" in res.data);
// The promoted user's existing token now carries moderator powers.
const { id: dumpId } = insertDump({ userId: admin.id });
assertEquals(
(await del(`/api/dumps/${dumpId}`, { auth: user.auth })).status,
200,
);
const bad = await get(path, { headers: { Authorization: "Bearer nope" } });
assertEquals(bad.status, 401);
assertEquals(bad.error.code, "UNAUTHORIZED");
assertEquals(
(await patch(`/api/users/${crypto.randomUUID()}/role`, {
auth: admin.auth,
json: { role: "user" },
})).status,
404,
);
});
test("required auth: missing, malformed and forged tokens are 401", async () => {
assertEquals((await get("/api/users/me")).status, 401);
test("GET /api/users/search: prefix match for mentions, capped at 8", async () => {
for (let i = 0; i < 10; i++) await createUser({ username: `zed_${i}` });
await createUser({ username: "other_zed" });
const res = await get<UserSuggestion[]>("/api/users/search?q=zed");
assertEquals(res.data.length, 8);
assertEquals(res.data[0], {
id: res.data[0].id,
username: "zed_0",
avatarMime: null,
});
assertEquals((await get<UserSuggestion[]>("/api/users/search?q=")).data, []);
});
test("GET /api/users/:username/invitees: the invite tree below a user", async () => {
const root = await createUser();
const child = await createUser({
invitedBy: root.id,
createdAt: "2024-01-01T00:00:00Z",
});
const grandchild = await createUser({
invitedBy: child.id,
createdAt: "2024-01-02T00:00:00Z",
});
const tree =
(await get<Invitee[]>(`/api/users/${root.username}/invitees`)).data;
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,
tree.map((
n: { username: string; invitedById: string },
) => [n.username, n.invitedById]),
[[child.username, root.id], [grandchild.username, child.id]],
);
const profile = await get<PublicUser>(`/api/users/${child.username}`);
assertEquals(profile.data.invitedByUsername, root.username);
});
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}`;
const total = async (opts?: { auth: { Authorization: string } }) =>
(await get<PaginatedData<Dump>>(path, opts)).data.items.length;
assertEquals(await total(), 1, sub);
assertEquals(await total({ auth: other.auth }), 1, sub);
assertEquals(
await total({ auth: owner.auth }),
2,
sub,
);
}
assert((await get(`/api/users/nobody_at_all/dumps`)).status === 404);
});
// ── Login and register ───────────────────────────────────────────────────────
@@ -216,14 +268,6 @@ test("POST /api/users/login: rate limited per account, case-insensitively", asyn
);
});
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);

View File

@@ -1,5 +1,3 @@
// The live side over a real socket: presence, votes, likes, chat, and the
// broadcasts REST writes trigger. Each test runs its own server.
import {
app,
count,
@@ -15,14 +13,13 @@ import {
post,
put,
resetDb,
test,
type TestUser,
} from "./helpers.ts";
} from "../helpers.ts";
import { assert, assertEquals, assertExists, assertFalse } from "@std/assert";
import type {
AuthTokens,
Dump,
ServerToClientMessage,
} from "../model/interfaces.ts";
import type { AuthTokens } from "../../../shared/user.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { ServerToClientMessage } from "../../../shared/ws.ts";
// deno.json's "dom" lib types WebSocket's second argument as protocols only,
// hiding Deno's own overload, which takes handshake headers.
@@ -32,6 +29,7 @@ const DenoWebSocket = WebSocket as unknown as new (
) => WebSocket;
type Msg = ServerToClientMessage;
type MsgOf<T extends Msg["type"]> = Extract<Msg, { type: T }>;
interface Client {
@@ -158,6 +156,18 @@ function wsTest(name: string, fn: Parameters<typeof withServer>[0]) {
});
}
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,
);
});
wsTest("welcome: anonymous and signed-in state", async (connect) => {
const user = await createUser({ createdAt: "2024-01-01T00:00:00.000Z" });
const { id: dumpId } = insertDump({ userId: user.id });

View File

@@ -0,0 +1,63 @@
import { get, html, stubNetwork } from "../helpers.ts";
import { assertEquals, assertFalse } from "@std/assert";
import type { YouTubePlaylist } from "../../../shared/player.ts";
function ytPage(data: unknown): string {
return `<script>var ytInitialData = ${JSON.stringify(data)};</script>`;
}
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("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<YouTubePlaylist>("/api/youtube/playlist?list=PLgood");
assertEquals(ok.status, 200);
assertEquals(ok.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"));
});

View File

@@ -1,6 +1,4 @@
// 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 { createUser, db, test } from "../helpers.ts";
import { assertEquals, assertThrows } from "@std/assert";
import {
createChatMessage,
@@ -10,8 +8,8 @@ import {
getRecentChatMessages,
markChatRead,
updateChatMessage,
} from "../services/chat-service.ts";
import { APIException, type ChatMessage } from "../model/interfaces.ts";
} from "../../services/chat-service.ts";
import { APIException } from "../../lib/api-exception.ts";
function insertMessage(
userId: string,
@@ -125,24 +123,6 @@ test("history pages: newest page in ascending order, older pages by cursor", asy
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<ChatMessage[]>(`/api/chat${q}`, { auth: user.auth })).data.map(
(m) => 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();

View File

@@ -0,0 +1,48 @@
import {
count,
createUser,
db,
insertDump,
patch,
post,
test,
} from "../helpers.ts";
import type { Comment } from "../../../shared/comment.ts";
import { assertEquals } from "@std/assert";
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<Comment>(`/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.data.id,
contextTitle: "Topic",
dumpId,
});
// Editing the comment doesn't notify a second time.
await patch(`/api/comments/${c.data.id}`, {
auth: author.auth,
json: { body: `again @${named.username}` },
});
assertEquals(
count(
`SELECT COUNT(*) as n FROM notifications WHERE type = 'user_mentioned';`,
),
1,
);
});

View File

@@ -0,0 +1,43 @@
import { html, stubNetwork } from "../helpers.ts";
import { assert, assertEquals, assertFalse } from "@std/assert";
import { tryFetchRichContent } from "../../services/rich-content-service.ts";
Deno.test("tryFetchRichContent: a preview and the post that follows share one fetch", async () => {
using net = stubNetwork(() => html(`<title>Once</title>`));
const first = await tryFetchRichContent("https://example.com/once");
const second = await tryFetchRichContent("https://example.com/once");
assertEquals(second, first);
assertEquals(net.calls.length, 1);
// A refresh always goes to the page.
await tryFetchRichContent("https://example.com/once", { fresh: true });
assertEquals(net.calls.length, 2);
});
Deno.test("tryFetchRichContent: failures aren't kept", async () => {
let up = false;
using net = stubNetwork(() => {
if (!up) throw new TypeError("connection refused");
return html(`<title>Up</title>`);
});
const url = "https://example.com/flaky";
assertFalse((await tryFetchRichContent(url)).ok);
up = true;
const { ok, content } = await tryFetchRichContent(url);
assert(ok);
assertEquals(content?.title, "Up");
assertEquals(net.calls.length, 2);
});
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",
},
});
});

View File

@@ -1,5 +1,5 @@
// 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
// module is evaluated. api/config.ts reads the environment and api/db/connection.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

View File

@@ -1,423 +0,0 @@
// 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 type {
AttachmentResponse,
Comment,
SelfUser,
} from "../model/interfaces.ts";
import { assertEquals, assertFalse, 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;");
// No version in the URL: kept, but asked about again each time.
assertEquals(res.headers.get("cache-control"), "public, no-cache");
const etag = res.headers.get("etag")!;
assertStringIncludes(etag, 'W/"');
// The client already has it: a 304, no bytes.
const again = await get(`/api/files/${id}`, {
headers: { "If-None-Match": etag },
});
assertEquals(again.status, 304);
assertEquals(again.bytes.byteLength, 0);
// The app's URL names the file's current version, which never changes.
const v = `${BYTES.byteLength}-${Date.parse(createdAtOf(id))}`;
assertEquals(
(await get(`/api/files/${id}?v=${v}`)).headers.get("cache-control"),
"public, max-age=31536000, immutable",
);
// A stale version is revalidated like no version at all.
assertEquals(
(await get(`/api/files/${id}?v=10-0`)).headers.get("cache-control"),
"public, no-cache",
);
});
function createdAtOf(id: string): string {
return (db.prepare(`SELECT created_at FROM dumps WHERE id = ?;`).get(id) as {
created_at: string;
}).created_at;
}
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=-",
]
) {
res = await range(bad);
assertEquals(res.status, 416, bad);
assertEquals(res.headers.get("content-range"), "bytes */10", bad);
}
// Several ranges, or a unit other than bytes, aren't refused: the header is
// ignored and the whole file sent, as RFC 9110 allows.
for (
const unsupported of [
"bytes=0-0,5-6",
"bytes=0-1, 3-4",
"items=0-1",
"bytes=zero-one",
]
) {
res = await range(unsupported);
assertEquals(res.status, 200, unsupported);
assertEquals(new TextDecoder().decode(res.bytes), "0123456789");
assertEquals(res.headers.get("content-range"), null, unsupported);
assertEquals(res.headers.get("content-length"), "10", unsupported);
}
});
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, no-cache");
// 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, no-cache");
const v = (id: string) => `5-${Date.parse(createdAtOf(id))}`;
assertEquals(
(await get(`/api/thumbnails/${video.id}?v=${v(video.id)}`)).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}?v=${v(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("GET /api/thumbnails/:id: an image dump gets a preview, a GIF itself", async () => {
const owner = await createUser();
const gif = new TextEncoder().encode("GIF89a-not-really");
const animated = insertDump({
userId: owner.id,
fileName: "a.gif",
fileMime: "image/gif",
fileData: gif,
});
const res = await get(`/api/thumbnails/${animated.id}`);
assertEquals(res.status, 200);
assertEquals(res.headers.get("content-type"), "image/gif");
assertEquals(res.bytes, gif);
// A real PNG is scaled into a preview, kept beside the video stills.
const photo = insertDump({
userId: owner.id,
fileName: "p.png",
fileMime: "image/png",
fileData: PNG,
});
const preview = await get(`/api/thumbnails/${photo.id}`);
assertEquals(preview.status, 200);
assertEquals(preview.headers.get("content-type"), "image/png");
if (await hasFfmpeg()) {
assertEquals(
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
n.startsWith(photo.id)
),
[`${photo.id}.preview.png`],
);
}
// Not an image ffmpeg can read (or no ffmpeg at all): the original goes out.
const broken = insertDump({
userId: owner.id,
fileName: "b.png",
fileMime: "image/png",
fileData: BYTES,
});
const fallback = await get(`/api/thumbnails/${broken.id}`);
assertEquals(fallback.status, 200);
assertEquals(fallback.headers.get("content-type"), "image/png");
assertEquals(fallback.bytes, BYTES);
assertEquals(
readDir(`${TEST_DIR}/uploads/thumbnails`).filter((n) =>
n.startsWith(broken.id)
),
[],
);
});
async function hasFfmpeg(): Promise<boolean> {
try {
return (await new Deno.Command("ffmpeg", {
args: ["-version"],
stdout: "null",
stderr: "null",
}).output()).success;
} catch {
return false;
}
}
function readDir(path: string): string[] {
try {
return [...Deno.readDirSync(path)].map((e) => e.name);
} catch {
return [];
}
}
test("attachments: upload an image, serve it back", async () => {
const user = await createUser();
const res = await post<AttachmentResponse>("/api/attachments", {
auth: user.auth,
form: formWithFile(PNG),
});
assertEquals(res.status, 201);
const id = res.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("<script>"),
"x.png",
"image/png",
),
})).status,
400,
);
assertEquals(
(await post("/api/attachments", { auth: user.auth, json: {} })).status,
400,
);
const empty = new FormData();
empty.set("other", "x");
assertEquals(
(await post("/api/attachments", { auth: user.auth, form: empty })).status,
400,
);
assertEquals(
(await post("/api/attachments", { form: formWithFile(PNG) })).status,
401,
);
assertEquals(count(`SELECT COUNT(*) as n FROM attachments;`), 0);
});
test("attachments are claimed by the first text that links them", async () => {
const user = await createUser();
const { id: dumpId } = insertDump({ userId: user.id });
const up = await post<AttachmentResponse>("/api/attachments", {
auth: user.auth,
form: formWithFile(PNG),
});
const attachmentId = up.data.id;
const link = `![img](/api/attachments/${attachmentId})`;
const comment = await post<Comment>(`/api/dumps/${dumpId}/comments`, {
auth: user.auth,
json: { body: link },
});
const owner = () =>
(db.prepare(`SELECT resource_id FROM attachments WHERE id = ?;`).get(
attachmentId,
) as { resource_id: string | null }).resource_id;
assertEquals(owner(), comment.data.id);
// A second text can't take it over.
await post(`/api/dumps/${dumpId}/comments`, {
auth: user.auth,
json: { body: link },
});
assertEquals(owner(), comment.data.id);
});
test("avatars: upload, serve, and 404 when there is none", async () => {
const user = await createUser();
assertEquals((await get(`/api/avatars/${user.id}`)).status, 404);
assertEquals((await get(`/api/avatars/${crypto.randomUUID()}`)).status, 404);
const res = await post<SelfUser>("/api/avatars/me", {
auth: user.auth,
form: formWithFile(PNG),
});
assertEquals(res.status, 200);
assertEquals(res.data.avatarMime, "image/png");
assertFalse("passwordHash" in res.data);
const served = await get(`/api/avatars/${user.id}`);
assertEquals(served.status, 200);
assertEquals(served.bytes, PNG);
assertEquals(
(await post("/api/avatars/me", {
auth: user.auth,
form: formWithFile(new Uint8Array([1, 2, 3])),
})).status,
400,
);
});

View File

@@ -1,166 +0,0 @@
import {
createUser,
db,
del,
get,
insertDump,
patch,
test,
} from "./helpers.ts";
import type {
Invitee,
PublicUser,
SelfUser,
UserSuggestion,
} from "../model/interfaces.ts";
import { assertEquals, assertFalse } from "@std/assert";
test("public profile: by username or id, without private fields", async () => {
const user = await createUser();
for (
const path of [`/api/users/${user.username}`, `/api/users/by-id/${user.id}`]
) {
const res = await get<PublicUser>(path);
assertEquals(res.status, 200, path);
assertEquals(res.data.username, user.username);
assertFalse("email" in res.data);
assertFalse("passwordHash" in res.data);
}
assertEquals((await get(`/api/users/no_one_here`)).status, 404);
const me = await get<SelfUser>("/api/users/me", { auth: user.auth });
assertEquals(me.data.email, user.email);
assertFalse("passwordHash" in me.data);
});
test("PATCH /api/users/me: description and email only", async () => {
const user = await createUser();
const taken = await createUser();
const res = await patch<PublicUser>("/api/users/me", {
auth: user.auth,
json: { description: "about me", email: "new@example.test" },
});
assertEquals(res.status, 200);
assertEquals(res.data.description, "about me");
assertFalse("email" in res.data);
assertEquals(
(await get<SelfUser>("/api/users/me", { auth: user.auth })).data.email,
"new@example.test",
);
// A role, username or password in the body is ignored, not applied.
await patch("/api/users/me", {
auth: user.auth,
json: { role: "admin", username: "hijack", password: "whatever-long" },
});
const row = db.prepare(`SELECT role, username FROM users WHERE id = ?;`).get(
user.id,
) as {
role: string;
username: string;
};
assertEquals(row, { role: "user", username: user.username });
assertEquals(
(await patch("/api/users/me", {
auth: user.auth,
json: { email: taken.email },
})).status,
409,
);
assertEquals(
(await patch("/api/users/me", { auth: user.auth, json: { email: "bad" } }))
.status,
400,
);
assertEquals(
(await patch("/api/users/me", {
auth: user.auth,
json: { description: "x".repeat(2001) },
})).status,
400,
);
});
test("PATCH /api/users/:id/role: admins only, never their own", async () => {
const admin = await createUser({ role: "admin" });
const mod = await createUser({ role: "moderator" });
const user = await createUser();
assertEquals(
(await patch(`/api/users/${user.id}/role`, {
auth: mod.auth,
json: { role: "admin" },
}))
.status,
403,
);
assertEquals(
(await patch(`/api/users/${admin.id}/role`, {
auth: admin.auth,
json: { role: "user" },
}))
.status,
400,
);
assertEquals(
(await patch(`/api/users/${user.id}/role`, {
auth: admin.auth,
json: { role: "god" },
}))
.status,
400,
);
const res = await patch<PublicUser>(`/api/users/${user.id}/role`, {
auth: admin.auth,
json: { role: "moderator" },
});
assertEquals(res.status, 200);
assertEquals(res.data.role, "moderator");
assertFalse("email" in res.data);
// The promoted user's existing token now carries moderator powers.
const { id: dumpId } = insertDump({ userId: admin.id });
assertEquals(
(await del(`/api/dumps/${dumpId}`, { auth: user.auth })).status,
200,
);
assertEquals(
(await patch(`/api/users/${crypto.randomUUID()}/role`, {
auth: admin.auth,
json: { role: "user" },
})).status,
404,
);
});
test("GET /api/users/search: prefix match for mentions, capped at 8", async () => {
for (let i = 0; i < 10; i++) await createUser({ username: `zed_${i}` });
await createUser({ username: "other_zed" });
const res = await get<UserSuggestion[]>("/api/users/search?q=zed");
assertEquals(res.data.length, 8);
assertEquals(res.data[0], {
id: res.data[0].id,
username: "zed_0",
avatarMime: null,
});
assertEquals((await get<UserSuggestion[]>("/api/users/search?q=")).data, []);
});
test("GET /api/users/:username/invitees: the invite tree below a user", async () => {
const root = await createUser();
const child = await createUser({
invitedBy: root.id,
createdAt: "2024-01-01T00:00:00Z",
});
const grandchild = await createUser({
invitedBy: child.id,
createdAt: "2024-01-02T00:00:00Z",
});
const tree =
(await get<Invitee[]>(`/api/users/${root.username}/invitees`)).data;
assertEquals(
tree.map((
n: { username: string; invitedById: string },
) => [n.username, n.invitedById]),
[[child.username, root.id], [grandchild.username, child.id]],
);
const profile = await get<PublicUser>(`/api/users/${child.username}`);
assertEquals(profile.data.invitedByUsername, root.username);
});

View File

@@ -1,182 +0,0 @@
// Request validators and row guards in model/interfaces.ts and model/db.ts.
import "./setup.ts";
import { assert, assertEquals, assertFalse } from "@std/assert";
import {
isAuthPayload,
isCreateCategoryRequest,
isCreateCommentRequest,
isCreatePlaylistRequest,
isCreateUrlDumpRequest,
isLoginUserRequest,
isPasswordResetPayload,
isReorderPlaylistRequest,
isRole,
isUpdateCategoryRequest,
isUpdateCommentRequest,
isUpdateDumpRequest,
isUpdatePlaylistRequest,
isUpdateUserRequest,
validateRegisterUserRequest,
} from "../model/interfaces.ts";
import { dumpApiToRow, type DumpRow, dumpRowToApi } from "../model/db.ts";
Deno.test("isRole", () => {
assert(isRole("user") && isRole("moderator") && isRole("admin"));
assertFalse(isRole("root"));
assertFalse(isRole(undefined));
});
Deno.test("isLoginUserRequest", () => {
assert(isLoginUserRequest({ username: "a", password: "b" }));
assertFalse(isLoginUserRequest({ username: "a" }));
assertFalse(isLoginUserRequest({ username: 1, password: "b" }));
assertFalse(isLoginUserRequest(null));
});
Deno.test("validateRegisterUserRequest", () => {
const ok = {
username: "a_b_1",
password: "12345678",
email: "a@b.co",
inviteToken: "t",
};
assertEquals(validateRegisterUserRequest(ok), null);
assertEquals(
validateRegisterUserRequest({ ...ok, email: "a@b" }),
"Invalid email address",
);
assert(
validateRegisterUserRequest({ ...ok, username: "" })?.startsWith(
"Username",
),
);
assert(
validateRegisterUserRequest({ ...ok, username: "é" })?.startsWith(
"Username",
),
);
assertEquals(validateRegisterUserRequest({}), "Invalid request");
});
Deno.test("isUpdateUserRequest", () => {
assert(isUpdateUserRequest({}));
assert(isUpdateUserRequest({ description: null }));
assert(isUpdateUserRequest({ description: "hi", email: "a@b.co" }));
assertFalse(isUpdateUserRequest({ description: "x".repeat(2001) }));
assertFalse(isUpdateUserRequest({ email: "nope" }));
assertFalse(isUpdateUserRequest({ username: "no spaces" }));
assertFalse(isUpdateUserRequest({ password: "short" }));
assertFalse(isUpdateUserRequest({ role: "root" }));
assertFalse(isUpdateUserRequest("string"));
});
Deno.test("isAuthPayload refuses purpose-scoped tokens", () => {
const base = { userId: "u", username: "n", role: "user", exp: 1 };
assert(isAuthPayload(base));
assertFalse(isAuthPayload({ ...base, purpose: "media" }));
assertFalse(isAuthPayload({ ...base, role: "root" }));
assertFalse(isAuthPayload({ ...base, exp: "1" }));
assert(isPasswordResetPayload({ purpose: "password-reset", userId: "u" }));
assertFalse(isPasswordResetPayload({ purpose: "media", userId: "u" }));
});
Deno.test("comment requests: non-blank, bounded, optional parent", () => {
assert(isCreateCommentRequest({ body: "hi" }));
assert(isCreateCommentRequest({ body: "hi", parentId: null }));
assert(isCreateCommentRequest({ body: "hi", parentId: "p" }));
assertFalse(isCreateCommentRequest({ body: " " }));
assertFalse(isCreateCommentRequest({ body: "x".repeat(5001) }));
assertFalse(isCreateCommentRequest({ body: "hi", parentId: 3 }));
assert(isUpdateCommentRequest({ body: "edit" }));
assertFalse(isUpdateCommentRequest({ body: "" }));
});
Deno.test("playlist requests", () => {
assert(isCreatePlaylistRequest({ title: "t", isPublic: true }));
assert(
isCreatePlaylistRequest({ title: "t", isPublic: false, description: null }),
);
assertFalse(isCreatePlaylistRequest({ title: "", isPublic: true }));
assertFalse(
isCreatePlaylistRequest({ title: "x".repeat(101), isPublic: true }),
);
assertFalse(isCreatePlaylistRequest({ title: "t" }));
assertFalse(
isCreatePlaylistRequest({
title: "t",
isPublic: true,
description: "x".repeat(2001),
}),
);
assert(isUpdatePlaylistRequest({}));
assert(isUpdatePlaylistRequest({ description: null }));
assertFalse(isUpdatePlaylistRequest({ title: "" }));
assertFalse(isUpdatePlaylistRequest({ isPublic: "yes" }));
assert(isReorderPlaylistRequest({ dumpIds: [] }));
assertFalse(isReorderPlaylistRequest({ dumpIds: [1] }));
assertFalse(isReorderPlaylistRequest({}));
});
Deno.test("dump requests", () => {
assert(isCreateUrlDumpRequest({ url: "https://x" }));
assert(
isCreateUrlDumpRequest({
url: "https://x",
title: "t",
comment: null,
isPrivate: true,
categoryIds: ["a"],
}),
);
assertFalse(isCreateUrlDumpRequest({}));
assertFalse(
isCreateUrlDumpRequest({ url: "https://x", title: "x".repeat(201) }),
);
assertFalse(
isCreateUrlDumpRequest({ url: "https://x", comment: "x".repeat(5001) }),
);
assertFalse(isCreateUrlDumpRequest({ url: "https://x", isPrivate: "true" }));
assertFalse(isCreateUrlDumpRequest({ url: "https://x", categoryIds: [1] }));
assert(isUpdateDumpRequest({}));
assert(isUpdateDumpRequest({ comment: null, url: null }));
assertFalse(isUpdateDumpRequest({ title: " " }));
assertFalse(isUpdateDumpRequest({ title: "x".repeat(201) }));
assertFalse(isUpdateDumpRequest({ categoryIds: "a" }));
});
Deno.test("category requests", () => {
assert(isCreateCategoryRequest({ slug: "a", name: "A" }));
assert(isCreateCategoryRequest({ slug: "a", name: "A", position: 2 }));
assertFalse(isCreateCategoryRequest({ slug: "a" }));
assertFalse(isCreateCategoryRequest({ slug: "a", name: "A", position: "2" }));
assert(isUpdateCategoryRequest({}));
assertFalse(isUpdateCategoryRequest({ name: 1 }));
});
Deno.test("dump rows round-trip through the API shape", () => {
const row: DumpRow = {
id: "id",
kind: "url",
title: "t",
slug: "t-id",
comment: null,
user_id: "u",
created_at: "2024-01-01T00:00:00.000Z",
updated_at: null,
url: "https://x",
rich_content: JSON.stringify({ type: "generic", url: "https://x" }),
file_name: null,
file_mime: null,
file_size: null,
vote_count: 3,
comment_count: 2,
is_private: 1,
custom_thumbnail_mime: null,
};
const dump = dumpRowToApi(row);
assertEquals(dump.isPrivate, true);
assertEquals(dump.richContent?.url, "https://x");
assertEquals(dump.comment, undefined);
assertEquals(dumpApiToRow(dump), row);
});

View File

@@ -0,0 +1,15 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import {
isCreateCategoryRequest,
isUpdateCategoryRequest,
} from "../../validators/category.ts";
Deno.test("category requests", () => {
assert(isCreateCategoryRequest({ slug: "a", name: "A" }));
assert(isCreateCategoryRequest({ slug: "a", name: "A", position: 2 }));
assertFalse(isCreateCategoryRequest({ slug: "a" }));
assertFalse(isCreateCategoryRequest({ slug: "a", name: "A", position: "2" }));
assert(isUpdateCategoryRequest({}));
assertFalse(isUpdateCategoryRequest({ name: 1 }));
});

View File

@@ -0,0 +1,17 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import {
isCreateCommentRequest,
isUpdateCommentRequest,
} from "../../validators/comment.ts";
Deno.test("comment requests: non-blank, bounded, optional parent", () => {
assert(isCreateCommentRequest({ body: "hi" }));
assert(isCreateCommentRequest({ body: "hi", parentId: null }));
assert(isCreateCommentRequest({ body: "hi", parentId: "p" }));
assertFalse(isCreateCommentRequest({ body: " " }));
assertFalse(isCreateCommentRequest({ body: "x".repeat(5001) }));
assertFalse(isCreateCommentRequest({ body: "hi", parentId: 3 }));
assert(isUpdateCommentRequest({ body: "edit" }));
assertFalse(isUpdateCommentRequest({ body: "" }));
});

View File

@@ -0,0 +1,34 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import {
isCreateUrlDumpRequest,
isUpdateDumpRequest,
} from "../../validators/dump.ts";
Deno.test("dump requests", () => {
assert(isCreateUrlDumpRequest({ url: "https://x" }));
assert(
isCreateUrlDumpRequest({
url: "https://x",
title: "t",
comment: null,
isPrivate: true,
categoryIds: ["a"],
}),
);
assertFalse(isCreateUrlDumpRequest({}));
assertFalse(
isCreateUrlDumpRequest({ url: "https://x", title: "x".repeat(201) }),
);
assertFalse(
isCreateUrlDumpRequest({ url: "https://x", comment: "x".repeat(5001) }),
);
assertFalse(isCreateUrlDumpRequest({ url: "https://x", isPrivate: "true" }));
assertFalse(isCreateUrlDumpRequest({ url: "https://x", categoryIds: [1] }));
assert(isUpdateDumpRequest({}));
assert(isUpdateDumpRequest({ comment: null, url: null }));
assertFalse(isUpdateDumpRequest({ title: " " }));
assertFalse(isUpdateDumpRequest({ title: "x".repeat(201) }));
assertFalse(isUpdateDumpRequest({ categoryIds: "a" }));
});

View File

@@ -0,0 +1,33 @@
import "../setup.ts";
import { assert, assertFalse } from "@std/assert";
import {
isCreatePlaylistRequest,
isReorderPlaylistRequest,
isUpdatePlaylistRequest,
} from "../../validators/playlist.ts";
Deno.test("playlist requests", () => {
assert(isCreatePlaylistRequest({ title: "t", isPublic: true }));
assert(
isCreatePlaylistRequest({ title: "t", isPublic: false, description: null }),
);
assertFalse(isCreatePlaylistRequest({ title: "", isPublic: true }));
assertFalse(
isCreatePlaylistRequest({ title: "x".repeat(101), isPublic: true }),
);
assertFalse(isCreatePlaylistRequest({ title: "t" }));
assertFalse(
isCreatePlaylistRequest({
title: "t",
isPublic: true,
description: "x".repeat(2001),
}),
);
assert(isUpdatePlaylistRequest({}));
assert(isUpdatePlaylistRequest({ description: null }));
assertFalse(isUpdatePlaylistRequest({ title: "" }));
assertFalse(isUpdatePlaylistRequest({ isPublic: "yes" }));
assert(isReorderPlaylistRequest({ dumpIds: [] }));
assertFalse(isReorderPlaylistRequest({ dumpIds: [1] }));
assertFalse(isReorderPlaylistRequest({}));
});

View File

@@ -0,0 +1,51 @@
import "../setup.ts";
import { assert, assertEquals, assertFalse } from "@std/assert";
import {
isLoginUserRequest,
isUpdateUserRequest,
validateRegisterUserRequest,
} from "../../validators/user.ts";
Deno.test("isLoginUserRequest", () => {
assert(isLoginUserRequest({ username: "a", password: "b" }));
assertFalse(isLoginUserRequest({ username: "a" }));
assertFalse(isLoginUserRequest({ username: 1, password: "b" }));
assertFalse(isLoginUserRequest(null));
});
Deno.test("validateRegisterUserRequest", () => {
const ok = {
username: "a_b_1",
password: "12345678",
email: "a@b.co",
inviteToken: "t",
};
assertEquals(validateRegisterUserRequest(ok), null);
assertEquals(
validateRegisterUserRequest({ ...ok, email: "a@b" }),
"Invalid email address",
);
assert(
validateRegisterUserRequest({ ...ok, username: "" })?.startsWith(
"Username",
),
);
assert(
validateRegisterUserRequest({ ...ok, username: "é" })?.startsWith(
"Username",
),
);
assertEquals(validateRegisterUserRequest({}), "Invalid request");
});
Deno.test("isUpdateUserRequest", () => {
assert(isUpdateUserRequest({}));
assert(isUpdateUserRequest({ description: null }));
assert(isUpdateUserRequest({ description: "hi", email: "a@b.co" }));
assertFalse(isUpdateUserRequest({ description: "x".repeat(2001) }));
assertFalse(isUpdateUserRequest({ email: "nope" }));
assertFalse(isUpdateUserRequest({ username: "no spaces" }));
assertFalse(isUpdateUserRequest({ password: "short" }));
assertFalse(isUpdateUserRequest({ role: "root" }));
assertFalse(isUpdateUserRequest("string"));
});