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>
163 lines
5.9 KiB
TypeScript
163 lines
5.9 KiB
TypeScript
// 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$/);
|
|
});
|