🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Add more integration tests

juprodh 2482b104 b4980520

+337 -67
+1 -4
.tangled/workflows/ci.yml
··· 8 8 nixpkgs/nixpkgs-unstable: 9 9 - bun 10 10 - biome 11 - - nodejs_22 12 11 13 12 steps: 14 13 - name: "Install dependencies" ··· 20 19 - name: "Dead code check" 21 20 command: "bun run knip" 22 21 - name: "Unit tests" 23 - command: "bun run test:unit --coverage" 24 - - name: "Integration tests" 25 - command: "bun run test:integration" 22 + command: "bun run test --coverage"
+10
src/lib/profile.ts
··· 1 1 import type { Did, Handle } from "@atcute/lexicons/syntax"; 2 + import { getDevAccounts } from "../atproto/env.ts"; 2 3 import { 3 4 getCachedProfile, 4 5 setCachedProfile, ··· 43 44 did: string, 44 45 fetchFn: typeof fetch = fetch, 45 46 ): Promise<ProfileInfo> { 47 + // Dev DIDs don't exist on plc.directory; short-circuit to the known handle. 48 + const devAccounts = getDevAccounts(); 49 + if (devAccounts) { 50 + const account = Object.values(devAccounts).find((a) => a.did === did); 51 + if (account) { 52 + return { handle: account.handle, displayName: null, avatar: null }; 53 + } 54 + } 55 + 46 56 // Check cache first (skip for injected fetchFn, i.e. tests) 47 57 if (fetchFn === fetch) { 48 58 const cached = getCachedProfile(did);
+53
src/server/app.ts
··· 1 + import { staticPlugin } from "@elysiajs/static"; 2 + import { Elysia } from "elysia"; 3 + import { getAtprotoEnv } from "../atproto/env.ts"; 4 + import { atprotoRoutes } from "../atproto/routes.ts"; 5 + import { AppError } from "../lib/errors.ts"; 6 + import { getDb } from "./db/index.ts"; 7 + import { blobRoutes } from "./routes/blob.ts"; 8 + import { bookmarkRoutes } from "./routes/bookmark.ts"; 9 + import { exploreRoutes } from "./routes/explore.ts"; 10 + import { homeRoute } from "./routes/home.ts"; 11 + import { localeRoutes } from "./routes/locale.ts"; 12 + import { membershipRoutes } from "./routes/membership.ts"; 13 + import { noteRoutes } from "./routes/note.ts"; 14 + import { profileRoutes } from "./routes/profile.ts"; 15 + import { searchRoutes } from "./routes/search.ts"; 16 + import { wikiRoutes } from "./routes/wiki.ts"; 17 + 18 + // Dev and prod auth must not coexist: the dev-session fallback trusts a `did=` 19 + // cookie without a password check. A prod instance that also sets DEV_ACCOUNTS 20 + // would still ignore it (getDevAccounts returns null when OAuth env is set), 21 + // but bail loudly to flag misconfiguration rather than silently dropping it. 22 + if (getAtprotoEnv() !== null && process.env["DEV_ACCOUNTS"]) { 23 + throw new Error( 24 + "Dev and prod auth are mutually exclusive — unset DEV_ACCOUNTS or the OAuth env vars (PUBLIC_URL / OAUTH_PRIVATE_KEY_PATH).", 25 + ); 26 + } 27 + 28 + export function buildApp() { 29 + getDb(); 30 + return new Elysia() 31 + .onError(({ error, code }) => { 32 + if (error instanceof AppError) { 33 + return new Response(error.message, { status: error.statusCode }); 34 + } 35 + if (code === "NOT_FOUND") { 36 + return new Response("Not found", { status: 404 }); 37 + } 38 + console.error("Unhandled error:", error); 39 + return new Response("Internal server error", { status: 500 }); 40 + }) 41 + .use(staticPlugin({ prefix: "/public", assets: "public" })) 42 + .use(atprotoRoutes()) 43 + .use(blobRoutes) 44 + .use(bookmarkRoutes) 45 + .use(localeRoutes) 46 + .use(profileRoutes) 47 + .use(homeRoute) 48 + .use(exploreRoutes) 49 + .use(searchRoutes) 50 + .use(membershipRoutes) 51 + .use(noteRoutes) 52 + .use(wikiRoutes); 53 + }
+3 -53
src/server/index.ts
··· 1 - import { staticPlugin } from "@elysiajs/static"; 2 - import { Elysia } from "elysia"; 3 - import { getAtprotoEnv, isAuthEnabled } from "../atproto/env.ts"; 4 - import { atprotoRoutes } from "../atproto/routes.ts"; 5 - import { AppError } from "../lib/errors.ts"; 6 - import { getDb } from "./db/index.ts"; 7 - import { blobRoutes } from "./routes/blob.ts"; 8 - import { bookmarkRoutes } from "./routes/bookmark.ts"; 9 - import { exploreRoutes } from "./routes/explore.ts"; 10 - import { homeRoute } from "./routes/home.ts"; 11 - import { localeRoutes } from "./routes/locale.ts"; 12 - import { membershipRoutes } from "./routes/membership.ts"; 13 - import { noteRoutes } from "./routes/note.ts"; 14 - import { profileRoutes } from "./routes/profile.ts"; 15 - import { searchRoutes } from "./routes/search.ts"; 16 - import { wikiRoutes } from "./routes/wiki.ts"; 17 - 18 - // Dev and prod auth must not coexist: the dev-session fallback trusts a `did=` 19 - // cookie without a password check. A prod instance that also sets DEV_ACCOUNTS 20 - // would still ignore it (getDevAccounts returns null when OAuth env is set), 21 - // but bail loudly to flag misconfiguration rather than silently dropping it. 22 - if (getAtprotoEnv() !== null && process.env["DEV_ACCOUNTS"]) { 23 - throw new Error( 24 - "Dev and prod auth are mutually exclusive — unset DEV_ACCOUNTS or the OAuth env vars (PUBLIC_URL / OAUTH_PRIVATE_KEY_PATH).", 25 - ); 26 - } 27 - 28 - // Initialize database on startup 29 - getDb(); 1 + import { isAuthEnabled } from "../atproto/env.ts"; 2 + import { buildApp } from "./app.ts"; 30 3 31 4 if (!isAuthEnabled()) { 32 5 console.warn( ··· 34 7 ); 35 8 } 36 9 37 - const app = new Elysia() 38 - .onError(({ error, code }) => { 39 - if (error instanceof AppError) { 40 - return new Response(error.message, { status: error.statusCode }); 41 - } 42 - if (code === "NOT_FOUND") { 43 - return new Response("Not found", { status: 404 }); 44 - } 45 - console.error("Unhandled error:", error); 46 - return new Response("Internal server error", { status: 500 }); 47 - }) 48 - .use(staticPlugin({ prefix: "/public", assets: "public" })) 49 - .use(atprotoRoutes()) 50 - .use(blobRoutes) 51 - .use(bookmarkRoutes) 52 - .use(localeRoutes) 53 - .use(profileRoutes) 54 - .use(homeRoute) 55 - .use(exploreRoutes) 56 - .use(searchRoutes) 57 - .use(membershipRoutes) 58 - .use(noteRoutes) 59 - .use(wikiRoutes) 60 - .listen(3000); 10 + const app = buildApp().listen(3000); 61 11 62 12 console.log(`Lichen running at http://localhost:${app.server?.port}`);
+4 -2
tests/integration/helpers.ts
··· 8 8 handle: string; 9 9 } 10 10 11 + // DIDs match the baked-in DEV_ACCOUNTS defaults in src/atproto/env.ts so a 12 + // /dev/login HTTP request and a synthesized firehose event refer to the same user. 11 13 export const ALICE: TestAccount = { 12 - did: "did:plc:alice000000000000000000", 14 + did: "did:plc:devalice00000000000000000", 13 15 handle: "alice.test", 14 16 }; 15 17 16 18 export const BOB: TestAccount = { 17 - did: "did:plc:bob0000000000000000000000", 19 + did: "did:plc:devbob0000000000000000000", 18 20 handle: "bob.test", 19 21 }; 20 22
+59
tests/integration/http-helpers.ts
··· 1 + import { buildApp } from "../../src/server/app.ts"; 2 + 3 + const app = buildApp(); 4 + 5 + interface FetchOptions { 6 + cookie?: string; 7 + body?: FormData | string; 8 + contentType?: string; 9 + } 10 + 11 + /** Send a Request through the app without binding a port. */ 12 + export function fetch( 13 + method: string, 14 + path: string, 15 + opts: FetchOptions = {}, 16 + ): Promise<Response> { 17 + const headers: Record<string, string> = {}; 18 + if (opts.cookie) headers["cookie"] = opts.cookie; 19 + if (opts.contentType) headers["content-type"] = opts.contentType; 20 + const init: RequestInit = { method, headers }; 21 + if (opts.body !== undefined) init.body = opts.body; 22 + return app.handle(new Request(`http://localhost${path}`, init)); 23 + } 24 + 25 + const cookieCache = new Map<string, string>(); 26 + 27 + /** 28 + * Exercise /dev/login/:handle and return the `did=...` cookie. Cached per handle 29 + * since the result is deterministic. 30 + */ 31 + export async function loginCookie(handle: string): Promise<string> { 32 + const cached = cookieCache.get(handle); 33 + if (cached) return cached; 34 + const res = await fetch("GET", `/dev/login/${handle}`); 35 + if (res.status !== 302) { 36 + throw new Error(`/dev/login/${handle} returned ${res.status}`); 37 + } 38 + const cookies = res.headers.getSetCookie(); 39 + const didCookie = cookies.find((c) => c.startsWith("did=")); 40 + if (!didCookie) { 41 + throw new Error( 42 + `No did cookie in /dev/login response: ${cookies.join(",")}`, 43 + ); 44 + } 45 + const value = didCookie.split(";")[0]; 46 + if (!value) { 47 + throw new Error(`Malformed cookie: ${didCookie}`); 48 + } 49 + cookieCache.set(handle, value); 50 + return value; 51 + } 52 + 53 + export function form(fields: Record<string, string>): FormData { 54 + const fd = new FormData(); 55 + for (const [key, value] of Object.entries(fields)) { 56 + fd.append(key, value); 57 + } 58 + return fd; 59 + }
+199
tests/integration/http-roundtrip.test.ts
··· 1 + import { afterAll, describe, expect, test } from "bun:test"; 2 + import { 3 + getCurrentNote, 4 + getMemberRole, 5 + getWiki, 6 + } from "../../src/server/db/queries/index.ts"; 7 + import { cleanupWikiAndDependents } from "../helpers/cleanup.ts"; 8 + import { 9 + ALICE, 10 + BOB, 11 + createDiff, 12 + emitNote, 13 + emitRevision, 14 + emitWiki, 15 + } from "./helpers.ts"; 16 + import { fetch, form, loginCookie } from "./http-helpers.ts"; 17 + 18 + const createdSlugs: string[] = []; 19 + 20 + afterAll(() => { 21 + for (const slug of createdSlugs) { 22 + cleanupWikiAndDependents(slug); 23 + } 24 + }); 25 + 26 + async function slugFromRedirect(res: Response): Promise<string> { 27 + const location = res.headers.get("location"); 28 + if (!location) { 29 + const body = await res.text(); 30 + throw new Error( 31 + `Expected redirect, got ${res.status}: ${body.slice(0, 500)}`, 32 + ); 33 + } 34 + const parts = location.split("/"); 35 + const slug = parts[2]; 36 + if (!slug) throw new Error(`Could not parse slug from ${location}`); 37 + return slug; 38 + } 39 + 40 + describe("HTTP forward flow: user → route → orchestrator → DB", () => { 41 + test("alice creates a wiki via POST /wiki/new and reads it back", async () => { 42 + const cookie = await loginCookie("alice.test"); 43 + 44 + const res = await fetch("POST", "/wiki/new", { 45 + cookie, 46 + body: form({ 47 + name: "Alice HTTP Wiki", 48 + language: "en", 49 + visibility: "public", 50 + description: "Created via HTTP test", 51 + }), 52 + }); 53 + expect(res.status).toBe(302); 54 + const slug = await slugFromRedirect(res); 55 + createdSlugs.push(slug); 56 + 57 + const wiki = getWiki(slug); 58 + expect(wiki?.name).toBe("Alice HTTP Wiki"); 59 + expect(wiki?.did).toBe(ALICE.did); 60 + expect(getMemberRole(slug, ALICE.did)).toBe("admin"); 61 + 62 + const page = await fetch("GET", `/wiki/${slug}`); 63 + expect(page.status).toBe(200); 64 + expect(await page.text()).toContain("Alice HTTP Wiki"); 65 + }); 66 + 67 + test("alice creates and edits a note; revision chain visible via HTTP", async () => { 68 + const cookie = await loginCookie("alice.test"); 69 + 70 + const wikiRes = await fetch("POST", "/wiki/new", { 71 + cookie, 72 + body: form({ 73 + name: "Alice Note Wiki", 74 + language: "en", 75 + visibility: "public", 76 + description: "", 77 + }), 78 + }); 79 + const wikiSlug = await slugFromRedirect(wikiRes); 80 + createdSlugs.push(wikiSlug); 81 + 82 + const createRes = await fetch("POST", `/wiki/${wikiSlug}/new`, { 83 + cookie, 84 + body: form({ 85 + title: "First Note", 86 + content: "Initial content", 87 + }), 88 + }); 89 + expect(createRes.status).toBe(302); 90 + const noteLocation = createRes.headers.get("location"); 91 + expect(noteLocation).toBe(`/wiki/${wikiSlug}/first-note`); 92 + 93 + const initial = getCurrentNote(wikiSlug, "first-note"); 94 + expect(initial?.content).toBe("Initial content"); 95 + 96 + const editRes = await fetch("POST", `/wiki/${wikiSlug}/first-note/edit`, { 97 + cookie, 98 + body: form({ 99 + title: "First Note", 100 + content: "Edited content", 101 + }), 102 + }); 103 + expect(editRes.status).toBe(302); 104 + 105 + const updated = getCurrentNote(wikiSlug, "first-note"); 106 + expect(updated?.content).toBe("Edited content"); 107 + 108 + const page = await fetch("GET", `/wiki/${wikiSlug}/first-note`); 109 + expect(page.status).toBe(200); 110 + expect(await page.text()).toContain("Edited content"); 111 + }); 112 + 113 + test("bob is denied access to alice's private wiki", async () => { 114 + const aliceC = await loginCookie("alice.test"); 115 + const bobC = await loginCookie("bob.test"); 116 + 117 + const res = await fetch("POST", "/wiki/new", { 118 + cookie: aliceC, 119 + body: form({ 120 + name: "Alice Private Wiki", 121 + language: "en", 122 + visibility: "private", 123 + description: "", 124 + }), 125 + }); 126 + const slug = await slugFromRedirect(res); 127 + createdSlugs.push(slug); 128 + 129 + // Bob is logged in but not a member — wiki page returns 403 130 + const bobView = await fetch("GET", `/wiki/${slug}`, { cookie: bobC }); 131 + expect(bobView.status).toBe(403); 132 + 133 + // Bob cannot create notes either 134 + const bobEdit = await fetch("POST", `/wiki/${slug}/new`, { 135 + cookie: bobC, 136 + body: form({ title: "Sneaky", content: "..." }), 137 + }); 138 + expect(bobEdit.status).toBe(403); 139 + 140 + // Alice can read her own wiki 141 + const aliceView = await fetch("GET", `/wiki/${slug}`, { cookie: aliceC }); 142 + expect(aliceView.status).toBe(200); 143 + }); 144 + 145 + test("unauthenticated POST /wiki/new is forbidden", async () => { 146 + const res = await fetch("POST", "/wiki/new", { 147 + body: form({ 148 + name: "Anon Wiki", 149 + language: "en", 150 + visibility: "public", 151 + description: "", 152 + }), 153 + }); 154 + expect(res.status).toBe(403); 155 + }); 156 + }); 157 + 158 + describe("HTTP reverse flow: firehose synthesis → DB → HTTP read", () => { 159 + test("synthesized remote wiki is reachable via GET /wiki/<slug>", async () => { 160 + const { rkey } = emitWiki( 161 + BOB.did, 162 + "Reverse Wiki", 163 + "public", 164 + "reverse-wiki", 165 + ); 166 + createdSlugs.push(rkey); 167 + 168 + const res = await fetch("GET", `/wiki/${rkey}`); 169 + expect(res.status).toBe(200); 170 + expect(await res.text()).toContain("Reverse Wiki"); 171 + }); 172 + 173 + test("synthesized revision is reflected in subsequent note GET", async () => { 174 + const { uri: wikiUri, rkey } = emitWiki( 175 + BOB.did, 176 + "Reverse Note Wiki", 177 + "public", 178 + "reverse-note-wiki", 179 + ); 180 + createdSlugs.push(rkey); 181 + 182 + const { uri: noteUri } = emitNote( 183 + BOB.did, 184 + "remote-note", 185 + "Remote Note", 186 + wikiUri, 187 + ); 188 + emitRevision( 189 + BOB.did, 190 + noteUri, 191 + createDiff("", "Synthesized via firehose"), 192 + "initial", 193 + ); 194 + 195 + const res = await fetch("GET", `/wiki/${rkey}/remote-note`); 196 + expect(res.status).toBe(200); 197 + expect(await res.text()).toContain("Synthesized via firehose"); 198 + }); 199 + });
+1 -1
tests/lib/access-context.test.ts
··· 16 16 getClient: async () => null, 17 17 getSession: async () => null, 18 18 getDevSession: () => null, 19 - getAgent: async () => null, 19 + getAgent: () => null, 20 20 })); 21 21 22 22 const { resolveWikiContext, resolveWikiContextSoft } = await import(
+1 -1
tests/lib/import-export/export.test.ts
··· 27 27 })); 28 28 mock.module("../../../src/atproto/session.ts", () => ({ 29 29 ...realSession, 30 - getAgent: mock(async () => ({}) as never), 30 + getAgent: mock(() => ({}) as never), 31 31 })); 32 32 33 33 const { createWikiAction } = await import(
+1 -1
tests/lib/import-export/import.test.ts
··· 24 24 uri: "at://did:plc:importtest/wiki.lichen.noteRevision/rev1", 25 25 cid: "bafyreirev", 26 26 })); 27 - const mockGetAgent = mock(async () => ({}) as never); 27 + const mockGetAgent = mock(() => ({}) as never); 28 28 29 29 mock.module("../../../src/atproto/pds.ts", () => ({ 30 30 ...realPds,
+1 -1
tests/lib/orchestrators/bookmark.test.ts
··· 9 9 cid: "bafyrei123", 10 10 })); 11 11 const mockDeleteRecord = mock(async () => {}); 12 - const mockGetAgent = mock(async () => ({}) as never); 12 + const mockGetAgent = mock(() => ({}) as never); 13 13 14 14 mock.module("../../../src/atproto/pds.ts", () => ({ 15 15 ...realPds,
+1 -1
tests/lib/orchestrators/membership.test.ts
··· 16 16 cid: "bafyrei456", 17 17 })); 18 18 const mockDeleteRecord = mock(async () => {}); 19 - const mockGetAgent = mock(async () => ({}) as never); 19 + const mockGetAgent = mock(() => ({}) as never); 20 20 21 21 mock.module("../../../src/atproto/pds.ts", () => ({ 22 22 ...realPds,
+1 -1
tests/lib/orchestrators/note.test.ts
··· 15 15 uri: "at://did:plc:test/wiki.lichen.noteRevision/def", 16 16 cid: "bafyrei456", 17 17 })); 18 - const mockGetAgent = mock(async () => ({}) as never); 18 + const mockGetAgent = mock(() => ({}) as never); 19 19 20 20 mock.module("../../../src/atproto/pds.ts", () => ({ 21 21 ...realPds,
+1 -1
tests/lib/orchestrators/wiki.test.ts
··· 27 27 cid: "bafyreirev", 28 28 })); 29 29 const mockDeleteRecord = mock(async () => {}); 30 - const mockGetAgent = mock(async () => ({}) as never); 30 + const mockGetAgent = mock(() => ({}) as never); 31 31 32 32 mock.module("../../../src/atproto/pds.ts", () => ({ 33 33 ...realPds,
+1 -1
tests/server/routes/helpers.ts
··· 20 20 getClient: async () => null, 21 21 getSession: async () => null, 22 22 getDevSession: () => null, 23 - getAgent: async () => ({}), 23 + getAgent: () => ({}), 24 24 })); 25 25 26 26 // Mock PDS writes so route tests don't need a real PDS