v3: the backend gets a test suite, and the bugs it turned up are fixed — deno task test runs 186 tests over api/ in about 18s, against a throwaway database and uploads directory (GERBEUR_DB_PATH and GERBEUR_UPLOADS_DIR now override the defaults; api/test/setup.ts sets them before any app module loads), with requests sent through the whole middleware stack in-process via app.handle, outbound fetches and DNS stubbed, and the socket tests on a real Oak listener since an upgraded request has no response for handle() to return. It covers auth (token purposes, revocation after a password change, live roles, login rate limiting, invites claimed once even under parallel registrations, password resets), dumps end to end (create, privacy, by-url matching on the canonical key, backlinks, categories, edits, file replacement, thumbnails, refresh, delete), file serving headers and ranges, attachments, avatars, comments and likes, playlists, follows and feeds, notifications and mentions, categories, search, chat cursors and read markers, link previews and the SSRF guard on every redirect hop, the Bandcamp and YouTube resolvers, migrations (a fresh schema is left untouched; the data rewrites in 0004/0005 and 0010–0013 do what they say), CORS, OG tags, static caching, and the WebSocket protocol. Fixed along the way: an unknown category id on a new dump returned 400 but kept the dump row, and for a file dump deleted the file while keeping the row; an edit or file replacement with one was half-applied before the 400 — categories are now checked before anything is written. A suffix range (bytes=-3) was read as an end offset and got a 416 instead of the last three bytes. Reordering a playlist accepted a list naming a member twice. verifyPassword threw on a stored hash without a salt instead of answering false. And a request with no Accept-Encoding, which RFC 9110 lets a server treat as accepting anything, was sent the brotli sibling; curl and bots now get the plain file.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,10 +28,12 @@ export const PUBLIC_URL =
|
||||
// frontend on a separate host/port (e.g. Vite dev server or a CDN origin).
|
||||
export const FRONTEND_URL = Deno.env.get("GERBEUR_FRONTEND_URL")?.trim() ||
|
||||
PUBLIC_URL;
|
||||
export const DB_PATH = "api/sql/gerbeur.db";
|
||||
// Overridable so the test suite can run against a throwaway database and
|
||||
// uploads directory (see api/test/setup.ts).
|
||||
export const DB_PATH = Deno.env.get("GERBEUR_DB_PATH") || "api/sql/gerbeur.db";
|
||||
|
||||
// Upload/files
|
||||
export const UPLOADS_DIR = "api/uploads";
|
||||
export const UPLOADS_DIR = Deno.env.get("GERBEUR_UPLOADS_DIR") || "api/uploads";
|
||||
export const DUMPS_DIR = `${UPLOADS_DIR}/dumps`;
|
||||
export const AVATARS_DIR = `${UPLOADS_DIR}/avatars`;
|
||||
export const PLAYLIST_IMAGES_DIR = `${UPLOADS_DIR}/playlist-images`;
|
||||
|
||||
@@ -113,6 +113,9 @@ export function verifyPassword(
|
||||
storedHash: string,
|
||||
): Promise<boolean> {
|
||||
const [hash, salt] = storedHash.split(".");
|
||||
// A malformed stored hash can't match anything; scrypt would throw on the
|
||||
// missing salt instead of answering.
|
||||
if (!hash || !salt) return Promise.resolve(false);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
scrypt(password, salt, 64, (err, derivedKey) => {
|
||||
|
||||
@@ -39,11 +39,16 @@ export function routeStaticFilesFrom(staticPaths: string[]) {
|
||||
const immutable = IMMUTABLE_PREFIXES.some((prefix) =>
|
||||
pathname.startsWith(prefix)
|
||||
);
|
||||
// A request without Accept-Encoding technically accepts any coding (RFC
|
||||
// 9110), but in practice it's curl or a bot that can't decode brotli.
|
||||
const compress = context.request.headers.has("accept-encoding");
|
||||
for (const path of staticPaths) {
|
||||
try {
|
||||
// send() picks a prebuilt .br or .gz sibling when the browser accepts it.
|
||||
await send(context, pathname, {
|
||||
root: path,
|
||||
brotli: compress,
|
||||
gzip: compress,
|
||||
...(immutable && { maxage: ONE_YEAR_MS, immutable: true }),
|
||||
});
|
||||
context.response.headers.append("Vary", "Accept-Encoding");
|
||||
|
||||
@@ -105,12 +105,19 @@ router.get("/:dumpId", async (ctx) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = match[1]
|
||||
? parseInt(match[1], 10)
|
||||
: total - parseInt(match[2], 10);
|
||||
const end = match[2]
|
||||
? Math.min(parseInt(match[2], 10), total - 1)
|
||||
: total - 1;
|
||||
// "bytes=a-b", "bytes=a-" (to the end) or "bytes=-n" (the last n bytes).
|
||||
let start: number;
|
||||
let end: number;
|
||||
if (match[1]) {
|
||||
start = parseInt(match[1], 10);
|
||||
end = match[2] ? Math.min(parseInt(match[2], 10), total - 1) : total - 1;
|
||||
} else {
|
||||
const suffix = parseInt(match[2], 10);
|
||||
start = Math.max(total - suffix, 0);
|
||||
end = total - 1;
|
||||
// "bytes=-" and "bytes=-0" name no bytes at all.
|
||||
if (!(suffix > 0)) start = total;
|
||||
}
|
||||
|
||||
if (start > end || start >= total) {
|
||||
ctx.response.status = 416;
|
||||
|
||||
@@ -182,13 +182,11 @@ export function deleteCategory(id: string): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the set of categories a dump belongs to. Unknown category ids are
|
||||
* rejected. An empty list clears all category assignments.
|
||||
* Throws a 400 unless every id names an existing category. Callers that write
|
||||
* anything else first (a dump row, an uploaded file) run this before those
|
||||
* writes, so a bad id can't leave them half-done.
|
||||
*/
|
||||
export function setDumpCategories(
|
||||
dumpId: string,
|
||||
categoryIds: string[],
|
||||
): void {
|
||||
export function assertCategoriesExist(categoryIds: string[]): void {
|
||||
const unique = [...new Set(categoryIds)];
|
||||
|
||||
if (unique.length > 0) {
|
||||
@@ -204,6 +202,18 @@ export function setDumpCategories(
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the set of categories a dump belongs to. Unknown category ids are
|
||||
* rejected. An empty list clears all category assignments.
|
||||
*/
|
||||
export function setDumpCategories(
|
||||
dumpId: string,
|
||||
categoryIds: string[],
|
||||
): void {
|
||||
assertCategoriesExist(categoryIds);
|
||||
const unique = [...new Set(categoryIds)];
|
||||
|
||||
db.prepare(`DELETE FROM dump_categories WHERE dump_id = ?;`).run(dumpId);
|
||||
const insert = db.prepare(
|
||||
|
||||
@@ -40,7 +40,11 @@ import {
|
||||
} from "../config.ts";
|
||||
import { linkAttachments } from "./attachment-service.ts";
|
||||
import { relinkBacklinks } from "./backlink-service.ts";
|
||||
import { attachCategoryIds, setDumpCategories } from "./category-service.ts";
|
||||
import {
|
||||
assertCategoriesExist,
|
||||
attachCategoryIds,
|
||||
setDumpCategories,
|
||||
} from "./category-service.ts";
|
||||
|
||||
function isAllowedMime(mime: string): boolean {
|
||||
return DUMP_ALLOWED_MIME_PREFIXES.some((p) => mime.startsWith(p)) ||
|
||||
@@ -81,6 +85,7 @@ export async function createUrlDump(
|
||||
if (!isValidHttpUrl(request.url)) {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid URL");
|
||||
}
|
||||
assertCategoriesExist(request.categoryIds ?? []);
|
||||
|
||||
const dumpId = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
@@ -162,6 +167,7 @@ export async function createFileDump(
|
||||
"File too large (max 50 MB)",
|
||||
);
|
||||
}
|
||||
assertCategoriesExist(categoryIds);
|
||||
|
||||
const dumpId = crypto.randomUUID();
|
||||
const createdAt = new Date();
|
||||
@@ -374,6 +380,9 @@ export async function updateDump(
|
||||
request: UpdateDumpRequest,
|
||||
): Promise<Dump> {
|
||||
const dump = fetchDump(dumpId);
|
||||
if (request.categoryIds !== undefined) {
|
||||
assertCategoriesExist(request.categoryIds);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -534,6 +543,7 @@ export async function replaceFileDump(
|
||||
if (dump.kind !== "file") {
|
||||
throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Not a file dump");
|
||||
}
|
||||
if (categoryIds !== undefined) assertCategoriesExist(categoryIds);
|
||||
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const filePath = `${DUMPS_DIR}/${dumpId}`;
|
||||
|
||||
@@ -342,6 +342,7 @@ export function reorderPlaylist(
|
||||
const newSet = new Set(dumpIds);
|
||||
|
||||
if (
|
||||
dumpIds.length !== newSet.size ||
|
||||
currentSet.size !== newSet.size ||
|
||||
!currentIds.every((id) => newSet.has(id))
|
||||
) {
|
||||
|
||||
458
api/test/auth.test.ts
Normal file
458
api/test/auth.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
// Tokens, passwords and the checks that turn a token into a user.
|
||||
import { count, createUser, db, get, PASSWORD, post, test } from "./helpers.ts";
|
||||
import {
|
||||
assert,
|
||||
assertEquals,
|
||||
assertFalse,
|
||||
assertNotEquals,
|
||||
} from "@std/assert";
|
||||
import { SignJWT } from "@panva/jose";
|
||||
import {
|
||||
createJWT,
|
||||
createMediaToken,
|
||||
createPasswordResetToken,
|
||||
hashPassword,
|
||||
verifyJWT,
|
||||
verifyMediaToken,
|
||||
verifyPassword,
|
||||
verifyPasswordResetToken,
|
||||
} from "../lib/jwt.ts";
|
||||
import { authenticate, authenticateMedia } from "../lib/auth.ts";
|
||||
|
||||
const KEY = new TextEncoder().encode("test-secret-test-secret-test-secret");
|
||||
|
||||
Deno.test("hashPassword/verifyPassword round-trip with a random salt", async () => {
|
||||
const a = await hashPassword("hunter22");
|
||||
const b = await hashPassword("hunter22");
|
||||
assertNotEquals(a, b);
|
||||
assert(await verifyPassword("hunter22", a));
|
||||
assertFalse(await verifyPassword("hunter23", a));
|
||||
// A malformed stored hash never verifies.
|
||||
assertFalse(await verifyPassword("hunter22", "not-a-hash"));
|
||||
assertFalse(await verifyPassword("hunter22", ""));
|
||||
});
|
||||
|
||||
Deno.test("session, media and reset tokens don't stand in for each other", async () => {
|
||||
const userId = crypto.randomUUID();
|
||||
const session = await createJWT({ userId, username: "u", role: "user" });
|
||||
const media = await createMediaToken(userId);
|
||||
const reset = await createPasswordResetToken(userId);
|
||||
|
||||
assertEquals((await verifyJWT(session))?.userId, userId);
|
||||
assertEquals(await verifyJWT(media), null);
|
||||
assertEquals(await verifyJWT(reset), null);
|
||||
|
||||
assertEquals((await verifyMediaToken(media))?.userId, userId);
|
||||
assertEquals(await verifyMediaToken(session), null);
|
||||
assertEquals(await verifyMediaToken(reset), null);
|
||||
|
||||
assertEquals((await verifyPasswordResetToken(reset))?.userId, userId);
|
||||
assertEquals(await verifyPasswordResetToken(session), null);
|
||||
assertEquals(await verifyPasswordResetToken(media), null);
|
||||
});
|
||||
|
||||
Deno.test("verifyJWT: refuses a bad signature, an expired token, garbage", async () => {
|
||||
const forged = await new SignJWT({
|
||||
userId: "x",
|
||||
username: "x",
|
||||
role: "admin",
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("1h")
|
||||
.sign(new TextEncoder().encode("some-other-secret"));
|
||||
assertEquals(await verifyJWT(forged), null);
|
||||
|
||||
const expired = await new SignJWT({
|
||||
userId: "x",
|
||||
username: "x",
|
||||
role: "user",
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime(Math.floor(Date.now() / 1000) - 10)
|
||||
.sign(KEY);
|
||||
assertEquals(await verifyJWT(expired), null);
|
||||
|
||||
const badRole = await new SignJWT({ userId: "x", username: "x", role: "god" })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("1h")
|
||||
.sign(KEY);
|
||||
assertEquals(await verifyJWT(badRole), null);
|
||||
|
||||
assertEquals(await verifyJWT("not.a.jwt"), null);
|
||||
});
|
||||
|
||||
test("authenticate: the role comes from the database, not the token", async () => {
|
||||
const user = await createUser({ role: "user" });
|
||||
db.prepare(`UPDATE users SET role = 'admin' WHERE id = ?;`).run(user.id);
|
||||
assertEquals((await authenticate(user.token))?.role, "admin");
|
||||
});
|
||||
|
||||
test("authenticate: a deleted user's token is refused", async () => {
|
||||
const user = await createUser();
|
||||
db.prepare(`DELETE FROM users WHERE id = ?;`).run(user.id);
|
||||
assertEquals(await authenticate(user.token), null);
|
||||
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
|
||||
});
|
||||
|
||||
test("authenticate: tokens issued before tokens_valid_after are revoked", async () => {
|
||||
const user = await createUser();
|
||||
const future = new Date(Date.now() + 60_000).toISOString();
|
||||
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
|
||||
future,
|
||||
user.id,
|
||||
);
|
||||
assertEquals(await authenticate(user.token), null);
|
||||
assertEquals(await authenticateMedia(user.mediaToken), null);
|
||||
// A token issued at the cutoff second or later passes.
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
db.prepare(`UPDATE users SET tokens_valid_after = ? WHERE id = ?;`).run(
|
||||
past,
|
||||
user.id,
|
||||
);
|
||||
assertEquals((await authenticate(user.token))?.userId, user.id);
|
||||
});
|
||||
|
||||
test("authenticateMedia: takes a media token or a legacy session token", async () => {
|
||||
const user = await createUser();
|
||||
assertEquals(await authenticateMedia(user.mediaToken), user.id);
|
||||
assertEquals(await authenticateMedia(user.token), user.id);
|
||||
assertEquals(await authenticateMedia("garbage"), null);
|
||||
});
|
||||
|
||||
test("optional auth: a missing or placeholder token is anonymous, a bad one is 401", async () => {
|
||||
const owner = await createUser();
|
||||
// A public listing that reads optional auth.
|
||||
const path = `/api/users/${owner.username}/dumps`;
|
||||
assertEquals((await get(path)).status, 200);
|
||||
for (const placeholder of ["Bearer undefined", "Bearer null", "Bearer "]) {
|
||||
assertEquals(
|
||||
(await get(path, { headers: { Authorization: placeholder } })).status,
|
||||
200,
|
||||
placeholder,
|
||||
);
|
||||
}
|
||||
assertEquals(
|
||||
(await get(path, { headers: { Authorization: "Basic abc" } })).status,
|
||||
200,
|
||||
);
|
||||
const bad = await get(path, { headers: { Authorization: "Bearer nope" } });
|
||||
assertEquals(bad.status, 401);
|
||||
assertEquals(bad.body.error.code, "UNAUTHORIZED");
|
||||
});
|
||||
|
||||
test("required auth: missing, malformed and forged tokens are 401", async () => {
|
||||
assertEquals((await get("/api/users/me")).status, 401);
|
||||
assertEquals(
|
||||
(await get("/api/users/me", { headers: { Authorization: "Token x" } }))
|
||||
.status,
|
||||
401,
|
||||
);
|
||||
const media = (await createUser()).mediaToken;
|
||||
assertEquals(
|
||||
(await get("/api/users/me", {
|
||||
headers: { Authorization: `Bearer ${media}` },
|
||||
})).status,
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Login and register ───────────────────────────────────────────────────────
|
||||
|
||||
test("POST /api/users/login: right password logs in, wrong one doesn't say which", async () => {
|
||||
const user = await createUser();
|
||||
const ok = await post("/api/users/login", {
|
||||
json: { username: user.username, password: PASSWORD },
|
||||
});
|
||||
assertEquals(ok.status, 200);
|
||||
assertEquals(ok.body.data.user.id, user.id);
|
||||
assertEquals(ok.body.data.user.passwordHash, undefined);
|
||||
assertEquals((await verifyJWT(ok.body.data.token))?.userId, user.id);
|
||||
assertEquals(
|
||||
(await verifyMediaToken(ok.body.data.mediaToken))?.userId,
|
||||
user.id,
|
||||
);
|
||||
|
||||
const wrong = await post("/api/users/login", {
|
||||
json: { username: user.username, password: "nope" },
|
||||
});
|
||||
const unknown = await post("/api/users/login", {
|
||||
json: { username: "no_such_user_here", password: "nope" },
|
||||
});
|
||||
assertEquals(wrong.status, 401);
|
||||
assertEquals(unknown.status, 401);
|
||||
assertEquals(wrong.body.error.message, unknown.body.error.message);
|
||||
|
||||
assertEquals(
|
||||
(await post("/api/users/login", { json: { username: user.username } }))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
test("POST /api/users/login: rate limited per account, case-insensitively", async () => {
|
||||
const user = await createUser();
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const username = i % 2 ? user.username.toUpperCase() : user.username;
|
||||
statuses.push(
|
||||
(await post("/api/users/login", {
|
||||
json: { username, password: "wrong" },
|
||||
})).status,
|
||||
);
|
||||
}
|
||||
assertEquals(statuses.slice(0, 10), Array(10).fill(401));
|
||||
assertEquals(statuses[10], 429);
|
||||
// Even the right password is held back now.
|
||||
assertEquals(
|
||||
(await post("/api/users/login", {
|
||||
json: { username: user.username, password: PASSWORD },
|
||||
})).status,
|
||||
429,
|
||||
);
|
||||
});
|
||||
|
||||
function invite(inviterId: string, createdAt = new Date()): string {
|
||||
const token = crypto.randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO invites (token, inviter_id, created_at) VALUES (?, ?, ?);`,
|
||||
).run(token, inviterId, createdAt.toISOString());
|
||||
return token;
|
||||
}
|
||||
|
||||
test("POST /api/users/register: an invite opens one account", async () => {
|
||||
const inviter = await createUser();
|
||||
const inviteToken = invite(inviter.id);
|
||||
const res = await post("/api/users/register", {
|
||||
json: {
|
||||
username: "newbie_reg",
|
||||
password: "longenough",
|
||||
email: "newbie@example.test",
|
||||
inviteToken,
|
||||
},
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
assertEquals(res.body.data.user.username, "newbie_reg");
|
||||
assertEquals(res.body.data.user.role, "user");
|
||||
assertEquals(res.body.data.user.passwordHash, undefined);
|
||||
assert(res.body.data.token);
|
||||
const row = db.prepare(
|
||||
`SELECT invited_by FROM users WHERE username = 'newbie_reg';`,
|
||||
).get() as { invited_by: string };
|
||||
assertEquals(row.invited_by, inviter.id);
|
||||
|
||||
const again = await post("/api/users/register", {
|
||||
json: {
|
||||
username: "second_reg",
|
||||
password: "longenough",
|
||||
email: "second@example.test",
|
||||
inviteToken,
|
||||
},
|
||||
});
|
||||
assertEquals(again.status, 409);
|
||||
});
|
||||
|
||||
test("POST /api/users/register: validation and unknown invites", async () => {
|
||||
const inviter = await createUser();
|
||||
const inviteToken = invite(inviter.id);
|
||||
const base = {
|
||||
username: "valid_name",
|
||||
password: "longenough",
|
||||
email: "v@example.test",
|
||||
inviteToken,
|
||||
};
|
||||
const cases: [Record<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.body.error.message), res.body.error.message);
|
||||
}
|
||||
const unknown = await post("/api/users/register", {
|
||||
json: { ...base, inviteToken: "made-up" },
|
||||
});
|
||||
assertEquals(unknown.status, 404);
|
||||
// None of the failures used the invite up.
|
||||
assertEquals(
|
||||
count(`SELECT COUNT(*) as n FROM invites WHERE used_at IS NULL;`),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("POST /api/users/register: a taken name or email hands the invite back", async () => {
|
||||
const inviter = await createUser();
|
||||
const taken = await createUser();
|
||||
const inviteToken = invite(inviter.id);
|
||||
const byName = await post("/api/users/register", {
|
||||
json: {
|
||||
username: taken.username,
|
||||
password: "longenough",
|
||||
email: "fresh@example.test",
|
||||
inviteToken,
|
||||
},
|
||||
});
|
||||
assertEquals(byName.status, 400);
|
||||
const byEmail = await post("/api/users/register", {
|
||||
json: {
|
||||
username: "fresh_name",
|
||||
password: "longenough",
|
||||
email: taken.email.toUpperCase(),
|
||||
inviteToken,
|
||||
},
|
||||
});
|
||||
assertEquals(byEmail.status, 409);
|
||||
assertEquals(
|
||||
count(
|
||||
`SELECT COUNT(*) as n FROM invites WHERE token = ? AND used_at IS NULL;`,
|
||||
inviteToken,
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("POST /api/users/register: an invite older than 7 days is refused", async () => {
|
||||
const inviter = await createUser();
|
||||
const inviteToken = invite(
|
||||
inviter.id,
|
||||
new Date(Date.now() - 8 * 24 * 60 * 60 * 1000),
|
||||
);
|
||||
const res = await post("/api/users/register", {
|
||||
json: {
|
||||
username: "late_comer",
|
||||
password: "longenough",
|
||||
email: "late@example.test",
|
||||
inviteToken,
|
||||
},
|
||||
});
|
||||
assertEquals(res.status, 404);
|
||||
});
|
||||
|
||||
test("POST /api/users/register: parallel registrations can't share an invite", async () => {
|
||||
const inviter = await createUser();
|
||||
const inviteToken = invite(inviter.id);
|
||||
const results = await Promise.all(
|
||||
[1, 2, 3].map((i) =>
|
||||
post("/api/users/register", {
|
||||
json: {
|
||||
username: `racer_${i}`,
|
||||
password: "longenough",
|
||||
email: `racer${i}@example.test`,
|
||||
inviteToken,
|
||||
},
|
||||
})
|
||||
),
|
||||
);
|
||||
assertEquals(results.filter((r) => r.status === 201).length, 1);
|
||||
assertEquals(
|
||||
count(`SELECT COUNT(*) as n FROM users WHERE invited_by = ?;`, inviter.id),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Password changes ─────────────────────────────────────────────────────────
|
||||
|
||||
test("change-password: needs the current one, revokes old tokens, returns new ones", async () => {
|
||||
const user = await createUser();
|
||||
const wrong = await post("/api/users/me/change-password", {
|
||||
auth: user.auth,
|
||||
json: { currentPassword: "nope", newPassword: "brand-new-pass" },
|
||||
});
|
||||
assertEquals(wrong.status, 401);
|
||||
const short = await post("/api/users/me/change-password", {
|
||||
auth: user.auth,
|
||||
json: { currentPassword: PASSWORD, newPassword: "short" },
|
||||
});
|
||||
assertEquals(short.status, 400);
|
||||
|
||||
// iat has one-second resolution: make sure the old token is from an earlier
|
||||
// second than the change.
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
const ok = await post("/api/users/me/change-password", {
|
||||
auth: user.auth,
|
||||
json: { currentPassword: PASSWORD, newPassword: "brand-new-pass" },
|
||||
});
|
||||
assertEquals(ok.status, 200);
|
||||
assertEquals((await get("/api/users/me", { auth: user.auth })).status, 401);
|
||||
assertEquals(await authenticateMedia(user.mediaToken), null);
|
||||
const fresh = { Authorization: `Bearer ${ok.body.data.token}` };
|
||||
assertEquals((await get("/api/users/me", { auth: fresh })).status, 200);
|
||||
assertEquals(await authenticateMedia(ok.body.data.mediaToken), user.id);
|
||||
|
||||
const login = await post("/api/users/login", {
|
||||
json: { username: user.username, password: "brand-new-pass" },
|
||||
});
|
||||
assertEquals(login.status, 200);
|
||||
});
|
||||
|
||||
test("request-password-reset: always 200, stores nothing without SMTP", async () => {
|
||||
const user = await createUser();
|
||||
for (const email of [user.email, "nobody@example.test", "", undefined]) {
|
||||
const res = await post("/api/users/request-password-reset", {
|
||||
json: { email },
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
}
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM password_reset_tokens;`), 0);
|
||||
});
|
||||
|
||||
test("reset-password: a stored token works once", async () => {
|
||||
const user = await createUser();
|
||||
const token = await createPasswordResetToken(user.id);
|
||||
db.prepare(
|
||||
`INSERT INTO password_reset_tokens (token, user_id, expires_at) VALUES (?, ?, ?);`,
|
||||
).run(token, user.id, new Date(Date.now() + 3600_000).toISOString());
|
||||
|
||||
const short = await post("/api/users/reset-password", {
|
||||
json: { token, newPassword: "short" },
|
||||
});
|
||||
assertEquals(short.status, 400);
|
||||
|
||||
const ok = await post("/api/users/reset-password", {
|
||||
json: { token, newPassword: "reset-password-1" },
|
||||
});
|
||||
assertEquals(ok.status, 200);
|
||||
const login = await post("/api/users/login", {
|
||||
json: { username: user.username, password: "reset-password-1" },
|
||||
});
|
||||
assertEquals(login.status, 200);
|
||||
|
||||
const reuse = await post("/api/users/reset-password", {
|
||||
json: { token, newPassword: "reset-password-2" },
|
||||
});
|
||||
assertEquals(reuse.status, 409);
|
||||
});
|
||||
|
||||
test("reset-password: an unstored, forged or wrong-purpose token fails", async () => {
|
||||
const user = await createUser();
|
||||
const unstored = await createPasswordResetToken(user.id);
|
||||
assertEquals(
|
||||
(await post("/api/users/reset-password", {
|
||||
json: { token: unstored, newPassword: "whatever-123" },
|
||||
})).status,
|
||||
404,
|
||||
);
|
||||
for (const token of ["garbage", user.token, user.mediaToken]) {
|
||||
assertEquals(
|
||||
(await post("/api/users/reset-password", {
|
||||
json: { token, newPassword: "whatever-123" },
|
||||
})).status,
|
||||
400,
|
||||
);
|
||||
}
|
||||
assertEquals(
|
||||
(await post("/api/users/reset-password", { json: { token: 1 } })).status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/users/me/media-token issues a media token for the caller", async () => {
|
||||
const user = await createUser();
|
||||
const res = await get("/api/users/me/media-token", { auth: user.auth });
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(await authenticateMedia(res.body.data.mediaToken), user.id);
|
||||
});
|
||||
104
api/test/canonical-url.test.ts
Normal file
104
api/test/canonical-url.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import "./setup.ts";
|
||||
import { assertEquals } from "@std/assert";
|
||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||
import { up as up0013 } from "../db/migrations/0013_dump_url_canonical.ts";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
Deno.test("canonicalizeUrl: scheme, www and trailing slash collapse", () => {
|
||||
const want = "https://example.com/a";
|
||||
for (
|
||||
const url of [
|
||||
"http://example.com/a",
|
||||
"https://www.example.com/a",
|
||||
"https://example.com/a/",
|
||||
"https://EXAMPLE.com/a//",
|
||||
]
|
||||
) {
|
||||
assertEquals(canonicalizeUrl(url), want, url);
|
||||
}
|
||||
assertEquals(canonicalizeUrl("https://example.com/"), "https://example.com");
|
||||
});
|
||||
|
||||
Deno.test("canonicalizeUrl: tracking parameters dropped, the rest sorted", () => {
|
||||
assertEquals(
|
||||
canonicalizeUrl(
|
||||
"https://example.com/p?utm_source=x&b=2&fbclid=y&a=1&UTM_Medium=z&si=q",
|
||||
),
|
||||
"https://example.com/p?a=1&b=2",
|
||||
);
|
||||
assertEquals(
|
||||
canonicalizeUrl("https://example.com/p?b=1&a=2"),
|
||||
canonicalizeUrl("https://example.com/p?a=2&b=1"),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("canonicalizeUrl: section anchors dropped, hash routes kept", () => {
|
||||
assertEquals(
|
||||
canonicalizeUrl("https://example.com/doc#intro"),
|
||||
"https://example.com/doc",
|
||||
);
|
||||
assertEquals(
|
||||
canonicalizeUrl("https://app.example.com/#/inbox"),
|
||||
"https://app.example.com#/inbox",
|
||||
);
|
||||
assertEquals(
|
||||
canonicalizeUrl("https://app.example.com/#!/inbox"),
|
||||
"https://app.example.com#!/inbox",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("canonicalizeUrl: every YouTube video shape is one key", () => {
|
||||
const want = "https://youtube.com/watch?v=dQw4w9WgXcQ";
|
||||
for (
|
||||
const url of [
|
||||
"https://youtu.be/dQw4w9WgXcQ",
|
||||
"https://youtu.be/dQw4w9WgXcQ?t=42",
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL1&t=1m",
|
||||
"https://m.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://music.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ",
|
||||
"https://youtube.com/shorts/dQw4w9WgXcQ",
|
||||
"https://youtube.com/live/dQw4w9WgXcQ",
|
||||
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ",
|
||||
]
|
||||
) {
|
||||
assertEquals(canonicalizeUrl(url), want, url);
|
||||
}
|
||||
assertEquals(
|
||||
canonicalizeUrl("https://www.youtube.com/playlist?list=PLx&si=abc"),
|
||||
"https://youtube.com/playlist?list=PLx",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("canonicalizeUrl: non-http and unparseable input", () => {
|
||||
assertEquals(canonicalizeUrl("ftp://example.com/a"), null);
|
||||
assertEquals(canonicalizeUrl("javascript:alert(1)"), null);
|
||||
assertEquals(canonicalizeUrl("not a url"), null);
|
||||
assertEquals(canonicalizeUrl(""), null);
|
||||
});
|
||||
|
||||
// Migration 0013 carries a frozen copy of the canonicalizer to backfill old
|
||||
// rows. Until the rules change on purpose (with a new backfill migration), the
|
||||
// two must agree, or rows from before and after the migration won't match.
|
||||
Deno.test("migration 0013 backfills the same keys canonicalizeUrl computes", () => {
|
||||
const urls = [
|
||||
"http://www.example.com/a/?utm_source=x&b=2&a=1#top",
|
||||
"https://youtu.be/abc123?t=5",
|
||||
"https://www.youtube.com/playlist?list=PLx",
|
||||
"https://app.example.com/#/route",
|
||||
];
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec(`CREATE TABLE dumps (id TEXT PRIMARY KEY, url TEXT);`);
|
||||
urls.forEach((url, i) =>
|
||||
db.prepare(`INSERT INTO dumps (id, url) VALUES (?, ?);`).run(String(i), url)
|
||||
);
|
||||
up0013(db);
|
||||
up0013(db); // idempotent
|
||||
const rows = db.prepare(`SELECT id, url_canonical FROM dumps ORDER BY id;`)
|
||||
.all() as { id: string; url_canonical: string }[];
|
||||
assertEquals(
|
||||
rows.map((r) => r.url_canonical),
|
||||
urls.map(canonicalizeUrl),
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
186
api/test/categories-search.test.ts
Normal file
186
api/test/categories-search.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import {
|
||||
createUser,
|
||||
db,
|
||||
del,
|
||||
get,
|
||||
insertCategory,
|
||||
insertDump,
|
||||
insertPlaylist,
|
||||
post,
|
||||
put,
|
||||
test,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals, assertStringIncludes } from "@std/assert";
|
||||
|
||||
test("categories: listed by position, then slug; found by slug", async () => {
|
||||
insertCategory({ slug: "zeta", position: 0 });
|
||||
insertCategory({ slug: "alpha", position: 1 });
|
||||
insertCategory({ slug: "beta", position: 0 });
|
||||
const list = await get("/api/categories");
|
||||
assertEquals(list.body.data.map((c: { slug: string }) => c.slug), [
|
||||
"beta",
|
||||
"zeta",
|
||||
"alpha",
|
||||
]);
|
||||
assertEquals((await get("/api/categories/alpha")).body.data.slug, "alpha");
|
||||
assertEquals((await get("/api/categories/nope")).status, 404);
|
||||
});
|
||||
|
||||
test("categories: only category:manage may create, update or delete", async () => {
|
||||
const admin = await createUser({ role: "admin" });
|
||||
const mod = await createUser({ role: "moderator" });
|
||||
const json = { slug: "music", name: "Music" };
|
||||
assertEquals((await post("/api/categories", { json })).status, 401);
|
||||
assertEquals(
|
||||
(await post("/api/categories", { auth: mod.auth, json })).status,
|
||||
403,
|
||||
);
|
||||
const created = await post("/api/categories", { auth: admin.auth, json });
|
||||
assertEquals(created.status, 201);
|
||||
const id = created.body.data.id;
|
||||
assertEquals(
|
||||
(await put(`/api/categories/${id}`, {
|
||||
auth: mod.auth,
|
||||
json: { name: "X" },
|
||||
})).status,
|
||||
403,
|
||||
);
|
||||
assertEquals(
|
||||
(await del(`/api/categories/${id}`, { auth: mod.auth })).status,
|
||||
403,
|
||||
);
|
||||
assertEquals(
|
||||
(await del(`/api/categories/${id}`, { auth: admin.auth })).status,
|
||||
200,
|
||||
);
|
||||
assertEquals(
|
||||
(await del(`/api/categories/${id}`, { auth: admin.auth })).status,
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
test("categories: slug and name rules, and friendly conflicts", async () => {
|
||||
const admin = await createUser({ role: "admin" });
|
||||
const create = (json: unknown) =>
|
||||
post("/api/categories", { auth: admin.auth, json });
|
||||
for (
|
||||
const [json, message] of [
|
||||
[{ slug: "Bad Slug", name: "x" }, "lowercase"],
|
||||
[{ slug: "users", name: "x" }, "reserved"],
|
||||
[{ slug: "a".repeat(51), name: "x" }, "at most 50"],
|
||||
[{ slug: "ok", name: " " }, "Name is required"],
|
||||
[{ slug: "ok", name: "x".repeat(51) }, "at most 50"],
|
||||
] as const
|
||||
) {
|
||||
const res = await create(json);
|
||||
assertEquals(res.status, 400, JSON.stringify(json));
|
||||
assertStringIncludes(res.body.error.message, message);
|
||||
}
|
||||
assertEquals((await create({ slug: "ok" })).status, 400);
|
||||
|
||||
const first = await create({ slug: "music", name: " Music " });
|
||||
assertEquals(first.body.data.name, "Music");
|
||||
const dupSlug = await create({ slug: "music", name: "Other" });
|
||||
assertEquals(dupSlug.status, 409);
|
||||
assertStringIncludes(dupSlug.body.error.message, "Slug");
|
||||
const dupName = await create({ slug: "other", name: "Music" });
|
||||
assertEquals(dupName.status, 409);
|
||||
assertStringIncludes(dupName.body.error.message, "Name");
|
||||
|
||||
const second = await create({ slug: "films", name: "Films" });
|
||||
const rename = await put(`/api/categories/${second.body.data.id}`, {
|
||||
auth: admin.auth,
|
||||
json: { slug: "music" },
|
||||
});
|
||||
assertEquals(rename.status, 409);
|
||||
const ok = await put(`/api/categories/${second.body.data.id}`, {
|
||||
auth: admin.auth,
|
||||
json: { name: "Movies", position: 3 },
|
||||
});
|
||||
assertEquals(ok.body.data.slug, "films");
|
||||
assertEquals(ok.body.data.name, "Movies");
|
||||
assertEquals(ok.body.data.position, 3);
|
||||
});
|
||||
|
||||
test("GET /api/categories/:slug/dumps: members only, private for the owner", async () => {
|
||||
const owner = await createUser();
|
||||
const cat = insertCategory({ slug: "music" });
|
||||
const inCat = insertDump({ userId: owner.id, title: "in" });
|
||||
const priv = insertDump({
|
||||
userId: owner.id,
|
||||
title: "private",
|
||||
isPrivate: true,
|
||||
});
|
||||
insertDump({ userId: owner.id, title: "out" });
|
||||
for (const d of [inCat, priv]) {
|
||||
db.prepare(
|
||||
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
|
||||
)
|
||||
.run(d.id, cat);
|
||||
}
|
||||
const anon = await get("/api/categories/music/dumps");
|
||||
assertEquals(anon.body.data.items.map((d: { title: string }) => d.title), [
|
||||
"in",
|
||||
]);
|
||||
assertEquals(anon.body.data.items[0].categoryIds, [cat]);
|
||||
assertEquals(
|
||||
(await get("/api/categories/music/dumps", { auth: owner.auth })).body.data
|
||||
.total,
|
||||
2,
|
||||
);
|
||||
assertEquals((await get("/api/categories/nope/dumps")).status, 404);
|
||||
|
||||
// Deleting the category unlinks its dumps without deleting them.
|
||||
const admin = await createUser({ role: "admin" });
|
||||
await del(`/api/categories/${cat}`, { auth: admin.auth });
|
||||
assertEquals((await get(`/api/dumps/${inCat.id}`)).body.data.categoryIds, []);
|
||||
});
|
||||
|
||||
test("GET /api/search: dumps, users and playlists, privacy-filtered", async () => {
|
||||
const owner = await createUser({ username: "searcher_a" });
|
||||
const other = await createUser();
|
||||
db.prepare(
|
||||
`UPDATE users SET description = 'loves banana bread' WHERE id = ?;`,
|
||||
).run(other.id);
|
||||
insertDump({ userId: owner.id, title: "Banana split" });
|
||||
insertDump({
|
||||
userId: owner.id,
|
||||
title: "x",
|
||||
comment: "a banana in the comment",
|
||||
});
|
||||
insertDump({
|
||||
userId: owner.id,
|
||||
title: "y",
|
||||
richContent: { type: "generic", url: "u", description: "banana page" },
|
||||
});
|
||||
insertDump({ userId: owner.id, title: "Secret banana", isPrivate: true });
|
||||
insertDump({ userId: owner.id, title: "Apple" });
|
||||
insertPlaylist({ userId: owner.id, title: "Banana hits" });
|
||||
insertPlaylist({
|
||||
userId: owner.id,
|
||||
title: "Private bananas",
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
const anon = (await get("/api/search?q=banana")).body.data;
|
||||
assertEquals(anon.dumps.total, 3);
|
||||
assertEquals(anon.users.map((u: { id: string }) => u.id), [other.id]);
|
||||
assertEquals(anon.playlists.map((p: { title: string }) => p.title), [
|
||||
"Banana hits",
|
||||
]);
|
||||
|
||||
const mine =
|
||||
(await get("/api/search?q=banana", { auth: owner.auth })).body.data;
|
||||
assertEquals(mine.dumps.total, 4);
|
||||
assertEquals(mine.playlists.length, 2);
|
||||
|
||||
const paged = (await get("/api/search?q=banana&limit=2")).body.data.dumps;
|
||||
assertEquals(paged.items.length, 2);
|
||||
assertEquals(paged.hasMore, true);
|
||||
|
||||
assertEquals((await get("/api/search?q=%20%20")).body.data, {
|
||||
dumps: { items: [], total: 0, hasMore: false },
|
||||
users: [],
|
||||
playlists: [],
|
||||
});
|
||||
});
|
||||
183
api/test/chat.test.ts
Normal file
183
api/test/chat.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
// Chat history, cursors and read markers. The live side (send, edit, delete
|
||||
// over the socket) is covered in ws.test.ts.
|
||||
import { createUser, db, get, test } from "./helpers.ts";
|
||||
import { assertEquals, assertThrows } from "@std/assert";
|
||||
import {
|
||||
createChatMessage,
|
||||
deleteChatMessage,
|
||||
getChatMessagesAfter,
|
||||
getChatReadState,
|
||||
getRecentChatMessages,
|
||||
markChatRead,
|
||||
updateChatMessage,
|
||||
} from "../services/chat-service.ts";
|
||||
import { APIException } from "../model/interfaces.ts";
|
||||
|
||||
function insertMessage(
|
||||
userId: string,
|
||||
body: string,
|
||||
createdAt: string,
|
||||
id?: string,
|
||||
) {
|
||||
const messageId = id ?? crypto.randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO chat_messages (id, user_id, body, created_at) VALUES (?, ?, ?, ?);`,
|
||||
).run(messageId, userId, body, createdAt);
|
||||
return messageId;
|
||||
}
|
||||
|
||||
const T = (n: number) => new Date(Date.UTC(2024, 0, 1, 0, 0, n)).toISOString();
|
||||
|
||||
test("createChatMessage: trims, bounds, and drops a dangling reply target", async () => {
|
||||
const user = await createUser();
|
||||
const m = createChatMessage(user.id, " hello ");
|
||||
assertEquals(m.body, "hello");
|
||||
assertEquals(m.authorUsername, user.username);
|
||||
assertThrows(() => createChatMessage(user.id, " "), APIException, "empty");
|
||||
assertThrows(
|
||||
() => createChatMessage(user.id, "x".repeat(2001)),
|
||||
APIException,
|
||||
"2000",
|
||||
);
|
||||
createChatMessage(user.id, "x".repeat(2000));
|
||||
|
||||
const dangling = createChatMessage(user.id, "re", crypto.randomUUID());
|
||||
assertEquals(dangling.replyToId, undefined);
|
||||
});
|
||||
|
||||
test("replies carry a snippet of their target, which goes when it's deleted", async () => {
|
||||
const user = await createUser();
|
||||
const long = createChatMessage(user.id, "a".repeat(500));
|
||||
const reply = createChatMessage(user.id, "re", long.id);
|
||||
assertEquals(reply.replyToId, long.id);
|
||||
assertEquals(reply.replyToAuthor, user.username);
|
||||
assertEquals(reply.replyToBody, "a".repeat(300));
|
||||
deleteChatMessage(long.id, user.id, false);
|
||||
const [after] = getRecentChatMessages();
|
||||
assertEquals(after.replyToId, long.id);
|
||||
assertEquals(after.replyToBody, undefined);
|
||||
});
|
||||
|
||||
test("edit and delete: the author or a moderator", async () => {
|
||||
const author = await createUser();
|
||||
const other = await createUser();
|
||||
const m = createChatMessage(author.id, "orig");
|
||||
assertThrows(
|
||||
() => updateChatMessage(m.id, "x", other.id, false),
|
||||
APIException,
|
||||
"Not authorized",
|
||||
);
|
||||
assertThrows(
|
||||
() => deleteChatMessage(m.id, other.id, false),
|
||||
APIException,
|
||||
"Not authorized",
|
||||
);
|
||||
assertThrows(
|
||||
() => updateChatMessage(m.id, " ", author.id, false),
|
||||
APIException,
|
||||
"empty",
|
||||
);
|
||||
const edited = updateChatMessage(m.id, " edited ", author.id, false);
|
||||
assertEquals(edited.body, "edited");
|
||||
assertEquals(typeof edited.updatedAt?.getTime(), "number");
|
||||
assertEquals(updateChatMessage(m.id, "mod", other.id, true).body, "mod");
|
||||
deleteChatMessage(m.id, other.id, true);
|
||||
assertThrows(
|
||||
() => deleteChatMessage(m.id, author.id, false),
|
||||
APIException,
|
||||
"not found",
|
||||
);
|
||||
});
|
||||
|
||||
test("history pages: newest page in ascending order, older pages by cursor", async () => {
|
||||
const user = await createUser();
|
||||
// Two messages share a timestamp; the id breaks the tie.
|
||||
const ids = [
|
||||
insertMessage(user.id, "m1", T(1)),
|
||||
insertMessage(user.id, "m2a", T(2), "00000000-0000-4000-8000-00000000000a"),
|
||||
insertMessage(user.id, "m2b", T(2), "00000000-0000-4000-8000-00000000000b"),
|
||||
insertMessage(user.id, "m3", T(3)),
|
||||
];
|
||||
const bodies = (ms: { body: string }[]) => ms.map((m) => m.body);
|
||||
assertEquals(bodies(getRecentChatMessages()), ["m1", "m2a", "m2b", "m3"]);
|
||||
assertEquals(bodies(getRecentChatMessages(2)), ["m2b", "m3"]);
|
||||
// Older than m2b: the page boundary falls between the two tied messages.
|
||||
assertEquals(
|
||||
bodies(getRecentChatMessages(10, { createdAt: T(2), id: ids[2] })),
|
||||
["m1", "m2a"],
|
||||
);
|
||||
// No id: everything strictly before the timestamp.
|
||||
assertEquals(bodies(getRecentChatMessages(10, { createdAt: T(2) })), ["m1"]);
|
||||
|
||||
assertEquals(
|
||||
bodies(getChatMessagesAfter({ createdAt: T(2), id: ids[1] })),
|
||||
["m2b", "m3"],
|
||||
);
|
||||
// No id: inclusive of the timestamp.
|
||||
assertEquals(bodies(getChatMessagesAfter({ createdAt: T(2) })), [
|
||||
"m2a",
|
||||
"m2b",
|
||||
"m3",
|
||||
]);
|
||||
assertEquals(bodies(getChatMessagesAfter({ createdAt: T(0) }, 1)), ["m1"]);
|
||||
// Limits are clamped to 1..100.
|
||||
assertEquals(getRecentChatMessages(0).length, 1);
|
||||
assertEquals(getRecentChatMessages(-5).length, 1);
|
||||
});
|
||||
|
||||
test("GET /api/chat: members only, with limit and both cursors", async () => {
|
||||
const user = await createUser();
|
||||
const ids = [1, 2, 3].map((n) => insertMessage(user.id, `m${n}`, T(n)));
|
||||
assertEquals((await get("/api/chat")).status, 401);
|
||||
const bodies = async (q: string) =>
|
||||
(await get(`/api/chat${q}`, { auth: user.auth })).body.data.map(
|
||||
(m: { body: string }) => m.body,
|
||||
);
|
||||
assertEquals(await bodies(""), ["m1", "m2", "m3"]);
|
||||
assertEquals(await bodies("?limit=1"), ["m3"]);
|
||||
assertEquals(await bodies("?limit=abc"), ["m1", "m2", "m3"]);
|
||||
assertEquals(await bodies(`?before=${T(3)}&beforeId=${ids[2]}`), [
|
||||
"m1",
|
||||
"m2",
|
||||
]);
|
||||
assertEquals(await bodies(`?after=${T(1)}&afterId=${ids[0]}`), ["m2", "m3"]);
|
||||
});
|
||||
|
||||
test("read state: unread counts others' messages since the marker", async () => {
|
||||
const me = await createUser({ createdAt: T(0) });
|
||||
const other = await createUser();
|
||||
insertMessage(other.id, "a", T(1));
|
||||
insertMessage(me.id, "mine", T(2));
|
||||
insertMessage(other.id, "b", T(3));
|
||||
// No marker yet: everything since the account was created.
|
||||
assertEquals(getChatReadState(me.id), { readAt: T(0), unread: 2 });
|
||||
assertEquals(markChatRead(me.id, T(1)), { readAt: T(1), unread: 1 });
|
||||
// Never backward.
|
||||
assertEquals(markChatRead(me.id, T(0)).readAt, T(1));
|
||||
// Never past the newest message.
|
||||
assertEquals(markChatRead(me.id, "2999-01-01T00:00:00Z"), {
|
||||
readAt: T(3),
|
||||
unread: 0,
|
||||
});
|
||||
assertThrows(
|
||||
() => markChatRead(me.id, "not a date"),
|
||||
APIException,
|
||||
"Invalid",
|
||||
);
|
||||
assertThrows(
|
||||
() => getChatReadState(crypto.randomUUID()),
|
||||
APIException,
|
||||
"not found",
|
||||
);
|
||||
});
|
||||
|
||||
test("read state: with no messages at all the marker can't be moved", async () => {
|
||||
const me = await createUser({ createdAt: T(0) });
|
||||
assertEquals(markChatRead(me.id, "2999-01-01T00:00:00Z"), {
|
||||
readAt: T(0),
|
||||
unread: 0,
|
||||
});
|
||||
const other = await createUser();
|
||||
insertMessage(other.id, "later", T(5));
|
||||
assertEquals(getChatReadState(me.id).unread, 1);
|
||||
});
|
||||
260
api/test/comments.test.ts
Normal file
260
api/test/comments.test.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import {
|
||||
count,
|
||||
createUser,
|
||||
db,
|
||||
del,
|
||||
get,
|
||||
insertComment,
|
||||
insertDump,
|
||||
patch,
|
||||
post,
|
||||
test,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals, assertExists } from "@std/assert";
|
||||
import {
|
||||
castCommentLike,
|
||||
removeCommentLike,
|
||||
} from "../services/comment-like-service.ts";
|
||||
import { APIException } from "../model/interfaces.ts";
|
||||
|
||||
test("comments: post, list in order, count on the dump", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id } = insertDump({ userId: owner.id });
|
||||
const first = await post(`/api/dumps/${id}/comments`, {
|
||||
auth: other.auth,
|
||||
json: { body: " first " },
|
||||
});
|
||||
assertEquals(first.status, 201);
|
||||
assertEquals(first.body.data.body, "first");
|
||||
assertEquals(first.body.data.authorUsername, other.username);
|
||||
const reply = await post(`/api/dumps/${id}/comments`, {
|
||||
auth: owner.auth,
|
||||
json: { body: "reply", parentId: first.body.data.id },
|
||||
});
|
||||
assertEquals(reply.body.data.parentId, first.body.data.id);
|
||||
const list = await get(`/api/dumps/${id}/comments`);
|
||||
assertEquals(list.body.data.map((c: { body: string }) => c.body), [
|
||||
"first",
|
||||
"reply",
|
||||
]);
|
||||
assertEquals((await get(`/api/dumps/${id}`)).body.data.commentCount, 2);
|
||||
});
|
||||
|
||||
test("comments: validation, auth and unknown dumps", async () => {
|
||||
const user = await createUser();
|
||||
const { id } = insertDump({ userId: user.id });
|
||||
for (const body of ["", " ", "x".repeat(5001)]) {
|
||||
assertEquals(
|
||||
(await post(`/api/dumps/${id}/comments`, {
|
||||
auth: user.auth,
|
||||
json: { body },
|
||||
}))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
}
|
||||
assertEquals(
|
||||
(await post(`/api/dumps/${id}/comments`, { json: { body: "hi" } })).status,
|
||||
401,
|
||||
);
|
||||
assertEquals(
|
||||
(await post(`/api/dumps/nope/comments`, {
|
||||
auth: user.auth,
|
||||
json: { body: "hi" },
|
||||
}))
|
||||
.status,
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
test("comments on a private dump: owner only", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id } = insertDump({ userId: owner.id, isPrivate: true });
|
||||
assertEquals(
|
||||
(await post(`/api/dumps/${id}/comments`, {
|
||||
auth: other.auth,
|
||||
json: { body: "x" },
|
||||
}))
|
||||
.status,
|
||||
404,
|
||||
);
|
||||
assertEquals(
|
||||
(await post(`/api/dumps/${id}/comments`, {
|
||||
auth: owner.auth,
|
||||
json: { body: "x" },
|
||||
}))
|
||||
.status,
|
||||
201,
|
||||
);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${id}/comments`, { auth: other.auth })).status,
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
test("comments: the dump's owner is notified, the commenter isn't", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id } = insertDump({ userId: owner.id, title: "Mine" });
|
||||
await post(`/api/dumps/${id}/comments`, {
|
||||
auth: owner.auth,
|
||||
json: { body: "self" },
|
||||
});
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
|
||||
const c = await post(`/api/dumps/${id}/comments`, {
|
||||
auth: other.auth,
|
||||
json: { body: "nice" },
|
||||
});
|
||||
const note = db.prepare(`SELECT user_id, type, data FROM notifications;`)
|
||||
.get() as {
|
||||
user_id: string;
|
||||
type: string;
|
||||
data: string;
|
||||
};
|
||||
assertEquals(note.user_id, owner.id);
|
||||
assertEquals(note.type, "dump_commented");
|
||||
assertEquals(JSON.parse(note.data).commentId, c.body.data.id);
|
||||
assertEquals(JSON.parse(note.data).dumpTitle, "Mine");
|
||||
});
|
||||
|
||||
test("PATCH /api/comments/:id: author and moderator edit, others can't", async () => {
|
||||
const author = await createUser();
|
||||
const other = await createUser();
|
||||
const mod = await createUser({ role: "moderator" });
|
||||
const { id: dumpId } = insertDump({ userId: author.id });
|
||||
const commentId = insertComment({ dumpId, userId: author.id, body: "orig" });
|
||||
assertEquals(
|
||||
(await patch(`/api/comments/${commentId}`, {
|
||||
auth: other.auth,
|
||||
json: { body: "x" },
|
||||
}))
|
||||
.status,
|
||||
401,
|
||||
);
|
||||
const edited = await patch(`/api/comments/${commentId}`, {
|
||||
auth: author.auth,
|
||||
json: { body: " edited " },
|
||||
});
|
||||
assertEquals(edited.status, 200);
|
||||
assertEquals(edited.body.data.body, "edited");
|
||||
assertExists(edited.body.data.updatedAt);
|
||||
assertEquals(
|
||||
(await patch(`/api/comments/${commentId}`, {
|
||||
auth: mod.auth,
|
||||
json: { body: "m" },
|
||||
}))
|
||||
.status,
|
||||
200,
|
||||
);
|
||||
assertEquals(
|
||||
(await patch(`/api/comments/${commentId}`, {
|
||||
auth: author.auth,
|
||||
json: { body: "" },
|
||||
}))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
assertEquals(
|
||||
(await patch(`/api/comments/${crypto.randomUUID()}`, {
|
||||
auth: author.auth,
|
||||
json: { body: "x" },
|
||||
})).status,
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
test("DELETE /api/comments/:id soft-deletes, and a deleted comment can't be edited", async () => {
|
||||
const author = await createUser();
|
||||
const other = await createUser();
|
||||
const { id: dumpId } = insertDump({ userId: author.id });
|
||||
const commentId = insertComment({
|
||||
dumpId,
|
||||
userId: author.id,
|
||||
body: "gone soon",
|
||||
});
|
||||
assertEquals(
|
||||
(await del(`/api/comments/${commentId}`, { auth: other.auth })).status,
|
||||
401,
|
||||
);
|
||||
assertEquals(
|
||||
(await del(`/api/comments/${commentId}`, { auth: author.auth })).status,
|
||||
200,
|
||||
);
|
||||
const [c] = (await get(`/api/dumps/${dumpId}/comments`)).body.data;
|
||||
assertEquals(c.deleted, true);
|
||||
assertEquals(c.body, "");
|
||||
assertEquals((await get(`/api/dumps/${dumpId}`)).body.data.commentCount, 0);
|
||||
assertEquals(
|
||||
(await patch(`/api/comments/${commentId}`, {
|
||||
auth: author.auth,
|
||||
json: { body: "back" },
|
||||
}))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
test("comment likes: count, conflicts, likers list and notifications", async () => {
|
||||
const author = await createUser();
|
||||
const liker = await createUser();
|
||||
const { id: dumpId } = insertDump({ userId: author.id });
|
||||
const commentId = insertComment({ dumpId, userId: author.id });
|
||||
|
||||
assertEquals(castCommentLike(commentId, liker.id).likeCount, 1);
|
||||
assertEquals(castCommentLike(commentId, author.id).likeCount, 2);
|
||||
try {
|
||||
castCommentLike(commentId, liker.id);
|
||||
throw new Error("expected a conflict");
|
||||
} catch (err) {
|
||||
assertEquals((err as APIException).status, 409);
|
||||
}
|
||||
// Only the other user's like notifies the author.
|
||||
assertEquals(
|
||||
count(
|
||||
`SELECT COUNT(*) as n FROM notifications WHERE type = 'comment_liked';`,
|
||||
),
|
||||
1,
|
||||
);
|
||||
|
||||
const likers = await get(`/api/comments/${commentId}/likers`);
|
||||
assertEquals(likers.body.data.total, 2);
|
||||
assertEquals(likers.body.data.items[0].email, undefined);
|
||||
|
||||
assertEquals(removeCommentLike(commentId, liker.id).likeCount, 1);
|
||||
try {
|
||||
removeCommentLike(commentId, liker.id);
|
||||
throw new Error("expected a 404");
|
||||
} catch (err) {
|
||||
assertEquals((err as APIException).status, 404);
|
||||
}
|
||||
assertEquals(
|
||||
(db.prepare(`SELECT like_count FROM comments WHERE id = ?;`).get(
|
||||
commentId,
|
||||
) as {
|
||||
like_count: number;
|
||||
}).like_count,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("comment likes on a private dump: owner only", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id: dumpId } = insertDump({ userId: owner.id, isPrivate: true });
|
||||
const commentId = insertComment({ dumpId, userId: owner.id });
|
||||
try {
|
||||
castCommentLike(commentId, other.id);
|
||||
throw new Error("expected a 404");
|
||||
} catch (err) {
|
||||
assertEquals((err as APIException).status, 404);
|
||||
}
|
||||
assertEquals(castCommentLike(commentId, owner.id).isPrivate, true);
|
||||
assertEquals((await get(`/api/comments/${commentId}/likers`)).status, 404);
|
||||
assertEquals(
|
||||
(await get(`/api/comments/${commentId}/likers`, { auth: owner.auth }))
|
||||
.status,
|
||||
200,
|
||||
);
|
||||
});
|
||||
747
api/test/dumps.test.ts
Normal file
747
api/test/dumps.test.ts
Normal file
@@ -0,0 +1,747 @@
|
||||
import {
|
||||
count,
|
||||
createUser,
|
||||
db,
|
||||
del,
|
||||
formWithFile,
|
||||
get,
|
||||
html,
|
||||
insertCategory,
|
||||
insertComment,
|
||||
insertDump,
|
||||
insertPlaylist,
|
||||
PNG,
|
||||
post,
|
||||
put,
|
||||
stubNetwork,
|
||||
test,
|
||||
TEST_DIR,
|
||||
} from "./helpers.ts";
|
||||
import {
|
||||
assert,
|
||||
assertEquals,
|
||||
assertExists,
|
||||
assertMatch,
|
||||
assertNotEquals,
|
||||
} from "@std/assert";
|
||||
|
||||
const PAGE = html(`<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("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://example.com/a?utm_source=x", comment: "look" },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const dump = res.body.data;
|
||||
assertEquals(dump.kind, "url");
|
||||
assertEquals(dump.title, "Scraped title");
|
||||
assertMatch(dump.slug, /^scraped-title-[0-9a-f]{8}$/);
|
||||
assertEquals(dump.userId, user.id);
|
||||
assertEquals(dump.isPrivate, false);
|
||||
assertEquals(dump.richContent.thumbnailUrl, "https://example.com/cover.jpg");
|
||||
const row = db.prepare(`SELECT url, url_canonical FROM dumps WHERE id = ?;`)
|
||||
.get(dump.id) as { url: string; url_canonical: string };
|
||||
// The exact URL is kept; the canonical key drops the tracking parameter.
|
||||
assertEquals(row.url, "https://example.com/a?utm_source=x");
|
||||
assertEquals(row.url_canonical, "https://example.com/a");
|
||||
});
|
||||
|
||||
test("POST /api/dumps: a typed title wins; an unreachable page still posts", async () => {
|
||||
const user = await createUser();
|
||||
{
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://example.com/b", title: " My title " },
|
||||
});
|
||||
assertEquals(res.body.data.title, "My title");
|
||||
}
|
||||
{
|
||||
using _ = stubNetwork(() => {
|
||||
throw new TypeError("offline");
|
||||
});
|
||||
const res = await post("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://www.offline.example/c" },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
assertEquals(res.body.data.title, "offline.example");
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /api/dumps: refuses bad URLs and bodies, and anonymous callers", async () => {
|
||||
const user = await createUser();
|
||||
using net = stubNetwork(() => PAGE.clone());
|
||||
for (
|
||||
const url of ["http://localhost/", "http://192.168.0.1/", "ftp://x/", "no"]
|
||||
) {
|
||||
const res = await post("/api/dumps", { auth: user.auth, json: { url } });
|
||||
assertEquals(res.status, 400, url);
|
||||
}
|
||||
assertEquals(
|
||||
(await post("/api/dumps", { auth: user.auth, json: { nope: 1 } })).status,
|
||||
400,
|
||||
);
|
||||
assertEquals(
|
||||
(await post("/api/dumps", { json: { url: "https://example.com" } })).status,
|
||||
401,
|
||||
);
|
||||
assertEquals(net.calls.length, 0);
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
|
||||
});
|
||||
|
||||
test("POST /api/dumps: categories are stored and deduplicated", async () => {
|
||||
const user = await createUser();
|
||||
const music = insertCategory({ slug: "music" });
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://example.com/d", categoryIds: [music, music] },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
assertEquals(res.body.data.categoryIds, [music]);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${res.body.data.id}`)).body.data.categoryIds,
|
||||
[music],
|
||||
);
|
||||
});
|
||||
|
||||
test("POST /api/dumps: an unknown category is refused and nothing is left behind", async () => {
|
||||
const user = await createUser();
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post("/api/dumps", {
|
||||
auth: user.auth,
|
||||
json: { url: "https://example.com/e", categoryIds: ["no-such-category"] },
|
||||
});
|
||||
assertEquals(res.status, 400);
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
|
||||
});
|
||||
|
||||
test("POST /api/dumps: a file dump is stored and served back", async () => {
|
||||
const user = await createUser();
|
||||
const res = await post("/api/dumps", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG, "pixel.png", "image/png", { comment: "tiny" }),
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const dump = res.body.data;
|
||||
assertEquals(dump.kind, "file");
|
||||
assertEquals(dump.title, "pixel.png");
|
||||
assertEquals(dump.fileMime, "image/png");
|
||||
assertEquals(dump.fileSize, PNG.byteLength);
|
||||
assertEquals(dump.comment, "tiny");
|
||||
assertEquals(readFile(`dumps/${dump.id}`), PNG);
|
||||
const file = await get(`/api/files/${dump.id}`);
|
||||
assertEquals(file.status, 200);
|
||||
assertEquals(file.bytes, PNG);
|
||||
});
|
||||
|
||||
test("POST /api/dumps: file type, size of text fields, and a missing file", async () => {
|
||||
const user = await createUser();
|
||||
const exe = await post("/api/dumps", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG, "x.exe", "application/x-msdownload"),
|
||||
});
|
||||
assertEquals(exe.status, 400);
|
||||
const longTitle = await post("/api/dumps", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG, "x.png", "image/png", { title: "x".repeat(201) }),
|
||||
});
|
||||
assertEquals(longTitle.status, 400);
|
||||
const longComment = await post("/api/dumps", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG, "x.png", "image/png", {
|
||||
comment: "x".repeat(5001),
|
||||
}),
|
||||
});
|
||||
assertEquals(longComment.status, 400);
|
||||
const form = new FormData();
|
||||
form.set("comment", "no file");
|
||||
assertEquals(
|
||||
(await post("/api/dumps", { auth: user.auth, form })).status,
|
||||
400,
|
||||
);
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
|
||||
});
|
||||
|
||||
test("POST /api/dumps: a file dump with an unknown category leaves nothing behind", async () => {
|
||||
const user = await createUser();
|
||||
const res = await post("/api/dumps", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG, "x.png", "image/png", {
|
||||
categoryIds: JSON.stringify(["nope"]),
|
||||
}),
|
||||
});
|
||||
assertEquals(res.status, 400);
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM dumps;`), 0);
|
||||
});
|
||||
|
||||
test("POST /api/dumps: followers are notified of public dumps, not private ones", async () => {
|
||||
const poster = await createUser();
|
||||
const follower = await createUser();
|
||||
db.prepare(
|
||||
`INSERT INTO follows (id, follower_id, followed_user_id, created_at) VALUES (?, ?, ?, ?);`,
|
||||
).run(crypto.randomUUID(), follower.id, poster.id, new Date().toISOString());
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
await post("/api/dumps", {
|
||||
auth: poster.auth,
|
||||
json: { url: "https://example.com/private", isPrivate: true },
|
||||
});
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
|
||||
const pub = await post("/api/dumps", {
|
||||
auth: poster.auth,
|
||||
json: { url: "https://example.com/public" },
|
||||
});
|
||||
const notes = db.prepare(
|
||||
`SELECT user_id, type, data FROM notifications;`,
|
||||
).all() as { user_id: string; type: string; data: string }[];
|
||||
assertEquals(notes.length, 1);
|
||||
assertEquals(notes[0].user_id, follower.id);
|
||||
assertEquals(notes[0].type, "user_dump_posted");
|
||||
assertEquals(JSON.parse(notes[0].data).dumpId, pub.body.data.id);
|
||||
});
|
||||
|
||||
// ── Read ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("GET /api/dumps/:id works by id and by slug", async () => {
|
||||
const user = await createUser();
|
||||
const { id, slug } = insertDump({ userId: user.id, title: "Hello" });
|
||||
assertEquals((await get(`/api/dumps/${id}`)).body.data.id, id);
|
||||
assertEquals((await get(`/api/dumps/${slug}`)).body.data.id, id);
|
||||
assertEquals((await get(`/api/dumps/nope`)).status, 404);
|
||||
assertEquals((await get(`/api/dumps/${crypto.randomUUID()}`)).status, 404);
|
||||
});
|
||||
|
||||
test("GET /api/dumps/:id: a private dump exists only for its owner", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id } = insertDump({ userId: owner.id, isPrivate: true });
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${id}`, { auth: owner.auth })).status,
|
||||
200,
|
||||
);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${id}`, { auth: other.auth })).status,
|
||||
404,
|
||||
);
|
||||
assertEquals((await get(`/api/dumps/${id}`)).status, 404);
|
||||
for (const sub of ["voters", "related", "playlists", "comments"]) {
|
||||
assertEquals((await get(`/api/dumps/${id}/${sub}`)).status, 404, sub);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${id}/${sub}`, { auth: owner.auth })).status,
|
||||
200,
|
||||
sub,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /api/dumps: newest first, paginated, private ones for the owner only", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
insertDump({
|
||||
userId: owner.id,
|
||||
title: `d${i}`,
|
||||
createdAt: new Date(Date.UTC(2024, 0, i + 1)).toISOString(),
|
||||
});
|
||||
}
|
||||
insertDump({
|
||||
userId: owner.id,
|
||||
title: "secret",
|
||||
isPrivate: true,
|
||||
createdAt: new Date(Date.UTC(2024, 0, 10)).toISOString(),
|
||||
});
|
||||
const page1 = await get("/api/dumps?limit=2");
|
||||
assertEquals(page1.body.data.items.map((d: { title: string }) => d.title), [
|
||||
"d4",
|
||||
"d3",
|
||||
]);
|
||||
assertEquals(page1.body.data.total, 5);
|
||||
assertEquals(page1.body.data.hasMore, true);
|
||||
const page3 = await get("/api/dumps?limit=2&page=3");
|
||||
assertEquals(page3.body.data.items.length, 1);
|
||||
assertEquals(page3.body.data.hasMore, false);
|
||||
|
||||
const mine = await get("/api/dumps?limit=1", { auth: owner.auth });
|
||||
assertEquals(mine.body.data.items[0].title, "secret");
|
||||
assertEquals(mine.body.data.total, 6);
|
||||
assertEquals(
|
||||
(await get("/api/dumps", { auth: other.auth })).body.data.total,
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/dumps/by-url: matches on the canonical key, respects privacy", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
insertDump({
|
||||
userId: owner.id,
|
||||
title: "public",
|
||||
url: "https://youtu.be/abc",
|
||||
});
|
||||
insertDump({
|
||||
userId: owner.id,
|
||||
title: "private",
|
||||
url: "https://www.youtube.com/watch?v=abc",
|
||||
isPrivate: true,
|
||||
});
|
||||
const q = encodeURIComponent("https://m.youtube.com/watch?v=abc&t=10");
|
||||
const anon = await get(`/api/dumps/by-url?url=${q}`);
|
||||
assertEquals(anon.body.data.map((d: { title: string }) => d.title), [
|
||||
"public",
|
||||
]);
|
||||
assertEquals(anon.body.data[0].username, owner.username);
|
||||
const mine = await get(`/api/dumps/by-url?url=${q}`, { auth: owner.auth });
|
||||
assertEquals(mine.body.data.length, 2);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/by-url?url=${q}`, { auth: other.auth })).body.data
|
||||
.length,
|
||||
1,
|
||||
);
|
||||
assertEquals((await get(`/api/dumps/by-url?url=garbage`)).body.data, []);
|
||||
});
|
||||
|
||||
test("GET /api/dumps/:id/voters lists voters without private fields", async () => {
|
||||
const owner = await createUser();
|
||||
const voter = await createUser();
|
||||
const { id } = insertDump({ userId: owner.id });
|
||||
db.prepare(
|
||||
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||
).run(id, voter.id, new Date().toISOString());
|
||||
const res = await get(`/api/dumps/${id}/voters`);
|
||||
assertEquals(res.body.data.total, 1);
|
||||
assertEquals(res.body.data.items[0].username, voter.username);
|
||||
assertEquals(res.body.data.items[0].email, undefined);
|
||||
assertEquals(res.body.data.items[0].passwordHash, undefined);
|
||||
});
|
||||
|
||||
test("GET /api/dumps/:id/related: links in both directions, hiding private ones", async () => {
|
||||
const user = await createUser();
|
||||
const other = await createUser();
|
||||
const target = insertDump({
|
||||
userId: user.id,
|
||||
title: "target",
|
||||
url: "https://x.test/t",
|
||||
});
|
||||
const secret = insertDump({
|
||||
userId: other.id,
|
||||
title: "secret",
|
||||
isPrivate: true,
|
||||
});
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
// A new dump whose comment links the target by its URL and the secret dump by
|
||||
// its page — and itself, which is ignored.
|
||||
const src = await post("/api/dumps", {
|
||||
auth: other.auth,
|
||||
json: {
|
||||
url: "https://example.com/src",
|
||||
comment:
|
||||
`see https://x.test/t and http://anywhere/dumps/${secret.slug} and http://gerbeur.test/dumps/self`,
|
||||
},
|
||||
});
|
||||
const srcId = src.body.data.id;
|
||||
const fromSrc = await get(`/api/dumps/${srcId}/related`);
|
||||
assertEquals(fromSrc.body.data.map((d: { title: string }) => d.title), [
|
||||
"target",
|
||||
]);
|
||||
const fromSrcAsOwner = await get(`/api/dumps/${srcId}/related`, {
|
||||
auth: other.auth,
|
||||
});
|
||||
assertEquals(fromSrcAsOwner.body.data.length, 2);
|
||||
const toTarget = await get(`/api/dumps/${target.id}/related`);
|
||||
assertEquals(toTarget.body.data.map((d: { id: string }) => d.id), [srcId]);
|
||||
|
||||
// Editing the comment away removes the links.
|
||||
await put(`/api/dumps/${srcId}`, { auth: other.auth, json: { comment: "" } });
|
||||
assertEquals((await get(`/api/dumps/${target.id}/related`)).body.data, []);
|
||||
});
|
||||
|
||||
test("GET /api/dumps/:id/playlists: public playlists, plus the viewer's private ones", async () => {
|
||||
const user = await createUser();
|
||||
const other = await createUser();
|
||||
const { id } = insertDump({ userId: user.id });
|
||||
const pub = insertPlaylist({ userId: user.id, title: "Public" });
|
||||
const priv = insertPlaylist({
|
||||
userId: user.id,
|
||||
title: "Private",
|
||||
isPublic: false,
|
||||
});
|
||||
for (const p of [pub, priv]) {
|
||||
db.prepare(
|
||||
`INSERT INTO playlist_dumps (playlist_id, dump_id, position, added_at) VALUES (?, ?, 0, ?);`,
|
||||
).run(p.id, id, new Date().toISOString());
|
||||
}
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${id}/playlists`, { auth: other.auth })).body.data
|
||||
.map((p: { title: string }) => p.title),
|
||||
["Public"],
|
||||
);
|
||||
assertEquals(
|
||||
(await get(`/api/dumps/${id}/playlists`, { auth: user.auth })).body.data
|
||||
.length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Update ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test("PUT /api/dumps/:id: owner and moderator may edit, others may not", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const mod = await createUser({ role: "moderator" });
|
||||
const { id, slug } = insertDump({ userId: owner.id, title: "Old" });
|
||||
assertEquals(
|
||||
(await put(`/api/dumps/${id}`, { auth: other.auth, json: { title: "X" } }))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
assertEquals(
|
||||
(await put(`/api/dumps/${id}`, { json: { title: "X" } })).status,
|
||||
401,
|
||||
);
|
||||
const res = await put(`/api/dumps/${slug}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "New title", comment: "hello" },
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.title, "New title");
|
||||
assertMatch(res.body.data.slug, /^new-title-/);
|
||||
assertNotEquals(res.body.data.slug, slug);
|
||||
assertExists(res.body.data.updatedAt);
|
||||
const byMod = await put(`/api/dumps/${id}`, {
|
||||
auth: mod.auth,
|
||||
json: { title: "Moderated" },
|
||||
});
|
||||
assertEquals(byMod.status, 200);
|
||||
assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Moderated");
|
||||
});
|
||||
|
||||
test("PUT /api/dumps/:id: invalid input is 422, a new URL refetches metadata", async () => {
|
||||
const owner = await createUser();
|
||||
const { id } = insertDump({
|
||||
userId: owner.id,
|
||||
title: "Old",
|
||||
url: "https://a.test/",
|
||||
});
|
||||
assertEquals(
|
||||
(await put(`/api/dumps/${id}`, { auth: owner.auth, json: { title: " " } }))
|
||||
.status,
|
||||
422,
|
||||
);
|
||||
assertEquals(
|
||||
(await put(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { url: "http://127.0.0.1/" },
|
||||
})).status,
|
||||
400,
|
||||
);
|
||||
using net = stubNetwork(() => PAGE.clone());
|
||||
// Same URL: no fetch, title kept.
|
||||
await put(`/api/dumps/${id}`, { auth: owner.auth, json: { comment: "c" } });
|
||||
assertEquals(net.calls.length, 0);
|
||||
const moved = await put(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { url: "https://b.test/?utm_medium=z" },
|
||||
});
|
||||
assertEquals(moved.body.data.title, "Scraped title");
|
||||
assertEquals(net.calls.length, 1);
|
||||
const row = db.prepare(`SELECT url_canonical FROM dumps WHERE id = ?;`).get(
|
||||
id,
|
||||
) as {
|
||||
url_canonical: string;
|
||||
};
|
||||
assertEquals(row.url_canonical, "https://b.test");
|
||||
});
|
||||
|
||||
test("PUT /api/dumps/:id: categories are replaced, or kept when omitted", async () => {
|
||||
const owner = await createUser();
|
||||
const a = insertCategory({ slug: "a" });
|
||||
const b = insertCategory({ slug: "b" });
|
||||
const { id } = insertDump({ userId: owner.id });
|
||||
await put(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { categoryIds: [a] },
|
||||
});
|
||||
const kept = await put(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "t" },
|
||||
});
|
||||
assertEquals(kept.body.data.categoryIds, [a]);
|
||||
const replaced = await put(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { categoryIds: [b] },
|
||||
});
|
||||
assertEquals(replaced.body.data.categoryIds, [b]);
|
||||
const cleared = await put(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { categoryIds: [] },
|
||||
});
|
||||
assertEquals(cleared.body.data.categoryIds, []);
|
||||
});
|
||||
|
||||
test("PUT /api/dumps/:id: an unknown category changes nothing", async () => {
|
||||
const owner = await createUser();
|
||||
const a = insertCategory({ slug: "a" });
|
||||
const { id } = insertDump({ userId: owner.id, title: "Before" });
|
||||
await put(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { categoryIds: [a] },
|
||||
});
|
||||
const res = await put(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "After", categoryIds: [a, "nope"] },
|
||||
});
|
||||
assertEquals(res.status, 400);
|
||||
const dump = (await get(`/api/dumps/${id}`)).body.data;
|
||||
assertEquals(dump.title, "Before");
|
||||
assertEquals(dump.categoryIds, [a]);
|
||||
});
|
||||
|
||||
test("PUT /api/dumps/:id/file: an unknown category keeps the old file", async () => {
|
||||
const owner = await createUser();
|
||||
const original = new TextEncoder().encode("original");
|
||||
const { id } = insertDump({
|
||||
userId: owner.id,
|
||||
title: "orig",
|
||||
fileName: "a.txt",
|
||||
fileData: original,
|
||||
});
|
||||
const res = await put(`/api/dumps/${id}/file`, {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG, "b.png", "image/png", {
|
||||
categoryIds: JSON.stringify(["nope"]),
|
||||
}),
|
||||
});
|
||||
assertEquals(res.status, 400);
|
||||
assertEquals(readFile(`dumps/${id}`), original);
|
||||
assertEquals((await get(`/api/dumps/${id}`)).body.data.fileName, "a.txt");
|
||||
});
|
||||
|
||||
test("PUT /api/dumps/:id: going private hides the dump from everyone else", async () => {
|
||||
const owner = await createUser();
|
||||
const { id } = insertDump({ userId: owner.id });
|
||||
await put(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { isPrivate: true },
|
||||
});
|
||||
assertEquals((await get(`/api/dumps/${id}`)).status, 404);
|
||||
await put(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { isPrivate: false },
|
||||
});
|
||||
assertEquals((await get(`/api/dumps/${id}`)).status, 200);
|
||||
});
|
||||
|
||||
test("PUT /api/dumps/:id: a file dump edits title, comment and privacy", async () => {
|
||||
const owner = await createUser();
|
||||
const { id } = insertDump({
|
||||
userId: owner.id,
|
||||
title: "file",
|
||||
fileName: "a.txt",
|
||||
});
|
||||
const res = await put(`/api/dumps/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: {
|
||||
title: "Renamed",
|
||||
comment: "c",
|
||||
isPrivate: true,
|
||||
url: "https://ignored.test",
|
||||
},
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.title, "Renamed");
|
||||
assertEquals(res.body.data.isPrivate, true);
|
||||
assertEquals(res.body.data.url, undefined);
|
||||
});
|
||||
|
||||
test("PUT /api/dumps/:id/file replaces a file dump's contents", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id } = insertDump({
|
||||
userId: owner.id,
|
||||
title: "orig",
|
||||
fileName: "a.txt",
|
||||
});
|
||||
const form = () =>
|
||||
formWithFile(PNG, "b.png", "image/png", { title: "Replaced" });
|
||||
assertEquals(
|
||||
(await put(`/api/dumps/${id}/file`, { auth: other.auth, form: form() }))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
const res = await put(`/api/dumps/${id}/file`, {
|
||||
auth: owner.auth,
|
||||
form: form(),
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.fileName, "b.png");
|
||||
assertEquals(res.body.data.title, "Replaced");
|
||||
assertEquals(readFile(`dumps/${id}`), PNG);
|
||||
|
||||
const urlDump = insertDump({ userId: owner.id });
|
||||
assertEquals(
|
||||
(await put(`/api/dumps/${urlDump.id}/file`, {
|
||||
auth: owner.auth,
|
||||
form: form(),
|
||||
}))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
test("custom thumbnails: set, served, removed", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id } = insertDump({ userId: owner.id });
|
||||
assertEquals(
|
||||
(await put(`/api/dumps/${id}/thumbnail`, {
|
||||
auth: other.auth,
|
||||
form: formWithFile(PNG),
|
||||
})).status,
|
||||
403,
|
||||
);
|
||||
assertEquals(
|
||||
(await put(`/api/dumps/${id}/thumbnail`, {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(
|
||||
new TextEncoder().encode("<svg/>"),
|
||||
"x.svg",
|
||||
"image/svg+xml",
|
||||
),
|
||||
})).status,
|
||||
400,
|
||||
);
|
||||
const set = await put(`/api/dumps/${id}/thumbnail`, {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals(set.status, 200);
|
||||
assertEquals(set.body.data.thumbnailMime, "image/png");
|
||||
const thumb = await get(`/api/thumbnails/${id}`);
|
||||
assertEquals(thumb.status, 200);
|
||||
assertEquals(thumb.headers.get("content-type"), "image/png");
|
||||
assertEquals(thumb.bytes, PNG);
|
||||
|
||||
const removed = await del(`/api/dumps/${id}/thumbnail`, { auth: owner.auth });
|
||||
assertEquals(removed.body.data.thumbnailMime, undefined);
|
||||
assertEquals(readFile(`thumbnails/${id}-custom`), null);
|
||||
// A URL dump without its own thumbnail has none to serve.
|
||||
assertEquals((await get(`/api/thumbnails/${id}`)).status, 404);
|
||||
});
|
||||
|
||||
test("POST /api/dumps/:id/refresh-metadata: updates, or keeps what it had on failure", async () => {
|
||||
const owner = await createUser();
|
||||
const { id } = insertDump({
|
||||
userId: owner.id,
|
||||
title: "Kept",
|
||||
url: "https://example.com/r",
|
||||
richContent: {
|
||||
type: "generic",
|
||||
url: "https://example.com/r",
|
||||
title: "Kept",
|
||||
},
|
||||
});
|
||||
{
|
||||
using _ = stubNetwork(() => {
|
||||
throw new TypeError("down");
|
||||
});
|
||||
const res = await post(`/api/dumps/${id}/refresh-metadata`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(res.status, 502);
|
||||
assertEquals((await get(`/api/dumps/${id}`)).body.data.title, "Kept");
|
||||
}
|
||||
{
|
||||
using _ = stubNetwork(() => PAGE.clone());
|
||||
const res = await post(`/api/dumps/${id}/refresh-metadata`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.title, "Scraped title");
|
||||
}
|
||||
const file = insertDump({ userId: owner.id, fileName: "f.txt" });
|
||||
assertEquals(
|
||||
(await post(`/api/dumps/${file.id}/refresh-metadata`, { auth: owner.auth }))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
test("DELETE /api/dumps/:id removes the dump, its file and what hangs off it", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const mod = await createUser({ role: "moderator" });
|
||||
const { id } = insertDump({ userId: owner.id, fileName: "f.txt" });
|
||||
insertComment({ dumpId: id, userId: other.id });
|
||||
db.prepare(
|
||||
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||
)
|
||||
.run(id, other.id, new Date().toISOString());
|
||||
|
||||
assertEquals(
|
||||
(await del(`/api/dumps/${id}`, { auth: other.auth })).status,
|
||||
403,
|
||||
);
|
||||
assertEquals(
|
||||
(await del(`/api/dumps/${id}`, { auth: owner.auth })).status,
|
||||
200,
|
||||
);
|
||||
assertEquals((await get(`/api/dumps/${id}`)).status, 404);
|
||||
assertEquals(readFile(`dumps/${id}`), null);
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM comments;`), 0);
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM votes;`), 0);
|
||||
|
||||
const second = insertDump({ userId: owner.id });
|
||||
assertEquals(
|
||||
(await del(`/api/dumps/${second.id}`, { auth: mod.auth })).status,
|
||||
200,
|
||||
);
|
||||
assertEquals(
|
||||
(await del(`/api/dumps/${second.id}`, { auth: mod.auth })).status,
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/users/:username/dumps and /votes respect privacy", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const pub = insertDump({ userId: owner.id, title: "pub" });
|
||||
const priv = insertDump({ userId: owner.id, title: "priv", isPrivate: true });
|
||||
for (const d of [pub, priv]) {
|
||||
db.prepare(
|
||||
`INSERT INTO votes (dump_id, user_id, created_at) VALUES (?, ?, ?);`,
|
||||
)
|
||||
.run(d.id, owner.id, new Date().toISOString());
|
||||
}
|
||||
for (const sub of ["dumps", "votes"]) {
|
||||
const path = `/api/users/${owner.username}/${sub}`;
|
||||
assertEquals((await get(path)).body.data.total, 1, sub);
|
||||
assertEquals(
|
||||
(await get(path, { auth: other.auth })).body.data.total,
|
||||
1,
|
||||
sub,
|
||||
);
|
||||
assertEquals(
|
||||
(await get(path, { auth: owner.auth })).body.data.total,
|
||||
2,
|
||||
sub,
|
||||
);
|
||||
}
|
||||
assert((await get(`/api/users/nobody_at_all/dumps`)).status === 404);
|
||||
});
|
||||
367
api/test/helpers.ts
Normal file
367
api/test/helpers.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
// Must stay first: see setup.ts.
|
||||
import { TEST_DIR } from "./setup.ts";
|
||||
|
||||
import { stub } from "@std/testing/mock";
|
||||
import { app } from "../main.ts";
|
||||
import { db } from "../model/db.ts";
|
||||
import { createJWT, createMediaToken, hashPassword } from "../lib/jwt.ts";
|
||||
import { makeSlug } from "../lib/slugify.ts";
|
||||
import { canonicalizeUrl } from "../lib/canonical-url.ts";
|
||||
import type { Role } from "../model/interfaces.ts";
|
||||
|
||||
export { app, db, TEST_DIR };
|
||||
|
||||
// ── Database ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TABLES = [
|
||||
"dump_backlinks",
|
||||
"dump_categories",
|
||||
"playlist_dumps",
|
||||
"comment_likes",
|
||||
"comments",
|
||||
"votes",
|
||||
"follows",
|
||||
"notifications",
|
||||
"password_reset_tokens",
|
||||
"invites",
|
||||
"attachments",
|
||||
"chat_messages",
|
||||
"categories",
|
||||
"playlists",
|
||||
"dumps",
|
||||
"users",
|
||||
];
|
||||
|
||||
/** Empties every table, the default admin included. */
|
||||
export function resetDb(): void {
|
||||
db.exec("PRAGMA foreign_keys = OFF;");
|
||||
for (const table of TABLES) db.exec(`DELETE FROM ${table};`);
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a test body so it starts from an empty database. Usernames and emails
|
||||
* still have to be unique across the whole run where the rate limiter keys on
|
||||
* them (login, password reset), since its buckets live for the process.
|
||||
*/
|
||||
export function test(
|
||||
name: string,
|
||||
fn: (t: Deno.TestContext) => void | Promise<void>,
|
||||
options: Omit<Deno.TestDefinition, "name" | "fn"> = {},
|
||||
): void {
|
||||
Deno.test({
|
||||
name,
|
||||
...options,
|
||||
async fn(t) {
|
||||
resetDb();
|
||||
await fn(t);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Users ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const PASSWORD = "correct-horse";
|
||||
// scrypt is deliberately slow; hash the shared password once.
|
||||
let passwordHash: Promise<string> | undefined;
|
||||
|
||||
let seq = 0;
|
||||
/** A name no other test in this process has used. */
|
||||
export function uniqueName(prefix = "user"): string {
|
||||
return `${prefix}_${++seq}_${crypto.randomUUID().slice(0, 6)}`;
|
||||
}
|
||||
|
||||
export interface TestUser {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: Role;
|
||||
token: string;
|
||||
mediaToken: string;
|
||||
/** Authorization header for this user. */
|
||||
auth: { Authorization: string };
|
||||
}
|
||||
|
||||
export async function createUser(
|
||||
opts: {
|
||||
username?: string;
|
||||
role?: Role;
|
||||
email?: string;
|
||||
invitedBy?: string;
|
||||
createdAt?: string;
|
||||
} = {},
|
||||
): Promise<TestUser> {
|
||||
const id = crypto.randomUUID();
|
||||
const username = opts.username ?? uniqueName();
|
||||
const email = opts.email ?? `${username}@example.test`;
|
||||
const role = opts.role ?? "user";
|
||||
passwordHash ??= hashPassword(PASSWORD);
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, created_at, email, invited_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
id,
|
||||
username,
|
||||
await passwordHash,
|
||||
role,
|
||||
opts.createdAt ?? new Date().toISOString(),
|
||||
email,
|
||||
opts.invitedBy ?? null,
|
||||
);
|
||||
const token = await createJWT({ userId: id, username, role });
|
||||
return {
|
||||
id,
|
||||
username,
|
||||
email,
|
||||
role,
|
||||
token,
|
||||
mediaToken: await createMediaToken(id),
|
||||
auth: { Authorization: `Bearer ${token}` },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Content ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface InsertDumpOptions {
|
||||
userId: string;
|
||||
title?: string;
|
||||
kind?: "url" | "file";
|
||||
url?: string;
|
||||
comment?: string;
|
||||
isPrivate?: boolean;
|
||||
createdAt?: string;
|
||||
fileName?: string;
|
||||
fileMime?: string;
|
||||
/** Bytes written to the uploads directory for a file dump. */
|
||||
fileData?: Uint8Array;
|
||||
richContent?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a dump row directly, skipping the metadata fetch the create route
|
||||
* does. Returns its id and slug.
|
||||
*/
|
||||
export function insertDump(
|
||||
opts: InsertDumpOptions,
|
||||
): { id: string; slug: string } {
|
||||
const id = crypto.randomUUID();
|
||||
const title = opts.title ?? "A dump";
|
||||
const slug = makeSlug(title, id);
|
||||
const kind = opts.kind ?? (opts.fileName ? "file" : "url");
|
||||
const url = kind === "url" ? (opts.url ?? `https://example.com/${id}`) : null;
|
||||
const data = opts.fileData ?? new TextEncoder().encode("hello");
|
||||
db.prepare(
|
||||
`INSERT INTO dumps (id, kind, title, slug, comment, user_id, created_at, url, url_canonical,
|
||||
rich_content, file_name, file_mime, file_size, is_private)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
id,
|
||||
kind,
|
||||
title,
|
||||
slug,
|
||||
opts.comment ?? null,
|
||||
opts.userId,
|
||||
opts.createdAt ?? new Date().toISOString(),
|
||||
url,
|
||||
url ? canonicalizeUrl(url) : null,
|
||||
opts.richContent ? JSON.stringify(opts.richContent) : null,
|
||||
kind === "file" ? (opts.fileName ?? "file.txt") : null,
|
||||
kind === "file" ? (opts.fileMime ?? "text/plain") : null,
|
||||
kind === "file" ? data.byteLength : null,
|
||||
opts.isPrivate ? 1 : 0,
|
||||
);
|
||||
if (kind === "file") {
|
||||
Deno.mkdirSync(`${TEST_DIR}/uploads/dumps`, { recursive: true });
|
||||
Deno.writeFileSync(`${TEST_DIR}/uploads/dumps/${id}`, data);
|
||||
}
|
||||
return { id, slug };
|
||||
}
|
||||
|
||||
export function insertPlaylist(
|
||||
opts: { userId: string; title?: string; isPublic?: boolean },
|
||||
): { id: string; slug: string } {
|
||||
const id = crypto.randomUUID();
|
||||
const title = opts.title ?? "A playlist";
|
||||
const slug = makeSlug(title, id);
|
||||
db.prepare(
|
||||
`INSERT INTO playlists (id, user_id, title, slug, is_public, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
id,
|
||||
opts.userId,
|
||||
title,
|
||||
slug,
|
||||
opts.isPublic === false ? 0 : 1,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return { id, slug };
|
||||
}
|
||||
|
||||
export function insertComment(
|
||||
opts: {
|
||||
dumpId: string;
|
||||
userId: string;
|
||||
body?: string;
|
||||
parentId?: string;
|
||||
createdAt?: string;
|
||||
},
|
||||
): string {
|
||||
const id = crypto.randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO comments (id, dump_id, user_id, parent_id, body, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
id,
|
||||
opts.dumpId,
|
||||
opts.userId,
|
||||
opts.parentId ?? null,
|
||||
opts.body ?? "A comment",
|
||||
opts.createdAt ?? new Date().toISOString(),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function insertCategory(
|
||||
opts: { slug: string; name?: string; position?: number },
|
||||
): string {
|
||||
const id = crypto.randomUUID();
|
||||
db.prepare(
|
||||
`INSERT INTO categories (id, slug, name, position, created_at) VALUES (?, ?, ?, ?, ?);`,
|
||||
).run(
|
||||
id,
|
||||
opts.slug,
|
||||
opts.name ?? opts.slug,
|
||||
opts.position ?? 0,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function count(sql: string, ...params: (string | number)[]): number {
|
||||
return (db.prepare(sql).get(...params) as { n: number }).n;
|
||||
}
|
||||
|
||||
// ── HTTP ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const BASE_URL = "http://gerbeur.test";
|
||||
|
||||
export interface ApiResponse {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
// deno-lint-ignore no-explicit-any
|
||||
body: any;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request through the full middleware stack in-process — no socket.
|
||||
* `json` sends a JSON body, `form` a multipart one.
|
||||
*/
|
||||
export async function request(
|
||||
method: string,
|
||||
path: string,
|
||||
opts: {
|
||||
auth?: { Authorization: string };
|
||||
json?: unknown;
|
||||
form?: FormData;
|
||||
headers?: Record<string, string>;
|
||||
} = {},
|
||||
): Promise<ApiResponse> {
|
||||
const headers = new Headers({ ...opts.auth, ...opts.headers });
|
||||
let body: BodyInit | undefined;
|
||||
if (opts.json !== undefined) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
body = JSON.stringify(opts.json);
|
||||
} else if (opts.form) {
|
||||
body = opts.form;
|
||||
}
|
||||
const res = await app.handle(
|
||||
new Request(`${BASE_URL}${path}`, { method, headers, body }),
|
||||
);
|
||||
if (!res) throw new Error(`No response for ${method} ${path}`);
|
||||
const bytes = new Uint8Array(await res.arrayBuffer());
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
let parsed: unknown = null;
|
||||
if ((res.headers.get("content-type") ?? "").includes("json") && text) {
|
||||
parsed = JSON.parse(text);
|
||||
}
|
||||
return { status: res.status, headers: res.headers, body: parsed, bytes };
|
||||
}
|
||||
|
||||
export const get = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("GET", path, opts);
|
||||
export const post = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("POST", path, opts);
|
||||
export const put = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("PUT", path, opts);
|
||||
export const patch = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("PATCH", path, opts);
|
||||
export const del = (path: string, opts?: Parameters<typeof request>[2]) =>
|
||||
request("DELETE", path, opts);
|
||||
|
||||
// ── Files ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Smallest valid PNG: a 1×1 transparent pixel. */
|
||||
export const PNG: Uint8Array<ArrayBuffer> = Uint8Array.from(
|
||||
atob(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
|
||||
),
|
||||
(c) => c.charCodeAt(0),
|
||||
);
|
||||
|
||||
export function formWithFile(
|
||||
bytes: Uint8Array<ArrayBuffer>,
|
||||
name = "file.png",
|
||||
type = "image/png",
|
||||
fields: Record<string, string> = {},
|
||||
): FormData {
|
||||
const form = new FormData();
|
||||
form.set("file", new File([bytes], name, { type }));
|
||||
for (const [key, value] of Object.entries(fields)) form.set(key, value);
|
||||
return form;
|
||||
}
|
||||
|
||||
// ── Network ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replaces outbound fetches and DNS for the life of the returned handle
|
||||
* (`using net = stubNetwork(...)`). Every hostname resolves to a public
|
||||
* address unless `dns` says otherwise; `handler` answers every fetch.
|
||||
*/
|
||||
export function stubNetwork(
|
||||
handler: (url: URL, init?: RequestInit) => Response | Promise<Response>,
|
||||
dns: Record<string, string[]> = {},
|
||||
): { calls: URL[] } & Disposable {
|
||||
const calls: URL[] = [];
|
||||
const fetchStub = stub(
|
||||
globalThis,
|
||||
"fetch",
|
||||
(input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(input instanceof Request ? input.url : input);
|
||||
calls.push(url);
|
||||
return Promise.resolve(handler(url, init));
|
||||
},
|
||||
);
|
||||
const dnsStub = stub(
|
||||
Deno,
|
||||
"resolveDns",
|
||||
((host: string, type: string) =>
|
||||
Promise.resolve(
|
||||
dns[host] ?? (type === "A" ? ["93.184.216.34"] : []),
|
||||
)) as typeof Deno.resolveDns,
|
||||
);
|
||||
return {
|
||||
calls,
|
||||
[Symbol.dispose]() {
|
||||
fetchStub.restore();
|
||||
dnsStub.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function html(body: string, status = 200): Response {
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
162
api/test/lib.test.ts
Normal file
162
api/test/lib.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// Small pure helpers: slugs, pagination, permissions, rate limiting, image
|
||||
// sniffing, self-links and the favicon codepoint.
|
||||
import "./setup.ts";
|
||||
import {
|
||||
assert,
|
||||
assertEquals,
|
||||
assertFalse,
|
||||
assertMatch,
|
||||
assertThrows,
|
||||
} from "@std/assert";
|
||||
import { FakeTime } from "@std/testing/time";
|
||||
import { makeSlug, slugify, UUID_RE } from "../lib/slugify.ts";
|
||||
import {
|
||||
isReservedSlug,
|
||||
isSlugAvailable,
|
||||
isValidSlugFormat,
|
||||
} from "../lib/reserved-slugs.ts";
|
||||
import { parsePagination } from "../lib/pagination.ts";
|
||||
import { can, hasPermission, ROLE_PERMISSIONS } from "../lib/permissions.ts";
|
||||
import { takeToken } from "../lib/rate-limit.ts";
|
||||
import { detectImageMime, validateImageUpload } from "../lib/upload.ts";
|
||||
import { isSelfUrl } from "../lib/self-url.ts";
|
||||
import { emojiToCodepoint } from "../lib/site-icons.ts";
|
||||
import { APIException } from "../model/interfaces.ts";
|
||||
import { PNG } from "./helpers.ts";
|
||||
|
||||
Deno.test("slugify: lowercases, strips accents, dashes the rest", () => {
|
||||
assertEquals(slugify("Hello, World!"), "hello-world");
|
||||
assertEquals(slugify("Crème brûlée à Paris"), "creme-brulee-a-paris");
|
||||
assertEquals(slugify(" --already--dashed-- "), "already-dashed");
|
||||
assertEquals(slugify("日本語"), "untitled");
|
||||
assertEquals(slugify(""), "untitled");
|
||||
assertEquals(slugify("x".repeat(100)).length, 60);
|
||||
});
|
||||
|
||||
Deno.test("makeSlug: suffixes the first 8 characters of the id", () => {
|
||||
const id = "0123abcd-0000-4000-8000-000000000000";
|
||||
assertEquals(makeSlug("My Song", id), "my-song-0123abcd");
|
||||
assert(UUID_RE.test(id));
|
||||
assertFalse(UUID_RE.test("my-song-0123abcd"));
|
||||
});
|
||||
|
||||
Deno.test("category slugs: format and reserved words", () => {
|
||||
assert(isValidSlugFormat("music"));
|
||||
assert(isValidSlugFormat("lo-fi-2"));
|
||||
for (const bad of ["Music", "-music", "music-", "lo--fi", "lo fi", ""]) {
|
||||
assertFalse(isValidSlugFormat(bad), bad);
|
||||
}
|
||||
assert(isReservedSlug("dumps"));
|
||||
assert(isReservedSlug("api"));
|
||||
assertFalse(isReservedSlug("music"));
|
||||
assert(isSlugAvailable("music"));
|
||||
assertFalse(isSlugAvailable("users"));
|
||||
assertFalse(isSlugAvailable("Nope"));
|
||||
});
|
||||
|
||||
Deno.test("parsePagination: defaults, bounds and garbage", () => {
|
||||
const p = (q: string, def?: number) =>
|
||||
parsePagination(new URLSearchParams(q), def);
|
||||
assertEquals(p(""), { page: 1, limit: 20 });
|
||||
assertEquals(p("page=3&limit=5"), { page: 3, limit: 5 });
|
||||
assertEquals(p("page=0&limit=0"), { page: 1, limit: 20 });
|
||||
assertEquals(p("page=-4&limit=-1"), { page: 1, limit: 1 });
|
||||
assertEquals(p("limit=1000"), { page: 1, limit: 100 });
|
||||
assertEquals(p("page=abc&limit=xyz"), { page: 1, limit: 20 });
|
||||
assertEquals(p("", 7), { page: 1, limit: 7 });
|
||||
});
|
||||
|
||||
Deno.test("permissions: admin has all, moderator moderates, user nothing", () => {
|
||||
for (const perm of ROLE_PERMISSIONS.admin) {
|
||||
assert(hasPermission("admin", perm));
|
||||
}
|
||||
assert(hasPermission("moderator", "dump:moderate"));
|
||||
assert(hasPermission("moderator", "chat:moderate"));
|
||||
assertFalse(hasPermission("moderator", "user:manage"));
|
||||
assertFalse(hasPermission("moderator", "category:manage"));
|
||||
assertEquals(ROLE_PERMISSIONS.user, []);
|
||||
assert(
|
||||
can({ userId: "x", username: "x", role: "admin", exp: 0 }, "user:manage"),
|
||||
);
|
||||
assertFalse(
|
||||
can({ userId: "x", username: "x", role: "user", exp: 0 }, "dump:moderate"),
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("takeToken: bursts to capacity, then refills over time", () => {
|
||||
using time = new FakeTime();
|
||||
const key = `test:${crypto.randomUUID()}`;
|
||||
const limit = { capacity: 3, refill: 1 }; // one token a second
|
||||
assert(takeToken(key, limit));
|
||||
assert(takeToken(key, limit));
|
||||
assert(takeToken(key, limit));
|
||||
assertFalse(takeToken(key, limit));
|
||||
time.tick(500);
|
||||
assertFalse(takeToken(key, limit));
|
||||
time.tick(500);
|
||||
assert(takeToken(key, limit));
|
||||
assertFalse(takeToken(key, limit));
|
||||
// Refill never exceeds capacity.
|
||||
time.tick(60_000);
|
||||
assert(takeToken(key, limit));
|
||||
assert(takeToken(key, limit));
|
||||
assert(takeToken(key, limit));
|
||||
assertFalse(takeToken(key, limit));
|
||||
// Keys are independent.
|
||||
assert(takeToken(`${key}:other`, limit));
|
||||
});
|
||||
|
||||
Deno.test("detectImageMime: sniffs magic bytes, not names", () => {
|
||||
assertEquals(detectImageMime(PNG), "image/png");
|
||||
assertEquals(
|
||||
detectImageMime(new Uint8Array([0xff, 0xd8, 0xff, 0xe0])),
|
||||
"image/jpeg",
|
||||
);
|
||||
assertEquals(
|
||||
detectImageMime(new TextEncoder().encode("GIF89a")),
|
||||
"image/gif",
|
||||
);
|
||||
assertEquals(
|
||||
detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")),
|
||||
"image/webp",
|
||||
);
|
||||
assertEquals(
|
||||
detectImageMime(new TextEncoder().encode("RIFF\0\0\0\0WAVEfmt ")),
|
||||
null,
|
||||
);
|
||||
assertEquals(
|
||||
detectImageMime(new TextEncoder().encode("<svg xmlns=...>")),
|
||||
null,
|
||||
);
|
||||
assertEquals(detectImageMime(new Uint8Array()), null);
|
||||
});
|
||||
|
||||
Deno.test("validateImageUpload: rejects non-images and anything over 5 MB", () => {
|
||||
assertEquals(validateImageUpload(PNG), "image/png");
|
||||
assertThrows(
|
||||
() => validateImageUpload(new TextEncoder().encode("<html>")),
|
||||
APIException,
|
||||
"not a recognised image",
|
||||
);
|
||||
const big = new Uint8Array(5 * 1024 * 1024 + 1);
|
||||
big.set(PNG);
|
||||
assertThrows(() => validateImageUpload(big), APIException, "too large");
|
||||
});
|
||||
|
||||
Deno.test("isSelfUrl: this site's dump, user and playlist pages only", () => {
|
||||
// GERBEUR_PUBLIC_URL is http://gerbeur.test in the suite.
|
||||
assert(isSelfUrl(new URL("http://gerbeur.test/dumps/abc")));
|
||||
assert(isSelfUrl(new URL("https://GERBEUR.test/users/bob")));
|
||||
assert(isSelfUrl(new URL("http://gerbeur.test/playlists/p-1")));
|
||||
assertFalse(isSelfUrl(new URL("http://gerbeur.test/dumps/abc/edit")));
|
||||
assertFalse(isSelfUrl(new URL("http://gerbeur.test/search")));
|
||||
assertFalse(isSelfUrl(new URL("http://elsewhere.test/users/bob")));
|
||||
});
|
||||
|
||||
Deno.test("emojiToCodepoint: Twemoji file naming", () => {
|
||||
assertEquals(emojiToCodepoint("🚚"), "1f69a");
|
||||
// A lone variation selector is dropped…
|
||||
assertEquals(emojiToCodepoint("❤️"), "2764");
|
||||
// …but kept inside a ZWJ sequence.
|
||||
assertMatch(emojiToCodepoint("🏳️🌈"), /^1f3f3-fe0f-200d-1f308$/);
|
||||
});
|
||||
255
api/test/middleware.test.ts
Normal file
255
api/test/middleware.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
// The app-wide middleware: error rendering, CORS, OG tags for crawlers, and
|
||||
// static file serving with its cache rules.
|
||||
import {
|
||||
BASE_URL,
|
||||
createUser,
|
||||
get,
|
||||
insertDump,
|
||||
insertPlaylist,
|
||||
request,
|
||||
test,
|
||||
TEST_DIR,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert";
|
||||
import { Application } from "@oak/oak";
|
||||
import { errorMiddleware } from "../middleware/error.ts";
|
||||
import { ogMiddleware } from "../middleware/og.ts";
|
||||
import { routeStaticFilesFrom } from "../lib/static.ts";
|
||||
import { APIErrorCode, APIException } from "../model/interfaces.ts";
|
||||
|
||||
async function handle(
|
||||
app: Application,
|
||||
path: string,
|
||||
headers: HeadersInit = {},
|
||||
) {
|
||||
const res = await app.handle(new Request(`${BASE_URL}${path}`, { headers }));
|
||||
if (!res) throw new Error("no response");
|
||||
return { res, text: await res.text() };
|
||||
}
|
||||
|
||||
Deno.test("errorMiddleware: APIExceptions keep their status, anything else is a bare 500", async () => {
|
||||
const app = new Application();
|
||||
app.use(errorMiddleware);
|
||||
app.use((ctx) => {
|
||||
if (ctx.request.url.pathname === "/api-error") {
|
||||
throw new APIException(APIErrorCode.NOT_FOUND, 404, "Nope");
|
||||
}
|
||||
throw new Error("secret internals");
|
||||
});
|
||||
const known = await handle(app, "/api-error");
|
||||
assertEquals(known.res.status, 404);
|
||||
assertEquals(JSON.parse(known.text), {
|
||||
success: false,
|
||||
error: { code: "NOT_FOUND", message: "Nope" },
|
||||
});
|
||||
const unknown = await handle(app, "/boom");
|
||||
assertEquals(unknown.res.status, 500);
|
||||
assertEquals(JSON.parse(unknown.text).error, {
|
||||
code: "SERVER_ERROR",
|
||||
message: "Unexpected server error",
|
||||
});
|
||||
});
|
||||
|
||||
test("CORS: allowed origins get the headers, others don't", async () => {
|
||||
const allowed = await get("/api/categories", {
|
||||
headers: { Origin: "http://allowed.test" },
|
||||
});
|
||||
assertEquals(
|
||||
allowed.headers.get("access-control-allow-origin"),
|
||||
"http://allowed.test",
|
||||
);
|
||||
// The public URL is always allowed.
|
||||
const self = await get("/api/categories", { headers: { Origin: BASE_URL } });
|
||||
assertEquals(self.headers.get("access-control-allow-origin"), BASE_URL);
|
||||
const evil = await get("/api/categories", {
|
||||
headers: { Origin: "http://evil.test" },
|
||||
});
|
||||
assertEquals(evil.status, 200);
|
||||
assertEquals(evil.headers.get("access-control-allow-origin"), null);
|
||||
|
||||
const preflight = await request("OPTIONS", "/api/dumps", {
|
||||
headers: {
|
||||
Origin: "http://allowed.test",
|
||||
"Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "authorization,content-type",
|
||||
},
|
||||
});
|
||||
assertEquals(preflight.status, 204);
|
||||
assertEquals(
|
||||
preflight.headers.get("access-control-allow-origin"),
|
||||
"http://allowed.test",
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /ws: refuses other origins, and plain requests from allowed ones", async () => {
|
||||
assertEquals(
|
||||
(await get("/ws", { headers: { Origin: "http://evil.test" } })).status,
|
||||
403,
|
||||
);
|
||||
assertEquals((await get("/ws")).status, 403);
|
||||
assertEquals(
|
||||
(await get("/ws", { headers: { Origin: "http://allowed.test" } })).status,
|
||||
426,
|
||||
);
|
||||
});
|
||||
|
||||
function ogApp() {
|
||||
const app = new Application();
|
||||
app.use(ogMiddleware);
|
||||
app.use((ctx) => {
|
||||
ctx.response.body = "fallthrough";
|
||||
});
|
||||
return app;
|
||||
}
|
||||
|
||||
test("ogMiddleware: dump pages get escaped OG tags", async () => {
|
||||
const user = await createUser();
|
||||
const dump = insertDump({
|
||||
userId: user.id,
|
||||
title: `Tom & "Jerry" <script>`,
|
||||
comment: "A comment",
|
||||
richContent: {
|
||||
type: "generic",
|
||||
url: "x",
|
||||
thumbnailUrl: "https://img.test/a.jpg",
|
||||
},
|
||||
});
|
||||
const { res, text } = await handle(ogApp(), `/dumps/${dump.slug}`);
|
||||
assertEquals(res.headers.get("content-type"), "text/html; charset=utf-8");
|
||||
assertStringIncludes(
|
||||
text,
|
||||
`<meta property="og:title" content="Tom & "Jerry" <script>" />`,
|
||||
);
|
||||
assertStringIncludes(
|
||||
text,
|
||||
`<title>Tom & "Jerry" <script></title>`,
|
||||
);
|
||||
assertStringIncludes(
|
||||
text,
|
||||
`<meta property="og:description" content="A comment" />`,
|
||||
);
|
||||
assertStringIncludes(
|
||||
text,
|
||||
`<meta property="og:image" content="https://img.test/a.jpg" />`,
|
||||
);
|
||||
assertStringIncludes(text, `content="summary_large_image"`);
|
||||
assertStringIncludes(
|
||||
text,
|
||||
`<meta property="og:url" content="${BASE_URL}/dumps/${dump.slug}" />`,
|
||||
);
|
||||
// The index's own title is replaced, not duplicated.
|
||||
assertEquals(text.match(/<title>/g)?.length, 1);
|
||||
assertFalse(text.includes("__SITE_NAME__"));
|
||||
});
|
||||
|
||||
test("ogMiddleware: no artwork falls back to the site icon as a small card", async () => {
|
||||
const user = await createUser();
|
||||
const dump = insertDump({ userId: user.id, title: "Plain" });
|
||||
const { text } = await handle(ogApp(), `/dumps/${dump.id}`);
|
||||
assertStringIncludes(text, `content="${BASE_URL}/apple-touch-icon.png"`);
|
||||
assertStringIncludes(text, `<meta name="twitter:card" content="summary" />`);
|
||||
const file = insertDump({
|
||||
userId: user.id,
|
||||
fileName: "a.png",
|
||||
fileMime: "image/png",
|
||||
});
|
||||
assertStringIncludes(
|
||||
(await handle(ogApp(), `/dumps/${file.id}`)).text,
|
||||
`content="${BASE_URL}/api/files/${file.id}"`,
|
||||
);
|
||||
});
|
||||
|
||||
test("ogMiddleware: users and public playlists; private things fall through", async () => {
|
||||
const user = await createUser();
|
||||
const app = ogApp();
|
||||
assertStringIncludes(
|
||||
(await handle(app, `/users/${user.username}/~/votes`)).text,
|
||||
`<meta property="og:title" content="${user.username}" />`,
|
||||
);
|
||||
const pub = insertPlaylist({ userId: user.id, title: "Open list" });
|
||||
assertStringIncludes(
|
||||
(await handle(app, `/playlists/${pub.slug}`)).text,
|
||||
"Open list",
|
||||
);
|
||||
const priv = insertPlaylist({
|
||||
userId: user.id,
|
||||
title: "Closed",
|
||||
isPublic: false,
|
||||
});
|
||||
assertEquals(
|
||||
(await handle(app, `/playlists/${priv.slug}`)).text,
|
||||
"fallthrough",
|
||||
);
|
||||
const secret = insertDump({
|
||||
userId: user.id,
|
||||
title: "Secret",
|
||||
isPrivate: true,
|
||||
});
|
||||
assertEquals((await handle(app, `/dumps/${secret.id}`)).text, "fallthrough");
|
||||
assertEquals((await handle(app, `/dumps/nope`)).text, "fallthrough");
|
||||
assertEquals(
|
||||
(await handle(app, `/api/dumps/${secret.id}`)).text,
|
||||
"fallthrough",
|
||||
);
|
||||
assertEquals((await handle(app, `/favicon.ico`)).text, "fallthrough");
|
||||
});
|
||||
|
||||
Deno.test("routeStaticFilesFrom: index, SPA fallback, cache rules, precompressed files", async () => {
|
||||
const root = `${TEST_DIR}/static-${crypto.randomUUID()}`;
|
||||
await Deno.mkdir(`${root}/assets`, { recursive: true });
|
||||
await Deno.writeTextFile(
|
||||
`${root}/index.html`,
|
||||
`<html><head><title>__SITE_NAME__</title></head><body>__BANDCAMP_PLAYER__</body></html>`,
|
||||
);
|
||||
const js = "console.log('app');".repeat(50);
|
||||
await Deno.writeTextFile(`${root}/assets/app-abc123.js`, js);
|
||||
const br = new Uint8Array([1, 2, 3]);
|
||||
await Deno.writeFile(`${root}/assets/app-abc123.js.br`, br);
|
||||
await Deno.writeTextFile(`${root}/robots.txt`, "User-agent: *");
|
||||
|
||||
const app = new Application();
|
||||
app.use(routeStaticFilesFrom([root]));
|
||||
|
||||
const index = await handle(app, "/");
|
||||
assertEquals(index.res.headers.get("cache-control"), "no-cache");
|
||||
assertEquals(
|
||||
index.text,
|
||||
"<html><head><title>gerbeur</title></head><body>embed</body></html>",
|
||||
);
|
||||
|
||||
const spa = await handle(app, "/users/someone");
|
||||
assertEquals(spa.res.status, 200);
|
||||
assertEquals(spa.text, index.text);
|
||||
|
||||
// No Accept-Encoding header: the plain file, not the .br sibling.
|
||||
const asset = await handle(app, "/assets/app-abc123.js");
|
||||
assertEquals(asset.text, js);
|
||||
assertEquals(asset.res.headers.get("content-encoding"), null);
|
||||
assertStringIncludes(asset.res.headers.get("cache-control")!, "immutable");
|
||||
assertStringIncludes(
|
||||
asset.res.headers.get("cache-control")!,
|
||||
"max-age=31536000",
|
||||
);
|
||||
assertStringIncludes(asset.res.headers.get("vary")!, "Accept-Encoding");
|
||||
|
||||
const compressed = await handle(app, "/assets/app-abc123.js", {
|
||||
"Accept-Encoding": "br, gzip",
|
||||
});
|
||||
assertEquals(compressed.res.headers.get("content-encoding"), "br");
|
||||
assertEquals(compressed.text, "\x01\x02\x03"); // the .br file's bytes
|
||||
|
||||
const plain = await handle(app, "/robots.txt");
|
||||
assertEquals(plain.text, "User-agent: *");
|
||||
assertFalse(
|
||||
(plain.res.headers.get("cache-control") ?? "").includes("immutable"),
|
||||
);
|
||||
|
||||
// A chunk from an older build 404s rather than being answered with the index.
|
||||
const stale = await handle(app, "/assets/app-old999.js");
|
||||
assertEquals(stale.res.status, 404);
|
||||
assertFalse(stale.text.includes("<html>"));
|
||||
assertEquals(
|
||||
(await handle(app, "/emojibase/17.0.0/missing.json")).res.status,
|
||||
404,
|
||||
);
|
||||
});
|
||||
182
api/test/migrations.test.ts
Normal file
182
api/test/migrations.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// Migrations run against fresh databases (built from schema.sql) and old ones;
|
||||
// each must be idempotent, and the data rewrites must touch only what they
|
||||
// mean to.
|
||||
import "./setup.ts";
|
||||
import { assertEquals } from "@std/assert";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { runMigrations } from "../db/migrate.ts";
|
||||
import { up as up0004 } from "../db/migrations/0004_user_roles.ts";
|
||||
import { up as up0005 } from "../db/migrations/0005_drop_is_admin.ts";
|
||||
import { up as up0010 } from "../db/migrations/0010_youtube_embed_start.ts";
|
||||
import { up as up0011 } from "../db/migrations/0011_split_favicon_thumbnail.ts";
|
||||
import { up as up0012 } from "../db/migrations/0012_fix_favicon_reclassification.ts";
|
||||
|
||||
const SCHEMA = Deno.readTextFileSync(
|
||||
new URL("../db/schema.sql", import.meta.url),
|
||||
);
|
||||
|
||||
function freshDb(): DatabaseSync {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
|
||||
function schemaOf(db: DatabaseSync): string[] {
|
||||
return (db.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE sql IS NOT NULL AND name != 'schema_migrations' ORDER BY name;`,
|
||||
).all() as { sql: string }[]).map((r) => r.sql);
|
||||
}
|
||||
|
||||
Deno.test("runMigrations: a fresh database is left as schema.sql made it", () => {
|
||||
const db = freshDb();
|
||||
const before = schemaOf(db);
|
||||
runMigrations(db);
|
||||
assertEquals(schemaOf(db), before);
|
||||
const applied = db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`)
|
||||
.get() as {
|
||||
n: number;
|
||||
};
|
||||
assertEquals(applied.n, 15);
|
||||
// Running again applies nothing.
|
||||
runMigrations(db);
|
||||
assertEquals(
|
||||
(db.prepare(`SELECT COUNT(*) as n FROM schema_migrations;`).get() as {
|
||||
n: number;
|
||||
}).n,
|
||||
15,
|
||||
);
|
||||
db.close();
|
||||
});
|
||||
|
||||
Deno.test("0004/0005: is_admin becomes role, then goes", () => {
|
||||
const db = new DatabaseSync(":memory:");
|
||||
db.exec(
|
||||
`CREATE TABLE users (id TEXT PRIMARY KEY, is_admin INTEGER NOT NULL DEFAULT 0);`,
|
||||
);
|
||||
db.exec(`INSERT INTO users (id, is_admin) VALUES ('a', 1), ('b', 0);`);
|
||||
up0004(db);
|
||||
up0004(db);
|
||||
up0005(db);
|
||||
up0005(db);
|
||||
assertEquals(
|
||||
db.prepare(`SELECT id, role FROM users ORDER BY id;`).all().map((r) => ({
|
||||
...r,
|
||||
})),
|
||||
[{ id: "a", role: "admin" }, { id: "b", role: "user" }],
|
||||
);
|
||||
const cols =
|
||||
(db.prepare(`PRAGMA table_info(users);`).all() as { name: string }[])
|
||||
.map((c) => c.name);
|
||||
assertEquals(cols, ["id", "role"]);
|
||||
db.close();
|
||||
});
|
||||
|
||||
function insertRich(db: DatabaseSync, id: string, url: string, rich: unknown) {
|
||||
db.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, created_at, email)
|
||||
VALUES ('u', 'u', 'x', 'now', 'u@x') ON CONFLICT DO NOTHING;`,
|
||||
).run();
|
||||
db.prepare(
|
||||
`INSERT INTO dumps (id, kind, title, user_id, created_at, url, rich_content)
|
||||
VALUES (?, 'url', 't', 'u', 'now', ?, ?);`,
|
||||
).run(id, url, typeof rich === "string" ? rich : JSON.stringify(rich));
|
||||
}
|
||||
|
||||
function richOf(db: DatabaseSync, id: string): Record<string, unknown> {
|
||||
return JSON.parse(
|
||||
(db.prepare(`SELECT rich_content FROM dumps WHERE id = ?;`).get(id) as {
|
||||
rich_content: string;
|
||||
}).rich_content,
|
||||
);
|
||||
}
|
||||
|
||||
Deno.test("0010: adds the start offset to YouTube embeds that lost it", () => {
|
||||
const db = freshDb();
|
||||
const embed = "https://www.youtube.com/embed/abc?rel=0";
|
||||
insertRich(db, "hms", "https://youtu.be/abc?t=1h2m3s", {
|
||||
type: "youtube",
|
||||
embedUrl: embed,
|
||||
});
|
||||
insertRich(db, "plain", "https://youtu.be/abc?start=42", {
|
||||
type: "youtube",
|
||||
embedUrl: embed,
|
||||
});
|
||||
insertRich(db, "none", "https://youtu.be/abc", {
|
||||
type: "youtube",
|
||||
embedUrl: embed,
|
||||
});
|
||||
insertRich(db, "has", "https://youtu.be/abc?t=5", {
|
||||
type: "youtube",
|
||||
embedUrl: `${embed}&start=9`,
|
||||
});
|
||||
insertRich(db, "other", "https://example.com/?t=5", {
|
||||
type: "generic",
|
||||
embedUrl: embed,
|
||||
});
|
||||
insertRich(db, "bad", "https://youtu.be/abc?t=5", "{not json");
|
||||
up0010(db);
|
||||
up0010(db);
|
||||
assertEquals(richOf(db, "hms").embedUrl, `${embed}&start=3723`);
|
||||
assertEquals(richOf(db, "plain").embedUrl, `${embed}&start=42`);
|
||||
assertEquals(richOf(db, "none").embedUrl, embed);
|
||||
assertEquals(richOf(db, "has").embedUrl, `${embed}&start=9`);
|
||||
assertEquals(richOf(db, "other").embedUrl, embed);
|
||||
db.close();
|
||||
});
|
||||
|
||||
Deno.test("0011 then 0012: icons move to faviconUrl, artwork stays or comes back", () => {
|
||||
const db = freshDb();
|
||||
const cases: Record<
|
||||
string,
|
||||
[string, string | undefined, string | undefined]
|
||||
> = {
|
||||
// id: [thumbnailUrl before, thumbnailUrl after, faviconUrl after]
|
||||
ico: [
|
||||
"https://a.test/favicon.ico",
|
||||
undefined,
|
||||
"https://a.test/favicon.ico",
|
||||
],
|
||||
touch: [
|
||||
"https://a.test/apple-touch-icon.png",
|
||||
undefined,
|
||||
"https://a.test/apple-touch-icon.png",
|
||||
],
|
||||
art: ["https://a.test/cover.jpg", "https://a.test/cover.jpg", undefined],
|
||||
// 0011 mistook these for icons; 0012 puts them back.
|
||||
logoFavicon: [
|
||||
"https://a.test/uploads/mir-logo-favicon.png",
|
||||
"https://a.test/uploads/mir-logo-favicon.png",
|
||||
undefined,
|
||||
],
|
||||
svgArt: [
|
||||
"https://a.test/art/poster.svg",
|
||||
"https://a.test/art/poster.svg",
|
||||
undefined,
|
||||
],
|
||||
// 0011 missed this one; 0012 catches it.
|
||||
iconName: [
|
||||
"https://a.test/img/icon_SEARCH.png",
|
||||
undefined,
|
||||
"https://a.test/img/icon_SEARCH.png",
|
||||
],
|
||||
};
|
||||
for (const [id, [thumbnailUrl]] of Object.entries(cases)) {
|
||||
insertRich(db, id, "https://a.test/", {
|
||||
type: "generic",
|
||||
url: "u",
|
||||
thumbnailUrl,
|
||||
});
|
||||
}
|
||||
up0011(db);
|
||||
up0011(db);
|
||||
up0012(db);
|
||||
up0012(db);
|
||||
for (const [id, [, thumbnailUrl, faviconUrl]] of Object.entries(cases)) {
|
||||
const rich = richOf(db, id);
|
||||
assertEquals([rich.thumbnailUrl, rich.faviconUrl], [
|
||||
thumbnailUrl,
|
||||
faviconUrl,
|
||||
], id);
|
||||
}
|
||||
db.close();
|
||||
});
|
||||
204
api/test/players.test.ts
Normal file
204
api/test/players.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// Play-time resolution: Bandcamp tracklists and YouTube playlist tracklists,
|
||||
// their parsers and their routes. All network access is stubbed.
|
||||
import { get, html, stubNetwork } from "./helpers.ts";
|
||||
import { assert, assertEquals, assertFalse } from "@std/assert";
|
||||
import { parseTralbum } from "../services/bandcamp-tralbum.ts";
|
||||
import { parsePlaylistPage } from "../services/youtube-playlist-service.ts";
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s.replaceAll("&", "&").replaceAll('"', """);
|
||||
}
|
||||
|
||||
function tralbumPage(data: unknown): string {
|
||||
return `<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" & more' },
|
||||
trackinfo: [
|
||||
{
|
||||
track_num: 1,
|
||||
title: "One",
|
||||
duration: 180.5,
|
||||
streaming: 1,
|
||||
file: { "mp3-128": STREAM },
|
||||
},
|
||||
{
|
||||
track_num: 2,
|
||||
title: "Preorder",
|
||||
streaming: 1,
|
||||
unreleased_track: true,
|
||||
file: null,
|
||||
},
|
||||
{
|
||||
track_num: 3,
|
||||
title: "",
|
||||
streaming: 1,
|
||||
file: { "mp3-128": "https://evil.test/x.mp3" },
|
||||
},
|
||||
{
|
||||
track_num: 4,
|
||||
title: "Capped",
|
||||
streaming: 1,
|
||||
is_capped: true,
|
||||
file: { "mp3-128": STREAM },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Deno.test("parseTralbum: tracks, streamability and art", () => {
|
||||
const album = parseTralbum(
|
||||
tralbumPage(ALBUM),
|
||||
"https://a.bandcamp.com/album/x",
|
||||
);
|
||||
assert(album);
|
||||
assertEquals(album.itemType, "album");
|
||||
assertEquals(album.artist, "Artist");
|
||||
// A title containing a literal & survives the single-pass decode.
|
||||
assertEquals(album.title, 'The "Album" & more');
|
||||
assertEquals(album.artworkUrl, "https://f4.bcbits.com/img/a12345_16.jpg");
|
||||
assertEquals(album.tracks.map((t) => t.streamable), [
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
assertEquals(album.tracks[0].streamUrl, STREAM);
|
||||
assertEquals(album.tracks[0].duration, 180.5);
|
||||
// A non-bcbits stream host is never relayed.
|
||||
assertEquals(album.tracks[2].streamUrl, null);
|
||||
assertEquals(album.tracks[2].title, "Track 3");
|
||||
assert(album.tracks[3].capped);
|
||||
});
|
||||
|
||||
Deno.test("parseTralbum: pages it can't use", () => {
|
||||
assertEquals(parseTralbum("<html></html>", "u"), null);
|
||||
assertEquals(parseTralbum(`<div data-tralbum="{oops"></div>`, "u"), null);
|
||||
assertEquals(
|
||||
parseTralbum(tralbumPage({ ...ALBUM, hasAudio: false }), "u"),
|
||||
null,
|
||||
);
|
||||
assertEquals(
|
||||
parseTralbum(
|
||||
tralbumPage({ ...ALBUM, trackinfo: [ALBUM.trackinfo[1]] }),
|
||||
"u",
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
function ytPage(data: unknown): string {
|
||||
return `<script>var ytInitialData = ${JSON.stringify(data)};</script>`;
|
||||
}
|
||||
|
||||
const YT_DATA = {
|
||||
metadata: { playlistMetadataRenderer: { title: "My mix" } },
|
||||
contents: {
|
||||
list: [
|
||||
{
|
||||
lockupViewModel: {
|
||||
contentType: "LOCKUP_CONTENT_TYPE_VIDEO",
|
||||
contentId: "vid1",
|
||||
metadata: {
|
||||
lockupMetadataViewModel: { title: { content: "First" } },
|
||||
},
|
||||
contentImage: {
|
||||
overlays: [{ thumbnailBadgeViewModel: { text: "1:02:03" } }],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
lockupViewModel: {
|
||||
contentType: "LOCKUP_CONTENT_TYPE_PLAYLIST",
|
||||
contentId: "ignored",
|
||||
},
|
||||
},
|
||||
{
|
||||
playlistVideoRenderer: {
|
||||
videoId: "vid2",
|
||||
title: { runs: [{ text: "Second" }] },
|
||||
lengthSeconds: "235",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Deno.test("parsePlaylistPage: current and legacy rows", () => {
|
||||
assertEquals(parsePlaylistPage(ytPage(YT_DATA), "PLx"), {
|
||||
listId: "PLx",
|
||||
title: "My mix",
|
||||
tracks: [
|
||||
{ videoId: "vid1", title: "First", duration: 3723 },
|
||||
{ videoId: "vid2", title: "Second", duration: 235 },
|
||||
],
|
||||
});
|
||||
assertEquals(parsePlaylistPage("<html></html>", "PLx"), null);
|
||||
assertEquals(parsePlaylistPage(ytPage({ contents: {} }), "PLx"), null);
|
||||
});
|
||||
|
||||
Deno.test("GET /api/bandcamp/tracks: resolves, caches, and forces a refetch", async () => {
|
||||
using net = stubNetwork(() => html(tralbumPage(ALBUM)));
|
||||
const url = "https://cached.bandcamp.com/album/x";
|
||||
const res = await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`);
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.headers.get("cache-control"), "private, max-age=3600");
|
||||
assertEquals(res.body.data.tracks.length, 4);
|
||||
// Same page (different query/case/trailing slash) → cache hit.
|
||||
await get(
|
||||
`/api/bandcamp/tracks?url=${
|
||||
encodeURIComponent("https://CACHED.bandcamp.com/album/x/?from=y")
|
||||
}`,
|
||||
);
|
||||
assertEquals(net.calls.length, 1);
|
||||
await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}&force=1`);
|
||||
assertEquals(net.calls.length, 2);
|
||||
});
|
||||
|
||||
Deno.test("GET /api/bandcamp/tracks: refusals and upstream failures", async () => {
|
||||
using _ = stubNetwork((url) => {
|
||||
if (url.hostname.startsWith("down")) {
|
||||
return new Response("", { status: 503 });
|
||||
}
|
||||
if (url.hostname.startsWith("json")) return Response.json({});
|
||||
return html("<html>no tralbum</html>");
|
||||
});
|
||||
const status = async (url: string) =>
|
||||
(await get(`/api/bandcamp/tracks?url=${encodeURIComponent(url)}`)).status;
|
||||
assertEquals(await status("https://example.com/album/x"), 400);
|
||||
assertEquals(await status("https://bandcamp.com.evil.test/album/x"), 400);
|
||||
assertEquals(await status("http://localhost/album/x"), 400);
|
||||
assertEquals(await status("https://down.bandcamp.com/album/x"), 502);
|
||||
assertEquals(await status("https://json.bandcamp.com/album/x"), 502);
|
||||
assertEquals(await status("https://empty.bandcamp.com/album/x"), 404);
|
||||
});
|
||||
|
||||
Deno.test("GET /api/youtube/playlist: resolves and validates the id", async () => {
|
||||
let cookie: string | null = null;
|
||||
using net = stubNetwork((url, init) => {
|
||||
cookie = new Headers(init?.headers).get("cookie");
|
||||
return url.searchParams.get("list") === "PLgone"
|
||||
? new Response("", { status: 404 })
|
||||
: html(ytPage(YT_DATA));
|
||||
});
|
||||
const ok = await get("/api/youtube/playlist?list=PLgood");
|
||||
assertEquals(ok.status, 200);
|
||||
assertEquals(ok.body.data.tracks.length, 2);
|
||||
assertEquals(ok.headers.get("cache-control"), "public, max-age=3600");
|
||||
// The pre-answered consent cookie keeps EU requests off the consent page.
|
||||
assertEquals(cookie, "SOCS=CAI");
|
||||
await get("/api/youtube/playlist?list=PLgood"); // cached
|
||||
assertEquals(net.calls.length, 1);
|
||||
|
||||
assertEquals((await get("/api/youtube/playlist?list=bad%20id")).status, 400);
|
||||
assertEquals((await get("/api/youtube/playlist")).status, 400);
|
||||
assertEquals((await get("/api/youtube/playlist?list=PLgone")).status, 502);
|
||||
assertFalse(net.calls.some((u) => u.searchParams.get("list") === "bad id"));
|
||||
});
|
||||
303
api/test/playlists.test.ts
Normal file
303
api/test/playlists.test.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import {
|
||||
count,
|
||||
createUser,
|
||||
db,
|
||||
del,
|
||||
formWithFile,
|
||||
get,
|
||||
insertDump,
|
||||
insertPlaylist,
|
||||
PNG,
|
||||
post,
|
||||
put,
|
||||
test,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals, assertMatch } from "@std/assert";
|
||||
|
||||
function ids(res: { body: { data: { dumps: { id: string }[] } } }): string[] {
|
||||
return res.body.data.dumps.map((d) => d.id);
|
||||
}
|
||||
|
||||
test("POST /api/playlists creates; GET finds it by id or slug", async () => {
|
||||
const user = await createUser();
|
||||
const res = await post("/api/playlists", {
|
||||
auth: user.auth,
|
||||
json: { title: "Road Trip", description: "songs", isPublic: true },
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const { id, slug } = res.body.data;
|
||||
assertMatch(slug, /^road-trip-/);
|
||||
assertEquals(
|
||||
(await get(`/api/playlists/${id}`)).body.data.title,
|
||||
"Road Trip",
|
||||
);
|
||||
const bySlug = await get(`/api/playlists/${slug}`);
|
||||
assertEquals(bySlug.body.data.id, id);
|
||||
assertEquals(bySlug.body.data.ownerUsername, user.username);
|
||||
assertEquals(bySlug.body.data.dumps, []);
|
||||
assertEquals(
|
||||
(await post("/api/playlists", { auth: user.auth, json: { title: "" } }))
|
||||
.status,
|
||||
400,
|
||||
);
|
||||
assertEquals(
|
||||
(await post("/api/playlists", { json: { title: "x", isPublic: true } }))
|
||||
.status,
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
test("private playlists are the owner's alone", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id } = insertPlaylist({ userId: owner.id, isPublic: false });
|
||||
assertEquals((await get(`/api/playlists/${id}`)).status, 404);
|
||||
assertEquals(
|
||||
(await get(`/api/playlists/${id}`, { auth: other.auth })).status,
|
||||
404,
|
||||
);
|
||||
assertEquals(
|
||||
(await get(`/api/playlists/${id}`, { auth: owner.auth })).status,
|
||||
200,
|
||||
);
|
||||
insertPlaylist({ userId: owner.id });
|
||||
const path = `/api/users/${owner.username}/playlists`;
|
||||
assertEquals((await get(path)).body.data.total, 1);
|
||||
assertEquals((await get(path, { auth: owner.auth })).body.data.total, 2);
|
||||
});
|
||||
|
||||
test("adding dumps: newest on top, no duplicates, only dumps you can see", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id } = insertPlaylist({ userId: owner.id });
|
||||
const a = insertDump({ userId: owner.id, title: "a" });
|
||||
const b = insertDump({ userId: other.id, title: "b" });
|
||||
const theirs = insertDump({ userId: other.id, isPrivate: true });
|
||||
|
||||
assertEquals(
|
||||
(await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth }))
|
||||
.status,
|
||||
204,
|
||||
);
|
||||
assertEquals(
|
||||
(await post(`/api/playlists/${id}/dumps/${b.id}`, { auth: owner.auth }))
|
||||
.status,
|
||||
204,
|
||||
);
|
||||
assertEquals(ids(await get(`/api/playlists/${id}`)), [b.id, a.id]);
|
||||
assertEquals(
|
||||
(await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth }))
|
||||
.status,
|
||||
409,
|
||||
);
|
||||
assertEquals(
|
||||
(await post(`/api/playlists/${id}/dumps/${theirs.id}`, {
|
||||
auth: owner.auth,
|
||||
})).status,
|
||||
404,
|
||||
);
|
||||
assertEquals(
|
||||
(await post(`/api/playlists/${id}/dumps/${crypto.randomUUID()}`, {
|
||||
auth: owner.auth,
|
||||
}))
|
||||
.status,
|
||||
404,
|
||||
);
|
||||
assertEquals(
|
||||
(await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: other.auth }))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
assertEquals((await get(`/api/playlists/${id}`)).body.data.dumpCount, 2);
|
||||
});
|
||||
|
||||
test("a public playlist hides its private dumps from everyone but their owner", async () => {
|
||||
const owner = await createUser();
|
||||
const { id } = insertPlaylist({ userId: owner.id });
|
||||
const pub = insertDump({ userId: owner.id });
|
||||
const priv = insertDump({ userId: owner.id, isPrivate: true });
|
||||
for (const d of [pub, priv]) {
|
||||
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
|
||||
}
|
||||
assertEquals(ids(await get(`/api/playlists/${id}`)), [pub.id]);
|
||||
assertEquals(
|
||||
ids(await get(`/api/playlists/${id}`, { auth: owner.auth })).length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("reorder: must name exactly the current members", async () => {
|
||||
const owner = await createUser();
|
||||
const { id } = insertPlaylist({ userId: owner.id });
|
||||
const [a, b, c] = [1, 2, 3].map(() => insertDump({ userId: owner.id }));
|
||||
for (const d of [a, b, c]) {
|
||||
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
|
||||
}
|
||||
const order = [a.id, c.id, b.id];
|
||||
assertEquals(
|
||||
(await put(`/api/playlists/${id}/order`, {
|
||||
auth: owner.auth,
|
||||
json: { dumpIds: order },
|
||||
}))
|
||||
.status,
|
||||
200,
|
||||
);
|
||||
assertEquals(ids(await get(`/api/playlists/${id}`)), order);
|
||||
for (const dumpIds of [[a.id, b.id], [a.id, b.id, crypto.randomUUID()]]) {
|
||||
assertEquals(
|
||||
(await put(`/api/playlists/${id}/order`, {
|
||||
auth: owner.auth,
|
||||
json: { dumpIds },
|
||||
}))
|
||||
.status,
|
||||
400,
|
||||
JSON.stringify(dumpIds),
|
||||
);
|
||||
}
|
||||
// A dump added after reordering still lands on top.
|
||||
const d = insertDump({ userId: owner.id });
|
||||
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
|
||||
assertEquals(ids(await get(`/api/playlists/${id}`))[0], d.id);
|
||||
});
|
||||
|
||||
test("reorder: a list naming a member twice is not the current members", async () => {
|
||||
const owner = await createUser();
|
||||
const { id } = insertPlaylist({ userId: owner.id });
|
||||
const [a, b] = [1, 2].map(() => insertDump({ userId: owner.id }));
|
||||
for (const d of [a, b]) {
|
||||
await post(`/api/playlists/${id}/dumps/${d.id}`, { auth: owner.auth });
|
||||
}
|
||||
const res = await put(`/api/playlists/${id}/order`, {
|
||||
auth: owner.auth,
|
||||
json: { dumpIds: [a.id, b.id, a.id] },
|
||||
});
|
||||
assertEquals(res.status, 400);
|
||||
});
|
||||
|
||||
test("removing a dump; updating; deleting", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const mod = await createUser({ role: "moderator" });
|
||||
const { id } = insertPlaylist({ userId: owner.id, title: "Old" });
|
||||
const a = insertDump({ userId: owner.id });
|
||||
await post(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth });
|
||||
assertEquals(
|
||||
(await del(`/api/playlists/${id}/dumps/${a.id}`, { auth: other.auth }))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
assertEquals(
|
||||
(await del(`/api/playlists/${id}/dumps/${a.id}`, { auth: owner.auth }))
|
||||
.status,
|
||||
204,
|
||||
);
|
||||
assertEquals(ids(await get(`/api/playlists/${id}`)), []);
|
||||
|
||||
assertEquals(
|
||||
(await put(`/api/playlists/${id}`, {
|
||||
auth: other.auth,
|
||||
json: { title: "x" },
|
||||
})).status,
|
||||
403,
|
||||
);
|
||||
const updated = await put(`/api/playlists/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { title: "New", description: "d", isPublic: false },
|
||||
});
|
||||
assertEquals(updated.body.data.title, "New");
|
||||
assertMatch(updated.body.data.slug, /^new-/);
|
||||
assertEquals(updated.body.data.isPublic, false);
|
||||
const cleared = await put(`/api/playlists/${id}`, {
|
||||
auth: owner.auth,
|
||||
json: { description: null },
|
||||
});
|
||||
assertEquals(cleared.body.data.description, undefined);
|
||||
assertEquals(cleared.body.data.title, "New");
|
||||
|
||||
assertEquals(
|
||||
(await del(`/api/playlists/${id}`, { auth: other.auth })).status,
|
||||
403,
|
||||
);
|
||||
assertEquals(
|
||||
(await del(`/api/playlists/${id}`, { auth: mod.auth })).status,
|
||||
204,
|
||||
);
|
||||
assertEquals(
|
||||
(await get(`/api/playlists/${id}`, { auth: owner.auth })).status,
|
||||
404,
|
||||
);
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM playlist_dumps;`), 0);
|
||||
});
|
||||
|
||||
test("memberships: which of my playlists hold this dump", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const p1 = insertPlaylist({ userId: owner.id, title: "has" });
|
||||
insertPlaylist({ userId: owner.id, title: "hasnt" });
|
||||
insertPlaylist({ userId: other.id, title: "theirs" });
|
||||
const d = insertDump({ userId: owner.id });
|
||||
await post(`/api/playlists/${p1.id}/dumps/${d.id}`, { auth: owner.auth });
|
||||
const res = await get(`/api/playlists/by-dump/${d.id}/memberships`, {
|
||||
auth: owner.auth,
|
||||
});
|
||||
const byTitle = Object.fromEntries(
|
||||
res.body.data.map((
|
||||
m: { playlist: { title: string }; hasDump: boolean },
|
||||
) => [
|
||||
m.playlist.title,
|
||||
m.hasDump,
|
||||
]),
|
||||
);
|
||||
assertEquals(byTitle, { has: true, hasnt: false });
|
||||
});
|
||||
|
||||
test("adding to a followed public playlist notifies followers, private dumps excepted", async () => {
|
||||
const owner = await createUser();
|
||||
const follower = await createUser();
|
||||
const { id } = insertPlaylist({ userId: owner.id, title: "Mix" });
|
||||
await post(`/api/follows/playlists/${id}`, { auth: follower.auth });
|
||||
db.exec(`DELETE FROM notifications;`); // the follow notified the owner
|
||||
const priv = insertDump({ userId: owner.id, isPrivate: true });
|
||||
await post(`/api/playlists/${id}/dumps/${priv.id}`, { auth: owner.auth });
|
||||
assertEquals(count(`SELECT COUNT(*) as n FROM notifications;`), 0);
|
||||
const pub = insertDump({ userId: owner.id, title: "Song" });
|
||||
await post(`/api/playlists/${id}/dumps/${pub.id}`, { auth: owner.auth });
|
||||
const note = db.prepare(`SELECT user_id, type, data FROM notifications;`)
|
||||
.get() as {
|
||||
user_id: string;
|
||||
type: string;
|
||||
data: string;
|
||||
};
|
||||
assertEquals(note.user_id, follower.id);
|
||||
assertEquals(note.type, "playlist_dump_added");
|
||||
assertEquals(JSON.parse(note.data), {
|
||||
dumpId: pub.id,
|
||||
dumpTitle: "Song",
|
||||
playlistId: id,
|
||||
playlistTitle: "Mix",
|
||||
});
|
||||
});
|
||||
|
||||
test("playlist images: upload and serve", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id, slug } = insertPlaylist({ userId: owner.id });
|
||||
assertEquals((await get(`/api/playlists/${id}/image`)).status, 404);
|
||||
assertEquals(
|
||||
(await post(`/api/playlists/${id}/image`, {
|
||||
auth: other.auth,
|
||||
form: formWithFile(PNG),
|
||||
}))
|
||||
.status,
|
||||
403,
|
||||
);
|
||||
const res = await post(`/api/playlists/${slug}/image`, {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.imageMime, "image/png");
|
||||
const served = await get(`/api/playlists/${slug}/image`);
|
||||
assertEquals(served.status, 200);
|
||||
assertEquals(served.bytes, PNG);
|
||||
});
|
||||
146
api/test/public-address.test.ts
Normal file
146
api/test/public-address.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import "./setup.ts";
|
||||
import { assert, assertEquals, assertFalse, assertRejects } from "@std/assert";
|
||||
import {
|
||||
isPrivateAddress,
|
||||
isPrivateHostname,
|
||||
resolvePublicAddress,
|
||||
} from "../lib/public-address.ts";
|
||||
import { stubNetwork } from "./helpers.ts";
|
||||
|
||||
Deno.test("isPrivateAddress: private and reserved IPv4 ranges", () => {
|
||||
for (
|
||||
const ip of [
|
||||
"0.0.0.0",
|
||||
"10.1.2.3",
|
||||
"127.0.0.1",
|
||||
"100.64.0.1",
|
||||
"100.127.255.255",
|
||||
"169.254.169.254",
|
||||
"172.16.0.1",
|
||||
"172.31.255.255",
|
||||
"192.0.0.8",
|
||||
"192.168.1.1",
|
||||
"198.18.0.1",
|
||||
"224.0.0.1",
|
||||
"255.255.255.255",
|
||||
]
|
||||
) {
|
||||
assert(isPrivateAddress(ip), ip);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("isPrivateAddress: public IPv4 addresses", () => {
|
||||
for (
|
||||
const ip of [
|
||||
"8.8.8.8",
|
||||
"93.184.216.34",
|
||||
"100.63.255.255",
|
||||
"100.128.0.0",
|
||||
"172.15.255.255",
|
||||
"172.32.0.0",
|
||||
"192.169.0.1",
|
||||
"223.255.255.255",
|
||||
]
|
||||
) {
|
||||
assertFalse(isPrivateAddress(ip), ip);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("isPrivateAddress: IPv6, including IPv4 carried inside it", () => {
|
||||
for (
|
||||
const ip of [
|
||||
"::",
|
||||
"::1",
|
||||
"fc00::1",
|
||||
"fd12:3456::1",
|
||||
"fe80::1%eth0",
|
||||
"fec0::1",
|
||||
"ff02::1",
|
||||
"::ffff:127.0.0.1",
|
||||
"::ffff:7f00:1",
|
||||
"::ffff:10.0.0.1",
|
||||
"64:ff9b::a00:1", // NAT64 of 10.0.0.1
|
||||
"2002:a00:1::", // 6to4 of 10.0.0.1
|
||||
"::10.0.0.1",
|
||||
]
|
||||
) {
|
||||
assert(isPrivateAddress(ip), ip);
|
||||
}
|
||||
for (
|
||||
const ip of [
|
||||
"2606:4700:4700::1111",
|
||||
"::ffff:8.8.8.8",
|
||||
"64:ff9b::808:808",
|
||||
"2002:808:808::",
|
||||
]
|
||||
) {
|
||||
assertFalse(isPrivateAddress(ip), ip);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("isPrivateAddress: anything that isn't an IP is not an address", () => {
|
||||
for (
|
||||
const s of ["example.com", "1.2.3", "1.2.3.4.5", "256.1.1.1", "1::2::3", ""]
|
||||
) {
|
||||
assertFalse(isPrivateAddress(s), s);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("isPrivateHostname: localhost names and bracketed literals", () => {
|
||||
assert(isPrivateHostname(new URL("http://localhost:8000/")));
|
||||
assert(isPrivateHostname(new URL("http://app.localhost/")));
|
||||
assert(isPrivateHostname(new URL("http://[::1]/")));
|
||||
assert(isPrivateHostname(new URL("http://127.0.0.1/")));
|
||||
// WHATWG URL parsing normalises these to 127.0.0.1 before we see them.
|
||||
assert(isPrivateHostname(new URL("http://0x7f000001/")));
|
||||
assert(isPrivateHostname(new URL("http://2130706433/")));
|
||||
assertFalse(isPrivateHostname(new URL("https://example.com/")));
|
||||
});
|
||||
|
||||
Deno.test("resolvePublicAddress: IP literals skip DNS", async () => {
|
||||
using net = stubNetwork(() => new Response());
|
||||
assertEquals(
|
||||
await resolvePublicAddress(new URL("https://8.8.8.8/")),
|
||||
"8.8.8.8",
|
||||
);
|
||||
assertEquals(
|
||||
await resolvePublicAddress(new URL("https://[2606:4700::1]/")),
|
||||
"2606:4700::1",
|
||||
);
|
||||
assertEquals(net.calls.length, 0);
|
||||
});
|
||||
|
||||
Deno.test("resolvePublicAddress: refuses private literals without resolving", async () => {
|
||||
await assertRejects(
|
||||
() => resolvePublicAddress(new URL("http://169.254.169.254/latest")),
|
||||
Error,
|
||||
"private",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("resolvePublicAddress: a name resolving anywhere private is refused", async () => {
|
||||
using _ = stubNetwork(() => new Response(), {
|
||||
"internal.example": ["10.0.0.5"],
|
||||
"mixed.example": ["93.184.216.34", "127.0.0.1"],
|
||||
"nowhere.example": [],
|
||||
});
|
||||
await assertRejects(
|
||||
() => resolvePublicAddress(new URL("http://internal.example/")),
|
||||
Error,
|
||||
"resolves to 10.0.0.5",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolvePublicAddress(new URL("http://mixed.example/")),
|
||||
Error,
|
||||
"resolves to 127.0.0.1",
|
||||
);
|
||||
await assertRejects(
|
||||
() => resolvePublicAddress(new URL("http://nowhere.example/")),
|
||||
Error,
|
||||
"Could not resolve",
|
||||
);
|
||||
assertEquals(
|
||||
await resolvePublicAddress(new URL("http://public.example/")),
|
||||
"93.184.216.34",
|
||||
);
|
||||
});
|
||||
382
api/test/rich-content.test.ts
Normal file
382
api/test/rich-content.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
// Link previews: the HTML extractors, the providers, and fetchWithTimeout's
|
||||
// guard on every hop. All network access is stubbed.
|
||||
import { createUser, html, insertDump, stubNetwork, test } from "./helpers.ts";
|
||||
import {
|
||||
assert,
|
||||
assertEquals,
|
||||
assertFalse,
|
||||
assertRejects,
|
||||
assertStringIncludes,
|
||||
} from "@std/assert";
|
||||
import {
|
||||
extractBestIcon,
|
||||
extractFirstContentImage,
|
||||
extractJsonLd,
|
||||
extractLargeImage,
|
||||
extractLinkHref,
|
||||
extractMaskIconColor,
|
||||
extractMetaName,
|
||||
extractOgTag,
|
||||
extractPageTitle,
|
||||
extractThemeColor,
|
||||
fetchRichContent,
|
||||
fetchWithTimeout,
|
||||
isValidHttpUrl,
|
||||
normalizeCssColor,
|
||||
tryFetchRichContent,
|
||||
} from "../services/rich-content-service.ts";
|
||||
|
||||
const BASE = "https://example.com/articles/1";
|
||||
|
||||
Deno.test("extractOgTag / extractMetaName: either attribute order, entities decoded", () => {
|
||||
const page = `
|
||||
<meta property="og:title" content="Tom & Jerry — ❤">
|
||||
<meta content='Desc' property='og:description'>
|
||||
<meta name="twitter:title" content="TW">
|
||||
<meta name="og:title" content="wrong attribute">`;
|
||||
assertEquals(extractOgTag(page, "title"), "Tom & Jerry — ❤");
|
||||
assertEquals(extractOgTag(page, "description"), "Desc");
|
||||
assertEquals(extractOgTag(page, "image"), undefined);
|
||||
assertEquals(extractMetaName(page, "twitter:title"), "TW");
|
||||
});
|
||||
|
||||
Deno.test("extractOgTag: content never spans into the next tag", () => {
|
||||
const page = `<meta property="og:title"><meta name="x" content="leak">`;
|
||||
assertEquals(extractOgTag(page, "title"), undefined);
|
||||
});
|
||||
|
||||
Deno.test("extractPageTitle", () => {
|
||||
assertEquals(
|
||||
extractPageTitle("<title lang=en> A <b> </title>"),
|
||||
"A <b>",
|
||||
);
|
||||
assertEquals(extractPageTitle("<p>none</p>"), undefined);
|
||||
});
|
||||
|
||||
Deno.test("extractThemeColor: unscoped, then light, then any", () => {
|
||||
assertEquals(
|
||||
extractThemeColor(`
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">
|
||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#fff">`),
|
||||
"#fff",
|
||||
);
|
||||
assertEquals(
|
||||
extractThemeColor(`
|
||||
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">
|
||||
<meta name="theme-color" content="#123">`),
|
||||
"#123",
|
||||
);
|
||||
assertEquals(
|
||||
extractThemeColor(
|
||||
`<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000">`,
|
||||
),
|
||||
"#000",
|
||||
);
|
||||
assertEquals(
|
||||
extractMaskIconColor(
|
||||
`<link rel="mask-icon" href="/m.svg" color="#5bbad5">`,
|
||||
),
|
||||
"#5bbad5",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("normalizeCssColor", () => {
|
||||
assertEquals(normalizeCssColor("#ABC"), "#aabbcc");
|
||||
assertEquals(normalizeCssColor("#abcd"), "#aabbcc");
|
||||
assertEquals(normalizeCssColor("#112233"), "#112233");
|
||||
assertEquals(normalizeCssColor("#11223380"), "#112233");
|
||||
assertEquals(normalizeCssColor(" Navy "), "#000080");
|
||||
assertEquals(normalizeCssColor("rgb(255, 0, 128)"), "#ff0080");
|
||||
assertEquals(normalizeCssColor("rgba(300 -5 50% / .5)"), "#ff0080");
|
||||
assertEquals(normalizeCssColor("#12345"), undefined);
|
||||
assertEquals(normalizeCssColor("hsl(0 100% 50%)"), undefined);
|
||||
assertEquals(normalizeCssColor("rgb(1,2)"), undefined);
|
||||
assertEquals(normalizeCssColor(undefined), undefined);
|
||||
});
|
||||
|
||||
Deno.test("extractJsonLd: @graph, arrays and image shapes", () => {
|
||||
const ld = (data: unknown) =>
|
||||
`<script type="application/ld+json">${JSON.stringify(data)}</script>`;
|
||||
assertEquals(
|
||||
extractJsonLd(
|
||||
ld({
|
||||
"@graph": [{ "@type": "WebSite" }, {
|
||||
headline: "Head",
|
||||
image: { url: "https://i/1.jpg" },
|
||||
}],
|
||||
}),
|
||||
),
|
||||
{ title: "Head", description: undefined, thumbnailUrl: "https://i/1.jpg" },
|
||||
);
|
||||
assertEquals(
|
||||
extractJsonLd(ld({ name: ["N"], image: ["https://i/2.jpg"] })).thumbnailUrl,
|
||||
"https://i/2.jpg",
|
||||
);
|
||||
assertEquals(
|
||||
extractJsonLd(ld({ name: "N", image: "/relative.jpg" })).thumbnailUrl,
|
||||
undefined,
|
||||
);
|
||||
// Invalid JSON is skipped for the next block.
|
||||
assertEquals(
|
||||
extractJsonLd(
|
||||
`<script type="application/ld+json">{oops</script>${
|
||||
ld({ name: "Second" })
|
||||
}`,
|
||||
).title,
|
||||
"Second",
|
||||
);
|
||||
assertEquals(extractJsonLd("<p></p>"), {});
|
||||
});
|
||||
|
||||
Deno.test("image extraction: large images, content images, relative URLs", () => {
|
||||
const page = `
|
||||
<img src="data:image/png;base64,xx" width="500" height="500">
|
||||
<img src="/small.jpg" width="50" height="50">
|
||||
<img src="/img/site-logo.png">
|
||||
<img src="/icons/sprite.svg">
|
||||
<img src="/photos/cat.jpg" width="640" height="480">`;
|
||||
assertEquals(
|
||||
extractLargeImage(page, BASE),
|
||||
"https://example.com/photos/cat.jpg",
|
||||
);
|
||||
assertEquals(
|
||||
extractFirstContentImage(page, BASE),
|
||||
"https://example.com/small.jpg",
|
||||
);
|
||||
assertEquals(
|
||||
extractFirstContentImage(`<img src="//cdn.test/a.jpg">`, BASE),
|
||||
"https://cdn.test/a.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("extractBestIcon: SVG beats everything, else the largest", () => {
|
||||
assertEquals(
|
||||
extractBestIcon(
|
||||
`<link rel="icon" href="/16.png" sizes="16x16">
|
||||
<link rel="apple-touch-icon" href="/180.png" sizes="180x180">
|
||||
<link rel="stylesheet" href="/x.css">`,
|
||||
BASE,
|
||||
),
|
||||
"https://example.com/180.png",
|
||||
);
|
||||
assertEquals(
|
||||
extractBestIcon(
|
||||
`<link rel="icon" href="/180.png" sizes="180x180">
|
||||
<link rel="icon" type="image/svg+xml" href="/icon">`,
|
||||
BASE,
|
||||
),
|
||||
"https://example.com/icon",
|
||||
);
|
||||
assertEquals(extractBestIcon("<p></p>", BASE), undefined);
|
||||
assertEquals(
|
||||
extractLinkHref(`<link href="/feed" rel="alternate">`, "alternate", BASE),
|
||||
"https://example.com/feed",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("isValidHttpUrl: http(s) to public hosts, or our own pages", () => {
|
||||
assert(isValidHttpUrl("https://example.com/x"));
|
||||
assert(isValidHttpUrl("http://gerbeur.test/dumps/abc"));
|
||||
assertFalse(isValidHttpUrl("http://localhost:8000/"));
|
||||
assertFalse(isValidHttpUrl("http://10.0.0.1/"));
|
||||
assertFalse(isValidHttpUrl("file:///etc/passwd"));
|
||||
assertFalse(isValidHttpUrl("nope"));
|
||||
});
|
||||
|
||||
Deno.test("fetchWithTimeout: follows redirects, checking every hop", async () => {
|
||||
using net = stubNetwork((url) => {
|
||||
if (url.pathname === "/start") {
|
||||
return new Response(null, { status: 302, headers: { location: "/end" } });
|
||||
}
|
||||
if (url.pathname === "/to-private") {
|
||||
return new Response(null, {
|
||||
status: 301,
|
||||
headers: { location: "http://169.254.169.254/latest/meta-data" },
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/loop") {
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "/loop" },
|
||||
});
|
||||
}
|
||||
return new Response("done");
|
||||
});
|
||||
const res = await fetchWithTimeout("https://example.com/start");
|
||||
assertEquals(await res.text(), "done");
|
||||
assertEquals(net.calls.map((u) => u.pathname), ["/start", "/end"]);
|
||||
|
||||
await assertRejects(
|
||||
() => fetchWithTimeout("https://example.com/to-private"),
|
||||
Error,
|
||||
"private",
|
||||
);
|
||||
await assertRejects(
|
||||
() => fetchWithTimeout("https://example.com/loop"),
|
||||
TypeError,
|
||||
"Too many redirects",
|
||||
);
|
||||
await assertRejects(
|
||||
() => fetchWithTimeout("ftp://example.com/"),
|
||||
TypeError,
|
||||
"Unsupported protocol",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("generic provider: the full fallback chain", async () => {
|
||||
using _ = stubNetwork(() =>
|
||||
html(`<html><head>
|
||||
<title>Page title</title>
|
||||
<meta property="og:site_name" content="Example">
|
||||
<meta property="og:title" content="OG title">
|
||||
<meta property="og:image" content="https://example.com/og.jpg">
|
||||
<meta name="description" content="Plain description">
|
||||
<meta name="theme-color" content="rgb(0, 128, 255)">
|
||||
<link rel="icon" href="/favicon-32.png" sizes="32x32">
|
||||
</head></html>`)
|
||||
);
|
||||
const content = await fetchRichContent(BASE);
|
||||
assertEquals(content, {
|
||||
type: "generic",
|
||||
url: BASE,
|
||||
title: "OG title",
|
||||
description: "Plain description",
|
||||
thumbnailUrl: "https://example.com/og.jpg",
|
||||
faviconUrl: "https://example.com/favicon-32.png",
|
||||
accentColor: "#0080ff",
|
||||
siteName: "Example",
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("generic provider: a site-wide og:url means og:title/image are ignored", async () => {
|
||||
using _ = stubNetwork(() =>
|
||||
html(`
|
||||
<meta property="og:url" content="https://example.com/">
|
||||
<meta property="og:title" content="Site name">
|
||||
<meta property="og:image" content="https://example.com/site.jpg">
|
||||
<meta name="twitter:title" content="Article title">
|
||||
<img src="/photo.jpg">`)
|
||||
);
|
||||
const content = await fetchRichContent("https://www.example.com/articles/1");
|
||||
assertEquals(content?.title, "Article title");
|
||||
assertEquals(content?.thumbnailUrl, "https://www.example.com/photo.jpg");
|
||||
assertEquals(content?.siteName, "example.com");
|
||||
assertEquals(content?.faviconUrl, "https://www.example.com/favicon.ico");
|
||||
});
|
||||
|
||||
Deno.test("generic provider: non-HTML responses carry just the URL", async () => {
|
||||
using _ = stubNetwork(() =>
|
||||
new Response("{}", { headers: { "content-type": "application/json" } })
|
||||
);
|
||||
assertEquals(await fetchRichContent(BASE), { type: "generic", url: BASE });
|
||||
});
|
||||
|
||||
Deno.test("tryFetchRichContent: an unreachable page is ok:false with a stub", async () => {
|
||||
using _ = stubNetwork(() => {
|
||||
throw new TypeError("connection refused");
|
||||
});
|
||||
assertEquals(await tryFetchRichContent("https://www.example.com/x"), {
|
||||
ok: false,
|
||||
content: {
|
||||
type: "generic",
|
||||
url: "https://www.example.com/x",
|
||||
siteName: "example.com",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("youtube provider: video with start time and playlist", async () => {
|
||||
using net = stubNetwork((url) =>
|
||||
url.pathname === "/oembed"
|
||||
? Response.json({ title: "Never Gonna", thumbnail_url: "ignored" })
|
||||
: new Response(null, { status: 404 })
|
||||
);
|
||||
const content = await fetchRichContent(
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL123&t=1m30s",
|
||||
);
|
||||
assertEquals(content?.type, "youtube");
|
||||
assertEquals(content?.videoId, "dQw4w9WgXcQ");
|
||||
assertEquals(content?.title, "Never Gonna");
|
||||
assertEquals(
|
||||
content?.thumbnailUrl,
|
||||
"https://img.youtube.com/vi/dQw4w9WgXcQ/hqdefault.jpg",
|
||||
);
|
||||
assertEquals(
|
||||
content?.embedUrl,
|
||||
"https://www.youtube.com/embed/dQw4w9WgXcQ?rel=0&list=PL123&start=90",
|
||||
);
|
||||
assertEquals(net.calls[0].hostname, "www.youtube.com");
|
||||
});
|
||||
|
||||
Deno.test("youtube provider: youtu.be, shorts, playlists and channels", async () => {
|
||||
using _ = stubNetwork((url) =>
|
||||
url.pathname === "/oembed"
|
||||
? Response.json({
|
||||
title: "T",
|
||||
thumbnail_url: "https://i.ytimg.com/x.jpg",
|
||||
})
|
||||
: html(`<meta property="og:title" content="Channel">
|
||||
<meta property="og:image" content="https://yt3/ch.jpg">`)
|
||||
);
|
||||
assertEquals(
|
||||
(await fetchRichContent("https://youtu.be/abc123?t=42"))?.embedUrl,
|
||||
"https://www.youtube.com/embed/abc123?rel=0&start=42",
|
||||
);
|
||||
assertEquals(
|
||||
(await fetchRichContent("https://youtube.com/shorts/xyz"))?.videoId,
|
||||
"xyz",
|
||||
);
|
||||
const list = await fetchRichContent(
|
||||
"https://www.youtube.com/playlist?list=PLabc",
|
||||
);
|
||||
assertEquals(
|
||||
list?.embedUrl,
|
||||
"https://www.youtube.com/embed/videoseries?list=PLabc&rel=0",
|
||||
);
|
||||
assertEquals(list?.thumbnailUrl, "https://i.ytimg.com/x.jpg");
|
||||
const channel = await fetchRichContent("https://www.youtube.com/@someone");
|
||||
assertEquals(channel?.title, "Channel");
|
||||
assertEquals(channel?.embedUrl, undefined);
|
||||
});
|
||||
|
||||
Deno.test("bandcamp and soundcloud providers", async () => {
|
||||
using _ = stubNetwork(() =>
|
||||
html(`<meta property="og:title" content="Album">
|
||||
<meta property="og:video" content="https://bandcamp.com/EmbeddedPlayer/album=1">`)
|
||||
);
|
||||
const bc = await fetchRichContent("https://artist.bandcamp.com/album/a");
|
||||
assertEquals(bc?.type, "bandcamp");
|
||||
assertEquals(bc?.title, "Album");
|
||||
assertEquals(bc?.embedUrl, "https://bandcamp.com/EmbeddedPlayer/album=1");
|
||||
const sc = await fetchRichContent("https://soundcloud.com/artist/track");
|
||||
assertEquals(sc?.type, "soundcloud");
|
||||
assertStringIncludes(
|
||||
sc?.embedUrl ?? "",
|
||||
encodeURIComponent("https://soundcloud.com/artist/track"),
|
||||
);
|
||||
});
|
||||
|
||||
test("self provider: our own dump, user and playlist pages come from the DB", async () => {
|
||||
const user = await createUser();
|
||||
const dump = insertDump({
|
||||
userId: user.id,
|
||||
title: "Local dump",
|
||||
comment: "About it",
|
||||
richContent: { type: "generic", url: "x", thumbnailUrl: "https://t/x.jpg" },
|
||||
});
|
||||
using net = stubNetwork(() => {
|
||||
throw new Error("no network for self links");
|
||||
});
|
||||
const content = await fetchRichContent(
|
||||
`http://gerbeur.test/dumps/${dump.slug}`,
|
||||
);
|
||||
assertEquals(content?.title, "Local dump");
|
||||
assertEquals(content?.description, "About it");
|
||||
assertEquals(content?.thumbnailUrl, "https://t/x.jpg");
|
||||
assertEquals(content?.siteName, "gerbeur");
|
||||
const profile = await fetchRichContent(
|
||||
`http://gerbeur.test/users/${user.username}`,
|
||||
);
|
||||
assertEquals(profile?.title, user.username);
|
||||
assertEquals(net.calls.length, 0);
|
||||
});
|
||||
31
api/test/setup.ts
Normal file
31
api/test/setup.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Points the app at a throwaway database and uploads directory before any app
|
||||
// module is evaluated. api/config.ts reads the environment and api/model/db.ts
|
||||
// opens the database at import time, so this module must be the first import
|
||||
// of every test file — directly, or as helpers.ts's first import. It imports
|
||||
// nothing from the app itself, and it is synchronous on purpose: a module with
|
||||
// a top-level await doesn't hold back the sibling imports that follow it.
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export const TEST_DIR = Deno.makeTempDirSync({ prefix: "gerbeur-test-" });
|
||||
|
||||
Deno.env.set("GERBEUR_DB_PATH", `${TEST_DIR}/gerbeur.db`);
|
||||
Deno.env.set("GERBEUR_UPLOADS_DIR", `${TEST_DIR}/uploads`);
|
||||
Deno.env.set("GERBEUR_JWT_SECRET", "test-secret-test-secret-test-secret");
|
||||
Deno.env.set("GERBEUR_PUBLIC_URL", "http://gerbeur.test");
|
||||
Deno.env.set("GERBEUR_ALLOWED_ORIGINS", "http://allowed.test");
|
||||
// The suite never sends mail; the routes behave as on an instance without SMTP.
|
||||
Deno.env.delete("GERBEUR_SMTPS_URL");
|
||||
|
||||
// The migrations, the error middleware and failed fetches all log; a passing
|
||||
// run shouldn't drown in them. TEST_VERBOSE=1 brings them back for debugging.
|
||||
if (!Deno.env.get("TEST_VERBOSE")) {
|
||||
console.log = () => {};
|
||||
console.error = () => {};
|
||||
}
|
||||
|
||||
// A fresh database is built from schema.sql, exactly as api/db/init.ts does.
|
||||
const db = new DatabaseSync(`${TEST_DIR}/gerbeur.db`);
|
||||
db.exec(
|
||||
Deno.readTextFileSync(new URL("../db/schema.sql", import.meta.url)),
|
||||
);
|
||||
db.close();
|
||||
284
api/test/social.test.ts
Normal file
284
api/test/social.test.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
// Follows, feeds, notifications and invites.
|
||||
import {
|
||||
count,
|
||||
createUser,
|
||||
db,
|
||||
del,
|
||||
get,
|
||||
insertCategory,
|
||||
insertDump,
|
||||
insertPlaylist,
|
||||
patch,
|
||||
post,
|
||||
test,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals, assertMatch } from "@std/assert";
|
||||
|
||||
function titles(res: { body: { data: { items: { title: string }[] } } }) {
|
||||
return res.body.data.items.map((d) => d.title);
|
||||
}
|
||||
|
||||
test("following users: status, idempotence, no self-follow, one notification", async () => {
|
||||
const me = await createUser();
|
||||
const them = await createUser();
|
||||
assertEquals(
|
||||
(await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status,
|
||||
204,
|
||||
);
|
||||
assertEquals(
|
||||
(await post(`/api/follows/users/${them.id}`, { auth: me.auth })).status,
|
||||
204,
|
||||
);
|
||||
assertEquals(
|
||||
(await post(`/api/follows/users/${me.id}`, { auth: me.auth })).status,
|
||||
400,
|
||||
);
|
||||
assertEquals(
|
||||
(await get("/api/follows/status", { auth: me.auth })).body.data,
|
||||
{ followedUserIds: [them.id], followedPlaylistIds: [] },
|
||||
);
|
||||
// Unfollow and follow again: still just the one notification.
|
||||
await del(`/api/follows/users/${them.id}`, { auth: me.auth });
|
||||
assertEquals(
|
||||
(await get("/api/follows/status", { auth: me.auth })).body.data
|
||||
.followedUserIds,
|
||||
[],
|
||||
);
|
||||
await post(`/api/follows/users/${them.id}`, { auth: me.auth });
|
||||
assertEquals(
|
||||
count(
|
||||
`SELECT COUNT(*) as n FROM notifications WHERE user_id = ? AND type = 'user_followed';`,
|
||||
them.id,
|
||||
),
|
||||
1,
|
||||
);
|
||||
const list = await get(`/api/users/${me.username}/followed-users`);
|
||||
assertEquals(list.body.data.items.map((u: { id: string }) => u.id), [
|
||||
them.id,
|
||||
]);
|
||||
assertEquals(list.body.data.items[0].email, undefined);
|
||||
});
|
||||
|
||||
test("following playlists: public ones only, and not your own notification", async () => {
|
||||
const owner = await createUser();
|
||||
const me = await createUser();
|
||||
const pub = insertPlaylist({ userId: owner.id });
|
||||
const priv = insertPlaylist({ userId: owner.id, isPublic: false });
|
||||
assertEquals(
|
||||
(await post(`/api/follows/playlists/${pub.id}`, { auth: me.auth })).status,
|
||||
204,
|
||||
);
|
||||
assertEquals(
|
||||
(await post(`/api/follows/playlists/${priv.id}`, { auth: me.auth })).status,
|
||||
403,
|
||||
);
|
||||
assertEquals(
|
||||
(await post(`/api/follows/playlists/${crypto.randomUUID()}`, {
|
||||
auth: me.auth,
|
||||
})).status,
|
||||
404,
|
||||
);
|
||||
await post(`/api/follows/playlists/${pub.id}`, { auth: owner.auth });
|
||||
assertEquals(
|
||||
count(
|
||||
`SELECT COUNT(*) as n FROM notifications WHERE type = 'playlist_followed';`,
|
||||
),
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
(await get(`/api/users/${me.username}/followed-playlists`)).body.data
|
||||
.items[0].id,
|
||||
pub.id,
|
||||
);
|
||||
await del(`/api/follows/playlists/${pub.id}`, { auth: me.auth });
|
||||
assertEquals(
|
||||
(await get("/api/follows/status", { auth: me.auth })).body.data
|
||||
.followedPlaylistIds,
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("followed-users feed: public dumps of the people I follow, newest first", async () => {
|
||||
const me = await createUser();
|
||||
const followed = await createUser();
|
||||
const stranger = await createUser();
|
||||
const music = insertCategory({ slug: "music" });
|
||||
insertDump({
|
||||
userId: followed.id,
|
||||
title: "old",
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
});
|
||||
const tagged = insertDump({
|
||||
userId: followed.id,
|
||||
title: "new",
|
||||
createdAt: "2024-02-01T00:00:00Z",
|
||||
});
|
||||
db.prepare(
|
||||
`INSERT INTO dump_categories (dump_id, category_id) VALUES (?, ?);`,
|
||||
)
|
||||
.run(tagged.id, music);
|
||||
insertDump({ userId: followed.id, title: "private", isPrivate: true });
|
||||
insertDump({ userId: stranger.id, title: "stranger" });
|
||||
await post(`/api/follows/users/${followed.id}`, { auth: me.auth });
|
||||
|
||||
const feed = await get("/api/follows/feed/users", { auth: me.auth });
|
||||
assertEquals(titles(feed), ["new", "old"]);
|
||||
assertEquals(feed.body.data.items[0].categoryIds, [music]);
|
||||
assertEquals(
|
||||
titles(
|
||||
await get("/api/follows/feed/users?category=music", { auth: me.auth }),
|
||||
),
|
||||
["new"],
|
||||
);
|
||||
// An unknown category filters everything out rather than nothing.
|
||||
assertEquals(
|
||||
(await get("/api/follows/feed/users?category=nope", { auth: me.auth })).body
|
||||
.data,
|
||||
{ items: [], total: 0, hasMore: false },
|
||||
);
|
||||
assertEquals((await get("/api/follows/feed/users")).status, 401);
|
||||
});
|
||||
|
||||
test("followed-playlists feed: each dump once, public playlists only", async () => {
|
||||
const me = await createUser();
|
||||
const owner = await createUser();
|
||||
const p1 = insertPlaylist({ userId: owner.id });
|
||||
const p2 = insertPlaylist({ userId: owner.id });
|
||||
const d = insertDump({ userId: owner.id, title: "shared" });
|
||||
const priv = insertDump({
|
||||
userId: owner.id,
|
||||
title: "private",
|
||||
isPrivate: true,
|
||||
});
|
||||
for (const p of [p1, p2]) {
|
||||
await post(`/api/follows/playlists/${p.id}`, { auth: me.auth });
|
||||
await post(`/api/playlists/${p.id}/dumps/${d.id}`, { auth: owner.auth });
|
||||
}
|
||||
await post(`/api/playlists/${p1.id}/dumps/${priv.id}`, { auth: owner.auth });
|
||||
const feed = await get("/api/follows/feed/playlists", { auth: me.auth });
|
||||
assertEquals(titles(feed), ["shared"]);
|
||||
assertEquals(feed.body.data.total, 1);
|
||||
|
||||
// A playlist gone private drops out of the feed.
|
||||
db.prepare(`UPDATE playlists SET is_public = 0;`).run();
|
||||
assertEquals(
|
||||
(await get("/api/follows/feed/playlists", { auth: me.auth })).body.data
|
||||
.total,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("notifications: listed newest first, marked read one or all, never someone else's", async () => {
|
||||
const me = await createUser();
|
||||
const other = await createUser();
|
||||
const { id: dumpId } = insertDump({ userId: me.id, title: "Mine" });
|
||||
// Two notifications for me, one for other.
|
||||
await post(`/api/follows/users/${me.id}`, { auth: other.auth });
|
||||
await post(`/api/dumps/${dumpId}/comments`, {
|
||||
auth: other.auth,
|
||||
json: { body: "hey" },
|
||||
});
|
||||
await post(`/api/follows/users/${other.id}`, { auth: me.auth });
|
||||
|
||||
const list = await get("/api/notifications", { auth: me.auth });
|
||||
assertEquals(list.body.data.total, 2);
|
||||
assertEquals(
|
||||
list.body.data.items.map((n: { type: string }) => n.type),
|
||||
["dump_commented", "user_followed"],
|
||||
);
|
||||
assertEquals(
|
||||
list.body.data.items.every((n: { read: boolean }) => !n.read),
|
||||
true,
|
||||
);
|
||||
|
||||
const theirs =
|
||||
(await get("/api/notifications", { auth: other.auth })).body.data
|
||||
.items[0].id;
|
||||
assertEquals(
|
||||
(await patch(`/api/notifications/${theirs}/read`, { auth: me.auth }))
|
||||
.status,
|
||||
204,
|
||||
);
|
||||
assertEquals(
|
||||
(await get("/api/notifications", { auth: other.auth })).body.data.items[0]
|
||||
.read,
|
||||
false,
|
||||
);
|
||||
|
||||
const first = list.body.data.items[0].id;
|
||||
await patch(`/api/notifications/${first}/read`, { auth: me.auth });
|
||||
const after =
|
||||
(await get("/api/notifications", { auth: me.auth })).body.data.items;
|
||||
assertEquals(after.map((n: { read: boolean }) => n.read), [true, false]);
|
||||
|
||||
assertEquals(
|
||||
(await post("/api/notifications/read-all", { auth: me.auth })).status,
|
||||
204,
|
||||
);
|
||||
assertEquals(
|
||||
count(
|
||||
`SELECT COUNT(*) as n FROM notifications WHERE user_id = ? AND read = 0;`,
|
||||
me.id,
|
||||
),
|
||||
0,
|
||||
);
|
||||
assertEquals((await get("/api/notifications")).status, 401);
|
||||
});
|
||||
|
||||
test("mentions: notify the named, once per context, never the author or an email domain", async () => {
|
||||
const author = await createUser();
|
||||
const named = await createUser();
|
||||
const { id: dumpId } = insertDump({ userId: author.id, title: "Topic" });
|
||||
const body = `hi @${named.username.toUpperCase()} and @${named.username}, ` +
|
||||
`me @${author.username}, mail x@${named.username}.com, ` +
|
||||
`[@${named.username}](link) @nobody_known`;
|
||||
const c = await post(`/api/dumps/${dumpId}/comments`, {
|
||||
auth: author.auth,
|
||||
json: { body },
|
||||
});
|
||||
const notes = db.prepare(
|
||||
`SELECT user_id, data FROM notifications WHERE type = 'user_mentioned';`,
|
||||
).all() as { user_id: string; data: string }[];
|
||||
assertEquals(notes.length, 1);
|
||||
assertEquals(notes[0].user_id, named.id);
|
||||
assertEquals(JSON.parse(notes[0].data), {
|
||||
mentionerId: author.id,
|
||||
mentionerUsername: author.username,
|
||||
contextType: "comment",
|
||||
contextId: c.body.data.id,
|
||||
contextTitle: "Topic",
|
||||
dumpId,
|
||||
});
|
||||
// Editing the comment doesn't notify a second time.
|
||||
await patch(`/api/comments/${c.body.data.id}`, {
|
||||
auth: author.auth,
|
||||
json: { body: `again @${named.username}` },
|
||||
});
|
||||
assertEquals(
|
||||
count(
|
||||
`SELECT COUNT(*) as n FROM notifications WHERE type = 'user_mentioned';`,
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("invites: anyone signed in can make one; it validates until used or stale", async () => {
|
||||
const user = await createUser();
|
||||
assertEquals((await post("/api/invites")).status, 401);
|
||||
const res = await post("/api/invites", { auth: user.auth });
|
||||
assertEquals(res.status, 201);
|
||||
const token = res.body.data.token;
|
||||
assertMatch(token, /^[A-Za-z0-9_-]{22}$/);
|
||||
assertEquals((await get(`/api/invites/${token}`)).status, 200);
|
||||
assertEquals((await get(`/api/invites/unknown`)).status, 404);
|
||||
db.prepare(`UPDATE invites SET used_at = ? WHERE token = ?;`).run(
|
||||
new Date().toISOString(),
|
||||
token,
|
||||
);
|
||||
assertEquals((await get(`/api/invites/${token}`)).status, 404);
|
||||
db.prepare(
|
||||
`UPDATE invites SET used_at = NULL, created_at = ? WHERE token = ?;`,
|
||||
)
|
||||
.run(new Date(Date.now() - 8 * 86400_000).toISOString(), token);
|
||||
assertEquals((await get(`/api/invites/${token}`)).status, 404);
|
||||
});
|
||||
297
api/test/uploads.test.ts
Normal file
297
api/test/uploads.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
// Serving uploaded bytes: dump files (headers, ranges, privacy), video
|
||||
// thumbnails, attachments, avatars.
|
||||
import {
|
||||
count,
|
||||
createUser,
|
||||
db,
|
||||
formWithFile,
|
||||
get,
|
||||
insertDump,
|
||||
PNG,
|
||||
post,
|
||||
test,
|
||||
TEST_DIR,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals, assertStringIncludes } from "@std/assert";
|
||||
|
||||
const BYTES = new TextEncoder().encode("0123456789");
|
||||
|
||||
test("GET /api/files/:id: headers for a safe inline type", async () => {
|
||||
const user = await createUser();
|
||||
const { id } = insertDump({
|
||||
userId: user.id,
|
||||
fileName: "notes.txt",
|
||||
fileMime: "text/plain",
|
||||
fileData: BYTES,
|
||||
});
|
||||
const res = await get(`/api/files/${id}`);
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.bytes, BYTES);
|
||||
assertEquals(res.headers.get("content-type"), "text/plain");
|
||||
assertEquals(res.headers.get("content-length"), "10");
|
||||
assertEquals(res.headers.get("accept-ranges"), "bytes");
|
||||
assertEquals(res.headers.get("x-content-type-options"), "nosniff");
|
||||
assertEquals(res.headers.get("referrer-policy"), "no-referrer");
|
||||
assertStringIncludes(res.headers.get("content-security-policy")!, "sandbox");
|
||||
assertStringIncludes(res.headers.get("content-disposition")!, "inline;");
|
||||
assertEquals(res.headers.get("cache-control"), null);
|
||||
});
|
||||
|
||||
test("GET /api/files/:id: active content downloads instead of rendering", async () => {
|
||||
const user = await createUser();
|
||||
for (const mime of ["text/html", "image/svg+xml", "application/xml"]) {
|
||||
const { id } = insertDump({
|
||||
userId: user.id,
|
||||
fileName: "x",
|
||||
fileMime: mime,
|
||||
});
|
||||
const res = await get(`/api/files/${id}`);
|
||||
assertStringIncludes(
|
||||
res.headers.get("content-disposition")!,
|
||||
"attachment;",
|
||||
mime,
|
||||
);
|
||||
}
|
||||
const pdf = insertDump({
|
||||
userId: user.id,
|
||||
fileName: "a.pdf",
|
||||
fileMime: "application/pdf",
|
||||
});
|
||||
const res = await get(`/api/files/${pdf.id}`);
|
||||
assertStringIncludes(res.headers.get("content-disposition")!, "inline;");
|
||||
assertEquals(res.headers.get("content-security-policy"), null);
|
||||
});
|
||||
|
||||
test("GET /api/files/:id: a non-Latin-1 filename doesn't break the header", async () => {
|
||||
const user = await createUser();
|
||||
const { id } = insertDump({
|
||||
userId: user.id,
|
||||
fileName: 'mix "été" 🎵.mp3',
|
||||
fileMime: "audio/mpeg",
|
||||
});
|
||||
const res = await get(`/api/files/${id}`);
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(
|
||||
res.headers.get("content-disposition"),
|
||||
`inline; filename="mix __t__ __.mp3"; filename*=UTF-8''${
|
||||
encodeURIComponent('mix "été" 🎵.mp3')
|
||||
}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/files/:id: byte ranges", async () => {
|
||||
const user = await createUser();
|
||||
const { id } = insertDump({
|
||||
userId: user.id,
|
||||
fileName: "d",
|
||||
fileData: BYTES,
|
||||
});
|
||||
const range = (r: string) =>
|
||||
get(`/api/files/${id}`, { headers: { Range: r } });
|
||||
|
||||
let res = await range("bytes=2-5");
|
||||
assertEquals(res.status, 206);
|
||||
assertEquals(new TextDecoder().decode(res.bytes), "2345");
|
||||
assertEquals(res.headers.get("content-range"), "bytes 2-5/10");
|
||||
assertEquals(res.headers.get("content-length"), "4");
|
||||
|
||||
res = await range("bytes=7-");
|
||||
assertEquals(new TextDecoder().decode(res.bytes), "789");
|
||||
|
||||
res = await range("bytes=-3");
|
||||
assertEquals(new TextDecoder().decode(res.bytes), "789");
|
||||
assertEquals(res.headers.get("content-range"), "bytes 7-9/10");
|
||||
|
||||
// A suffix longer than the file is the whole file.
|
||||
res = await range("bytes=-100");
|
||||
assertEquals(new TextDecoder().decode(res.bytes), "0123456789");
|
||||
assertEquals(res.headers.get("content-range"), "bytes 0-9/10");
|
||||
|
||||
res = await range("bytes=5-100");
|
||||
assertEquals(new TextDecoder().decode(res.bytes), "56789");
|
||||
|
||||
for (
|
||||
const bad of [
|
||||
"bytes=10-",
|
||||
"bytes=6-2",
|
||||
"bytes=-0",
|
||||
"bytes=-",
|
||||
"items=0-1",
|
||||
"bytes=0-1,3-4",
|
||||
]
|
||||
) {
|
||||
res = await range(bad);
|
||||
assertEquals(res.status, 416, bad);
|
||||
assertEquals(res.headers.get("content-range"), "bytes */10", bad);
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /api/files/:id: private files need the owner's media token", async () => {
|
||||
const owner = await createUser();
|
||||
const other = await createUser();
|
||||
const { id } = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "p",
|
||||
isPrivate: true,
|
||||
});
|
||||
assertEquals((await get(`/api/files/${id}`)).status, 404);
|
||||
assertEquals(
|
||||
(await get(`/api/files/${id}?token=${other.mediaToken}`)).status,
|
||||
404,
|
||||
);
|
||||
assertEquals((await get(`/api/files/${id}?token=garbage`)).status, 404);
|
||||
const res = await get(`/api/files/${id}?token=${owner.mediaToken}`);
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.headers.get("cache-control"), "private");
|
||||
// A legacy session token still works in the query string.
|
||||
assertEquals(
|
||||
(await get(`/api/files/${id}?token=${owner.token}`)).status,
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
test("GET /api/files/:id: bad ids, URL dumps and missing bytes", async () => {
|
||||
const user = await createUser();
|
||||
assertEquals((await get(`/api/files/..%2F..%2Fetc%2Fpasswd`)).status, 400);
|
||||
const url = insertDump({ userId: user.id });
|
||||
assertEquals((await get(`/api/files/${url.id}`)).status, 404);
|
||||
const gone = insertDump({ userId: user.id, fileName: "g" });
|
||||
Deno.removeSync(`${TEST_DIR}/uploads/dumps/${gone.id}`);
|
||||
assertEquals((await get(`/api/files/${gone.id}`)).status, 404);
|
||||
});
|
||||
|
||||
test("GET /api/thumbnails/:id: serves a cached video still, with privacy", async () => {
|
||||
const owner = await createUser();
|
||||
const video = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "v.mp4",
|
||||
fileMime: "video/mp4",
|
||||
});
|
||||
const priv = insertDump({
|
||||
userId: owner.id,
|
||||
fileName: "p.mp4",
|
||||
fileMime: "video/mp4",
|
||||
isPrivate: true,
|
||||
});
|
||||
Deno.mkdirSync(`${TEST_DIR}/uploads/thumbnails`, { recursive: true });
|
||||
for (const { id } of [video, priv]) {
|
||||
Deno.writeFileSync(`${TEST_DIR}/uploads/thumbnails/${id}.jpg`, PNG);
|
||||
}
|
||||
const res = await get(`/api/thumbnails/${video.id}`);
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.headers.get("content-type"), "image/jpeg");
|
||||
assertEquals(
|
||||
res.headers.get("cache-control"),
|
||||
"public, max-age=31536000, immutable",
|
||||
);
|
||||
assertEquals((await get(`/api/thumbnails/${priv.id}`)).status, 404);
|
||||
const own = await get(`/api/thumbnails/${priv.id}?token=${owner.mediaToken}`);
|
||||
assertEquals(own.status, 200);
|
||||
assertEquals(
|
||||
own.headers.get("cache-control"),
|
||||
"private, max-age=31536000, immutable",
|
||||
);
|
||||
|
||||
const text = insertDump({ userId: owner.id, fileName: "t.txt" });
|
||||
assertEquals((await get(`/api/thumbnails/${text.id}`)).status, 404);
|
||||
assertEquals((await get(`/api/thumbnails/nope`)).status, 400);
|
||||
});
|
||||
|
||||
test("attachments: upload an image, serve it back", async () => {
|
||||
const user = await createUser();
|
||||
const res = await post("/api/attachments", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals(res.status, 201);
|
||||
const id = res.body.data.id;
|
||||
const served = await get(`/api/attachments/${id}`);
|
||||
assertEquals(served.status, 200);
|
||||
assertEquals(served.bytes, PNG);
|
||||
assertEquals(served.headers.get("content-type"), "image/png");
|
||||
assertEquals(served.headers.get("x-content-type-options"), "nosniff");
|
||||
assertEquals(served.headers.get("cache-control"), "public, max-age=3600");
|
||||
assertEquals(
|
||||
(await get(`/api/attachments/${crypto.randomUUID()}`)).status,
|
||||
404,
|
||||
);
|
||||
});
|
||||
|
||||
test("attachments: refuses non-images, non-multipart and anonymous uploads", async () => {
|
||||
const user = await createUser();
|
||||
assertEquals(
|
||||
(await post("/api/attachments", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(
|
||||
new TextEncoder().encode("<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("/api/attachments", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
const attachmentId = up.body.data.id;
|
||||
const link = ``;
|
||||
const comment = await post(`/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.body.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.body.data.id);
|
||||
});
|
||||
|
||||
test("avatars: upload, serve, and 404 when there is none", async () => {
|
||||
const user = await createUser();
|
||||
assertEquals((await get(`/api/avatars/${user.id}`)).status, 404);
|
||||
assertEquals((await get(`/api/avatars/${crypto.randomUUID()}`)).status, 404);
|
||||
const res = await post("/api/avatars/me", {
|
||||
auth: user.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.avatarMime, "image/png");
|
||||
assertEquals(res.body.data.passwordHash, undefined);
|
||||
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,
|
||||
);
|
||||
});
|
||||
159
api/test/users.test.ts
Normal file
159
api/test/users.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
createUser,
|
||||
db,
|
||||
del,
|
||||
get,
|
||||
insertDump,
|
||||
patch,
|
||||
test,
|
||||
} from "./helpers.ts";
|
||||
import { assertEquals } from "@std/assert";
|
||||
|
||||
test("public profile: by username or id, without private fields", async () => {
|
||||
const user = await createUser();
|
||||
for (
|
||||
const path of [`/api/users/${user.username}`, `/api/users/by-id/${user.id}`]
|
||||
) {
|
||||
const res = await get(path);
|
||||
assertEquals(res.status, 200, path);
|
||||
assertEquals(res.body.data.username, user.username);
|
||||
assertEquals(res.body.data.email, undefined);
|
||||
assertEquals(res.body.data.passwordHash, undefined);
|
||||
}
|
||||
assertEquals((await get(`/api/users/no_one_here`)).status, 404);
|
||||
const me = await get("/api/users/me", { auth: user.auth });
|
||||
assertEquals(me.body.data.email, user.email);
|
||||
assertEquals(me.body.data.passwordHash, undefined);
|
||||
});
|
||||
|
||||
test("PATCH /api/users/me: description and email only", async () => {
|
||||
const user = await createUser();
|
||||
const taken = await createUser();
|
||||
const res = await patch("/api/users/me", {
|
||||
auth: user.auth,
|
||||
json: { description: "about me", email: "new@example.test" },
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.description, "about me");
|
||||
assertEquals(res.body.data.email, undefined);
|
||||
assertEquals(
|
||||
(await get("/api/users/me", { auth: user.auth })).body.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(`/api/users/${user.id}/role`, {
|
||||
auth: admin.auth,
|
||||
json: { role: "moderator" },
|
||||
});
|
||||
assertEquals(res.status, 200);
|
||||
assertEquals(res.body.data.role, "moderator");
|
||||
assertEquals(res.body.data.email, undefined);
|
||||
// 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("/api/users/search?q=zed");
|
||||
assertEquals(res.body.data.length, 8);
|
||||
assertEquals(res.body.data[0], {
|
||||
id: res.body.data[0].id,
|
||||
username: "zed_0",
|
||||
avatarMime: null,
|
||||
});
|
||||
assertEquals((await get("/api/users/search?q=")).body.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(`/api/users/${root.username}/invitees`)).body.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(`/api/users/${child.username}`);
|
||||
assertEquals(profile.body.data.invitedByUsername, root.username);
|
||||
});
|
||||
182
api/test/validators.test.ts
Normal file
182
api/test/validators.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// Request validators and row guards in model/interfaces.ts and model/db.ts.
|
||||
import "./setup.ts";
|
||||
import { assert, assertEquals, assertFalse } from "@std/assert";
|
||||
import {
|
||||
isAuthPayload,
|
||||
isCreateCategoryRequest,
|
||||
isCreateCommentRequest,
|
||||
isCreatePlaylistRequest,
|
||||
isCreateUrlDumpRequest,
|
||||
isLoginUserRequest,
|
||||
isPasswordResetPayload,
|
||||
isReorderPlaylistRequest,
|
||||
isRole,
|
||||
isUpdateCategoryRequest,
|
||||
isUpdateCommentRequest,
|
||||
isUpdateDumpRequest,
|
||||
isUpdatePlaylistRequest,
|
||||
isUpdateUserRequest,
|
||||
validateRegisterUserRequest,
|
||||
} from "../model/interfaces.ts";
|
||||
import { dumpApiToRow, type DumpRow, dumpRowToApi } from "../model/db.ts";
|
||||
|
||||
Deno.test("isRole", () => {
|
||||
assert(isRole("user") && isRole("moderator") && isRole("admin"));
|
||||
assertFalse(isRole("root"));
|
||||
assertFalse(isRole(undefined));
|
||||
});
|
||||
|
||||
Deno.test("isLoginUserRequest", () => {
|
||||
assert(isLoginUserRequest({ username: "a", password: "b" }));
|
||||
assertFalse(isLoginUserRequest({ username: "a" }));
|
||||
assertFalse(isLoginUserRequest({ username: 1, password: "b" }));
|
||||
assertFalse(isLoginUserRequest(null));
|
||||
});
|
||||
|
||||
Deno.test("validateRegisterUserRequest", () => {
|
||||
const ok = {
|
||||
username: "a_b_1",
|
||||
password: "12345678",
|
||||
email: "a@b.co",
|
||||
inviteToken: "t",
|
||||
};
|
||||
assertEquals(validateRegisterUserRequest(ok), null);
|
||||
assertEquals(
|
||||
validateRegisterUserRequest({ ...ok, email: "a@b" }),
|
||||
"Invalid email address",
|
||||
);
|
||||
assert(
|
||||
validateRegisterUserRequest({ ...ok, username: "" })?.startsWith(
|
||||
"Username",
|
||||
),
|
||||
);
|
||||
assert(
|
||||
validateRegisterUserRequest({ ...ok, username: "é" })?.startsWith(
|
||||
"Username",
|
||||
),
|
||||
);
|
||||
assertEquals(validateRegisterUserRequest({}), "Invalid request");
|
||||
});
|
||||
|
||||
Deno.test("isUpdateUserRequest", () => {
|
||||
assert(isUpdateUserRequest({}));
|
||||
assert(isUpdateUserRequest({ description: null }));
|
||||
assert(isUpdateUserRequest({ description: "hi", email: "a@b.co" }));
|
||||
assertFalse(isUpdateUserRequest({ description: "x".repeat(2001) }));
|
||||
assertFalse(isUpdateUserRequest({ email: "nope" }));
|
||||
assertFalse(isUpdateUserRequest({ username: "no spaces" }));
|
||||
assertFalse(isUpdateUserRequest({ password: "short" }));
|
||||
assertFalse(isUpdateUserRequest({ role: "root" }));
|
||||
assertFalse(isUpdateUserRequest("string"));
|
||||
});
|
||||
|
||||
Deno.test("isAuthPayload refuses purpose-scoped tokens", () => {
|
||||
const base = { userId: "u", username: "n", role: "user", exp: 1 };
|
||||
assert(isAuthPayload(base));
|
||||
assertFalse(isAuthPayload({ ...base, purpose: "media" }));
|
||||
assertFalse(isAuthPayload({ ...base, role: "root" }));
|
||||
assertFalse(isAuthPayload({ ...base, exp: "1" }));
|
||||
assert(isPasswordResetPayload({ purpose: "password-reset", userId: "u" }));
|
||||
assertFalse(isPasswordResetPayload({ purpose: "media", userId: "u" }));
|
||||
});
|
||||
|
||||
Deno.test("comment requests: non-blank, bounded, optional parent", () => {
|
||||
assert(isCreateCommentRequest({ body: "hi" }));
|
||||
assert(isCreateCommentRequest({ body: "hi", parentId: null }));
|
||||
assert(isCreateCommentRequest({ body: "hi", parentId: "p" }));
|
||||
assertFalse(isCreateCommentRequest({ body: " " }));
|
||||
assertFalse(isCreateCommentRequest({ body: "x".repeat(5001) }));
|
||||
assertFalse(isCreateCommentRequest({ body: "hi", parentId: 3 }));
|
||||
assert(isUpdateCommentRequest({ body: "edit" }));
|
||||
assertFalse(isUpdateCommentRequest({ body: "" }));
|
||||
});
|
||||
|
||||
Deno.test("playlist requests", () => {
|
||||
assert(isCreatePlaylistRequest({ title: "t", isPublic: true }));
|
||||
assert(
|
||||
isCreatePlaylistRequest({ title: "t", isPublic: false, description: null }),
|
||||
);
|
||||
assertFalse(isCreatePlaylistRequest({ title: "", isPublic: true }));
|
||||
assertFalse(
|
||||
isCreatePlaylistRequest({ title: "x".repeat(101), isPublic: true }),
|
||||
);
|
||||
assertFalse(isCreatePlaylistRequest({ title: "t" }));
|
||||
assertFalse(
|
||||
isCreatePlaylistRequest({
|
||||
title: "t",
|
||||
isPublic: true,
|
||||
description: "x".repeat(2001),
|
||||
}),
|
||||
);
|
||||
assert(isUpdatePlaylistRequest({}));
|
||||
assert(isUpdatePlaylistRequest({ description: null }));
|
||||
assertFalse(isUpdatePlaylistRequest({ title: "" }));
|
||||
assertFalse(isUpdatePlaylistRequest({ isPublic: "yes" }));
|
||||
assert(isReorderPlaylistRequest({ dumpIds: [] }));
|
||||
assertFalse(isReorderPlaylistRequest({ dumpIds: [1] }));
|
||||
assertFalse(isReorderPlaylistRequest({}));
|
||||
});
|
||||
|
||||
Deno.test("dump requests", () => {
|
||||
assert(isCreateUrlDumpRequest({ url: "https://x" }));
|
||||
assert(
|
||||
isCreateUrlDumpRequest({
|
||||
url: "https://x",
|
||||
title: "t",
|
||||
comment: null,
|
||||
isPrivate: true,
|
||||
categoryIds: ["a"],
|
||||
}),
|
||||
);
|
||||
assertFalse(isCreateUrlDumpRequest({}));
|
||||
assertFalse(
|
||||
isCreateUrlDumpRequest({ url: "https://x", title: "x".repeat(201) }),
|
||||
);
|
||||
assertFalse(
|
||||
isCreateUrlDumpRequest({ url: "https://x", comment: "x".repeat(5001) }),
|
||||
);
|
||||
assertFalse(isCreateUrlDumpRequest({ url: "https://x", isPrivate: "true" }));
|
||||
assertFalse(isCreateUrlDumpRequest({ url: "https://x", categoryIds: [1] }));
|
||||
|
||||
assert(isUpdateDumpRequest({}));
|
||||
assert(isUpdateDumpRequest({ comment: null, url: null }));
|
||||
assertFalse(isUpdateDumpRequest({ title: " " }));
|
||||
assertFalse(isUpdateDumpRequest({ title: "x".repeat(201) }));
|
||||
assertFalse(isUpdateDumpRequest({ categoryIds: "a" }));
|
||||
});
|
||||
|
||||
Deno.test("category requests", () => {
|
||||
assert(isCreateCategoryRequest({ slug: "a", name: "A" }));
|
||||
assert(isCreateCategoryRequest({ slug: "a", name: "A", position: 2 }));
|
||||
assertFalse(isCreateCategoryRequest({ slug: "a" }));
|
||||
assertFalse(isCreateCategoryRequest({ slug: "a", name: "A", position: "2" }));
|
||||
assert(isUpdateCategoryRequest({}));
|
||||
assertFalse(isUpdateCategoryRequest({ name: 1 }));
|
||||
});
|
||||
|
||||
Deno.test("dump rows round-trip through the API shape", () => {
|
||||
const row: DumpRow = {
|
||||
id: "id",
|
||||
kind: "url",
|
||||
title: "t",
|
||||
slug: "t-id",
|
||||
comment: null,
|
||||
user_id: "u",
|
||||
created_at: "2024-01-01T00:00:00.000Z",
|
||||
updated_at: null,
|
||||
url: "https://x",
|
||||
rich_content: JSON.stringify({ type: "generic", url: "https://x" }),
|
||||
file_name: null,
|
||||
file_mime: null,
|
||||
file_size: null,
|
||||
vote_count: 3,
|
||||
comment_count: 2,
|
||||
is_private: 1,
|
||||
custom_thumbnail_mime: null,
|
||||
};
|
||||
const dump = dumpRowToApi(row);
|
||||
assertEquals(dump.isPrivate, true);
|
||||
assertEquals(dump.richContent?.url, "https://x");
|
||||
assertEquals(dump.comment, undefined);
|
||||
assertEquals(dumpApiToRow(dump), row);
|
||||
});
|
||||
545
api/test/ws.test.ts
Normal file
545
api/test/ws.test.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
// The live side over a real socket: presence, votes, likes, chat, and the
|
||||
// broadcasts REST writes trigger. Each test runs its own server.
|
||||
import {
|
||||
app,
|
||||
count,
|
||||
createUser,
|
||||
db,
|
||||
formWithFile,
|
||||
insertComment,
|
||||
insertDump,
|
||||
insertPlaylist,
|
||||
PASSWORD,
|
||||
PNG,
|
||||
post,
|
||||
put,
|
||||
resetDb,
|
||||
type TestUser,
|
||||
} from "./helpers.ts";
|
||||
import { assert, assertEquals, assertExists, assertFalse } from "@std/assert";
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
type Msg = Record<string, any>;
|
||||
|
||||
interface Client {
|
||||
ws: WebSocket;
|
||||
messages: Msg[];
|
||||
/** Resolves with the next unread message of `type` (and matching `where`). */
|
||||
next(type: string, where?: (m: Msg) => boolean): Promise<Msg>;
|
||||
/** Every message of `type` received so far. */
|
||||
all(type: string): Msg[];
|
||||
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 WebSocket(
|
||||
`ws://127.0.0.1:${port}/ws${query}`,
|
||||
{ headers: { origin } } as unknown as string[],
|
||||
);
|
||||
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(type, where = () => true) {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (true) {
|
||||
const hit = messages.find((m) =>
|
||||
!read.has(m) && m.type === type && where(m)
|
||||
);
|
||||
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: (type) => messages.filter((m) => 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),
|
||||
});
|
||||
}
|
||||
|
||||
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: Msg) => 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",
|
||||
success: true,
|
||||
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("/api/dumps", {
|
||||
auth: owner.auth,
|
||||
form: formWithFile(PNG),
|
||||
});
|
||||
assertEquals((await x.next("dump_created")).dump.id, pub.body.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.body.data.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { isPrivate: true },
|
||||
});
|
||||
assertEquals((await x.next("dump_deleted")).dumpId, pub.body.data.id);
|
||||
await put(`/api/dumps/${pub.body.data.id}`, {
|
||||
auth: owner.auth,
|
||||
json: { isPrivate: false },
|
||||
});
|
||||
await x.next("dump_created", (m) => m.dump.id === pub.body.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.body.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("/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.body.data.token);
|
||||
assertFalse(res.body.data.token === user.token);
|
||||
});
|
||||
@@ -7,7 +7,8 @@
|
||||
"serve": "deno task build && deno task icons dist && deno task server:start",
|
||||
"i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1",
|
||||
"i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1",
|
||||
"start": "deno task icons dist && deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts"
|
||||
"start": "deno task icons dist && deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts",
|
||||
"test": "deno test -A api/"
|
||||
},
|
||||
"nodeModulesDir": "auto",
|
||||
"compilerOptions": {
|
||||
@@ -26,6 +27,8 @@
|
||||
},
|
||||
"imports": {
|
||||
"@db/sqlite": "jsr:@db/sqlite@^0.13.0",
|
||||
"@std/assert": "jsr:@std/assert@^1.0.19",
|
||||
"@std/testing": "jsr:@std/testing@^1.0.21",
|
||||
"@oak/oak": "jsr:@oak/oak@^17.2.0",
|
||||
"@panva/jose": "jsr:@panva/jose@^6.2.3",
|
||||
"@tajpouria/cors": "jsr:@tajpouria/cors@^1.2.1",
|
||||
|
||||
30
deno.lock
generated
30
deno.lock
generated
@@ -4,11 +4,15 @@
|
||||
"jsr:@db/sqlite@0.13": "0.13.0",
|
||||
"jsr:@denosaurs/plug@1": "1.1.0",
|
||||
"jsr:@oak/commons@1": "1.0.1",
|
||||
"jsr:@oak/oak@*": "17.2.0",
|
||||
"jsr:@oak/oak@^17.2.0": "17.2.0",
|
||||
"jsr:@panva/jose@^6.2.3": "6.2.3",
|
||||
"jsr:@std/assert@1": "1.0.19",
|
||||
"jsr:@std/assert@^1.0.19": "1.0.19",
|
||||
"jsr:@std/async@^1.5.1": "1.5.1",
|
||||
"jsr:@std/bytes@1": "1.0.6",
|
||||
"jsr:@std/crypto@1": "1.0.5",
|
||||
"jsr:@std/data-structures@^1.1.3": "1.1.3",
|
||||
"jsr:@std/encoding@1": "1.0.10",
|
||||
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
||||
"jsr:@std/fmt@1": "1.0.9",
|
||||
@@ -19,6 +23,7 @@
|
||||
"jsr:@std/path@1": "1.1.4",
|
||||
"jsr:@std/path@1.0": "1.0.9",
|
||||
"jsr:@std/path@^1.1.4": "1.1.4",
|
||||
"jsr:@std/testing@^1.0.21": "1.0.21",
|
||||
"jsr:@tajpouria/cors@^1.2.1": "1.2.1",
|
||||
"npm:@eslint/js@^10.0.1": "10.0.1_eslint@10.6.0__jiti@2.7.0_jiti@2.7.0",
|
||||
"npm:@fontsource-variable/fraunces@5.3.0": "5.3.0",
|
||||
@@ -83,7 +88,7 @@
|
||||
"@oak/commons@1.0.1": {
|
||||
"integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert",
|
||||
"jsr:@std/assert@1",
|
||||
"jsr:@std/bytes",
|
||||
"jsr:@std/crypto",
|
||||
"jsr:@std/encoding@1",
|
||||
@@ -95,7 +100,7 @@
|
||||
"integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1",
|
||||
"dependencies": [
|
||||
"jsr:@oak/commons",
|
||||
"jsr:@std/assert",
|
||||
"jsr:@std/assert@1",
|
||||
"jsr:@std/bytes",
|
||||
"jsr:@std/http",
|
||||
"jsr:@std/media-types",
|
||||
@@ -107,7 +112,13 @@
|
||||
"integrity": "57cc772f01b2ac60f847a7e6ec815059762768186266ed833fb59953e9611312"
|
||||
},
|
||||
"@std/assert@1.0.19": {
|
||||
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e"
|
||||
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/async@1.5.1": {
|
||||
"integrity": "c82455dc70cab29a0adf3ac8075db934785bffdd3902af5afcf25d21a74cfd86"
|
||||
},
|
||||
"@std/bytes@1.0.6": {
|
||||
"integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a"
|
||||
@@ -115,6 +126,9 @@
|
||||
"@std/crypto@1.0.5": {
|
||||
"integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40"
|
||||
},
|
||||
"@std/data-structures@1.1.3": {
|
||||
"integrity": "a5ec67199e79ee5b29123474d93fa9a47701ec08a4ef3bd1b623a36476306c5b"
|
||||
},
|
||||
"@std/encoding@1.0.10": {
|
||||
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
|
||||
},
|
||||
@@ -149,6 +163,14 @@
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/testing@1.0.21": {
|
||||
"integrity": "a0d14b2519005242821565091127c7d6fa96ca1b330e84e9249afcd602ee764f",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.19",
|
||||
"jsr:@std/async",
|
||||
"jsr:@std/data-structures"
|
||||
]
|
||||
},
|
||||
"@tajpouria/cors@1.2.1": {
|
||||
"integrity": "eca42e4fb7cb3906ef0ee3d1e565dd6bb4632ccd8e70a95cf4279759743328f0"
|
||||
}
|
||||
@@ -2684,6 +2706,8 @@
|
||||
"jsr:@db/sqlite@0.13",
|
||||
"jsr:@oak/oak@^17.2.0",
|
||||
"jsr:@panva/jose@^6.2.3",
|
||||
"jsr:@std/assert@^1.0.19",
|
||||
"jsr:@std/testing@^1.0.21",
|
||||
"jsr:@tajpouria/cors@^1.2.1",
|
||||
"npm:@resvg/resvg-wasm@^2.6.2",
|
||||
"npm:marked@^18.0.5",
|
||||
|
||||
Reference in New Issue
Block a user