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 = "";
const refs = extractBlobRefs(content);
expect(refs).toEqual([{ did: "did:plc:abc", cid: "bafyrei123ABCdef456" }]);
});
+
+ test("ignores malformed blob URLs (missing did prefix)", () => {
+ const content = "";
+ expect(extractBlobRefs(content)).toEqual([]);
+ });
+
+ test("ignores blob URL without CID", () => {
+ const content = "";
+ 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("