/** * AI Software Factory — E2E: Developers UI + API-key lifecycle (TASK-0128) * * The acceptance-criteria scenarios, driven through the REAL settings UI: * * 1. Owner (the demo user) opens Settings → Developers → creates a key * (name "CI", READ+WRITE, no expiry) via the create dialog → the RAW * key is read from the reveal-dialog DOM (`data-testid="apikey-raw"`) * → POST /pages with the key → 2xx → GET → 200 with the title → * PATCH title → 2xx, verified by GET. * 2. A second key (READ only) → POST via the key → 403 * `api_key_scope_write`; GET succeeds. * 3. Revoke the WRITE key from the UI (inline confirm) → immediate GET * with it → 401 `api_key_revoked`; the row shows Revoked. * 4. Audit (via the audit API): api_key.created ×2, api_key.revoked ×1. * 5. The raw key appears in exactly ONE DOM location (the reveal * dialog); after "I have stored it" it is gone from the DOM and never * reappears on re-render. * 6. A MEMBER does not see the Developers tab (absent from the settings * DOM) and their direct request still 403s (rule 19: UX ≠ authz). * * Fixtures (tests/e2e/fixtures.ts): `api` = fresh demo login (Bearer), * `page` = the demo's authenticated browser page, `secondUser()` = a * freshly-registered user with page + api, `freshWorkspace` = an owned * workspace (auto-deleted in teardown). The key-credential requests use a * bare `request` context (no auth fixture — the credential IS the key). */ import { test, expect } from "../fixtures"; import { addMemberViaSeam, seamServiceToken } from "../utils/api"; import { request as pwRequest } from "@playwright/test"; import type { APIRequestContext, Page } from "@playwright/test"; const API = process.env.E2E_API_URL ?? "http://localhost:4000"; /** A bare context — the credential is supplied per-request (the key). */ async function bareCtx(): Promise { return pwRequest.newContext({ baseURL: API, timeout: 30_000 }); } /** * Open the Developers tab. A "ready" page shows either the create button * (owner/admin) or the role notice (anyone else — which this spec's owner * is not). * * Do NOT reload in a loop here (this helper used to): the fresh-context * auth bootstrap is `cookie → POST /auth/refresh → GET /auth/me → * setUser` and needs ~3s AFTER the page's JS has hydrated. Reloading on a * ~2s cadence aborts the in-flight refresh→me chain before React commits * the user (the (app) layout then sits on its "Loading workspace…" gate * indefinitely — verified: single uninterrupted loads clear in 2–3s 3/3, * reload-looped loads livelocked with /auth/me returning 200 but the gate * never clearing, on desktop AND mobile). One load + a generous budget is * the correct shape; a cold dev server (concurrent compile) is absorbed by * the 20s timeout. */ async function openDevelopers(page: Page, workspaceId: string): Promise { await page.goto(`/w/${workspaceId}/settings/developers`); const ready = page .locator('[data-testid="apikey-create-open"]') .or(page.getByText("Owner or Admin required")) .first(); await expect(ready).toBeVisible({ timeout: 20_000 }); } /** * Click a button/interactive, immune to the Playwright 1.49 mobile-emulation * hit-test desync. Under the `chromium-mobile` project (iPhone 13 profile * pinned to chromium) Chromium intermittently zooms the LAYOUT viewport * out to ~670px even though the served `` * is correct and present at byte 92 of the HTML (verified), so * `innerWidth` reads 670 while the 375px visual viewport shows the page * scaled to 0.56. At that moment Playwright's actionability CDP hit-test * resolves a button's center onto a different element (the reported * interceptor varies run-to-run: backdrop / footer / fieldset / input — * while the page's own `elementFromPoint` at the same coordinate returns * the button). The app itself is correct: with the dialog open, no element * is wider than 375px and `scrollWidth` stays 375 (probed). So this is a * test-harness artifact, not a mobile layout defect. * * Fallback: coordinate-free activation — dispatch the element's real * `click` event (the exact event React's onClick is wired to) with NO * pointer coordinates, bypassing the broken hit-test entirely. * `locator.click({ force })` was rejected (it still resolves the target * through the same hit-test); `locator.focus()` was rejected too (its * actionability pre-check hangs on the same path). The caller still * asserts the full post-click state (dialog closed / reveal appeared / * row flipped to Revoked), so a fallback that silently did nothing * fails the test rather than masking a broken UI. */ async function safeClick( page: Page, locator: import("@playwright/test").Locator, label: string, ): Promise { try { await locator.click(); return; } catch { /* actionability hit-test flake (mobile emulation) — fall through */ } // Coordinate-free activation: dispatch the element's real `click` event // (the same event React's onClick is wired to) with NO pointer // coordinates, so the emulation's broken hit-test is bypassed entirely. // `locator.click({ force })` was rejected: it still computes the target // via the same CDP hit-test and landed on a different element. await expect(locator, `${label}: resolvable for click fallback`).toBeAttached(); await locator.evaluate((el) => el.click()); } test.describe("Developers UI + API-key lifecycle (TASK-0128)", () => { test("owner: create (reveal) → key POST/GET/PATCH → READ 403 → revoke → audit", async ({ api, authPage: page, freshWorkspace, }) => { const ws = await freshWorkspace(`DEV E2E ${Date.now()}`); // -- Create the WRITE key via the UI dialog --------------------------- await openDevelopers(page, ws.id); await page.getByTestId("apikey-create-open").click(); await page.getByLabel("Name").fill("CI"); await page.getByRole("checkbox", { name: /WRITE/ }).check(); // + READ await safeClick(page, page.getByRole("button", { name: "Create key" }), "Create key"); // The reveal dialog: the raw key is in EXACTLY ONE DOM location. const rawField = page.getByTestId("apikey-raw"); await expect(rawField).toBeVisible(); const rawKey = (await rawField.inputValue()).trim(); expect(rawKey).toMatch(/^fk_live_/); // The raw key is in EXACTLY ONE DOM location: the reveal input. // (The list table only carries the MASKED prefix `fk_live_ab12…•••`, // and `innerText` does not read values — so assert both.) const inputsWithKey = await page .locator("input") .evaluateAll((els, key) => els.filter((e) => e.value === key).length, rawKey); expect(inputsWithKey).toBe(1); expect(await page.getByText(rawKey).count()).toBe(0); // no text-node copy await expect(page.getByText("This key will not be shown again")).toBeVisible(); // Copy works (clipboard API — the context needs the clipboard // permission, which the shared fixture does not grant). await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); await page.getByTestId("apikey-copy").click(); await expect(page.getByRole("button", { name: "Copy API key" })).toHaveText(/Copied/); // "I have stored it" → the raw key leaves the DOM permanently. await page.getByTestId("apikey-stored-confirm").check(); await safeClick( page, page.getByRole("button", { name: "I have stored it" }), "I have stored it", ); await expect(page.getByTestId("apikey-raw")).toHaveCount(0); expect( await page .locator("input") .evaluateAll((els, key) => els.filter((e) => e.value === key).length, rawKey), ).toBe(0); // The list row for "CI" (prefix masked, scopes badged, Active). // 20s budget: the row is driven by a create→invalidate→refetch cycle // that provably returns 200 and renders in ~6s when the dev server is // idle, but a concurrent compile (typecheck/build) can push the cold // refetch past the default 10s. A real "row never renders" defect // would still fail at 20s, so this budgets dev-server latency without // masking a broken invalidation. const ciRow = page.getByRole("row", { name: /CI/ }); await expect(ciRow).toBeVisible({ timeout: 20_000 }); await expect(ciRow.getByText("fk_live_")).toBeVisible(); await expect(ciRow.getByText("WRITE")).toBeVisible(); await expect(ciRow.getByText("Active")).toBeVisible(); // Re-render (leave the tab and come back): the raw key must NOT // come back (the reveal state is in-memory, dropped on confirm). // // Two hard full-page loads (goto → goto) are a deliberate STRESS on the // auth bootstrap: each goto is a fresh JS context whose one-time // bootstrap posts POST /auth/refresh to rotate the httpOnly cookie, and // per-tab single-flight cannot dedupe across destroyed/recreated // documents — so two overlapping bootstraps race the same rotation the // way two tabs would. Real users navigate between settings tabs via the // SPA router (one bootstrap per tab), never a hard reload per tab, so // settle the first load (auth gate cleared, Members page rendered) // before the second to keep the stress at exactly one in-flight // bootstrap — the worst case the production race fix must cover. // (If the first load's bootstrap fails, the app redirects to /login; // waiting for the Members h1 then fails fast with a clear signal.) await page.goto(`/w/${ws.id}/settings/members`); await expect(page.getByRole("heading", { name: "Members", level: 1 })).toBeVisible({ timeout: 20_000 }); await page.goto(`/w/${ws.id}/settings/developers`); await expect(page.getByRole("row", { name: /CI/ })).toBeVisible({ timeout: 20_000 }); expect( await page .locator("input") .evaluateAll((els, key) => els.filter((e) => e.value === key).length, rawKey), ).toBe(0); expect(await page.getByText(rawKey).count()).toBe(0); // -- The curl roundtrip with the key ---------------------------------- const keyReq = await bareCtx(); const keyAuth = { Authorization: `Bearer ${rawKey}` }; const created = await keyReq.post(`/api/v1/workspaces/${ws.id}/pages`, { headers: keyAuth, data: { title: "made by CI key" }, }); expect(created.status(), await created.text()).toBeLessThan(300); const pg = (await created.json()) as { id: string; title: string }; expect(pg.title).toBe("made by CI key"); // GET /pages/:id returns `{ page, workspace, … }` (the nested shape); // the create/patch responses return the bare page — read both. const titleOf = (b: Record) => (b as { page?: { title?: string } }).page?.title ?? (b as { title?: string }).title; const fetched = await keyReq.get(`/api/v1/pages/${pg.id}`, { headers: keyAuth }); expect(fetched.status()).toBe(200); expect(titleOf(await fetched.json())).toBe("made by CI key"); const patched = await keyReq.patch(`/api/v1/pages/${pg.id}`, { headers: keyAuth, data: { title: "renamed by CI key" }, }); expect(patched.status()).toBe(200); const recheck = await keyReq.get(`/api/v1/pages/${pg.id}`, { headers: keyAuth }); expect(titleOf(await recheck.json())).toBe("renamed by CI key"); // -- The READ-only key: POST 403, GET ok ------------------------------- await page.getByTestId("apikey-create-open").click(); await page.getByLabel("Name").fill("READONLY"); // The dialog resets to READ-only on open (WRITE stays unchecked). await safeClick(page, page.getByRole("button", { name: "Create key" }), "Create key"); const readField = page.getByTestId("apikey-raw"); await expect(readField).toBeVisible(); const readKey = (await readField.inputValue()).trim(); await page.getByTestId("apikey-stored-confirm").check(); await safeClick( page, page.getByRole("button", { name: "I have stored it" }), "I have stored it", ); const readReq = await bareCtx(); const rAuth = { Authorization: `Bearer ${readKey}` }; const readGet = await readReq.get(`/api/v1/workspaces/${ws.id}/pages`, { headers: rAuth }); expect(readGet.status()).toBe(200); const readPost = await readReq.post(`/api/v1/workspaces/${ws.id}/pages`, { headers: rAuth, data: { title: "nope" }, }); expect(readPost.status()).toBe(403); const rErr = (await readPost.json()) as { error: { details?: Array<{ reason?: string }> } }; expect(rErr.error.details?.some((d) => d.reason === "api_key_scope_write")).toBe(true); // -- Revoke the WRITE key from the UI ---------------------------------- await safeClick(page, ciRow.getByRole("button", { name: "Revoke" }), "Revoke (CI)"); // Inline confirm copy (the acceptance wording). await expect( page.getByText("Revoke this key? Requests will fail immediately with 401."), ).toBeVisible(); // Confirm (the destructive button in the confirm row). await safeClick(page, page.getByRole("button", { name: "Revoke" }).last(), "Revoke (confirm)"); // The row flips to Revoked. await expect(page.getByRole("row", { name: /CI/ }).getByText("Revoked")).toBeVisible(); // Immediately dead — 401 api_key_revoked. const dead = await keyReq.get(`/api/v1/pages/${pg.id}`, { headers: keyAuth }); expect(dead.status()).toBe(401); const dErr = (await dead.json()) as { error: { details?: Array<{ reason?: string }> } }; expect(dErr.error.details?.some((d) => d.reason === "api_key_revoked")).toBe(true); // -- Audit: created ×2, revoked ×1 (via the audit API) ------------------ const audit = await api.get(`/api/v1/workspaces/${ws.id}/audit?limit=100`); expect(audit.status()).toBe(200); const rows = ((await audit.json()) as { data: Array<{ action: string }> }).data; expect(rows.filter((r) => r.action === "api_key.created").length).toBe(2); expect(rows.filter((r) => r.action === "api_key.revoked").length).toBe(1); await keyReq.dispose(); await readReq.dispose(); }); test("a member does not see the Developers tab (absent from the settings DOM)", async ({ freshWorkspace, secondUser, }) => { // A workspace owned by the demo user; a freshly-registered user joins // as MEMBER through the internal test seam (the e2e stack runs with the // seam open — membership is DB-driven, immediate). const ws = await freshWorkspace(`MEMBER TAB ${Date.now()}`); const member = await secondUser(); await addMemberViaSeam(seamServiceToken(), ws.id, member.user.email, "MEMBER"); // As the member: the settings nav has NO "Developers" tab. await member.page.goto(`/w/${ws.id}/settings`); const nav = member.page.getByRole("navigation", { name: "Settings sections" }); await expect(nav).toBeVisible({ timeout: 20_000 }); await expect(nav.getByRole("link", { name: "Developers" })).toHaveCount(0); // …and the server still 403s their direct request (rule 19: UX ≠ authz). const direct = await member.api.get(`/api/v1/workspaces/${ws.id}/api-keys`); expect(direct.status()).toBe(403); }); });