1
28import { test, expect } from "../fixtures";
29import { addMemberViaSeam, seamServiceToken } from "../utils/api";
30import { request as pwRequest } from "@playwright/test";
31import type { APIRequestContext, Page } from "@playwright/test";
32
33const API = process.env.E2E_API_URL ?? "http://localhost:4000";
34
35
36async function bareCtx(): Promise<APIRequestContext> {
37 return pwRequest.newContext({ baseURL: API, timeout: 30_000 });
38}
39
40
56async function openDevelopers(page: Page, workspaceId: string): Promise<void> {
57 await page.goto(`/w/${workspaceId}/settings/developers`);
58 const ready = page
59 .locator('[data-testid="apikey-create-open"]')
60 .or(page.getByText("Owner or Admin required"))
61 .first();
62 await expect(ready).toBeVisible({ timeout: 20_000 });
63}
64
65
90async function safeClick(
91 page: Page,
92 locator: import("@playwright/test").Locator,
93 label: string,
94): Promise<void> {
95 try {
96 await locator.click();
97 return;
98 } catch {
99
100 }
101
102
103
104
105
106 await expect(locator, `${label}: resolvable for click fallback`).toBeAttached();
107 await locator.evaluate((el) => el.click());
108}
109
110test.describe("Developers UI + API-key lifecycle (TASK-0128)", () => {
111 test("owner: create (reveal) → key POST/GET/PATCH → READ 403 → revoke → audit", async ({
112 api,
113 authPage: page,
114 freshWorkspace,
115 }) => {
116 const ws = await freshWorkspace(`DEV E2E ${Date.now()}`);
117
118
119 await openDevelopers(page, ws.id);
120 await page.getByTestId("apikey-create-open").click();
121 await page.getByLabel("Name").fill("CI");
122 await page.getByRole("checkbox", { name: /WRITE/ }).check();
123 await safeClick(page, page.getByRole("button", { name: "Create key" }), "Create key");
124
125
126 const rawField = page.getByTestId("apikey-raw");
127 await expect(rawField).toBeVisible();
128 const rawKey = (await rawField.inputValue()).trim();
129 expect(rawKey).toMatch(/^fk_live_/);
130
131
132
133 const inputsWithKey = await page
134 .locator("input")
135 .evaluateAll((els, key) => els.filter((e) => e.value === key).length, rawKey);
136 expect(inputsWithKey).toBe(1);
137 expect(await page.getByText(rawKey).count()).toBe(0);
138 await expect(page.getByText("This key will not be shown again")).toBeVisible();
139
140
141
142 await page.context().grantPermissions(["clipboard-read", "clipboard-write"]);
143 await page.getByTestId("apikey-copy").click();
144 await expect(page.getByRole("button", { name: "Copy API key" })).toHaveText(/Copied/);
145
146
147 await page.getByTestId("apikey-stored-confirm").check();
148 await safeClick(
149 page,
150 page.getByRole("button", { name: "I have stored it" }),
151 "I have stored it",
152 );
153 await expect(page.getByTestId("apikey-raw")).toHaveCount(0);
154 expect(
155 await page
156 .locator("input")
157 .evaluateAll((els, key) => els.filter((e) => e.value === key).length, rawKey),
158 ).toBe(0);
159
160
161
162
163
164
165
166
167 const ciRow = page.getByRole("row", { name: /CI/ });
168 await expect(ciRow).toBeVisible({ timeout: 20_000 });
169 await expect(ciRow.getByText("fk_live_")).toBeVisible();
170 await expect(ciRow.getByText("WRITE")).toBeVisible();
171 await expect(ciRow.getByText("Active")).toBeVisible();
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188 await page.goto(`/w/${ws.id}/settings/members`);
189 await expect(page.getByRole("heading", { name: "Members", level: 1 })).toBeVisible({ timeout: 20_000 });
190 await page.goto(`/w/${ws.id}/settings/developers`);
191 await expect(page.getByRole("row", { name: /CI/ })).toBeVisible({ timeout: 20_000 });
192 expect(
193 await page
194 .locator("input")
195 .evaluateAll((els, key) => els.filter((e) => e.value === key).length, rawKey),
196 ).toBe(0);
197 expect(await page.getByText(rawKey).count()).toBe(0);
198
199
200 const keyReq = await bareCtx();
201 const keyAuth = { Authorization: `Bearer ${rawKey}` };
202
203 const created = await keyReq.post(`/api/v1/workspaces/${ws.id}/pages`, {
204 headers: keyAuth,
205 data: { title: "made by CI key" },
206 });
207 expect(created.status(), await created.text()).toBeLessThan(300);
208 const pg = (await created.json()) as { id: string; title: string };
209 expect(pg.title).toBe("made by CI key");
210
211
212
213 const titleOf = (b: Record<string, unknown>) =>
214 (b as { page?: { title?: string } }).page?.title ?? (b as { title?: string }).title;
215 const fetched = await keyReq.get(`/api/v1/pages/${pg.id}`, { headers: keyAuth });
216 expect(fetched.status()).toBe(200);
217 expect(titleOf(await fetched.json())).toBe("made by CI key");
218
219 const patched = await keyReq.patch(`/api/v1/pages/${pg.id}`, {
220 headers: keyAuth,
221 data: { title: "renamed by CI key" },
222 });
223 expect(patched.status()).toBe(200);
224 const recheck = await keyReq.get(`/api/v1/pages/${pg.id}`, { headers: keyAuth });
225 expect(titleOf(await recheck.json())).toBe("renamed by CI key");
226
227
228 await page.getByTestId("apikey-create-open").click();
229 await page.getByLabel("Name").fill("READONLY");
230
231 await safeClick(page, page.getByRole("button", { name: "Create key" }), "Create key");
232 const readField = page.getByTestId("apikey-raw");
233 await expect(readField).toBeVisible();
234 const readKey = (await readField.inputValue()).trim();
235 await page.getByTestId("apikey-stored-confirm").check();
236 await safeClick(
237 page,
238 page.getByRole("button", { name: "I have stored it" }),
239 "I have stored it",
240 );
241
242 const readReq = await bareCtx();
243 const rAuth = { Authorization: `Bearer ${readKey}` };
244 const readGet = await readReq.get(`/api/v1/workspaces/${ws.id}/pages`, { headers: rAuth });
245 expect(readGet.status()).toBe(200);
246 const readPost = await readReq.post(`/api/v1/workspaces/${ws.id}/pages`, {
247 headers: rAuth,
248 data: { title: "nope" },
249 });
250 expect(readPost.status()).toBe(403);
251 const rErr = (await readPost.json()) as { error: { details?: Array<{ reason?: string }> } };
252 expect(rErr.error.details?.some((d) => d.reason === "api_key_scope_write")).toBe(true);
253
254
255 await safeClick(page, ciRow.getByRole("button", { name: "Revoke" }), "Revoke (CI)");
256
257 await expect(
258 page.getByText("Revoke this key? Requests will fail immediately with 401."),
259 ).toBeVisible();
260
261 await safeClick(page, page.getByRole("button", { name: "Revoke" }).last(), "Revoke (confirm)");
262
263 await expect(page.getByRole("row", { name: /CI/ }).getByText("Revoked")).toBeVisible();
264
265
266 const dead = await keyReq.get(`/api/v1/pages/${pg.id}`, { headers: keyAuth });
267 expect(dead.status()).toBe(401);
268 const dErr = (await dead.json()) as { error: { details?: Array<{ reason?: string }> } };
269 expect(dErr.error.details?.some((d) => d.reason === "api_key_revoked")).toBe(true);
270
271
272 const audit = await api.get(`/api/v1/workspaces/${ws.id}/audit?limit=100`);
273 expect(audit.status()).toBe(200);
274 const rows = ((await audit.json()) as { data: Array<{ action: string }> }).data;
275 expect(rows.filter((r) => r.action === "api_key.created").length).toBe(2);
276 expect(rows.filter((r) => r.action === "api_key.revoked").length).toBe(1);
277
278 await keyReq.dispose();
279 await readReq.dispose();
280 });
281
282 test("a member does not see the Developers tab (absent from the settings DOM)", async ({
283 freshWorkspace,
284 secondUser,
285 }) => {
286
287
288
289 const ws = await freshWorkspace(`MEMBER TAB ${Date.now()}`);
290 const member = await secondUser();
291 await addMemberViaSeam(seamServiceToken(), ws.id, member.user.email, "MEMBER");
292
293
294 await member.page.goto(`/w/${ws.id}/settings`);
295 const nav = member.page.getByRole("navigation", { name: "Settings sections" });
296 await expect(nav).toBeVisible({ timeout: 20_000 });
297 await expect(nav.getByRole("link", { name: "Developers" })).toHaveCount(0);
298
299
300 const direct = await member.api.get(`/api/v1/workspaces/${ws.id}/api-keys`);
301 expect(direct.status()).toBe(403);
302 });
303});
304