/** * TASK-0132 — SCIM 2.0 E2E (the phase exit criterion). * * Real two-origin dev stack: API :4000 (must run with SSO_ORIGIN= * http://localhost:4000), web :3000. The "IdP" is a plain fetch client * (an SCIM client library is just HTTP) holding the reveal-once bearer * token obtained from the admin API. * * Covers (per the task's acceptance criteria): * 1. LIFECYCLE (browser + SCIM client): * admin enables SCIM → SCIM client creates a user WITH a password → * BROWSER login with the pushed password succeeds → SCIM client * PATCHes `active:false` (deactivation) → the in-flight browser session * is rejected (403 on the next API call) AND a fresh browser login is * refused with the `account_suspended` error. * 2. ADMIN UI (ScimCard): enable → reveal-once token panel + copy, * test-connection (pasted token → green panel), rotate → new token, * disable → confirm dialog → empty state; the protocol 401s a * disabled token immediately. * * Cleanup: orgs/workspaces/users are pruned via Prisma (there is no user/ * org delete endpoint in this phase). */ import { test, expect, request } from "@playwright/test"; import type { PrismaClient } from "@prisma/client"; const API = process.env.E2E_API_URL ?? "http://localhost:4000"; const WEB = process.env.E2E_BASE_URL ?? "http://localhost:3000"; 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 { PrismaClient: PC } = await import("@prisma/client"); const prisma = new PC({ datasources: { db: { url: DSN } } }); for (const fn of cleanups.splice(0).reverse()) { try { await fn(); } catch { /* best-effort */ } } await prisma.$disconnect(); }); async function prisma(): Promise { const { PrismaClient: PC } = await import("@prisma/client"); return new PC({ datasources: { db: { url: DSN } } }); } /** Register + login a fresh owner via the API; returns the access token. */ async function createOwner(): Promise<{ email: string; password: string; token: string }> { const email = `scim-owner-${RUN}-${Math.random().toString(36).slice(2, 6)}@factory.local`; const password = `Pw!${RUN}${Date.now() % 100000}`; const ctx = await request.newContext(); const reg = await ctx.post(`${API}/api/v1/auth/register`, { data: { name: "Scim Owner", email, password } }); expect(reg.status(), "register owner").toBe(201); const login = await ctx.post(`${API}/api/v1/auth/login`, { data: { email, password } }); expect(login.status(), "login owner").toBe(200); const token = ((await login.json()) as { accessToken: string }).accessToken; await ctx.dispose(); return { email, password, token }; } /** Create an org + a team workspace linked to it (Prisma interim — TASK-0133 owns the route). */ async function setupOrg(token: string): Promise<{ orgId: string; wsId: string }> { const orgRes = (await ( await fetch(`${API}/api/v1/orgs`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ name: `scim-e2e-${RUN}-${Math.random().toString(36).slice(2, 6)}` }) }) ).json()) as { id: string }; const wsRes = (await ( await fetch(`${API}/api/v1/workspaces`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${token}` }, body: JSON.stringify({ name: `Scim WS ${RUN}` }) }) ).json()) as { id: string }; const p = await prisma(); await p.workspace.update({ where: { id: wsRes.id }, data: { organizationId: orgRes.id } }); await p.$disconnect(); cleanups.push(async () => { const pc = await prisma(); await pc.$transaction([ pc.organizationMember.deleteMany({ where: { organizationId: orgRes.id } }), pc.workspaceMember.deleteMany({ where: { workspaceId: wsRes.id } }), pc.workspace.delete({ where: { id: wsRes.id } }), pc.organization.delete({ where: { id: orgRes.id } }) ]); await pc.$disconnect(); }); return { orgId: orgRes.id, wsId: wsRes.id }; } /** Enable SCIM via the admin API; returns the reveal-once bearer token. */ async function enableScim(token: string, orgId: string, userEmailDomain = "scim-e2e.local"): 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); const body = (await res.json()) as { token: string }; return body.token; } /** 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, ifMatch?: string) { const res = await fetch(`${API}/scim/v2${path}`, { method, headers: { authorization: `Bearer ${this.scimToken}`, "content-type": "application/scim+json", ...(ifMatch ? { "if-match": ifMatch } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined }); const json = (await res.json().catch(() => ({}))) as Record; return { status: res.status, json, headers: res.headers }; } createUserName = `scim.user.${RUN}@scim-e2e.local`; create(email: string, password: string) { return this.call("POST", "/Users", { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], userName: email, name: { givenName: "Scim", familyName: `E2E ${RUN}` }, password, active: true }); } etag(id: string) { return this.call("GET", `/Users/${id}`).then((r) => r.headers.get("etag") as string | null); } deactivate(id: string, ifMatch: string) { return this.call("PATCH", `/Users/${id}`, { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [{ op: "replace", path: "active", value: false }] }, ifMatch); } list(filter?: string) { return this.call("GET", `/Users${filter ? `?filter=${encodeURIComponent(filter)}` : ""}`); } } test.describe("TASK-0132 SCIM", () => { test("lifecycle: SCIM create (with password) → browser login → SCIM deactivation blocks in-flight + fresh login", async ({ page, browser }) => { const owner = await createOwner(); const { orgId } = await setupOrg(owner.token); const scimToken = await enableScim(owner.token, orgId); const client = new ScimClient(scimToken); // 1. The SCIM client provisions a user WITH a password. const created = await client.create(client.createUserName, "Scim-Pw-12345!"); expect(created.status, `create user (${JSON.stringify(created.json)})`).toBe(201); const userId = created.json.id as string; // Best-effort user cleanup (the org cleanup above removes only the // membership link, not the User row the SCIM client provisioned). cleanups.push(async () => { const pc = await prisma(); await pc.user.deleteMany({ where: { id: userId } }); await pc.$disconnect(); }); expect(created.json.userName).toBe(client.createUserName); expect(created.headers.get("location") ?? "").toContain("/scim/v2/Users/"); // 2. BROWSER login with the pushed password succeeds (real session). // Capture the SCIM user's access token from the /auth/login response — // the web keeps it in JS memory (not a cookie), so it IS the in-flight // session credential we assert on in step 4a. let accessToken: string | null = null; page.on("response", async (res) => { if (res.url().includes("/api/v1/auth/login") && res.status() === 200) { try { accessToken = ((await res.json()) as { accessToken?: string }).accessToken ?? null; } catch { /* non-JSON */ } } }); await page.goto(`${WEB}/login`); await page.getByTestId("auth-email").fill(client.createUserName); await page.getByTestId("auth-password").fill("Scim-Pw-12345!"); await page.getByTestId("auth-submit").click(); // Lands in a workspace (the SCIM user auto-joined the org workspace). await expect(page).toHaveURL(/\/w\//, { timeout: 20_000 }); // The access token was issued (the in-flight session credential). await expect .poll(() => accessToken, { timeout: 10_000, message: "login access token captured" }) .not.toBeNull(); // The in-flight session works (an API call with the access token). const me = await page.request.get(`${API}/api/v1/users/me`, { headers: { authorization: `Bearer ${accessToken}` } }); expect(me.status(), "in-flight session alive").toBe(200); // 3. SCIM client deactivates the user (replace active:false, with ETag). const etag = await client.etag(userId); expect(etag, "GET etag").toBeTruthy(); const patched = await client.deactivate(userId, etag!); expect(patched.status, `deactivate (${JSON.stringify(patched.json)})`).toBe(200); expect(patched.json.active).toBe(false); // 4a. The IN-FLIGHT session is now rejected (the AuthGuard fix). const blocked = await page.request.get(`${API}/api/v1/users/me`, { headers: { authorization: `Bearer ${accessToken}` } }); expect(blocked.status(), "in-flight session blocked after deactivation").toBe(403); const blockedBody = (await blocked.json()) as { error?: { details?: Array<{ reason?: string }> } }; expect(blockedBody.error?.details?.[0]?.reason).toBe("account_suspended"); // 4b. A FRESH browser login is refused with the visible error. Use a NEW // context (no cookies / no in-memory token) so the app actually shows the // login form — the original page still holds a (now-suspended) session. const freshCtx = await browser.newContext(); const freshPage = await freshCtx.newPage(); await freshPage.goto(`${WEB}/login`); const freshEmail = freshPage.getByTestId("auth-email"); await freshEmail.waitFor({ state: "visible", timeout: 18_000 }); await freshEmail.fill(client.createUserName); await freshPage.getByTestId("auth-password").fill("Scim-Pw-12345!"); await freshPage.getByTestId("auth-submit").click(); await expect(freshPage.getByTestId("auth-error")).toContainText("Account suspended.", { timeout: 15_000 }); await freshCtx.close(); // The DB row SURVIVES (deactivation, not deletion) + audit rows exist. const p = await prisma(); const row = await p.user.findUnique({ where: { id: userId } }); expect(row?.status).toBe("SUSPENDED"); expect(await p.organizationMember.findUnique({ where: { organizationId_userId: { organizationId: orgId, userId } } })).not.toBeNull(); const audit = await p.auditLog.findMany({ where: { action: { in: ["scim.user.created", "scim.user.deactivated"] }, actorId: orgId } }); expect(audit.some((a) => a.action === "scim.user.created" && a.actorType === "INTEGRATION")).toBe(true); expect(audit.some((a) => a.action === "scim.user.deactivated" && a.actorType === "INTEGRATION")).toBe(true); await p.$disconnect(); // The deactivated user appears in the `active eq false` SCIM list. const list = await client.list("active eq false"); expect(list.status).toBe(200); const resources = (list.json.Resources as Array<{ id: string }>) ?? []; expect(resources.some((r) => r.id === userId)).toBe(true); }); test("admin UI: enable → reveal-once token + copy, test-connection, rotate, disable", async ({ page, context }) => { // Headless Chromium needs the clipboard permission granted for the // copy button (navigator.clipboard.writeText). await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: WEB }); const owner = await createOwner(); const { orgId } = await setupOrg(owner.token); // Log in through the BROWSER context (refresh cookie in its jar). const loginRes = await page.request.post(`${API}/api/v1/auth/login`, { data: { email: owner.email, password: owner.password } }); expect(loginRes.status(), "login owner").toBe(200); await page.goto(`${WEB}/org/${orgId}/admin/integrations`); await expect(page.getByTestId("scim-enable")).toBeVisible({ timeout: 15_000 }); // Fill the enable form + enable. await page.getByTestId("scim-user-email-domain").fill("scim-ui.local"); await page.getByTestId("scim-default-role").selectOption("GUEST"); await page.getByTestId("scim-auto-join").uncheck(); await page.getByTestId("scim-enable").click(); // Reveal-once token panel + copy. const tokenPanel = page.getByTestId("scim-token-panel"); await expect(tokenPanel).toBeVisible({ timeout: 15_000 }); const tokenEl = page.getByTestId("scim-token"); const rawToken = ((await tokenEl.innerText()) ?? "").trim(); expect(rawToken, "token shape").toMatch(/^scim_[A-Za-z0-9_-]{32,}$/); await page.getByTestId("scim-copy-token").click(); await expect(page.getByTestId("scim-copy-token")).toContainText("Copied", { timeout: 5_000 }); // Enabled summary: endpoint + curl example + email domain. await expect(page.getByTestId("scim-endpoint")).toContainText("/scim/v2"); await expect(page.getByTestId("scim-curl")).toContainText("Bearer"); await expect(page.getByText("Guest")).toBeVisible(); // Test-connection: the token field is pre-filled with the revealed token. await expect(page.getByTestId("scim-test-token")).toHaveValue(rawToken, { timeout: 5_000 }); await page.getByTestId("scim-test").click(); await expect(page.getByTestId("scim-test-result")).toContainText("Connected", { timeout: 20_000 }); // Test-connection with a WRONG token → red panel. await page.getByTestId("scim-test-token").fill("scim_wrong_token_0000000000"); await page.getByTestId("scim-test").click(); await expect(page.getByTestId("scim-test-result")).toContainText("Failed", { timeout: 20_000 }); // Rotate → a NEW reveal-once token; the old one stops working (401). const oldToken = rawToken; await page.getByTestId("scim-rotate").click(); await expect(page.getByTestId("scim-token-panel")).toBeVisible({ timeout: 15_000 }); const newToken = ((await page.getByTestId("scim-token").innerText()) ?? "").trim(); expect(newToken).not.toBe(oldToken); const oldProbe = await fetch(`${API}/scim/v2/Users?count=1`, { headers: { authorization: `Bearer ${oldToken}` } }); expect(oldProbe.status, "old token invalid after rotate").toBe(401); const newProbe = await fetch(`${API}/scim/v2/Users?count=1`, { headers: { authorization: `Bearer ${newToken}` } }); expect(newProbe.status, "new token works").toBe(200); // Disable → confirm dialog → empty state; the protocol 401s immediately. await page.getByTestId("scim-disable").click(); await page.getByRole("dialog").getByRole("button", { name: "Disable" }).click(); await expect(page.getByTestId("scim-enable")).toBeVisible({ timeout: 15_000 }); await expect(page.getByTestId("scim-token-panel")).toHaveCount(0); const deadProbe = await fetch(`${API}/scim/v2/Users?count=1`, { headers: { authorization: `Bearer ${newToken}` } }); expect(deadProbe.status, "token invalid after disable").toBe(401); await context.clearCookies(); }); });