// E2E: the self-hosted editor flow that replaces "edit on GitHub". // // Covers the unauthenticated paths only — full GitHub OAuth is not // scripted here because it requires a real user account and adds // flakiness against the live site. Authenticated paths (admin direct- // write, proposal submission) are smoke-tested via the login-wall // behavior; the actual write paths are unit-tested in src/*.test.ts. import { test, expect } from "@playwright/test"; test.describe("editor flow (unauthenticated)", () => { test("docs page exposes propose-an-edit + view-source links", async ({ page }) => { const res = await page.goto("/sama/sorted"); expect(res?.status()).toBe(200); const editLink = page.locator('a[href="/edit/sama/sorted"]', { hasText: /propose an edit/i }); await expect(editLink).toBeVisible(); const sourceLink = page.locator('a[href="/content/sama/sorted.md"]', { hasText: /view source/i }); await expect(sourceLink).toBeVisible(); // The link must NOT point at the git.tdd.md subdomain anymore. const gitLinks = await page.locator('a[href*="git.tdd.md"]').count(); expect(gitLinks, "no docs link should still target git.tdd.md").toBe(0); }); test("clicking 'view source' returns the raw markdown from tdd.md", async ({ request }) => { const res = await request.get("/content/sama/sorted.md"); expect(res.status()).toBe(200); expect(res.headers()["content-type"]).toMatch(/text\/plain/); const body = await res.text(); // The .md file is the actual source — should contain the SAMA // Sorted page's frontmatter / heading, never raw HTML chrome. expect(body).toMatch(/sorted/i); expect(body).not.toContain(" { const res = await request.get("/content/sama/does-not-exist.md"); expect(res.status()).toBe(404); }); test("raw source 404s for non-editable section", async ({ request }) => { const res = await request.get("/content/etc/passwd.md"); expect(res.status()).toBe(404); }); test("raw source 404s without .md suffix", async ({ request }) => { const res = await request.get("/content/sama/sorted"); expect(res.status()).toBe(404); }); test("/edit/:section/:slug shows the GitHub login wall when unauthenticated", async ({ page }) => { const res = await page.goto("/edit/sama/sorted"); expect(res?.status()).toBe(401); await expect(page.getByRole("heading", { name: /edit · S — Sorted/i })).toBeVisible(); const signIn = page.getByRole("link", { name: /sign in with github/i }); await expect(signIn).toBeVisible(); const href = await signIn.getAttribute("href"); expect(href).toContain("/auth/github/start"); expect(href).toContain("to=%2Fedit%2Fsama%2Fsorted"); }); test("/edit returns 404 for unknown slug", async ({ page }) => { const res = await page.goto("/edit/sama/nonexistent-slug-9999"); expect(res?.status()).toBe(404); }); test("/edit returns 404 for non-editable section", async ({ page }) => { const res = await page.goto("/edit/notreal/whatever"); expect(res?.status()).toBe(404); }); }); test.describe("admin routes are gone (proposal queue retired)", () => { // The /admin/proposals queue was removed when admin edits started // committing directly to Forgejo via c14_forgejo.commitFile. The // routes should now 404 (no fallthrough handler claims them). test("/admin/proposals returns 404", async ({ request }) => { const res = await request.get("/admin/proposals", { maxRedirects: 0 }); expect(res.status()).toBe(404); }); test("/admin/proposals/1/approve returns 404", async ({ request }) => { const res = await request.post("/admin/proposals/1/approve", { maxRedirects: 0 }); expect(res.status()).toBe(404); }); }); test.describe("OAuth start preserves return URL", () => { // Use raw fetch with redirect:"manual" — Playwright's request fixture // either follows redirects or throws on maxRedirects:0 unreliably, // which makes the 302 cookie inspection awkward. const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? "https://tdd.md"; test("?to= is honored as a cookie + GitHub redirect", async () => { const res = await fetch(`${baseURL}/auth/github/start?to=/edit/sama/sorted`, { redirect: "manual", }); expect(res.status).toBe(302); expect(res.headers.get("location")).toContain("github.com/login/oauth/authorize"); const setCookies = res.headers.getSetCookie?.() ?? [res.headers.get("set-cookie") ?? ""]; const joined = setCookies.join(" | "); expect(joined).toContain("tdd_oauth_state="); expect(joined).toContain("tdd_oauth_return=%2Fedit%2Fsama%2Fsorted"); }); test("?to= rejects unsafe return URLs (no scheme jumps)", async () => { const unsafe = encodeURIComponent("https://evil.example.com/steal"); const res = await fetch(`${baseURL}/auth/github/start?to=${unsafe}`, { redirect: "manual", }); expect(res.status).toBe(302); const setCookies = res.headers.getSetCookie?.() ?? [res.headers.get("set-cookie") ?? ""]; const joined = setCookies.join(" | "); // State cookie is always set; the return cookie must NOT be set // for an unsafe URL — that's the open-redirect guard. expect(joined).toContain("tdd_oauth_state="); expect(joined).not.toContain("tdd_oauth_return="); expect(joined).not.toContain("evil.example.com"); }); }); test.describe("docs site smoke", () => { test("homepage loads", async ({ page }) => { const res = await page.goto("/"); expect(res?.status()).toBe(200); await expect(page).toHaveTitle(/sama/i); }); test("sama landing loads with docs chrome", async ({ page }) => { const res = await page.goto("/sama"); expect(res?.status()).toBe(200); await expect(page.locator(".docs-content")).toBeVisible(); }); test("blog index lists at least one post", async ({ page }) => { const res = await page.goto("/blog"); expect(res?.status()).toBe(200); }); });