/** * TASK-0133 — org admin console E2E (the phase's four-part exit criterion). * * Real two-origin dev stack: API :4000, web :3000, and the WORKER (its * health port, default 4003) — retention run-now is async via BullMQ and * only the worker writes the report the console polls. A per-run mock SAML * IdP stands in for the customer IdP (same mock as sso.spec.ts). * * Part 1 SAML → console access: owner (password) configures SAML via the * admin API; a JIT SSO user logs in through the mock IdP and is a * MEMBER of the org → the console `/org/:orgId/admin` renders the * role-gate panel (403 `requires_org_admin` from * GET /orgs/:orgId/admin/workspaces). The OWNER (password login, * same flow) sees the full console: header, tabs, and the * Overview data (org's workspace listed). * Part 2 SCIM lifecycle + Members suspend round-trip: SCIM create (with * password) → the user is a MEMBER org member → the console * Members tab shows the row → Suspend (confirm dialog) → the row * shows SUSPENDED → the user's fresh login is blocked * (403 `account_suspended`) → Unsuspend → ACTIVE again and login * works → SCIM deactivation (PATCH active:false) blocks login the * same way (the two deactivation paths converge on User.status). * Part 3 Policy blocks guest public share: the console Policies tab * toggles `disableGuestPublicShare` (PATCH /orgs/:orgId/admin/policy) * → creating a GUEST public share (POST /shares kind=public, the * server-authoritative path the UI funnels into) → 403 * `guest_public_share_disabled`; the web share page ( * /w/:ws/page/:id/share) shows the policy NOTICE (the acceptance * item) with the "Anyone with the link" option disabled. Member * (link) shares are unaffected. * Part 4 Retention dry → execute: seed a backdated trashed page (Prisma), * set the trash policy (PURGE, 1 day) in the console Retention tab * → `Dry` → a `dry_run` report with the page counted and NOTHING * deleted → `Execute` (confirm) → a `purged` report and the page * row gone. * Part 5 Health tabs render (T-176/T-177/T-178): the Jobs tab shows all * seven cluster-wide queue counters + the empty DLQ view; the * Search health tab shows "Index reachable" + the counts/freshness * grid + the (wired) reindex button; the Notifications tab shows * the four derived delivery stats + the failed-email list. The * role-gated API shapes are asserted alongside. * * Cleanup: org (cascades policy/SSO/SCIM/retention rows), workspace, and * the provisioned users — best-effort via Prisma. */ import { test, expect, request } from "@playwright/test"; import type { PrismaClient } from "@prisma/client"; import { startMockSamlIdp, type MockSamlIdp } from "../mocks/saml-idp"; const API = process.env.E2E_API_URL ?? "http://localhost:4000"; const WEB = process.env.E2E_BASE_URL ?? "http://localhost:3000"; const WORKER = process.env.E2E_WORKER_URL ?? "http://127.0.0.1:4003"; /** SCIM is a machine/IdP protocol, NOT a browser surface: the Caddyfile * (infrastructure/caddy) routes `/scim/v2/*` on the API host ("NOT * browser-facing — the browser uses notion.filps.com only"). On the local * default (single-origin API) that is `API`; on the domain topology the * browser surface (notion.filps.com) 307s `/scim/*` → /login, so the run * passes E2E_SCIM_URL=. */ const SCIM = process.env.E2E_SCIM_URL ?? API; const DSN = process.env.DATABASE_URL ?? "postgresql://factory:factory@localhost:5432/factory"; const RUN = Math.random().toString(36).slice(2, 8); const cleanups: Array<() => Promise> = []; test.afterAll(async () => { const prisma = await newPrisma(); for (const fn of cleanups.splice(0).reverse()) { try { await fn(); } catch { /* best-effort */ } } await prisma.$disconnect(); }); async function newPrisma(): Promise { const { PrismaClient: PC } = await import("@prisma/client"); return new PC({ datasources: { db: { url: DSN } } }); } async function api(method: string, path: string, token: string, body?: unknown) { const res = await fetch(`${API}/api/v1${path}`, { method, headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: body !== undefined ? JSON.stringify(body) : undefined }); const json = (await res.json().catch(() => ({}))) as Record; return { status: res.status, json, headers: res.headers }; } const reasonOf = (json: Record): string | undefined => ((json.error as { details?: Array<{ reason?: string }> })?.details?.[0]?.reason); /** Register + log in a fresh user; returns an API token. */ async function createUser(name: string, email: string, password: string): Promise { const ctx = await request.newContext(); const reg = await ctx.post(`${API}/api/v1/auth/register`, { data: { name, email, password } }); expect(reg.status(), `register ${email}`).toBe(201); const login = await ctx.post(`${API}/api/v1/auth/login`, { data: { email, password } }); expect(login.status(), `login ${email}`).toBe(200); const token = ((await login.json()) as { accessToken: string }).accessToken; await ctx.dispose(); return token; } /** Log a user in in the BROWSER (sets the refresh cookie + boots the session). */ async function browserLogin(page: import("@playwright/test").Page, email: string, password: string) { await page.goto(`${WEB}/login`); await page.getByTestId("auth-email").fill(email); await page.getByTestId("auth-password").fill(password); await page.getByTestId("auth-submit").click(); await page.waitForURL(/\/(w|org)\//, { timeout: 20_000 }); } /** Fresh org + team workspace owned by the user; the user is the OWNER member. */ async function setupOrg(token: string): Promise<{ orgId: string; slug: string; teamWsId: string }> { const org = await api("POST", "/orgs", token, { name: `org-admin-e2e-${RUN}` }); expect(org.status).toBe(201); const orgId = org.json.id as string; const slug = org.json.slug as string; const ws = await api("POST", "/workspaces", token, { name: `org-admin ws ${RUN}` }); expect(ws.status, `create workspace`).toBe(201); const teamWsId = ws.json.id as string; const p = await newPrisma(); await p.workspace.update({ where: { id: teamWsId }, data: { organizationId: orgId } }); await p.$disconnect(); cleanups.push(async () => { const pc = await newPrisma(); await pc.organizationMember.deleteMany({ where: { organizationId: orgId } }); await pc.workspaceMember.deleteMany({ where: { workspaceId: teamWsId } }); await pc.$transaction([ pc.organization.delete({ where: { id: orgId } }), // cascades policy/sso/scim/retention pc.workspace.delete({ where: { id: teamWsId } }) ]); await pc.$disconnect(); }); return { orgId, slug, teamWsId }; } async function configureSaml(token: string, orgId: string, slug: string, idp: MockSamlIdp) { const res = await fetch(`${API}/api/v1/orgs/${orgId}/sso`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ type: "SAML", name: "Mock IdP", certificate: idp.certPem, config: { entityId: `${API}/saml/metadata/${slug}`, ssoUrl: `${idp.url}/authorize`, acsUrl: `${API}/saml/acs`, metadataUrl: idp.metadataUrl } }) }); expect(res.status, "saml provider create").toBe(201); } /** The SSO start dispatcher (the same URL the login page's SSO button navigates to). */ async function ssoLogin(page: import("@playwright/test").Page, slug: string) { await page.goto(`${API}/sso/start/${slug}`); await page.waitForURL(new RegExp("127\\.0\\.0\\.1:\\d+/authorize"), { timeout: 15_000 }); await page.waitForURL(`${WEB}/w/**`, { timeout: 25_000 }); } /** A minimal SCIM client (what an IdP does): bearer-token fetches. */ class ScimClient { constructor(private scimToken: string) {} private async call(method: string, path: string, body?: unknown) { const res = await fetch(`${SCIM}/scim/v2${path}`, { method, headers: { authorization: `Bearer ${this.scimToken}`, "content-type": "application/scim+json" }, body: body !== undefined ? JSON.stringify(body) : undefined }); const json = (await res.json().catch(() => ({}))) as Record; return { status: res.status, json, headers: res.headers }; } create(email: string, password: string) { return this.call("POST", "/Users", { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], userName: email, name: { givenName: "Org", familyName: `Admin E2E ${RUN}` }, password, active: true }); } deactivate(id: string) { return this.call("PATCH", `/Users/${id}`, { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [{ op: "replace", path: "active", value: false }] }); } } async function enableScim(token: string, orgId: string, userEmailDomain: string): Promise { const res = await fetch(`${API}/api/v1/orgs/${orgId}/scim`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ userEmailDomain, defaultRole: "MEMBER", autoJoinOrgWorkspaces: true }) }); expect(res.status, "enable scim").toBe(201); return ((await res.json()) as { token: string }).token; } /** Open the console at a specific tab and wait for the tab bar. */ async function openConsole(page: import("@playwright/test").Page, orgId: string, tab: string) { await page.goto(`${WEB}/org/${orgId}/admin?tab=${tab}`); await expect(page.getByRole("button", { name: "Overview", exact: true })).toBeVisible({ timeout: 20_000 }); } test.describe("TASK-0133 org admin console", () => { // ────────────────────────────────────────────────────────────────────── test("Part 1: SAML → console access (owner sees the console; a MEMBER JIT user hits the role gate)", async ({ browser }) => { const ownerEmail = `org-owner-${RUN}@factory.local`; const ownerPassword = `Pw!${RUN}-owner`; const ssoEmail = `org-jit-${RUN}@factory.local`; const ownerToken = await createUser("Org Owner", ownerEmail, ownerPassword); const { orgId, slug, teamWsId } = await setupOrg(ownerToken); const idp = await startMockSamlIdp({ acsUrl: `${API}/saml/acs`, spEntityId: `${API}/saml/metadata/${slug}`, user: { nameId: ssoEmail, email: ssoEmail, displayName: "Jit Console" } }); cleanups.push(() => idp.close()); await configureSaml(ownerToken, orgId, slug, idp); // The worker must be up (Parts 1–3 don't need it, but the suite does). const worker = await fetch(`${WORKER}/health`); expect(worker.status, "worker health").toBe(200); // ---- a) the SSO JIT user is a MEMBER → the console is role-gated ---- const jitPage = await (await browser.newContext()).newPage(); await ssoLogin(jitPage, slug); await openConsole(jitPage, orgId, "overview"); // The gate panel replaces the tab content (the tab bar stays — it is // above the gate; the assertion is the panel + no Overview data). await expect(jitPage.getByTestId("org-admin-forbidden")).toBeVisible({ timeout: 20_000 }); // Capture the JIT refresh cookie (the ACS set it on this context) for the // API-level gate assertion below, then tear the browser down. const jitCookies = await jitPage.context().cookies(API); const jitRefresh = jitCookies.find((c) => c.name === "factory_refresh")?.value ?? ""; expect(jitRefresh, "JIT refresh cookie set by the ACS").toBeTruthy(); await jitPage.context().close(); // ---- b) the OWNER sees the full console (Overview data renders) ---- const ownerPage = await (await browser.newContext()).newPage(); await browserLogin(ownerPage, ownerEmail, ownerPassword); await openConsole(ownerPage, orgId, "overview"); await expect(ownerPage.getByRole("heading", { name: "Effective policy" })).toBeVisible({ timeout: 20_000 }); // The overview data loaded: the "Workspaces" stat cell shows 1 (the // team workspace; no tab is named "Workspaces", so the locator is unique). await expect(ownerPage.getByText("Workspaces", { exact: true }).locator("xpath=ancestor::div[1]")).toHaveText(/1/); await ownerPage.close(); // The org's workspace is listed in the picker data (overview loads it). const ws = await api("GET", `/orgs/${orgId}/admin/workspaces`, ownerToken); expect(ws.status).toBe(200); const rows = (ws.json.data as Array<{ id: string }>) ?? []; expect(rows.map((r) => r.id)).toContain(teamWsId); // ---- c) the role gate is ENFORCED at the API, not just hidden in UI ---- // POST the captured refresh token to /auth/refresh for the JIT's access token. const ctx = await request.newContext(); const refresh = await ctx.post(`${API}/api/v1/auth/refresh`, { headers: { "content-type": "application/json" }, data: { refreshToken: jitRefresh } }); expect(refresh.status(), "JIT refresh from cookie value").toBe(200); const jitToken = ((await refresh.json()) as { accessToken: string }).accessToken; const gated = await api("GET", `/orgs/${orgId}/admin/workspaces`, jitToken); expect(gated.status, "JIT member blocked from console API").toBe(403); expect(reasonOf(gated.json)).toBe("requires_org_admin"); await ctx.dispose(); }); // ────────────────────────────────────────────────────────────────────── test("Part 2: SCIM lifecycle + Members suspend round-trip in the console", async ({ browser }) => { const ownerEmail = `org2-owner-${RUN}@factory.local`; const ownerPassword = `Pw!${RUN}-owner2`; const scimDomain = `orgadmin2-${RUN}.local`; const scimEmail = `scim.orgadmin.${RUN}@${scimDomain}`; const scimPassword = `Scim-${RUN}!pw`; const ownerToken = await createUser("Org2 Owner", ownerEmail, ownerPassword); const { orgId } = await setupOrg(ownerToken); const scimToken = await enableScim(ownerToken, orgId, scimDomain); const client = new ScimClient(scimToken); // 1. SCIM provisions a user WITH a password (a MEMBER org member). const created = await client.create(scimEmail, scimPassword); expect(created.status, `scim create (${JSON.stringify(created.json)})`).toBe(201); const scimUserId = created.json.id as string; cleanups.push(async () => { const pc = await newPrisma(); await pc.user.deleteMany({ where: { id: scimUserId } }); // cascades sessions await pc.$disconnect(); }); // 2. The console Members tab shows the SCIM member. const ownerPage = await (await browser.newContext()).newPage(); await browserLogin(ownerPage, ownerEmail, ownerPassword); await openConsole(ownerPage, orgId, "members"); const memberRow = ownerPage.getByTestId(`org-admin-member-${scimUserId}`); await expect(memberRow).toBeVisible({ timeout: 20_000 }); await expect(memberRow).toContainText("ACTIVE"); // 3. Suspend from the console (confirm dialog) → SUSPENDED. The dialog // title scopes the confirm button (the row keeps its own "Suspend"). await memberRow.getByTestId("member-suspend").click(); await ownerPage .getByRole("dialog") .filter({ hasText: "Suspend this member?" }) .getByRole("button", { name: "Suspend" }) .click(); await expect(memberRow.getByText("SUSPENDED", { exact: true })).toBeVisible({ timeout: 20_000 }); // 4. Login is blocked (the suspend blocks fresh logins). const blocked = await fetch(`${API}/api/v1/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: scimEmail, password: scimPassword }) }); expect(blocked.status, "suspended login blocked").toBe(403); expect(reasonOf((await blocked.json().catch(() => ({}))) as Record)).toBe("account_suspended"); // 5. Unsuspend from the console → ACTIVE, login works again. await memberRow.getByTestId("member-unsuspend").click(); await expect(memberRow).toContainText("ACTIVE", { timeout: 20_000 }); const unblocked = await fetch(`${API}/api/v1/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: scimEmail, password: scimPassword }) }); expect(unblocked.status, "unsuspended login works").toBe(200); // 6. SCIM deactivation converges on the same User.status → blocked again. const deactivated = await client.deactivate(scimUserId); expect(deactivated.status, `scim deactivate (${JSON.stringify(deactivated.json)})`).toBe(200); const blocked2 = await fetch(`${API}/api/v1/auth/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email: scimEmail, password: scimPassword }) }); expect(blocked2.status, "scim-deactivated login blocked").toBe(403); expect(reasonOf((await blocked2.json().catch(() => ({}))) as Record)).toBe("account_suspended"); await ownerPage.close(); }); // ────────────────────────────────────────────────────────────────────── test("Part 3: org policy blocks a guest public share (403) + the share page shows the notice", async ({ browser }) => { const ownerEmail = `org3-owner-${RUN}@factory.local`; const ownerPassword = `Pw!${RUN}-owner3`; const ownerToken = await createUser("Org3 Owner", ownerEmail, ownerPassword); const { orgId, teamWsId } = await setupOrg(ownerToken); // A page to share (in the org's workspace). const page = await api("POST", `/workspaces/${teamWsId}/pages`, ownerToken, { title: "Shareable" }); expect(page.status, `create page`).toBe(201); const pageId = page.json.id as string; // Baseline: before the policy, a guest public share is ALLOWED. const before = await api("POST", "/shares", ownerToken, { workspaceId: teamWsId, targetType: "page", targetId: pageId, kind: "public" }); expect(before.status, "public share allowed before the policy").toBe(201); // ---- the console Policies tab toggles disableGuestPublicShare ---- const ownerPage = await (await browser.newContext()).newPage(); await browserLogin(ownerPage, ownerEmail, ownerPassword); await openConsole(ownerPage, orgId, "policies"); const toggle = ownerPage.getByRole("switch", { name: "Disable guest public shares" }); await expect(toggle).toBeVisible({ timeout: 20_000 }); await expect(toggle).toHaveAttribute("aria-checked", "false"); await toggle.click(); await expect(toggle).toHaveAttribute("aria-checked", "true", { timeout: 20_000 }); // The policy is persisted server-side. const policy = await api("GET", `/orgs/${orgId}/admin/policy`, ownerToken); expect((policy.json.policy as { disableGuestPublicShare?: boolean })?.disableGuestPublicShare).toBe(true); // ---- the server-authoritative share path now 403s ---- const after = await api("POST", "/shares", ownerToken, { workspaceId: teamWsId, targetType: "page", targetId: pageId, kind: "public" }); expect(after.status, "public share blocked after the policy").toBe(403); expect(reasonOf(after.json)).toBe("guest_public_share_disabled"); // Member (link) shares are unaffected. const memberShare = await api("POST", "/shares", ownerToken, { workspaceId: teamWsId, targetType: "page", targetId: pageId, kind: "link", level: "VIEW" }); expect(memberShare.status, "link share unaffected").toBe(201); // ---- the web share page shows the policy notice ---- await ownerPage.goto(`${WEB}/w/${teamWsId}/page/${pageId}/share`); const notice = ownerPage.getByTestId("guest-public-share-notice"); await expect(notice).toBeVisible({ timeout: 20_000 }); await expect(notice).toContainText("disabled public"); // The "Anyone with the link" option is disabled (UX affordance). await expect(ownerPage.getByTestId("share-level-public").getByRole("radio")).toBeDisabled(); await ownerPage.close(); }); // ────────────────────────────────────────────────────────────────────── test("Part 4: retention dry → execute (the console runs the worker, real purge)", async ({ browser }) => { const ownerEmail = `org4-owner-${RUN}@factory.local`; const ownerPassword = `Pw!${RUN}-owner4`; const ownerToken = await createUser("Org4 Owner", ownerEmail, ownerPassword); const { orgId, teamWsId } = await setupOrg(ownerToken); // Seed a BACKDATED trashed page (45 days old > the 1-day policy below). const p = await newPrisma(); const page = await p.page.create({ data: { workspaceId: teamWsId, title: "Old trashed page", trashedAt: new Date(Date.now() - 45 * 86_400_000) } }); await p.$disconnect(); // ---- the console Retention tab: set the trash policy, then run ---- const ownerPage = await (await browser.newContext()).newPage(); await browserLogin(ownerPage, ownerEmail, ownerPassword); await openConsole(ownerPage, orgId, "retention"); // Trash policy: PURGE after 1 day (the default is already PURGE 30d; set // 1 so the 45-day-old page is inside the purge window). // The trash scope card: a direct child of the scope-cards grid, anchored // by the card's unique description (the "Run now" row also says "Trash", // so the label alone is ambiguous). const trashCard = ownerPage .locator(".grid.gap-4 > div") .filter({ has: ownerPage.getByText("Permanently delete trashed pages") }); await trashCard.getByLabel("Trash retain days").fill("1"); const trashSave = trashCard.getByRole("button", { name: "Save", exact: true }); await expect(trashSave).toBeEnabled(); await trashSave.click(); // Save disables once the PATCH lands and the draft matches the server. await expect(trashSave).toBeDisabled({ timeout: 15_000 }); // The policy is persisted server-side. const retention = await api("GET", `/orgs/${orgId}/admin/retention`, ownerToken); const trashRow = ((retention.json.data as Array<{ scope: string; type: string; retainDays: number }>) ?? []).find( (r) => r.scope === "trash" ); expect(trashRow, "trash policy row").toBeTruthy(); expect(trashRow?.type).toBe("PURGE"); expect(trashRow?.retainDays).toBe(1); // a) DRY run: the page is counted, nothing is deleted. const trashRun = ownerPage.getByTestId("retention-run-trash"); await trashRun.getByRole("button", { name: "Dry" }).click(); const dryReport = ownerPage.getByTestId("org-admin-retention-report"); await expect(dryReport).toBeVisible({ timeout: 30_000 }); await expect(dryReport).toContainText("Dry-run complete"); await expect(dryReport).toContainText("1 page"); const pDry = await newPrisma(); const stillThere = await pDry.page.findUnique({ where: { id: page.id } }); expect(stillThere, "dry run deletes nothing").toBeTruthy(); await pDry.$disconnect(); // b) EXECUTE run: the page is purged. await trashRun.getByRole("button", { name: "Execute" }).click(); await ownerPage.getByRole("dialog").getByRole("button", { name: "Execute" }).click(); await expect(dryReport).toContainText("Executed", { timeout: 30_000 }); await expect(dryReport).toContainText("1 page"); const pExec = await newPrisma(); const gone = await pExec.page.findUnique({ where: { id: page.id } }); expect(gone, "execute purges the trashed page").toBeNull(); await pExec.$disconnect(); // The run updated the scope's lastRun (the policy card shows it). await expect(trashCard.getByText("last run")).toBeVisible({ timeout: 10_000 }); await ownerPage.close(); }); // ────────────────────────────────────────────────────────────────────── test("Part 5: the three health tabs (jobs / search / notifications) render in the console", async ({ browser }) => { const ownerEmail = `org-health-${RUN}@factory.local`; const ownerPassword = `Pw!${RUN}-health`; const ownerToken = await createUser("Org Health", ownerEmail, ownerPassword); const { orgId } = await setupOrg(ownerToken); const page = await (await browser.newContext()).newPage(); await browserLogin(page, ownerEmail, ownerPassword); // JOBS (T-176): cluster-wide queue state — all seven worker queues render // with their wait/active/fail counters, and the empty DLQ view. await openConsole(page, orgId, "jobs"); for (const q of ["files", "search-index", "webhooks", "automations", "email", "ai", "maintenance"]) { const cell = page.getByTestId(`jobs-queue-${q}`); await expect(cell).toBeVisible({ timeout: 20_000 }); await expect(cell).toContainText(/wait/); await expect(cell).toContainText(/fail/); } await expect(page.getByRole("heading", { name: "Failed jobs" })).toBeVisible(); // The DLQ content is state-dependent (the dev cluster accumulates failed // jobs — `ai` alone has thousands) — the section RENDERING (heading + // the "All queues" filter) is what this tab proves; row-by-row requeue // round-trips are proven at the API layer (org-admin-health). await expect(page.getByLabel("Filter failed jobs by queue")).toBeVisible(); // SEARCH HEALTH (T-177): the dev/CI OpenSearch is up → "Index reachable" // (a down search renders the `search-health-down` alert instead — proven // at the API layer by org-admin-health.integration.test.ts). await openConsole(page, orgId, "search-health"); const ok = page.getByTestId("search-health-ok"); await expect(ok).toBeVisible({ timeout: 20_000 }); await expect(ok).toContainText("Index reachable"); for (const label of ["Source docs", "Index docs", "Drift", "Indexing lag"]) { await expect(page.getByText(label, { exact: true })).toBeVisible(); } await expect(page.getByTestId("search-health-reindex")).toBeVisible(); // NOTIFICATIONS (T-178): the derived delivery stats + the (empty) failed // email list. await openConsole(page, orgId, "notifications-health"); for (const label of ["In-app total", "In-app unread", "Email queued (cluster)", "Email failed (org)"]) { await expect(page.getByText(label, { exact: true })).toBeVisible({ timeout: 20_000 }); } await expect(page.getByText("Recent failed email jobs")).toBeVisible(); // API contract the tabs read (the server-authoritative shape). const jobs = await api("GET", `/orgs/${orgId}/admin/jobs`, ownerToken); expect(jobs.status).toBe(200); expect(jobs.json.scope).toBe("cluster"); expect((jobs.json.queues as Array<{ name: string }>).map((q) => q.name)).toEqual( ["files", "search-index", "webhooks", "automations", "email", "ai", "maintenance"] ); const sh = await api("GET", `/orgs/${orgId}/admin/search/health`, ownerToken); expect(sh.status).toBe(200); expect(sh.json.healthy).toBe(true); await page.close(); }); test("Part 6: the Analytics tab (Phase 8) shows org-scoped usage in the console", async ({ browser }) => { const ownerEmail = `org-analytic-${RUN}@factory.local`; const ownerPassword = `Pw!${RUN}-analytic`; const ownerToken = await createUser("Org Analytics", ownerEmail, ownerPassword); const { orgId, teamWsId } = await setupOrg(ownerToken); // Two real pages in the org's workspace → the analytics page counts + a // `page.created` audit row (org-scoped via the workspace relation). for (const title of ["Analytics E2E A", "Analytics E2E B"]) { const p = await api("POST", `/workspaces/${teamWsId}/pages`, ownerToken, { title, type: "page" }); expect(p.status, `create ${title}`).toBe(201); } // API contract (server-authoritative, the tab's data source). const analytics = await api("GET", `/orgs/${orgId}/admin/analytics`, ownerToken); expect(analytics.status).toBe(200); const data = (analytics.json as { data: Record }).data; expect((data as { workspaces: number }).workspaces).toBe(1); expect(((data as { pages: Record }).pages as Record).page).toBe(2); expect(((data as { trends: { newPages: unknown[] } }).trends as { newPages: unknown[] }).newPages.length).toBe(30); // A non-admin member of the org cannot read analytics (403, server-side). const memberEmail = `org-analytic-member-${RUN}@factory.local`; const memberPassword = `Pw!${RUN}-member`; const memberToken = await createUser("Org Analytics Member", memberEmail, memberPassword); const prisma = await newPrisma(); const memberUser = await prisma.user.findUnique({ where: { email: memberEmail } }); await prisma.organizationMember.upsert({ where: { organizationId_userId: { organizationId: orgId, userId: memberUser!.id } }, update: { role: "MEMBER" }, create: { organizationId: orgId, userId: memberUser!.id, role: "MEMBER" } }); await prisma.$disconnect(); cleanups.push(async () => { const pc = await newPrisma(); await pc.user.deleteMany({ where: { id: memberUser!.id } }); await pc.$disconnect(); }); const denied = await api("GET", `/orgs/${orgId}/admin/analytics`, memberToken); expect(denied.status).toBe(403); expect(reasonOf(denied.json)).toBe("requires_org_admin"); // The browser tab renders the org-scoped numbers. const page = await (await browser.newContext()).newPage(); await browserLogin(page, ownerEmail, ownerPassword); await openConsole(page, orgId, "analytics"); await expect(page.getByRole("heading", { name: "Content mix" })).toBeVisible({ timeout: 20_000 }); // The page-kind row for the two seeded pages. await expect(page.getByText("page", { exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "New pages (30 days)" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Top audit actions (30 days)" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Storage growth (30 days)" })).toBeVisible(); await page.close(); }); });