/** * AI Software Factory — E2E: API keys (TASK-0127) * * The Phase-13 exit criterion "API-key auth E2E-tested": a browser-free API * session against the deployed stack (http://localhost:4000): * * 1. JWT admin session (register/login) → create a workspace → create a * WRITE-scoped API key through the admin API (the raw `fk_…` key is * returned exactly once). * 2. Use the KEY as the credential: `POST /pages` → 2xx, `GET` the page * → 2xx, `PATCH` the title → 2xx (the acceptance-criteria curl * sequence, with X-RateLimit-* headers asserted on the way). * 3. Scope: a READ key cannot create (403 SCOPE_DENIED reason). * 4. Idempotency: the same key + body twice → the second response carries * `Idempotency-Replayed: true` and no duplicate page is created. * 5. Revocation: revoke the key → the very next request is 401. * * This runs against the EXTERNAL e2e stack (no webServer — the stack is up * via `pnpm docker:up && pnpm dev`), exactly like the other API-driven specs. */ import { test, expect } from "@playwright/test"; import { request as pwRequest } from "@playwright/test"; import { register, login, createWorkspace } from "../utils/api"; import type { APIRequestContext } from "@playwright/test"; const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000"; const uniqueEmail = () => `e2e-apikey-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`; /** A bare bearer context (no fixed auth — the credential is per-request). */ async function bareCtx(): Promise { return pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 }); } test.describe("API keys — the exit-criteria E2E (TASK-0127)", () => { test("create key via JWT → use it: POST /pages → GET → PATCH (all 2xx)", async () => { const ctx = await bareCtx(); try { // 1) The admin (JWT) session. const email = uniqueEmail(); const password = "correct-horse-battery-9"; await register(ctx, email, password, "API Key E2E"); const { accessToken } = await login(ctx, email, password); expect(accessToken, "login returned an access token").toBeTruthy(); const userHeaders = { Authorization: `Bearer ${accessToken}` }; const ws = await createWorkspace(ctx, `API Key E2E ${Date.now()}`); const wsId = ws.id; // 2) Create a WRITE-scoped key through the admin API. const createKey = await ctx.post(`/api/v1/workspaces/${wsId}/api-keys`, { headers: userHeaders, data: { name: "e2e-write", scopes: ["READ", "WRITE"] }, }); expect(createKey.status(), `create key: ${await createKey.text()}`).toBe(201); const keyBody = (await createKey.json()) as { id: string; key: string; prefix: string; scopes: string[]; }; expect(keyBody.key).toMatch(/^fk_live_/); const keyId = keyBody.id; const keyHeaders = { Authorization: `Bearer ${keyBody.key}` }; // 3) THE EXIT-CRITERIA SEQUENCE, with the key as the credential: // POST /pages → 2xx. const createPage = await ctx.post(`/api/v1/workspaces/${wsId}/pages`, { headers: keyHeaders, data: { title: "created by API key" }, }); expect( createPage.status(), `key POST /pages: ${createPage.status()} ${await createPage.text()}`, ).toBeLessThan(300); // The per-key rate-limit header is present (docs §11 — 600 default). expect(createPage.headers()["x-ratelimit-limit"]).toBeTruthy(); const page = (await createPage.json()) as { id: string; title: string }; expect(page.title).toBe("created by API key"); // GET the page → 2xx. const getPage = await ctx.get(`/api/v1/pages/${page.id}`, { headers: keyHeaders }); expect(getPage.status(), `key GET page: ${await getPage.text()}`).toBeLessThan(300); const fetched = (await getPage.json()) as { id: string; title: string }; expect(fetched.id).toBe(page.id); // PATCH the title → 2xx. const patch = await ctx.patch(`/api/v1/pages/${page.id}`, { headers: keyHeaders, data: { title: "renamed by API key" }, }); expect( patch.status(), `key PATCH page: ${patch.status()} ${await patch.text()}`, ).toBeLessThan(300); const renamed = (await patch.json()) as { title: string }; expect(renamed.title).toBe("renamed by API key"); // 4) Idempotency for keys: same key + body twice → replay, no dup. const idemKey = `e2e-${Date.now()}`; const idemOpts = { headers: { ...keyHeaders, "Idempotency-Key": idemKey }, data: { title: "idempotent e2e page" } }; const first = await ctx.post(`/api/v1/workspaces/${wsId}/pages`, idemOpts); expect(first.status()).toBeLessThan(300); const second = await ctx.post(`/api/v1/workspaces/${wsId}/pages`, idemOpts); expect(second.status()).toBeLessThan(300); expect(second.headers()["idempotency-replayed"]).toBe("true"); const firstBody = (await first.json()) as { id: string }; const secondBody = (await second.json()) as { id: string }; expect(secondBody.id).toBe(firstBody.id); // 5) Revocation: the key is dead on the next request. const revoke = await ctx.delete(`/api/v1/workspaces/${wsId}/api-keys/${keyId}`, { headers: userHeaders, }); expect(revoke.status(), `revoke: ${await revoke.text()}`).toBeLessThan(300); const dead = await ctx.get(`/api/v1/pages/${page.id}`, { headers: keyHeaders }); expect(dead.status()).toBe(401); } finally { await ctx.dispose(); } }); test("a READ key cannot create (403 api_key_scope_write)", async () => { const ctx = await bareCtx(); try { const email = uniqueEmail(); const password = "correct-horse-battery-9"; await register(ctx, email, password, "API Key E2E READ"); const { accessToken } = await login(ctx, email, password); const userHeaders = { Authorization: `Bearer ${accessToken}` }; const ws = await createWorkspace(ctx, `READ key E2E ${Date.now()}`); const createKey = await ctx.post(`/api/v1/workspaces/${ws.id}/api-keys`, { headers: userHeaders, data: { name: "e2e-read", scopes: ["READ"] }, }); expect(createKey.status()).toBe(201); const key = (await createKey.json()) as { key: string }; const keyHeaders = { Authorization: `Bearer ${key.key}` }; // Reads work. const pages = await ctx.get(`/api/v1/workspaces/${ws.id}/pages`, { headers: keyHeaders }); expect(pages.status()).toBeLessThan(300); // A write is the SCOPE_DENIED case. const create = await ctx.post(`/api/v1/workspaces/${ws.id}/pages`, { headers: keyHeaders, data: { title: "should not exist" }, }); expect(create.status()).toBe(403); const body = (await create.json()) as { error: { code: string; details?: Array<{ reason?: string }> }; }; expect(body.error.code).toBe("FORBIDDEN"); expect(body.error.details?.some((d) => d.reason === "api_key_scope_write")).toBe(true); } finally { await ctx.dispose(); } }); });