From 4f244c559d8794ca80879a942b79ce73fbc68b44 Mon Sep 17 00:00:00 2001 From: Holke Brammer <103380539+holkexyz@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:47:18 +0200 Subject: [PATCH] test: add contract tests and a credential-free Playwright suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vitest suite (147 files, ~1235 tests) is dense on utilities but every test is single-layer: the client/server seam is always cut with vi.mock or a stubbed fetch. That is why the org.hypercerts.context.attachment allowlist gap shipped — client and server were each correct, only the pair was wrong. Adds the two layers that can see a mismatch. Tier 1 — contract tests, no browser: - indexer operation contract: scans src/ for every operationName passed to postIndexer/getIndexer and asserts it exists in the server's OPERATIONS map. A missing entry is a 400 at runtime and invisible to every other test. Currently 20 call sites, 19 distinct operations. - public-read-methods: pins the signed-out read boundary behaviourally (getRecord/listRecords/getBlob public; everything else 401) so it cannot silently widen or narrow. Tier 2 — Playwright, no credentials. /dev/preview/{surface} already mounts the real production components against fixtures via MockFetchProvider; it was only ever driven by the screenshot script. Adds playwright.config.ts plus specs covering the 8 surfaces across populated/empty/managed scenarios and dark mode, and a signed-out walk of the public routes asserting no console errors or uncaught exceptions. 39 pass in ~17s against next dev, with no ePDS, Redis, or indexer access. Tier 3 — authenticated flows, scaffolded and skipped by default. There is no password grant (OAuth + emailed OTP), but the cookie and OAuth halves of a session are stored independently, so one interactive login for a throwaway account lets global-setup mint cookies for 30 days. Ships the update create/edit/delete lifecycle spec; skips cleanly without E2E_TEST_DID so CI and forks are unaffected. Also: the preview harness mocks fetch client-side only, so its SSR/client hydration mismatch is structural — tolerated on /dev/preview only, never on real routes, which hydrate clean today. Housekeeping: AGENTS.md claimed in three places that no tests exist; adds §27 documenting the layout, the contract-test rule, and the CSRF origin discipline that makes these suites reproducible. Removes two manual test plans covering the retired /groups index and the removed notifications system. Wires @testing-library/jest-dom and a global afterEach(cleanup) into test-setup.ts — both were dependencies that were never imported. Verified: tsc, typecheck:test, lint clean; 1245 vitest tests pass; 39 e2e pass / 4 skip. Removing the collection from ALLOWED_WRITE_COLLECTIONS fails the contract test in 32ms. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 42 +++++ .gitignore | 8 + AGENTS.md | 88 ++++++++- e2e/auth/global-setup.ts | 130 ++++++++++++++ e2e/auth/update-lifecycle.spec.ts | 117 ++++++++++++ e2e/helpers.ts | 112 ++++++++++++ e2e/public-routes.spec.ts | 94 ++++++++++ e2e/smoke.spec.ts | 102 +++++++++++ package-lock.json | 22 ++- package.json | 3 + playwright.config.ts | 66 +++++++ .../__tests__/operation-contract.test.ts | 101 +++++++++++ .../__tests__/public-read-methods.test.ts | 167 ++++++++++++++++++ src/test-setup.ts | 16 ++ tests/groups.test-plan.md | 99 ----------- tests/notifications.test-plan.md | 77 -------- 16 files changed, 1060 insertions(+), 184 deletions(-) create mode 100644 e2e/auth/global-setup.ts create mode 100644 e2e/auth/update-lifecycle.spec.ts create mode 100644 e2e/helpers.ts create mode 100644 e2e/public-routes.spec.ts create mode 100644 e2e/smoke.spec.ts create mode 100644 playwright.config.ts create mode 100644 src/app/api/indexer/__tests__/operation-contract.test.ts create mode 100644 src/app/api/xrpc/[...method]/__tests__/public-read-methods.test.ts delete mode 100644 tests/groups.test-plan.md delete mode 100644 tests/notifications.test-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20385efa..58bd2569 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,3 +36,45 @@ jobs: - name: Build run: COOKIE_SECRET="$(openssl rand -hex 32)" npm run build + + # Browser tests. Split from `verify` so a flaky browser run is + # distinguishable at a glance from a broken build, and so the two run + # concurrently rather than adding to the critical path. + # + # Credential-free by design: the specs drive `/dev/preview/*`, which + # serves the real components against fixtures via MockFetchProvider, so + # no ePDS, Redis, or indexer access is needed. The authenticated tier + # (e2e/auth/*) skips itself unless E2E_TEST_DID is present. + e2e: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20.19.0' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright browser + run: npx playwright install --with-deps chromium + + - name: Run E2E tests + # playwright.config.ts starts `next dev` itself on 127.0.0.1:3000. + # COOKIE_SECRET is required at import time by src/lib/auth/session.ts. + run: npm run test:e2e + env: + COOKIE_SECRET: ci-e2e-secret-not-used-for-real-sessions + PUBLIC_URL: http://127.0.0.1:3000 + + - name: Upload report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index c9990269..7322b14b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,14 @@ # testing /coverage +# playwright +/test-results +/playwright-report +/blob-report +/playwright/.cache +# Seeded session state for tier-3 E2E — contains a live session cookie. +/e2e/.auth + # next.js /.next/ /out/ diff --git a/AGENTS.md b/AGENTS.md index b54e2f32..037c45c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -105,7 +105,7 @@ Certified is a passwordless identity platform built on **AT Protocol** (atproto) | Fonts | Inter (sans), Noto Serif (headline), Instrument Serif (alt) — via `next/font/google` | | Hosting | Vercel | | Lint | ESLint flat config extending `next/core-web-vitals` and `next/typescript` | -| Test runner | **None.** The "quality gate" is `next build` + `tsc --noEmit`. A behavioral plan lives at `tests/groups.test-plan.md`. | +| Test runner | **Vitest** (jsdom), ~1235 tests in `src/**/__tests__/`. Playwright for E2E in `e2e/`. See §27. | > Note: Next.js 16 renamed `middleware.ts` to `proxy.ts`. This app ships **no** edge proxy/middleware — the `/` redirect is client-side (`HomeClient`). @@ -695,7 +695,7 @@ When adding a new public page: set `metadata.title`, `description`, `alternates. - `main` → production (`certified.app`) - `staging` → preview (`staging.certified.app`) - **Workflow:** push to `staging`, open a PR to `main`. Vercel deploys both branches automatically. -- **Quality gate:** `npm run build` must succeed before pushing. There is no test suite to run; `tsc --noEmit` is implicit in `next build`. +- **Quality gate:** `npm run lint && npx tsc --noEmit && npm test && npm run build` must all pass before pushing — CI runs exactly that on every PR (`.github/workflows/ci.yml`). See §27 for the test layout. - **OAuth URL precedence:** `PUBLIC_URL` → `VERCEL_BRANCH_URL` → `VERCEL_URL`. Prefer an explicit `PUBLIC_URL` for stable production/staging callbacks. Generated Vercel origins are also accepted for same-origin CSRF requests. The selected metadata/JWKS endpoints must be public, and deployments that initiate and complete one OAuth flow must share compatible Redis state/session configuration. Distinct canonical OAuth origins should use separate Redis databases because saved OAuth session keys are DID-based and are not namespaced by `client_id`. - Don't commit secrets (`.env.local` is gitignored). `COOKIE_SECRET`, `UPSTASH_*`, `ATPROTO_PRIVATE_KEY`, `RESEND_API_KEY` live in Vercel envs. @@ -711,8 +711,11 @@ certified-app/ ├── eslint.config.mjs # next/core-web-vitals + next/typescript ├── postcss.config.mjs # tailwindcss only — autoprefixer intentionally omitted (no browserslist); vendor prefixes are hand-maintained ├── .env.local.example # Env var template -├── tests/ -│ └── groups.test-plan.md # Manual behavioral test plan (no automated tests) +├── e2e/ # Playwright specs — see §27 +│ ├── smoke.spec.ts # /dev/preview surfaces, no credentials +│ ├── public-routes.spec.ts # signed-out route walk +│ └── auth/ # authenticated flows, skipped without E2E_TEST_DID +├── playwright.config.ts # baseURL pinned to PUBLIC_URL's exact origin + port ├── public/ │ ├── llms.txt # AI crawler description │ ├── assets/ @@ -898,7 +901,7 @@ certified-app/ ## 21. Known Limitations -- **No automated tests.** `tests/groups.test-plan.md` is a manual behavioral plan, not runnable. The quality gates are `npm run build` and `npx tsc --noEmit`. +- **Thin coverage above the utility layer.** The vitest suite is dense on `src/lib/utils` (~85% of modules) but thin on components (~10% rendered) and pages (~12%). Most tests are single-layer: the client/server seam is always cut with `vi.mock` or a stubbed `fetch`, so a mismatch between what the client sends and what a route accepts is invisible to them — that is how the `org.hypercerts.context.attachment` allowlist gap shipped. Contract tests (§27) exist to close that specific class. - **2FA / TOTP** — not implemented on the ePDS. The `/settings` page shows a "This will be available soon" placeholder. - **App passwords** — same status: placeholder card on `/settings`. - **ERC-1271 / ERC-6492 verification** — `useIdentityLinks` returns `verified: false` for smart-contract-wallet signatures with `verificationError: "On-chain verification not yet supported"`. The signing path can produce these (the `signatureType` field exists), but the verifier doesn't make on-chain `isValidSignature` calls yet. @@ -1153,3 +1156,78 @@ npx next build Plus a smoke test of the changed surface in `next dev` when the change is user-facing. + +--- + +## 27. Testing + +### Layout + +| Kind | Location | Runner | Needs credentials? | +|---|---|---|---| +| Unit / route-handler / component | `src/**/__tests__/*.{test,spec}.{ts,tsx}` | Vitest (jsdom) | no | +| Contract ("tier 1") | same, colocated with the allowlist they guard | Vitest | no | +| Smoke E2E ("tier 2") | `e2e/*.spec.ts` | Playwright | no | +| Authenticated E2E ("tier 3") | `e2e/auth/*.spec.ts` | Playwright | yes — skipped without `E2E_TEST_DID` | + +```bash +npm test # vitest run +npm run test:watch # vitest +npm run test:coverage # vitest run --coverage (no thresholds set yet) +npm run typecheck:test # tsc against tsconfig.test.json +npx playwright test # tiers 2 + 3 +``` + +`src/test-setup.ts` is the only `setupFiles` entry. It registers the jest-dom +matchers, runs `cleanup()` after every test, stubs `window.matchMedia`, and +forces an in-memory `Storage` onto both `window` and `globalThis` (Node 21+ +ships a native `localStorage` global with no backing store that otherwise +shadows jsdom's working one). + +### Contract tests — the class of bug that hurts here + +Most of this app's surfaces talk to themselves through an allowlist: the XRPC +proxy's `ALLOWED_WRITE_COLLECTIONS`, its `PUBLIC_READ_METHODS`, the indexer's +operation map, the group BFF's op list. **A client that sends something absent +from the matching allowlist gets a silent 403, and no single-layer test can +see it** — the client is correct, the server is correct, only the pair is +wrong. That is precisely how own-repo activity updates shipped broken. + +The fix is a test that asserts one side's set against the other's. +`src/app/api/xrpc/[...method]/__tests__/allowed-collections.test.ts` is the +template: it enumerates every collection client code writes and drives each +through the real route handler. **When you add a write surface, add its NSID +there** — the test fails until the allowlist agrees. + +### E2E without credentials + +`/dev/preview/{surface}` mounts the **real** production components against +fixture data, wrapped in `MockFetchProvider`, which intercepts every network +egress (same-origin API routes plus `plc.directory` and `public.api.bsky.app`). +Surfaces: `profile`, `profile-org`, `feed`, `settings`, `workspace`, `create`, +`profile-edit`, `activity-edit`; `?fixture=empty` and `?managed=1` switch +scenarios. This is how the smoke suite covers auth-gated screens with no +account at all. + +These routes `notFound()` when `NODE_ENV === "production"`, so they work under +`npm run dev` only — never against a deployed environment. + +### Authenticated E2E + +There is no password grant. Sign-in is atproto OAuth against the ePDS with an +emailed OTP, so it cannot be driven headlessly. Instead the OAuth session is +stored in Redis keyed by DID for 30 days, and the `certified_session` cookie is +just `${sessionId}.${hmac(sessionId, COOKIE_SECRET)}` over a Redis DID lookup. +So: sign a dedicated test account in **once**, then `e2e/auth/global-setup.ts` +mints fresh cookies for that DID for the next 30 days. + +Tier 3 writes real, federated records. Use a throwaway account, and clean up +what you create. + +### Origin discipline + +`checkCsrf` requires the request origin to equal the destination origin **and** +be allowlisted; the loopback exemption matches on protocol *and* port. A dev +server that falls back to `:3001`, or mixing `localhost` with `127.0.0.1` +(cookies don't cross the two), produces 403s that look like auth bugs. Pin the +port and use one spelling everywhere — `playwright.config.ts` does this. diff --git a/e2e/auth/global-setup.ts b/e2e/auth/global-setup.ts new file mode 100644 index 00000000..110e8971 --- /dev/null +++ b/e2e/auth/global-setup.ts @@ -0,0 +1,130 @@ +import { createHmac, randomBytes } from "node:crypto" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname, join } from "node:path" +import { Redis } from "@upstash/redis" + +/** + * Tier 3 global setup — mint a session cookie for a dedicated test DID. + * + * WHY THIS SHAPE. certified-app has no password grant: sign-in is atproto + * OAuth against the ePDS with an emailed OTP typed on the ePDS's own + * origin, so it cannot be driven headlessly (see AGENTS.md §27). But the + * two halves of a session are stored independently: + * + * - `certified_session` cookie = `${sessionId}.${hmac(sessionId, COOKIE_SECRET)}`, + * and `session:did:{sessionId}` in Redis maps it to a DID; + * - the OAuth tokens live at `oauth:session:{did}`, keyed ONLY by DID, + * with a 30-day TTL. + * + * So one interactive login for the test account populates the OAuth half + * for 30 days, and this setup mints the cookie half fresh on every run. + * No credential ever has to be handed to CI or to a test author. + * + * NO-OP WITHOUT CONFIG. When `E2E_TEST_DID` is absent this writes an + * empty storage state and returns, so Tiers 1-2 run unchanged on forks + * and in secret-less CI. Specs that need a session call + * `requiresSession()` and skip themselves. + */ + +const AUTH_DIR = join(process.cwd(), "e2e", ".auth") +export const STORAGE_STATE = join(AUTH_DIR, "state.json") + +const SESSION_COOKIE = "certified_session" +const SESSION_DID_PREFIX = "session:did:" +const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 + +/** + * Minimal .env parser so this file needs no dotenv dependency. Only + * `KEY=value` lines; existing process.env always wins. + */ +function loadEnvFile(path: string) { + if (!existsSync(path)) return + for (const rawLine of readFileSync(path, "utf8").split("\n")) { + const line = rawLine.trim() + if (!line || line.startsWith("#")) continue + const eq = line.indexOf("=") + if (eq === -1) continue + const key = line.slice(0, eq).trim() + let value = line.slice(eq + 1).trim() + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + if (!(key in process.env)) process.env[key] = value + } +} + +function writeStorageState(cookies: unknown[]) { + mkdirSync(dirname(STORAGE_STATE), { recursive: true }) + writeFileSync( + STORAGE_STATE, + JSON.stringify({ cookies, origins: [] }, null, 2), + "utf8", + ) +} + +export default async function globalSetup() { + // `.env.test.local` matches the gitignored `.env*.local` pattern. + loadEnvFile(join(process.cwd(), ".env.test.local")) + + const did = process.env.E2E_TEST_DID + if (!did) { + // Credential-free run: tiers 1-2 only. + writeStorageState([]) + return + } + + const cookieSecret = process.env.COOKIE_SECRET + const redisUrl = process.env.UPSTASH_REDIS_REST_URL + const redisToken = process.env.UPSTASH_REDIS_REST_TOKEN + + const missing = [ + !cookieSecret && "COOKIE_SECRET", + !redisUrl && "UPSTASH_REDIS_REST_URL", + !redisToken && "UPSTASH_REDIS_REST_TOKEN", + ].filter(Boolean) + + if (missing.length > 0) { + // Fail loudly: E2E_TEST_DID was set deliberately, so a half-configured + // run is a mistake, not a reason to silently skip. + throw new Error( + `E2E_TEST_DID is set but ${missing.join(", ")} ${ + missing.length === 1 ? "is" : "are" + } missing. Add to .env.test.local (gitignored) or the CI secrets.`, + ) + } + + if (!did.startsWith("did:")) { + throw new Error(`E2E_TEST_DID must be a DID, got: ${did}`) + } + + // Mint the cookie half, mirroring createSession() in src/lib/auth/session.ts. + const sessionId = randomBytes(32).toString("hex") + const signature = createHmac("sha256", cookieSecret!) + .update(sessionId) + .digest("hex") + + const redis = new Redis({ url: redisUrl!, token: redisToken! }) + await redis.set(`${SESSION_DID_PREFIX}${sessionId}`, did, { + ex: SESSION_TTL_SECONDS, + }) + + const baseUrl = new URL(process.env.E2E_BASE_URL ?? "http://127.0.0.1:3000") + + writeStorageState([ + { + name: SESSION_COOKIE, + value: `${sessionId}.${signature}`, + domain: baseUrl.hostname, + path: "/", + // Session TTL in seconds since epoch. + expires: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS, + httpOnly: true, + // `secure` is set only in production by the app; match the origin. + secure: baseUrl.protocol === "https:", + sameSite: "Lax" as const, + }, + ]) +} diff --git a/e2e/auth/update-lifecycle.spec.ts b/e2e/auth/update-lifecycle.spec.ts new file mode 100644 index 00000000..b6cf1e99 --- /dev/null +++ b/e2e/auth/update-lifecycle.spec.ts @@ -0,0 +1,117 @@ +import { test, expect } from "@playwright/test" +import { STORAGE_STATE } from "./global-setup" +import { collectProblems, gotoAndSettle } from "../helpers" + +/** + * Tier 3 — the real write path, against a real ePDS. + * + * This is the regression test for the bug that started all of this: + * posting an activity update 403'd because + * `org.hypercerts.context.attachment` was missing from the XRPC proxy's + * write allowlist. Create / edit / delete all route through the same + * gate, so all three are exercised here. + * + * `allowed-collections.test.ts` catches that specific omission in + * milliseconds without a browser — this spec is the backstop that proves + * the whole chain works against the actual PDS: session → CSRF → proxy → + * OAuth session → PDS write → indexer read-back. + * + * COSTS REAL RECORDS. Every run writes to a real, federated atproto repo. + * Use a throwaway account (E2E_TEST_DID), and note the cleanup contract + * below — a failed run can leave records behind. + */ + +const TEST_ACTIVITY_URI = process.env.E2E_TEST_ACTIVITY_URI +const hasSession = !!process.env.E2E_TEST_DID + +test.describe("update lifecycle (authenticated)", () => { + test.skip( + !hasSession, + "Set E2E_TEST_DID in .env.test.local to run authenticated tests — see AGENTS.md §27", + ) + test.skip( + !TEST_ACTIVITY_URI, + "Set E2E_TEST_ACTIVITY_URI to an activity the test account authored", + ) + + test.use({ storageState: STORAGE_STATE }) + + // Writes are serial and stateful: create → edit → delete on one record. + test.describe.configure({ mode: "serial" }) + + const title = `E2E update ${Date.now()}` + let updateUrl: string | null = null + + test("the session is actually authenticated", async ({ page }) => { + // Fail fast and clearly if the seeded cookie didn't take — otherwise + // every later assertion fails for a confusing downstream reason. + const res = await page.request.get("/api/auth/session") + expect(res.status(), "seeded session cookie should authenticate").toBe(200) + const body = await res.json() + expect(body.did).toBe(process.env.E2E_TEST_DID) + }) + + test("posts a new update", async ({ page }) => { + const problems = collectProblems(page) + + // at://did/collection/rkey → /{did}/activity/{rkey}/update/new + const [, , did, , rkey] = TEST_ACTIVITY_URI!.split("/") + await gotoAndSettle(page, `/${did}/activity/${rkey}/update/new`) + + await page.getByLabel(/^title$/i).fill(title) + await page.locator('[contenteditable="true"]').first().fill("Posted by the E2E suite.") + await page.getByRole("button", { name: /post update/i }).click() + + // The 403 this suite exists for surfaced as an inline error box. + await expect( + page.getByText(/must be an allowed collection/i), + "the collection allowlist rejected the write", + ).toHaveCount(0) + + await expect(page).toHaveURL(/tab=updates/, { timeout: 20_000 }) + await expect(page.getByText(title)).toBeVisible() + + updateUrl = page.url() + expect(problems.pageErrors).toEqual([]) + }) + + test("edits the update (putRecord path)", async ({ page }) => { + test.skip(!updateUrl, "create step did not complete") + + await gotoAndSettle(page, updateUrl!) + await page.getByText(title).click() + await page.getByRole("link", { name: /edit/i }).first().click() + + const edited = `${title} (edited)` + await page.getByLabel(/^title$/i).fill(edited) + await page.getByRole("button", { name: /save|update/i }).first().click() + + await expect(page.getByText(edited)).toBeVisible({ timeout: 20_000 }) + }) + + test("deletes the update (deleteRecord path)", async ({ page }) => { + test.skip(!updateUrl, "create step did not complete") + + await gotoAndSettle(page, updateUrl!) + await page.getByText(new RegExp(title)).first().click() + await page.getByRole("button", { name: /delete/i }).first().click() + // ConfirmDialog — accept. + await page.getByRole("button", { name: /^delete$/i }).last().click() + + await expect(page.getByText(new RegExp(title))).toHaveCount(0, { + timeout: 20_000, + }) + }) +}) + +/** + * CLEANUP CONTRACT + * + * The delete step is the cleanup for the happy path. If an earlier step + * fails the record survives, so before adding more write specs, add a + * sweep here that lists `org.hypercerts.context.attachment` records on + * E2E_TEST_DID whose title starts with "E2E update " and deletes them. + * `deleteContextUpdate` in src/lib/atproto/context-attachment.ts is the + * client-side equivalent; from a spec, drive it via page.request against + * /api/xrpc/com/atproto/repo/deleteRecord with the seeded session. + */ diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 00000000..41d12871 --- /dev/null +++ b/e2e/helpers.ts @@ -0,0 +1,112 @@ +import type { Page, ConsoleMessage } from "@playwright/test" + +/** + * Shared helpers for the E2E tiers. + * + * The load-time assertion these specs lean on is "the page rendered and + * the console stayed clean". That catches the failure mode unit tests + * structurally cannot: a component that throws during render, a hook that + * blows up on real data shapes, a hydration mismatch, a missing provider. + */ + +/** + * Console noise that is expected and must not fail a spec. + * + * Keep this list SHORT and justified — every entry is a class of real + * error we've agreed to stop seeing. Prefer fixing the source. + */ +const IGNORED_CONSOLE = [ + // React DevTools nag in dev builds. + /Download the React DevTools/i, + // Next dev overlay + HMR chatter. + /\[Fast Refresh\]/i, + // jsdom/browser noise from next/image's dev-mode warnings about + // fixture images that intentionally 404 in the mock harness. + /Failed to load resource/i, +] + +/** + * Structural to the `/dev/preview/*` harness, NOT a product bug. + * + * `MockFetchProvider` is a client component that patches `window.fetch` + * during first render, so the server render sees no fixtures (empty + * state) while the client render sees them (populated). React reports + * the difference as a hydration mismatch on every preview surface. + * + * Allowed ONLY where `allowHydrationMismatch` is passed — i.e. in + * smoke.spec.ts. On real routes a hydration mismatch is a genuine bug + * and must fail: public-routes.spec.ts hydrates clean today, which is + * what makes this exemption safe to grant. + */ +const HYDRATION_MISMATCH = /Hydration failed because the server rendered/i + +export interface PageProblems { + consoleErrors: string[] + pageErrors: string[] +} + +export interface CollectOptions { + /** Tolerate the preview harness's inherent SSR/client fixture mismatch. */ + allowHydrationMismatch?: boolean +} + +/** + * Start collecting console errors and uncaught exceptions. Call before + * `page.goto`; read the arrays after the page settles. + */ +export function collectProblems( + page: Page, + options: CollectOptions = {}, +): PageProblems { + const problems: PageProblems = { consoleErrors: [], pageErrors: [] } + + const ignored = options.allowHydrationMismatch + ? [...IGNORED_CONSOLE, HYDRATION_MISMATCH] + : IGNORED_CONSOLE + + page.on("console", (msg: ConsoleMessage) => { + if (msg.type() !== "error") return + const text = msg.text() + if (ignored.some((re) => re.test(text))) return + problems.consoleErrors.push(text) + }) + + // Uncaught exceptions and unhandled rejections in page context. + page.on("pageerror", (err) => { + const message = err.message.split("\n")[0] + if (ignored.some((re) => re.test(message))) return + problems.pageErrors.push(message) + }) + + return problems +} + +/** + * Seed the theme before first paint. next-themes persists to + * localStorage under "theme" and flips `data-theme` on ; setting + * both avoids a flash and removes any dependence on the OS preference. + * Mirrors scripts/preview-screenshots.mjs. + */ +export async function seedTheme(page: Page, theme: "light" | "dark") { + await page.addInitScript((t) => { + try { + localStorage.setItem("theme", t as string) + document.documentElement.setAttribute("data-theme", t as string) + document.documentElement.style.colorScheme = t as string + } catch { + /* ignore */ + } + }, theme) +} + +/** + * Navigate and wait for the fixture-driven fetches to resolve and paint. + * `networkidle` alone returns too early on these surfaces because the + * mock fetch layer resolves synchronously and React renders after. + */ +export async function gotoAndSettle(page: Page, url: string) { + await page.goto(url, { waitUntil: "networkidle" }) + await page.waitForLoadState("domcontentloaded") + // Let effect-driven fixture renders flush. + await page.waitForTimeout(400) +} diff --git a/e2e/public-routes.spec.ts b/e2e/public-routes.spec.ts new file mode 100644 index 00000000..8d19f42c --- /dev/null +++ b/e2e/public-routes.spec.ts @@ -0,0 +1,94 @@ +import { test, expect } from "@playwright/test" +import { collectProblems, gotoAndSettle } from "./helpers" + +/** + * Tier 2 — the signed-out route walk. + * + * Unlike smoke.spec.ts these are the REAL routes with no fixture layer, + * visited with no session cookie. They exercise the public-read boundary + * end to end: `PUBLIC_READ_METHODS` in the XRPC proxy, the unauthenticated + * `/api/search-actors` path, and every marketing/legal page. + * + * A logged-in developer never sees these break. That is exactly why they + * are worth pinning. + */ + +/** Routes that must render for an anonymous visitor. */ +const PUBLIC_ROUTES = [ + "/welcome", + "/explore", + "/apps", + "/help", + "/terms", + "/privacy", + "/imprint", + "/dsa", +] as const + +test.describe("public routes render for signed-out visitors", () => { + for (const route of PUBLIC_ROUTES) { + test(`${route}`, async ({ page }) => { + const problems = collectProblems(page) + const response = await page.goto(route, { waitUntil: "networkidle" }) + + expect(response?.status(), `${route} should not error`).toBeLessThan(400) + // Every page must expose exactly one h1 — nested
landmarks + // and duplicate h1s were both real regressions here. + await expect(page.locator("h1").first()).toBeVisible() + + expect(problems.pageErrors, `${route}: uncaught exceptions`).toEqual([]) + expect(problems.consoleErrors, `${route}: console errors`).toEqual([]) + }) + } +}) + +test.describe("routing invariants", () => { + test("/ redirects signed-out visitors to /welcome", async ({ page }) => { + await gotoAndSettle(page, "/") + await expect(page).toHaveURL(/\/welcome$/) + }) + + test("/groups redirects to /home (retired index)", async ({ page }) => { + // The standalone listing was replaced by the profile Groups tab; the + // URL is kept as a redirect so old links don't 404. + await page.goto("/groups", { waitUntil: "domcontentloaded" }) + await expect(page).not.toHaveURL(/\/groups$/) + }) + + test("an unresolvable handle settles into 'Profile not found'", async ({ + page, + }) => { + // There is no 404 here to assert: `/{actor}` is a catch-all, so any + // unknown top-level path is treated as a handle and resolved at + // runtime. What matters is that resolution FAILS CLOSED — it must + // reach the empty state rather than spin forever or throw. + await page.goto("/this-handle-does-not-exist", { + waitUntil: "domcontentloaded", + }) + + // Role-scoped: the resolver signals the miss by throwing, so the + // literal string also appears in Next's dev error overlay and in the + // source frame it prints. Only the rendered heading is the assertion. + await expect( + page.getByRole("heading", { name: /profile not found/i }), + ).toBeVisible({ timeout: 20_000 }) + + // No console-error assertion here on purpose: the not-found path is + // implemented as throw-and-catch, so dev-mode surfaces it in the + // overlay even though the UI handled it correctly. + }) +}) + +test.describe("dev preview is not reachable in production builds", () => { + // Guard rail: if these routes ever stopped being NODE_ENV-gated, the + // fixture harness (and a fake session) would ship to real users. + test("the gate exists in the source", async () => { + const { readFileSync } = await import("node:fs") + const source = readFileSync( + "src/app/dev/preview/[surface]/page.tsx", + "utf8", + ) + expect(source).toContain('process.env.NODE_ENV === "production"') + expect(source).toContain("notFound()") + }) +}) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 00000000..38f1affb --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,102 @@ +import { test, expect } from "@playwright/test" +import { collectProblems, gotoAndSettle, seedTheme } from "./helpers" + +/** + * Tier 2 — auth-gated surfaces, no credentials. + * + * `/dev/preview/{surface}` mounts the REAL production components against + * fixture data under `MockFetchProvider`, which intercepts every network + * egress (same-origin API routes plus plc.directory and + * public.api.bsky.app). That lets us render profile / feed / settings / + * workspace / create / edit screens — all normally behind a session — + * with no account, no Redis, and no ePDS. + * + * What this tier is for: catching render-time explosions, hydration + * mismatches, missing providers, and crashes on empty-state data. It is + * NOT a substitute for Tier 3 — nothing here touches a real write path. + * + * These routes `notFound()` when NODE_ENV === "production", so this file + * only works against `npm run dev` (which playwright.config.ts starts). + */ + +const SURFACES = [ + "profile", + "profile-org", + "feed", + "settings", + "workspace", + "create", + "profile-edit", + "activity-edit", +] as const + +/** + * The preview harness mocks fetch client-side only, so the server render + * (no fixtures) and the client render (fixtures) legitimately differ. + * Every other error still fails the spec — see helpers.ts. + */ +const PREVIEW_OPTS = { allowHydrationMismatch: true } as const + +test.describe("preview surfaces render cleanly", () => { + for (const surface of SURFACES) { + test(`${surface} — populated`, async ({ page }) => { + const problems = collectProblems(page, PREVIEW_OPTS) + await gotoAndSettle(page, `/dev/preview/${surface}`) + + // Rendered something real, not an error boundary or a 404. + await expect(page.locator("body")).toBeVisible() + expect(await page.locator("body").innerText()).not.toContain( + "This page could not be found", + ) + + expect(problems.pageErrors, `${surface}: uncaught exceptions`).toEqual([]) + expect(problems.consoleErrors, `${surface}: console errors`).toEqual([]) + }) + } +}) + +test.describe("preview surfaces survive empty state", () => { + // `?fixture=empty` swaps every connection for a zero-row response. This + // is where "cannot read property of undefined" lives. + for (const surface of SURFACES) { + test(`${surface} — empty`, async ({ page }) => { + const problems = collectProblems(page, PREVIEW_OPTS) + await gotoAndSettle(page, `/dev/preview/${surface}?fixture=empty`) + + await expect(page.locator("body")).toBeVisible() + expect(problems.pageErrors, `${surface}: uncaught exceptions`).toEqual([]) + expect(problems.consoleErrors, `${surface}: console errors`).toEqual([]) + }) + } +}) + +test.describe("managed (group-owned) scenario", () => { + // `?managed=1` gives the mock session groups it owns/admins, exercising + // the org-aggregation paths on Home and the profile tabs. + for (const surface of ["feed", "profile", "workspace"] as const) { + test(`${surface} — managed`, async ({ page }) => { + const problems = collectProblems(page, PREVIEW_OPTS) + await gotoAndSettle(page, `/dev/preview/${surface}?managed=1`) + + await expect(page.locator("body")).toBeVisible() + expect(problems.pageErrors, `${surface}: uncaught exceptions`).toEqual([]) + expect(problems.consoleErrors, `${surface}: console errors`).toEqual([]) + }) + } +}) + +test.describe("dark mode", () => { + // Hard rule #9 in CLAUDE.md: dark mode must work everywhere. A surface + // that throws only under data-theme="dark" is a real (and easy) miss. + for (const surface of SURFACES) { + test(`${surface} — dark`, async ({ page }) => { + const problems = collectProblems(page, PREVIEW_OPTS) + await seedTheme(page, "dark") + await gotoAndSettle(page, `/dev/preview/${surface}`) + + await expect(page.locator("html")).toHaveAttribute("data-theme", "dark") + expect(problems.pageErrors, `${surface}: uncaught exceptions`).toEqual([]) + expect(problems.consoleErrors, `${surface}: console errors`).toEqual([]) + }) + } +}) diff --git a/package-lock.json b/package-lock.json index f8e85ec9..6b04dd64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "1.60.0", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", @@ -1376,6 +1377,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@react-leaflet/core": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", @@ -6668,7 +6685,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.60.0" @@ -6687,7 +6704,7 @@ "version": "1.60.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -6700,7 +6717,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index cebe1133..9e051a0c 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "typecheck": "tsc --noEmit", "typecheck:test": "tsc --noEmit -p tsconfig.test.json", "styleguide:generate": "node scripts/generate-styleguide.mjs" @@ -43,6 +45,7 @@ }, "devDependencies": { "@eslint/eslintrc": "^3", + "@playwright/test": "1.60.0", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.2", "@testing-library/user-event": "14.6.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..a18a221f --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,66 @@ +import { defineConfig, devices } from "@playwright/test" + +/** + * Playwright config for the E2E tiers. See AGENTS.md §27. + * + * ORIGIN DISCIPLINE — the single most important thing in this file. + * `checkCsrf` (src/lib/auth/csrf.ts) requires the request origin to equal + * the destination origin AND be allowlisted, and the loopback exemption + * matches on protocol *and* port. So: + * + * - the host spelling must match PUBLIC_URL exactly — `127.0.0.1` and + * `localhost` are different origins to CSRF, and cookies don't cross + * between them either; + * - the port must be pinned. `next dev` silently falls back to 3001 + * when 3000 is busy, which produces 403s on every POST that read as + * auth bugs. `--port` without `--turbo`'s auto-increment plus + * `reuseExistingServer` keeps this honest. + * + * Change BASE_URL here only together with PUBLIC_URL in .env.local. + */ + +const HOST = "127.0.0.1" +const PORT = 3000 +const BASE_URL = process.env.E2E_BASE_URL ?? `http://${HOST}:${PORT}` + +export default defineConfig({ + testDir: "./e2e", + // Tier 3 needs a session seeded before any spec runs; it no-ops when + // E2E_TEST_DID is absent so the credential-free tiers still run. + globalSetup: "./e2e/auth/global-setup.ts", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + // `next dev` compiles routes on first request. Too much parallelism + // makes every worker race the same cold compile and time out. + workers: process.env.CI ? 2 : 4, + reporter: process.env.CI ? [["github"], ["html", { open: "never" }]] : [["list"]], + timeout: 45_000, + expect: { timeout: 10_000 }, + + use: { + baseURL: BASE_URL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "off", + }, + + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"], viewport: { width: 1440, height: 900 } }, + }, + ], + + webServer: { + // Pinned host + port — see ORIGIN DISCIPLINE above. + command: `npm run dev -- --port ${PORT} --hostname ${HOST}`, + url: BASE_URL, + reuseExistingServer: !process.env.CI, + // A cold Next dev boot plus first compile is slow; this is startup + // budget, not per-test budget. + timeout: 180_000, + stdout: "ignore", + stderr: "pipe", + }, +}) diff --git a/src/app/api/indexer/__tests__/operation-contract.test.ts b/src/app/api/indexer/__tests__/operation-contract.test.ts new file mode 100644 index 00000000..b389c95f --- /dev/null +++ b/src/app/api/indexer/__tests__/operation-contract.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from "vitest" +import { readdirSync, readFileSync, statSync } from "node:fs" +import { join, relative } from "node:path" +import { OPERATIONS } from "../operations" + +/** + * Contract test: every operation name the client asks for must exist in + * the server's `OPERATIONS` map. + * + * The indexer proxy is a trust boundary — the client sends an + * `operationName` and the server holds the query strings, refusing + * anything it doesn't recognise (`route.ts`: `Object.hasOwn(OPERATIONS, + * operationName)` → 400 "Unknown operation"). That check is correct, and + * it is also invisible: a hook that asks for an operation nobody added to + * `operations.ts` compiles, type-checks, lints, and passes every + * single-layer test, then 400s in production. + * + * This is the same failure shape as the XRPC write allowlist that broke + * own-repo activity updates — see `allowed-collections.test.ts`, the + * sibling of this test. Both sides are individually correct; only the + * pair is wrong. So we check the pair. + * + * Deliberately a source scan rather than a runtime check: the operation + * names are string literals at ~30 call sites, and extracting them + * statically is what lets this fail at `npm test` instead of at runtime. + * If you add a `postIndexer` call, add the query to `operations.ts` and + * this passes on its own. + */ + +const SRC_ROOT = join(__dirname, "..", "..", "..", "..") + +/** Source files that could contain a client call, excluding test fixtures. */ +function collectSourceFiles(dir: string, acc: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + if (statSync(full).isDirectory()) { + // __tests__ files use invented operation names against a stubbed + // fetch (e.g. "ThingCount"); they never reach the real route. + if (entry === "__tests__" || entry === "node_modules") continue + collectSourceFiles(full, acc) + } else if (/\.tsx?$/.test(entry)) { + acc.push(full) + } + } + return acc +} + +/** + * Match the first string argument to postIndexer/getIndexer, across both + * the single-line form — postIndexer("Followers", {...}) — and the + * prettier-wrapped form where the name lands on the next line. + */ +const CALL_RE = /\b(?:postIndexer|getIndexer)\s*(?:<[^>()]*>)?\s*\(\s*"([A-Za-z0-9_]+)"/g + +interface Usage { + operationName: string + file: string +} + +function findUsages(): Usage[] { + const usages: Usage[] = [] + for (const file of collectSourceFiles(SRC_ROOT)) { + const source = readFileSync(file, "utf8") + for (const match of source.matchAll(CALL_RE)) { + usages.push({ operationName: match[1], file: relative(SRC_ROOT, file) }) + } + } + return usages +} + +describe("indexer operation contract", () => { + const usages = findUsages() + + it("finds client call sites at all (guards against the scanner silently breaking)", () => { + // If a refactor renames postIndexer or changes the call shape, this + // test would otherwise pass vacuously by finding nothing. + expect(usages.length).toBeGreaterThan(10) + }) + + it("every operation the client requests exists in OPERATIONS", () => { + const missing = usages.filter( + (u) => !Object.hasOwn(OPERATIONS, u.operationName), + ) + + expect( + missing.map((u) => `${u.operationName} (${u.file})`), + "these would 400 'Unknown operation' at runtime — add the query to src/app/api/indexer/operations.ts", + ).toEqual([]) + }) + + it("every OPERATIONS entry is a non-empty GraphQL document", () => { + for (const [name, query] of Object.entries(OPERATIONS)) { + expect(typeof query, `${name} should map to a query string`).toBe("string") + expect(query.trim().length, `${name} should not be empty`).toBeGreaterThan(0) + // The proxy forwards `operationName` alongside the document, so the + // document must actually declare that operation or the upstream + // rejects it. + expect(query, `${name} should declare operation "${name}"`).toContain(name) + } + }) +}) diff --git a/src/app/api/xrpc/[...method]/__tests__/public-read-methods.test.ts b/src/app/api/xrpc/[...method]/__tests__/public-read-methods.test.ts new file mode 100644 index 00000000..9a957e6c --- /dev/null +++ b/src/app/api/xrpc/[...method]/__tests__/public-read-methods.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" + +/** + * Tests for the signed-out read boundary in the XRPC proxy GET handler + * (`PUBLIC_READ_METHODS`). + * + * The proxy serves three atproto reads to unauthenticated visitors — + * `getRecord`, `listRecords`, `sync.getBlob` — because public profiles, + * activities and avatars have to render for logged-out users. Everything + * else 401s. + * + * Two ways this can rot, in opposite directions: + * - too narrow → a public page silently 401s for signed-out visitors + * while working perfectly for the logged-in developer who wrote it; + * - too wide → a session-bearing method becomes readable anonymously. + * + * Asserted behaviourally (no session cookie at all) rather than by + * exporting the Set, so the test exercises the real gate at route.ts. + */ + +const getSessionDid = vi.fn() +const getOAuthClient = vi.fn() +const resolvePdsUrl = vi.fn() + +vi.mock("@/lib/auth/oauth-client", () => ({ getOAuthClient })) +vi.mock("@/lib/auth/session", () => ({ + getSessionDid, + deleteSession: vi.fn(), +})) +vi.mock("@/lib/auth/csrf", () => ({ checkCsrf: vi.fn() })) +vi.mock("@/lib/atproto/did", () => ({ + resolvePdsUrl, + invalidateDidDoc: vi.fn(), +})) +vi.mock("@/lib/auth/rate-limit", () => ({ + checkAndIncrementWriteRate: vi.fn(), + RATE_LIMITED_WRITE_COLLECTIONS: {}, + makeLimiter: (name: string, max: number, windowSec: number) => ({ + name, + max, + windowSec, + }), + enforceRateLimit: vi.fn(async () => null), +})) +vi.mock("@/lib/utils/ip", () => ({ clientIp: () => "test-ip" })) +vi.mock("@atproto/api", () => ({ Agent: class {} })) + +const SOME_DID = "did:plc:s4puetfspot742ai7y4otuel" + +function makeGet(query: string) { + const url = new URL(`https://app.example/api/xrpc/${query}`) + return { nextUrl: url } as unknown as Parameters< + Awaited["GET"] + >[0] +} + +function makePost(body: unknown) { + return { + nextUrl: new URL("https://app.example/api/xrpc/com.atproto.repo.createRecord"), + json: async () => body, + headers: new Headers({ "content-type": "application/json" }), + } as unknown as Parameters["POST"]>[0] +} + +function params(methodName: string) { + return { params: Promise.resolve({ method: methodName.split(".") }) } +} + +beforeEach(() => { + // No session cookie — every request in this file is anonymous. + getSessionDid.mockReset().mockResolvedValue(null) + getOAuthClient.mockReset() + resolvePdsUrl.mockReset().mockResolvedValue("https://pds.example") + vi.spyOn(console, "error").mockImplementation(() => undefined) + vi.spyOn(console, "warn").mockImplementation(() => undefined) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe("public reads — served without a session", () => { + it("getRecord is public", async () => { + const { GET } = await import("../route") + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ uri: "at://x", value: {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const res = await GET( + makeGet( + `com.atproto.repo.getRecord?repo=${SOME_DID}&collection=app.certified.actor.profile&rkey=self`, + ), + params("com.atproto.repo.getRecord"), + ) + + expect(res.status).not.toBe(401) + expect(getOAuthClient).not.toHaveBeenCalled() + fetchSpy.mockRestore() + }) + + it("listRecords is public", async () => { + const { GET } = await import("../route") + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ records: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ) + + const res = await GET( + makeGet( + `com.atproto.repo.listRecords?repo=${SOME_DID}&collection=org.hypercerts.claim.activity`, + ), + params("com.atproto.repo.listRecords"), + ) + + expect(res.status).not.toBe(401) + fetchSpy.mockRestore() + }) + + it("sync.getBlob is public (avatars on signed-out pages)", async () => { + const { GET } = await import("../route") + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("bytes", { + status: 200, + headers: { "content-type": "image/png", "content-length": "5" }, + }), + ) + + const res = await GET( + makeGet(`com.atproto.sync.getBlob?did=${SOME_DID}&cid=bafkreigh2akiscaildc`), + params("com.atproto.sync.getBlob"), + ) + + expect(res.status).not.toBe(401) + fetchSpy.mockRestore() + }) +}) + +describe("non-public methods — 401 without a session", () => { + it.each([ + "com.atproto.server.getSession", + "com.atproto.identity.updateHandle", + "com.atproto.server.createAppPassword", + ])("%s requires auth", async (method) => { + const { GET } = await import("../route") + const res = await GET(makeGet(method), params(method)) + expect(res.status).toBe(401) + }) + + it("writes are never anonymous, even for an allowlisted collection", async () => { + const { POST } = await import("../route") + const res = await POST( + makePost({ + repo: SOME_DID, + collection: "org.hypercerts.context.attachment", + record: {}, + }), + params("com.atproto.repo.createRecord"), + ) + + expect(res.status).toBe(401) + }) +}) diff --git a/src/test-setup.ts b/src/test-setup.ts index ae3af81b..8511c3db 100644 --- a/src/test-setup.ts +++ b/src/test-setup.ts @@ -1,3 +1,19 @@ +import { afterEach } from "vitest" +import { cleanup } from "@testing-library/react" +// Registers the jest-dom matchers (`toBeInTheDocument`, `toHaveAttribute`, +// `toBeDisabled`, …). The package was already a devDependency but was never +// imported, which is why older suites assert via +// `expect(container.querySelector(...)).toBeNull()` instead. Both styles +// work; prefer the matchers (and role-based queries) in new tests. +import "@testing-library/jest-dom/vitest" + +// Unmount anything rendered by @testing-library/react between tests. Suites +// that already call `cleanup()` themselves keep working — it's idempotent. +// `globals: false` in vitest.config.ts, so `afterEach` is imported, not global. +afterEach(() => { + cleanup() +}) + // jsdom does not implement window.matchMedia. Stub it so hooks that // call matchMedia in effects (e.g. useLayoutBreakpoints) don't throw. Object.defineProperty(window, "matchMedia", { diff --git a/tests/groups.test-plan.md b/tests/groups.test-plan.md deleted file mode 100644 index dddc386f..00000000 --- a/tests/groups.test-plan.md +++ /dev/null @@ -1,99 +0,0 @@ -# Groups Feature — Behavioral Test Plan - -## Prerequisites -- User is authenticated (signed in) -- Dev server running at http://localhost:3000 - ---- - -## T1: Navigation — Groups link visible -1. Navigate to http://localhost:3000 -2. Verify "Groups" appears in the top navbar links -3. Verify "Groups" appears in the sidebar navigation - -## T2: Groups page — Empty state -1. Navigate to http://localhost:3000/groups -2. Verify the page title "Groups" is displayed -3. Verify empty state with "No groups yet" message is shown -4. Verify "Create Group" button is visible -5. Verify "Add Existing" button is visible - -## T3: Create Group page — Renders correctly -1. Click "Create" button on the Groups page (or navigate to /groups/create) -2. Verify page title "Create Group" is displayed -3. Verify "Group name" input field exists -4. Verify "Handle" input field exists -5. Verify "Cancel" and "Create Group" buttons exist -6. Verify "Create Group" button is disabled when fields are empty - -## T4: Create Group — Form validation -1. Navigate to /groups/create -2. Type a name longer than 64 characters → verify error message -3. Clear name, type a handle with uppercase → verify it auto-lowercases -4. Type handle with special chars (e.g. "my org!") → verify they are stripped -5. Type a handle shorter than 2 chars → verify error on blur/submit -6. Fill valid name "Test Org" and handle "test-org" → verify button becomes enabled - -## T5: Create Group — Submission (requires group service) -1. Fill name "Test Org" and handle "test-org" -2. Click "Create Group" -3. Observe loading state on button -4. If group service is unavailable, verify error message is shown gracefully -5. If group service responds, verify redirect to /groups - -## T6: Add Existing Group modal -1. Navigate to /groups -2. Click "Add Existing" button -3. Verify modal appears with title "Add Existing Group" -4. Verify "Group DID" input field exists -5. Verify "Your role" dropdown exists with options: Member, Admin, Owner -6. Verify "Cancel" and "Add Group" buttons exist -7. Click "Cancel" → verify modal closes - -## T7: Account Switcher — Dropdown renders -1. On any authenticated page, click the avatar/chevron in the top-right navbar -2. Verify dropdown menu appears -3. Verify "User" section with personal account is shown -4. If groups exist, verify "Groups" section appears - -## T8: Group Profile page — Renders -1. If an org exists, navigate to /groups/{groupDid} -2. Verify the org name is displayed -3. Verify DID is shown in the Identity section -4. Verify "Edit Profile", "Apps", and "Settings" actions exist - -## T9: Group Edit Profile page — Renders -1. Navigate to /groups/{groupDid}/edit-profile -2. Verify "Display name", "About", and "Website" fields exist -3. Verify "Cancel" and "Save Changes" buttons exist - -## T10: Group Apps page — Mirrors personal -1. Navigate to /groups/{groupDid}/apps -2. Verify "Explore apps" section appears -3. Verify the same apps from the personal apps page are listed - -## T11: Group Settings page — Members section -1. Navigate to /groups/{groupDid}/settings -2. Verify "Handle" section shows the org handle -3. Verify "Members & Roles" section exists -4. If admin, verify "Add member" form with DID input and role dropdown -5. Verify "Activity Log" section exists (for admin+) - -## T12: Group Settings — Activity log empty state -1. Navigate to /groups/{groupDid}/settings -2. Verify "Activity Log" section shows "No activity recorded yet." when empty - -## T13: Auth guard — Unauthenticated redirect -1. Sign out -2. Navigate directly to /groups -3. Verify redirect to /?returnTo=%2Fgroups - -## T14: XRPC allowlist — Membership collection -1. (API test) Verify that PUTing a record to `app.certified.actor.membership` collection succeeds (not blocked by allowlist) -2. (API test) Verify that PUTing a record to `app.certified.actor.group` collection succeeds - -## T15: CSS — No visual regressions -1. Navigate to /groups → verify page renders without broken layout -2. Navigate to /groups/create → verify form is properly styled -3. Open account switcher → verify dropdown is positioned correctly -4. On mobile viewport (375px) → verify responsive layout works diff --git a/tests/notifications.test-plan.md b/tests/notifications.test-plan.md deleted file mode 100644 index e1b03c50..00000000 --- a/tests/notifications.test-plan.md +++ /dev/null @@ -1,77 +0,0 @@ -# Notifications — test plan - -Manual/behavioral checklist for the notifications feature (`/notifications` page, nav badges, proxy route). - -## Authentication - -- [ ] Signed-out user visiting `/notifications` redirects to sign-in (same as other auth'd pages) -- [ ] Calling `POST /api/notifications` without a session returns 401 -- [ ] After sign-out, the badge count resets to 0 and polling stops -- [ ] After sign-in, the first badge poll fires and the count appears - -## Proxy security (AT Protocol service-auth) - -- [ ] Client cannot call `POST /api/notifications` without a CSRF-valid request -- [ ] Client sending an `operationName` not in the allowlist gets 400 -- [ ] `INDEXER_DID` unset → 503 "Notifications not configured" -- [ ] No admin API key in env; the proxy mints per-request JWTs via `getServiceAuth` -- [ ] JWT is never exposed to the client (server-only, in the upstream fetch) -- [ ] Requesting `first: 1000` is clamped to 100 -- [ ] Response headers include `Cache-Control: no-store` -- [ ] Upstream 401/403 from indexer → client sees 502 (not 401 — no session cascade) -- [ ] Upstream 429 preserves `Retry-After` -- [ ] Session expired (OAuth restore fails) → 401 + deleteSession -- [ ] User's PDS down / `getServiceAuth` fails → 502 "Could not mint service-auth token" (session not invalidated) -- [ ] `getServiceAuth` timeout (>5s) → 502 (session still valid) - -## Feed rendering - -- [ ] Loading: 3 skeleton rows appear on first visit -- [ ] Empty: "No notifications yet" shown when user has no notifications -- [ ] Populated: rows render with avatar, actor handle, reason text, relative time -- [ ] Endorsement, count=1: "@handle endorsed your activity" -- [ ] Endorsement, count>1: "@handle and N others endorsed your activity" -- [ ] Activity-contributor: "@handle listed you as a contributor" -- [ ] Unresolvable author DID falls back to a truncated DID (e.g. `did:plc:abc…wxyz`) -- [ ] Row without a valid `latestRecordUri` renders as a non-clickable div -- [ ] Row with valid URI navigates to the activity detail page on click -- [ ] Row click fires `track("notification_opened", ...)` analytics event - -## Read state - -- [ ] On page mount, unread notifications have an accent border -- [ ] After a brief moment, the badge clears (optimistic) -- [ ] `markNotificationsSeen` is called once with the newest `sortAt` -- [ ] Unread styling remains visible for the rest of the page session (doesn't flip mid-view) -- [ ] A new notification arriving via `loadMore` with `isRead=false` is added to the unread snapshot - -## Pagination - -- [ ] Infinite scroll triggers `loadMore` near the bottom of the list -- [ ] `loadMore` appends to the list, keeps `endCursor` and `hasMore` in sync -- [ ] Reaching the end hides the loading skeleton - -## Badge (bottom-nav) - -- [ ] Badge hidden when count is 0 or not yet loaded -- [ ] Badge shows actual count when 1–98 -- [ ] Badge shows "99+" when count >= 99 or `more === true` -- [ ] Badge color is error/accent; aria-label includes "N unread" -- [ ] Polling runs every 60s when tab visible, pauses when hidden, resumes on visible - -## Badge (mobile sidebar) - -- [ ] Same behavior as bottom-nav badge -- [ ] Displayed inline after the "Notifications" label - -## Error handling - -- [ ] Indexer down: page shows "Couldn't load notifications" with retry-eligible state -- [ ] GraphQL error with null connection: logs warning, shows empty state (not error) -- [ ] Mark-seen failure: next poll reconciles (no toast, silent recovery) -- [ ] Network error on unread-count poll: badge retains last value, next poll retries - -## Deep links - -- [ ] Notification row link navigates to `/activity/{did}/{rkey}` for endorsement and contributor reasons -- [ ] Back navigation from activity returns to `/notifications` with scroll position preserved (best-effort)