1
21import { test, expect } from "@playwright/test";
22import { request as pwRequest } from "@playwright/test";
23import { register, login, createWorkspace } from "../utils/api";
24import type { APIRequestContext } from "@playwright/test";
25
26const API_BASE = process.env.E2E_API_URL ?? "http://localhost:4000";
27
28const uniqueEmail = () =>
29 `e2e-apikey-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@factory.local`;
30
31
32async function bareCtx(): Promise<APIRequestContext> {
33 return pwRequest.newContext({ baseURL: API_BASE, timeout: 30_000 });
34}
35
36test.describe("API keys — the exit-criteria E2E (TASK-0127)", () => {
37 test("create key via JWT → use it: POST /pages → GET → PATCH (all 2xx)", async () => {
38 const ctx = await bareCtx();
39 try {
40
41 const email = uniqueEmail();
42 const password = "correct-horse-battery-9";
43 await register(ctx, email, password, "API Key E2E");
44 const { accessToken } = await login(ctx, email, password);
45 expect(accessToken, "login returned an access token").toBeTruthy();
46 const userHeaders = { Authorization: `Bearer ${accessToken}` };
47
48 const ws = await createWorkspace(ctx, `API Key E2E ${Date.now()}`);
49 const wsId = ws.id;
50
51
52 const createKey = await ctx.post(`/api/v1/workspaces/${wsId}/api-keys`, {
53 headers: userHeaders,
54 data: { name: "e2e-write", scopes: ["READ", "WRITE"] },
55 });
56 expect(createKey.status(), `create key: ${await createKey.text()}`).toBe(201);
57 const keyBody = (await createKey.json()) as {
58 id: string;
59 key: string;
60 prefix: string;
61 scopes: string[];
62 };
63 expect(keyBody.key).toMatch(/^fk_live_/);
64 const keyId = keyBody.id;
65 const keyHeaders = { Authorization: `Bearer ${keyBody.key}` };
66
67
68
69 const createPage = await ctx.post(`/api/v1/workspaces/${wsId}/pages`, {
70 headers: keyHeaders,
71 data: { title: "created by API key" },
72 });
73 expect(
74 createPage.status(),
75 `key POST /pages: ${createPage.status()} ${await createPage.text()}`,
76 ).toBeLessThan(300);
77
78 expect(createPage.headers()["x-ratelimit-limit"]).toBeTruthy();
79 const page = (await createPage.json()) as { id: string; title: string };
80 expect(page.title).toBe("created by API key");
81
82
83 const getPage = await ctx.get(`/api/v1/pages/${page.id}`, { headers: keyHeaders });
84 expect(getPage.status(), `key GET page: ${await getPage.text()}`).toBeLessThan(300);
85 const fetched = (await getPage.json()) as { id: string; title: string };
86 expect(fetched.id).toBe(page.id);
87
88
89 const patch = await ctx.patch(`/api/v1/pages/${page.id}`, {
90 headers: keyHeaders,
91 data: { title: "renamed by API key" },
92 });
93 expect(
94 patch.status(),
95 `key PATCH page: ${patch.status()} ${await patch.text()}`,
96 ).toBeLessThan(300);
97 const renamed = (await patch.json()) as { title: string };
98 expect(renamed.title).toBe("renamed by API key");
99
100
101 const idemKey = `e2e-${Date.now()}`;
102 const idemOpts = {
103 headers: { ...keyHeaders, "Idempotency-Key": idemKey },
104 data: { title: "idempotent e2e page" }
105 };
106 const first = await ctx.post(`/api/v1/workspaces/${wsId}/pages`, idemOpts);
107 expect(first.status()).toBeLessThan(300);
108 const second = await ctx.post(`/api/v1/workspaces/${wsId}/pages`, idemOpts);
109 expect(second.status()).toBeLessThan(300);
110 expect(second.headers()["idempotency-replayed"]).toBe("true");
111 const firstBody = (await first.json()) as { id: string };
112 const secondBody = (await second.json()) as { id: string };
113 expect(secondBody.id).toBe(firstBody.id);
114
115
116 const revoke = await ctx.delete(`/api/v1/workspaces/${wsId}/api-keys/${keyId}`, {
117 headers: userHeaders,
118 });
119 expect(revoke.status(), `revoke: ${await revoke.text()}`).toBeLessThan(300);
120 const dead = await ctx.get(`/api/v1/pages/${page.id}`, { headers: keyHeaders });
121 expect(dead.status()).toBe(401);
122 } finally {
123 await ctx.dispose();
124 }
125 });
126
127 test("a READ key cannot create (403 api_key_scope_write)", async () => {
128 const ctx = await bareCtx();
129 try {
130 const email = uniqueEmail();
131 const password = "correct-horse-battery-9";
132 await register(ctx, email, password, "API Key E2E READ");
133 const { accessToken } = await login(ctx, email, password);
134 const userHeaders = { Authorization: `Bearer ${accessToken}` };
135 const ws = await createWorkspace(ctx, `READ key E2E ${Date.now()}`);
136
137 const createKey = await ctx.post(`/api/v1/workspaces/${ws.id}/api-keys`, {
138 headers: userHeaders,
139 data: { name: "e2e-read", scopes: ["READ"] },
140 });
141 expect(createKey.status()).toBe(201);
142 const key = (await createKey.json()) as { key: string };
143 const keyHeaders = { Authorization: `Bearer ${key.key}` };
144
145
146 const pages = await ctx.get(`/api/v1/workspaces/${ws.id}/pages`, { headers: keyHeaders });
147 expect(pages.status()).toBeLessThan(300);
148
149
150 const create = await ctx.post(`/api/v1/workspaces/${ws.id}/pages`, {
151 headers: keyHeaders,
152 data: { title: "should not exist" },
153 });
154 expect(create.status()).toBe(403);
155 const body = (await create.json()) as {
156 error: { code: string; details?: Array<{ reason?: string }> };
157 };
158 expect(body.error.code).toBe("FORBIDDEN");
159 expect(body.error.details?.some((d) => d.reason === "api_key_scope_write")).toBe(true);
160 } finally {
161 await ctx.dispose();
162 }
163 });
164});
165