806 lines
27 KiB
TypeScript
806 lines
27 KiB
TypeScript
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 } 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");
|
|
assertEquals(dump.slug, "scraped-title");
|
|
assertEquals(dump.userId, user.id);
|
|
assertEquals(dump.ownerUsername, user.username);
|
|
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/dumps/${dump.id}/file`);
|
|
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 only", async () => {
|
|
const user = await createUser();
|
|
const { id, slug } = insertDump({ userId: user.id, title: "Hello" });
|
|
assertEquals((await get<Dump>(`/api/dumps/${id}`)).data.id, id);
|
|
// A slug is only unique under its owner: it is read by page path.
|
|
assertEquals((await get(`/api/dumps/${slug}`)).status, 404);
|
|
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?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?url=${q}`);
|
|
assertEquals(anon.data.map((d) => d.title), [
|
|
"public",
|
|
]);
|
|
assertEquals(anon.data[0].ownerUsername, owner.username);
|
|
const mine = await get<DumpUrlMatch[]>(`/api/dumps?url=${q}`, {
|
|
auth: owner.auth,
|
|
});
|
|
assertEquals(mine.data.length, 2);
|
|
assertEquals(
|
|
(await get<DumpUrlMatch[]>(`/api/dumps?url=${q}`, {
|
|
auth: other.auth,
|
|
})).data
|
|
.length,
|
|
1,
|
|
);
|
|
assertEquals(
|
|
(await get<DumpUrlMatch[]>(`/api/dumps?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 another shape of 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://www.x.test/t/?utm_source=feed and http://anywhere${secret.path}#comment-x and http://gerbeur.test/@${other.username}/dumps/scraped-title`,
|
|
},
|
|
});
|
|
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: a slug changes when sent, the old one stays an alias, 3 a day", async () => {
|
|
const owner = await createUser();
|
|
const mod = await createUser({ role: "moderator" });
|
|
const { id } = insertDump({ userId: owner.id, title: "First" });
|
|
const other = insertDump({ userId: owner.id, title: "Taken" });
|
|
const byPath = (slug: string) =>
|
|
get<Dump>(`/api/users/@${owner.username}/dumps/${slug}`, {
|
|
auth: owner.auth,
|
|
});
|
|
const setSlug = (slug: string, auth = owner.auth) =>
|
|
put<Dump>(`/api/dumps/${id}`, { auth, json: { slug } });
|
|
|
|
assertEquals((await setSlug("Not A Slug")).status, 400);
|
|
// Another dump's slug, current or former, is refused.
|
|
assertEquals((await setSlug(other.slug)).status, 409);
|
|
assertEquals((await setSlug("second")).data.slug, "second");
|
|
// The old slug answers the dump, which carries its current one.
|
|
assertEquals((await byPath("first")).data.slug, "second");
|
|
assertEquals((await byPath("second")).data.id, id);
|
|
// Taking back its own former slug swaps them; the limit counts the aliases
|
|
// left, so a swap doesn't add to it.
|
|
assertEquals((await setSlug("first")).data.slug, "first");
|
|
assertEquals((await byPath("second")).data.slug, "first");
|
|
assertEquals((await setSlug("third")).data.slug, "third");
|
|
assertEquals((await setSlug("fourth")).data.slug, "fourth");
|
|
// A fourth alias in a day is refused, but not to a moderator.
|
|
assertEquals((await setSlug("fifth")).status, 429);
|
|
assertEquals((await setSlug("fifth", mod.auth)).data.slug, "fifth");
|
|
// Every former slug still leads to it, without chaining.
|
|
for (const slug of ["first", "second", "third", "fourth", "fifth"]) {
|
|
assertEquals((await byPath(slug)).data.slug, "fifth", slug);
|
|
}
|
|
// Deleting the dump frees its aliases.
|
|
await del(`/api/dumps/${id}`, { auth: owner.auth });
|
|
assertEquals(
|
|
count(`SELECT COUNT(*) AS n FROM dump_slug_aliases WHERE dump_id = ?;`, id),
|
|
0,
|
|
);
|
|
const reborn = insertDump({ userId: owner.id, title: "Second" });
|
|
assertEquals(reborn.slug, "second");
|
|
});
|
|
|
|
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/${id}`, {
|
|
auth: owner.auth,
|
|
json: { title: "New title", comment: "hello" },
|
|
});
|
|
assertEquals(res.status, 200);
|
|
assertEquals(res.data.title, "New title");
|
|
// A new title never changes the slug by itself.
|
|
assertEquals(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 400, 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,
|
|
400,
|
|
);
|
|
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/dumps/${id}/file`)).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/dumps/${id}/thumbnail`);
|
|
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/dumps/${id}/thumbnail`)).status, 404);
|
|
});
|
|
|
|
test("POST /api/dumps/:id/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}/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}/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}/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,
|
|
);
|
|
});
|