From 1448be307e430446807be63eaf6f3b8547cd1edd Mon Sep 17 00:00:00 2001 From: juprodh Date: Fri, 22 May 2026 17:04:36 +0800 Subject: [PATCH] Add public profiles --- src/lib/i18n/en.ts | 5 + src/lib/i18n/fr.ts | 6 ++ src/lib/i18n/index.ts | 5 + src/lib/urls.ts | 4 + src/server/app.ts | 2 + src/server/canonical-handle-plugin.ts | 30 ++++++ src/server/db/queries/wiki.ts | 20 +++- src/server/routes/membership.ts | 21 +++- src/server/routes/profile.ts | 21 ++-- src/server/routes/wiki.ts | 4 +- src/views/layout.ts | 11 +++ src/views/members.ts | 98 +++++++++++++++++++ src/views/profile.ts | 40 ++++++-- tests/server/routes/helpers.ts | 11 ++- tests/server/routes/membership.test.ts | 31 ++++++ tests/server/routes/profile.test.ts | 129 ++++++++++++++++++++----- 16 files changed, 383 insertions(+), 55 deletions(-) create mode 100644 src/server/canonical-handle-plugin.ts create mode 100644 src/views/members.ts diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index 2fb2d12..dd2a0da 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -145,6 +145,11 @@ export const en: Messages = { noBookmarks: "No bookmarks yet.", bookmark: "Bookmark", removeBookmark: "Remove bookmark", + profile: "Profile", + ownedByOther: "Wikis", + collaboratingOther: "Collaborating on", + noPublicOwnedWikis: "No public wikis yet.", + noPublicCollaboratingWikis: "Not collaborating on any public wikis yet.", }, settings: { heading: "Settings", diff --git a/src/lib/i18n/fr.ts b/src/lib/i18n/fr.ts index 7e514ef..631766a 100644 --- a/src/lib/i18n/fr.ts +++ b/src/lib/i18n/fr.ts @@ -148,6 +148,12 @@ export const fr: PartialMessages = { noBookmarks: "Aucun signet pour le moment.", bookmark: "Ajouter aux signets", removeBookmark: "Retirer des signets", + profile: "Profil", + ownedByOther: "Wikis", + collaboratingOther: "Collabore sur", + noPublicOwnedWikis: "Aucun wiki public pour le moment.", + noPublicCollaboratingWikis: + "Ne collabore sur aucun wiki public pour le moment.", }, settings: { heading: "Paramètres", diff --git a/src/lib/i18n/index.ts b/src/lib/i18n/index.ts index 0902a93..08d2402 100644 --- a/src/lib/i18n/index.ts +++ b/src/lib/i18n/index.ts @@ -144,6 +144,11 @@ export interface Messages { noBookmarks: string; bookmark: string; removeBookmark: string; + profile: string; + ownedByOther: string; + collaboratingOther: string; + noPublicOwnedWikis: string; + noPublicCollaboratingWikis: string; }; settings: { heading: string; diff --git a/src/lib/urls.ts b/src/lib/urls.ts index 54de13d..dd5b369 100644 --- a/src/lib/urls.ts +++ b/src/lib/urls.ts @@ -26,6 +26,10 @@ export function settingsUrl(handle: string, wikiSlug: string): string { return `/@${handle}/${wikiSlug}/-/settings`; } +export function membersUrl(handle: string, wikiSlug: string): string { + return `/@${handle}/${wikiSlug}/-/members`; +} + export function sidebarEditUrl(handle: string, wikiSlug: string): string { return `/@${handle}/${wikiSlug}/-/sidebar/edit`; } diff --git a/src/server/app.ts b/src/server/app.ts index 10dff52..61eedaf 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -5,6 +5,7 @@ import { atprotoRoutes } from "../atproto/routes.ts"; import { AppError } from "../lib/errors.ts"; import { fmt, resolveLocale, t } from "../lib/i18n/index.ts"; import { notFoundPage } from "../views/not-found.ts"; +import { canonicalHandlePlugin } from "./canonical-handle-plugin.ts"; import { contextPlugin } from "./context-plugin.ts"; import { getDb } from "./db/index.ts"; import { blobRoutes } from "./routes/blob.ts"; @@ -70,6 +71,7 @@ export function buildApp() { return new Response("Internal server error", { status: 500 }); }) .use(staticPlugin({ prefix: "/public", assets: "public" })) + .use(canonicalHandlePlugin) .use(contextPlugin) .use(atprotoRoutes()) .use(blobRoutes) diff --git a/src/server/canonical-handle-plugin.ts b/src/server/canonical-handle-plugin.ts new file mode 100644 index 0000000..d055b2e --- /dev/null +++ b/src/server/canonical-handle-plugin.ts @@ -0,0 +1,30 @@ +import { Elysia } from "elysia"; +import { resolveProfile } from "../lib/profile.ts"; + +// Matches /@(/rest)? where the segment is a DID (starts with "did:"). +// We rewrite the leading segment to the current handle and 301 to the canonical +// URL. Falls back to rendering in place if the handle cannot be resolved +// (deactivated account, broken identity record, etc.). +const DID_HANDLE_RE = /^\/@(did:[^/]+)(\/.*)?$/; + +export const canonicalHandlePlugin = new Elysia({ + name: "canonical-handle", +}).onRequest(async ({ request }) => { + const url = new URL(request.url); + const match = url.pathname.match(DID_HANDLE_RE); + if (!match) return; + + const did = match[1] as string; + const rest = match[2] ?? ""; + + const profile = await resolveProfile(did); + // Skip when the handle is missing or itself a DID (e.g. profile resolution + // returned the DID unchanged, which would 301 to the same URL). + if (!profile.handle || profile.handle.startsWith("did:")) return; + + const canonical = `/@${profile.handle}${rest}${url.search}`; + return new Response(null, { + status: 301, + headers: { Location: canonical }, + }); +}); diff --git a/src/server/db/queries/wiki.ts b/src/server/db/queries/wiki.ts index 8c22f73..adbcd78 100644 --- a/src/server/db/queries/wiki.ts +++ b/src/server/db/queries/wiki.ts @@ -128,25 +128,37 @@ export function getWiki(did: string, slug: string): WikiRow | null { ); } -export function listOwnedWikis(did: string): WikiWithNoteCount[] { +export function listOwnedWikis( + did: string, + options: { publicOnly?: boolean } = {}, +): WikiWithNoteCount[] { const db = getDb(); + const visibilityClause = options.publicOnly + ? " AND w.visibility = 'public'" + : ""; return db .query( `${WIKI_WITH_COUNTS} - WHERE w.did = ? + WHERE w.did = ?${visibilityClause} GROUP BY w.at_uri ORDER BY w.updated_at DESC`, ) .all(did) as WikiWithNoteCount[]; } -export function listCollaboratingWikis(did: string): WikiWithNoteCount[] { +export function listCollaboratingWikis( + did: string, + options: { publicOnly?: boolean } = {}, +): WikiWithNoteCount[] { const db = getDb(); + const visibilityClause = options.publicOnly + ? " AND w.visibility = 'public'" + : ""; return db .query( `${WIKI_WITH_COUNTS} INNER JOIN memberships m ON m.wiki_at_uri = w.at_uri AND m.did = ? - WHERE w.did != ? + WHERE w.did != ?${visibilityClause} GROUP BY w.at_uri ORDER BY w.updated_at DESC`, ) diff --git a/src/server/routes/membership.ts b/src/server/routes/membership.ts index 9c674b1..f11a8a5 100644 --- a/src/server/routes/membership.ts +++ b/src/server/routes/membership.ts @@ -18,8 +18,12 @@ import { deleteMemberAction, requestAccessAction, } from "../../lib/orchestrators/membership.ts"; -import { resolveHandleToDid } from "../../lib/profile.ts"; +import { resolveHandleToDid, resolveProfiles } from "../../lib/profile.ts"; +import { htmlResponse } from "../../lib/response.ts"; import { redirect, settingsUrl, wikiUrl } from "../../lib/urls.ts"; +import { membersPage } from "../../views/members.ts"; +import { wikiLayoutOptions } from "../../views/view-options.ts"; +import { listMembers } from "../db/queries/index.ts"; interface HandleWikiParams { handle: string; @@ -56,9 +60,18 @@ export const membershipRoutes = new Elysia() return redirect(wikiUrl(ctx.ownerHandle ?? urlHandle, ctx.wiki.slug)); }) - .get("/@:handle/:wikiSlug/-/members", async ({ params }) => { - const { handle, wikiSlug } = hwp(params); - return redirect(settingsUrl(handle, wikiSlug)); + .get("/@:handle/:wikiSlug/-/members", async ({ params, request }) => { + const { handle: urlHandle, wikiSlug } = hwp(params); + const ctx = await resolveWikiContext(request, urlHandle, wikiSlug, "read"); + const members = listMembers(ctx.wiki.at_uri).filter( + (m) => m.role !== "viewer", + ); + const profiles = await resolveProfiles(members.map((m) => m.did)); + return htmlResponse( + membersPage(ctx.wiki.name, ctx.wiki.did, members, profiles, { + ...wikiLayoutOptions(ctx), + }), + ); }) .post( "/@:handle/:wikiSlug/-/members/:memberDid/approve", diff --git a/src/server/routes/profile.ts b/src/server/routes/profile.ts index 342eb9c..49b0c4f 100644 --- a/src/server/routes/profile.ts +++ b/src/server/routes/profile.ts @@ -1,5 +1,5 @@ import { Elysia } from "elysia"; -import { ForbiddenError, NotFoundError } from "../../lib/errors.ts"; +import { NotFoundError } from "../../lib/errors.ts"; import { resolveHandleToDid, resolveProfile } from "../../lib/profile.ts"; import { htmlResponse } from "../../lib/response.ts"; import { profileUrl, redirect } from "../../lib/urls.ts"; @@ -17,11 +17,6 @@ export const profileRoutes = new Elysia() .get("/@:handle", async ({ params, ctx }) => { const handle = (params as { handle: string }).handle; - if (!ctx.session) { - const returnTo = `/@${handle}`; - return redirect(`/login?returnTo=${encodeURIComponent(returnTo)}`); - } - const did = await resolveHandleToDid(handle); if (!did) { throw new NotFoundError("Profile not found", { @@ -29,14 +24,13 @@ export const profileRoutes = new Elysia() }); } - if (did !== ctx.session.did) { - throw new ForbiddenError("This profile is private"); - } - + const isOwner = ctx.session?.did === did; const profile = await resolveProfile(did); - const ownedWikis = listOwnedWikis(did); - const collaboratingWikis = listCollaboratingWikis(did); - const bookmarks = getBookmarksForUser(did); + const ownedWikis = listOwnedWikis(did, { publicOnly: !isOwner }); + const collaboratingWikis = listCollaboratingWikis(did, { + publicOnly: !isOwner, + }); + const bookmarks = isOwner ? getBookmarksForUser(did) : []; return htmlResponse( profilePage( @@ -44,6 +38,7 @@ export const profileRoutes = new Elysia() ownedWikis, collaboratingWikis, bookmarks, + isOwner, baseLayoutOptions(ctx), ), ); diff --git a/src/server/routes/wiki.ts b/src/server/routes/wiki.ts index 7db26ec..ab5c07a 100644 --- a/src/server/routes/wiki.ts +++ b/src/server/routes/wiki.ts @@ -67,7 +67,9 @@ function hp(params: { wikiSlug: string }): HandleWikiParams { } async function loadSettingsData(wikiAtUri: string) { - const members = listMembers(wikiAtUri); + // Viewers are intentionally hidden from the member list (even for admins); + // once granted, viewer access is currently unrevokable through the UI. + const members = listMembers(wikiAtUri).filter((m) => m.role !== "viewer"); const requests = listRequests(wikiAtUri); const allDids = [...members.map((m) => m.did), ...requests.map((r) => r.did)]; const profiles = await resolveProfiles(allDids); diff --git a/src/views/layout.ts b/src/views/layout.ts index 5d42f05..3bc9c2c 100644 --- a/src/views/layout.ts +++ b/src/views/layout.ts @@ -8,6 +8,7 @@ import { exportNoteUrl, exportUrl, historyNoteUrl, + membersUrl, newNoteUrl, noteUrl, profileUrl, @@ -176,9 +177,18 @@ function renderWithSidebar(body: string, options: LayoutOptions): string { ` : ""; + const membersLink = + handle && slug + ? ` + ${ICONS.user} + ${msg.access.members} + ` + : ""; + const adminLinks = canManage(level) ? `
${historyLink} + ${membersLink} ${options.shareHtml ?? ""} ${exportDropdown} ${sidebarEditLink} @@ -189,6 +199,7 @@ function renderWithSidebar(body: string, options: LayoutOptions): string {
` : `
${historyLink} + ${membersLink} ${options.shareHtml ?? ""} ${exportDropdown}
`; diff --git a/src/views/members.ts b/src/views/members.ts new file mode 100644 index 0000000..9e2869b --- /dev/null +++ b/src/views/members.ts @@ -0,0 +1,98 @@ +import { escapeHtml } from "../lib/html.ts"; +import { t } from "../lib/i18n/index.ts"; +import type { ProfileInfo } from "../lib/profile.ts"; +import { profileUrl } from "../lib/urls.ts"; +import type { MembershipRow } from "../server/db/queries/index.ts"; +import { type LayoutOptions, layout } from "./layout.ts"; +import { THEME } from "./theme/index.ts"; + +function renderMemberCard( + m: MembershipRow, + wikiDid: string, + profile: ProfileInfo | undefined, + roleLabel: (role: string) => string, +): string { + const isOwner = m.did === wikiDid; + const role = isOwner ? "owner" : m.role; + const handle = profile?.handle ?? null; + const displayName = profile?.displayName ?? null; + const href = profileUrl(handle ?? m.did); + + const avatar = profile?.avatar + ? `` + : `
+ +
`; + + const nameLine = displayName + ? `${escapeHtml(displayName)}` + : ""; + const handleLine = handle + ? `@${escapeHtml(handle)}` + : `${escapeHtml(m.did.slice(0, 24))}…`; + + return ` + ${avatar} +
+ ${nameLine} + ${handleLine} +
+ ${escapeHtml(roleLabel(role))} +
`; +} + +export function membersPage( + wikiName: string, + wikiDid: string, + members: MembershipRow[], + profiles: Map, + options: LayoutOptions, +): string { + const locale = options.locale ?? "en"; + const msg = t(locale); + const roleLabel = (role: string) => + ({ + admin: msg.access.roleAdmin, + contributor: msg.access.roleContributor, + viewer: msg.access.roleViewer, + owner: msg.access.roleOwner, + })[role] ?? role; + + // Sort: owner first, then admins, then contributors. Viewers are excluded + // upstream — they never appear on the public members page. + const rolePriority: Record = { + admin: 1, + contributor: 2, + }; + const sorted = [...members].sort((a, b) => { + const aOwner = a.did === wikiDid ? 0 : (rolePriority[a.role] ?? 3); + const bOwner = b.did === wikiDid ? 0 : (rolePriority[b.role] ?? 3); + if (aOwner !== bOwner) return aOwner - bOwner; + return a.created_at.localeCompare(b.created_at); + }); + + const cards = sorted + .map((m) => renderMemberCard(m, wikiDid, profiles.get(m.did), roleLabel)) + .join("\n"); + + const body = + sorted.length > 0 + ? `
${cards}
` + : `

${msg.access.noMembers}

`; + + return layout( + `${msg.access.members} — ${wikiName}`, + `
+

${msg.access.members}

+ ${body} +
`, + options, + ); +} diff --git a/src/views/profile.ts b/src/views/profile.ts index 4590904..715df71 100644 --- a/src/views/profile.ts +++ b/src/views/profile.ts @@ -11,6 +11,7 @@ export function profilePage( ownedWikis: WikiWithNoteCount[], collaboratingWikis: WikiWithNoteCount[], bookmarkedWikis: WikiWithNoteCount[], + isOwner: boolean, options?: LayoutOptions, ): string { const locale = options?.locale ?? "en"; @@ -43,23 +44,42 @@ export function profilePage( ? `

${handle}

` : ""; + const ownedHeading = isOwner ? msg.profile.myWikis : msg.profile.ownedByOther; + const collaboratingHeading = isOwner + ? msg.profile.collaborating + : msg.profile.collaboratingOther; + const ownedEmptyHtml = isOwner + ? `${msg.profile.noOwnedWikis} ${msg.home.createAWiki}` + : msg.profile.noPublicOwnedWikis; + const collaboratingEmptyHtml = isOwner + ? msg.profile.noCollaboratingWikis + : msg.profile.noPublicCollaboratingWikis; + const ownedSection = ownedWikis.length > 0 ? wikiGridCards(ownedWikis, locale) - : `

${msg.profile.noOwnedWikis} ${msg.home.createAWiki}

`; + : `

${ownedEmptyHtml}

`; const collaboratingSection = collaboratingWikis.length > 0 ? wikiGridCards(collaboratingWikis, locale) - : `

${msg.profile.noCollaboratingWikis}

`; + : `

${collaboratingEmptyHtml}

`; const bookmarkSection = bookmarkedWikis.length > 0 ? wikiGridCards(bookmarkedWikis, locale) : `

${msg.profile.noBookmarks}

`; + const hasPublicFootprint = + ownedWikis.length > 0 || collaboratingWikis.length > 0; + const shouldNoindex = isOwner || !hasPublicFootprint; + + const pageTitle = isOwner + ? msg.profile.myWikis + : (profile.displayName ?? profile.handle ?? msg.profile.profile); + return layout( - msg.profile.myWikis, + pageTitle, `
@@ -71,21 +91,25 @@ export function profilePage(
-

${msg.profile.myWikis}

+

${ownedHeading}

${ownedSection}
-

${msg.profile.collaborating}

+

${collaboratingHeading}

${collaboratingSection}
-
+ ${ + isOwner + ? `

${msg.profile.bookmarks}

${bookmarkSection} -
+
` + : "" + }
`, - { ...options, noindex: true }, + { ...options, noindex: shouldNoindex }, ); } diff --git a/tests/server/routes/helpers.ts b/tests/server/routes/helpers.ts index ac6256d..c14fec5 100644 --- a/tests/server/routes/helpers.ts +++ b/tests/server/routes/helpers.ts @@ -14,6 +14,8 @@ const mockPdsResult = { uri: "at://mock", cid: "bafymock" }; const realSession = await import("../../../src/atproto/session.ts"); const realPds = await import("../../../src/atproto/pds.ts"); +const realEnv = await import("../../../src/atproto/env.ts"); + mock.module("../../../src/atproto/session.ts", () => ({ ...realSession, getSessionFromRequest: async (request: Request) => { @@ -21,7 +23,14 @@ mock.module("../../../src/atproto/session.ts", () => ({ const match = cookie.match(/(?:^|;\s*)did=([^;]+)/); if (match?.[1]) { const did = decodeURIComponent(match[1]); - return { did, handle: did }; + // Reuse the dev-account handle when the DID matches one — keeps the + // mocked session behavior aligned with `getDevSession` so canonical + // URLs use the handle rather than the raw DID. + const accounts = realEnv.getDevAccounts(); + const dev = accounts + ? Object.values(accounts).find((a) => a.did === did) + : null; + return { did, handle: dev?.handle ?? did }; } return null; }, diff --git a/tests/server/routes/membership.test.ts b/tests/server/routes/membership.test.ts index 316d4c3..e42c49c 100644 --- a/tests/server/routes/membership.test.ts +++ b/tests/server/routes/membership.test.ts @@ -84,4 +84,35 @@ describe("membership routes", () => { expect(res.status).toBe(302); expect(getMemberRole(AT_URI, MEMBER_DID)).toBeNull(); }); + + test("GET /-/members is accessible to anyone with read access", async () => { + // Add a contributor and a viewer; viewer must not appear on the page. + const contribDid = "did:plc:mb-contrib"; + const viewerDid = "did:plc:mb-viewer"; + upsertMembership( + AT_URI, + SLUG, + contribDid, + "contributor", + `at://${contribDid}/wiki.lichen.membership/c1`, + new Date().toISOString(), + ); + upsertMembership( + AT_URI, + SLUG, + viewerDid, + "viewer", + `at://${viewerDid}/wiki.lichen.membership/v1`, + new Date().toISOString(), + ); + + // Unauthenticated reader can view (public wiki). + const anonRes = await app.handle( + new Request(`http://localhost/@${TEST_DID}/${SLUG}/-/members`), + ); + expect(anonRes.status).toBe(200); + const body = await anonRes.text(); + expect(body).toContain(contribDid.slice(0, 24)); + expect(body).not.toContain(viewerDid.slice(0, 24)); + }); }); diff --git a/tests/server/routes/profile.test.ts b/tests/server/routes/profile.test.ts index 1a5ae76..83e781f 100644 --- a/tests/server/routes/profile.test.ts +++ b/tests/server/routes/profile.test.ts @@ -1,38 +1,119 @@ -import { describe, expect, test } from "bun:test"; -import { Elysia } from "elysia"; -import { profileRoutes } from "../../../src/server/routes/profile.ts"; +import { afterAll, describe, expect, mock, test } from "bun:test"; +import { cleanupWikiAndDependents } from "../../helpers/cleanup.ts"; +import { ALICE, BOB, emitWiki } from "../../integration/helpers.ts"; -const app = new Elysia().use(profileRoutes); +// Other test files (e.g. tests/atproto/session.test.ts) mock `lib/profile.ts` +// process-globally with a stub that returns `handle = did`. Re-apply our own +// mock here so canonical-handle redirects resolve to the dev-account handles +// regardless of which file loaded first. +const realProfile = await import("../../../src/lib/profile.ts"); +const devProfiles: Record = { + [ALICE.did]: { handle: ALICE.handle }, + [BOB.did]: { handle: BOB.handle }, +}; +mock.module("../../../src/lib/profile.ts", () => ({ + ...realProfile, + resolveProfile: async (did: string) => { + const known = devProfiles[did]; + return { + handle: known?.handle ?? did, + displayName: null, + avatar: null, + }; + }, +})); -function makeRequest(path: string): Request { - return new Request(`http://localhost${path}`); -} +const { fetch, loginCookie } = await import( + "../../integration/http-helpers.ts" +); -describe("GET /@:handle", () => { - test("redirects to login when not authenticated", async () => { - const res = await app.handle(makeRequest("/@some-handle")); - expect(res.status).toBe(302); - const location = res.headers.get("location"); - expect(location).toContain("/login"); - expect(location).toContain( - `returnTo=${encodeURIComponent("/@some-handle")}`, - ); +const createdSlugs: { slug: string }[] = []; + +afterAll(() => { + for (const { slug } of createdSlugs) { + cleanupWikiAndDependents(slug); + } +}); + +describe("GET /@:handle (public profile)", () => { + test("renders the profile page for any handle (no login required)", async () => { + const res = await fetch("GET", `/@${ALICE.handle}`); + expect(res.status).toBe(200); }); - test("preserves DID in returnTo when handle is a DID", async () => { - const res = await app.handle(makeRequest("/@did:plc:abc123")); - expect(res.status).toBe(302); - const location = res.headers.get("location"); - expect(location).toContain( - `returnTo=${encodeURIComponent("/@did:plc:abc123")}`, - ); + test("visitor only sees alice's public wikis, not her private ones", async () => { + const pubSlug = "alice-pub-prof"; + const privSlug = "alice-priv-prof"; + emitWiki(ALICE.did, "Alice Public", "public", pubSlug); + emitWiki(ALICE.did, "Alice Private", "private", privSlug); + createdSlugs.push({ slug: pubSlug }, { slug: privSlug }); + + const bobC = await loginCookie("bob.test"); + const res = await fetch("GET", `/@${ALICE.handle}`, { cookie: bobC }); + const body = await res.text(); + expect(res.status).toBe(200); + expect(body).toContain("Alice Public"); + expect(body).not.toContain("Alice Private"); + }); + + test("owner sees their public and private wikis", async () => { + const pubSlug = "alice-pub-owner"; + const privSlug = "alice-priv-owner"; + emitWiki(ALICE.did, "Alice Pub Owner", "public", pubSlug); + emitWiki(ALICE.did, "Alice Priv Owner", "private", privSlug); + createdSlugs.push({ slug: pubSlug }, { slug: privSlug }); + + const aliceC = await loginCookie("alice.test"); + const res = await fetch("GET", `/@${ALICE.handle}`, { cookie: aliceC }); + const body = await res.text(); + expect(res.status).toBe(200); + expect(body).toContain("Alice Pub Owner"); + expect(body).toContain("Alice Priv Owner"); + }); + + test("bookmarks section only renders for the profile owner", async () => { + const aliceC = await loginCookie("alice.test"); + const ownerView = await ( + await fetch("GET", `/@${ALICE.handle}`, { cookie: aliceC }) + ).text(); + const visitorView = await ( + await fetch("GET", `/@${ALICE.handle}`, { + cookie: await loginCookie("bob.test"), + }) + ).text(); + // "Bookmarks" heading only present in owner view. + expect(ownerView).toContain("Bookmarks"); + expect(visitorView).not.toContain("Bookmarks"); }); }); describe("GET /profile-redirect", () => { test("redirects to login when not authenticated", async () => { - const res = await app.handle(makeRequest("/profile-redirect")); + const res = await fetch("GET", "/profile-redirect"); expect(res.status).toBe(302); expect(res.headers.get("location")).toBe("/login"); }); + + test("redirects authenticated user to their profile", async () => { + const aliceC = await loginCookie("alice.test"); + const res = await fetch("GET", "/profile-redirect", { cookie: aliceC }); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe(`/@${ALICE.did}`); + }); +}); + +describe("DID → handle canonical redirect", () => { + test("/@ 301s to /@", async () => { + const res = await fetch("GET", `/@${ALICE.did}`); + expect(res.status).toBe(301); + expect(res.headers.get("location")).toBe(`/@${ALICE.handle}`); + }); + + test("preserves wiki/note path segments and query string", async () => { + const res = await fetch("GET", `/@${BOB.did}/some-wiki/some-note?foo=bar`); + expect(res.status).toBe(301); + expect(res.headers.get("location")).toBe( + `/@${BOB.handle}/some-wiki/some-note?foo=bar`, + ); + }); }); -- 2.51.2