/** * AI Software Factory — Integration: API keys (TASK-0127). * * The API-key AUTH E2E is a Phase-13 exit criterion, so this suite proves * the full loop IN-PROCESS against the real stack (the Playwright E2E suite * reuses the same endpoints against the deployed app): * * 1. key lifecycle through the API (create → list → use → revoke): * the raw `fk_…` key is returned EXACTLY ONCE (the list shows only the * prefix), a revoked key 401s immediately, and every mutation is * audited. * 2. scope matrix (the published contract, docs/api/authentication.md §7): * READ → reads OK, writes 403 `api_key_scope_write` (SCOPE_DENIED), * admin/user-identity endpoints 403; * WRITE → up to EDIT OK, workspace-admin actions 403; * ADMIN → all workspace content + admin key management OK, but a key * is NEVER OWNER (OWNER-only gates — delete workspace, transfer * — stay closed). * Cross-workspace: 403 `workspace_mismatch` (the key's creator's * membership in the other workspace must NOT leak). * 3. per-key rate limits (decision #1, docs §11): a key with * `rateLimit: 10` gets 10×200 then 429 `RATE_LIMITED` + `Retry-After` * + correct `X-RateLimit-Limit: 10`; a key WITHOUT `rateLimit` is * limited at the key default (`X-RateLimit-Limit: 600`). * 4. idempotency for keys: a repeated `Idempotency-Key` replays the stored * 2xx body with `Idempotency-Replayed: true` and does NOT re-run the * handler (no duplicate resource). * 5. error envelope (the §9 contract): every error in this suite has the * canonical `{error:{code,message,details?,requestId}}` shape. * * Env: real Testcontainers Postgres/Redis (helpers/containers.ts); random * secrets per run. The test app raises the generic RATE_LIMIT_API so user * callers never collide with the per-key windows under test. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser, uniqueEmail, } from "./helpers/seed"; let app: TestAppHandle; let owner: { accessToken: string }; let wsA: { workspaceId: string }; let wsB: { workspaceId: string }; const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; /** The canonical error envelope (docs/api/authentication.md §9). */ async function expectEnvelope( res: Response, status: number, code: string, ): Promise> { expect(res.status, `status: ${res.status} (body: ${await res.clone().text()})`).toBe(status); const body = await json(res); const err = body.error as | { code: string; message: string; requestId: string; details?: Array<{ reason?: string }> } | undefined; expect(err, `envelope missing on ${status}: ${JSON.stringify(body)}`).toBeTruthy(); expect(err!.code).toBe(code); expect(typeof err!.message).toBe("string"); expect(err!.requestId).toBeTruthy(); return (err!.details ?? []) as Array<{ reason?: string }>; } /** Create an API key through the API as the workspace owner. */ async function createKey( workspaceId: string, name: string, scopes: string[], rateLimit?: number, ): Promise<{ keyId: string; rawKey: string }> { const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/api-keys`, { headers: authHeaders(owner.accessToken), body: { name, scopes, ...(rateLimit ? { rateLimit } : {}) }, }); expect(res.status).toBe(201); const body = await json(res); expect(String(body.key ?? "")).toMatch(/^fk_live_/); return { keyId: String(body.id), rawKey: String(body.key) }; } const keyAuth = (rawKey: string, workspaceId?: string): Record => ({ Authorization: `Bearer ${rawKey}`, ...(workspaceId ? { "X-Factory-Workspace": workspaceId } : {}), }); beforeAll(async () => { app = await createTestApp(); // The generic per-credential window (users/IPs) must not collide with the // per-key windows under test — the per-key limit is what this suite proves. // (The test app sets RATE_LIMIT_API=100000; keys WITHOUT rateLimit use // RATE_LIMIT_API_KEY=600 — asserted via the header, not by 600 requests.) owner = { accessToken: "" }; const u = await createUser(app.request, { name: "Key Owner" }); owner.accessToken = u.accessToken!; OWNER_USER_ID = u.userId; wsA = await createWorkspace(app.request, authHeaders(owner.accessToken), `WS A ${Date.now()}`); wsB = await createWorkspace(app.request, authHeaders(owner.accessToken), `WS B ${Date.now()}`); // A SECOND user who owns wsB but NOT wsA — the cross-workspace non-leak // proof: a wsA key must not see wsB through the creator's (or anyone's) // membership. const u2 = await createUser(app.request, { name: "Cross User" }); const wsC = await createWorkspace(app.request, authHeaders(u2.accessToken), `WS C ${Date.now()}`); // The creator of the wsA keys (the owner) is NOT a member of wsC — a // wsA key pointed at wsC must 403 workspace_mismatch, never 200. (app as unknown as { __wsC?: string }).__wsC = wsC.workspaceId; }, 180_000); afterAll(async () => { await app?.close(); }); describe("API keys — lifecycle (TASK-0127)", () => { it("create returns the raw key exactly once; list shows only the prefix", async () => { const { rawKey, keyId } = await createKey(wsA.workspaceId, "ci-key", ["READ", "WRITE"]); const list = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/api-keys`, { headers: authHeaders(owner.accessToken), }); expect(list.status).toBe(200); const body = await json(list); const rows = (body.data ?? body) as unknown; const text = JSON.stringify(body); // The raw key is NEVER re-served. expect(text).not.toContain(rawKey); // The row identifies the key by id/prefix. expect(text).toContain(keyId); // Audit: key creation is security-sensitive. const audits = await countAuditEvents(app.prisma, { actionPrefix: "api_key.created" }); expect(audits).toBeGreaterThanOrEqual(1); }); it("revoked key 401s immediately (and the attempt is audited)", async () => { const { rawKey, keyId } = await createKey(wsA.workspaceId, "doomed", ["READ"]); // Live: the key works. const ok = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), }); expect(ok.status).toBe(200); // Revoke. const del = await app.request( "DELETE", `/api/v1/workspaces/${wsA.workspaceId}/api-keys/${keyId}`, { headers: authHeaders(owner.accessToken), }, ); expect(del.status).toBe(200); // Immediately dead — 401 (the guard checks revokedAt on every lookup). const dead = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), }); const details = await expectEnvelope(dead, 401, "UNAUTHORIZED"); expect(details.some((d) => d.reason === "api_key_revoked")).toBe(true); // Audit: revocation. const audits = await countAuditEvents(app.prisma, { actionPrefix: "api_key.revoked" }); expect(audits).toBeGreaterThanOrEqual(1); }); it("key management is OWNER/ADMIN-gated (MEMBER gets 403)", async () => { // Add a MEMBER (not admin) to wsA through the test seam. const member = await createUser(app.request, { name: "Plain Member" }); await addMemberViaSeam( app.request, app.internalServiceToken, wsA.workspaceId, member.email, "MEMBER", ); const mres = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/api-keys`, { headers: authHeaders(member.accessToken!), }); await expectEnvelope(mres, 403, "FORBIDDEN"); }); }); describe("API keys — lifecycle contract (TASK-0128)", () => { it("a duplicate ACTIVE key name → 409 (the create dialog's field error)", async () => { await createKey(wsA.workspaceId, "dup-name", ["READ"]); const dup = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/api-keys`, { headers: authHeaders(owner.accessToken), body: { name: "dup-name", scopes: ["READ"] }, }); const details = await expectEnvelope(dup, 409, "CONFLICT"); expect(details.some((d) => d.field === "name" && d.issue === "already_exists")).toBe(true); // A REVOKED key frees the name (only ACTIVE keys are unique). const first = (await app.prisma.apiKey.findFirst({ where: { workspaceId: wsA.workspaceId, name: "dup-name" }, }))!; await app.prisma.apiKey.update({ where: { id: first.id }, data: { revokedAt: new Date() } }); const again = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/api-keys`, { headers: authHeaders(owner.accessToken), body: { name: "dup-name", scopes: ["READ"] }, }); expect(again.status).toBe(201); }); it("the list response is a strict schema — NO raw key, NO hash, fixed fields", async () => { const { rawKey } = await createKey(wsA.workspaceId, "schema", ["READ"]); const list = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/api-keys`, { headers: authHeaders(owner.accessToken), }); expect(list.status).toBe(200); const body = await json(list); const rows = (body.data ?? body) as Array>; expect(Array.isArray(rows)).toBe(true); expect(rows.length).toBeGreaterThan(0); // Neither the raw key nor its hash appears ANYWHERE in the body. const text = JSON.stringify(body); expect(text).not.toContain(rawKey); const hash = (await app.prisma.apiKey.findFirst({ where: { prefix: rawKey.slice(0, 14) } })) ?.keyHash; if (hash) expect(text).not.toContain(hash); // Every row is EXACTLY the documented field set (the UI binds to it). const allowed = new Set([ "id", "name", "prefix", "scopes", "rateLimit", "lastUsedAt", "expiresAt", "revokedAt", "createdById", "createdAt", ]); for (const row of rows) { for (const k of Object.keys(row)) { expect(allowed.has(k), `unexpected list field: ${k}`).toBe(true); } expect(row).not.toHaveProperty("key"); expect(row).not.toHaveProperty("keyHash"); } }); it("revoke is idempotent and audited ONCE (re-DELETE does not re-mint the audit)", async () => { const { rawKey, keyId } = await createKey(wsA.workspaceId, "once", ["READ"]); const before = await countAuditEvents(app.prisma, { action: "api_key.revoked" }); const d1 = await app.request( "DELETE", `/api/v1/workspaces/${wsA.workspaceId}/api-keys/${keyId}`, { headers: authHeaders(owner.accessToken), }, ); expect(d1.status).toBe(200); // Revoke again (idempotent). const d2 = await app.request( "DELETE", `/api/v1/workspaces/${wsA.workspaceId}/api-keys/${keyId}`, { headers: authHeaders(owner.accessToken), }, ); expect(d2.status).toBe(200); const after = await countAuditEvents(app.prisma, { action: "api_key.revoked" }); expect(after - before).toBe(1); // exactly ONE audit for the transition // And the key is dead. const dead = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), }); expect(dead.status).toBe(401); }); }); describe("API keys — scope matrix (docs §7)", () => { it("READ key: reads OK, writes → 403 api_key_scope_write (SCOPE_DENIED)", async () => { const { rawKey } = await createKey(wsA.workspaceId, "read-only", ["READ"]); // Reads pass. const pages = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), }); expect(pages.status).toBe(200); const dbs = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/databases`, { headers: keyAuth(rawKey, wsA.workspaceId), }); expect(dbs.status).toBe(200); // A write is the SCOPE_DENIED case. const create = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), body: { title: "should not exist" }, }); const details = await expectEnvelope(create, 403, "FORBIDDEN"); expect(details.some((d) => d.reason === "api_key_scope_write")).toBe(true); }); it("WRITE key: edits OK, a workspace-admin (OWNER/ADMIN-gated) action → 403 api_key_scope_write", async () => { const { rawKey } = await createKey(wsA.workspaceId, "writer", ["WRITE"]); // A mutation up to EDIT passes. const created = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), body: { title: "written by key" }, }); expect(created.status).toBe(201); const pageId = String((await json(created)).id); const patched = await app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: keyAuth(rawKey, wsA.workspaceId), body: { title: "renamed by key" }, }); expect(patched.status).toBe(200); // Creating a webhook requires the workspace OWNER/ADMIN ROLE (docs §7: // the ADMIN scope, not mere write access). A WRITE key's cap is EDIT — // the assertRole gate denies it with the scope reason (SCOPE_DENIED). const wh = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/webhooks`, { headers: keyAuth(rawKey, wsA.workspaceId), body: { url: "https://example.com/hook", events: ["page.created"] }, }); const details = await expectEnvelope(wh, 403, "FORBIDDEN"); expect(details.some((d) => d.reason === "api_key_scope_write")).toBe(true); }); it("ADMIN key: the OWNER/ADMIN-gated surface opens (ADMIN > WRITE), user-identity gates stay closed", async () => { const { rawKey } = await createKey(wsA.workspaceId, "admin-key", ["ADMIN"]); // Reads + writes pass. expect( ( await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), }) ).status, ).toBe(200); // The same OWNER/ADMIN-gated webhook creation that a WRITE key cannot do // (the previous test) succeeds for an ADMIN key — this is the concrete // ADMIN > WRITE distinction on the live surface. const wh = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/webhooks`, { headers: keyAuth(rawKey, wsA.workspaceId), body: { url: "https://example.com/hook", events: ["page.created"] }, }); expect(wh.status).toBe(201); // A key is NEVER OWNER / user-identity: workspace deletion (OWNER-only // semantics, user-identity endpoint) is 403 even for an ADMIN-scope key. const del = await app.request("DELETE", `/api/v1/workspaces/${wsA.workspaceId}`, { headers: keyAuth(rawKey, wsA.workspaceId), }); await expectEnvelope(del, 403, "FORBIDDEN"); }); it("cross-workspace: a key pointed at a foreign workspace → 403 workspace_mismatch", async () => { const { rawKey } = await createKey(wsA.workspaceId, "cross", ["ADMIN"]); const foreign = (app as unknown as { __wsC?: string }).__wsC!; const pages = await app.request("GET", `/api/v1/workspaces/${foreign}/pages`, { headers: keyAuth(rawKey, foreign), }); const details = await expectEnvelope(pages, 403, "FORBIDDEN"); expect(details.some((d) => d.reason === "workspace_mismatch")).toBe(true); // Same for the creator's OWN other workspace (wsB — the creator is // OWNER there, and the leak must not open the door). const b = await app.request("GET", `/api/v1/workspaces/${wsB.workspaceId}/pages`, { headers: keyAuth(rawKey, wsB.workspaceId), }); const detailsB = await expectEnvelope(b, 403, "FORBIDDEN"); expect(detailsB.some((d) => d.reason === "workspace_mismatch")).toBe(true); }); it("user-identity-only endpoints reject keys (403 requires_user_credentials)", async () => { const { rawKey } = await createKey(wsA.workspaceId, "id-only", ["ADMIN"]); // The AI conversation list is user-identity-only (personal // conversations — the key's creator identity is for attribution, not // for acting as the user), even though it is workspace-scoped. const conv = await app.request( "GET", `/api/v1/workspaces/${wsA.workspaceId}/ai/conversations`, { headers: keyAuth(rawKey, wsA.workspaceId), }, ); const details = await expectEnvelope(conv, 403, "FORBIDDEN"); expect(details.some((d) => d.reason === "requires_user_credentials")).toBe(true); }); it("a creator who is OWNER of another workspace does NOT open it (the leak proof, strong form)", async () => { // The cross-workspace test above used wsB (owned by the creator). Make // the proof explicit about WHY it matters: the creator is OWNER of wsB, // yet the wsA key gets 403 — the membership probe never happens. const { rawKey } = await createKey(wsA.workspaceId, "leak", ["ADMIN"]); const keyRow = await app.prisma.apiKey.findFirst({ where: { workspaceId: wsA.workspaceId, name: "leak" }, select: { createdById: true }, }); const members = await app.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId: wsB.workspaceId, userId: keyRow!.createdById } }, }); // The creator is the OWNER of wsB (they created it). expect(members?.role).toBe("OWNER"); const res = await app.request("GET", `/api/v1/workspaces/${wsB.workspaceId}/databases`, { headers: keyAuth(rawKey, wsB.workspaceId), }); const details = await expectEnvelope(res, 403, "FORBIDDEN"); expect(details.some((d) => d.reason === "workspace_mismatch")).toBe(true); }); }); describe("API keys — audit attribution (docs §7)", () => { it("a key's page create is audited as API_KEY (actorId api:{keyId}) — never the creator", async () => { const { rawKey, keyId } = await createKey(wsA.workspaceId, "audited", ["WRITE"]); const res = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), body: { title: "audited page" }, }); expect([200, 201]).toContain(res.status); const pageId = String((await json(res)).id); const row = await app.prisma.auditLog.findFirst({ where: { action: "page.created", resourceId: pageId }, orderBy: { createdAt: "desc" }, }); expect(row, "the page.create audit row").not.toBeNull(); expect(row?.actorType).toBe("API_KEY"); expect(row?.actorId).toBe(`api:${keyId}`); // The key's creator is a real user (the owner) — and must NOT be the // audit actor. expect(row?.actorId).not.toBe(ownerUserId()); }); }); describe("API keys — idempotency single-flight + body digest (docs §9)", () => { it("concurrent duplicates of the same operation → exactly one side effect", async () => { const { rawKey } = await createKey(wsA.workspaceId, "conc", ["WRITE"]); const idemKey = `key_${Date.now().toString(16)}_${Math.random().toString(16).slice(2)}`; const opts = { headers: { ...keyAuth(rawKey, wsA.workspaceId), "Idempotency-Key": idemKey }, body: { title: "single-flight page" }, }; // Two in-flight duplicates (the acceptance criterion's "concurrent // (parallel) duplicates → exactly one side effect"). The first takes // the single-flight lock; the second waits for the memo. const [a, b] = await Promise.all([ app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, opts), app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, opts), ]); expect(a.status).toBe(b.status); const ba = await json(a); const bb = await json(b); // Both see the SAME resource (one was the fresh run, the other the // replayed memo — both carry the idempotent body). expect(ba.id).toBe(bb.id); const pages = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), }); const list = (await json(pages)) as { data?: Array<{ title: string }> }; const matches = (list.data ?? []).filter((p) => p.title === "single-flight page"); expect(matches).toHaveLength(1); // exactly one side effect }); it("a different body with the same key → two side effects (body is in the memo digest)", async () => { const { rawKey } = await createKey(wsA.workspaceId, "digest", ["WRITE"]); const idemKey = `key_${Date.now().toString(16)}_${Math.random().toString(16).slice(2)}`; const base = { ...keyAuth(rawKey, wsA.workspaceId), "Idempotency-Key": idemKey }; const r1 = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: base, body: { title: "digest-A" }, }); const r2 = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: base, body: { title: "digest-B" }, }); expect([200, 201]).toContain(r1.status); expect([200, 201]).toContain(r2.status); const b1 = await json(r1); const b2 = await json(r2); // Different body → different digest → NOT a replay → two resources. expect(b1.id).not.toBe(b2.id); expect(r2.headers.get("idempotency-replayed")).toBeNull(); const pages = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), }); const list = (await json(pages)) as { data?: Array<{ title: string }> }; const matches = (list.data ?? []).filter( (p) => p.title === "digest-A" || p.title === "digest-B", ); expect(matches).toHaveLength(2); }); }); /** The owner's user id (the creator of every key in this suite). */ let OWNER_USER_ID = ""; function ownerUserId(): string { return OWNER_USER_ID; } describe("API keys — per-key rate limits (docs §11, decision #1)", () => { it("a key with rateLimit: 10 gets 10×200 then 429 + Retry-After + X-RateLimit-Limit: 10", async () => { const { rawKey } = await createKey(wsA.workspaceId, "throttled", ["READ"], 10); let last: Response | undefined; for (let i = 0; i < 11; i++) { last = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), }); } // The 11th request (count 11 > limit 10) is the 429. expect(last!.status).toBe(429); expect(last!.headers.get("retry-after")).toBeTruthy(); expect(last!.headers.get("x-ratelimit-limit")).toBe("10"); const body = await json(last!); expect((body.error as { code: string }).code).toBe("RATE_LIMITED"); }); it("a key WITHOUT rateLimit is limited at the key default (X-RateLimit-Limit: 600)", async () => { const { rawKey } = await createKey(wsA.workspaceId, "default-limit", ["READ"]); const res = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), }); expect(res.status).toBe(200); expect(res.headers.get("x-ratelimit-limit")).toBe("600"); expect(res.headers.get("x-ratelimit-remaining")).toBe("599"); }); }); describe("API keys — idempotency (docs §9)", () => { it("a repeated Idempotency-Key replays the stored body (Idempotency-Replayed: true), no duplicate", async () => { const { rawKey } = await createKey(wsA.workspaceId, "idem", ["WRITE"]); const idemKey = `key_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`; const opts = { headers: { ...keyAuth(rawKey, wsA.workspaceId), "Idempotency-Key": idemKey }, body: { title: "idempotent page" }, }; const first = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, opts); expect([200, 201]).toContain(first.status); const firstBody = await json(first); expect(first.headers.get("idempotency-replayed")).toBeNull(); const second = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, opts); expect(second.status).toBe(first.status); expect(second.headers.get("idempotency-replayed")).toBe("true"); const secondBody = await json(second); // The SAME resource — the handler did not run again. expect(secondBody.id).toBe(firstBody.id); // Exactly ONE page was created. const pages = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, { headers: keyAuth(rawKey, wsA.workspaceId), }); const list = (await json(pages)) as { data?: Array<{ title: string }> }; const matches = (list.data ?? []).filter((p) => p.title === "idempotent page"); expect(matches).toHaveLength(1); }); });