1
46import { test, expect, request } from "@playwright/test";
47import type { PrismaClient } from "@prisma/client";
48import { startMockSamlIdp, type MockSamlIdp } from "../mocks/saml-idp";
49
50const API = process.env.E2E_API_URL ?? "http://localhost:4000";
51const WEB = process.env.E2E_BASE_URL ?? "http://localhost:3000";
52const WORKER = process.env.E2E_WORKER_URL ?? "http://127.0.0.1:4003";
53
59const SCIM = process.env.E2E_SCIM_URL ?? API;
60const DSN = process.env.DATABASE_URL ?? "postgresql://factory:factory@localhost:5432/factory";
61const RUN = Math.random().toString(36).slice(2, 8);
62
63const cleanups: Array<() => Promise<void>> = [];
64test.afterAll(async () => {
65 const prisma = await newPrisma();
66 for (const fn of cleanups.splice(0).reverse()) {
67 try {
68 await fn();
69 } catch {
70
71 }
72 }
73 await prisma.$disconnect();
74});
75
76async function newPrisma(): Promise<PrismaClient> {
77 const { PrismaClient: PC } = await import("@prisma/client");
78 return new PC({ datasources: { db: { url: DSN } } });
79}
80
81async function api(method: string, path: string, token: string, body?: unknown) {
82 const res = await fetch(`${API}/api/v1${path}`, {
83 method,
84 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
85 body: body !== undefined ? JSON.stringify(body) : undefined
86 });
87 const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
88 return { status: res.status, json, headers: res.headers };
89}
90
91const reasonOf = (json: Record<string, unknown>): string | undefined =>
92 ((json.error as { details?: Array<{ reason?: string }> })?.details?.[0]?.reason);
93
94
95async function createUser(name: string, email: string, password: string): Promise<string> {
96 const ctx = await request.newContext();
97 const reg = await ctx.post(`${API}/api/v1/auth/register`, { data: { name, email, password } });
98 expect(reg.status(), `register ${email}`).toBe(201);
99 const login = await ctx.post(`${API}/api/v1/auth/login`, { data: { email, password } });
100 expect(login.status(), `login ${email}`).toBe(200);
101 const token = ((await login.json()) as { accessToken: string }).accessToken;
102 await ctx.dispose();
103 return token;
104}
105
106
107async function browserLogin(page: import("@playwright/test").Page, email: string, password: string) {
108 await page.goto(`${WEB}/login`);
109 await page.getByTestId("auth-email").fill(email);
110 await page.getByTestId("auth-password").fill(password);
111 await page.getByTestId("auth-submit").click();
112 await page.waitForURL(/\/(w|org)\
113}
114
115
116async function setupOrg(token: string): Promise<{ orgId: string; slug: string; teamWsId: string }> {
117 const org = await api("POST", "/orgs", token, { name: `org-admin-e2e-${RUN}` });
118 expect(org.status).toBe(201);
119 const orgId = org.json.id as string;
120 const slug = org.json.slug as string;
121
122 const ws = await api("POST", "/workspaces", token, { name: `org-admin ws ${RUN}` });
123 expect(ws.status, `create workspace`).toBe(201);
124 const teamWsId = ws.json.id as string;
125
126 const p = await newPrisma();
127 await p.workspace.update({ where: { id: teamWsId }, data: { organizationId: orgId } });
128 await p.$disconnect();
129 cleanups.push(async () => {
130 const pc = await newPrisma();
131 await pc.organizationMember.deleteMany({ where: { organizationId: orgId } });
132 await pc.workspaceMember.deleteMany({ where: { workspaceId: teamWsId } });
133 await pc.$transaction([
134 pc.organization.delete({ where: { id: orgId } }),
135 pc.workspace.delete({ where: { id: teamWsId } })
136 ]);
137 await pc.$disconnect();
138 });
139 return { orgId, slug, teamWsId };
140}
141
142async function configureSaml(token: string, orgId: string, slug: string, idp: MockSamlIdp) {
143 const res = await fetch(`${API}/api/v1/orgs/${orgId}/sso`, {
144 method: "POST",
145 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
146 body: JSON.stringify({
147 type: "SAML",
148 name: "Mock IdP",
149 certificate: idp.certPem,
150 config: {
151 entityId: `${API}/saml/metadata/${slug}`,
152 ssoUrl: `${idp.url}/authorize`,
153 acsUrl: `${API}/saml/acs`,
154 metadataUrl: idp.metadataUrl
155 }
156 })
157 });
158 expect(res.status, "saml provider create").toBe(201);
159}
160
161
162async function ssoLogin(page: import("@playwright/test").Page, slug: string) {
163 await page.goto(`${API}/sso/start/${slug}`);
164 await page.waitForURL(new RegExp("127\\.0\\.0\\.1:\\d+/authorize"), { timeout: 15_000 });
165 await page.waitForURL(`${WEB}/w/**`, { timeout: 25_000 });
166}
167
168
169class ScimClient {
170 constructor(private scimToken: string) {}
171 private async call(method: string, path: string, body?: unknown) {
172 const res = await fetch(`${SCIM}/scim/v2${path}`, {
173 method,
174 headers: { authorization: `Bearer ${this.scimToken}`, "content-type": "application/scim+json" },
175 body: body !== undefined ? JSON.stringify(body) : undefined
176 });
177 const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
178 return { status: res.status, json, headers: res.headers };
179 }
180 create(email: string, password: string) {
181 return this.call("POST", "/Users", {
182 schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
183 userName: email,
184 name: { givenName: "Org", familyName: `Admin E2E ${RUN}` },
185 password,
186 active: true
187 });
188 }
189 deactivate(id: string) {
190 return this.call("PATCH", `/Users/${id}`, {
191 schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
192 Operations: [{ op: "replace", path: "active", value: false }]
193 });
194 }
195}
196
197async function enableScim(token: string, orgId: string, userEmailDomain: string): Promise<string> {
198 const res = await fetch(`${API}/api/v1/orgs/${orgId}/scim`, {
199 method: "POST",
200 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
201 body: JSON.stringify({ userEmailDomain, defaultRole: "MEMBER", autoJoinOrgWorkspaces: true })
202 });
203 expect(res.status, "enable scim").toBe(201);
204 return ((await res.json()) as { token: string }).token;
205}
206
207
208async function openConsole(page: import("@playwright/test").Page, orgId: string, tab: string) {
209 await page.goto(`${WEB}/org/${orgId}/admin?tab=${tab}`);
210 await expect(page.getByRole("button", { name: "Overview", exact: true })).toBeVisible({ timeout: 20_000 });
211}
212
213test.describe("TASK-0133 org admin console", () => {
214
215 test("Part 1: SAML → console access (owner sees the console; a MEMBER JIT user hits the role gate)", async ({
216 browser
217 }) => {
218 const ownerEmail = `org-owner-${RUN}@factory.local`;
219 const ownerPassword = `Pw!${RUN}-owner`;
220 const ssoEmail = `org-jit-${RUN}@factory.local`;
221
222 const ownerToken = await createUser("Org Owner", ownerEmail, ownerPassword);
223 const { orgId, slug, teamWsId } = await setupOrg(ownerToken);
224
225 const idp = await startMockSamlIdp({
226 acsUrl: `${API}/saml/acs`,
227 spEntityId: `${API}/saml/metadata/${slug}`,
228 user: { nameId: ssoEmail, email: ssoEmail, displayName: "Jit Console" }
229 });
230 cleanups.push(() => idp.close());
231 await configureSaml(ownerToken, orgId, slug, idp);
232
233
234 const worker = await fetch(`${WORKER}/health`);
235 expect(worker.status, "worker health").toBe(200);
236
237
238 const jitPage = await (await browser.newContext()).newPage();
239 await ssoLogin(jitPage, slug);
240 await openConsole(jitPage, orgId, "overview");
241
242
243 await expect(jitPage.getByTestId("org-admin-forbidden")).toBeVisible({ timeout: 20_000 });
244
245
246 const jitCookies = await jitPage.context().cookies(API);
247 const jitRefresh = jitCookies.find((c) => c.name === "factory_refresh")?.value ?? "";
248 expect(jitRefresh, "JIT refresh cookie set by the ACS").toBeTruthy();
249 await jitPage.context().close();
250
251
252 const ownerPage = await (await browser.newContext()).newPage();
253 await browserLogin(ownerPage, ownerEmail, ownerPassword);
254 await openConsole(ownerPage, orgId, "overview");
255 await expect(ownerPage.getByRole("heading", { name: "Effective policy" })).toBeVisible({ timeout: 20_000 });
256
257
258 await expect(ownerPage.getByText("Workspaces", { exact: true }).locator("xpath=ancestor::div[1]")).toHaveText(/1/);
259 await ownerPage.close();
260
261
262 const ws = await api("GET", `/orgs/${orgId}/admin/workspaces`, ownerToken);
263 expect(ws.status).toBe(200);
264 const rows = (ws.json.data as Array<{ id: string }>) ?? [];
265 expect(rows.map((r) => r.id)).toContain(teamWsId);
266
267
268
269 const ctx = await request.newContext();
270 const refresh = await ctx.post(`${API}/api/v1/auth/refresh`, {
271 headers: { "content-type": "application/json" },
272 data: { refreshToken: jitRefresh }
273 });
274 expect(refresh.status(), "JIT refresh from cookie value").toBe(200);
275 const jitToken = ((await refresh.json()) as { accessToken: string }).accessToken;
276 const gated = await api("GET", `/orgs/${orgId}/admin/workspaces`, jitToken);
277 expect(gated.status, "JIT member blocked from console API").toBe(403);
278 expect(reasonOf(gated.json)).toBe("requires_org_admin");
279 await ctx.dispose();
280 });
281
282
283 test("Part 2: SCIM lifecycle + Members suspend round-trip in the console", async ({ browser }) => {
284 const ownerEmail = `org2-owner-${RUN}@factory.local`;
285 const ownerPassword = `Pw!${RUN}-owner2`;
286 const scimDomain = `orgadmin2-${RUN}.local`;
287 const scimEmail = `scim.orgadmin.${RUN}@${scimDomain}`;
288 const scimPassword = `Scim-${RUN}!pw`;
289
290 const ownerToken = await createUser("Org2 Owner", ownerEmail, ownerPassword);
291 const { orgId } = await setupOrg(ownerToken);
292 const scimToken = await enableScim(ownerToken, orgId, scimDomain);
293 const client = new ScimClient(scimToken);
294
295
296 const created = await client.create(scimEmail, scimPassword);
297 expect(created.status, `scim create (${JSON.stringify(created.json)})`).toBe(201);
298 const scimUserId = created.json.id as string;
299 cleanups.push(async () => {
300 const pc = await newPrisma();
301 await pc.user.deleteMany({ where: { id: scimUserId } });
302 await pc.$disconnect();
303 });
304
305
306 const ownerPage = await (await browser.newContext()).newPage();
307 await browserLogin(ownerPage, ownerEmail, ownerPassword);
308 await openConsole(ownerPage, orgId, "members");
309 const memberRow = ownerPage.getByTestId(`org-admin-member-${scimUserId}`);
310 await expect(memberRow).toBeVisible({ timeout: 20_000 });
311 await expect(memberRow).toContainText("ACTIVE");
312
313
314
315 await memberRow.getByTestId("member-suspend").click();
316 await ownerPage
317 .getByRole("dialog")
318 .filter({ hasText: "Suspend this member?" })
319 .getByRole("button", { name: "Suspend" })
320 .click();
321 await expect(memberRow.getByText("SUSPENDED", { exact: true })).toBeVisible({ timeout: 20_000 });
322
323
324 const blocked = await fetch(`${API}/api/v1/auth/login`, {
325 method: "POST",
326 headers: { "content-type": "application/json" },
327 body: JSON.stringify({ email: scimEmail, password: scimPassword })
328 });
329 expect(blocked.status, "suspended login blocked").toBe(403);
330 expect(reasonOf((await blocked.json().catch(() => ({}))) as Record<string, unknown>)).toBe("account_suspended");
331
332
333 await memberRow.getByTestId("member-unsuspend").click();
334 await expect(memberRow).toContainText("ACTIVE", { timeout: 20_000 });
335 const unblocked = await fetch(`${API}/api/v1/auth/login`, {
336 method: "POST",
337 headers: { "content-type": "application/json" },
338 body: JSON.stringify({ email: scimEmail, password: scimPassword })
339 });
340 expect(unblocked.status, "unsuspended login works").toBe(200);
341
342
343 const deactivated = await client.deactivate(scimUserId);
344 expect(deactivated.status, `scim deactivate (${JSON.stringify(deactivated.json)})`).toBe(200);
345 const blocked2 = await fetch(`${API}/api/v1/auth/login`, {
346 method: "POST",
347 headers: { "content-type": "application/json" },
348 body: JSON.stringify({ email: scimEmail, password: scimPassword })
349 });
350 expect(blocked2.status, "scim-deactivated login blocked").toBe(403);
351 expect(reasonOf((await blocked2.json().catch(() => ({}))) as Record<string, unknown>)).toBe("account_suspended");
352
353 await ownerPage.close();
354 });
355
356
357 test("Part 3: org policy blocks a guest public share (403) + the share page shows the notice", async ({
358 browser
359 }) => {
360 const ownerEmail = `org3-owner-${RUN}@factory.local`;
361 const ownerPassword = `Pw!${RUN}-owner3`;
362
363 const ownerToken = await createUser("Org3 Owner", ownerEmail, ownerPassword);
364 const { orgId, teamWsId } = await setupOrg(ownerToken);
365
366
367 const page = await api("POST", `/workspaces/${teamWsId}/pages`, ownerToken, { title: "Shareable" });
368 expect(page.status, `create page`).toBe(201);
369 const pageId = page.json.id as string;
370
371
372 const before = await api("POST", "/shares", ownerToken, {
373 workspaceId: teamWsId,
374 targetType: "page",
375 targetId: pageId,
376 kind: "public"
377 });
378 expect(before.status, "public share allowed before the policy").toBe(201);
379
380
381 const ownerPage = await (await browser.newContext()).newPage();
382 await browserLogin(ownerPage, ownerEmail, ownerPassword);
383 await openConsole(ownerPage, orgId, "policies");
384 const toggle = ownerPage.getByRole("switch", { name: "Disable guest public shares" });
385 await expect(toggle).toBeVisible({ timeout: 20_000 });
386 await expect(toggle).toHaveAttribute("aria-checked", "false");
387 await toggle.click();
388 await expect(toggle).toHaveAttribute("aria-checked", "true", { timeout: 20_000 });
389
390
391 const policy = await api("GET", `/orgs/${orgId}/admin/policy`, ownerToken);
392 expect((policy.json.policy as { disableGuestPublicShare?: boolean })?.disableGuestPublicShare).toBe(true);
393
394
395 const after = await api("POST", "/shares", ownerToken, {
396 workspaceId: teamWsId,
397 targetType: "page",
398 targetId: pageId,
399 kind: "public"
400 });
401 expect(after.status, "public share blocked after the policy").toBe(403);
402 expect(reasonOf(after.json)).toBe("guest_public_share_disabled");
403
404
405 const memberShare = await api("POST", "/shares", ownerToken, {
406 workspaceId: teamWsId,
407 targetType: "page",
408 targetId: pageId,
409 kind: "link",
410 level: "VIEW"
411 });
412 expect(memberShare.status, "link share unaffected").toBe(201);
413
414
415 await ownerPage.goto(`${WEB}/w/${teamWsId}/page/${pageId}/share`);
416 const notice = ownerPage.getByTestId("guest-public-share-notice");
417 await expect(notice).toBeVisible({ timeout: 20_000 });
418 await expect(notice).toContainText("disabled public");
419
420 await expect(ownerPage.getByTestId("share-level-public").getByRole("radio")).toBeDisabled();
421 await ownerPage.close();
422 });
423
424
425 test("Part 4: retention dry → execute (the console runs the worker, real purge)", async ({ browser }) => {
426 const ownerEmail = `org4-owner-${RUN}@factory.local`;
427 const ownerPassword = `Pw!${RUN}-owner4`;
428
429 const ownerToken = await createUser("Org4 Owner", ownerEmail, ownerPassword);
430 const { orgId, teamWsId } = await setupOrg(ownerToken);
431
432
433 const p = await newPrisma();
434 const page = await p.page.create({
435 data: {
436 workspaceId: teamWsId,
437 title: "Old trashed page",
438 trashedAt: new Date(Date.now() - 45 * 86_400_000)
439 }
440 });
441 await p.$disconnect();
442
443
444 const ownerPage = await (await browser.newContext()).newPage();
445 await browserLogin(ownerPage, ownerEmail, ownerPassword);
446 await openConsole(ownerPage, orgId, "retention");
447
448
449
450
451
452
453 const trashCard = ownerPage
454 .locator(".grid.gap-4 > div")
455 .filter({ has: ownerPage.getByText("Permanently delete trashed pages") });
456 await trashCard.getByLabel("Trash retain days").fill("1");
457 const trashSave = trashCard.getByRole("button", { name: "Save", exact: true });
458 await expect(trashSave).toBeEnabled();
459 await trashSave.click();
460
461 await expect(trashSave).toBeDisabled({ timeout: 15_000 });
462
463
464 const retention = await api("GET", `/orgs/${orgId}/admin/retention`, ownerToken);
465 const trashRow = ((retention.json.data as Array<{ scope: string; type: string; retainDays: number }>) ?? []).find(
466 (r) => r.scope === "trash"
467 );
468 expect(trashRow, "trash policy row").toBeTruthy();
469 expect(trashRow?.type).toBe("PURGE");
470 expect(trashRow?.retainDays).toBe(1);
471
472
473 const trashRun = ownerPage.getByTestId("retention-run-trash");
474 await trashRun.getByRole("button", { name: "Dry" }).click();
475 const dryReport = ownerPage.getByTestId("org-admin-retention-report");
476 await expect(dryReport).toBeVisible({ timeout: 30_000 });
477 await expect(dryReport).toContainText("Dry-run complete");
478 await expect(dryReport).toContainText("1 page");
479 const pDry = await newPrisma();
480 const stillThere = await pDry.page.findUnique({ where: { id: page.id } });
481 expect(stillThere, "dry run deletes nothing").toBeTruthy();
482 await pDry.$disconnect();
483
484
485 await trashRun.getByRole("button", { name: "Execute" }).click();
486 await ownerPage.getByRole("dialog").getByRole("button", { name: "Execute" }).click();
487 await expect(dryReport).toContainText("Executed", { timeout: 30_000 });
488 await expect(dryReport).toContainText("1 page");
489 const pExec = await newPrisma();
490 const gone = await pExec.page.findUnique({ where: { id: page.id } });
491 expect(gone, "execute purges the trashed page").toBeNull();
492 await pExec.$disconnect();
493
494
495 await expect(trashCard.getByText("last run")).toBeVisible({ timeout: 10_000 });
496 await ownerPage.close();
497 });
498
499
500 test("Part 5: the three health tabs (jobs / search / notifications) render in the console", async ({
501 browser
502 }) => {
503 const ownerEmail = `org-health-${RUN}@factory.local`;
504 const ownerPassword = `Pw!${RUN}-health`;
505 const ownerToken = await createUser("Org Health", ownerEmail, ownerPassword);
506 const { orgId } = await setupOrg(ownerToken);
507
508 const page = await (await browser.newContext()).newPage();
509 await browserLogin(page, ownerEmail, ownerPassword);
510
511
512
513 await openConsole(page, orgId, "jobs");
514 for (const q of ["files", "search-index", "webhooks", "automations", "email", "ai", "maintenance"]) {
515 const cell = page.getByTestId(`jobs-queue-${q}`);
516 await expect(cell).toBeVisible({ timeout: 20_000 });
517 await expect(cell).toContainText(/wait/);
518 await expect(cell).toContainText(/fail/);
519 }
520 await expect(page.getByRole("heading", { name: "Failed jobs" })).toBeVisible();
521
522
523
524
525 await expect(page.getByLabel("Filter failed jobs by queue")).toBeVisible();
526
527
528
529
530 await openConsole(page, orgId, "search-health");
531 const ok = page.getByTestId("search-health-ok");
532 await expect(ok).toBeVisible({ timeout: 20_000 });
533 await expect(ok).toContainText("Index reachable");
534 for (const label of ["Source docs", "Index docs", "Drift", "Indexing lag"]) {
535 await expect(page.getByText(label, { exact: true })).toBeVisible();
536 }
537 await expect(page.getByTestId("search-health-reindex")).toBeVisible();
538
539
540
541 await openConsole(page, orgId, "notifications-health");
542 for (const label of ["In-app total", "In-app unread", "Email queued (cluster)", "Email failed (org)"]) {
543 await expect(page.getByText(label, { exact: true })).toBeVisible({ timeout: 20_000 });
544 }
545 await expect(page.getByText("Recent failed email jobs")).toBeVisible();
546
547
548 const jobs = await api("GET", `/orgs/${orgId}/admin/jobs`, ownerToken);
549 expect(jobs.status).toBe(200);
550 expect(jobs.json.scope).toBe("cluster");
551 expect((jobs.json.queues as Array<{ name: string }>).map((q) => q.name)).toEqual(
552 ["files", "search-index", "webhooks", "automations", "email", "ai", "maintenance"]
553 );
554 const sh = await api("GET", `/orgs/${orgId}/admin/search/health`, ownerToken);
555 expect(sh.status).toBe(200);
556 expect(sh.json.healthy).toBe(true);
557 await page.close();
558 });
559
560 test("Part 6: the Analytics tab (Phase 8) shows org-scoped usage in the console", async ({ browser }) => {
561 const ownerEmail = `org-analytic-${RUN}@factory.local`;
562 const ownerPassword = `Pw!${RUN}-analytic`;
563 const ownerToken = await createUser("Org Analytics", ownerEmail, ownerPassword);
564 const { orgId, teamWsId } = await setupOrg(ownerToken);
565
566
567
568 for (const title of ["Analytics E2E A", "Analytics E2E B"]) {
569 const p = await api("POST", `/workspaces/${teamWsId}/pages`, ownerToken, { title, type: "page" });
570 expect(p.status, `create ${title}`).toBe(201);
571 }
572
573
574 const analytics = await api("GET", `/orgs/${orgId}/admin/analytics`, ownerToken);
575 expect(analytics.status).toBe(200);
576 const data = (analytics.json as { data: Record<string, unknown> }).data;
577 expect((data as { workspaces: number }).workspaces).toBe(1);
578 expect(((data as { pages: Record<string, number> }).pages as Record<string, number>).page).toBe(2);
579 expect(((data as { trends: { newPages: unknown[] } }).trends as { newPages: unknown[] }).newPages.length).toBe(30);
580
581
582 const memberEmail = `org-analytic-member-${RUN}@factory.local`;
583 const memberPassword = `Pw!${RUN}-member`;
584 const memberToken = await createUser("Org Analytics Member", memberEmail, memberPassword);
585 const prisma = await newPrisma();
586 const memberUser = await prisma.user.findUnique({ where: { email: memberEmail } });
587 await prisma.organizationMember.upsert({
588 where: { organizationId_userId: { organizationId: orgId, userId: memberUser!.id } },
589 update: { role: "MEMBER" },
590 create: { organizationId: orgId, userId: memberUser!.id, role: "MEMBER" }
591 });
592 await prisma.$disconnect();
593 cleanups.push(async () => {
594 const pc = await newPrisma();
595 await pc.user.deleteMany({ where: { id: memberUser!.id } });
596 await pc.$disconnect();
597 });
598 const denied = await api("GET", `/orgs/${orgId}/admin/analytics`, memberToken);
599 expect(denied.status).toBe(403);
600 expect(reasonOf(denied.json)).toBe("requires_org_admin");
601
602
603 const page = await (await browser.newContext()).newPage();
604 await browserLogin(page, ownerEmail, ownerPassword);
605 await openConsole(page, orgId, "analytics");
606 await expect(page.getByRole("heading", { name: "Content mix" })).toBeVisible({ timeout: 20_000 });
607
608 await expect(page.getByText("page", { exact: true })).toBeVisible();
609 await expect(page.getByRole("heading", { name: "New pages (30 days)" })).toBeVisible();
610 await expect(page.getByRole("heading", { name: "Top audit actions (30 days)" })).toBeVisible();
611 await expect(page.getByRole("heading", { name: "Storage growth (30 days)" })).toBeVisible();
612 await page.close();
613 });
614});
615