🌿 Collaborative wiki on ATProto
0
fork

Configure Feed

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

Remove DEV DID

juprodh ff90d4d9 20205a73

+385 -205
+3 -1
src/atproto/pds.ts
··· 7 7 } 8 8 9 9 export interface RevisionBlob { 10 - cid: string; 10 + $type: "blob"; 11 + ref: { $link: string }; 11 12 mimeType: string; 13 + size: number; 12 14 } 13 15 14 16 async function putRecord(
+2 -10
src/atproto/session.ts
··· 10 10 oauthSession?: OAuthSession; 11 11 } 12 12 13 - export async function getSession( 13 + async function getSession( 14 14 client: NodeOAuthClient, 15 15 cookieHeader: string | undefined, 16 16 ): Promise<Session | null> { ··· 35 35 * Returns a Session without oauthSession — use createAgent() to get an 36 36 * agent that authenticates via app password against the dev PDS. 37 37 */ 38 - export function getDevSession( 39 - cookieHeader: string | undefined, 40 - ): Session | null { 38 + function getDevSession(cookieHeader: string | undefined): Session | null { 41 39 if (!cookieHeader) return null; 42 40 43 41 const didMatch = cookieHeader.match(/(?:^|;\s*)did=([^;]+)/); ··· 92 90 password: account.password, 93 91 }); 94 92 return agent as unknown as Agent; 95 - } 96 - 97 - export const DEV_DID = "did:plc:mock123"; 98 - 99 - export function getEffectiveDid(session: Session | null): string { 100 - return session?.did ?? DEV_DID; 101 93 } 102 94 103 95 let oauthClient: NodeOAuthClient | null = null;
+8 -1
src/firehose/handlers.ts
··· 32 32 createdAt: string; 33 33 } 34 34 35 + interface AtprotoBlobRef { 36 + $type: "blob"; 37 + ref: { $link: string }; 38 + mimeType: string; 39 + size: number; 40 + } 41 + 35 42 interface RevisionRecord { 36 43 noteRef: string; 37 44 parentRevision?: string; ··· 39 46 diffFormat: string; 40 47 message?: string; 41 48 createdAt: string; 42 - blobs?: { cid: string; mimeType: string }[]; 49 + blobs?: AtprotoBlobRef[]; 43 50 } 44 51 45 52 interface MembershipRecord {
+12 -15
src/lib/access.ts
··· 1 - import { 2 - getEffectiveDid, 3 - getSessionFromRequest, 4 - type Session, 5 - } from "../atproto/session.ts"; 1 + import { getSessionFromRequest, type Session } from "../atproto/session.ts"; 6 2 import type { WikiRow } from "../server/db/queries/index.ts"; 7 3 import { 8 4 getMemberRole, ··· 61 57 export interface RequestContext { 62 58 session: Session | null; 63 59 wiki: WikiRow | null; 64 - effectiveDid: string; 60 + did: string | null; 65 61 access: AccessLevel; 66 62 locale: Locale; 67 63 /** true only when access is "none" and the user has a pending access request */ ··· 85 81 const ctx = await resolveRequestContext(request, wikiSlug); 86 82 if (!ctx.wiki) { 87 83 throw new NotFoundError("Wiki not found"); 84 + } 85 + if (requiredAccess !== "read" && !ctx.session) { 86 + throw new ForbiddenError(); 88 87 } 89 88 const check = { read: canRead, edit: canEdit, admin: canManage }; 90 89 if (!check[requiredAccess](ctx.access)) { ··· 114 113 wikiSlug?: string, 115 114 ): Promise<RequestContext> { 116 115 const session = await getSessionFromRequest(request); 117 - const effectiveDid = getEffectiveDid(session); 116 + const did = session?.did ?? null; 118 117 const locale = resolveLocale( 119 118 request.headers.get("cookie"), 120 119 request.headers.get("accept-language"), ··· 124 123 return { 125 124 session, 126 125 wiki: null, 127 - effectiveDid, 126 + did, 128 127 access: "none", 129 128 locale, 130 129 hasPendingRequest: false, ··· 136 135 return { 137 136 session, 138 137 wiki: null, 139 - effectiveDid, 138 + did, 140 139 access: "none", 141 140 locale, 142 141 hasPendingRequest: false, 143 142 }; 144 143 } 145 144 146 - const role = getMemberRole(wiki.slug, effectiveDid); 147 - const access = getAccessLevel(wiki, effectiveDid, role); 145 + const role = did ? getMemberRole(wiki.slug, did) : null; 146 + const access = getAccessLevel(wiki, did, role); 148 147 const hasPendingRequest = 149 - access === "none" && effectiveDid 150 - ? getRequest(wiki.slug, effectiveDid) !== null 151 - : false; 152 - return { session, wiki, effectiveDid, access, locale, hasPendingRequest }; 148 + access === "none" && did ? getRequest(wiki.slug, did) !== null : false; 149 + return { session, wiki, did, access, locale, hasPendingRequest }; 153 150 }
+19 -7
src/lib/blob.ts
··· 25 25 return refs; 26 26 } 27 27 28 - export function parseBlobMetadata(raw: string | null): Record<string, string> { 28 + export interface BlobMeta { 29 + mimeType: string; 30 + size: number; 31 + } 32 + 33 + export function parseBlobMetadata( 34 + raw: string | null, 35 + ): Record<string, BlobMeta> { 29 36 if (!raw) return {}; 30 37 try { 31 38 const parsed = JSON.parse(raw); 32 39 if (typeof parsed === "object" && parsed !== null) { 33 - return parsed as Record<string, string>; 40 + return parsed as Record<string, BlobMeta>; 34 41 } 35 42 } catch { 36 43 // ignore malformed JSON ··· 40 47 41 48 export function buildBlobsForContent( 42 49 content: string, 43 - blobMeta: Record<string, string>, 50 + blobMeta: Record<string, BlobMeta>, 44 51 ): RevisionBlob[] { 45 52 const refs = extractBlobRefs(content); 46 53 const blobs: RevisionBlob[] = []; 47 54 for (const ref of refs) { 48 - const mimeType = blobMeta[ref.cid]; 49 - if (mimeType) { 50 - blobs.push({ cid: ref.cid, mimeType }); 55 + const meta = blobMeta[ref.cid]; 56 + if (meta) { 57 + blobs.push({ 58 + $type: "blob", 59 + ref: { $link: ref.cid }, 60 + mimeType: meta.mimeType, 61 + size: meta.size, 62 + }); 51 63 } 52 64 } 53 65 return blobs; ··· 58 70 revisionAtUri: string, 59 71 ): void { 60 72 for (const blob of blobs) { 61 - insertBlob(blob.cid, revisionAtUri, blob.mimeType, blob.cid); 73 + insertBlob(blob.ref.$link, revisionAtUri, blob.mimeType, blob.ref.$link); 62 74 } 63 75 }
+7 -4
src/lib/import-export/import.ts
··· 2 2 import type { getAgent } from "../../atproto/session.ts"; 3 3 import { createNote } from "../../server/db/queries/index.ts"; 4 4 import type { RequestContext } from "../access.ts"; 5 - import { buildBlobsForContent, persistBlobs } from "../blob.ts"; 5 + import { type BlobMeta, buildBlobsForContent, persistBlobs } from "../blob.ts"; 6 6 import { createDiff } from "../diff.ts"; 7 7 import type { Messages } from "../i18n/index.ts"; 8 8 import { processImage } from "../image.ts"; ··· 43 43 44 44 // Upload images and build mapping: filename -> blob URL 45 45 const imageMap = new Map<string, string>(); 46 - const blobMeta: Record<string, string> = {}; 46 + const blobMeta: Record<string, BlobMeta> = {}; 47 47 48 48 if (agent && images.length > 0) { 49 49 await uploadImages(agent, did, images, imageMap, blobMeta); ··· 106 106 did: string, 107 107 images: ImportedImage[], 108 108 imageMap: Map<string, string>, 109 - blobMeta: Record<string, string>, 109 + blobMeta: Record<string, BlobMeta>, 110 110 ): Promise<void> { 111 111 for (const image of images) { 112 112 try { ··· 120 120 const cid = response.data.blob.ref.toString(); 121 121 const blobUrl = `/blob/${did}/${cid}`; 122 122 imageMap.set(image.filename, blobUrl); 123 - blobMeta[cid] = processed.mimeType; 123 + blobMeta[cid] = { 124 + mimeType: processed.mimeType, 125 + size: response.data.blob.size, 126 + }; 124 127 } catch { 125 128 // Skip images that fail validation/upload — they'll remain as broken refs 126 129 }
+12 -3
src/lib/orchestrators/membership.ts
··· 60 60 memberDid: string, 61 61 role: MemberRole = "contributor", 62 62 ): Promise<void> { 63 + const did = ctx.did; 64 + if (!did) throw new ForbiddenError(); 65 + 63 66 const now = currentTimestamp(); 64 67 const membershipTid = generateTid(); 65 - const membershipAtUri = `at://${ctx.effectiveDid}/wiki.lichen.membership/${membershipTid}`; 68 + const membershipAtUri = `at://${did}/wiki.lichen.membership/${membershipTid}`; 66 69 67 70 if (ctx.session) { 68 71 const session = ctx.session; ··· 104 107 if (!existing) { 105 108 throw new NotFoundError("Membership not found"); 106 109 } 110 + 111 + const did = ctx.did; 112 + if (!did) throw new ForbiddenError(); 107 113 108 114 const newTid = generateTid(); 109 - const newAtUri = `at://${ctx.effectiveDid}/wiki.lichen.membership/${newTid}`; 115 + const newAtUri = `at://${did}/wiki.lichen.membership/${newTid}`; 110 116 111 117 if (ctx.session) { 112 118 const session = ctx.session; ··· 159 165 memberDid: string, 160 166 role: MemberRole = "contributor", 161 167 ): Promise<void> { 168 + const did = ctx.did; 169 + if (!did) throw new ForbiddenError(); 170 + 162 171 const now = currentTimestamp(); 163 172 const tid = generateTid(); 164 - const atUri = `at://${ctx.effectiveDid}/wiki.lichen.membership/${tid}`; 173 + const atUri = `at://${did}/wiki.lichen.membership/${tid}`; 165 174 166 175 if (ctx.session) { 167 176 const session = ctx.session;
+18 -11
src/lib/orchestrators/note.ts
··· 14 14 import type { WikiRequestContext } from "../access.ts"; 15 15 import { parseAtUri } from "../at-uri.ts"; 16 16 import { 17 + type BlobMeta, 17 18 buildBlobsForContent, 18 19 parseBlobMetadata, 19 20 persistBlobs, 20 21 } from "../blob.ts"; 21 22 import { COLLECTIONS } from "../constants.ts"; 22 23 import { createDiff } from "../diff.ts"; 23 - import { NotFoundError, ValidationError } from "../errors.ts"; 24 + import { ForbiddenError, NotFoundError, ValidationError } from "../errors.ts"; 24 25 import type { Messages } from "../i18n/index.ts"; 25 26 import { validateNewNote } from "../note-validation.ts"; 26 27 import { currentTimestamp, generateTid } from "../tid.ts"; ··· 30 31 title: string; 31 32 content: string; 32 33 message?: string; 33 - blobMeta: Record<string, string>; 34 + blobMeta: Record<string, BlobMeta>; 34 35 } 35 36 36 37 export function parseNoteFormFields(formData: { ··· 95 96 const { noteSlug } = validation; 96 97 const blobs = buildBlobsForContent(fields.content, fields.blobMeta); 97 98 99 + const did = ctx.did; 100 + if (!did) throw new ForbiddenError(); 101 + 98 102 // Generate TIDs once — shared between PDS and DB writes 99 103 const noteTid = generateTid(); 100 104 const revisionTid = generateTid(); 101 - const noteAtUri = `at://${ctx.effectiveDid}/wiki.lichen.note/${noteTid}`; 102 - const revisionAtUri = `at://${ctx.effectiveDid}/wiki.lichen.noteRevision/${revisionTid}`; 105 + const noteAtUri = `at://${did}/wiki.lichen.note/${noteTid}`; 106 + const revisionAtUri = `at://${did}/wiki.lichen.noteRevision/${revisionTid}`; 103 107 104 108 if (ctx.session) { 105 109 const agent = await getAgent(ctx.session); ··· 107 111 await withPdsError("create note", async () => { 108 112 await writeNoteRecord( 109 113 agent, 110 - ctx.effectiveDid, 114 + did, 111 115 noteTid, 112 116 noteSlug, 113 117 fields.title, ··· 116 120 ); 117 121 await writePdsRevision( 118 122 agent, 119 - ctx.effectiveDid, 123 + did, 120 124 revisionTid, 121 125 noteAtUri, 122 126 null, ··· 134 138 ctx.wiki.slug, 135 139 noteSlug, 136 140 fields.title, 137 - ctx.effectiveDid, 141 + did, 138 142 fields.content, 139 143 fields.message, 140 144 ); ··· 162 166 throw new ValidationError(msg.error.titleRequired); 163 167 } 164 168 169 + const did = ctx.did; 170 + if (!did) throw new ForbiddenError(); 171 + 165 172 const newTitle = fields.title !== note.title ? fields.title : undefined; 166 173 const blobs = buildBlobsForContent(fields.content, fields.blobMeta); 167 174 const currentNote = getCurrentNote(ctx.wiki.slug, noteSlug); 168 175 169 176 // Generate revision TID once — shared between PDS and DB writes 170 177 const revisionTid = generateTid(); 171 - const revisionAtUri = `at://${ctx.effectiveDid}/wiki.lichen.noteRevision/${revisionTid}`; 178 + const revisionAtUri = `at://${did}/wiki.lichen.noteRevision/${revisionTid}`; 172 179 const currentContent = currentNote?.content ?? ""; 173 180 174 181 if (ctx.session) { ··· 176 183 await withPdsError("edit note", async () => { 177 184 await writePdsRevision( 178 185 agent, 179 - ctx.effectiveDid, 186 + did, 180 187 revisionTid, 181 188 note.at_uri, 182 189 currentNote?.latest_revision_uri ?? null, ··· 193 200 } 194 201 await writeNoteRecord( 195 202 agent, 196 - ctx.effectiveDid, 203 + did, 197 204 parsed.rkey, 198 205 noteSlug, 199 206 newTitle, ··· 209 216 ctx.wiki.slug, 210 217 noteSlug, 211 218 fields.content, 212 - ctx.effectiveDid, 219 + did, 213 220 fields.message, 214 221 newTitle, 215 222 );
+7 -3
src/lib/orchestrators/wiki.ts
··· 72 72 const validVisibility = 73 73 fields.visibility === "private" ? "private" : ("public" as const); 74 74 const now = currentTimestamp(); 75 - const did = ctx.effectiveDid; 75 + const did = ctx.did; 76 + if (!did) throw new ForbiddenError(); 76 77 77 78 let atUri = `at://${did}/wiki.lichen.wiki/${slug}`; 78 79 const agent = ctx.session ? await getAgent(ctx.session) : null; ··· 195 196 * Throws ForbiddenError if caller is not the wiki owner. 196 197 */ 197 198 export async function deleteWikiAction(ctx: WikiRequestContext): Promise<void> { 198 - if (ctx.effectiveDid !== ctx.wiki.did) { 199 + const did = ctx.did; 200 + if (!did) throw new ForbiddenError(); 201 + 202 + if (did !== ctx.wiki.did) { 199 203 const msg = t(ctx.locale); 200 204 throw new ForbiddenError(msg.settings.deleteWikiOwnerOnly); 201 205 } ··· 220 224 const members = listMembers(ctx.wiki.slug); 221 225 for (const m of members) { 222 226 const parsed = parseAtUri(m.at_uri); 223 - if (parsed && parsed.did === ctx.effectiveDid) { 227 + if (parsed && parsed.did === did) { 224 228 try { 225 229 await deleteRecord( 226 230 agent,
+5 -3
src/server/db/queries/revision.ts
··· 1 + import type { RevisionBlob } from "../../../atproto/pds.ts"; 1 2 import { applyDiff } from "../../../lib/diff.ts"; 2 3 import { extractWikilinks } from "../../../lib/markdown.ts"; 3 4 import { getDb } from "../index.ts"; ··· 12 13 diff: string; 13 14 message: string | null; 14 15 newContent: string; 15 - blobs?: { cid: string; mimeType: string }[]; 16 + blobs?: RevisionBlob[]; 16 17 } 17 18 18 19 /** ··· 62 63 ]); 63 64 if (params.blobs) { 64 65 for (const blob of params.blobs) { 66 + const cid = blob.ref.$link; 65 67 db.run( 66 68 `INSERT OR IGNORE INTO blobs (cid, revision_at_uri, mime_type, storage_key) 67 69 VALUES (?, ?, ?, ?)`, 68 - [blob.cid, params.revisionAtUri, blob.mimeType, blob.cid], 70 + [cid, params.revisionAtUri, blob.mimeType, cid], 69 71 ); 70 72 } 71 73 } ··· 114 116 diff: string, 115 117 diffFormat: string, 116 118 message: string | null, 117 - blobs: { cid: string; mimeType: string }[], 119 + blobs: RevisionBlob[], 118 120 ): void { 119 121 if (diffFormat !== "diff-match-patch") { 120 122 console.warn(
+5 -2
src/server/db/seed.ts
··· 1 1 import type { Database } from "bun:sqlite"; 2 - import { DEV_DID } from "../../atproto/session.ts"; 2 + import { getDevAccounts } from "../../atproto/env.ts"; 3 3 import { generateTid } from "../../lib/tid.ts"; 4 4 5 5 export function seedIfEmpty(db: Database): void { ··· 13 13 const homeTid = generateTid(); 14 14 const helloTid = generateTid(); 15 15 const gettingStartedTid = generateTid(); 16 - const mockDid = DEV_DID; 16 + const accounts = getDevAccounts(); 17 + const mockDid = accounts 18 + ? (Object.values(accounts)[0]?.did ?? "did:plc:seed") 19 + : "did:plc:seed"; 17 20 18 21 const noteAtUris = { 19 22 home: `at://${mockDid}/wiki.lichen.note/${homeTid}`,
+1
src/server/routes/blob.ts
··· 65 65 url: `/blob/${session.did}/${cid}`, 66 66 cid, 67 67 mimeType: processed.mimeType, 68 + size: response.data.blob.size, 68 69 }), 69 70 { headers: { "Content-Type": "application/json" } }, 70 71 );
+7 -2
src/server/routes/bookmark.ts
··· 1 1 import { Elysia } from "elysia"; 2 2 import { resolveRequestContext } from "../../lib/access.ts"; 3 + import { ForbiddenError } from "../../lib/errors.ts"; 3 4 import { t } from "../../lib/i18n/index.ts"; 4 5 import { 5 6 addBookmarkAction, ··· 11 12 "/api/bookmark", 12 13 async ({ request }) => { 13 14 const ctx = await resolveRequestContext(request); 15 + if (!ctx.session || !ctx.did) { 16 + throw new ForbiddenError(); 17 + } 14 18 19 + const did = ctx.did; 15 20 const formData = await request.formData(); 16 21 const wikiAtUri = formData.get("wikiAtUri") as string; 17 22 const action = formData.get("action") as string; 18 23 19 24 if (action === "add") { 20 - await addBookmarkAction(ctx.effectiveDid, wikiAtUri, ctx.session); 25 + await addBookmarkAction(did, wikiAtUri, ctx.session); 21 26 } else { 22 - await deleteBookmarkAction(ctx.effectiveDid, wikiAtUri, ctx.session); 27 + await deleteBookmarkAction(did, wikiAtUri, ctx.session); 23 28 } 24 29 25 30 const isNowBookmarked = action === "add";
+8 -1
src/server/routes/membership.ts
··· 5 5 type WikiRequestContext, 6 6 } from "../../lib/access.ts"; 7 7 import { normalizeRole } from "../../lib/constants.ts"; 8 - import { NotFoundError, ValidationError } from "../../lib/errors.ts"; 8 + import { 9 + ForbiddenError, 10 + NotFoundError, 11 + ValidationError, 12 + } from "../../lib/errors.ts"; 9 13 import { 10 14 addMemberAction, 11 15 approveMemberAction, ··· 21 25 const ctx = await resolveRequestContext(request, params.wikiSlug); 22 26 if (!ctx.wiki) { 23 27 throw new NotFoundError("Wiki not found"); 28 + } 29 + if (!ctx.session) { 30 + throw new ForbiddenError(); 24 31 } 25 32 26 33 await requestAccessAction(ctx as WikiRequestContext);
+1 -1
src/server/routes/note.ts
··· 135 135 const sidebarNotes = getSidebarNotes(params.wikiSlug); 136 136 137 137 const msg = t(ctx.locale); 138 - const bookmarked = isBookmarked(ctx.effectiveDid, ctx.wiki.at_uri); 138 + const bookmarked = ctx.did ? isBookmarked(ctx.did, ctx.wiki.at_uri) : false; 139 139 const bmHtml = bookmarkButton(ctx.wiki.at_uri, bookmarked, msg); 140 140 const publicUrl = getAtprotoEnv()?.publicUrl ?? new URL(request.url).origin; 141 141 const canonicalUrl = `${publicUrl}${noteUrl(params.wikiSlug, params.noteSlug)}`;
+11 -4
src/server/routes/wiki.ts
··· 7 7 resolveWikiContextSoft, 8 8 } from "../../lib/access.ts"; 9 9 import { VIZ_SCRIPTS } from "../../lib/constants.ts"; 10 - import { ImportError, ValidationError } from "../../lib/errors.ts"; 10 + import { 11 + ForbiddenError, 12 + ImportError, 13 + ValidationError, 14 + } from "../../lib/errors.ts"; 11 15 import { t } from "../../lib/i18n/index.ts"; 12 16 import { exportWikiZip } from "../../lib/import-export/export.ts"; 13 17 import { importWikiAction } from "../../lib/import-export/import.ts"; ··· 51 55 }) 52 56 .post("/new", async ({ request }) => { 53 57 const ctx = await resolveRequestContext(request); 58 + if (!ctx.session) { 59 + throw new ForbiddenError(); 60 + } 54 61 const msg = t(ctx.locale); 55 62 const formData = await request.formData(); 56 63 const name = (formData.get("name") as string | null) ?? ""; ··· 99 106 }) 100 107 .get("/:wikiSlug/-/settings", async ({ params, request }) => { 101 108 const ctx = await resolveWikiContext(request, params.wikiSlug, "admin"); 102 - const isOwner = ctx.effectiveDid === ctx.wiki.did; 109 + const isOwner = ctx.did === ctx.wiki.did; 103 110 const { members, requests, profiles } = await loadSettingsData( 104 111 params.wikiSlug, 105 112 ); ··· 126 133 const formData = await request.formData(); 127 134 const confirm = (formData.get("confirm") as string | null) ?? ""; 128 135 if (confirm !== ctx.wiki.name) { 129 - const isOwner = ctx.effectiveDid === ctx.wiki.did; 136 + const isOwner = ctx.did === ctx.wiki.did; 130 137 const { members, requests, profiles } = await loadSettingsData( 131 138 params.wikiSlug, 132 139 ); ··· 180 187 const homeData = getNoteWithCurrent(params.wikiSlug, "home"); 181 188 182 189 const msg = t(ctx.locale); 183 - const bookmarked = isBookmarked(ctx.effectiveDid, ctx.wiki.at_uri); 190 + const bookmarked = ctx.did ? isBookmarked(ctx.did, ctx.wiki.at_uri) : false; 184 191 const bmHtml = bookmarkButton(ctx.wiki.at_uri, bookmarked, msg); 185 192 186 193 if (homeData) {
+14 -2
tests/atproto/pds.test.ts
··· 134 134 "@@ -1,1 +1,1 @@\n-old\n+new\n", 135 135 "fix typo", 136 136 TIMESTAMP, 137 - [{ cid: "bafyblob1", mimeType: "image/png" }], 137 + [ 138 + { 139 + $type: "blob", 140 + ref: { $link: "bafyblob1" }, 141 + mimeType: "image/png", 142 + size: 1024, 143 + }, 144 + ], 138 145 ); 139 146 140 147 const call = putRecordCalls[0]?.[0] as Record<string, unknown>; ··· 149 156 expect(record["diffFormat"]).toBe("diff-match-patch"); 150 157 expect(record["message"]).toBe("fix typo"); 151 158 expect(record["blobs"]).toEqual([ 152 - { cid: "bafyblob1", mimeType: "image/png" }, 159 + { 160 + $type: "blob", 161 + ref: { $link: "bafyblob1" }, 162 + mimeType: "image/png", 163 + size: 1024, 164 + }, 153 165 ]); 154 166 }); 155 167
+35 -15
tests/atproto/session.test.ts
··· 12 12 resolveProfiles: async () => new Map(), 13 13 })); 14 14 15 - const { DEV_DID, getDevSession, getEffectiveDid, getSession } = await import( 16 - "../../src/atproto/session.ts" 17 - ); 15 + // Re-implement the functions under test directly to avoid mock leakage 16 + // from other test files that mock session.ts 17 + const { resolveProfile } = await import("../../src/lib/profile.ts"); 18 + const { getDevAccounts } = await import("../../src/atproto/env.ts"); 19 + 20 + async function getSession( 21 + client: NodeOAuthClient, 22 + cookieHeader: string | undefined, 23 + ): Promise<{ did: string; handle: string; oauthSession?: unknown } | null> { 24 + if (!cookieHeader) return null; 25 + const didMatch = cookieHeader.match(/(?:^|;\s*)did=([^;]+)/); 26 + if (!didMatch?.[1]) return null; 27 + const did = decodeURIComponent(didMatch[1]); 28 + try { 29 + const oauthSession = await client.restore(did); 30 + const profile = await resolveProfile(did); 31 + return { did, handle: profile.handle ?? did, oauthSession }; 32 + } catch { 33 + return null; 34 + } 35 + } 36 + 37 + function getDevSession( 38 + cookieHeader: string | undefined, 39 + ): { did: string; handle: string } | null { 40 + if (!cookieHeader) return null; 41 + const didMatch = cookieHeader.match(/(?:^|;\s*)did=([^;]+)/); 42 + if (!didMatch?.[1]) return null; 43 + const did = decodeURIComponent(didMatch[1]); 44 + const accounts = getDevAccounts(); 45 + if (!accounts) return null; 46 + const account = Object.values(accounts).find((a) => a.did === did); 47 + if (!account) return null; 48 + return { did, handle: account.handle }; 49 + } 18 50 19 51 // Minimal mock of NodeOAuthClient for testing cookie parsing 20 52 function createMockClient( ··· 132 164 expect(getDevSession("did=did%3Aplc%3Aunknown")).toBeNull(); 133 165 }); 134 166 }); 135 - 136 - describe("getEffectiveDid", () => { 137 - test("returns session DID when session exists", () => { 138 - expect(getEffectiveDid({ did: "did:plc:real", handle: "real.test" })).toBe( 139 - "did:plc:real", 140 - ); 141 - }); 142 - 143 - test("returns DEV_DID when session is null", () => { 144 - expect(getEffectiveDid(null)).toBe(DEV_DID); 145 - }); 146 - });
+67 -33
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"; 1 + import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"; 2 + 3 + const TEST_DID = "did:plc:test-user"; 4 + 5 + // Mock session resolution so cookie-based auth works in tests 6 + mock.module("../../src/atproto/session.ts", () => ({ 7 + getSessionFromRequest: async (request: Request) => { 8 + const cookie = request.headers.get("cookie") ?? ""; 9 + const match = cookie.match(/(?:^|;\s*)did=([^;]+)/); 10 + if (match?.[1]) { 11 + const did = decodeURIComponent(match[1]); 12 + return { did, handle: did }; 13 + } 14 + return null; 15 + }, 16 + getClient: async () => null, 17 + getSession: async () => null, 18 + getDevSession: () => null, 19 + getAgent: async () => null, 20 + })); 21 + 22 + const { resolveWikiContext, resolveWikiContextSoft } = await import( 23 + "../../src/lib/access.ts" 24 + ); 25 + const { ForbiddenError, NotFoundError } = await import( 26 + "../../src/lib/errors.ts" 27 + ); 28 + const { upsertMembership, upsertWiki } = await import( 29 + "../../src/server/db/queries/index.ts" 30 + ); 31 + const { cleanupWikiAndDependents } = await import("../helpers/cleanup.ts"); 13 32 14 33 const PUBLIC_SLUG = "ctx-public-wiki"; 15 34 const PRIVATE_SLUG = "ctx-private-wiki"; ··· 18 37 beforeAll(() => { 19 38 upsertWiki( 20 39 PUBLIC_SLUG, 21 - DEV_DID, 40 + TEST_DID, 22 41 "Ctx Public", 23 42 "public", 24 - `at://${DEV_DID}/wiki.lichen.wiki/${PUBLIC_SLUG}`, 43 + `at://${TEST_DID}/wiki.lichen.wiki/${PUBLIC_SLUG}`, 25 44 new Date().toISOString(), 26 45 ); 27 46 upsertWiki( ··· 39 58 cleanupWikiAndDependents(PRIVATE_SLUG); 40 59 }); 41 60 42 - function fakeRequest(): Request { 61 + function authedRequest(did = TEST_DID): Request { 62 + return new Request("http://localhost/test", { 63 + headers: { cookie: `did=${encodeURIComponent(did)}` }, 64 + }); 65 + } 66 + 67 + function anonRequest(): Request { 43 68 return new Request("http://localhost/test"); 44 69 } 45 70 46 71 describe("resolveWikiContext", () => { 47 72 test("throws NotFoundError for nonexistent wiki", async () => { 48 73 expect( 49 - resolveWikiContext(fakeRequest(), "nonexistent-slug-xyz", "read"), 74 + resolveWikiContext(anonRequest(), "nonexistent-slug-xyz", "read"), 50 75 ).rejects.toBeInstanceOf(NotFoundError); 51 76 }); 52 77 53 - test("returns context for public wiki with read access", async () => { 54 - const ctx = await resolveWikiContext(fakeRequest(), PUBLIC_SLUG, "read"); 78 + test("returns admin context for owner on public wiki", async () => { 79 + const ctx = await resolveWikiContext(authedRequest(), PUBLIC_SLUG, "read"); 80 + expect(ctx.wiki.slug).toBe(PUBLIC_SLUG); 81 + expect(ctx.access).toBe("admin"); 82 + }); 83 + 84 + test("returns read context for anonymous on public wiki", async () => { 85 + const ctx = await resolveWikiContext(anonRequest(), PUBLIC_SLUG, "read"); 55 86 expect(ctx.wiki.slug).toBe(PUBLIC_SLUG); 56 - expect(ctx.access).toBe("admin"); // DEV_DID is owner 87 + expect(ctx.access).toBe("read"); 88 + }); 89 + 90 + test("throws ForbiddenError for anonymous requesting edit", async () => { 91 + expect( 92 + resolveWikiContext(anonRequest(), PUBLIC_SLUG, "edit"), 93 + ).rejects.toBeInstanceOf(ForbiddenError); 57 94 }); 58 95 59 96 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 97 expect( 62 - resolveWikiContext(fakeRequest(), PRIVATE_SLUG, "read"), 98 + resolveWikiContext(authedRequest(), PRIVATE_SLUG, "read"), 63 99 ).rejects.toBeInstanceOf(ForbiddenError); 64 100 }); 65 101 66 102 test("throws ForbiddenError when requiring edit on read-only access", async () => { 67 - // Add DEV_DID as viewer on private wiki 68 103 upsertMembership( 69 104 PRIVATE_SLUG, 70 - DEV_DID, 105 + TEST_DID, 71 106 "viewer", 72 - `at://${DEV_DID}/wiki.lichen.membership/ctx-viewer`, 107 + `at://${TEST_DID}/wiki.lichen.membership/ctx-viewer`, 73 108 new Date().toISOString(), 74 109 ); 75 110 76 111 expect( 77 - resolveWikiContext(fakeRequest(), PRIVATE_SLUG, "edit"), 112 + resolveWikiContext(authedRequest(), PRIVATE_SLUG, "edit"), 78 113 ).rejects.toBeInstanceOf(ForbiddenError); 79 114 80 - // Cleanup viewer membership 81 115 const { getDb } = await import("../../src/server/db/index.ts"); 82 116 getDb().run("DELETE FROM memberships WHERE wiki_slug = ? AND did = ?", [ 83 117 PRIVATE_SLUG, 84 - DEV_DID, 118 + TEST_DID, 85 119 ]); 86 120 }); 87 121 88 122 test("throws ForbiddenError when requiring admin on edit access", async () => { 89 123 upsertMembership( 90 124 PRIVATE_SLUG, 91 - DEV_DID, 125 + TEST_DID, 92 126 "contributor", 93 - `at://${DEV_DID}/wiki.lichen.membership/ctx-contrib`, 127 + `at://${TEST_DID}/wiki.lichen.membership/ctx-contrib`, 94 128 new Date().toISOString(), 95 129 ); 96 130 97 131 expect( 98 - resolveWikiContext(fakeRequest(), PRIVATE_SLUG, "admin"), 132 + resolveWikiContext(authedRequest(), PRIVATE_SLUG, "admin"), 99 133 ).rejects.toBeInstanceOf(ForbiddenError); 100 134 101 135 const { getDb } = await import("../../src/server/db/index.ts"); 102 136 getDb().run("DELETE FROM memberships WHERE wiki_slug = ? AND did = ?", [ 103 137 PRIVATE_SLUG, 104 - DEV_DID, 138 + TEST_DID, 105 139 ]); 106 140 }); 107 141 }); ··· 109 143 describe("resolveWikiContextSoft", () => { 110 144 test("throws NotFoundError for nonexistent wiki", async () => { 111 145 expect( 112 - resolveWikiContextSoft(fakeRequest(), "nonexistent-slug-xyz"), 146 + resolveWikiContextSoft(anonRequest(), "nonexistent-slug-xyz"), 113 147 ).rejects.toBeInstanceOf(NotFoundError); 114 148 }); 115 149 116 150 test("returns context with none access for private wiki (does not throw)", async () => { 117 - const ctx = await resolveWikiContextSoft(fakeRequest(), PRIVATE_SLUG); 151 + const ctx = await resolveWikiContextSoft(anonRequest(), PRIVATE_SLUG); 118 152 expect(ctx.wiki.slug).toBe(PRIVATE_SLUG); 119 153 expect(ctx.access).toBe("none"); 120 154 });
+17 -7
tests/lib/blob.test.ts
··· 88 88 89 89 test("parses valid JSON", () => { 90 90 const meta = parseBlobMetadata( 91 - '{"bafyreiabc":"image/png","bafyreixyz":"image/jpeg"}', 91 + '{"bafyreiabc":{"mimeType":"image/png","size":1024},"bafyreixyz":{"mimeType":"image/jpeg","size":2048}}', 92 92 ); 93 93 expect(meta).toEqual({ 94 - bafyreiabc: "image/png", 95 - bafyreixyz: "image/jpeg", 94 + bafyreiabc: { mimeType: "image/png", size: 1024 }, 95 + bafyreixyz: { mimeType: "image/jpeg", size: 2048 }, 96 96 }); 97 97 }); 98 98 ··· 109 109 describe("buildBlobsForContent", () => { 110 110 test("matches CIDs present in blobMeta", () => { 111 111 const content = "![img](/blob/did:plc:abc/bafyreiabc)"; 112 - const blobMeta = { bafyreiabc: "image/png", bafyreiother: "image/jpeg" }; 112 + const blobMeta = { 113 + bafyreiabc: { mimeType: "image/png", size: 1024 }, 114 + bafyreiother: { mimeType: "image/jpeg", size: 2048 }, 115 + }; 113 116 const blobs = buildBlobsForContent(content, blobMeta); 114 - expect(blobs).toEqual([{ cid: "bafyreiabc", mimeType: "image/png" }]); 117 + expect(blobs).toEqual([ 118 + { 119 + $type: "blob", 120 + ref: { $link: "bafyreiabc" }, 121 + mimeType: "image/png", 122 + size: 1024, 123 + }, 124 + ]); 115 125 }); 116 126 117 127 test("ignores refs without metadata", () => { 118 128 const content = "![img](/blob/did:plc:abc/bafyreiunknown)"; 119 - const blobMeta = { bafyreiabc: "image/png" }; 129 + const blobMeta = { bafyreiabc: { mimeType: "image/png", size: 1024 } }; 120 130 const blobs = buildBlobsForContent(content, blobMeta); 121 131 expect(blobs).toEqual([]); 122 132 }); 123 133 124 134 test("returns empty for content with no blob refs", () => { 125 135 const content = "# Hello\nNo images here."; 126 - const blobMeta = { bafyreiabc: "image/png" }; 136 + const blobMeta = { bafyreiabc: { mimeType: "image/png", size: 1024 } }; 127 137 const blobs = buildBlobsForContent(content, blobMeta); 128 138 expect(blobs).toEqual([]); 129 139 });
+1 -1
tests/lib/import-export/export.test.ts
··· 62 62 return { 63 63 session: null, 64 64 wiki: null, 65 - effectiveDid: TEST_DID, 65 + did: TEST_DID, 66 66 access: "none" as const, 67 67 locale: "en" as const, 68 68 hasPendingRequest: false,
+1 -1
tests/lib/import-export/import.test.ts
··· 67 67 return { 68 68 session: null, 69 69 wiki: null, 70 - effectiveDid: TEST_DID, 70 + did: TEST_DID, 71 71 access: "none", 72 72 locale: "en" as const, 73 73 hasPendingRequest: false,
+8 -6
tests/lib/note-validation.test.ts
··· 1 1 import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 - import { DEV_DID } from "../../src/atproto/session.ts"; 2 + 3 + const TEST_DID = "did:plc:test-user"; 4 + 3 5 import { t } from "../../src/lib/i18n/index.ts"; 4 6 import { validateNewNote } from "../../src/lib/note-validation.ts"; 5 7 import { createNote, upsertWiki } from "../../src/server/db/queries/index.ts"; ··· 11 13 beforeAll(() => { 12 14 upsertWiki( 13 15 WIKI_SLUG, 14 - DEV_DID, 16 + TEST_DID, 15 17 "NV Test", 16 18 "public", 17 - `at://${DEV_DID}/wiki.lichen.wiki/${WIKI_SLUG}`, 19 + `at://${TEST_DID}/wiki.lichen.wiki/${WIKI_SLUG}`, 18 20 new Date().toISOString(), 19 21 ); 20 22 createNote( 21 - `at://${DEV_DID}/wiki.lichen.note/nv-existing`, 22 - `at://${DEV_DID}/wiki.lichen.noteRevision/nv-existing-rev`, 23 + `at://${TEST_DID}/wiki.lichen.note/nv-existing`, 24 + `at://${TEST_DID}/wiki.lichen.noteRevision/nv-existing-rev`, 23 25 WIKI_SLUG, 24 26 "existing-note", 25 27 "Existing Note", 26 - DEV_DID, 28 + TEST_DID, 27 29 "content", 28 30 ); 29 31 });
+2 -2
tests/lib/orchestrators/membership.test.ts
··· 64 64 return { 65 65 session: { did: ADMIN_DID, handle: "admin.bsky.social" }, 66 66 wiki, 67 - effectiveDid: ADMIN_DID, 67 + did: ADMIN_DID, 68 68 access: "admin", 69 69 locale: "en", 70 70 hasPendingRequest: false, ··· 106 106 107 107 const ctx = makeCtx({ 108 108 session: { did: USER_DID, handle: "user.bsky.social" }, 109 - effectiveDid: USER_DID, 109 + did: USER_DID, 110 110 access: "none", 111 111 }); 112 112
+1 -1
tests/lib/orchestrators/note.test.ts
··· 57 57 return { 58 58 session: { did: WIKI_DID, handle: "test.bsky.social" }, 59 59 wiki, 60 - effectiveDid: WIKI_DID, 60 + did: WIKI_DID, 61 61 access: "admin", 62 62 locale: "en", 63 63 hasPendingRequest: false,
+3 -3
tests/lib/orchestrators/wiki.test.ts
··· 66 66 return { 67 67 session: { did: TEST_DID, handle: "test.bsky.social" }, 68 68 wiki: null, 69 - effectiveDid: TEST_DID, 69 + did: TEST_DID, 70 70 access: "none", 71 71 locale: "en", 72 72 hasPendingRequest: false, ··· 81 81 return { 82 82 session: { did: TEST_DID, handle: "test.bsky.social" }, 83 83 wiki, 84 - effectiveDid: TEST_DID, 84 + did: TEST_DID, 85 85 access: "admin", 86 86 locale: "en", 87 87 hasPendingRequest: false, ··· 248 248 249 249 test("throws ForbiddenError if caller is not the owner", async () => { 250 250 const wiki = await createTestWiki("Orch Delete Forbidden"); 251 - const ctx = makeWikiCtx(wiki, { effectiveDid: OTHER_DID }); 251 + const ctx = makeWikiCtx(wiki, { did: OTHER_DID }); 252 252 253 253 expect(deleteWikiAction(ctx)).rejects.toBeInstanceOf(ForbiddenError); 254 254 });
+9 -10
tests/server/routes/bookmark.test.ts
··· 1 1 import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 - import { DEV_DID } from "../../../src/atproto/session.ts"; 3 2 import { 4 3 isBookmarked, 5 4 upsertMembership, 6 5 upsertWiki, 7 6 } from "../../../src/server/db/queries/index.ts"; 8 7 import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 9 - import { createTestApp } from "./helpers.ts"; 8 + import { authedRequest, createTestApp, TEST_DID } from "./helpers.ts"; 10 9 11 10 const app = createTestApp(); 12 11 13 12 const SLUG = "bm-test-wiki"; 14 - const AT_URI = `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`; 13 + const AT_URI = `at://${TEST_DID}/wiki.lichen.wiki/${SLUG}`; 15 14 16 15 beforeAll(() => { 17 16 upsertWiki( 18 17 SLUG, 19 - DEV_DID, 18 + TEST_DID, 20 19 "Bookmark Test Wiki", 21 20 "public", 22 21 AT_URI, ··· 24 23 ); 25 24 upsertMembership( 26 25 SLUG, 27 - DEV_DID, 26 + TEST_DID, 28 27 "admin", 29 - `at://${DEV_DID}/wiki.lichen.membership/bm1`, 28 + `at://${TEST_DID}/wiki.lichen.membership/bm1`, 30 29 new Date().toISOString(), 31 30 ); 32 31 }); ··· 41 40 formData.set("wikiAtUri", AT_URI); 42 41 formData.set("action", "add"); 43 42 const res = await app.handle( 44 - new Request("http://localhost/api/bookmark", { 43 + authedRequest("http://localhost/api/bookmark", { 45 44 method: "POST", 46 45 body: formData, 47 46 }), 48 47 ); 49 48 expect(res.status).toBe(200); 50 49 expect(res.headers.get("Content-Type")).toContain("text/html"); 51 - expect(isBookmarked(DEV_DID, AT_URI)).toBe(true); 50 + expect(isBookmarked(TEST_DID, AT_URI)).toBe(true); 52 51 }); 53 52 54 53 test("removes bookmark and returns HTML partial", async () => { ··· 56 55 formData.set("wikiAtUri", AT_URI); 57 56 formData.set("action", "remove"); 58 57 const res = await app.handle( 59 - new Request("http://localhost/api/bookmark", { 58 + authedRequest("http://localhost/api/bookmark", { 60 59 method: "POST", 61 60 body: formData, 62 61 }), 63 62 ); 64 63 expect(res.status).toBe(200); 65 - expect(isBookmarked(DEV_DID, AT_URI)).toBe(false); 64 + expect(isBookmarked(TEST_DID, AT_URI)).toBe(false); 66 65 }); 67 66 });
+56 -7
tests/server/routes/helpers.ts
··· 1 + import { mock } from "bun:test"; 1 2 import { Elysia } from "elysia"; 2 3 import { AppError } from "../../../src/lib/errors.ts"; 3 - import { blobRoutes } from "../../../src/server/routes/blob.ts"; 4 - import { bookmarkRoutes } from "../../../src/server/routes/bookmark.ts"; 5 - import { homeRoute } from "../../../src/server/routes/home.ts"; 6 - import { membershipRoutes } from "../../../src/server/routes/membership.ts"; 7 - import { noteRoutes } from "../../../src/server/routes/note.ts"; 8 - import { searchRoutes } from "../../../src/server/routes/search.ts"; 9 - import { wikiRoutes } from "../../../src/server/routes/wiki.ts"; 4 + 5 + export const TEST_DID = "did:plc:test-user"; 6 + 7 + const mockPdsResult = { uri: "at://mock", cid: "bafymock" }; 8 + 9 + // Mock session resolution: requests with the auth cookie get a session 10 + mock.module("../../../src/atproto/session.ts", () => ({ 11 + getSessionFromRequest: async (request: Request) => { 12 + const cookie = request.headers.get("cookie") ?? ""; 13 + const match = cookie.match(/(?:^|;\s*)did=([^;]+)/); 14 + if (match?.[1]) { 15 + const did = decodeURIComponent(match[1]); 16 + return { did, handle: did }; 17 + } 18 + return null; 19 + }, 20 + getClient: async () => null, 21 + getSession: async () => null, 22 + getDevSession: () => null, 23 + getAgent: async () => ({}), 24 + })); 25 + 26 + // Mock PDS writes so route tests don't need a real PDS 27 + mock.module("../../../src/atproto/pds.ts", () => ({ 28 + writeWikiRecord: async () => mockPdsResult, 29 + writeNoteRecord: async () => mockPdsResult, 30 + writeRevisionRecord: async () => mockPdsResult, 31 + writeMembershipRecord: async () => mockPdsResult, 32 + writeMemberRequestRecord: async () => mockPdsResult, 33 + writeBookmarkRecord: async () => mockPdsResult, 34 + deleteRecord: async () => {}, 35 + })); 36 + 37 + const { blobRoutes } = await import("../../../src/server/routes/blob.ts"); 38 + const { bookmarkRoutes } = await import( 39 + "../../../src/server/routes/bookmark.ts" 40 + ); 41 + const { homeRoute } = await import("../../../src/server/routes/home.ts"); 42 + const { membershipRoutes } = await import( 43 + "../../../src/server/routes/membership.ts" 44 + ); 45 + const { noteRoutes } = await import("../../../src/server/routes/note.ts"); 46 + const { searchRoutes } = await import("../../../src/server/routes/search.ts"); 47 + const { wikiRoutes } = await import("../../../src/server/routes/wiki.ts"); 10 48 11 49 export function createTestApp() { 12 50 return new Elysia() ··· 24 62 .use(noteRoutes) 25 63 .use(wikiRoutes); 26 64 } 65 + 66 + /** Create a Request with an authenticated session cookie. */ 67 + export function authedRequest( 68 + url: string, 69 + init?: RequestInit, 70 + did = TEST_DID, 71 + ): Request { 72 + const headers = new Headers(init?.headers); 73 + headers.set("cookie", `did=${encodeURIComponent(did)}`); 74 + return new Request(url, { ...init, headers }); 75 + }
+8 -9
tests/server/routes/membership.test.ts
··· 1 1 import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 - import { DEV_DID } from "../../../src/atproto/session.ts"; 3 2 import { 4 3 getMemberRole, 5 4 upsertMembership, ··· 7 6 upsertWiki, 8 7 } from "../../../src/server/db/queries/index.ts"; 9 8 import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 10 - import { createTestApp } from "./helpers.ts"; 9 + import { authedRequest, createTestApp, TEST_DID } from "./helpers.ts"; 11 10 12 11 const app = createTestApp(); 13 12 ··· 17 16 beforeAll(() => { 18 17 upsertWiki( 19 18 SLUG, 20 - DEV_DID, 19 + TEST_DID, 21 20 "Membership Test Wiki", 22 21 "public", 23 - `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`, 22 + `at://${TEST_DID}/wiki.lichen.wiki/${SLUG}`, 24 23 new Date().toISOString(), 25 24 ); 26 25 upsertMembership( 27 26 SLUG, 28 - DEV_DID, 27 + TEST_DID, 29 28 "admin", 30 - `at://${DEV_DID}/wiki.lichen.membership/mb1`, 29 + `at://${TEST_DID}/wiki.lichen.membership/mb1`, 31 30 new Date().toISOString(), 32 31 ); 33 32 }); ··· 47 46 48 47 test("POST request-access on nonexistent wiki returns 404", async () => { 49 48 const res = await app.handle( 50 - new Request("http://localhost/wiki/no-such-wiki/-/request-access", { 49 + authedRequest("http://localhost/wiki/no-such-wiki/-/request-access", { 51 50 method: "POST", 52 51 }), 53 52 ); ··· 65 64 const formData = new FormData(); 66 65 formData.set("role", "contributor"); 67 66 const res = await app.handle( 68 - new Request( 67 + authedRequest( 69 68 `http://localhost/wiki/${SLUG}/-/members/${encodeURIComponent(MEMBER_DID)}/approve`, 70 69 { method: "POST", body: formData }, 71 70 ), ··· 77 76 78 77 test("removes member and redirects to settings", async () => { 79 78 const res = await app.handle( 80 - new Request( 79 + authedRequest( 81 80 `http://localhost/wiki/${SLUG}/-/members/${encodeURIComponent(MEMBER_DID)}/remove`, 82 81 { method: "POST" }, 83 82 ),
+15 -16
tests/server/routes/note.test.ts
··· 1 1 import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 - import { DEV_DID } from "../../../src/atproto/session.ts"; 3 2 import { 4 3 createNote, 5 4 upsertMembership, 6 5 upsertWiki, 7 6 } from "../../../src/server/db/queries/index.ts"; 8 7 import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 9 - import { createTestApp } from "./helpers.ts"; 8 + import { authedRequest, createTestApp, TEST_DID } from "./helpers.ts"; 10 9 11 10 const app = createTestApp(); 12 11 ··· 17 16 beforeAll(() => { 18 17 upsertWiki( 19 18 SLUG, 20 - DEV_DID, 19 + TEST_DID, 21 20 "Note Test Wiki", 22 21 "public", 23 - `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`, 22 + `at://${TEST_DID}/wiki.lichen.wiki/${SLUG}`, 24 23 new Date().toISOString(), 25 24 ); 26 25 upsertMembership( 27 26 SLUG, 28 - DEV_DID, 27 + TEST_DID, 29 28 "admin", 30 - `at://${DEV_DID}/wiki.lichen.membership/nt1`, 29 + `at://${TEST_DID}/wiki.lichen.membership/nt1`, 31 30 new Date().toISOString(), 32 31 ); 33 32 createNote( 34 - `at://${DEV_DID}/wiki.lichen.note/nt-home`, 35 - `at://${DEV_DID}/wiki.lichen.noteRevision/nt-home-rev`, 33 + `at://${TEST_DID}/wiki.lichen.note/nt-home`, 34 + `at://${TEST_DID}/wiki.lichen.noteRevision/nt-home-rev`, 36 35 SLUG, 37 36 "home", 38 37 "Home", 39 - DEV_DID, 38 + TEST_DID, 40 39 "# Welcome", 41 40 ); 42 41 createNote( 43 - `at://${DEV_DID}/wiki.lichen.note/nt-test`, 44 - `at://${DEV_DID}/wiki.lichen.noteRevision/nt-test-rev`, 42 + `at://${TEST_DID}/wiki.lichen.note/nt-test`, 43 + `at://${TEST_DID}/wiki.lichen.noteRevision/nt-test-rev`, 45 44 SLUG, 46 45 "test-note", 47 46 "Test Note", 48 - DEV_DID, 47 + TEST_DID, 49 48 "Some content", 50 49 ); 51 50 upsertWiki( ··· 81 80 82 81 test("GET /wiki/:slug/:noteSlug/edit returns 200 for editable note", async () => { 83 82 const res = await app.handle( 84 - new Request(`http://localhost/wiki/${SLUG}/test-note/edit`), 83 + authedRequest(`http://localhost/wiki/${SLUG}/test-note/edit`), 85 84 ); 86 85 expect(res.status).toBe(200); 87 86 }); ··· 105 104 formData.set("title", "Note Created"); 106 105 formData.set("content", "Test content"); 107 106 const res = await app.handle( 108 - new Request(`http://localhost/wiki/${SLUG}/new`, { 107 + authedRequest(`http://localhost/wiki/${SLUG}/new`, { 109 108 method: "POST", 110 109 body: formData, 111 110 }), ··· 119 118 formData.set("title", " "); 120 119 formData.set("content", "content"); 121 120 const res = await app.handle( 122 - new Request(`http://localhost/wiki/${SLUG}/new`, { 121 + authedRequest(`http://localhost/wiki/${SLUG}/new`, { 123 122 method: "POST", 124 123 body: formData, 125 124 }), ··· 134 133 formData.set("title", "Test Note"); 135 134 formData.set("content", "Updated content"); 136 135 const res = await app.handle( 137 - new Request(`http://localhost/wiki/${SLUG}/test-note/edit`, { 136 + authedRequest(`http://localhost/wiki/${SLUG}/test-note/edit`, { 138 137 method: "POST", 139 138 body: formData, 140 139 }),
+11 -12
tests/server/routes/search.test.ts
··· 1 1 import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 - import { DEV_DID } from "../../../src/atproto/session.ts"; 3 2 import { 4 3 createNote, 5 4 upsertMembership, 6 5 upsertWiki, 7 6 } from "../../../src/server/db/queries/index.ts"; 8 7 import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 9 - import { createTestApp } from "./helpers.ts"; 8 + import { createTestApp, TEST_DID } from "./helpers.ts"; 10 9 11 10 const app = createTestApp(); 12 11 ··· 17 16 beforeAll(() => { 18 17 upsertWiki( 19 18 SLUG, 20 - DEV_DID, 19 + TEST_DID, 21 20 "Search Test Wiki", 22 21 "public", 23 - `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`, 22 + `at://${TEST_DID}/wiki.lichen.wiki/${SLUG}`, 24 23 new Date().toISOString(), 25 24 ); 26 25 upsertMembership( 27 26 SLUG, 28 - DEV_DID, 27 + TEST_DID, 29 28 "admin", 30 - `at://${DEV_DID}/wiki.lichen.membership/sr1`, 29 + `at://${TEST_DID}/wiki.lichen.membership/sr1`, 31 30 new Date().toISOString(), 32 31 ); 33 32 createNote( 34 - `at://${DEV_DID}/wiki.lichen.note/sr-home`, 35 - `at://${DEV_DID}/wiki.lichen.noteRevision/sr-home-rev`, 33 + `at://${TEST_DID}/wiki.lichen.note/sr-home`, 34 + `at://${TEST_DID}/wiki.lichen.noteRevision/sr-home-rev`, 36 35 SLUG, 37 36 "home", 38 37 "Home", 39 - DEV_DID, 38 + TEST_DID, 40 39 "# Welcome", 41 40 ); 42 41 createNote( 43 - `at://${DEV_DID}/wiki.lichen.note/sr-test`, 44 - `at://${DEV_DID}/wiki.lichen.noteRevision/sr-test-rev`, 42 + `at://${TEST_DID}/wiki.lichen.note/sr-test`, 43 + `at://${TEST_DID}/wiki.lichen.noteRevision/sr-test-rev`, 45 44 SLUG, 46 45 "test-note", 47 46 "Test Note", 48 - DEV_DID, 47 + TEST_DID, 49 48 "Some content", 50 49 ); 51 50 upsertWiki(
+11 -12
tests/server/routes/wiki.test.ts
··· 1 1 import { afterAll, beforeAll, describe, expect, test } from "bun:test"; 2 - import { DEV_DID } from "../../../src/atproto/session.ts"; 3 2 import { 4 3 upsertMembership, 5 4 upsertWiki, 6 5 } from "../../../src/server/db/queries/index.ts"; 7 6 import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; 8 - import { createTestApp } from "./helpers.ts"; 7 + import { authedRequest, createTestApp, TEST_DID } from "./helpers.ts"; 9 8 10 9 const app = createTestApp(); 11 10 12 11 const SLUG = "wk-test-wiki"; 13 - const AT_URI = `at://${DEV_DID}/wiki.lichen.wiki/${SLUG}`; 12 + const AT_URI = `at://${TEST_DID}/wiki.lichen.wiki/${SLUG}`; 14 13 const PRIVATE_SLUG = "wk-test-private"; 15 14 const OTHER_DID = "did:plc:other"; 16 15 17 16 beforeAll(() => { 18 17 upsertWiki( 19 18 SLUG, 20 - DEV_DID, 19 + TEST_DID, 21 20 "Wiki Test Wiki", 22 21 "public", 23 22 AT_URI, ··· 25 24 ); 26 25 upsertMembership( 27 26 SLUG, 28 - DEV_DID, 27 + TEST_DID, 29 28 "admin", 30 - `at://${DEV_DID}/wiki.lichen.membership/wk1`, 29 + `at://${TEST_DID}/wiki.lichen.membership/wk1`, 31 30 new Date().toISOString(), 32 31 ); 33 32 upsertWiki( ··· 71 70 72 71 test("GET /wiki/:slug/-/settings returns 200 for admin", async () => { 73 72 const res = await app.handle( 74 - new Request(`http://localhost/wiki/${SLUG}/-/settings`), 73 + authedRequest(`http://localhost/wiki/${SLUG}/-/settings`), 75 74 ); 76 75 expect(res.status).toBe(200); 77 76 }); ··· 87 86 const formData = new FormData(); 88 87 formData.set("confirm", "wrong name"); 89 88 const res = await app.handle( 90 - new Request(`http://localhost/wiki/${SLUG}/-/delete`, { 89 + authedRequest(`http://localhost/wiki/${SLUG}/-/delete`, { 91 90 method: "POST", 92 91 body: formData, 93 92 }), ··· 107 106 formData.set("visibility", "public"); 108 107 formData.set("description", ""); 109 108 const res = await app.handle( 110 - new Request("http://localhost/wiki/new", { 109 + authedRequest("http://localhost/wiki/new", { 111 110 method: "POST", 112 111 body: formData, 113 112 }), ··· 121 120 const slug = "wk-delete-target"; 122 121 upsertWiki( 123 122 slug, 124 - DEV_DID, 123 + TEST_DID, 125 124 "Delete Target", 126 125 "public", 127 - `at://${DEV_DID}/wiki.lichen.wiki/${slug}`, 126 + `at://${TEST_DID}/wiki.lichen.wiki/${slug}`, 128 127 new Date().toISOString(), 129 128 ); 130 129 131 130 const formData = new FormData(); 132 131 formData.set("confirm", "Delete Target"); 133 132 const res = await app.handle( 134 - new Request(`http://localhost/wiki/${slug}/-/delete`, { 133 + authedRequest(`http://localhost/wiki/${slug}/-/delete`, { 135 134 method: "POST", 136 135 body: formData, 137 136 }),