/** * TASK-0132 — SCIM 2.0 integration (real API + Postgres + Redis). * * The five acceptance matrix groups: * 1. token auth — wrong token 401 SCIM shape; disabled token 401 * (immediate, cache invalidation); two orgs → each scoped (cross-org * reads 404, not a leak). * 2. user CRUD — create (201 + Location + `@`-less email mapping), * duplicate 409, GET by id, list `active eq false` (exact set), * pagination (105 users, count=50 → 3 pages, totalResults=105), * PATCH `replace emails` (ETag changes), stale If-Match → 412, * DELETE deactivates (not deletes). * 3. groups — create with 2 members → team in the primary workspace; * PATCH adds a member (email resolution); DELETE removes the team, * users keep workspace membership. * 4. bad filters — `neq` → 400 invalidFilter; empty → 400. * 5. audit — scim.user.created / scim.user.deactivated rows, * actorType INTEGRATION. * * The org's workspace is linked to the org via direct Prisma (no API route * sets `Workspace.organizationId` — TASK-0133 owns that; same interim * approach as the SSO E2E). */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { authHeaders, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; let ownerToken: string; let orgId: string; let scimToken: string; let workspaceId: string; const j = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; const scimHeaders = (token: string) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/scim+json" }); /** Create an org (owner) + link a workspace to it (Prisma interim). */ async function setupOrg(): Promise<{ orgId: string; wsId: string }> { const res = await app.request("POST", "/api/v1/orgs", { headers: authHeaders(ownerToken), body: { name: `SCIM Org ${Date.now()}` } }); const org = (await j(res)) as { id: string }; const { workspaceId: wsId } = await createWorkspace(app.request, authHeaders(ownerToken), `SCIM WS ${Date.now()}`); await app.prisma.workspace.update({ where: { id: wsId }, data: { organizationId: org.id } }); return { orgId: org.id, wsId }; } /** Enable SCIM for an org via the admin API → the reveal-once token. */ async function enableScim(orgId: string, userEmailDomain?: string): Promise { const res = await app.request("POST", `/api/v1/orgs/${orgId}/scim`, { headers: authHeaders(ownerToken), body: userEmailDomain ? { userEmailDomain } : {} }); if (res.status !== 201 && res.status !== 200) throw new Error(`enable scim failed (${res.status}): ${await res.text()}`); return (await j(res)).token as string; } describe("TASK-0132 SCIM 2.0", () => { beforeAll(async () => { app = await createTestApp(); const owner = await createUser(app.request); ownerToken = owner.accessToken!; ({ orgId, wsId: workspaceId } = await setupOrg()); scimToken = await enableScim(orgId, "scim-integ.test"); }, 180_000); afterAll(async () => { await app?.close(); }); it("1. token auth: missing / wrong / unknown → 401 SCIM error shape", async () => { const missing = await app.request("GET", "/scim/v2/Users", { headers: { "Content-Type": "application/scim+json" } }); expect(missing.status).toBe(401); expect(missing.headers.get("content-type") ?? "").toContain("application/scim+json"); const mBody = await j(missing); expect(mBody.schemas).toEqual(["urn:ietf:params:scim:api:errors:2.0"]); expect(mBody.status).toBe("401"); expect(mBody.scimType).toBe("invalidCredentials"); const wrong = await app.request("GET", "/scim/v2/Users", { headers: scimHeaders("scim_wrong_token_here_000000") }); expect(wrong.status).toBe(401); expect((await j(wrong)).scimType).toBe("invalidCredentials"); const notScim = await app.request("GET", "/scim/v2/Users", { headers: { Authorization: `Bearer ${ownerToken}` } }); expect(notScim.status).toBe(401); // a user JWT is not a SCIM token }); it("2a. create: 201 + Location + ETag + SCIM representation (email mapping)", async () => { const res = await app.request("POST", "/scim/v2/Users", { headers: scimHeaders(scimToken), body: { schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"], userName: "jane.scim", name: { givenName: "Jane", familyName: "Scim" }, password: "initial-secret-pw", active: true } }); expect(res.status).toBe(201); const location = res.headers.get("location"); expect(location).toMatch(/^\/scim\/v2\/Users\//); const etag = res.headers.get("etag"); expect(etag).toMatch(/^W\/"scim-[0-9a-f]{16}"$/); const body = await j(res); expect(body.schemas).toContain("urn:ietf:params:scim:schemas:core:2.0:User"); // @-less userName + userEmailDomain → mapped email expect(body.userName).toBe("jane.scim@scim-integ.test"); expect(body.active).toBe(true); expect(body.id).toBeTruthy(); expect(body).not.toHaveProperty("password"); const userId = body.id as string; // The User row: ACTIVE, verified, password set (hash, not raw). const row = await app.prisma.user.findUniqueOrThrow({ where: { id: userId } }); expect(row.email).toBe("jane.scim@scim-integ.test"); expect(row.status).toBe("ACTIVE"); expect(row.emailVerification).toBe("VERIFIED"); expect(row.passwordHash).toBeTruthy(); expect(row.passwordHash).not.toContain("initial-secret-pw"); // Org membership (MEMBER cap) + auto-join workspace. const orgMember = await app.prisma.organizationMember.findUnique({ where: { organizationId_userId: { organizationId: orgId, userId } } }); expect(orgMember?.role).toBe("MEMBER"); const wsMember = await app.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId } } }); expect(wsMember?.role).toBe("MEMBER"); // The user can LOG IN with the pushed password (lifecycle prerequisite). const login = await app.request("POST", "/api/v1/auth/login", { body: { email: "jane.scim@scim-integ.test", password: "initial-secret-pw" } }); expect(login.status).toBe(200); }); it("2b. create: duplicate userName → 409 duplicate", async () => { const res = await app.request("POST", "/scim/v2/Users", { headers: scimHeaders(scimToken), body: { userName: "jane.scim@scim-integ.test", active: true } }); expect(res.status).toBe(409); const body = await j(res); expect(body.scimType).toBe("duplicate"); expect(body.status).toBe("409"); }); it("2c. create: @-less userName without userEmailDomain → 400 invalidValue", async () => { const { orgId: org2 } = await setupOrg(); const token2 = await enableScim(org2); // NO userEmailDomain const res = await app.request("POST", "/scim/v2/Users", { headers: scimHeaders(token2), body: { userName: "nousername.domain", active: true } }); expect(res.status).toBe(400); expect((await j(res)).scimType).toBe("invalidValue"); }); it("2d. GET by id returns the representation + ETag; cross-org → 404", async () => { // Create a SCIM user whose email is NOT shared with org B's owner (the // org owner is auto-added as a member of EVERY org they create — a // cross-org test must use a user that belongs to exactly one org). const created = await app.request("POST", "/scim/v2/Users", { headers: scimHeaders(scimToken), body: { userName: "crossorg@scim-integ.test" } }); expect(created.status).toBe(201); const id = (await j(created)).id as string; const get = await app.request("GET", `/scim/v2/Users/${id}`, { headers: scimHeaders(scimToken) }); expect(get.status).toBe(200); expect((await j(get)).id).toBe(id); expect(get.headers.get("etag")).toBeTruthy(); // A different org's token cannot read this org's user (404, not 403). const { orgId: orgB } = await setupOrg(); const tokenB = await enableScim(orgB, "orgb.test"); const cross = await app.request("GET", `/scim/v2/Users/${id}`, { headers: scimHeaders(tokenB) }); expect(cross.status).toBe(404); expect((await j(cross)).detail).toBeTruthy(); // the SCIM error envelope }); it("2e. PATCH replace emails: updates + ETag changes; stale If-Match → 412", async () => { const created = await app.request("POST", "/scim/v2/Users", { headers: scimHeaders(scimToken), body: { userName: "patch-me@scim-integ.test", active: true } }); const cBody = await j(created); const id = cBody.id as string; const etag1 = (await app.request("GET", `/scim/v2/Users/${id}`, { headers: scimHeaders(scimToken) })).headers.get("etag")!; // Stale If-Match → 412. const stale = await app.request("PATCH", `/scim/v2/Users/${id}`, { headers: { ...scimHeaders(scimToken), "If-Match": `W/"scim-0000000000000000"` }, body: { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [{ op: "replace", path: "active", value: true }] } }); expect(stale.status).toBe(412); expect((await j(stale)).status).toBe("412"); // Current If-Match → 200 + new ETag + updated email. const patched = await app.request("PATCH", `/scim/v2/Users/${id}`, { headers: { ...scimHeaders(scimToken), "If-Match": etag1 }, body: { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [{ op: "replace", path: "emails", value: [{ value: "patched@scim-integ.test", primary: true }] }] } }); expect(patched.status).toBe(200); const etag2 = patched.headers.get("etag"); expect(etag2).toBeTruthy(); expect(etag2).not.toBe(etag1); const pBody = await j(patched); expect(pBody.userName).toBe("patched@scim-integ.test"); const row = await app.prisma.user.findUniqueOrThrow({ where: { id } }); expect(row.email).toBe("patched@scim-integ.test"); }); it("2f. DELETE deactivates (SUSPENDED), never deletes; membership kept", async () => { const created = await app.request("POST", "/scim/v2/Users", { headers: scimHeaders(scimToken), body: { userName: "delete-me@scim-integ.test", active: true } }); const id = (await j(created)).id as string; const del = await app.request("DELETE", `/scim/v2/Users/${id}`, { headers: scimHeaders(scimToken) }); expect(del.status).toBe(200); const row = await app.prisma.user.findUnique({ where: { id } }); expect(row).not.toBeNull(); // the row SURVIVES expect(row?.status).toBe("SUSPENDED"); const orgMember = await app.prisma.organizationMember.findUnique({ where: { organizationId_userId: { organizationId: orgId, userId: id } } }); expect(orgMember).not.toBeNull(); // memberships kept }); it("2g. deactivation blocks login + in-flight sessions (the prerequisite fix)", async () => { const created = await app.request("POST", "/scim/v2/Users", { headers: scimHeaders(scimToken), body: { userName: "suspend-me@scim-integ.test", password: "susp-pw-12345", active: true } }); const id = (await j(created)).id as string; // Login works while active; capture an in-flight access token. const login = await app.request("POST", "/api/v1/auth/login", { body: { email: "suspend-me@scim-integ.test", password: "susp-pw-12345" } }); expect(login.status).toBe(200); const accessToken = (await j(login)).accessToken as string; const me = await app.request("GET", "/api/v1/users/me", { headers: authHeaders(accessToken) }); expect(me.status).toBe(200); // Deactivate via SCIM (replace active:false). const etag = (await app.request("GET", `/scim/v2/Users/${id}`, { headers: scimHeaders(scimToken) })).headers.get("etag")!; const patch = await app.request("PATCH", `/scim/v2/Users/${id}`, { headers: { ...scimHeaders(scimToken), "If-Match": etag }, body: { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [{ op: "replace", path: "active", value: false }] } }); expect(patch.status).toBe(200); expect((await j(patch)).active).toBe(false); // In-flight session rejected (403 account_suspended). const blocked = await app.request("GET", "/api/v1/users/me", { headers: authHeaders(accessToken) }); expect(blocked.status).toBe(403); const bBody = await j(blocked); // The canonical envelope is { error: { code, message, details } }. expect(((bBody.error as { details?: Array<{ reason?: string }> })?.details ?? [])?.[0]?.reason).toBe("account_suspended"); // A fresh login attempt is blocked too. const rel = await app.request("POST", "/api/v1/auth/login", { body: { email: "suspend-me@scim-integ.test", password: "susp-pw-12345" } }); expect(rel.status).toBe(403); const relBody = await j(rel); expect(((relBody.error as { details?: Array<{ reason?: string }> })?.details ?? [])?.[0]?.reason).toBe("account_suspended"); }); it("2h. list: filter active eq false returns EXACTLY the deactivated set", async () => { // Ensure a known deactivated user exists (2f created one). const res = await app.request("GET", "/scim/v2/Users?filter=active+eq+false&count=100", { headers: scimHeaders(scimToken) }); expect(res.status).toBe(200); const body = await j(res); expect(body.schemas).toEqual(["urn:ietf:params:scim:api:messages:2.0:ListResponse"]); const emails = (body.Resources as Array<{ userName: string }>).map((r) => r.userName).sort(); // delete-me (2f) + suspend-me (2g) are deactivated; jane.scim is not. expect(emails).toContain("delete-me@scim-integ.test"); expect(emails).toContain("suspend-me@scim-integ.test"); expect(emails).not.toContain("jane.scim@scim-integ.test"); for (const r of body.Resources as Array<{ active?: boolean }>) expect(r.active).toBe(false); }); it("2i. pagination: 105 SCIM users (+1 org owner = 106 members), count=50 → 3 pages", async () => { const { orgId: orgP } = await setupOrg(); const tokenP = await enableScim(orgP, "pag.test"); for (let i = 0; i < 105; i += 1) { const r = await app.request("POST", "/scim/v2/Users", { headers: scimHeaders(tokenP), body: { userName: `user${String(i).padStart(3, "0")}@pag.test` } }); if (r.status !== 201) throw new Error(`bulk create ${i} failed: ${r.status} ${await r.text()}`); } // The org owner is an org member (role OWNER) too → 106 total. const p1 = await j(await app.request("GET", "/scim/v2/Users?count=50&startIndex=1", { headers: scimHeaders(tokenP) })); expect(p1.totalResults).toBe(106); expect((p1.Resources as unknown[]).length).toBe(50); expect(p1.itemsPerPage).toBe(50); expect(p1.startIndex).toBe(1); const p2 = await j(await app.request("GET", "/scim/v2/Users?count=50&startIndex=51", { headers: scimHeaders(tokenP) })); expect((p2.Resources as unknown[]).length).toBe(50); const p3 = await j(await app.request("GET", "/scim/v2/Users?count=50&startIndex=101", { headers: scimHeaders(tokenP) })); expect((p3.Resources as unknown[]).length).toBe(6); // No overlap between pages. const ids1 = new Set((p1.Resources as Array<{ id: string }>).map((r) => r.id)); for (const r of p2.Resources as Array<{ id: string }>) expect(ids1.has(r.id)).toBe(false); }); it("3a. groups: create with 2 members → team in the primary workspace", async () => { const members = [0, 1].map((i) => `gmember${i}@scim-integ.test`); for (const m of members) { const r = await app.request("POST", "/scim/v2/Users", { headers: scimHeaders(scimToken), body: { userName: m } }); expect(r.status).toBe(201); } const res = await app.request("POST", "/scim/v2/Groups", { headers: scimHeaders(scimToken), body: { schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"], displayName: "SCIM Engineers", members: members.map((v) => ({ value: v, $ref: "User" })) } }); expect(res.status).toBe(201); const gBody = await j(res); expect(gBody.displayName).toBe("SCIM Engineers"); const teamId = gBody.id as string; const team = await app.prisma.team.findUnique({ where: { id: teamId } }); expect(team?.workspaceId).toBe(workspaceId); // the PRIMARY workspace const teamMembers = await app.prisma.teamMember.findMany({ where: { teamId } }); expect(teamMembers.length).toBe(2); // GET by id returns memberCount. const get = await app.request("GET", `/scim/v2/Groups/${teamId}`, { headers: scimHeaders(scimToken) }); expect(get.status).toBe(200); }); it("3b. groups: PATCH adds a member (email resolution); DELETE removes team, keeps membership", async () => { const list = await j(await app.request("GET", "/scim/v2/Groups", { headers: scimHeaders(scimToken) })); const teamId = (list.Resources as Array<{ id: string }>)[0].id; const etag = (await app.request("GET", `/scim/v2/Groups/${teamId}`, { headers: scimHeaders(scimToken) })).headers.get("etag")!; const created = await app.request("POST", "/scim/v2/Users", { headers: scimHeaders(scimToken), body: { userName: "gmember2@scim-integ.test" } }); const newUserId = (await j(created)).id as string; const patch = await app.request("PATCH", `/scim/v2/Groups/${teamId}`, { headers: { ...scimHeaders(scimToken), "If-Match": etag }, body: { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [{ op: "add", path: "members", value: { value: "gmember2@scim-integ.test", $ref: "User" } }] } }); expect(patch.status).toBe(200); const tm = await app.prisma.teamMember.findUnique({ where: { teamId_userId: { teamId, userId: newUserId } } }); expect(tm).not.toBeNull(); // DELETE the team — the members KEEP their workspace membership. const del = await app.request("DELETE", `/scim/v2/Groups/${teamId}`, { headers: scimHeaders(scimToken) }); expect(del.status).toBe(200); expect(await app.prisma.team.findUnique({ where: { id: teamId } })).toBeNull(); const wsMember = await app.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId: newUserId } } }); expect(wsMember).not.toBeNull(); }); it("4. bad filters: neq → 400 invalidFilter; empty → 400", async () => { const neq = await app.request("GET", '/scim/v2/Users?filter=userName+neq+"x"', { headers: scimHeaders(scimToken) }); expect(neq.status).toBe(400); const nb = await j(neq); expect(nb.scimType).toBe("invalidFilter"); expect(nb.status).toBe("400"); const empty = await app.request("GET", "/scim/v2/Users?filter=", { headers: scimHeaders(scimToken) }); expect(empty.status).toBe(400); expect((await j(empty)).scimType).toBe("invalidFilter"); }); it("5. audit: scim.user.created + scim.user.deactivated, actorType INTEGRATION", async () => { const created = await app.prisma.auditLog.findFirst({ where: { action: "scim.user.created", actorId: orgId } }); expect(created).not.toBeNull(); expect(created!.actorType).toBe("INTEGRATION"); expect((created!.metadata as Record).source).toBe("scim"); expect(JSON.stringify(created!.metadata)).not.toContain("secret-pw"); // no password, ever const deactivated = await app.prisma.auditLog.findFirst({ where: { action: "scim.user.deactivated", actorId: orgId } }); expect(deactivated).not.toBeNull(); expect(deactivated!.actorType).toBe("INTEGRATION"); }); it("admin: disable invalidates the token immediately (cache invalidation)", async () => { // A list call still works. const ok = await app.request("GET", "/scim/v2/Users?count=1", { headers: scimHeaders(scimToken) }); expect(ok.status).toBe(200); const dis = await app.request("PATCH", `/api/v1/orgs/${orgId}/scim`, { headers: authHeaders(ownerToken), body: { disable: true } }); expect(dis.status).toBe(200); // Immediately 401 — the guard re-checks the org row (scimConfig null). const dead = await app.request("GET", "/scim/v2/Users?count=1", { headers: scimHeaders(scimToken) }); expect(dead.status).toBe(401); expect((await j(dead)).scimType).toBe("invalidCredentials"); // The admin GET shows disabled (masked, no token). const get = await j(await app.request("GET", `/api/v1/orgs/${orgId}/scim`, { headers: authHeaders(ownerToken) })); expect(get.enabled).toBe(false); }); });