/** * TASK-0133 — org admin console API integration. * * Against the real API + Postgres + Redis (Testcontainers, the production * AppModule): the `requireOrgAdmin` guard matrix (owner/admin pass, member/ * guest/non-member/cross-org 403 `requires_org_admin`), the org POLICY editor * (+ the five enforcement points it feeds), the org-scoped AUDIT viewer * (filters + pagination + deep-link + the sync CSV export), and RETENTION * (policies CRUD + the run-now enqueue/poll seam). * * Org members beyond the creator (ADMIN/MEMBER/GUEST) are seeded via direct * Prisma — the only public path is the mail-gated invite flow, which an * automated test cannot complete (recorded test-seam rule, helpers/seed.ts). * * The async CSV export (>10k) + the worker retention purge are covered by the * S3-backed spec (skipped without MinIO) + the worker unit/integration, so * this file exercises the SYNC path (≤10k) and the enqueue seam (202 runId). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { authHeaders, createUser, createWorkspace } from "./helpers/seed"; import type { PrismaClient } from "@prisma/client"; let app: TestAppHandle; const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; /** The canonical envelope puts `reason` at `error.details[0].reason`. */ const reasonOf = (body: Record): string | undefined => ((body.error as { details?: Array<{ reason?: string }> })?.details?.[0]?.reason); /** Create an org through the API; the caller becomes the OWNER member. */ async function makeOrg(tok: string) { const res = await app.request("POST", "/api/v1/orgs", { headers: authHeaders(tok), body: { name: `Org ${Date.now()}-${Math.random().toString(36).slice(2, 7)}` } }); const body = (await json(res)) as { id?: string; organization?: { id?: string } }; const orgId = body.id ?? body.organization?.id; if (!orgId) throw new Error(`makeOrg failed: ${res.status} ${await res.text()}`); return orgId; } /** Seed an org member with a given role (direct DB — invites are mail-gated). */ async function addOrgMember(prisma: PrismaClient, orgId: string, userId: string, role: "ADMIN" | "MEMBER" | "GUEST") { await prisma.organizationMember.upsert({ where: { organizationId_userId: { organizationId: orgId, userId } }, update: { role }, create: { organizationId: orgId, userId, role } }); } describe("TASK-0133 org-admin console API", () => { beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); it("guard matrix: owner + admin pass; member / guest / non-member / cross-org → 403 requires_org_admin", async () => { const owner = await createUser(app.request); const orgId = await makeOrg(owner.accessToken!); const admin = await createUser(app.request); const member = await createUser(app.request); const guest = await createUser(app.request); const outsider = await createUser(app.request); await addOrgMember(app.prisma, orgId, admin.userId, "ADMIN"); await addOrgMember(app.prisma, orgId, member.userId, "MEMBER"); await addOrgMember(app.prisma, orgId, guest.userId, "GUEST"); const path = `/api/v1/orgs/${orgId}/admin/overview`; const ok = await app.request("GET", path, { headers: authHeaders(owner.accessToken!) }); expect(ok.status).toBe(200); const okAdmin = await app.request("GET", path, { headers: authHeaders(admin.accessToken!) }); expect(okAdmin.status).toBe(200); for (const [u, label] of [ [member, "member"], [guest, "guest"], [outsider, "non-member"] ] as const) { const r = await app.request("GET", path, { headers: authHeaders(u.accessToken!) }); expect(r.status, label).toBe(403); expect(reasonOf(await json(r)), label).toBe("requires_org_admin"); } // Cross-org: the admin of ANOTHER org cannot touch this one. const otherOrg = await makeOrg(admin.accessToken!); const cross = await app.request("GET", `/api/v1/orgs/${orgId}/admin/overview`, { headers: authHeaders(admin.accessToken!) }); expect(cross.status).toBe(200); // admin IS a member of orgId void otherOrg; // A brand-new org's owner is NOT a member of orgId. const stranger = await createUser(app.request); await makeOrg(stranger.accessToken!); const crossOrg = await app.request("GET", path, { headers: authHeaders(stranger.accessToken!) }); expect(crossOrg.status).toBe(403); expect(reasonOf(await json(crossOrg))).toBe("requires_org_admin"); // Anonymous → 401 (the global auth guard precedes the org-admin gate). const anon = await app.request("GET", path); expect(anon.status).toBe(401); }); // Regression: the audit-viewer routes (GET audit, GET audit/actions, // POST audit/export, GET audit/exports/:id) were once reachable by any // org MEMBER — the controller imported `assertOrgAdmin` but never called // it, and `OrgAuditService` didn't gate. The gate lives in the service // now (like every other org-admin service); this test proves it. it("audit viewer routes are admin-only (member/guest/non-member → 403, owner → 200)", async () => { const owner = await createUser(app.request); const orgId = await makeOrg(owner.accessToken!); const member = await createUser(app.request); const outsider = await createUser(app.request); await addOrgMember(app.prisma, orgId, member.userId, "MEMBER"); // Give the org one audited row so the owner's read/export return 200. const ws = await app.prisma.workspace.create({ data: { name: "audit gate ws", slug: `audit-gate-${orgId.slice(0, 8)}` } }); await app.prisma.workspace.update({ where: { id: ws.id }, data: { organizationId: orgId } }); await app.prisma.auditLog.create({ data: { actorType: "USER", actorId: owner.userId, workspaceId: ws.id, resourceType: "workspace", resourceId: ws.id, action: "workspace.created" } }); const auditRoutes: Array<[string, string, Record?]> = [ ["GET", `/api/v1/orgs/${orgId}/admin/audit`], ["GET", `/api/v1/orgs/${orgId}/admin/audit/actions`], ["POST", `/api/v1/orgs/${orgId}/admin/audit/export`, {}], ["GET", `/api/v1/orgs/${orgId}/admin/audit/exports/does-not-exist`] ]; for (const [u, label] of [ [member, "member"], [outsider, "non-member"] ] as const) { for (const [method, route, body] of auditRoutes) { const r = await app.request(method, route, { headers: authHeaders(u.accessToken!), data: body }); expect(r.status, `${label} ${method} ${route}`).toBe(403); expect(reasonOf(await json(r)), `${label} ${method} ${route}`).toBe("requires_org_admin"); } } // The owner passes every audit route. for (const [method, route, body] of auditRoutes) { const r = await app.request(method, route, { headers: authHeaders(owner.accessToken!), data: body }); // `exports/does-not-exist` → 404 (the gate passes, the row is absent); // the read + actions + sync export → 200. const expected = route.endsWith("does-not-exist") ? 404 : 200; expect(r.status, `owner ${method} ${route}`).toBe(expected); } }); it("policy: GET returns the permissive baseline; PATCH merges + audits + invalidates", async () => { const owner = await createUser(app.request); const orgId = await makeOrg(owner.accessToken!); const h = authHeaders(owner.accessToken!); const base = await app.request("GET", `/api/v1/orgs/${orgId}/admin/policy`, { headers: h }); expect(base.status).toBe(200); const baseBody = await json(base); expect((baseBody.policy as Record).require2FA).toBe(false); expect((baseBody.policy as Record).allowExternalEmbeds).toBe(true); const patch = await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/policy`, { headers: h, body: { disableGuestPublicShare: true, allowExternalEmbeds: false, maxFileSizeMB: 20 } }); expect(patch.status).toBe(200); const patchBody = await json(patch); const policy = patchBody.policy as Record; expect(policy.disableGuestPublicShare).toBe(true); expect(policy.allowExternalEmbeds).toBe(false); expect(policy.maxFileSizeMB).toBe(20); // The audit carries the diff (changed keys only). const row = await app.prisma.auditLog.findFirst({ where: { action: "org.policy.updated", resourceId: orgId }, orderBy: { createdAt: "desc" } }); expect(row).toBeTruthy(); const meta = row!.metadata as Record; expect((meta.changes as Record)["allowExternalEmbeds"]).toBeTruthy(); }); it("enforcement #2: disableGuestPublicShare blocks a guest (anyone) public share (403)", async () => { const owner = await createUser(app.request); const orgId = await makeOrg(owner.accessToken!); const h = authHeaders(owner.accessToken!); await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/policy`, { headers: h, body: { disableGuestPublicShare: true } }); // A workspace in the org (the owner's personal ws is NOT in the org — // create one and attach it via the org's primary workspace plan is overkill; // use a fresh workspace the owner owns, then set its organizationId). const ws = await createWorkspace(app.request, h); await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { organizationId: orgId } }); // Create a page to share. const page = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "shareable" } }); const pageId = ((await json(page)) as { id?: string }).id; expect(pageId).toBeTruthy(); const share = await app.request("POST", `/api/v1/shares`, { headers: h, body: { workspaceId: ws.workspaceId, targetType: "page", targetId: pageId!, kind: "public" } }); expect(share.status).toBe(403); expect(reasonOf(await json(share))).toBe("guest_public_share_disabled"); }); it("enforcement #5: allowExternalEmbeds=false blocks the embed preview (403, before any fetch)", async () => { // The policy gate fires BEFORE the SSRF-guarded fetch, so this is // hermetic (no network). The ALLOW path (200 + fetched title) requires a // genuinely public URL (the SSRF guard blocks loopback/private), which is // covered by the E2E + the SSRF-guard unit tests, not here. const owner = await createUser(app.request); const orgId = await makeOrg(owner.accessToken!); const h = authHeaders(owner.accessToken!); await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/policy`, { headers: h, body: { allowExternalEmbeds: false } }); const ws = await createWorkspace(app.request, h); await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { organizationId: orgId } }); const preview = await app.request( "GET", `/api/v1/workspaces/${ws.workspaceId}/embeds/preview?url=${encodeURIComponent("https://example.com")}`, { headers: { ...h, "x-factory-workspace": ws.workspaceId } } ); expect(preview.status).toBe(403); expect(reasonOf(await json(preview))).toBe("external_embeds_disabled"); }); it("enforcement #3: maxFileSizeMB caps the presign size (413 when over the org cap)", async () => { const owner = await createUser(app.request); const orgId = await makeOrg(owner.accessToken!); const h = authHeaders(owner.accessToken!); // free plan = 50MB; the org cap of 20MB is tighter. await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/policy`, { headers: h, body: { maxFileSizeMB: 20 } }); const ws = await createWorkspace(app.request, h); await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { organizationId: orgId } }); const over = 25 * 1024 * 1024; // 25MB > 20MB org cap const create = await app.request("POST", `/api/v1/files/uploads`, { headers: { ...h, "x-factory-workspace": ws.workspaceId }, body: { name: "big.bin", size: over, mimeType: "application/octet-stream" } }); expect(create.status).toBe(413); expect(((await json(create)).error as { code?: string })?.code).toBe("FILE_TOO_LARGE"); }); it("enforcement #1: require2FA gates login (202 twoFactorRequired + verificationId)", async () => { const owner = await createUser(app.request); const orgId = await makeOrg(owner.accessToken!); const h = authHeaders(owner.accessToken!); await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/policy`, { headers: h, body: { require2FA: true } }); // A second login by the SAME user now requires the 2FA step. const login = await app.request("POST", "/api/v1/auth/login", { body: { email: owner.email, password: owner.password } }); expect(login.status).toBe(202); const body = (await json(login)) as { twoFactorRequired?: boolean; reason?: string; verificationId?: string }; expect(body.twoFactorRequired).toBe(true); expect(body.reason).toBe("2fa_required"); expect(body.verificationId).toBeTruthy(); }); it("audit viewer: filters + pagination + org scoping + deep-link map", async () => { const owner = await createUser(app.request); const orgId = await makeOrg(owner.accessToken!); const h = authHeaders(owner.accessToken!); const ws = await createWorkspace(app.request, h); await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { organizationId: orgId } }); // Generate some audited activity (page create/updated → audited). const page = await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "audited" } }); const pageId = ((await json(page)) as { id?: string }).id; await app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: { ...h, "x-factory-workspace": ws.workspaceId }, body: { title: "audited-2" } }); const list = await app.request("GET", `/api/v1/orgs/${orgId}/admin/audit`, { headers: h }); expect(list.status).toBe(200); const body = (await json(list)) as { data: unknown[]; total: number; page: number; pageSize: number }; expect(body.page).toBe(1); expect(body.pageSize).toBe(50); expect(body.total).toBeGreaterThan(0); const actions = body.data.map((r) => (r as { action: string }).action); expect(actions).toContain("page.created"); // Filter by action narrows the set. const filtered = await app.request("GET", `/api/v1/orgs/${orgId}/admin/audit?action=page.created`, { headers: h }); const fbody = (await json(filtered)) as { data: Array<{ action: string }> }; expect(fbody.data.every((r) => r.action === "page.created")).toBe(true); // Invalid page size → 400. const bad = await app.request("GET", `/api/v1/orgs/${orgId}/admin/audit?pageSize=1000`, { headers: h }); expect(bad.status).toBe(400); }); it("audit export: sync (≤10k) → 200 text/csv with the 13-column header", async () => { const owner = await createUser(app.request); const orgId = await makeOrg(owner.accessToken!); const h = authHeaders(owner.accessToken!); const ws = await createWorkspace(app.request, h); await app.prisma.workspace.update({ where: { id: ws.workspaceId }, data: { organizationId: orgId } }); await app.request("POST", `/api/v1/workspaces/${ws.workspaceId}/pages`, { headers: h, body: { title: "csv-row" } }); const res = await app.request("POST", `/api/v1/orgs/${orgId}/admin/audit/export`, { headers: h, body: {} }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("text/csv"); const csv = await res.text(); const firstLine = csv.split("\n")[0]; expect(firstLine).toBe( "id,created_at,actor_type,actor_id,actor_name,workspace_id,workspace_name,resource_type,resource_id,action,ip_address,user_agent,metadata" ); // At least the created page row is present. expect(csv).toContain("page.created"); }); it("retention: GET returns 4 scopes with defaults; PATCH upserts + audits; run-now enqueues 202 runId", async () => { const owner = await createUser(app.request); const orgId = await makeOrg(owner.accessToken!); const h = authHeaders(owner.accessToken!); const list = await app.request("GET", `/api/v1/orgs/${orgId}/admin/retention`, { headers: h }); expect(list.status).toBe(200); const body = (await json(list)) as { data: Array<{ scope: string; type: string; retainDays: number }> }; expect(body.data.map((s) => s.scope).sort()).toEqual(["audit_logs", "comments", "files", "trash"]); const trash = body.data.find((s) => s.scope === "trash")!; expect(trash.type).toBe("PURGE"); expect(trash.retainDays).toBe(30); const patch = await app.request("PATCH", `/api/v1/orgs/${orgId}/admin/retention`, { headers: h, body: { trash: { type: "HOLD", retainDays: 60 } } }); expect(patch.status).toBe(200); const after = (await json(await app.request("GET", `/api/v1/orgs/${orgId}/admin/retention`, { headers: h }))) as { data: Array<{ scope: string; type: string; retainDays: number }> }; expect(after.data.find((s) => s.scope === "trash")!.type).toBe("HOLD"); expect(after.data.find((s) => s.scope === "trash")!.retainDays).toBe(60); // run-now: enqueues the worker job → 202 {runId, status:"processing"}. const run = await app.request("POST", `/api/v1/orgs/${orgId}/admin/retention/run`, { headers: h, body: { scope: "trash", mode: "dry" } }); expect(run.status).toBe(202); const runBody = (await json(run)) as { runId?: string; status?: string }; expect(runBody.runId).toBeTruthy(); expect(runBody.status).toBe("processing"); // The poll endpoint returns the seeded processing report (orgId-scoped). const status = await app.request("GET", `/api/v1/orgs/${orgId}/admin/retention/runs/${runBody.runId}`, { headers: h }); expect(status.status).toBe(200); const statusBody = (await json(status)) as Record; expect(statusBody.scope).toBe("trash"); expect(statusBody.mode).toBe("dry"); }); // Phase 8 — org ANALYTICS endpoint (read-only, org-scoped, admin-only). it("analytics: owner 200 with org-scoped counts + 30-day trends; member/guest/non-member 403; anon 401", async () => { const owner = await createUser(app.request); const orgId = await makeOrg(owner.accessToken!); const h = authHeaders(owner.accessToken!); const member = await createUser(app.request); const guest = await createUser(app.request); const outsider = await createUser(app.request); await addOrgMember(app.prisma, orgId, member.userId, "MEMBER"); await addOrgMember(app.prisma, orgId, guest.userId, "GUEST"); // Bind a real workspace to the org (the API's org-binding is invite-gated; // direct Prisma is the recorded test seam — same as addOrgMember above). const { workspaceId } = await createWorkspace(app.request, h, `Analytics WS ${Date.now()}`); await app.prisma.workspace.update({ where: { id: workspaceId }, data: { organizationId: orgId } }); // Two pages via the public API → page counts + a real `page.created` audit // row scoped to the org's workspace (proves the audit trend is org-scoped). for (const title of ["Analytics A", "Analytics B"]) { const p = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: h, body: { title, type: "page" } }); expect(p.status).toBe(201); } const res = await app.request("GET", `/api/v1/orgs/${orgId}/admin/analytics`, { headers: h }); expect(res.status).toBe(200); const body = (await json(res)) as { data: { workspaces: number; members: number; membersByRole: Record; pages: Record; pagesTotal: number; newPages: { last30d: number; last7d: number }; audit: { last30d: number; topActions: Array<{ action: string; count: number }> }; trends: { newPages: Array<{ day: string; count: number }>; storage: Array<{ day: string; bytes: number }>; members: Array<{ day: string; count: number }>; audit: Array<{ day: string; count: number }> } } }; expect(body.data.workspaces).toBe(1); expect(body.data.members).toBe(3); // owner + member + guest expect(body.data.membersByRole.OWNER).toBe(1); expect(body.data.membersByRole.MEMBER).toBe(1); expect(body.data.pages.page).toBe(2); expect(body.data.pagesTotal).toBe(2); expect(body.data.newPages.last30d).toBe(2); expect(body.data.newPages.last7d).toBe(2); expect(body.data.audit.last30d).toBeGreaterThanOrEqual(1); // the page.created rows expect(body.data.audit.topActions.some((a) => a.action === "page.created")).toBe(true); // 30-day zero-filled buckets, ISO day keys. for (const arr of [body.data.trends.newPages, body.data.trends.storage, body.data.trends.members, body.data.trends.audit]) { expect(arr.length).toBe(30); expect(arr[0].day).toMatch(/^\d{4}-\d{2}-\d{2}$/); } // Non-admin org members → 403 requires_org_admin; anonymous → 401. for (const [u, label] of [[member, "member"], [guest, "guest"], [outsider, "non-member"]] as const) { const r = await app.request("GET", `/api/v1/orgs/${orgId}/admin/analytics`, { headers: authHeaders(u.accessToken!) }); expect(r.status, label).toBe(403); expect(reasonOf(await json(r)), label).toBe("requires_org_admin"); } expect((await app.request("GET", `/api/v1/orgs/${orgId}/admin/analytics`)).status).toBe(401); // Unknown org → 404 (the guard's notFound branch). expect((await app.request("GET", "/api/v1/orgs/does-not-exist/admin/analytics", { headers: h })).status).toBe(404); }); });