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,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

@@ -0,0 +1,150 @@
import {
createUser,
db,
del,
get,
insertCategory,
insertDump,
post,
put,
test,
} 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 });
insertCategory({ slug: "alpha", position: 1 });
insertCategory({ slug: "beta", position: 0 });
const list = await get<Category[]>("/api/categories");
assertEquals(list.data.map((c) => c.slug), [
"beta",
"zeta",
"alpha",
]);
assertEquals(
(await get<Category>("/api/categories/alpha")).data.slug,
"alpha",
);
assertEquals((await get("/api/categories/nope")).status, 404);
});
test("categories: only category:manage may create, update or delete", async () => {
const admin = await createUser({ role: "admin" });
const mod = await createUser({ role: "moderator" });
const json = { slug: "music", name: "Music" };
assertEquals((await post("/api/categories", { json })).status, 401);
assertEquals(
(await post("/api/categories", { auth: mod.auth, json })).status,
403,
);
const created = await post<Category>("/api/categories", {
auth: admin.auth,
json,
});
assertEquals(created.status, 201);
const id = created.data.id;
assertEquals(
(await put(`/api/categories/${id}`, {
auth: mod.auth,
json: { name: "X" },
})).status,
403,
);
assertEquals(
(await del(`/api/categories/${id}`, { auth: mod.auth })).status,
403,
);
assertEquals(
(await del(`/api/categories/${id}`, { auth: admin.auth })).status,
200,
);
assertEquals(
(await del(`/api/categories/${id}`, { auth: admin.auth })).status,
404,
);
});
test("categories: slug and name rules, and friendly conflicts", async () => {
const admin = await createUser({ role: "admin" });
const create = (json: unknown) =>
post<Category>("/api/categories", { auth: admin.auth, json });
for (
const [json, message] of [
[{ slug: "Bad Slug", name: "x" }, "lowercase"],
[{ slug: "users", name: "x" }, "reserved"],
[{ slug: "a".repeat(51), name: "x" }, "at most 50"],
[{ slug: "ok", name: " " }, "Name is required"],
[{ slug: "ok", name: "x".repeat(51) }, "at most 50"],
] as const
) {
const res = await create(json);
assertEquals(res.status, 400, JSON.stringify(json));
assertStringIncludes(res.error.message, message);
}
assertEquals((await create({ slug: "ok" })).status, 400);
const first = await create({ slug: "music", name: " Music " });
assertEquals(first.data.name, "Music");
const dupSlug = await create({ slug: "music", name: "Other" });
assertEquals(dupSlug.status, 409);
assertStringIncludes(dupSlug.error.message, "Slug");
const dupName = await create({ slug: "other", name: "Music" });
assertEquals(dupName.status, 409);
assertStringIncludes(dupName.error.message, "Name");
const second = await create({ slug: "films", name: "Films" });
const rename = await put(`/api/categories/${second.data.id}`, {
auth: admin.auth,
json: { slug: "music" },
});
assertEquals(rename.status, 409);
const ok = await put<Category>(`/api/categories/${second.data.id}`, {
auth: admin.auth,
json: { name: "Movies", position: 3 },
});
assertEquals(ok.data.slug, "films");
assertEquals(ok.data.name, "Movies");
assertEquals(ok.data.position, 3);
});
test("GET /api/categories/:slug/dumps: members only, private for the owner", async () => {
const owner = await createUser();
const cat = insertCategory({ slug: "music" });
const inCat = insertDump({ userId: owner.id, title: "in" });
const priv = insertDump({
userId: owner.id,
title: "private",
isPrivate: true,
});
insertDump({ userId: owner.id, title: "out" });
for (const d of [inCat, priv]) {
db.prepare(
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
)
.run(d.id, cat);
}
const anon = await get<PaginatedData<Dump>>("/api/categories/music/dumps");
assertEquals(anon.data.items.map((d) => d.title), [
"in",
]);
assertEquals(anon.data.items[0].categoryIds, [cat]);
assertEquals(
(await get<PaginatedData<Dump>>("/api/categories/music/dumps", {
auth: owner.auth,
})).data
.items.length,
2,
);
assertEquals((await get("/api/categories/nope/dumps")).status, 404);
// Deleting the category unlinks its dumps without deleting them.
const admin = await createUser({ role: "admin" });
await del(`/api/categories/${cat}`, { auth: admin.auth });
assertEquals(
(await get<Dump>(`/api/dumps/${inCat.id}`)).data.categoryIds,
[],
);
});

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

@@ -0,0 +1,266 @@
import {
count,
createUser,
db,
del,
get,
insertComment,
insertDump,
patch,
post,
test,
} from "../helpers.ts";
import { assertEquals, assertExists, assertFalse } from "@std/assert";
import {
castCommentLike,
removeCommentLike,
} 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();
const other = await createUser();
const { id } = insertDump({ userId: owner.id });
const first = await post<Comment>(`/api/dumps/${id}/comments`, {
auth: other.auth,
json: { body: " first " },
});
assertEquals(first.status, 201);
assertEquals(first.data.body, "first");
assertEquals(first.data.authorUsername, other.username);
const reply = await post<Comment>(`/api/dumps/${id}/comments`, {
auth: owner.auth,
json: { body: "reply", parentId: first.data.id },
});
assertEquals(reply.data.parentId, first.data.id);
const list = await get<Comment[]>(`/api/dumps/${id}/comments`);
assertEquals(list.data.map((c) => c.body), [
"first",
"reply",
]);
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.commentCount, 2);
});
test("comments: validation, auth and unknown dumps", async () => {
const user = await createUser();
const { id } = insertDump({ userId: user.id });
for (const body of ["", " ", "x".repeat(5001)]) {
assertEquals(
(await post(`/api/dumps/${id}/comments`, {
auth: user.auth,
json: { body },
}))
.status,
400,
);
}
assertEquals(
(await post(`/api/dumps/${id}/comments`, { json: { body: "hi" } })).status,
401,
);
assertEquals(
(await post(`/api/dumps/nope/comments`, {
auth: user.auth,
json: { body: "hi" },
}))
.status,
404,
);
});
test("comments on a private dump: owner only", async () => {
const owner = await createUser();
const other = await createUser();
const { id } = insertDump({ userId: owner.id, isPrivate: true });
assertEquals(
(await post(`/api/dumps/${id}/comments`, {
auth: other.auth,
json: { body: "x" },
}))
.status,
404,
);
assertEquals(
(await post(`/api/dumps/${id}/comments`, {
auth: owner.auth,
json: { body: "x" },
}))
.status,
201,
);
assertEquals(
(await get(`/api/dumps/${id}/comments`, { auth: other.auth })).status,
404,
);
});
test("comments: the dump's owner is notified, the commenter isn't", async () => {
const owner = await createUser();
const other = await createUser();
const { id } = insertDump({ userId: owner.id, title: "Mine" });
await post(`/api/dumps/${id}/comments`, {
auth: owner.auth,
json: { body: "self" },
});
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
const c = await post<Comment>(`/api/dumps/${id}/comments`, {
auth: other.auth,
json: { body: "nice" },
});
const note = db.prepare(`SELECT user_id, type, data FROM notifications;`)
.get() as {
user_id: string;
type: string;
data: string;
};
assertEquals(note.user_id, owner.id);
assertEquals(note.type, "dump_commented");
assertEquals(JSON.parse(note.data).commentId, c.data.id);
assertEquals(JSON.parse(note.data).dumpTitle, "Mine");
});
test("PATCH /api/comments/:id: author and moderator edit, others can't", async () => {
const author = await createUser();
const other = await createUser();
const mod = await createUser({ role: "moderator" });
const { id: dumpId } = insertDump({ userId: author.id });
const commentId = insertComment({ dumpId, userId: author.id, body: "orig" });
assertEquals(
(await patch(`/api/comments/${commentId}`, {
auth: other.auth,
json: { body: "x" },
}))
.status,
401,
);
const edited = await patch<Comment>(`/api/comments/${commentId}`, {
auth: author.auth,
json: { body: " edited " },
});
assertEquals(edited.status, 200);
assertEquals(edited.data.body, "edited");
assertExists(edited.data.updatedAt);
assertEquals(
(await patch(`/api/comments/${commentId}`, {
auth: mod.auth,
json: { body: "m" },
}))
.status,
200,
);
assertEquals(
(await patch(`/api/comments/${commentId}`, {
auth: author.auth,
json: { body: "" },
}))
.status,
400,
);
assertEquals(
(await patch(`/api/comments/${crypto.randomUUID()}`, {
auth: author.auth,
json: { body: "x" },
})).status,
404,
);
});
test("DELETE /api/comments/:id soft-deletes, and a deleted comment can't be edited", async () => {
const author = await createUser();
const other = await createUser();
const { id: dumpId } = insertDump({ userId: author.id });
const commentId = insertComment({
dumpId,
userId: author.id,
body: "gone soon",
});
assertEquals(
(await del(`/api/comments/${commentId}`, { auth: other.auth })).status,
401,
);
assertEquals(
(await del(`/api/comments/${commentId}`, { auth: author.auth })).status,
200,
);
const [c] = (await get<Comment[]>(`/api/dumps/${dumpId}/comments`)).data;
assertEquals(c.deleted, true);
assertEquals(c.body, "");
assertEquals((await get<Dump>(`/api/dumps/${dumpId}`)).data.commentCount, 0);
assertEquals(
(await patch(`/api/comments/${commentId}`, {
auth: author.auth,
json: { body: "back" },
}))
.status,
400,
);
});
test("comment likes: count, conflicts, likers list and notifications", async () => {
const author = await createUser();
const liker = await createUser();
const { id: dumpId } = insertDump({ userId: author.id });
const commentId = insertComment({ dumpId, userId: author.id });
assertEquals(castCommentLike(commentId, liker.id).likeCount, 1);
assertEquals(castCommentLike(commentId, author.id).likeCount, 2);
try {
castCommentLike(commentId, liker.id);
throw new Error("expected a conflict");
} catch (err) {
assertEquals((err as APIException).status, 409);
}
// Only the other user's like notifies the author.
assertEquals(
count(
`SELECT COUNT(*) as n FROM notifications WHERE type = 'comment_liked';`,
),
1,
);
const likers = await get<PaginatedData<PublicUser>>(
`/api/comments/${commentId}/likers`,
);
assertEquals(likers.data.items.length, 2);
assertFalse("email" in likers.data.items[0]);
assertEquals(removeCommentLike(commentId, liker.id).likeCount, 1);
try {
removeCommentLike(commentId, liker.id);
throw new Error("expected a 404");
} catch (err) {
assertEquals((err as APIException).status, 404);
}
assertEquals(
(db.prepare(`SELECT like_count FROM comments WHERE id = ?;`).get(
commentId,
) as {
like_count: number;
}).like_count,
1,
);
});
test("comment likes on a private dump: owner only", async () => {
const owner = await createUser();
const other = await createUser();
const { id: dumpId } = insertDump({ userId: owner.id, isPrivate: true });
const commentId = insertComment({ dumpId, userId: owner.id });
try {
castCommentLike(commentId, other.id);
throw new Error("expected a 404");
} catch (err) {
assertEquals((err as APIException).status, 404);
}
assertEquals(castCommentLike(commentId, owner.id).isPrivate, true);
assertEquals((await get(`/api/comments/${commentId}/likers`)).status, 404);
assertEquals(
(await get(`/api/comments/${commentId}/likers`, { auth: owner.auth }))
.status,
200,
);
});

