// 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("")), 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("")), 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$/); });