1
19import { afterAll, beforeAll, describe, expect, it } from "vitest";
20import { createTestApp, type TestAppHandle } from "./helpers/api";
21import { authHeaders, createUser, createWorkspace } from "./helpers/seed";
22import type { PrismaClient } from "@prisma/client";
23
24let app: TestAppHandle;
25
26const json = async (res: Response): Promise<Record<string, unknown>> =>
27 (await res.json().catch(() => ({}))) as Record<string, unknown>;
28
29
30const reasonOf = (body: Record<string, unknown>): string | undefined =>
31 ((body.error as { details?: Array<{ reason?: string }> })?.details?.[0]?.reason);
32
33
34async function makeOrg(tok: string) {
35 const res = await app.request("POST", "/api/v1/orgs", {
36 headers: authHeaders(tok),
37 body: { name: `Org ${Date.now()}-${Math.random().toString(36).slice(2, 7)}` }
38 });
39 const body = (await json(res)) as { id?: string; organization?: { id?: string } };
40 const orgId = body.id ?? body.organization?.id;
41 if (!orgId) throw new Error(`makeOrg failed: ${res.status} ${await res.text()}`);
42 return orgId;
43}
44
45
46async function addOrgMember(prisma: PrismaClient, orgId: string, userId: string, role: "ADMIN" | "MEMBER" | "GUEST") {
47 await prisma.organizationMember.upsert({
48 where: { organizationId_userId: { organizationId: orgId, userId } },
49 update: { role },
50 create: { organizationId: orgId, userId, role }
51 });
52}
53
54describe("TASK-0133 org-admin console API", () => {
55 beforeAll(async () => {
56 app = await createTestApp();
57 }, 180_000);
58 afterAll(async () => {
59 await app?.close();
60 });
61
62 it("guard matrix: owner + admin pass; member / guest / non-member / cross-org → 403 requires_org_admin", async () => {
63 const owner = await createUser(app.request);
64 const orgId = await makeOrg(owner.accessToken!);
65
66 const admin = await createUser(app.request);
67 const member = await createUser(app.request);
68 const guest = await createUser(app.request);
69 const outsider = await createUser(app.request);
70 await addOrgMember(app.prisma, orgId, admin.userId, "ADMIN");
71 await addOrgMember(app.prisma, orgId, member.userId, "MEMBER");
72 await addOrgMember(app.prisma, orgId, guest.userId, "GUEST");
73
74 const path = `/api/v1/orgs/${orgId}/admin/overview`;
75 const ok = await app.request("GET", path, { headers: authHeaders(owner.accessToken!) });
76 expect(ok.status).toBe(200);
77 const okAdmin = await app.request("GET", path, { headers: authHeaders(admin.accessToken!) });
78 expect(okAdmin.status).toBe(200);
79
80 for (const [u, label] of [
81 [member, "member"],
82 [guest, "guest"],
83 [outsider, "non-member"]
84 ] as const) {
85 const r = await app.request("GET", path, { headers: authHeaders(u.accessToken!) });
86 expect(r.status, label).toBe(403);
87 expect(reasonOf(await json(r)), label).toBe("requires_org_admin");
88 }
89
90
91 const otherOrg = await makeOrg(admin.accessToken!);
92 const cross = await app.request("GET", `/api/v1/orgs/${orgId}/admin/overview`, {
93 headers: authHeaders(admin.accessToken!)
94 });
95 expect(cross.status).toBe(200);
96 void otherOrg;
97
98 const stranger = await createUser(app.request);
99 await makeOrg(stranger.accessToken!);
100 const crossOrg = await app.request("GET", path, { headers: authHeaders(stranger.accessToken!) });
101 expect(crossOrg.status).toBe(403);
102 expect(reasonOf(await json(crossOrg))).toBe("requires_org_admin");
103
104
105 const anon = await app.request("GET", path);
106 expect(anon.status).toBe(401);
107 });
108
109
110
111
112
113
114 it("audit viewer routes are admin-only (member/guest/non-member → 403, owner → 200)", async () => {
115 const owner = await createUser(app.request);
116 const orgId = await makeOrg(owner.accessToken!);
117 const member = await createUser(app.request);
118 const outsider = await createUser(app.request);
119 await addOrgMember(app.prisma, orgId, member.userId, "MEMBER");
120
121
122 const ws = await app.prisma.workspace.create({
123 data: { name: "audit gate ws", slug: `audit-gate-${orgId.slice(0, 8)}` }
124 });
125 await app.prisma.workspace.update({ where: { id: ws.id }, data: { organizationId: orgId } });
126 await app.prisma.auditLog.create({
127 data: {
128 actorType: "USER",
129 actorId: owner.userId,
130 workspaceId: ws.id,
131 resourceType: "workspace",
132 resourceId: ws.id,
133 action: "workspace.created"
134 }
135 });
136
137 const auditRoutes: Array<[string, string, Record<string, unknown>?]> = [
138 ["GET", `/api/v1/orgs/${orgId}/admin/audit`],
139 ["GET", `/api/v1/orgs/${orgId}/admin/audit/actions`],
140 ["POST", `/api/v1/orgs/${orgId}/admin/audit/export`, {}],
141 ["GET", `/api/v1/orgs/${orgId}/admin/audit/exports/does-not-exist`]
142 ];
143 for (const [u, label] of [
144 [member, "member"],
145 [outsider, "non-member"]
146 ] as const) {
147 for (const [method, route, body] of auditRoutes) {
148 const r = await app.request(method, route, {
149 headers: authHeaders(u.accessToken!),
150 data: body
151 });
152 expect(r.status, `${label} ${method} ${route}`).toBe(403);
153 expect(reasonOf(await json(r)), `${label} ${method} ${route}`).toBe("requires_org_admin");
154 }
155 }
156
157
158 for (const [method, route, body] of auditRoutes) {
159 const r = await app.request(method, route, {
160 headers: authHeaders(owner.accessToken!),
161 data: body
162 });
163
164
165 const expected = route.endsWith("does-not-exist") ? 404 : 200;
166 expect(r.status, `owner ${method} ${route}`).toBe(expected);
167 }
168 });
169
170 it("policy: GET returns the permissive baseline; PATCH merges + audits + invalidates", async () => {
171 const owner = await createUser(app.request);
172 const orgId = await makeOrg(owner.accessToken!);
173 const h = authHeaders(owner.accessToken!);
174
175 const base = await app.request("GET", `/api/v1/orgs/${orgId}/admin/policy`, { headers: h });
176 expect(base.status).toBe(200);
177 const baseBody = await json(base);
178 expect((baseBody.policy as Record<string, unknown>).require2FA).toBe(false);
179 expect((baseBody.policy as Record<string, unknown>).allowExternalEmbeds).toBe(true);
180
181 const patch = await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/policy`, {
182 headers: h,
183 body: { disableGuestPublicShare: true, allowExternalEmbeds: false, maxFileSizeMB: 20 }
184 });
185 expect(patch.status).toBe(200);
186 const patchBody = await json(patch);
187 const policy = patchBody.policy as Record<string, unknown>;
188 expect(policy.disableGuestPublicShare).toBe(true);
189 expect(policy.allowExternalEmbeds).toBe(false);
190 expect(policy.maxFileSizeMB).toBe(20);
191
192
193 const row = await app.prisma.auditLog.findFirst({
194 where: { action: "org.policy.updated", resourceId: orgId },
195 orderBy: { createdAt: "desc" }
196 });
197 expect(row).toBeTruthy();
198 const meta = row!.metadata as Record<string, unknown>;
199 expect((meta.changes as Record<string, unknown>)["allowExternalEmbeds"]).toBeTruthy();
200 });
201
202 it("enforcement #2: disableGuestPublicShare blocks a guest (anyone) public share (403)", async () => {
203 const owner = await createUser(app.request);
204 const orgId = await makeOrg(owner.accessToken!);
205 const h = authHeaders(owner.accessToken!);
206 await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/policy`, {
207 headers: h,
208 body: { disableGuestPublicShare: true }
209 });
210
211
212
213 const ws = await createWorkspace(app.request, h);
214 await app.prisma.workspace.update({
215 where: { id: ws.workspaceId },
216 data: { organizationId: orgId }
217 });
218
219 const page = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, {
220 headers: h,
221 body: { title: "shareable" }
222 });
223 const pageId = ((await json(page)) as { id?: string }).id;
224 expect(pageId).toBeTruthy();
225 const share = await app.request("POST", `/api/v1/shares`, {
226 headers: h,
227 body: { workspaceId: ws.workspaceId, targetType: "page", targetId: pageId!, kind: "public" }
228 });
229 expect(share.status).toBe(403);
230 expect(reasonOf(await json(share))).toBe("guest_public_share_disabled");
231 });
232
233 it("enforcement #5: allowExternalEmbeds=false blocks the embed preview (403, before any fetch)", async () => {
234
235
236
237
238 const owner = await createUser(app.request);
239 const orgId = await makeOrg(owner.accessToken!);
240 const h = authHeaders(owner.accessToken!);
241 await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/policy`, {
242 headers: h,
243 body: { allowExternalEmbeds: false }
244 });
245 const ws = await createWorkspace(app.request, h);
246 await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { organizationId: orgId } });
247 const preview = await app.request(
248 "GET",
249 `/api/v1/workspaces/${ws.workspaceId}/embeds/preview?url=${encodeURIComponent("https://example.com")}`,
250 { headers: { ...h, "x-factory-workspace": ws.workspaceId } }
251 );
252 expect(preview.status).toBe(403);
253 expect(reasonOf(await json(preview))).toBe("external_embeds_disabled");
254 });
255
256 it("enforcement #3: maxFileSizeMB caps the presign size (413 when over the org cap)", async () => {
257 const owner = await createUser(app.request);
258 const orgId = await makeOrg(owner.accessToken!);
259 const h = authHeaders(owner.accessToken!);
260
261 await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/policy`, {
262 headers: h,
263 body: { maxFileSizeMB: 20 }
264 });
265 const ws = await createWorkspace(app.request, h);
266 await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { organizationId: orgId } });
267 const over = 25 * 1024 * 1024;
268 const create = await app.request("POST", `/api/v1/files/uploads`, {
269 headers: { ...h, "x-factory-workspace": ws.workspaceId },
270 body: { name: "big.bin", size: over, mimeType: "application/octet-stream" }
271 });
272 expect(create.status).toBe(413);
273 expect(((await json(create)).error as { code?: string })?.code).toBe("FILE_TOO_LARGE");
274 });
275
276 it("enforcement #1: require2FA gates login (202 twoFactorRequired + verificationId)", async () => {
277 const owner = await createUser(app.request);
278 const orgId = await makeOrg(owner.accessToken!);
279 const h = authHeaders(owner.accessToken!);
280 await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/policy`, {
281 headers: h,
282 body: { require2FA: true }
283 });
284
285 const login = await app.request("POST", "/api/v1/auth/login", {
286 body: { email: owner.email, password: owner.password }
287 });
288 expect(login.status).toBe(202);
289 const body = (await json(login)) as { twoFactorRequired?: boolean; reason?: string; verificationId?: string };
290 expect(body.twoFactorRequired).toBe(true);
291 expect(body.reason).toBe("2fa_required");
292 expect(body.verificationId).toBeTruthy();
293 });
294
295 it("audit viewer: filters + pagination + org scoping + deep-link map", async () => {
296 const owner = await createUser(app.request);
297 const orgId = await makeOrg(owner.accessToken!);
298 const h = authHeaders(owner.accessToken!);
299 const ws = await createWorkspace(app.request, h);
300 await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { organizationId: orgId } });
301
302 const page = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, {
303 headers: h,
304 body: { title: "audited" }
305 });
306 const pageId = ((await json(page)) as { id?: string }).id;
307 await app.request("PATCH", `/api/v1/pages/${pageId}`, {
308 headers: { ...h, "x-factory-workspace": ws.workspaceId },
309 body: { title: "audited-2" }
310 });
311
312 const list = await app.request("GET", `/api/v1/orgs/${orgId}/admin/audit`, { headers: h });
313 expect(list.status).toBe(200);
314 const body = (await json(list)) as { data: unknown[]; total: number; page: number; pageSize: number };
315 expect(body.page).toBe(1);
316 expect(body.pageSize).toBe(50);
317 expect(body.total).toBeGreaterThan(0);
318 const actions = body.data.map((r) => (r as { action: string }).action);
319 expect(actions).toContain("page.created");
320
321
322 const filtered = await app.request("GET", `/api/v1/orgs/${orgId}/admin/audit?action=page.created`, { headers: h });
323 const fbody = (await json(filtered)) as { data: Array<{ action: string }> };
324 expect(fbody.data.every((r) => r.action === "page.created")).toBe(true);
325
326
327 const bad = await app.request("GET", `/api/v1/orgs/${orgId}/admin/audit?pageSize=1000`, { headers: h });
328 expect(bad.status).toBe(400);
329 });
330
331 it("audit export: sync (≤10k) → 200 text/csv with the 13-column header", async () => {
332 const owner = await createUser(app.request);
333 const orgId = await makeOrg(owner.accessToken!);
334 const h = authHeaders(owner.accessToken!);
335 const ws = await createWorkspace(app.request, h);
336 await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { organizationId: orgId } });
337 await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, {
338 headers: h,
339 body: { title: "csv-row" }
340 });
341
342 const res = await app.request("POST", `/api/v1/orgs/${orgId}/admin/audit/export`, {
343 headers: h,
344 body: {}
345 });
346 expect(res.status).toBe(200);
347 expect(res.headers.get("content-type")).toContain("text/csv");
348 const csv = await res.text();
349 const firstLine = csv.split("\n")[0];
350 expect(firstLine).toBe(
351 "id,created_at,actor_type,actor_id,actor_name,workspace_id,workspace_name,resource_type,resource_id,action,ip_address,user_agent,metadata"
352 );
353
354 expect(csv).toContain("page.created");
355 });
356
357 it("retention: GET returns 4 scopes with defaults; PATCH upserts + audits; run-now enqueues 202 runId", async () => {
358 const owner = await createUser(app.request);
359 const orgId = await makeOrg(owner.accessToken!);
360 const h = authHeaders(owner.accessToken!);
361
362 const list = await app.request("GET", `/api/v1/orgs/${orgId}/admin/retention`, { headers: h });
363 expect(list.status).toBe(200);
364 const body = (await json(list)) as { data: Array<{ scope: string; type: string; retainDays: number }> };
365 expect(body.data.map((s) => s.scope).sort()).toEqual(["audit_logs", "comments", "files", "trash"]);
366 const trash = body.data.find((s) => s.scope === "trash")!;
367 expect(trash.type).toBe("PURGE");
368 expect(trash.retainDays).toBe(30);
369
370 const patch = await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/retention`, {
371 headers: h,
372 body: { trash: { type: "HOLD", retainDays: 60 } }
373 });
374 expect(patch.status).toBe(200);
375 const after = (await json(await app.request("GET", `/api/v1/orgs/${orgId}/admin/retention`, { headers: h }))) as {
376 data: Array<{ scope: string; type: string; retainDays: number }>
377 };
378 expect(after.data.find((s) => s.scope === "trash")!.type).toBe("HOLD");
379 expect(after.data.find((s) => s.scope === "trash")!.retainDays).toBe(60);
380
381
382 const run = await app.request("POST", `/api/v1/orgs/${orgId}/admin/retention/run`, {
383 headers: h,
384 body: { scope: "trash", mode: "dry" }
385 });
386 expect(run.status).toBe(202);
387 const runBody = (await json(run)) as { runId?: string; status?: string };
388 expect(runBody.runId).toBeTruthy();
389 expect(runBody.status).toBe("processing");
390
391 const status = await app.request("GET", `/api/v1/orgs/${orgId}/admin/retention/runs/${runBody.runId}`, { headers: h });
392 expect(status.status).toBe(200);
393 const statusBody = (await json(status)) as Record<string, unknown>;
394 expect(statusBody.scope).toBe("trash");
395 expect(statusBody.mode).toBe("dry");
396 });
397
398
399 it("analytics: owner 200 with org-scoped counts + 30-day trends; member/guest/non-member 403; anon 401", async () => {
400 const owner = await createUser(app.request);
401 const orgId = await makeOrg(owner.accessToken!);
402 const h = authHeaders(owner.accessToken!);
403
404 const member = await createUser(app.request);
405 const guest = await createUser(app.request);
406 const outsider = await createUser(app.request);
407 await addOrgMember(app.prisma, orgId, member.userId, "MEMBER");
408 await addOrgMember(app.prisma, orgId, guest.userId, "GUEST");
409
410
411
412 const { workspaceId } = await createWorkspace(app.request, h, `Analytics WS ${Date.now()}`);
413 await app.prisma.workspace.update({ where: { id: workspaceId }, data: { organizationId: orgId } });
414
415
416
417 for (const title of ["Analytics A", "Analytics B"]) {
418 const p = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, {
419 headers: h,
420 body: { title, type: "page" }
421 });
422 expect(p.status).toBe(201);
423 }
424
425 const res = await app.request("GET", `/api/v1/orgs/${orgId}/admin/analytics`, { headers: h });
426 expect(res.status).toBe(200);
427 const body = (await json(res)) as {
428 data: {
429 workspaces: number;
430 members: number;
431 membersByRole: Record<string, number>;
432 pages: Record<string, number>;
433 pagesTotal: number;
434 newPages: { last30d: number; last7d: number };
435 audit: { last30d: number; topActions: Array<{ action: string; count: number }> };
436 trends: {
437 newPages: Array<{ day: string; count: number }>;
438 storage: Array<{ day: string; bytes: number }>;
439 members: Array<{ day: string; count: number }>;
440 audit: Array<{ day: string; count: number }>
441 }
442 }
443 };
444 expect(body.data.workspaces).toBe(1);
445 expect(body.data.members).toBe(3);
446 expect(body.data.membersByRole.OWNER).toBe(1);
447 expect(body.data.membersByRole.MEMBER).toBe(1);
448 expect(body.data.pages.page).toBe(2);
449 expect(body.data.pagesTotal).toBe(2);
450 expect(body.data.newPages.last30d).toBe(2);
451 expect(body.data.newPages.last7d).toBe(2);
452 expect(body.data.audit.last30d).toBeGreaterThanOrEqual(1);
453 expect(body.data.audit.topActions.some((a) => a.action === "page.created")).toBe(true);
454
455 for (const arr of [body.data.trends.newPages, body.data.trends.storage, body.data.trends.members, body.data.trends.audit]) {
456 expect(arr.length).toBe(30);
457 expect(arr[0].day).toMatch(/^\d{4}-\d{2}-\d{2}$/);
458 }
459
460
461 for (const [u, label] of [[member, "member"], [guest, "guest"], [outsider, "non-member"]] as const) {
462 const r = await app.request("GET", `/api/v1/orgs/${orgId}/admin/analytics`, {
463 headers: authHeaders(u.accessToken!)
464 });
465 expect(r.status, label).toBe(403);
466 expect(reasonOf(await json(r)), label).toBe("requires_org_admin");
467 }
468 expect((await app.request("GET", `/api/v1/orgs/${orgId}/admin/analytics`)).status).toBe(401);
469
470
471 expect((await app.request("GET", "/api/v1/orgs/does-not-exist/admin/analytics", { headers: h })).status).toBe(404);
472 });
473});
474