syntaxai/tdd.md · main · e2e / editor-flow.spec.ts

editor-flow.spec.ts 143 lines · 6128 bytes raw
// 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("<html");
    expect(body).not.toContain("<aside class=\"docs-sidebar\"");
  });

  test("raw source 404s for unknown slug", async ({ request }) => {
    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);
  });
});