View File

@@ -0,0 +1,767 @@
import {
count,
createUser,
db,
del,
formWithFile,
get,
html,
insertCategory,
insertComment,
insertDump,
insertPlaylist,
PNG,
post,
put,
stubNetwork,
test,
TEST_DIR,
} 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 {
assertEquals,
assertExists,
assertFalse,
assertMatch,
assertNotEquals,
} from "@std/assert";
const PAGE = html(`<title>Scraped title</title>
<meta property="og:image" content="https://example.com/cover.jpg">`);
function readFile(path: string): Uint8Array | null {
try {
return Deno.readFileSync(`${TEST_DIR}/uploads/${path}`);
} catch {
return null;
}
}
// ── Create ───────────────────────────────────────────────────────────────────
test("POST /api/dumps: a URL dump takes its title from the page", async () => {
const user = await createUser();
using _ = stubNetwork(() => PAGE.clone());
const res = await post<Dump>("/api/dumps", {
auth: user.auth,
json: { url: "https://example.com/a?utm_source=x", comment: "look" },
});
assertEquals(res.status, 201);
const dump = res.data;
assertEquals(dump.kind, "url");
assertEquals(dump.title, "Scraped title");
assertMatch(dump.slug ?? "", /^scraped-title-[0-9a-f]{8}$/);
assertEquals(dump.userId, user.id);
assertEquals(dump.isPrivate, false);
assertEquals(dump.richContent?.thumbnailUrl, "https://example.com/cover.jpg");
const row = db.prepare(`SELECT url, url_canonical FROM dumps WHERE id = ?;`)
.get(dump.id) as { url: string; url_canonical: string };
// The exact URL is kept; the canonical key drops the tracking parameter.
assertEquals(row.url, "https://example.com/a?utm_source=x");
assertEquals(row.url_canonical, "https://example.com/a");
});
test("POST /api/dumps: a typed title wins; an unreachable page still posts", async () => {
const user = await createUser();
{
using _ = stubNetwork(() => PAGE.clone());
const res = await post<Dump>("/api/dumps", {
auth: user.auth,
json: { url: "https://example.com/b", title: " My title " },
});
assertEquals(res.data.title, "My title");
}
{
using _ = stubNetwork(() => {
throw new TypeError("offline");
});
const res = await post<Dump>("/api/dumps", {
auth: user.auth,
json: { url: "https://www.offline.example/c" },
});
assertEquals(res.status, 201);
assertEquals(res.data.title, "offline.example");
}
});
test("POST /api/dumps: refuses bad URLs and bodies, and anonymous callers", async () => {
const user = await createUser();
using net = stubNetwork(() => PAGE.clone());
for (
const url of ["http://localhost/", "http://192.168.0.1/", "ftp://x/", "no"]
) {
const res = await post("/api/dumps", { auth: user.auth, json: { url } });
assertEquals(res.status, 400, url);
}
assertEquals(
(await post("/api/dumps", { auth: user.auth, json: { nope: 1 } })).status,
400,
);
assertEquals(
(await post("/api/dumps", { json: { url: "https://example.com" } })).status,
401,
);
assertEquals(net.calls.length, 0);
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
});
test("POST /api/dumps: categories are stored and deduplicated", async () => {
const user = await createUser();
const music = insertCategory({ slug: "music" });
using _ = stubNetwork(() => PAGE.clone());
const res = await post<Dump>("/api/dumps", {
auth: user.auth,
json: { url: "https://example.com/d", categoryIds: [music, music] },
});
assertEquals(res.status, 201);
assertEquals(res.data.categoryIds, [music]);
assertEquals(
(await get<Dump>(`/api/dumps/${res.data.id}`)).data.categoryIds,
[music],
);
});
test("POST /api/dumps: an unknown category is refused and nothing is left behind", async () => {
const user = await createUser();
using _ = stubNetwork(() => PAGE.clone());
const res = await post("/api/dumps", {
auth: user.auth,
json: { url: "https://example.com/e", categoryIds: ["no-such-category"] },
});
assertEquals(res.status, 400);
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
});
test("POST /api/dumps: a file dump is stored and served back", async () => {
const user = await createUser();
const res = await post<Dump>("/api/dumps", {
auth: user.auth,
form: formWithFile(PNG, "pixel.png", "image/png", { comment: "tiny" }),
});
assertEquals(res.status, 201);
const dump = res.data;
assertEquals(dump.kind, "file");
assertEquals(dump.title, "pixel.png");
assertEquals(dump.fileMime, "image/png");
assertEquals(dump.fileSize, PNG.byteLength);
assertEquals(dump.comment, "tiny");
assertEquals(readFile(`dumps/${dump.id}`), PNG);
const file = await get(`/api/files/${dump.id}`);
assertEquals(file.status, 200);
assertEquals(file.bytes, PNG);
});
test("POST /api/dumps: file type, size of text fields, and a missing file", async () => {
const user = await createUser();
const exe = await post("/api/dumps", {
auth: user.auth,
form: formWithFile(PNG, "x.exe", "application/x-msdownload"),
});
assertEquals(exe.status, 400);
const longTitle = await post("/api/dumps", {
auth: user.auth,
form: formWithFile(PNG, "x.png", "image/png", { title: "x".repeat(201) }),
});
assertEquals(longTitle.status, 400);
const longComment = await post("/api/dumps", {
auth: user.auth,
form: formWithFile(PNG, "x.png", "image/png", {
comment: "x".repeat(5001),
}),
});
assertEquals(longComment.status, 400);
const form = new FormData();
form.set("comment", "no file");
assertEquals(
(await post("/api/dumps", { auth: user.auth, form })).status,
400,
);
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
});
test("POST /api/dumps: a file dump with an unknown category leaves nothing behind", async () => {
const user = await createUser();
const res = await post("/api/dumps", {
auth: user.auth,
form: formWithFile(PNG, "x.png", "image/png", {
categoryIds: JSON.stringify(["nope"]),
}),
});
assertEquals(res.status, 400);
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
});
test("POST /api/dumps: followers are notified of public dumps, not private ones", async () => {
const poster = await createUser();
const follower = await createUser();
db.prepare(
`INSERT INTO follows (id, follower_id, followed_user_id, created_at) VALUES (?, ?, ?, ?);`,
).run(crypto.randomUUID(), follower.id, poster.id, new Date().toISOString());
using _ = stubNetwork(() => PAGE.clone());
await post("/api/dumps", {
auth: poster.auth,
json: { url: "https://example.com/private", isPrivate: true },
});
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
const pub = await post<Dump>("/api/dumps", {
auth: poster.auth,
json: { url: "https://example.com/public" },
});
const notes = db.prepare(
`SELECT user_id, type, data FROM notifications;`,
).all() as { user_id: string; type: string; data: string }[];
assertEquals(notes.length, 1);
assertEquals(notes[0].user_id, follower.id);
assertEquals(notes[0].type, "user_dump_posted");
assertEquals(JSON.parse(notes[0].data).dumpId, pub.data.id);
});
// ── Read ─────────────────────────────────────────────────────────────────────
test("GET /api/dumps/:id works by id and by slug", async () => {
const user = await createUser();
const { id, slug } = insertDump({ userId: user.id, title: "Hello" });
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.id, id);
assertEquals((await get<Dump>(`/api/dumps/${slug}`)).data.id, id);
assertEquals((await get(`/api/dumps/nope`)).status, 404);
assertEquals((await get(`/api/dumps/${crypto.randomUUID()}`)).status, 404);
});
test("GET /api/dumps/:id: a private dump exists only for its owner", async () => {
const owner = await createUser();
const other = await createUser();
const { id } = insertDump({ userId: owner.id, isPrivate: true });
assertEquals(
(await get(`/api/dumps/${id}`, { auth: owner.auth })).status,
200,
);
assertEquals(
(await get(`/api/dumps/${id}`, { auth: other.auth })).status,
404,
);
assertEquals((await get(`/api/dumps/${id}`)).status, 404);
for (const sub of ["voters", "related", "playlists", "comments"]) {
assertEquals((await get(`/api/dumps/${id}/${sub}`)).status, 404, sub);
assertEquals(
(await get(`/api/dumps/${id}/${sub}`, { auth: owner.auth })).status,
200,
sub,
);
}
});
test("GET /api/dumps: newest first, paginated, private ones for the owner only", async () => {
const owner = await createUser();
const other = await createUser();
for (let i = 0; i < 5; i++) {
insertDump({
userId: owner.id,
title: `d${i}`,
createdAt: new Date(Date.UTC(2024, 0, i + 1)).toISOString(),
});
}
insertDump({
userId: owner.id,
title: "secret",
isPrivate: true,
createdAt: new Date(Date.UTC(2024, 0, 10)).toISOString(),
});
const page1 = await get<PaginatedData<Dump>>("/api/dumps?limit=2");
assertEquals(page1.data.items.map((d) => d.title), [
"d4",
"d3",
]);
assertEquals(page1.data.hasMore, true);
// Pages are told apart by the row past the limit, not by a count.
assertEquals(page1.data.total, undefined);
const page3 = await get<PaginatedData<Dump>>("/api/dumps?limit=2&page=3");
assertEquals(page3.data.items.length, 1);
assertEquals(page3.data.hasMore, false);
const mine = await get<PaginatedData<Dump>>("/api/dumps", {
auth: owner.auth,
});
assertEquals(mine.data.items[0].title, "secret");
assertEquals(mine.data.items.length, 6);
assertEquals(
(await get<PaginatedData<Dump>>("/api/dumps", { auth: other.auth })).data
.items.length,
5,
);
});
test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", async () => {
const owner = await createUser();
const other = await createUser();
insertDump({
userId: owner.id,
title: "public",
url: "https://youtu.be/abc",
});
insertDump({
userId: owner.id,
title: "private",
url: "https://www.youtube.com/watch?v=abc",
isPrivate: true,
});
const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10");
const anon = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`);
assertEquals(anon.data.map((d) => d.title), [
"public",
]);
assertEquals(anon.data[0].username, owner.username);
const mine = await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
auth: owner.auth,
});
assertEquals(mine.data.length, 2);
assertEquals(
(await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=${q}`, {
auth: other.auth,
})).data
.length,
1,
);
assertEquals(
(await get<DumpUrlMatch[]>(`/api/dumps/by-url?url=garbage`)).data,
[],
);
});
test("GET /api/dumps/:id/voters lists voters without private fields", async () => {
const owner = await createUser();
const voter = await createUser();
const { id } = insertDump({ userId: owner.id });
db.prepare(
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
).run(id, voter.id, new Date().toISOString());
const res = await get<PaginatedData<PublicUser>>(`/api/dumps/${id}/voters`);
assertEquals(res.data.items.length, 1);
assertEquals(res.data.items[0].username, voter.username);
assertFalse("email" in res.data.items[0]);
assertFalse("passwordHash" in res.data.items[0]);
});
test("GET /api/dumps/:id/related: links in both directions, hiding private ones", async () => {
const user = await createUser();
const other = await createUser();
const target = insertDump({
userId: user.id,
title: "target",
url: "https://x.test/t",
});
const secret = insertDump({
userId: other.id,
title: "secret",
isPrivate: true,
});
using _ = stubNetwork(() => PAGE.clone());
// A new dump whose comment links the target by its URL and the secret dump by
// its page — and itself, which is ignored.
const src = await post<Dump>("/api/dumps", {
auth: other.auth,
json: {
url: "https://example.com/src",
comment:
`see https://x.test/t and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`,
},
});
const srcId = src.data.id;
const fromSrc = await get<Dump[]>(`/api/dumps/${srcId}/related`);
assertEquals(fromSrc.data.map((d) => d.title), [
"target",
]);
const fromSrcAsOwner = await get<Dump[]>(`/api/dumps/${srcId}/related`, {
auth: other.auth,
});
assertEquals(fromSrcAsOwner.data.length, 2);
const toTarget = await get<Dump[]>(`/api/dumps/${target.id}/related`);
assertEquals(toTarget.data.map((d) => d.id), [srcId]);
// Editing the comment away removes the links.
await put(`/api/dumps/${srcId}`, { auth: other.auth, json: { comment: "" } });
assertEquals((await get<Dump[]>(`/api/dumps/${target.id}/related`)).data, []);
});
test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private ones", async () => {
const user = await createUser();
const other = await createUser();
const { id } = insertDump({ userId: user.id });
const pub = insertPlaylist({ userId: user.id, title: "Public" });
const priv = insertPlaylist({
userId: user.id,
title: "Private",
isPublic: false,
});
for (const p of [pub, priv]) {
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?);`,
).run(p.id, id, new Date().toISOString());
}
assertEquals(
(await get<Playlist[]>(`/api/dumps/${id}/playlists`, { auth: other.auth }))
.data
.map((p) => p.title),
["Public"],
);
assertEquals(
(await get<Playlist[]>(`/api/dumps/${id}/playlists`, { auth: user.auth }))
.data
.length,
2,
);
});
// ── Update ───────────────────────────────────────────────────────────────────
test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async () => {
const owner = await createUser();
const other = await createUser();
const mod = await createUser({ role: "moderator" });
const { id, slug } = insertDump({ userId: owner.id, title: "Old" });
assertEquals(
(await put(`/api/dumps/${id}`, { auth: other.auth, json: { title: "X" } }))
.status,
403,
);
assertEquals(
(await put(`/api/dumps/${id}`, { json: { title: "X" } })).status,
401,
);
const res = await put<Dump>(`/api/dumps/${slug}`, {
auth: owner.auth,
json: { title: "New title", comment: "hello" },
});
assertEquals(res.status, 200);
assertEquals(res.data.title, "New title");
assertMatch(res.data.slug ?? "", /^new-title-/);
assertNotEquals(res.data.slug, slug);
assertExists(res.data.updatedAt);
const byMod = await put(`/api/dumps/${id}`, {
auth: mod.auth,
json: { title: "Moderated" },
});
assertEquals(byMod.status, 200);
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.title, "Moderated");
});
test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", async () => {
const owner = await createUser();
const { id } = insertDump({
userId: owner.id,
title: "Old",
url: "https://a.test/",
});
assertEquals(
(await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: " " } }))
.status,
422,
);
assertEquals(
(await put(`/api/dumps/${id}`, {
auth: owner.auth,
json: { url: "http://127.0.0.1/" },
})).status,
400,
);
using net = stubNetwork(() => PAGE.clone());
// Same URL: no fetch, title kept.
await put(`/api/dumps/${id}`, { auth: owner.auth, json: { comment: "c" } });
assertEquals(net.calls.length, 0);
const moved = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: { url: "https://b.test/?utm_medium=z" },
});
assertEquals(moved.data.title, "Scraped title");
assertEquals(net.calls.length, 1);
const row = db.prepare(`SELECT url_canonical FROM dumps WHERE id = ?;`).get(
id,
) as {
url_canonical: string;
};
assertEquals(row.url_canonical, "https://b.test");
});
test("PUT /api/dumps/:id: categories are replaced, or kept when omitted", async () => {
const owner = await createUser();
const a = insertCategory({ slug: "a" });
const b = insertCategory({ slug: "b" });
const { id } = insertDump({ userId: owner.id });
await put(`/api/dumps/${id}`, {
auth: owner.auth,
json: { categoryIds: [a] },
});
const kept = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: { title: "t" },
});
assertEquals(kept.data.categoryIds, [a]);
const replaced = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: { categoryIds: [b] },
});
assertEquals(replaced.data.categoryIds, [b]);
const cleared = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: { categoryIds: [] },
});
assertEquals(cleared.data.categoryIds, []);
});
test("PUT /api/dumps/:id: an unknown category changes nothing", async () => {
const owner = await createUser();
const a = insertCategory({ slug: "a" });
const { id } = insertDump({ userId: owner.id, title: "Before" });
await put(`/api/dumps/${id}`, {
auth: owner.auth,
json: { categoryIds: [a] },
});
const res = await put(`/api/dumps/${id}`, {
auth: owner.auth,
json: { title: "After", categoryIds: [a, "nope"] },
});
assertEquals(res.status, 400);
const dump = (await get<Dump>(`/api/dumps/${id}`)).data;
assertEquals(dump.title, "Before");
assertEquals(dump.categoryIds, [a]);
});
test("PUT /api/dumps/:id/file: an unknown category keeps the old file", async () => {
const owner = await createUser();
const original = new TextEncoder().encode("original");
const { id } = insertDump({
userId: owner.id,
title: "orig",
fileName: "a.txt",
fileData: original,
});
const res = await put(`/api/dumps/${id}/file`, {
auth: owner.auth,
form: formWithFile(PNG, "b.png", "image/png", {
categoryIds: JSON.stringify(["nope"]),
}),
});
assertEquals(res.status, 400);
assertEquals(readFile(`dumps/${id}`), original);
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.fileName, "a.txt");
});
test("PUT /api/dumps/:id: going private hides the dump from everyone else", async () => {
const owner = await createUser();
const { id } = insertDump({ userId: owner.id });
await put(`/api/dumps/${id}`, {
auth: owner.auth,
json: { isPrivate: true },
});
assertEquals((await get(`/api/dumps/${id}`)).status, 404);
await put(`/api/dumps/${id}`, {
auth: owner.auth,
json: { isPrivate: false },
});
assertEquals((await get(`/api/dumps/${id}`)).status, 200);
});
test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async () => {
const owner = await createUser();
const { id } = insertDump({
userId: owner.id,
title: "file",
fileName: "a.txt",
});
const res = await put<Dump>(`/api/dumps/${id}`, {
auth: owner.auth,
json: {
title: "Renamed",
comment: "c",
isPrivate: true,
url: "https://ignored.test",
},
});
assertEquals(res.status, 200);
assertEquals(res.data.title, "Renamed");
assertEquals(res.data.isPrivate, true);
assertEquals(res.data.url, undefined);
});
test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => {
const owner = await createUser();
const other = await createUser();
const { id } = insertDump({
userId: owner.id,
title: "orig",
fileName: "a.txt",
});
const form = () =>
formWithFile(PNG, "b.png", "image/png", { title: "Replaced" });
assertEquals(
(await put(`/api/dumps/${id}/file`, { auth: other.auth, form: form() }))
.status,
403,
);
const res = await put<Dump>(`/api/dumps/${id}/file`, {
auth: owner.auth,
form: form(),
});
assertEquals(res.status, 200);
assertEquals(res.data.fileName, "b.png");
assertEquals(res.data.title, "Replaced");
assertEquals(readFile(`dumps/${id}`), PNG);
const urlDump = insertDump({ userId: owner.id });
assertEquals(
(await put(`/api/dumps/${urlDump.id}/file`, {
auth: owner.auth,
form: form(),
}))
.status,
400,
);
});
test("PUT /api/dumps/:id/file: visibility changes with the file, or stays", async () => {
const owner = await createUser();
const other = await createUser();
const { id } = insertDump({ userId: owner.id, fileName: "a.txt" });
const replace = (fields: Record<string, string>) =>
put<Dump>(`/api/dumps/${id}/file`, {
auth: owner.auth,
form: formWithFile(PNG, "b.png", "image/png", fields),
});
const visible = async () =>
(await get(`/api/dumps/${id}`, { auth: other.auth })).status === 200;
let res = await replace({ isPrivate: "true" });
assertEquals(res.data.isPrivate, true);
assertEquals(await visible(), false);
assertEquals((await get(`/api/files/${id}`)).status, 404);
// Left out, it stays private.
res = await replace({});
assertEquals(res.data.isPrivate, true);
assertEquals(await visible(), false);
res = await replace({ isPrivate: "false" });
assertEquals(res.data.isPrivate, false);
assertEquals(await visible(), true);
// A refused replacement changes neither.
res = await replace({ isPrivate: "true", categoryIds: '["nope"]' });
assertEquals(res.status, 400);
assertEquals(await visible(), true);
});
test("custom thumbnails: set, served, removed", async () => {
const owner = await createUser();
const other = await createUser();
const { id } = insertDump({ userId: owner.id });
assertEquals(
(await put(`/api/dumps/${id}/thumbnail`, {
auth: other.auth,
form: formWithFile(PNG),
})).status,
403,
);
assertEquals(
(await put(`/api/dumps/${id}/thumbnail`, {
auth: owner.auth,
form: formWithFile(
new TextEncoder().encode("<svg/>"),
"x.svg",
"image/svg+xml",
),
})).status,
400,
);
const set = await put<Dump>(`/api/dumps/${id}/thumbnail`, {
auth: owner.auth,
form: formWithFile(PNG),
});
assertEquals(set.status, 200);
assertEquals(set.data.thumbnailMime, "image/png");
const thumb = await get(`/api/thumbnails/${id}`);
assertEquals(thumb.status, 200);
assertEquals(thumb.headers.get("content-type"), "image/png");
assertEquals(thumb.bytes, PNG);
const removed = await del<Dump>(`/api/dumps/${id}/thumbnail`, {
auth: owner.auth,
});
assertEquals(removed.data.thumbnailMime, undefined);
assertEquals(readFile(`thumbnails/${id}-custom`), null);
// A URL dump without its own thumbnail has none to serve.
assertEquals((await get(`/api/thumbnails/${id}`)).status, 404);
});
test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on failure", async () => {
const owner = await createUser();
const { id } = insertDump({
userId: owner.id,
title: "Kept",
url: "https://example.com/r",
richContent: {
type: "generic",
url: "https://example.com/r",
title: "Kept",
},
});
{
using _ = stubNetwork(() => {
throw new TypeError("down");
});
const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, {
auth: owner.auth,
});
assertEquals(res.status, 502);
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.title, "Kept");
}
{
using _ = stubNetwork(() => PAGE.clone());
const res = await post<Dump>(`/api/dumps/${id}/refresh-metadata`, {
auth: owner.auth,
});
assertEquals(res.status, 200);
assertEquals(res.data.title, "Scraped title");
}
const file = insertDump({ userId: owner.id, fileName: "f.txt" });
assertEquals(
(await post(`/api/dumps/${file.id}/refresh-metadata`, { auth: owner.auth }))
.status,
400,
);
});
// ── Delete ───────────────────────────────────────────────────────────────────
test("DELETE /api/dumps/:id removes the dump, its file and what hangs off it", async () => {
const owner = await createUser();
const other = await createUser();
const mod = await createUser({ role: "moderator" });
const { id } = insertDump({ userId: owner.id, fileName: "f.txt" });
insertComment({ dumpId: id, userId: other.id });
db.prepare(
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
)
.run(id, other.id, new Date().toISOString());
assertEquals(
(await del(`/api/dumps/${id}`, { auth: other.auth })).status,
403,
);
assertEquals(
(await del(`/api/dumps/${id}`, { auth: owner.auth })).status,
200,
);
assertEquals((await get(`/api/dumps/${id}`)).status, 404);
assertEquals(readFile(`dumps/${id}`), null);
assertEquals(count(`SELECT COUNT(*) as n FROM comments;`), 0);
assertEquals(count(`SELECT COUNT(*) as n FROM votes;`), 0);
const second = insertDump({ userId: owner.id });
assertEquals(
(await del(`/api/dumps/${second.id}`, { auth: mod.auth })).status,
200,
);
assertEquals(
(await del(`/api/dumps/${second.id}`, { auth: mod.auth })).status,
404,
);
});

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

@@ -0,0 +1,184 @@
import {
type ApiResponse,
count,
createUser,
db,
del,
get,
insertCategory,
insertDump,
insertPlaylist,
post,
test,
} 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);
}
test("following users: status, idempotence, no self-follow, one notification", async () => {
const me = await createUser();
const them = await createUser();
assertEquals(
(await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status,
204,
);
assertEquals(
(await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status,
204,
);
assertEquals(
(await post(`/api/follows/users/${me.id}`, { auth: me.auth })).status,
400,
);
assertEquals(
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data,
{ followedUserIds: [them.id], followedPlaylistIds: [] },
);
// Unfollow and follow again: still just the one notification.
await del(`/api/follows/users/${them.id}`, { auth: me.auth });
assertEquals(
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data
.followedUserIds,
[],
);
await post(`/api/follows/users/${them.id}`, { auth: me.auth });
assertEquals(
count(
`SELECT COUNT(*) as n FROM notifications WHERE user_id = ? AND type = 'user_followed';`,
them.id,
),
1,
);
const list = await get<PaginatedData<PublicUser>>(
`/api/users/${me.username}/followed-users`,
);
assertEquals(list.data.items.map((u) => u.id), [
them.id,
]);
assertFalse("email" in list.data.items[0]);
});
test("following playlists: public ones only, and not your own notification", async () => {
const owner = await createUser();
const me = await createUser();
const pub = insertPlaylist({ userId: owner.id });
const priv = insertPlaylist({ userId: owner.id, isPublic: false });
assertEquals(
(await post(`/api/follows/playlists/${pub.id}`, { auth: me.auth })).status,
204,
);
assertEquals(
(await post(`/api/follows/playlists/${priv.id}`, { auth: me.auth })).status,
403,
);
assertEquals(
(await post(`/api/follows/playlists/${crypto.randomUUID()}`, {
auth: me.auth,
})).status,
404,
);
await post(`/api/follows/playlists/${pub.id}`, { auth: owner.auth });
assertEquals(
count(
`SELECT COUNT(*) as n FROM notifications WHERE type = 'playlist_followed';`,
),
1,
);
assertEquals(
(await get<PaginatedData<Playlist>>(
`/api/users/${me.username}/followed-playlists`,
)).data
.items[0].id,
pub.id,
);
await del(`/api/follows/playlists/${pub.id}`, { auth: me.auth });
assertEquals(
(await get<FollowStatus>("/api/follows/status", { auth: me.auth })).data
.followedPlaylistIds,
[],
);
});
test("followed-users feed: public dumps of the people I follow, newest first", async () => {
const me = await createUser();
const followed = await createUser();
const stranger = await createUser();
const music = insertCategory({ slug: "music" });
insertDump({
userId: followed.id,
title: "old",
createdAt: "2024-01-01T00:00:00Z",
});
const tagged = insertDump({
userId: followed.id,
title: "new",
createdAt: "2024-02-01T00:00:00Z",
});
db.prepare(
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
)
.run(tagged.id, music);
insertDump({ userId: followed.id, title: "private", isPrivate: true });
insertDump({ userId: stranger.id, title: "stranger" });
await post(`/api/follows/users/${followed.id}`, { auth: me.auth });
const feed = await get<PaginatedData<Dump>>("/api/follows/feed/users", {
auth: me.auth,
});
assertEquals(titles(feed), ["new", "old"]);
assertEquals(feed.data.items[0].categoryIds, [music]);
assertEquals(
titles(
await get("/api/follows/feed/users?category=music", { auth: me.auth }),
),
["new"],
);
// An unknown category filters everything out rather than nothing.
assertEquals(
(await get<PaginatedData<Dump>>("/api/follows/feed/users?category=nope", {
auth: me.auth,
})).data,
{ items: [], hasMore: false },
);
assertEquals((await get("/api/follows/feed/users")).status, 401);
});
test("followed-playlists feed: each dump once, public playlists only", async () => {
const me = await createUser();
const owner = await createUser();
const p1 = insertPlaylist({ userId: owner.id });
const p2 = insertPlaylist({ userId: owner.id });
const d = insertDump({ userId: owner.id, title: "shared" });
const priv = insertDump({
userId: owner.id,
title: "private",
isPrivate: true,
});
for (const p of [p1, p2]) {
await post(`/api/follows/playlists/${p.id}`, { auth: me.auth });
await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth });
}
await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth });
const feed = await get<PaginatedData<Dump>>("/api/follows/feed/playlists", {
auth: me.auth,
});
assertEquals(titles(feed), ["shared"]);
assertEquals(feed.data.items.length, 1);
// A playlist gone private drops out of the feed.
db.prepare(`UPDATE playlists SET is_public = 0;`).run();
assertEquals(
(await get<PaginatedData<Dump>>("/api/follows/feed/playlists", {
auth: me.auth,
})).data
.items.length,
0,
);
});

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

@@ -0,0 +1,465 @@
import {
type ApiResponse,
count,
createUser,
db,
del,
formWithFile,
get,
insertDump,
insertPlaylist,
PNG,
post,
put,
test,
TEST_DIR,
} from "../helpers.ts";
import type { PaginatedData } from "../../../shared/api.ts";
import type {
Playlist,
PlaylistMembership,
PlaylistWithDumps,
} from "../../../shared/playlist.ts";
import { assert, assertEquals, assertMatch } from "@std/assert";
function ids(res: ApiResponse<PlaylistWithDumps>): string[] {
return res.data.dumps.map((d) => d.id);
}
test("POST /api/playlists creates; GET finds it by id or slug", async () => {
const user = await createUser();
const res = await post<Playlist>("/api/playlists", {
auth: user.auth,
json: { title: "Road Trip", description: "songs", isPublic: true },
});
assertEquals(res.status, 201);
const { id, slug } = res.data;
assertMatch(slug ?? "", /^road-trip-/);
assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.title,
"Road Trip",
);
const bySlug = await get<PlaylistWithDumps>(`/api/playlists/${slug}`);
assertEquals(bySlug.data.id, id);
assertEquals(bySlug.data.ownerUsername, user.username);
assertEquals(bySlug.data.dumps, []);
assertEquals(
(await post("/api/playlists", { auth: user.auth, json: { title: "" } }))
.status,
400,
);
assertEquals(
(await post("/api/playlists", { json: { title: "x", isPublic: true } }))
.status,
401,
);
});
test("private playlists are the owner's alone", async () => {
const owner = await createUser();
const other = await createUser();
const { id } = insertPlaylist({ userId: owner.id, isPublic: false });
assertEquals((await get(`/api/playlists/${id}`)).status, 404);
assertEquals(
(await get(`/api/playlists/${id}`, { auth: other.auth })).status,
404,
);
assertEquals(
(await get(`/api/playlists/${id}`, { auth: owner.auth })).status,
200,
);
insertPlaylist({ userId: owner.id });
const path = `/api/users/${owner.username}/playlists`;
assertEquals((await get<PaginatedData<Playlist>>(path)).data.items.length, 1);
assertEquals(
(await get<PaginatedData<Playlist>>(path, { auth: owner.auth })).data.items
.length,
2,
);
});
test("adding dumps: newest on top, no duplicates, only dumps you can see", async () => {
const owner = await createUser();
const other = await createUser();
const { id } = insertPlaylist({ userId: owner.id });
const a = insertDump({ userId: owner.id, title: "a" });
const b = insertDump({ userId: other.id, title: "b" });
const theirs = insertDump({ userId: other.id, isPrivate: true });
assertEquals(
(await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth }))
.status,
204,
);
assertEquals(
(await post(`/api/playlists/${id}/dumps/${b.id}`, { auth: owner.auth }))
.status,
204,
);
assertEquals(ids(await get(`/api/playlists/${id}`)), [b.id, a.id]);
assertEquals(
(await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth }))
.status,
409,
);
assertEquals(
(await post(`/api/playlists/${id}/dumps/${theirs.id}`, {
auth: owner.auth,
})).status,
404,
);
assertEquals(
(await post(`/api/playlists/${id}/dumps/${crypto.randomUUID()}`, {
auth: owner.auth,
}))
.status,
404,
);
assertEquals(
(await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: other.auth }))
.status,
403,
);
assertEquals(
(await get<PlaylistWithDumps>(`/api/playlists/${id}`)).data.dumpCount,
2,
);
});
test("a public playlist hides its private dumps from everyone but their owner", async () => {
const owner = await createUser();
const { id } = insertPlaylist({ userId: owner.id });
const pub = insertDump({ userId: owner.id });
const priv = insertDump({ userId: owner.id, isPrivate: true });
for (const d of [pub, priv]) {
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
}
assertEquals(ids(await get(`/api/playlists/${id}`)), [pub.id]);
assertEquals(
ids(await get(`/api/playlists/${id}`, { auth: owner.auth })).length,
2,
);
});
test("reorder: must name exactly the current members", async () => {
const owner = await createUser();
const { id } = insertPlaylist({ userId: owner.id });
const [a, b, c] = [1, 2, 3].map(() => insertDump({ userId: owner.id }));
for (const d of [a, b, c]) {
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
}
const order = [a.id, c.id, b.id];
assertEquals(
(await put(`/api/playlists/${id}/order`, {
auth: owner.auth,
json: { dumpIds: order },
}))
.status,
200,
);
assertEquals(ids(await get(`/api/playlists/${id}`)), order);
for (const dumpIds of [[a.id, b.id], [a.id, b.id, crypto.randomUUID()]]) {
assertEquals(
(await put(`/api/playlists/${id}/order`, {
auth: owner.auth,
json: { dumpIds },
}))
.status,
400,
JSON.stringify(dumpIds),
);
}
// A dump added after reordering still lands on top.
const d = insertDump({ userId: owner.id });
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
assertEquals(ids(await get(`/api/playlists/${id}`))[0], d.id);
});
test("reorder: a list naming a member twice is not the current members", async () => {
const owner = await createUser();
const { id } = insertPlaylist({ userId: owner.id });
const [a, b] = [1, 2].map(() => insertDump({ userId: owner.id }));
for (const d of [a, b]) {
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
}
const res = await put(`/api/playlists/${id}/order`, {
auth: owner.auth,
json: { dumpIds: [a.id, b.id, a.id] },
});
assertEquals(res.status, 400);
});
test("removing a dump; updating; deleting", async () => {
const owner = await createUser();
const other = await createUser();
const mod = await createUser({ role: "moderator" });
const { id } = insertPlaylist({ userId: owner.id, title: "Old" });
const a = insertDump({ userId: owner.id });
await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth });
assertEquals(
(await del(`/api/playlists/${id}/dumps/${a.id}`, { auth: other.auth }))
.status,
403,
);
assertEquals(
(await del(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth }))
.status,
204,
);
assertEquals(ids(await get(`/api/playlists/${id}`)), []);
assertEquals(
(await put(`/api/playlists/${id}`, {
auth: other.auth,
json: { title: "x" },
})).status,
403,
);
const updated = await put<Playlist>(`/api/playlists/${id}`, {
auth: owner.auth,
json: { title: "New", description: "d", isPublic: false },
});
assertEquals(updated.data.title, "New");
assertMatch(updated.data.slug ?? "", /^new-/);
assertEquals(updated.data.isPublic, false);
const cleared = await put<Playlist>(`/api/playlists/${id}`, {
auth: owner.auth,
json: { description: null },
});
assertEquals(cleared.data.description, undefined);
assertEquals(cleared.data.title, "New");
assertEquals(
(await del(`/api/playlists/${id}`, { auth: other.auth })).status,
403,
);
assertEquals(
(await del(`/api/playlists/${id}`, { auth: mod.auth })).status,
204,
);
assertEquals(
(await get(`/api/playlists/${id}`, { auth: owner.auth })).status,
404,
);
assertEquals(count(`SELECT COUNT(*) as n FROM playlist_dumps;`), 0);
});
test("memberships: which of my playlists hold this dump", async () => {
const owner = await createUser();
const other = await createUser();
const p1 = insertPlaylist({ userId: owner.id, title: "has" });
insertPlaylist({ userId: owner.id, title: "hasnt" });
insertPlaylist({ userId: other.id, title: "theirs" });
const d = insertDump({ userId: owner.id });
await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth });
const res = await get<PlaylistMembership[]>(
`/api/playlists/by-dump/${d.id}/memberships`,
{
auth: owner.auth,
},
);
const byTitle = Object.fromEntries(
res.data.map((
m: { playlist: { title: string }; hasDump: boolean },
) => [
m.playlist.title,
m.hasDump,
]),
);
assertEquals(byTitle, { has: true, hasnt: false });
});
test("adding to a followed public playlist notifies followers, private dumps excepted", async () => {
const owner = await createUser();
const follower = await createUser();
const { id } = insertPlaylist({ userId: owner.id, title: "Mix" });
await post(`/api/follows/playlists/${id}`, { auth: follower.auth });
db.exec(`DELETE FROM notifications;`); // the follow notified the owner
const priv = insertDump({ userId: owner.id, isPrivate: true });
await post(`/api/playlists/${id}/dumps/${priv.id}`, { auth: owner.auth });
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
const pub = insertDump({ userId: owner.id, title: "Song" });
await post(`/api/playlists/${id}/dumps/${pub.id}`, { auth: owner.auth });
const note = db.prepare(`SELECT user_id, type, data FROM notifications;`)
.get() as {
user_id: string;
type: string;
data: string;
};
assertEquals(note.user_id, follower.id);
assertEquals(note.type, "playlist_dump_added");
assertEquals(JSON.parse(note.data), {
dumpId: pub.id,
dumpTitle: "Song",
playlistId: id,
playlistTitle: "Mix",
});
});
test("playlist images: upload and serve", async () => {
const owner = await createUser();
const other = await createUser();
const { id, slug } = insertPlaylist({ userId: owner.id });
assertEquals((await get(`/api/playlists/${id}/image`)).status, 404);
assertEquals(
(await post(`/api/playlists/${id}/image`, {
auth: other.auth,
form: formWithFile(PNG),
}))
.status,
403,
);
const res = await post<Playlist>(`/api/playlists/${slug}/image`, {
auth: owner.auth,
form: formWithFile(PNG),
});
assertEquals(res.status, 200);
assertEquals(res.data.imageMime, "image/png");
// A new cover counts as an edit, so image URLs versioned on it change.
assert(res.data.updatedAt);
const served = await get(`/api/playlists/${slug}/image`);
assertEquals(served.status, 200);
assertEquals(served.bytes, PNG);
});
test("playlist images: a failed write leaves the row and the old cover alone", async () => {
const owner = await createUser();
const { id } = insertPlaylist({ userId: owner.id });
const dir = `${TEST_DIR}/uploads/playlist-images`;
const row = () =>
db.prepare(`SELECT image_mime, updated_at FROM playlists WHERE id = ?;`)
.get(id) as { image_mime: string | null; updated_at: string | null };
// No cover yet, and the write fails (a directory sits where the file goes):
// the playlist mustn't claim a cover it hasn't got.
await Deno.mkdir(`${dir}/${id}/blocker`, { recursive: true });
try {
const failed = await post(`/api/playlists/${id}/image`, {
auth: owner.auth,
form: formWithFile(PNG),
});
assertEquals(failed.status, 500);
assertEquals(row(), { image_mime: null, updated_at: null });
assertEquals((await get(`/api/playlists/${id}/image`)).status, 404);
} finally {
await Deno.remove(`${dir}/${id}`, { recursive: true });
}
// With a cover in place, a failed replacement keeps serving the old one
// under the old version, and leaves no stray temporary file behind.
await post(`/api/playlists/${id}/image`, {
auth: owner.auth,
form: formWithFile(PNG),
});
const before = row();
await Deno.chmod(dir, 0o555);
try {
const failed = await post(`/api/playlists/${id}/image`, {
auth: owner.auth,
form: formWithFile(new Uint8Array([...PNG, 0])),
});
assertEquals(failed.status, 500);
} finally {
await Deno.chmod(dir, 0o755);
}
assertEquals(row(), before);
assertEquals((await get(`/api/playlists/${id}/image`)).bytes, PNG);
const left = [...Deno.readDirSync(dir)]
.map((e) => e.name)
.filter((name) => name.startsWith(id));
assertEquals(left, [id]);
});
test("playlist images: a replaced cover is served under the new version", async () => {
const owner = await createUser();
const { id } = insertPlaylist({ userId: owner.id });
const first = await post<Playlist>(`/api/playlists/${id}/image`, {
auth: owner.auth,
form: formWithFile(PNG),
});
await new Promise((r) => setTimeout(r, 5)); // a distinct updated_at
const other = new Uint8Array([...PNG, 1, 2, 3]);
const second = await post<Playlist>(`/api/playlists/${id}/image`, {
auth: owner.auth,
form: formWithFile(other),
});
assert(second.data.updatedAt !== first.data.updatedAt);
assertEquals((await get(`/api/playlists/${id}/image`)).bytes, other);
});
test("reorder: someone else's dump that went private keeps its slot", async () => {
const owner = await createUser();
const author = await createUser();
const { id } = insertPlaylist({ userId: owner.id });
const a = insertDump({ userId: owner.id });
const theirs = insertDump({ userId: author.id });
const b = insertDump({ userId: owner.id });
// Each add goes on top: the order is b, theirs, a.
for (const d of [a, theirs, b]) {
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
}
await put(`/api/dumps/${theirs.id}`, {
auth: author.auth,
json: { isPrivate: true },
});
// The owner can't see it, so can't name it: what they see is theirs to order.
assertEquals(ids(await get(`/api/playlists/${id}`, { auth: owner.auth })), [
b.id,
a.id,
]);
const res = await put(`/api/playlists/${id}/order`, {
auth: owner.auth,
json: { dumpIds: [a.id, b.id] },
});
assertEquals(res.status, 200);
assertEquals(ids(await get(`/api/playlists/${id}`, { auth: owner.auth })), [
a.id,
b.id,
]);
// The hidden one hasn't moved, and comes back where it was if made public.
await put(`/api/dumps/${theirs.id}`, {
auth: author.auth,
json: { isPrivate: false },
});
assertEquals(ids(await get(`/api/playlists/${id}`)), [a.id, theirs.id, b.id]);
// Naming a hidden id is still not the list the requester sees.
await put(`/api/dumps/${theirs.id}`, {
auth: author.auth,
json: { isPrivate: true },
});
assertEquals(
(await put(`/api/playlists/${id}/order`, {
auth: owner.auth,
json: { dumpIds: [a.id, theirs.id, b.id] },
})).status,
400,
);
});
test("reorder: a moderator orders around the owner's private dumps", async () => {
const owner = await createUser();
const mod = await createUser({ role: "moderator" });
const { id } = insertPlaylist({ userId: owner.id });
const a = insertDump({ userId: owner.id });
const priv = insertDump({ userId: owner.id, isPrivate: true });
const b = insertDump({ userId: owner.id });
for (const d of [a, priv, b]) {
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
}
assertEquals(ids(await get(`/api/playlists/${id}`, { auth: mod.auth })), [
b.id,
a.id,
]);
assertEquals(
(await put(`/api/playlists/${id}/order`, {
auth: mod.auth,
json: { dumpIds: [a.id, b.id] },
})).status,
200,
);
assertEquals(ids(await get(`/api/playlists/${id}`, { auth: owner.auth })), [
a.id,
priv.id,
b.id,
]);
});

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

@@ -0,0 +1,509 @@
import {
count,
createUser,
db,
del,
get,
insertDump,
PASSWORD,
patch,
post,
test,
} from "../helpers.ts";
import type {
AuthResponse,
AuthTokens,
Invitee,
MediaTokenResponse,
PublicUser,
SelfUser,
UserSuggestion,
} from "../../../shared/user.ts";
import { assert, assertEquals, assertFalse } from "@std/assert";
import {
createPasswordResetToken,
verifyJWT,
verifyMediaToken,
} from "../../lib/jwt.ts";
import { authenticateMedia } from "../../lib/auth.ts";
import type { Dump } from "../../../shared/dump.ts";
import type { PaginatedData } from "../../../shared/api.ts";
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("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);
});
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 ───────────────────────────────────────────────────────
test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => {
const user = await createUser();
const ok = await post<AuthResponse>("/api/users/login", {
json: { username: user.username, password: PASSWORD },
});
assertEquals(ok.status, 200);
assertEquals(ok.data.user.id, user.id);
assertFalse("passwordHash" in ok.data.user);
assertEquals((await verifyJWT(ok.data.token))?.userId, user.id);
assertEquals(
(await verifyMediaToken(ok.data.mediaToken))?.userId,
user.id,
);
const wrong = await post("/api/users/login", {
json: { username: user.username, password: "nope" },
});
const unknown = await post("/api/users/login", {
json: { username: "no_such_user_here", password: "nope" },
});
assertEquals(wrong.status, 401);
assertEquals(unknown.status, 401);
assertEquals(wrong.error.message, unknown.error.message);
assertEquals(
(await post("/api/users/login", { json: { username: user.username } }))
.status,
400,
);
});
test("POST /api/users/login: rate limited per account, case-insensitively", async () => {
const user = await createUser();
const statuses: number[] = [];
for (let i = 0; i < 11; i++) {
const username = i % 2 ? user.username.toUpperCase() : user.username;
statuses.push(
(await post("/api/users/login", {
json: { username, password: "wrong" },
})).status,
);
}
assertEquals(statuses.slice(0, 10), Array(10).fill(401));
assertEquals(statuses[10], 429);
// Even the right password is held back now.
assertEquals(
(await post("/api/users/login", {
json: { username: user.username, password: PASSWORD },
})).status,
429,
);
});
test("POST /api/users/register: an invite opens one account", async () => {
const inviter = await createUser();
const inviteToken = invite(inviter.id);
const res = await post<AuthResponse>("/api/users/register", {
json: {
username: "newbie_reg",
password: "longenough",
email: "newbie@example.test",
inviteToken,
},
});
assertEquals(res.status, 201);
assertEquals(res.data.user.username, "newbie_reg");
assertEquals(res.data.user.role, "user");
assertFalse("passwordHash" in res.data.user);
assert(res.data.token);
const row = db.prepare(
`SELECT invited_by FROM users WHERE username = 'newbie_reg';`,
).get() as { invited_by: string };
assertEquals(row.invited_by, inviter.id);
const again = await post("/api/users/register", {
json: {
username: "second_reg",
password: "longenough",
email: "second@example.test",
inviteToken,
},
});
assertEquals(again.status, 409);
});
test("POST /api/users/register: validation and unknown invites", async () => {
const inviter = await createUser();
const inviteToken = invite(inviter.id);
const base = {
username: "valid_name",
password: "longenough",
email: "v@example.test",
inviteToken,
};
const cases: [Record<string, unknown>, RegExp][] = [
[{ ...base, email: "nope" }, /email/],
[{ ...base, username: "has space" }, /Username/],
[{ ...base, username: "x".repeat(33) }, /Username/],
[{ ...base, password: "short" }, /at least 8/],
[{ ...base, password: "x".repeat(129) }, /at most 128/],
[{ ...base, inviteToken: 42 }, /Invalid request/],
];
for (const [json, message] of cases) {
const res = await post("/api/users/register", { json });
assertEquals(res.status, 400, JSON.stringify(json));
assert(message.test(res.error.message), res.error.message);
}
const unknown = await post("/api/users/register", {
json: { ...base, inviteToken: "made-up" },
});
assertEquals(unknown.status, 404);
// None of the failures used the invite up.
assertEquals(
count(`SELECT COUNT(*) as n FROM invites WHERE used_at IS NULL;`),
1,
);
});
test("POST /api/users/register: a taken name or email hands the invite back", async () => {
const inviter = await createUser();
const taken = await createUser();
const inviteToken = invite(inviter.id);
const byName = await post("/api/users/register", {
json: {
username: taken.username,
password: "longenough",
email: "fresh@example.test",
inviteToken,
},
});
assertEquals(byName.status, 400);
const byEmail = await post("/api/users/register", {
json: {
username: "fresh_name",
password: "longenough",
email: taken.email.toUpperCase(),
inviteToken,
},
});
assertEquals(byEmail.status, 409);
assertEquals(
count(
`SELECT COUNT(*) as n FROM invites WHERE token = ? AND used_at IS NULL;`,
inviteToken,
),
1,
);
});
test("POST /api/users/register: an invite older than 7 days is refused", async () => {
const inviter = await createUser();
const inviteToken = invite(
inviter.id,
new Date(Date.now() - 8 * 24 * 60 * 60 * 1000),
);
const res = await post("/api/users/register", {
json: {
username: "late_comer",
password: "longenough",
email: "late@example.test",
inviteToken,
},
});
assertEquals(res.status, 404);
});
test("POST /api/users/register: parallel registrations can't share an invite", async () => {
const inviter = await createUser();
const inviteToken = invite(inviter.id);
const results = await Promise.all(
[1, 2, 3].map((i) =>
post("/api/users/register", {
json: {
username: `racer_${i}`,
password: "longenough",
email: `racer${i}@example.test`,
inviteToken,
},
})
),
);
assertEquals(results.filter((r) => r.status === 201).length, 1);
assertEquals(
count(`SELECT COUNT(*) as n FROM users WHERE invited_by = ?;`, inviter.id),
1,
);
});
// ── Password changes ─────────────────────────────────────────────────────────
test("change-password: needs the current one, revokes old tokens, returns new ones", async () => {
const user = await createUser();
const wrong = await post("/api/users/me/change-password", {
auth: user.auth,
json: { currentPassword: "nope", newPassword: "brand-new-pass" },
});
assertEquals(wrong.status, 401);
const short = await post("/api/users/me/change-password", {
auth: user.auth,
json: { currentPassword: PASSWORD, newPassword: "short" },
});
assertEquals(short.status, 400);
// iat has one-second resolution: make sure the old token is from an earlier
// second than the change.
await new Promise((r) => setTimeout(r, 1100));
const ok = await post<AuthTokens>("/api/users/me/change-password", {
auth: user.auth,
json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" },
});
assertEquals(ok.status, 200);
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
assertEquals(await authenticateMedia(user.mediaToken), null);
const fresh = { Authorization: `Bearer ${ok.data.token}` };
assertEquals((await get("/api/users/me", { auth: fresh })).status, 200);
assertEquals(await authenticateMedia(ok.data.mediaToken), user.id);
const login = await post("/api/users/login", {
json: { username: user.username, password: "brand-new-pass" },
});
assertEquals(login.status, 200);
});
test("request-password-reset: always 200, stores nothing without SMTP", async () => {
const user = await createUser();
for (const email of [user.email, "nobody@example.test", "", undefined]) {
const res = await post("/api/users/request-password-reset", {
json: { email },
});
assertEquals(res.status, 200);
}
assertEquals(count(`SELECT COUNT(*) as n FROM password_reset_tokens;`), 0);
});
test("reset-password: a stored token works once", async () => {
const user = await createUser();
const token = await createPasswordResetToken(user.id);
db.prepare(
`INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`,
).run(token, user.id, new Date(Date.now() + 3600_000).toISOString());
const short = await post("/api/users/reset-password", {
json: { token, newPassword: "short" },
});
assertEquals(short.status, 400);
const ok = await post("/api/users/reset-password", {
json: { token, newPassword: "reset-password-1" },
});
assertEquals(ok.status, 200);
const login = await post("/api/users/login", {
json: { username: user.username, password: "reset-password-1" },
});
assertEquals(login.status, 200);
const reuse = await post("/api/users/reset-password", {
json: { token, newPassword: "reset-password-2" },
});
assertEquals(reuse.status, 409);
});
test("reset-password: an unstored, forged or wrong-purpose token fails", async () => {
const user = await createUser();
const unstored = await createPasswordResetToken(user.id);
assertEquals(
(await post("/api/users/reset-password", {
json: { token: unstored, newPassword: "whatever-123" },
})).status,
404,
);
for (const token of ["garbage", user.token, user.mediaToken]) {
assertEquals(
(await post("/api/users/reset-password", {
json: { token, newPassword: "whatever-123" },
})).status,
400,
);
}
assertEquals(
(await post("/api/users/reset-password", { json: { token: 1 } })).status,
400,
);
});
test("GET /api/users/me/media-token issues a media token for the caller", async () => {
const user = await createUser();
const res = await get<MediaTokenResponse>("/api/users/me/media-token", {
auth: user.auth,
});
assertEquals(res.status, 200);
assertEquals(await authenticateMedia(res.data.mediaToken), user.id);
});

660
api/test/routes/ws.test.ts Normal file
View File

@@ -0,0 +1,660 @@
import {
app,
count,
createUser,
db,
formWithFile,
get,
insertComment,
insertDump,
insertPlaylist,
PASSWORD,
PNG,
post,
put,
resetDb,
test,
type TestUser,
} from "../helpers.ts";
import { assert, assertEquals, assertExists, assertFalse } from "@std/assert";
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.
const DenoWebSocket = WebSocket as unknown as new (
url: string,
options: { headers: HeadersInit },
) => WebSocket;
type Msg = ServerToClientMessage;
type MsgOf<T extends Msg["type"]> = Extract<Msg, { type: T }>;
interface Client {
ws: WebSocket;
messages: Msg[];
/** Resolves with the next unread message of `type` (and matching `where`). */
next<T extends Msg["type"]>(
type: T,
where?: (m: MsgOf<T>) => boolean,
): Promise<MsgOf<T>>;
/** Every message of `type` received so far. */
all<T extends Msg["type"]>(type: T): MsgOf<T>[];
send(data: unknown): void;
/** Round-trips a ping, so anything the server sent before it has arrived. */
sync(): Promise<void>;
closed: Promise<CloseEvent>;
}
async function withServer(
fn: (
connect: (user?: TestUser, origin?: string) => Promise<Client>,
) => Promise<void>,
): Promise<void> {
resetDb();
// Oak's own server, not app.handle(): an upgraded request has no response
// for handle() to return. Its listen() promise never settles after the
// abort, hence the sanitizers being off for these tests.
const controller = new AbortController();
const port = await new Promise<number>((resolve) => {
app.addEventListener("listen", (e) => resolve(e.port), { once: true });
app.listen({ hostname: "127.0.0.1", port: 0, signal: controller.signal });
});
const clients: Client[] = [];
const connect = (user?: TestUser, origin = "http://allowed.test") => {
const query = user ? `?token=${user.token}` : "";
const ws = new DenoWebSocket(
`ws://127.0.0.1:${port}/ws${query}`,
{ headers: { origin } },
);
const messages: Msg[] = [];
const read = new Set<Msg>();
const waiters: (() => void)[] = [];
ws.onmessage = (e) => {
messages.push(JSON.parse(e.data));
waiters.splice(0).forEach((w) => w());
};
const closed = new Promise<CloseEvent>((resolve) => {
ws.addEventListener("close", (e) => resolve(e));
});
const client: Client = {
ws,
messages,
closed,
async next<T extends Msg["type"]>(
type: T,
where: (m: MsgOf<T>) => boolean = () => true,
) {
const deadline = Date.now() + 3000;
while (true) {
const hit = messages.find((m): m is MsgOf<T> =>
!read.has(m) && m.type === type && where(m as MsgOf<T>)
);
if (hit) {
read.add(hit);
return hit;
}
if (Date.now() > deadline) {
throw new Error(
`No ${type} message; got ${
messages.map((m) => m.type).join(", ")
}`,
);
}
await Promise.race([
new Promise<void>((r) => waiters.push(r)),
new Promise((r) => setTimeout(r, 50)),
]);
}
},
all: <T extends Msg["type"]>(type: T) =>
messages.filter((m): m is MsgOf<T> => m.type === type),
send: (data) =>
ws.send(typeof data === "string" ? data : JSON.stringify(data)),
async sync() {
client.send({ type: "ping" });
await client.next("pong");
},
};
clients.push(client);
return new Promise<Client>((resolve, reject) => {
ws.onopen = async () => {
await client.next("welcome");
resolve(client);
};
ws.onerror = () => reject(new Error("socket error"));
});
};
try {
await fn(connect);
} finally {
for (const c of clients) {
if (
c.ws.readyState === WebSocket.OPEN ||
c.ws.readyState === WebSocket.CONNECTING
) {
c.ws.close();
}
}
await Promise.all(clients.map((c) => c.closed));
// Let the server see the closes and drop the clients from its registry.
await new Promise((r) => setTimeout(r, 20));
controller.abort();
}
}
function wsTest(name: string, fn: Parameters<typeof withServer>[0]) {
Deno.test({
name,
sanitizeOps: false,
sanitizeResources: false,
fn: () => withServer(fn),
});
}
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 });
db.prepare(
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
)
.run(dumpId, user.id, new Date().toISOString());
const anon = await connect();
const [welcome] = anon.all("welcome");
assertEquals(welcome.users, []);
assertEquals(welcome.myVotes, []);
assertEquals(welcome.unreadChatCount, 0);
assertEquals(welcome.chatReadAt, undefined);
const me = await connect(user);
const [mine] = me.all("welcome");
assertEquals(mine.myVotes, [dumpId]);
assertEquals(mine.users.map((u) => u.username), [user.username]);
assertEquals(mine.chatReadAt, "2024-01-01T00:00:00.000Z");
// The anonymous client hears about the arrival.
const presence = await anon.next(
"presence_update",
(m) => m.users.length === 1,
);
assertEquals(presence.users[0].userId, user.id);
});
wsTest(
"presence: one entry per user however many tabs, gone on close",
async (connect) => {
const user = await createUser();
const watcher = await connect();
const tab1 = await connect(user);
const tab2 = await connect(user);
assertEquals(tab2.all("welcome")[0].users.length, 1);
tab1.ws.close();
await tab1.closed;
tab2.ws.close();
await tab2.closed;
await watcher.next("presence_update", (m) => m.users.length === 0);
},
);
wsTest(
"a refused origin never connects; a revoked token connects anonymously",
async (connect) => {
let refused = false;
await connect(undefined, "http://evil.test").catch(() => (refused = true));
assert(refused);
const user = await createUser();
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`)
.run(new Date(Date.now() + 60_000).toISOString(), user.id);
const client = await connect(user);
assertEquals(client.all("welcome")[0].users, []);
client.send({ type: "vote_cast", dumpId: "x" });
assertEquals(
(await client.next("error")).message,
"Authentication required",
);
},
);
wsTest(
"votes: ack to the voter, count to everyone, errors carry context",
async (connect) => {
const owner = await createUser();
const voter = await createUser();
const { id: dumpId } = insertDump({ userId: owner.id });
const watcher = await connect();
const v = await connect(voter);
v.send({ type: "vote_cast", dumpId });
assertEquals(await v.next("vote_ack"), {
type: "vote_ack",
dumpId,
action: "cast",
voteCount: 1,
});
const update = await watcher.next("votes_update");
assertEquals([update.voteCount, update.voterId, update.action], [
1,
voter.id,
"cast",
]);
v.send({ type: "vote_cast", dumpId });
const dup = await v.next("error");
assertEquals([dup.message, dup.dumpId, dup.action], [
"Already voted",
dumpId,
"cast",
]);
v.send({ type: "vote_remove", dumpId });
assertEquals((await v.next("vote_ack")).voteCount, 0);
v.send({ type: "vote_remove", dumpId });
assertEquals((await v.next("error")).message, "Vote not found");
// The owner was notified of the upvote, live.
assertEquals(
count(
`SELECT COUNT(*) as n FROM notifications WHERE user_id = ?;`,
owner.id,
),
1,
);
const anon = await connect();
anon.send({ type: "vote_cast", dumpId });
assertEquals((await anon.next("error")).message, "Authentication required");
},
);
wsTest(
"votes on a private dump: others get a 404, the count stays with the owner",
async (connect) => {
const owner = await createUser();
const other = await createUser();
const { id: dumpId } = insertDump({ userId: owner.id, isPrivate: true });
const o = await connect(owner);
const x = await connect(other);
x.send({ type: "vote_cast", dumpId });
assertEquals((await x.next("error")).message, "Dump not found");
o.send({ type: "vote_cast", dumpId });
await o.next("vote_ack");
await o.next("votes_update");
await x.sync();
assertEquals(x.all("votes_update"), []);
},
);
wsTest("comment likes over the socket", async (connect) => {
const author = await createUser();
const liker = await createUser();
const { id: dumpId } = insertDump({ userId: author.id });
const commentId = insertComment({ dumpId, userId: author.id });
const a = await connect(author);
const l = await connect(liker);
l.send({ type: "comment_like_cast", commentId });
assertEquals((await l.next("comment_like_ack")).likeCount, 1);
assertEquals((await a.next("comment_likes_update")).likerId, liker.id);
// The like notification reaches the author live.
const note = await a.next("notification_created");
assertEquals(note.notification.type, "comment_liked");
l.send({ type: "comment_like_remove", commentId });
assertEquals((await l.next("comment_like_ack")).likeCount, 0);
l.send({ type: "comment_like_cast", commentId: crypto.randomUUID() });
assertEquals((await l.next("error")).message, "Comment not found");
});
wsTest(
"chat: members hear messages, anonymous sockets don't",
async (connect) => {
const alice = await createUser();
const bob = await createUser();
const a = await connect(alice);
const b = await connect(bob);
const anon = await connect();
a.send({ type: "chat_send", body: " hello ", nonce: "n1" });
const mine = await a.next("chat_message");
assertEquals(mine.nonce, "n1");
assertEquals(mine.message.body, "hello");
const theirs = await b.next("chat_message");
assertEquals(theirs.message.id, mine.message.id);
b.send({ type: "chat_send", body: "reply", replyToId: mine.message.id });
const reply = await a.next(
"chat_message",
(m) => m.message.body === "reply",
);
assertEquals(reply.message.replyToBody, "hello");
await anon.sync();
assertEquals(anon.all("chat_message"), []);
anon.send({ type: "chat_send", body: "hi", nonce: "anon" });
const err = await anon.next("error");
assertEquals([err.scope, err.nonce, err.message], [
"chat",
"anon",
"Authentication required",
]);
},
);
wsTest("chat: bad sends are reported with their nonce", async (connect) => {
const user = await createUser();
const c = await connect(user);
c.send({ type: "chat_send", body: " ", nonce: "blank" });
assertEquals((await c.next("error")).nonce, "blank");
c.send({ type: "chat_send", body: 42, nonce: "number" });
assertEquals((await c.next("error")).message, "Message cannot be empty");
c.send({ type: "chat_send", body: "x".repeat(2001), nonce: "long" });
assertEquals(
(await c.next("error", (m) => m.nonce === "long")).scope,
"chat",
);
// A nonce over 64 characters isn't echoed.
c.send({ type: "chat_send", body: " ", nonce: "n".repeat(65) });
assertEquals((await c.next("error")).nonce, undefined);
});
wsTest(
"chat: edits and deletes by the author or a moderator only",
async (connect) => {
const author = await createUser();
const other = await createUser();
const mod = await createUser({ role: "moderator" });
const a = await connect(author);
const o = await connect(other);
const m = await connect(mod);
a.send({ type: "chat_send", body: "original" });
const { message } = await a.next("chat_message");
o.send({ type: "chat_edit", id: message.id, body: "hacked" });
assertEquals(
(await o.next("error")).message,
"Not authorized to edit this message",
);
o.send({ type: "chat_delete", id: message.id });
assertEquals(
(await o.next("error")).message,
"Not authorized to delete this message",
);
a.send({ type: "chat_edit", id: message.id, body: "edited" });
assertEquals((await o.next("chat_message_updated")).message.body, "edited");
// A moderator demoted since connecting loses the power at once.
db.prepare(`UPDATE users SET role = 'user' WHERE id = ?;`).run(mod.id);
m.send({ type: "chat_delete", id: message.id });
assertEquals((await m.next("error")).scope, "chat");
db.prepare(`UPDATE users SET role = 'moderator' WHERE id = ?;`).run(mod.id);
m.send({ type: "chat_delete", id: message.id });
assertEquals((await a.next("chat_message_deleted")).id, message.id);
a.send({ type: "chat_edit", id: 5, body: "x" });
assertEquals((await a.next("error")).message, "Invalid message");
},
);
wsTest("chat: a burst of writes is rate limited", async (connect) => {
const user = await createUser();
const c = await connect(user);
for (let i = 0; i < 9; i++) {
c.send({ type: "chat_send", body: `m${i}`, nonce: `${i}` });
}
const err = await c.next("error");
assertEquals(err.nonce, "8");
assertEquals(err.message, "You're sending messages too fast — wait a moment");
await c.sync();
assertEquals(c.all("chat_message").length, 8);
});
wsTest(
"chat: mentions notify, unless the chat is already open",
async (connect) => {
const alice = await createUser();
const bob = await createUser();
const carol = await createUser();
const a = await connect(alice);
const b = await connect(bob);
b.send({ type: "chat_focus", open: true });
await b.sync();
a.send({
type: "chat_send",
body: `hey @${bob.username} @${carol.username}`,
});
await a.next("chat_message");
const notified = db.prepare(
`SELECT user_id FROM notifications WHERE type = 'user_mentioned';`,
).all().map((r) => r.user_id);
assertEquals(notified, [carol.id]);
},
);
wsTest("chat: typing and read markers", async (connect) => {
const alice = await createUser();
const bob = await createUser();
const a = await connect(alice);
const b1 = await connect(bob);
const b2 = await connect(bob);
a.send({ type: "chat_typing", typing: true });
const typing = await b1.next(
"chat_typing_update",
(m) => m.users.length === 1,
);
assertEquals(typing.users[0].userId, alice.id);
// Sending clears it.
a.send({ type: "chat_send", body: "done" });
await b1.next("chat_typing_update", (m) => m.users.length === 0);
const { message } = await b1.next("chat_message");
b1.send({ type: "chat_read", at: message.createdAt });
// Both of Bob's sockets drop the badge together.
for (const b of [b1, b2]) {
const update = await b.next("chat_read_update");
assertEquals([update.readAt, update.unread], [message.createdAt, 0]);
}
await a.sync();
assertEquals(a.all("chat_read_update"), []);
});
wsTest(
"garbage frames are ignored and the socket stays up",
async (connect) => {
const user = await createUser();
const c = await connect(user);
for (
const frame of [
"not json",
"null",
"42",
"[]",
`{"no":"type"}`,
`{"type":7}`,
`{"type":"unknown"}`,
`{"type":"vote_cast"}`,
`{"type":"chat_read","at":"nonsense"}`,
]
) {
c.send(frame);
}
c.ws.send(new Uint8Array([1, 2, 3]));
await c.sync();
assertEquals(c.ws.readyState, WebSocket.OPEN);
},
);
wsTest("REST writes broadcast to the right audience", async (connect) => {
const owner = await createUser();
const other = await createUser();
const o = await connect(owner);
const x = await connect(other);
// A public file dump arrives for everyone; a private one for no one.
const pub = await post<Dump>("/api/dumps", {
auth: owner.auth,
form: formWithFile(PNG),
});
assertEquals((await x.next("dump_created")).dump.id, pub.data.id);
await post("/api/dumps", {
auth: owner.auth,
form: formWithFile(PNG, "p.png", "image/png", { isPrivate: "true" }),
});
await x.sync();
assertEquals(x.all("dump_created").length, 1);
// Going private is a deletion for everyone else; going public an arrival.
await put(`/api/dumps/${pub.data.id}`, {
auth: owner.auth,
json: { isPrivate: true },
});
assertEquals((await x.next("dump_deleted")).dumpId, pub.data.id);
await put(`/api/dumps/${pub.data.id}`, {
auth: owner.auth,
json: { isPrivate: false },
});
await x.next("dump_created", (m) => m.dump.id === pub.data.id);
// Comments on a private dump stay quiet.
const priv = insertDump({ userId: owner.id, isPrivate: true });
await post(`/api/dumps/${priv.id}/comments`, {
auth: owner.auth,
json: { body: "shh" },
});
await post(`/api/dumps/${pub.data.id}/comments`, {
auth: owner.auth,
json: { body: "hi" },
});
const created = await x.next("comment_created");
assertEquals(created.comment.body, "hi");
await x.sync();
assertEquals(x.all("comment_created").length, 1);
// A private playlist's events go to its owner alone.
const pl = insertPlaylist({ userId: owner.id, isPublic: false });
await post(`/api/playlists/${pl.id}/dumps/${priv.id}`, { auth: owner.auth });
await o.next("playlist_dumps_updated");
await x.sync();
assertEquals(x.all("playlist_dumps_updated"), []);
});
wsTest("a password change closes the user's sockets", async (connect) => {
const user = await createUser();
const c = await connect(user);
await new Promise((r) => setTimeout(r, 1100)); // see auth.test.ts
const res = await post<AuthTokens>("/api/users/me/change-password", {
auth: user.auth,
json: { currentPassword: PASSWORD, newPassword: "a-new-password" },
});
assertEquals(res.status, 200);
const closed = await c.closed;
assertEquals(closed.reason, "Password changed");
// Reconnecting with the old token is anonymous now.
const again = await connect(user);
assertEquals(again.all("welcome")[0].users, []);
assertExists(res.data.token);
assertFalse(res.data.token === user.token);
});
wsTest(
"playlist_dumps_updated lists only what each viewer can see",
async (connect) => {
const owner = await createUser();
const author = await createUser();
const other = await createUser();
const pl = insertPlaylist({ userId: owner.id });
const pub = insertDump({ userId: owner.id });
const mine = insertDump({ userId: owner.id, isPrivate: true });
const theirs = insertDump({ userId: author.id, isPrivate: true });
// Rows straight in: the dumps are private now, as if they went private after
// being added.
for (const [i, d] of [pub, mine, theirs].entries()) {
db.prepare(
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at)
VALUES (?, ?, ?, ?);`,
).run(pl.id, d.id, i, new Date().toISOString());
}
const o = await connect(owner);
const a = await connect(author);
const x = await connect(other);
const anon = await connect();
const added = insertDump({ userId: owner.id });
await post(`/api/playlists/${pl.id}/dumps/${added.id}`, {
auth: owner.auth,
});
// The owner sees their own private dump but not the author's; the author,
// not being the owner, sees neither private one; nor does anyone else.
assertEquals((await o.next("playlist_dumps_updated")).dumpIds, [
added.id,
pub.id,
mine.id,
]);
for (const c of [a, x, anon]) {
const msg = await c.next("playlist_dumps_updated");
assertEquals(msg.dumpIds, [added.id, pub.id]);
// The count is the playlist's, as its dumpCount gives it to everyone.
assertEquals(msg.dumpCount, 4);
}
},
);
wsTest(
"a new playlist cover is on disk by the time it's announced",
async (connect) => {
const owner = await createUser();
const pl = insertPlaylist({ userId: owner.id });
const c = await connect();
const cover = new Uint8Array([...PNG, 9, 9, 9]);
const upload = post(`/api/playlists/${pl.id}/image`, {
auth: owner.auth,
form: formWithFile(cover),
});
// Fetch the image the moment the socket says it changed, as a client would.
const msg = await c.next("playlist_updated");
const served = await get(
`/api/playlists/${pl.id}/image?v=${msg.playlist.updatedAt}`,
);
assertEquals(served.status, 200);
assertEquals(served.bytes, cover);
assertEquals((await upload).status, 200);
},
);
wsTest(
"replacing a dump's file is broadcast like any edit",
async (connect) => {
const owner = await createUser();
const other = await createUser();
const { id } = insertDump({ userId: owner.id, fileName: "a.txt" });
const x = await connect(other);
const replace = (fields: Record<string, string>) =>
put(`/api/dumps/${id}/file`, {
auth: owner.auth,
form: formWithFile(PNG, "b.png", "image/png", fields),
});
await replace({ title: "New title" });
assertEquals((await x.next("dump_updated")).dump.title, "New title");
await replace({ isPrivate: "true" });
assertEquals((await x.next("dump_deleted")).dumpId, id);
await replace({ isPrivate: "false" });
assertEquals((await x.next("dump_created")).dump.id, 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"));
});