🌿 Collaborative wiki on ATProto
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add routings tests + delete repetitive tests

juprodh fcdd978c 3ce5be81

+658 -133
+2 -1
src/lib/markdown.ts
··· 1 1 import MarkdownIt from "markdown-it"; 2 2 import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; 3 + import { escapeHtml } from "./html.ts"; 3 4 import { type VizPluginEnv, vizPlugin } from "./viz/plugin.ts"; 4 5 5 6 export interface RenderResult { ··· 42 43 const href = wikiSlug ? `/wiki/${wikiSlug}/${slug.trim()}` : slug.trim(); 43 44 44 45 const token = state.push("html_inline", "", 0); 45 - token.content = `<a href="${href}" class="wikilink">${label.trim()}</a>`; 46 + token.content = `<a href="${escapeHtml(href)}" class="wikilink">${escapeHtml(label.trim())}</a>`; 46 47 } 47 48 48 49 state.pos = closeIdx + 2;
+37
tests/atproto/routes.test.ts
··· 16 16 expect(setCookie).toContain("Max-Age=0"); 17 17 }); 18 18 }); 19 + 20 + describe("client-metadata.json", () => { 21 + test("returns 503 when OAuth is not configured", async () => { 22 + const res = await app.handle( 23 + new Request("http://localhost/client-metadata.json"), 24 + ); 25 + // In test env, OAuth is not configured 26 + expect(res.status).toBe(503); 27 + }); 28 + }); 29 + 30 + describe("jwks.json", () => { 31 + test("returns 503 when OAuth is not configured", async () => { 32 + const res = await app.handle(new Request("http://localhost/jwks.json")); 33 + expect(res.status).toBe(503); 34 + }); 35 + }); 36 + 37 + describe("login", () => { 38 + test("GET /login returns HTML", async () => { 39 + const res = await app.handle(new Request("http://localhost/login")); 40 + expect(res.status).toBe(200); 41 + expect(res.headers.get("Content-Type")).toContain("text/html"); 42 + }); 43 + 44 + test("POST /login without OAuth configured returns 503", async () => { 45 + const formData = new FormData(); 46 + formData.set("handle", "alice.test"); 47 + const res = await app.handle( 48 + new Request("http://localhost/login", { 49 + method: "POST", 50 + body: formData, 51 + }), 52 + ); 53 + expect(res.status).toBe(503); 54 + }); 55 + });
+121
tests/lib/access-context.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { DEV_DID } from "../../src/atproto/session.ts"; 3 + import { 4 + resolveWikiContext, 5 + resolveWikiContextSoft, 6 + } from "../../src/lib/access.ts"; 7 + import { ForbiddenError, NotFoundError } from "../../src/lib/errors.ts"; 8 + import { 9 + upsertMembership, 10 + upsertWiki, 11 + } from "../../src/server/db/queries/index.ts"; 12 + import { cleanupWikiAndDependents } from "../helpers/cleanup.ts"; 13 + 14 + const PUBLIC_SLUG = "ctx-public-wiki"; 15 + const PRIVATE_SLUG = "ctx-private-wiki"; 16 + const OTHER_DID = "did:plc:ctx-other"; 17 + 18 + beforeAll(() => { 19 + upsertWiki( 20 + PUBLIC_SLUG, 21 + DEV_DID, 22 + "Ctx Public", 23 + "public", 24 + `at://${DEV_DID}/wiki.lichen.wiki/${PUBLIC_SLUG}`, 25 + new Date().toISOString(), 26 + ); 27 + upsertWiki( 28 + PRIVATE_SLUG, 29 + OTHER_DID, 30 + "Ctx Private", 31 + "private", 32 + `at://${OTHER_DID}/wiki.lichen.wiki/${PRIVATE_SLUG}`, 33 + new Date().toISOString(), 34 + ); 35 + }); 36 + 37 + afterAll(() => { 38 + cleanupWikiAndDependents(PUBLIC_SLUG); 39 + cleanupWikiAndDependents(PRIVATE_SLUG); 40 + }); 41 + 42 + function fakeRequest(): Request { 43 + return new Request("http://localhost/test"); 44 + } 45 + 46 + describe("resolveWikiContext", () => { 47 + test("throws NotFoundError for nonexistent wiki", async () => { 48 + expect( 49 + resolveWikiContext(fakeRequest(), "nonexistent-slug-xyz", "read"), 50 + ).rejects.toBeInstanceOf(NotFoundError); 51 + }); 52 + 53 + test("returns context for public wiki with read access", async () => { 54 + const ctx = await resolveWikiContext(fakeRequest(), PUBLIC_SLUG, "read"); 55 + expect(ctx.wiki.slug).toBe(PUBLIC_SLUG); 56 + expect(ctx.access).toBe("admin"); // DEV_DID is owner 57 + }); 58 + 59 + test("throws ForbiddenError for private wiki when user has no membership", async () => { 60 + // DEV_DID is not the owner and has no membership on private wiki 61 + expect( 62 + resolveWikiContext(fakeRequest(), PRIVATE_SLUG, "read"), 63 + ).rejects.toBeInstanceOf(ForbiddenError); 64 + }); 65 + 66 + test("throws ForbiddenError when requiring edit on read-only access", async () => { 67 + // Add DEV_DID as viewer on private wiki 68 + upsertMembership( 69 + PRIVATE_SLUG, 70 + DEV_DID, 71 + "viewer", 72 + `at://${DEV_DID}/wiki.lichen.membership/ctx-viewer`, 73 + new Date().toISOString(), 74 + ); 75 + 76 + expect( 77 + resolveWikiContext(fakeRequest(), PRIVATE_SLUG, "edit"), 78 + ).rejects.toBeInstanceOf(ForbiddenError); 79 + 80 + // Cleanup viewer membership 81 + const { getDb } = await import("../../src/server/db/index.ts"); 82 + getDb().run("DELETE FROM memberships WHERE wiki_slug = ? AND did = ?", [ 83 + PRIVATE_SLUG, 84 + DEV_DID, 85 + ]); 86 + }); 87 + 88 + test("throws ForbiddenError when requiring admin on edit access", async () => { 89 + upsertMembership( 90 + PRIVATE_SLUG, 91 + DEV_DID, 92 + "contributor", 93 + `at://${DEV_DID}/wiki.lichen.membership/ctx-contrib`, 94 + new Date().toISOString(), 95 + ); 96 + 97 + expect( 98 + resolveWikiContext(fakeRequest(), PRIVATE_SLUG, "admin"), 99 + ).rejects.toBeInstanceOf(ForbiddenError); 100 + 101 + const { getDb } = await import("../../src/server/db/index.ts"); 102 + getDb().run("DELETE FROM memberships WHERE wiki_slug = ? AND did = ?", [ 103 + PRIVATE_SLUG, 104 + DEV_DID, 105 + ]); 106 + }); 107 + }); 108 + 109 + describe("resolveWikiContextSoft", () => { 110 + test("throws NotFoundError for nonexistent wiki", async () => { 111 + expect( 112 + resolveWikiContextSoft(fakeRequest(), "nonexistent-slug-xyz"), 113 + ).rejects.toBeInstanceOf(NotFoundError); 114 + }); 115 + 116 + test("returns context with none access for private wiki (does not throw)", async () => { 117 + const ctx = await resolveWikiContextSoft(fakeRequest(), PRIVATE_SLUG); 118 + expect(ctx.wiki.slug).toBe(PRIVATE_SLUG); 119 + expect(ctx.access).toBe("none"); 120 + }); 121 + });
+16
tests/lib/blob.test.ts
··· 59 59 const refs = extractBlobRefs(content); 60 60 expect(refs).toEqual([{ did: "did:plc:abc", cid: "bafyrei123ABCdef456" }]); 61 61 }); 62 + 63 + test("ignores malformed blob URLs (missing did prefix)", () => { 64 + const content = "![img](/blob/notadid/bafyreiabc)"; 65 + expect(extractBlobRefs(content)).toEqual([]); 66 + }); 67 + 68 + test("ignores blob URL without CID", () => { 69 + const content = "![img](/blob/did:plc:abc/)"; 70 + expect(extractBlobRefs(content)).toEqual([]); 71 + }); 72 + 73 + test("does not extract from non-image markdown links", () => { 74 + // Regular links (not images) should not match 75 + const content = "[click here](/blob/did:plc:abc/bafyreiabc)"; 76 + expect(extractBlobRefs(content)).toEqual([]); 77 + }); 62 78 }); 63 79 64 80 describe("parseBlobMetadata", () => {
+22
tests/lib/constants.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { normalizeRole } from "../../src/lib/constants.ts"; 3 + 4 + describe("normalizeRole", () => { 5 + test("passes through valid roles", () => { 6 + expect(normalizeRole("admin")).toBe("admin"); 7 + expect(normalizeRole("viewer")).toBe("viewer"); 8 + }); 9 + 10 + test("defaults contributor for valid contributor input", () => { 11 + expect(normalizeRole("contributor")).toBe("contributor"); 12 + }); 13 + 14 + test("defaults to contributor for invalid or missing input", () => { 15 + expect(normalizeRole(null)).toBe("contributor"); 16 + expect(normalizeRole(undefined)).toBe("contributor"); 17 + expect(normalizeRole("")).toBe("contributor"); 18 + expect(normalizeRole("superadmin")).toBe("contributor"); 19 + expect(normalizeRole("ADMIN")).toBe("contributor"); 20 + expect(normalizeRole("<script>")).toBe("contributor"); 21 + }); 22 + });
-57
tests/lib/errors.test.ts
··· 1 - import { describe, expect, test } from "bun:test"; 2 - import { 3 - AppError, 4 - ForbiddenError, 5 - formatError, 6 - NotFoundError, 7 - PdsWriteError, 8 - ValidationError, 9 - } from "../../src/lib/errors.ts"; 10 - 11 - describe("formatError", () => { 12 - test("extracts message from Error", () => { 13 - expect(formatError(new Error("boom"))).toBe("boom"); 14 - }); 15 - 16 - test("converts non-Error to string", () => { 17 - expect(formatError("oops")).toBe("oops"); 18 - expect(formatError(42)).toBe("42"); 19 - }); 20 - }); 21 - 22 - describe("error classes", () => { 23 - test("NotFoundError defaults to 404", () => { 24 - const err = new NotFoundError(); 25 - expect(err).toBeInstanceOf(AppError); 26 - expect(err).toBeInstanceOf(Error); 27 - expect(err.statusCode).toBe(404); 28 - expect(err.message).toBe("Not found"); 29 - }); 30 - 31 - test("NotFoundError accepts custom message", () => { 32 - const err = new NotFoundError("Wiki not found"); 33 - expect(err.statusCode).toBe(404); 34 - expect(err.message).toBe("Wiki not found"); 35 - }); 36 - 37 - test("ForbiddenError defaults to 403", () => { 38 - const err = new ForbiddenError(); 39 - expect(err).toBeInstanceOf(AppError); 40 - expect(err.statusCode).toBe(403); 41 - expect(err.message).toBe("Forbidden"); 42 - }); 43 - 44 - test("PdsWriteError is 502", () => { 45 - const err = new PdsWriteError("PDS unreachable"); 46 - expect(err).toBeInstanceOf(AppError); 47 - expect(err.statusCode).toBe(502); 48 - expect(err.message).toBe("PDS unreachable"); 49 - }); 50 - 51 - test("ValidationError is 400", () => { 52 - const err = new ValidationError("Title required"); 53 - expect(err).toBeInstanceOf(AppError); 54 - expect(err.statusCode).toBe(400); 55 - expect(err.message).toBe("Title required"); 56 - }); 57 - });
+32
tests/lib/image-validation.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { 3 + ALLOWED_MIME_TYPES, 4 + ImageValidationError, 5 + MAX_IMAGE_SIZE, 6 + } from "../../src/lib/image.ts"; 7 + 8 + // These tests validate the image constraints WITHOUT calling processImage 9 + // (which requires sharp). They verify the exported constants and error class. 10 + 11 + describe("image validation constants", () => { 12 + test("only allows jpg, png, gif, webp", () => { 13 + expect(ALLOWED_MIME_TYPES).toContain("image/jpeg"); 14 + expect(ALLOWED_MIME_TYPES).toContain("image/png"); 15 + expect(ALLOWED_MIME_TYPES).toContain("image/gif"); 16 + expect(ALLOWED_MIME_TYPES).toContain("image/webp"); 17 + expect(ALLOWED_MIME_TYPES).toHaveLength(4); 18 + // Dangerous types must not be present 19 + expect(ALLOWED_MIME_TYPES).not.toContain("image/svg+xml"); 20 + expect(ALLOWED_MIME_TYPES).not.toContain("application/pdf"); 21 + }); 22 + 23 + test("max image size is 10MB", () => { 24 + expect(MAX_IMAGE_SIZE).toBe(10 * 1024 * 1024); 25 + }); 26 + 27 + test("ImageValidationError is an Error", () => { 28 + const err = new ImageValidationError("test"); 29 + expect(err).toBeInstanceOf(Error); 30 + expect(err.name).toBe("ImageValidationError"); 31 + }); 32 + });
+34 -37
tests/lib/markdown.test.ts
··· 2 2 import { extractWikilinks, renderMarkdown } from "../../src/lib/markdown.ts"; 3 3 4 4 describe("renderMarkdown", () => { 5 - test("does not render raw HTML", () => { 5 + test("does not render raw HTML (XSS prevention)", () => { 6 6 const { html } = renderMarkdown('<script>alert("xss")</script>'); 7 7 expect(html).not.toContain("<script>"); 8 8 }); 9 + 10 + test("renders empty string without error", () => { 11 + const { html } = renderMarkdown(""); 12 + expect(html).toBe(""); 13 + }); 9 14 }); 10 15 11 16 describe("wikilinks", () => { ··· 14 19 test("renders [[slug]] as a link with wiki prefix", () => { 15 20 const { html } = renderMarkdown("see [[my-page]]", WIKI); 16 21 expect(html).toContain('href="/wiki/test-wiki/my-page"'); 17 - expect(html).toContain("my-page"); 18 22 expect(html).toContain('class="wikilink"'); 19 23 }); 20 24 ··· 30 34 expect(html).toContain('href="/wiki/test-wiki/page-b"'); 31 35 }); 32 36 33 - test("handles wikilink with pipe in label", () => { 37 + test("handles pipe in label (preserves extra pipes)", () => { 34 38 const { html } = renderMarkdown("[[slug|label with | pipe]]", WIKI); 35 39 expect(html).toContain('href="/wiki/test-wiki/slug"'); 36 40 expect(html).toContain("label with | pipe"); ··· 48 52 expect(html).toContain("My Page"); 49 53 }); 50 54 51 - test("works alongside normal markdown", () => { 52 - const { html } = renderMarkdown("**bold** and [[link]]", WIKI); 53 - expect(html).toContain("<strong>bold</strong>"); 54 - expect(html).toContain('class="wikilink"'); 55 - }); 56 - 57 55 test("falls back to bare slug when no wikiSlug provided", () => { 58 56 const { html } = renderMarkdown("see [[my-page]]"); 59 57 expect(html).toContain('href="my-page"'); 60 58 }); 59 + 60 + test("XSS in wikilink slug: quote breakout in href is escaped", () => { 61 + const { html } = renderMarkdown('[[" onclick="alert(1)]]', WIKI); 62 + // Quotes are escaped so the attacker can't break out of the href attribute 63 + expect(html).not.toContain(' onclick="alert'); 64 + expect(html).toContain("&quot;"); 65 + expect(html).toContain("wikilink"); 66 + }); 67 + 68 + test("XSS in wikilink label: HTML tags are escaped", () => { 69 + const { html } = renderMarkdown( 70 + "[[safe-slug|<img src=x onerror=alert(1)>]]", 71 + WIKI, 72 + ); 73 + expect(html).toContain('href="/wiki/test-wiki/safe-slug"'); 74 + expect(html).not.toContain("<img"); 75 + expect(html).toContain("&lt;img"); 76 + }); 61 77 }); 62 78 63 79 describe("extractWikilinks", () => { 64 80 test("returns empty array for content with no wikilinks", () => { 65 81 expect(extractWikilinks("no links here")).toEqual([]); 66 - }); 67 - 68 - test("returns empty array for empty string", () => { 69 82 expect(extractWikilinks("")).toEqual([]); 70 83 }); 71 84 72 - test("extracts a single [[slug]]", () => { 85 + test("extracts slugs from [[slug]] and [[slug|label]]", () => { 73 86 expect(extractWikilinks("see [[my-page]]")).toEqual(["my-page"]); 74 - }); 75 - 76 - test("extracts slug from [[slug|label]]", () => { 77 87 expect(extractWikilinks("see [[my-page|My Page]]")).toEqual(["my-page"]); 78 88 }); 79 89 80 - test("extracts multiple wikilinks", () => { 81 - const result = extractWikilinks("[[page-a]] and [[page-b]]"); 82 - expect(result).toEqual(["page-a", "page-b"]); 83 - }); 84 - 85 - test("deduplicates repeated slugs", () => { 86 - const result = extractWikilinks("[[foo]] then [[foo]] again"); 87 - expect(result).toEqual(["foo"]); 90 + test("extracts multiple wikilinks and deduplicates", () => { 91 + expect(extractWikilinks("[[page-a]] and [[page-b]]")).toEqual([ 92 + "page-a", 93 + "page-b", 94 + ]); 95 + expect(extractWikilinks("[[foo]] then [[foo]] again")).toEqual(["foo"]); 88 96 }); 89 97 90 98 test("trims whitespace from slugs", () => { 91 99 expect(extractWikilinks("[[ spaced ]]")).toEqual(["spaced"]); 92 - }); 93 - 94 - test("trims whitespace from piped slugs", () => { 95 100 expect(extractWikilinks("[[ spaced | Label ]]")).toEqual(["spaced"]); 96 101 }); 97 102 98 - test("ignores unclosed brackets", () => { 103 + test("ignores unclosed brackets and empty slugs", () => { 99 104 expect(extractWikilinks("[[unclosed")).toEqual([]); 105 + expect(extractWikilinks("[[]]")).toEqual([]); 100 106 }); 101 107 102 108 test("handles wikilinks across multiple lines", () => { 103 109 const result = extractWikilinks("line1 [[a]]\nline2 [[b]]"); 104 110 expect(result).toEqual(["a", "b"]); 105 - }); 106 - 107 - test("handles pipe in label (takes first part as slug)", () => { 108 - const result = extractWikilinks("[[slug|label with | pipe]]"); 109 - expect(result).toEqual(["slug"]); 110 - }); 111 - 112 - test("skips empty slug between brackets", () => { 113 - expect(extractWikilinks("[[]]")).toEqual([]); 114 111 }); 115 112 });
+63
tests/lib/note-validation.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { DEV_DID } from "../../src/atproto/session.ts"; 3 + import { t } from "../../src/lib/i18n/index.ts"; 4 + import { validateNewNote } from "../../src/lib/note-validation.ts"; 5 + import { createNote, upsertWiki } from "../../src/server/db/queries/index.ts"; 6 + import { cleanupWikiAndDependents } from "../helpers/cleanup.ts"; 7 + 8 + const msg = t("en"); 9 + const WIKI_SLUG = "nv-test-wiki"; 10 + 11 + beforeAll(() => { 12 + upsertWiki( 13 + WIKI_SLUG, 14 + DEV_DID, 15 + "NV Test", 16 + "public", 17 + `at://${DEV_DID}/wiki.lichen.wiki/${WIKI_SLUG}`, 18 + new Date().toISOString(), 19 + ); 20 + createNote( 21 + `at://${DEV_DID}/wiki.lichen.note/nv-existing`, 22 + `at://${DEV_DID}/wiki.lichen.noteRevision/nv-existing-rev`, 23 + WIKI_SLUG, 24 + "existing-note", 25 + "Existing Note", 26 + DEV_DID, 27 + "content", 28 + ); 29 + }); 30 + 31 + afterAll(() => { 32 + cleanupWikiAndDependents(WIKI_SLUG); 33 + }); 34 + 35 + describe("validateNewNote", () => { 36 + test("returns slug for valid title", () => { 37 + const result = validateNewNote(WIKI_SLUG, "My New Page", msg); 38 + expect(result).toEqual({ noteSlug: "my-new-page" }); 39 + }); 40 + 41 + test("rejects empty title", () => { 42 + const result = validateNewNote(WIKI_SLUG, "", msg); 43 + expect("error" in result).toBe(true); 44 + }); 45 + 46 + test("rejects whitespace-only title", () => { 47 + const result = validateNewNote(WIKI_SLUG, " ", msg); 48 + expect("error" in result).toBe(true); 49 + }); 50 + 51 + test("rejects title that produces existing slug", () => { 52 + const result = validateNewNote(WIKI_SLUG, "Existing Note", msg); 53 + expect("error" in result).toBe(true); 54 + if ("error" in result) { 55 + expect(result.error).toContain("existing-note"); 56 + } 57 + }); 58 + 59 + test("rejects title that produces invalid slug (special chars only)", () => { 60 + const result = validateNewNote(WIKI_SLUG, "!!!???", msg); 61 + expect("error" in result).toBe(true); 62 + }); 63 + });
+5 -25
tests/lib/slug.test.ts
··· 2 2 import { isValidSlug } from "../../src/lib/slug.ts"; 3 3 4 4 describe("isValidSlug", () => { 5 - test("accepts simple slugs", () => { 5 + test("accepts valid slugs", () => { 6 6 expect(isValidSlug("hello")).toBe(true); 7 7 expect(isValidSlug("hello-world")).toBe(true); 8 + expect(isValidSlug("page1")).toBe(true); 8 9 expect(isValidSlug("a")).toBe(true); 9 10 }); 10 11 11 - test("accepts slugs with numbers", () => { 12 - expect(isValidSlug("page1")).toBe(true); 13 - expect(isValidSlug("123")).toBe(true); 14 - expect(isValidSlug("v2-release")).toBe(true); 15 - }); 16 - 17 - test("rejects uppercase", () => { 12 + test("rejects invalid slugs", () => { 13 + expect(isValidSlug("")).toBe(false); 18 14 expect(isValidSlug("Hello")).toBe(false); 19 - }); 20 - 21 - test("rejects spaces", () => { 22 15 expect(isValidSlug("hello world")).toBe(false); 23 - }); 24 - 25 - test("rejects underscores", () => { 26 16 expect(isValidSlug("hello_world")).toBe(false); 27 - }); 28 - 29 - test("rejects leading or trailing hyphens", () => { 30 17 expect(isValidSlug("-hello")).toBe(false); 31 18 expect(isValidSlug("hello-")).toBe(false); 32 - }); 33 - 34 - test("rejects consecutive hyphens", () => { 35 19 expect(isValidSlug("hello--world")).toBe(false); 36 20 }); 37 21 38 - test("rejects empty string", () => { 39 - expect(isValidSlug("")).toBe(false); 40 - }); 41 - 42 - test("rejects slugs over 128 characters", () => { 22 + test("enforces 128 character max length", () => { 43 23 expect(isValidSlug("a".repeat(128))).toBe(true); 44 24 expect(isValidSlug("a".repeat(129))).toBe(false); 45 25 });
+1 -13
tests/lib/urls.test.ts
··· 9 9 } from "../../src/lib/urls.ts"; 10 10 11 11 describe("URL helpers", () => { 12 - test("wikiUrl", () => { 12 + test("builds correct paths for all route types", () => { 13 13 expect(wikiUrl("cooking")).toBe("/wiki/cooking"); 14 - }); 15 - 16 - test("noteUrl", () => { 17 14 expect(noteUrl("cooking", "pasta")).toBe("/wiki/cooking/pasta"); 18 - }); 19 - 20 - test("editNoteUrl", () => { 21 15 expect(editNoteUrl("cooking", "pasta")).toBe("/wiki/cooking/pasta/edit"); 22 - }); 23 - 24 - test("newNoteUrl", () => { 25 16 expect(newNoteUrl("cooking")).toBe("/wiki/cooking/new"); 26 - }); 27 - 28 - test("membersUrl", () => { 29 17 expect(membersUrl("cooking")).toBe("/wiki/cooking/-/members"); 30 18 }); 31 19
+30
tests/server/db/queries/cursor.test.ts
··· 1 + import { afterAll, describe, expect, test } from "bun:test"; 2 + import { getDb } from "../../../../src/server/db/index.ts"; 3 + import { 4 + getCursor, 5 + setCursor, 6 + } from "../../../../src/server/db/queries/cursor.ts"; 7 + 8 + afterAll(() => { 9 + // Clean up cursor row 10 + getDb().run("DELETE FROM firehose_cursor WHERE id = 1"); 11 + }); 12 + 13 + describe("firehose cursor", () => { 14 + test("getCursor returns null when no cursor is set", () => { 15 + getDb().run("DELETE FROM firehose_cursor WHERE id = 1"); 16 + expect(getCursor()).toBeNull(); 17 + }); 18 + 19 + test("setCursor stores and getCursor retrieves the value", () => { 20 + setCursor(12345); 21 + expect(getCursor()).toBe(12345); 22 + }); 23 + 24 + test("setCursor upserts (overwrites previous value)", () => { 25 + setCursor(100); 26 + expect(getCursor()).toBe(100); 27 + setCursor(200); 28 + expect(getCursor()).toBe(200); 29 + }); 30 + });
+295
tests/server/routes/routes.test.ts
··· 1 + import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 + import { Elysia } from "elysia"; 3 + import { DEV_DID } from "../../../src/atproto/session.ts"; 4 + import { AppError } from "../../../src/lib/errors.ts"; 5 + import { 6 + createNote, 7 + upsertMembership, 8 + upsertWiki, 9 + } from "../../../src/server/db/queries/index.ts"; 10 + import { blobRoutes } from "../../../src/server/routes/blob.ts"; 11 + import { homeRoute } from "../../../src/server/routes/home.ts"; 12 + import { membershipRoutes } from "../../../src/server/routes/membership.ts"; 13 + import { noteRoutes } from "../../../src/server/routes/note.ts"; 14 + import { searchRoutes } from "../../../src/server/routes/search.ts"; 15 + import { wikiRoutes } from "../../../src/server/routes/wiki.ts"; 16 + import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 17 + 18 + // Build a test app with the same error handler as production 19 + const app = new Elysia() 20 + .onError(({ error }) => { 21 + if (error instanceof AppError) { 22 + return new Response(error.message, { status: error.statusCode }); 23 + } 24 + return new Response("Internal server error", { status: 500 }); 25 + }) 26 + .use(blobRoutes) 27 + .use(homeRoute) 28 + .use(searchRoutes) 29 + .use(membershipRoutes) 30 + .use(noteRoutes) 31 + .use(wikiRoutes); 32 + 33 + const WIKI_SLUG = "rt-test-wiki"; 34 + const WIKI_AT_URI = `at://${DEV_DID}/wiki.lichen.wiki/${WIKI_SLUG}`; 35 + const PRIVATE_WIKI_SLUG = "rt-test-private"; 36 + const PRIVATE_WIKI_AT_URI = `at://did:plc:other/wiki.lichen.wiki/${PRIVATE_WIKI_SLUG}`; 37 + const OTHER_DID = "did:plc:other"; 38 + 39 + beforeAll(() => { 40 + // Seed a public wiki owned by DEV_DID 41 + upsertWiki( 42 + WIKI_SLUG, 43 + DEV_DID, 44 + "Route Test Wiki", 45 + "public", 46 + WIKI_AT_URI, 47 + new Date().toISOString(), 48 + ); 49 + upsertMembership( 50 + WIKI_SLUG, 51 + DEV_DID, 52 + "admin", 53 + `at://${DEV_DID}/wiki.lichen.membership/rt1`, 54 + new Date().toISOString(), 55 + ); 56 + createNote( 57 + `at://${DEV_DID}/wiki.lichen.note/rt-home`, 58 + `at://${DEV_DID}/wiki.lichen.noteRevision/rt-home-rev`, 59 + WIKI_SLUG, 60 + "home", 61 + "Home", 62 + DEV_DID, 63 + "# Welcome", 64 + ); 65 + createNote( 66 + `at://${DEV_DID}/wiki.lichen.note/rt-test-note`, 67 + `at://${DEV_DID}/wiki.lichen.noteRevision/rt-test-rev`, 68 + WIKI_SLUG, 69 + "test-note", 70 + "Test Note", 71 + DEV_DID, 72 + "Some content", 73 + ); 74 + 75 + // Seed a private wiki owned by another DID (DEV_DID has no membership) 76 + upsertWiki( 77 + PRIVATE_WIKI_SLUG, 78 + OTHER_DID, 79 + "Private Wiki", 80 + "private", 81 + PRIVATE_WIKI_AT_URI, 82 + new Date().toISOString(), 83 + ); 84 + }); 85 + 86 + afterAll(() => { 87 + cleanupWikiAndDependents(WIKI_SLUG); 88 + cleanupWikiAndDependents(PRIVATE_WIKI_SLUG); 89 + }); 90 + 91 + // --- Wiki routes --- 92 + 93 + describe("wiki routes", () => { 94 + test("GET /wiki/:slug returns 200 for public wiki", async () => { 95 + const res = await app.handle( 96 + new Request(`http://localhost/wiki/${WIKI_SLUG}`), 97 + ); 98 + expect(res.status).toBe(200); 99 + expect(res.headers.get("Content-Type")).toContain("text/html"); 100 + }); 101 + 102 + test("GET /wiki/:slug returns 403 for private wiki (non-member)", async () => { 103 + const res = await app.handle( 104 + new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}`), 105 + ); 106 + expect(res.status).toBe(403); 107 + }); 108 + 109 + test("GET /wiki/nonexistent returns 404", async () => { 110 + const res = await app.handle( 111 + new Request("http://localhost/wiki/does-not-exist-xyz"), 112 + ); 113 + expect(res.status).toBe(404); 114 + }); 115 + 116 + test("GET /wiki/:slug/-/settings returns 200 for admin", async () => { 117 + const res = await app.handle( 118 + new Request(`http://localhost/wiki/${WIKI_SLUG}/-/settings`), 119 + ); 120 + expect(res.status).toBe(200); 121 + }); 122 + 123 + test("GET /wiki/:slug/-/settings returns 403 for non-admin", async () => { 124 + const res = await app.handle( 125 + new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}/-/settings`), 126 + ); 127 + expect(res.status).toBe(403); 128 + }); 129 + 130 + test("POST /wiki/:slug/-/delete rejects wrong confirmation name", async () => { 131 + const formData = new FormData(); 132 + formData.set("confirm", "wrong name"); 133 + const res = await app.handle( 134 + new Request(`http://localhost/wiki/${WIKI_SLUG}/-/delete`, { 135 + method: "POST", 136 + body: formData, 137 + }), 138 + ); 139 + expect(res.status).toBe(400); 140 + }); 141 + 142 + test("GET /wiki/new returns 200", async () => { 143 + const res = await app.handle(new Request("http://localhost/wiki/new")); 144 + expect(res.status).toBe(200); 145 + }); 146 + }); 147 + 148 + // --- Note routes --- 149 + 150 + describe("note routes", () => { 151 + test("GET /wiki/:slug/:noteSlug returns 200 for existing note", async () => { 152 + const res = await app.handle( 153 + new Request(`http://localhost/wiki/${WIKI_SLUG}/test-note`), 154 + ); 155 + expect(res.status).toBe(200); 156 + expect(res.headers.get("Content-Type")).toContain("text/html"); 157 + }); 158 + 159 + test("GET /wiki/:slug/:noteSlug returns 404 for nonexistent note", async () => { 160 + const res = await app.handle( 161 + new Request(`http://localhost/wiki/${WIKI_SLUG}/nonexistent-note-xyz`), 162 + ); 163 + expect(res.status).toBe(404); 164 + }); 165 + 166 + test("GET /wiki/:slug/:noteSlug/edit returns 200 for editable note", async () => { 167 + const res = await app.handle( 168 + new Request(`http://localhost/wiki/${WIKI_SLUG}/test-note/edit`), 169 + ); 170 + expect(res.status).toBe(200); 171 + }); 172 + 173 + test("GET edit on private wiki returns 403 for non-member", async () => { 174 + const res = await app.handle( 175 + new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}/some-note/edit`), 176 + ); 177 + expect(res.status).toBe(403); 178 + }); 179 + 180 + test("GET new note on private wiki returns 403 for non-member", async () => { 181 + const res = await app.handle( 182 + new Request(`http://localhost/wiki/${PRIVATE_WIKI_SLUG}/new`), 183 + ); 184 + expect(res.status).toBe(403); 185 + }); 186 + 187 + test("POST /wiki/:slug/new creates a note and redirects", async () => { 188 + const formData = new FormData(); 189 + formData.set("title", "Route Created Note"); 190 + formData.set("content", "Test content from route test"); 191 + const res = await app.handle( 192 + new Request(`http://localhost/wiki/${WIKI_SLUG}/new`, { 193 + method: "POST", 194 + body: formData, 195 + }), 196 + ); 197 + expect(res.status).toBe(302); 198 + expect(res.headers.get("Location")).toContain(`/wiki/${WIKI_SLUG}/`); 199 + }); 200 + 201 + test("POST /wiki/:slug/new with empty title returns validation error", async () => { 202 + const formData = new FormData(); 203 + formData.set("title", " "); 204 + formData.set("content", "content"); 205 + const res = await app.handle( 206 + new Request(`http://localhost/wiki/${WIKI_SLUG}/new`, { 207 + method: "POST", 208 + body: formData, 209 + }), 210 + ); 211 + // ValidationError is caught by the route and re-rendered as the form page 212 + expect(res.status).toBe(200); 213 + const body = await res.text(); 214 + expect(body).toContain("required"); 215 + }); 216 + }); 217 + 218 + // --- Search routes --- 219 + 220 + describe("search routes", () => { 221 + test("GET /search with empty query returns empty", async () => { 222 + const res = await app.handle(new Request("http://localhost/search?q=")); 223 + expect(res.status).toBe(200); 224 + const body = await res.text(); 225 + expect(body).toBe(""); 226 + }); 227 + 228 + test("GET /search returns results for matching wikis", async () => { 229 + const res = await app.handle( 230 + new Request("http://localhost/search?q=Route+Test"), 231 + ); 232 + expect(res.status).toBe(200); 233 + const body = await res.text(); 234 + expect(body).toContain("Route Test Wiki"); 235 + }); 236 + 237 + test("GET /search within wiki returns note results", async () => { 238 + const res = await app.handle( 239 + new Request(`http://localhost/search?q=Test&wiki=${WIKI_SLUG}`), 240 + ); 241 + expect(res.status).toBe(200); 242 + }); 243 + 244 + test("GET /search within private wiki returns empty for non-member", async () => { 245 + const res = await app.handle( 246 + new Request( 247 + `http://localhost/search?q=anything&wiki=${PRIVATE_WIKI_SLUG}`, 248 + ), 249 + ); 250 + expect(res.status).toBe(200); 251 + const body = await res.text(); 252 + expect(body).toBe(""); 253 + }); 254 + }); 255 + 256 + // --- Blob routes --- 257 + 258 + describe("blob routes", () => { 259 + test("POST /api/upload-image without file returns 400", async () => { 260 + const formData = new FormData(); 261 + const res = await app.handle( 262 + new Request("http://localhost/api/upload-image", { 263 + method: "POST", 264 + body: formData, 265 + }), 266 + ); 267 + expect(res.status).toBe(400); 268 + const json = (await res.json()) as { error: string }; 269 + expect(json.error).toContain("No file"); 270 + }); 271 + 272 + test("GET /blob/local/ rejects path traversal", async () => { 273 + const res = await app.handle( 274 + new Request("http://localhost/blob/local/..%2F..%2Fetc%2Fpasswd"), 275 + ); 276 + expect(res.status).toBe(400); 277 + }); 278 + 279 + test("GET /blob/local/ rejects filename with double dots", async () => { 280 + const res = await app.handle( 281 + new Request("http://localhost/blob/local/..secret.jpg"), 282 + ); 283 + expect(res.status).toBe(400); 284 + }); 285 + }); 286 + 287 + // --- Home route --- 288 + 289 + describe("home route", () => { 290 + test("GET / returns 200 with HTML", async () => { 291 + const res = await app.handle(new Request("http://localhost/")); 292 + expect(res.status).toBe(200); 293 + expect(res.headers.get("Content-Type")).toContain("text/html"); 294 + }); 295 + });