diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -1,5 +1,6 @@ import MarkdownIt from "markdown-it"; import type StateInline from "markdown-it/lib/rules_inline/state_inline.mjs"; +import { escapeHtml } from "./html.ts"; import { type VizPluginEnv, vizPlugin } from "./viz/plugin.ts"; export interface RenderResult { @@ -42,7 +43,7 @@ const wikiSlug = env.wikiSlug; const href = wikiSlug ? `/wiki/${wikiSlug}/${slug.trim()}` : slug.trim(); const token = state.push("html_inline", "", 0); - token.content = `${label.trim()}`; + token.content = `${escapeHtml(label.trim())}`; } state.pos = closeIdx + 2; diff --git a/tests/atproto/routes.test.ts b/tests/atproto/routes.test.ts --- a/tests/atproto/routes.test.ts +++ b/tests/atproto/routes.test.ts @@ -16,3 +16,40 @@ expect(setCookie).toContain("did="); expect(setCookie).toContain("Max-Age=0"); }); }); + +describe("client-metadata.json", () => { + test("returns 503 when OAuth is not configured", async () => { + const res = await app.handle( + new Request("http://localhost/client-metadata.json"), + ); + // In test env, OAuth is not configured + expect(res.status).toBe(503); + }); +}); + +describe("jwks.json", () => { + test("returns 503 when OAuth is not configured", async () => { + const res = await app.handle(new Request("http://localhost/jwks.json")); + expect(res.status).toBe(503); + }); +}); + +describe("login", () => { + test("GET /login returns HTML", async () => { + const res = await app.handle(new Request("http://localhost/login")); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toContain("text/html"); + }); + + test("POST /login without OAuth configured returns 503", async () => { + const formData = new FormData(); + formData.set("handle", "alice.test"); + const res = await app.handle( + new Request("http://localhost/login", { + method: "POST", + body: formData, + }), + ); + expect(res.status).toBe(503); + }); +}); diff --git a/tests/lib/access-context.test.ts b/tests/lib/access-context.test.ts new file mode 100644 --- /dev/null +++ b/tests/lib/access-context.test.ts @@ -0,0 +1,121 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { DEV_DID } from "../../src/atproto/session.ts"; +import { + resolveWikiContext, + resolveWikiContextSoft, +} from "../../src/lib/access.ts"; +import { ForbiddenError, NotFoundError } from "../../src/lib/errors.ts"; +import { + upsertMembership, + upsertWiki, +} from "../../src/server/db/queries/index.ts"; +import { cleanupWikiAndDependents } from "../helpers/cleanup.ts"; + +const PUBLIC_SLUG = "ctx-public-wiki"; +const PRIVATE_SLUG = "ctx-private-wiki"; +const OTHER_DID = "did:plc:ctx-other"; + +beforeAll(() => { + upsertWiki( + PUBLIC_SLUG, + DEV_DID, + "Ctx Public", + "public", + `at://${DEV_DID}/wiki.lichen.wiki/${PUBLIC_SLUG}`, + new Date().toISOString(), + ); + upsertWiki( + PRIVATE_SLUG, + OTHER_DID, + "Ctx Private", + "private", + `at://${OTHER_DID}/wiki.lichen.wiki/${PRIVATE_SLUG}`, + new Date().toISOString(), + ); +}); + +afterAll(() => { + cleanupWikiAndDependents(PUBLIC_SLUG); + cleanupWikiAndDependents(PRIVATE_SLUG); +}); + +function fakeRequest(): Request { + return new Request("http://localhost/test"); +} + +describe("resolveWikiContext", () => { + test("throws NotFoundError for nonexistent wiki", async () => { + expect( + resolveWikiContext(fakeRequest(), "nonexistent-slug-xyz", "read"), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + test("returns context for public wiki with read access", async () => { + const ctx = await resolveWikiContext(fakeRequest(), PUBLIC_SLUG, "read"); + expect(ctx.wiki.slug).toBe(PUBLIC_SLUG); + expect(ctx.access).toBe("admin"); // DEV_DID is owner + }); + + test("throws ForbiddenError for private wiki when user has no membership", async () => { + // DEV_DID is not the owner and has no membership on private wiki + expect( + resolveWikiContext(fakeRequest(), PRIVATE_SLUG, "read"), + ).rejects.toBeInstanceOf(ForbiddenError); + }); + + test("throws ForbiddenError when requiring edit on read-only access", async () => { + // Add DEV_DID as viewer on private wiki + upsertMembership( + PRIVATE_SLUG, + DEV_DID, + "viewer", + `at://${DEV_DID}/wiki.lichen.membership/ctx-viewer`, + new Date().toISOString(), + ); + + expect( + resolveWikiContext(fakeRequest(), PRIVATE_SLUG, "edit"), + ).rejects.toBeInstanceOf(ForbiddenError); + + // Cleanup viewer membership + const { getDb } = await import("../../src/server/db/index.ts"); + getDb().run("DELETE FROM memberships WHERE wiki_slug = ? AND did = ?", [ + PRIVATE_SLUG, + DEV_DID, + ]); + }); + + test("throws ForbiddenError when requiring admin on edit access", async () => { + upsertMembership( + PRIVATE_SLUG, + DEV_DID, + "contributor", + `at://${DEV_DID}/wiki.lichen.membership/ctx-contrib`, + new Date().toISOString(), + ); + + expect( + resolveWikiContext(fakeRequest(), PRIVATE_SLUG, "admin"), + ).rejects.toBeInstanceOf(ForbiddenError); + + const { getDb } = await import("../../src/server/db/index.ts"); + getDb().run("DELETE FROM memberships WHERE wiki_slug = ? AND did = ?", [ + PRIVATE_SLUG, + DEV_DID, + ]); + }); +}); + +describe("resolveWikiContextSoft", () => { + test("throws NotFoundError for nonexistent wiki", async () => { + expect( + resolveWikiContextSoft(fakeRequest(), "nonexistent-slug-xyz"), + ).rejects.toBeInstanceOf(NotFoundError); + }); + + test("returns context with none access for private wiki (does not throw)", async () => { + const ctx = await resolveWikiContextSoft(fakeRequest(), PRIVATE_SLUG); + expect(ctx.wiki.slug).toBe(PRIVATE_SLUG); + expect(ctx.access).toBe("none"); + }); +}); diff --git a/tests/lib/blob.test.ts b/tests/lib/blob.test.ts --- a/tests/lib/blob.test.ts +++ b/tests/lib/blob.test.ts @@ -59,6 +59,22 @@ const content = "![img](/blob/did:plc:abc/bafyrei123ABCdef456)"; const refs = extractBlobRefs(content); expect(refs).toEqual([{ did: "did:plc:abc", cid: "bafyrei123ABCdef456" }]); }); + + test("ignores malformed blob URLs (missing did prefix)", () => { + const content = "![img](/blob/notadid/bafyreiabc)"; + expect(extractBlobRefs(content)).toEqual([]); + }); + + test("ignores blob URL without CID", () => { + const content = "![img](/blob/did:plc:abc/)"; + expect(extractBlobRefs(content)).toEqual([]); + }); + + test("does not extract from non-image markdown links", () => { + // Regular links (not images) should not match + const content = "[click here](/blob/did:plc:abc/bafyreiabc)"; + expect(extractBlobRefs(content)).toEqual([]); + }); }); describe("parseBlobMetadata", () => { diff --git a/tests/lib/constants.test.ts b/tests/lib/constants.test.ts new file mode 100644 --- /dev/null +++ b/tests/lib/constants.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeRole } from "../../src/lib/constants.ts"; + +describe("normalizeRole", () => { + test("passes through valid roles", () => { + expect(normalizeRole("admin")).toBe("admin"); + expect(normalizeRole("viewer")).toBe("viewer"); + }); + + test("defaults contributor for valid contributor input", () => { + expect(normalizeRole("contributor")).toBe("contributor"); + }); + + test("defaults to contributor for invalid or missing input", () => { + expect(normalizeRole(null)).toBe("contributor"); + expect(normalizeRole(undefined)).toBe("contributor"); + expect(normalizeRole("")).toBe("contributor"); + expect(normalizeRole("superadmin")).toBe("contributor"); + expect(normalizeRole("ADMIN")).toBe("contributor"); + expect(normalizeRole("'); expect(html).not.toContain("