1
35import { afterAll, beforeAll, describe, expect, it } from "vitest";
36import { createTestApp, type TestAppHandle } from "./helpers/api";
37import {
38 addMemberViaSeam,
39 authHeaders,
40 countAuditEvents,
41 createWorkspace,
42 createUser,
43 uniqueEmail,
44} from "./helpers/seed";
45
46let app: TestAppHandle;
47let owner: { accessToken: string };
48let wsA: { workspaceId: string };
49let wsB: { workspaceId: string };
50
51const json = async (res: Response): Promise<Record<string, unknown>> =>
52 (await res.json().catch(() => ({}))) as Record<string, unknown>;
53
54
55async function expectEnvelope(
56 res: Response,
57 status: number,
58 code: string,
59): Promise<Array<{ reason?: string }>> {
60 expect(res.status, `status: ${res.status} (body: ${await res.clone().text()})`).toBe(status);
61 const body = await json(res);
62 const err = body.error as
63 | { code: string; message: string; requestId: string; details?: Array<{ reason?: string }> }
64 | undefined;
65 expect(err, `envelope missing on ${status}: ${JSON.stringify(body)}`).toBeTruthy();
66 expect(err!.code).toBe(code);
67 expect(typeof err!.message).toBe("string");
68 expect(err!.requestId).toBeTruthy();
69 return (err!.details ?? []) as Array<{ reason?: string }>;
70}
71
72
73async function createKey(
74 workspaceId: string,
75 name: string,
76 scopes: string[],
77 rateLimit?: number,
78): Promise<{ keyId: string; rawKey: string }> {
79 const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/api-keys`, {
80 headers: authHeaders(owner.accessToken),
81 body: { name, scopes, ...(rateLimit ? { rateLimit } : {}) },
82 });
83 expect(res.status).toBe(201);
84 const body = await json(res);
85 expect(String(body.key ?? "")).toMatch(/^fk_live_/);
86 return { keyId: String(body.id), rawKey: String(body.key) };
87}
88
89const keyAuth = (rawKey: string, workspaceId?: string): Record<string, string> => ({
90 Authorization: `Bearer ${rawKey}`,
91 ...(workspaceId ? { "X-Factory-Workspace": workspaceId } : {}),
92});
93
94beforeAll(async () => {
95 app = await createTestApp();
96
97
98
99
100
101 owner = { accessToken: "" };
102 const u = await createUser(app.request, { name: "Key Owner" });
103 owner.accessToken = u.accessToken!;
104 OWNER_USER_ID = u.userId;
105 wsA = await createWorkspace(app.request, authHeaders(owner.accessToken), `WS A ${Date.now()}`);
106 wsB = await createWorkspace(app.request, authHeaders(owner.accessToken), `WS B ${Date.now()}`);
107
108
109
110 const u2 = await createUser(app.request, { name: "Cross User" });
111 const wsC = await createWorkspace(app.request, authHeaders(u2.accessToken), `WS C ${Date.now()}`);
112
113
114 (app as unknown as { __wsC?: string }).__wsC = wsC.workspaceId;
115}, 180_000);
116afterAll(async () => {
117 await app?.close();
118});
119
120describe("API keys — lifecycle (TASK-0127)", () => {
121 it("create returns the raw key exactly once; list shows only the prefix", async () => {
122 const { rawKey, keyId } = await createKey(wsA.workspaceId, "ci-key", ["READ", "WRITE"]);
123
124 const list = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/api-keys`, {
125 headers: authHeaders(owner.accessToken),
126 });
127 expect(list.status).toBe(200);
128 const body = await json(list);
129 const rows = (body.data ?? body) as unknown;
130 const text = JSON.stringify(body);
131
132 expect(text).not.toContain(rawKey);
133
134 expect(text).toContain(keyId);
135
136
137 const audits = await countAuditEvents(app.prisma, { actionPrefix: "api_key.created" });
138 expect(audits).toBeGreaterThanOrEqual(1);
139 });
140
141 it("revoked key 401s immediately (and the attempt is audited)", async () => {
142 const { rawKey, keyId } = await createKey(wsA.workspaceId, "doomed", ["READ"]);
143
144
145 const ok = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
146 headers: keyAuth(rawKey, wsA.workspaceId),
147 });
148 expect(ok.status).toBe(200);
149
150
151 const del = await app.request(
152 "DELETE",
153 `/api/v1/workspaces/${wsA.workspaceId}/api-keys/${keyId}`,
154 {
155 headers: authHeaders(owner.accessToken),
156 },
157 );
158 expect(del.status).toBe(200);
159
160
161 const dead = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
162 headers: keyAuth(rawKey, wsA.workspaceId),
163 });
164 const details = await expectEnvelope(dead, 401, "UNAUTHORIZED");
165 expect(details.some((d) => d.reason === "api_key_revoked")).toBe(true);
166
167
168 const audits = await countAuditEvents(app.prisma, { actionPrefix: "api_key.revoked" });
169 expect(audits).toBeGreaterThanOrEqual(1);
170 });
171
172 it("key management is OWNER/ADMIN-gated (MEMBER gets 403)", async () => {
173
174 const member = await createUser(app.request, { name: "Plain Member" });
175 await addMemberViaSeam(
176 app.request,
177 app.internalServiceToken,
178 wsA.workspaceId,
179 member.email,
180 "MEMBER",
181 );
182 const mres = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/api-keys`, {
183 headers: authHeaders(member.accessToken!),
184 });
185 await expectEnvelope(mres, 403, "FORBIDDEN");
186 });
187});
188
189describe("API keys — lifecycle contract (TASK-0128)", () => {
190 it("a duplicate ACTIVE key name → 409 (the create dialog's field error)", async () => {
191 await createKey(wsA.workspaceId, "dup-name", ["READ"]);
192 const dup = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/api-keys`, {
193 headers: authHeaders(owner.accessToken),
194 body: { name: "dup-name", scopes: ["READ"] },
195 });
196 const details = await expectEnvelope(dup, 409, "CONFLICT");
197 expect(details.some((d) => d.field === "name" && d.issue === "already_exists")).toBe(true);
198
199
200 const first = (await app.prisma.apiKey.findFirst({
201 where: { workspaceId: wsA.workspaceId, name: "dup-name" },
202 }))!;
203 await app.prisma.apiKey.update({ where: { id: first.id }, data: { revokedAt: new Date() } });
204 const again = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/api-keys`, {
205 headers: authHeaders(owner.accessToken),
206 body: { name: "dup-name", scopes: ["READ"] },
207 });
208 expect(again.status).toBe(201);
209 });
210
211 it("the list response is a strict schema — NO raw key, NO hash, fixed fields", async () => {
212 const { rawKey } = await createKey(wsA.workspaceId, "schema", ["READ"]);
213 const list = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/api-keys`, {
214 headers: authHeaders(owner.accessToken),
215 });
216 expect(list.status).toBe(200);
217 const body = await json(list);
218 const rows = (body.data ?? body) as Array<Record<string, unknown>>;
219 expect(Array.isArray(rows)).toBe(true);
220 expect(rows.length).toBeGreaterThan(0);
221
222 const text = JSON.stringify(body);
223 expect(text).not.toContain(rawKey);
224 const hash = (await app.prisma.apiKey.findFirst({ where: { prefix: rawKey.slice(0, 14) } }))
225 ?.keyHash;
226 if (hash) expect(text).not.toContain(hash);
227
228 const allowed = new Set([
229 "id",
230 "name",
231 "prefix",
232 "scopes",
233 "rateLimit",
234 "lastUsedAt",
235 "expiresAt",
236 "revokedAt",
237 "createdById",
238 "createdAt",
239 ]);
240 for (const row of rows) {
241 for (const k of Object.keys(row)) {
242 expect(allowed.has(k), `unexpected list field: ${k}`).toBe(true);
243 }
244 expect(row).not.toHaveProperty("key");
245 expect(row).not.toHaveProperty("keyHash");
246 }
247 });
248
249 it("revoke is idempotent and audited ONCE (re-DELETE does not re-mint the audit)", async () => {
250 const { rawKey, keyId } = await createKey(wsA.workspaceId, "once", ["READ"]);
251 const before = await countAuditEvents(app.prisma, { action: "api_key.revoked" });
252 const d1 = await app.request(
253 "DELETE",
254 `/api/v1/workspaces/${wsA.workspaceId}/api-keys/${keyId}`,
255 {
256 headers: authHeaders(owner.accessToken),
257 },
258 );
259 expect(d1.status).toBe(200);
260
261 const d2 = await app.request(
262 "DELETE",
263 `/api/v1/workspaces/${wsA.workspaceId}/api-keys/${keyId}`,
264 {
265 headers: authHeaders(owner.accessToken),
266 },
267 );
268 expect(d2.status).toBe(200);
269 const after = await countAuditEvents(app.prisma, { action: "api_key.revoked" });
270 expect(after - before).toBe(1);
271
272 const dead = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
273 headers: keyAuth(rawKey, wsA.workspaceId),
274 });
275 expect(dead.status).toBe(401);
276 });
277});
278
279describe("API keys — scope matrix (docs §7)", () => {
280 it("READ key: reads OK, writes → 403 api_key_scope_write (SCOPE_DENIED)", async () => {
281 const { rawKey } = await createKey(wsA.workspaceId, "read-only", ["READ"]);
282
283
284 const pages = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
285 headers: keyAuth(rawKey, wsA.workspaceId),
286 });
287 expect(pages.status).toBe(200);
288 const dbs = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/databases`, {
289 headers: keyAuth(rawKey, wsA.workspaceId),
290 });
291 expect(dbs.status).toBe(200);
292
293
294 const create = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
295 headers: keyAuth(rawKey, wsA.workspaceId),
296 body: { title: "should not exist" },
297 });
298 const details = await expectEnvelope(create, 403, "FORBIDDEN");
299 expect(details.some((d) => d.reason === "api_key_scope_write")).toBe(true);
300 });
301
302 it("WRITE key: edits OK, a workspace-admin (OWNER/ADMIN-gated) action → 403 api_key_scope_write", async () => {
303 const { rawKey } = await createKey(wsA.workspaceId, "writer", ["WRITE"]);
304
305
306 const created = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
307 headers: keyAuth(rawKey, wsA.workspaceId),
308 body: { title: "written by key" },
309 });
310 expect(created.status).toBe(201);
311 const pageId = String((await json(created)).id);
312 const patched = await app.request("PATCH", `/api/v1/pages/${pageId}`, {
313 headers: keyAuth(rawKey, wsA.workspaceId),
314 body: { title: "renamed by key" },
315 });
316 expect(patched.status).toBe(200);
317
318
319
320
321 const wh = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/webhooks`, {
322 headers: keyAuth(rawKey, wsA.workspaceId),
323 body: { url: "https://example.com/hook", events: ["page.created"] },
324 });
325 const details = await expectEnvelope(wh, 403, "FORBIDDEN");
326 expect(details.some((d) => d.reason === "api_key_scope_write")).toBe(true);
327 });
328
329 it("ADMIN key: the OWNER/ADMIN-gated surface opens (ADMIN > WRITE), user-identity gates stay closed", async () => {
330 const { rawKey } = await createKey(wsA.workspaceId, "admin-key", ["ADMIN"]);
331
332
333 expect(
334 (
335 await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
336 headers: keyAuth(rawKey, wsA.workspaceId),
337 })
338 ).status,
339 ).toBe(200);
340
341
342
343
344 const wh = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/webhooks`, {
345 headers: keyAuth(rawKey, wsA.workspaceId),
346 body: { url: "https://example.com/hook", events: ["page.created"] },
347 });
348 expect(wh.status).toBe(201);
349
350
351
352 const del = await app.request("DELETE", `/api/v1/workspaces/${wsA.workspaceId}`, {
353 headers: keyAuth(rawKey, wsA.workspaceId),
354 });
355 await expectEnvelope(del, 403, "FORBIDDEN");
356 });
357
358 it("cross-workspace: a key pointed at a foreign workspace → 403 workspace_mismatch", async () => {
359 const { rawKey } = await createKey(wsA.workspaceId, "cross", ["ADMIN"]);
360 const foreign = (app as unknown as { __wsC?: string }).__wsC!;
361
362 const pages = await app.request("GET", `/api/v1/workspaces/${foreign}/pages`, {
363 headers: keyAuth(rawKey, foreign),
364 });
365 const details = await expectEnvelope(pages, 403, "FORBIDDEN");
366 expect(details.some((d) => d.reason === "workspace_mismatch")).toBe(true);
367
368
369
370 const b = await app.request("GET", `/api/v1/workspaces/${wsB.workspaceId}/pages`, {
371 headers: keyAuth(rawKey, wsB.workspaceId),
372 });
373 const detailsB = await expectEnvelope(b, 403, "FORBIDDEN");
374 expect(detailsB.some((d) => d.reason === "workspace_mismatch")).toBe(true);
375 });
376
377 it("user-identity-only endpoints reject keys (403 requires_user_credentials)", async () => {
378 const { rawKey } = await createKey(wsA.workspaceId, "id-only", ["ADMIN"]);
379
380
381
382 const conv = await app.request(
383 "GET",
384 `/api/v1/workspaces/${wsA.workspaceId}/ai/conversations`,
385 {
386 headers: keyAuth(rawKey, wsA.workspaceId),
387 },
388 );
389 const details = await expectEnvelope(conv, 403, "FORBIDDEN");
390 expect(details.some((d) => d.reason === "requires_user_credentials")).toBe(true);
391 });
392
393 it("a creator who is OWNER of another workspace does NOT open it (the leak proof, strong form)", async () => {
394
395
396
397 const { rawKey } = await createKey(wsA.workspaceId, "leak", ["ADMIN"]);
398 const keyRow = await app.prisma.apiKey.findFirst({
399 where: { workspaceId: wsA.workspaceId, name: "leak" },
400 select: { createdById: true },
401 });
402 const members = await app.prisma.workspaceMember.findUnique({
403 where: { workspaceId_userId: { workspaceId: wsB.workspaceId, userId: keyRow!.createdById } },
404 });
405
406 expect(members?.role).toBe("OWNER");
407 const res = await app.request("GET", `/api/v1/workspaces/${wsB.workspaceId}/databases`, {
408 headers: keyAuth(rawKey, wsB.workspaceId),
409 });
410 const details = await expectEnvelope(res, 403, "FORBIDDEN");
411 expect(details.some((d) => d.reason === "workspace_mismatch")).toBe(true);
412 });
413});
414
415describe("API keys — audit attribution (docs §7)", () => {
416 it("a key's page create is audited as API_KEY (actorId api:{keyId}) — never the creator", async () => {
417 const { rawKey, keyId } = await createKey(wsA.workspaceId, "audited", ["WRITE"]);
418 const res = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
419 headers: keyAuth(rawKey, wsA.workspaceId),
420 body: { title: "audited page" },
421 });
422 expect([200, 201]).toContain(res.status);
423 const pageId = String((await json(res)).id);
424
425 const row = await app.prisma.auditLog.findFirst({
426 where: { action: "page.created", resourceId: pageId },
427 orderBy: { createdAt: "desc" },
428 });
429 expect(row, "the page.create audit row").not.toBeNull();
430 expect(row?.actorType).toBe("API_KEY");
431 expect(row?.actorId).toBe(`api:${keyId}`);
432
433
434 expect(row?.actorId).not.toBe(ownerUserId());
435 });
436});
437
438describe("API keys — idempotency single-flight + body digest (docs §9)", () => {
439 it("concurrent duplicates of the same operation → exactly one side effect", async () => {
440 const { rawKey } = await createKey(wsA.workspaceId, "conc", ["WRITE"]);
441 const idemKey = `key_${Date.now().toString(16)}_${Math.random().toString(16).slice(2)}`;
442 const opts = {
443 headers: { ...keyAuth(rawKey, wsA.workspaceId), "Idempotency-Key": idemKey },
444 body: { title: "single-flight page" },
445 };
446
447
448
449 const [a, b] = await Promise.all([
450 app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, opts),
451 app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, opts),
452 ]);
453 expect(a.status).toBe(b.status);
454 const ba = await json(a);
455 const bb = await json(b);
456
457
458 expect(ba.id).toBe(bb.id);
459
460 const pages = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
461 headers: keyAuth(rawKey, wsA.workspaceId),
462 });
463 const list = (await json(pages)) as { data?: Array<{ title: string }> };
464 const matches = (list.data ?? []).filter((p) => p.title === "single-flight page");
465 expect(matches).toHaveLength(1);
466 });
467
468 it("a different body with the same key → two side effects (body is in the memo digest)", async () => {
469 const { rawKey } = await createKey(wsA.workspaceId, "digest", ["WRITE"]);
470 const idemKey = `key_${Date.now().toString(16)}_${Math.random().toString(16).slice(2)}`;
471 const base = { ...keyAuth(rawKey, wsA.workspaceId), "Idempotency-Key": idemKey };
472 const r1 = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
473 headers: base,
474 body: { title: "digest-A" },
475 });
476 const r2 = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
477 headers: base,
478 body: { title: "digest-B" },
479 });
480 expect([200, 201]).toContain(r1.status);
481 expect([200, 201]).toContain(r2.status);
482 const b1 = await json(r1);
483 const b2 = await json(r2);
484
485 expect(b1.id).not.toBe(b2.id);
486 expect(r2.headers.get("idempotency-replayed")).toBeNull();
487
488 const pages = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
489 headers: keyAuth(rawKey, wsA.workspaceId),
490 });
491 const list = (await json(pages)) as { data?: Array<{ title: string }> };
492 const matches = (list.data ?? []).filter(
493 (p) => p.title === "digest-A" || p.title === "digest-B",
494 );
495 expect(matches).toHaveLength(2);
496 });
497});
498
499
500let OWNER_USER_ID = "";
501function ownerUserId(): string {
502 return OWNER_USER_ID;
503}
504
505describe("API keys — per-key rate limits (docs §11, decision #1)", () => {
506 it("a key with rateLimit: 10 gets 10×200 then 429 + Retry-After + X-RateLimit-Limit: 10", async () => {
507 const { rawKey } = await createKey(wsA.workspaceId, "throttled", ["READ"], 10);
508 let last: Response | undefined;
509 for (let i = 0; i < 11; i++) {
510 last = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
511 headers: keyAuth(rawKey, wsA.workspaceId),
512 });
513 }
514
515 expect(last!.status).toBe(429);
516 expect(last!.headers.get("retry-after")).toBeTruthy();
517 expect(last!.headers.get("x-ratelimit-limit")).toBe("10");
518 const body = await json(last!);
519 expect((body.error as { code: string }).code).toBe("RATE_LIMITED");
520 });
521
522 it("a key WITHOUT rateLimit is limited at the key default (X-RateLimit-Limit: 600)", async () => {
523 const { rawKey } = await createKey(wsA.workspaceId, "default-limit", ["READ"]);
524 const res = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
525 headers: keyAuth(rawKey, wsA.workspaceId),
526 });
527 expect(res.status).toBe(200);
528 expect(res.headers.get("x-ratelimit-limit")).toBe("600");
529 expect(res.headers.get("x-ratelimit-remaining")).toBe("599");
530 });
531});
532
533describe("API keys — idempotency (docs §9)", () => {
534 it("a repeated Idempotency-Key replays the stored body (Idempotency-Replayed: true), no duplicate", async () => {
535 const { rawKey } = await createKey(wsA.workspaceId, "idem", ["WRITE"]);
536 const idemKey = `key_${Math.random().toString(16).slice(2)}${Date.now().toString(16)}`;
537 const opts = {
538 headers: { ...keyAuth(rawKey, wsA.workspaceId), "Idempotency-Key": idemKey },
539 body: { title: "idempotent page" },
540 };
541 const first = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, opts);
542 expect([200, 201]).toContain(first.status);
543 const firstBody = await json(first);
544 expect(first.headers.get("idempotency-replayed")).toBeNull();
545
546 const second = await app.request("POST", `/api/v1/workspaces/${wsA.workspaceId}/pages`, opts);
547 expect(second.status).toBe(first.status);
548 expect(second.headers.get("idempotency-replayed")).toBe("true");
549 const secondBody = await json(second);
550
551 expect(secondBody.id).toBe(firstBody.id);
552
553
554 const pages = await app.request("GET", `/api/v1/workspaces/${wsA.workspaceId}/pages`, {
555 headers: keyAuth(rawKey, wsA.workspaceId),
556 });
557 const list = (await json(pages)) as { data?: Array<{ title: string }> };
558 const matches = (list.data ?? []).filter((p) => p.title === "idempotent page");
559 expect(matches).toHaveLength(1);
560 });
561});
562