1
24import { afterAll, beforeAll, describe, expect, it } from "vitest";
25import { createTestApp, type TestAppHandle } from "./helpers/api";
26import { authHeaders, createWorkspace, createUser } from "./helpers/seed";
27
28let app: TestAppHandle;
29let ownerToken: string;
30let orgId: string;
31let scimToken: string;
32let workspaceId: string;
33
34const j = async (res: Response): Promise<Record<string, unknown>> =>
35 (await res.json().catch(() => ({}))) as Record<string, unknown>;
36
37const scimHeaders = (token: string) => ({
38 Authorization: `Bearer ${token}`,
39 "Content-Type": "application/scim+json"
40});
41
42
43async function setupOrg(): Promise<{ orgId: string; wsId: string }> {
44 const res = await app.request("POST", "/api/v1/orgs", {
45 headers: authHeaders(ownerToken),
46 body: { name: `SCIM Org ${Date.now()}` }
47 });
48 const org = (await j(res)) as { id: string };
49 const { workspaceId: wsId } = await createWorkspace(app.request, authHeaders(ownerToken), `SCIM WS ${Date.now()}`);
50 await app.prisma.workspace.update({ where: { id: wsId }, data: { organizationId: org.id } });
51 return { orgId: org.id, wsId };
52}
53
54
55async function enableScim(orgId: string, userEmailDomain?: string): Promise<string> {
56 const res = await app.request("POST", `/api/v1/orgs/${orgId}/scim`, {
57 headers: authHeaders(ownerToken),
58 body: userEmailDomain ? { userEmailDomain } : {}
59 });
60 if (res.status !== 201 && res.status !== 200) throw new Error(`enable scim failed (${res.status}): ${await res.text()}`);
61 return (await j(res)).token as string;
62}
63
64describe("TASK-0132 SCIM 2.0", () => {
65 beforeAll(async () => {
66 app = await createTestApp();
67 const owner = await createUser(app.request);
68 ownerToken = owner.accessToken!;
69 ({ orgId, wsId: workspaceId } = await setupOrg());
70 scimToken = await enableScim(orgId, "scim-integ.test");
71 }, 180_000);
72
73 afterAll(async () => {
74 await app?.close();
75 });
76
77 it("1. token auth: missing / wrong / unknown → 401 SCIM error shape", async () => {
78 const missing = await app.request("GET", "/scim/v2/Users", { headers: { "Content-Type": "application/scim+json" } });
79 expect(missing.status).toBe(401);
80 expect(missing.headers.get("content-type") ?? "").toContain("application/scim+json");
81 const mBody = await j(missing);
82 expect(mBody.schemas).toEqual(["urn:ietf:params:scim:api:errors:2.0"]);
83 expect(mBody.status).toBe("401");
84 expect(mBody.scimType).toBe("invalidCredentials");
85
86 const wrong = await app.request("GET", "/scim/v2/Users", { headers: scimHeaders("scim_wrong_token_here_000000") });
87 expect(wrong.status).toBe(401);
88 expect((await j(wrong)).scimType).toBe("invalidCredentials");
89
90 const notScim = await app.request("GET", "/scim/v2/Users", { headers: { Authorization: `Bearer ${ownerToken}` } });
91 expect(notScim.status).toBe(401);
92 });
93
94 it("2a. create: 201 + Location + ETag + SCIM representation (email mapping)", async () => {
95 const res = await app.request("POST", "/scim/v2/Users", {
96 headers: scimHeaders(scimToken),
97 body: {
98 schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
99 userName: "jane.scim",
100 name: { givenName: "Jane", familyName: "Scim" },
101 password: "initial-secret-pw",
102 active: true
103 }
104 });
105 expect(res.status).toBe(201);
106 const location = res.headers.get("location");
107 expect(location).toMatch(/^\/scim\/v2\/Users\
108 const etag = res.headers.get("etag");
109 expect(etag).toMatch(/^W\/"scim-[0-9a-f]{16}"$/);
110
111 const body = await j(res);
112 expect(body.schemas).toContain("urn:ietf:params:scim:schemas:core:2.0:User");
113
114 expect(body.userName).toBe("jane.scim@scim-integ.test");
115 expect(body.active).toBe(true);
116 expect(body.id).toBeTruthy();
117 expect(body).not.toHaveProperty("password");
118
119 const userId = body.id as string;
120
121 const row = await app.prisma.user.findUniqueOrThrow({ where: { id: userId } });
122 expect(row.email).toBe("jane.scim@scim-integ.test");
123 expect(row.status).toBe("ACTIVE");
124 expect(row.emailVerification).toBe("VERIFIED");
125 expect(row.passwordHash).toBeTruthy();
126 expect(row.passwordHash).not.toContain("initial-secret-pw");
127
128 const orgMember = await app.prisma.organizationMember.findUnique({
129 where: { organizationId_userId: { organizationId: orgId, userId } }
130 });
131 expect(orgMember?.role).toBe("MEMBER");
132 const wsMember = await app.prisma.workspaceMember.findUnique({
133 where: { workspaceId_userId: { workspaceId, userId } }
134 });
135 expect(wsMember?.role).toBe("MEMBER");
136
137
138 const login = await app.request("POST", "/api/v1/auth/login", {
139 body: { email: "jane.scim@scim-integ.test", password: "initial-secret-pw" }
140 });
141 expect(login.status).toBe(200);
142 });
143
144 it("2b. create: duplicate userName → 409 duplicate", async () => {
145 const res = await app.request("POST", "/scim/v2/Users", {
146 headers: scimHeaders(scimToken),
147 body: { userName: "jane.scim@scim-integ.test", active: true }
148 });
149 expect(res.status).toBe(409);
150 const body = await j(res);
151 expect(body.scimType).toBe("duplicate");
152 expect(body.status).toBe("409");
153 });
154
155 it("2c. create: @-less userName without userEmailDomain → 400 invalidValue", async () => {
156 const { orgId: org2 } = await setupOrg();
157 const token2 = await enableScim(org2);
158 const res = await app.request("POST", "/scim/v2/Users", {
159 headers: scimHeaders(token2),
160 body: { userName: "nousername.domain", active: true }
161 });
162 expect(res.status).toBe(400);
163 expect((await j(res)).scimType).toBe("invalidValue");
164 });
165
166 it("2d. GET by id returns the representation + ETag; cross-org → 404", async () => {
167
168
169
170 const created = await app.request("POST", "/scim/v2/Users", {
171 headers: scimHeaders(scimToken),
172 body: { userName: "crossorg@scim-integ.test" }
173 });
174 expect(created.status).toBe(201);
175 const id = (await j(created)).id as string;
176
177 const get = await app.request("GET", `/scim/v2/Users/${id}`, { headers: scimHeaders(scimToken) });
178 expect(get.status).toBe(200);
179 expect((await j(get)).id).toBe(id);
180 expect(get.headers.get("etag")).toBeTruthy();
181
182
183 const { orgId: orgB } = await setupOrg();
184 const tokenB = await enableScim(orgB, "orgb.test");
185 const cross = await app.request("GET", `/scim/v2/Users/${id}`, { headers: scimHeaders(tokenB) });
186 expect(cross.status).toBe(404);
187 expect((await j(cross)).detail).toBeTruthy();
188 });
189
190 it("2e. PATCH replace emails: updates + ETag changes; stale If-Match → 412", async () => {
191 const created = await app.request("POST", "/scim/v2/Users", {
192 headers: scimHeaders(scimToken),
193 body: { userName: "patch-me@scim-integ.test", active: true }
194 });
195 const cBody = await j(created);
196 const id = cBody.id as string;
197 const etag1 = (await app.request("GET", `/scim/v2/Users/${id}`, { headers: scimHeaders(scimToken) })).headers.get("etag")!;
198
199
200 const stale = await app.request("PATCH", `/scim/v2/Users/${id}`, {
201 headers: { ...scimHeaders(scimToken), "If-Match": `W/"scim-0000000000000000"` },
202 body: { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [{ op: "replace", path: "active", value: true }] }
203 });
204 expect(stale.status).toBe(412);
205 expect((await j(stale)).status).toBe("412");
206
207
208 const patched = await app.request("PATCH", `/scim/v2/Users/${id}`, {
209 headers: { ...scimHeaders(scimToken), "If-Match": etag1 },
210 body: {
211 schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
212 Operations: [{ op: "replace", path: "emails", value: [{ value: "patched@scim-integ.test", primary: true }] }]
213 }
214 });
215 expect(patched.status).toBe(200);
216 const etag2 = patched.headers.get("etag");
217 expect(etag2).toBeTruthy();
218 expect(etag2).not.toBe(etag1);
219 const pBody = await j(patched);
220 expect(pBody.userName).toBe("patched@scim-integ.test");
221 const row = await app.prisma.user.findUniqueOrThrow({ where: { id } });
222 expect(row.email).toBe("patched@scim-integ.test");
223 });
224
225 it("2f. DELETE deactivates (SUSPENDED), never deletes; membership kept", async () => {
226 const created = await app.request("POST", "/scim/v2/Users", {
227 headers: scimHeaders(scimToken),
228 body: { userName: "delete-me@scim-integ.test", active: true }
229 });
230 const id = (await j(created)).id as string;
231 const del = await app.request("DELETE", `/scim/v2/Users/${id}`, { headers: scimHeaders(scimToken) });
232 expect(del.status).toBe(200);
233
234 const row = await app.prisma.user.findUnique({ where: { id } });
235 expect(row).not.toBeNull();
236 expect(row?.status).toBe("SUSPENDED");
237 const orgMember = await app.prisma.organizationMember.findUnique({
238 where: { organizationId_userId: { organizationId: orgId, userId: id } }
239 });
240 expect(orgMember).not.toBeNull();
241 });
242
243 it("2g. deactivation blocks login + in-flight sessions (the prerequisite fix)", async () => {
244 const created = await app.request("POST", "/scim/v2/Users", {
245 headers: scimHeaders(scimToken),
246 body: { userName: "suspend-me@scim-integ.test", password: "susp-pw-12345", active: true }
247 });
248 const id = (await j(created)).id as string;
249
250
251 const login = await app.request("POST", "/api/v1/auth/login", {
252 body: { email: "suspend-me@scim-integ.test", password: "susp-pw-12345" }
253 });
254 expect(login.status).toBe(200);
255 const accessToken = (await j(login)).accessToken as string;
256 const me = await app.request("GET", "/api/v1/users/me", { headers: authHeaders(accessToken) });
257 expect(me.status).toBe(200);
258
259
260 const etag = (await app.request("GET", `/scim/v2/Users/${id}`, { headers: scimHeaders(scimToken) })).headers.get("etag")!;
261 const patch = await app.request("PATCH", `/scim/v2/Users/${id}`, {
262 headers: { ...scimHeaders(scimToken), "If-Match": etag },
263 body: {
264 schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
265 Operations: [{ op: "replace", path: "active", value: false }]
266 }
267 });
268 expect(patch.status).toBe(200);
269 expect((await j(patch)).active).toBe(false);
270
271
272 const blocked = await app.request("GET", "/api/v1/users/me", { headers: authHeaders(accessToken) });
273 expect(blocked.status).toBe(403);
274 const bBody = await j(blocked);
275
276 expect(((bBody.error as { details?: Array<{ reason?: string }> })?.details ?? [])?.[0]?.reason).toBe("account_suspended");
277
278
279 const rel = await app.request("POST", "/api/v1/auth/login", {
280 body: { email: "suspend-me@scim-integ.test", password: "susp-pw-12345" }
281 });
282 expect(rel.status).toBe(403);
283 const relBody = await j(rel);
284 expect(((relBody.error as { details?: Array<{ reason?: string }> })?.details ?? [])?.[0]?.reason).toBe("account_suspended");
285 });
286
287 it("2h. list: filter active eq false returns EXACTLY the deactivated set", async () => {
288
289 const res = await app.request("GET", "/scim/v2/Users?filter=active+eq+false&count=100", { headers: scimHeaders(scimToken) });
290 expect(res.status).toBe(200);
291 const body = await j(res);
292 expect(body.schemas).toEqual(["urn:ietf:params:scim:api:messages:2.0:ListResponse"]);
293 const emails = (body.Resources as Array<{ userName: string }>).map((r) => r.userName).sort();
294
295 expect(emails).toContain("delete-me@scim-integ.test");
296 expect(emails).toContain("suspend-me@scim-integ.test");
297 expect(emails).not.toContain("jane.scim@scim-integ.test");
298 for (const r of body.Resources as Array<{ active?: boolean }>) expect(r.active).toBe(false);
299 });
300
301 it("2i. pagination: 105 SCIM users (+1 org owner = 106 members), count=50 → 3 pages", async () => {
302 const { orgId: orgP } = await setupOrg();
303 const tokenP = await enableScim(orgP, "pag.test");
304 for (let i = 0; i < 105; i += 1) {
305 const r = await app.request("POST", "/scim/v2/Users", {
306 headers: scimHeaders(tokenP),
307 body: { userName: `user${String(i).padStart(3, "0")}@pag.test` }
308 });
309 if (r.status !== 201) throw new Error(`bulk create ${i} failed: ${r.status} ${await r.text()}`);
310 }
311
312
313 const p1 = await j(await app.request("GET", "/scim/v2/Users?count=50&startIndex=1", { headers: scimHeaders(tokenP) }));
314 expect(p1.totalResults).toBe(106);
315 expect((p1.Resources as unknown[]).length).toBe(50);
316 expect(p1.itemsPerPage).toBe(50);
317 expect(p1.startIndex).toBe(1);
318
319 const p2 = await j(await app.request("GET", "/scim/v2/Users?count=50&startIndex=51", { headers: scimHeaders(tokenP) }));
320 expect((p2.Resources as unknown[]).length).toBe(50);
321
322 const p3 = await j(await app.request("GET", "/scim/v2/Users?count=50&startIndex=101", { headers: scimHeaders(tokenP) }));
323 expect((p3.Resources as unknown[]).length).toBe(6);
324
325
326 const ids1 = new Set((p1.Resources as Array<{ id: string }>).map((r) => r.id));
327 for (const r of p2.Resources as Array<{ id: string }>) expect(ids1.has(r.id)).toBe(false);
328 });
329
330 it("3a. groups: create with 2 members → team in the primary workspace", async () => {
331 const members = [0, 1].map((i) => `gmember${i}@scim-integ.test`);
332 for (const m of members) {
333 const r = await app.request("POST", "/scim/v2/Users", {
334 headers: scimHeaders(scimToken),
335 body: { userName: m }
336 });
337 expect(r.status).toBe(201);
338 }
339 const res = await app.request("POST", "/scim/v2/Groups", {
340 headers: scimHeaders(scimToken),
341 body: {
342 schemas: ["urn:ietf:params:scim:schemas:core:2.0:Group"],
343 displayName: "SCIM Engineers",
344 members: members.map((v) => ({ value: v, $ref: "User" }))
345 }
346 });
347 expect(res.status).toBe(201);
348 const gBody = await j(res);
349 expect(gBody.displayName).toBe("SCIM Engineers");
350 const teamId = gBody.id as string;
351
352 const team = await app.prisma.team.findUnique({ where: { id: teamId } });
353 expect(team?.workspaceId).toBe(workspaceId);
354 const teamMembers = await app.prisma.teamMember.findMany({ where: { teamId } });
355 expect(teamMembers.length).toBe(2);
356
357
358 const get = await app.request("GET", `/scim/v2/Groups/${teamId}`, { headers: scimHeaders(scimToken) });
359 expect(get.status).toBe(200);
360 });
361
362 it("3b. groups: PATCH adds a member (email resolution); DELETE removes team, keeps membership", async () => {
363 const list = await j(await app.request("GET", "/scim/v2/Groups", { headers: scimHeaders(scimToken) }));
364 const teamId = (list.Resources as Array<{ id: string }>)[0].id;
365 const etag = (await app.request("GET", `/scim/v2/Groups/${teamId}`, { headers: scimHeaders(scimToken) })).headers.get("etag")!;
366
367 const created = await app.request("POST", "/scim/v2/Users", {
368 headers: scimHeaders(scimToken),
369 body: { userName: "gmember2@scim-integ.test" }
370 });
371 const newUserId = (await j(created)).id as string;
372
373 const patch = await app.request("PATCH", `/scim/v2/Groups/${teamId}`, {
374 headers: { ...scimHeaders(scimToken), "If-Match": etag },
375 body: {
376 schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
377 Operations: [{ op: "add", path: "members", value: { value: "gmember2@scim-integ.test", $ref: "User" } }]
378 }
379 });
380 expect(patch.status).toBe(200);
381 const tm = await app.prisma.teamMember.findUnique({ where: { teamId_userId: { teamId, userId: newUserId } } });
382 expect(tm).not.toBeNull();
383
384
385 const del = await app.request("DELETE", `/scim/v2/Groups/${teamId}`, { headers: scimHeaders(scimToken) });
386 expect(del.status).toBe(200);
387 expect(await app.prisma.team.findUnique({ where: { id: teamId } })).toBeNull();
388 const wsMember = await app.prisma.workspaceMember.findUnique({
389 where: { workspaceId_userId: { workspaceId, userId: newUserId } }
390 });
391 expect(wsMember).not.toBeNull();
392 });
393
394 it("4. bad filters: neq → 400 invalidFilter; empty → 400", async () => {
395 const neq = await app.request("GET", '/scim/v2/Users?filter=userName+neq+"x"', { headers: scimHeaders(scimToken) });
396 expect(neq.status).toBe(400);
397 const nb = await j(neq);
398 expect(nb.scimType).toBe("invalidFilter");
399 expect(nb.status).toBe("400");
400
401 const empty = await app.request("GET", "/scim/v2/Users?filter=", { headers: scimHeaders(scimToken) });
402 expect(empty.status).toBe(400);
403 expect((await j(empty)).scimType).toBe("invalidFilter");
404 });
405
406 it("5. audit: scim.user.created + scim.user.deactivated, actorType INTEGRATION", async () => {
407 const created = await app.prisma.auditLog.findFirst({ where: { action: "scim.user.created", actorId: orgId } });
408 expect(created).not.toBeNull();
409 expect(created!.actorType).toBe("INTEGRATION");
410 expect((created!.metadata as Record<string, unknown>).source).toBe("scim");
411 expect(JSON.stringify(created!.metadata)).not.toContain("secret-pw");
412
413 const deactivated = await app.prisma.auditLog.findFirst({
414 where: { action: "scim.user.deactivated", actorId: orgId }
415 });
416 expect(deactivated).not.toBeNull();
417 expect(deactivated!.actorType).toBe("INTEGRATION");
418 });
419
420 it("admin: disable invalidates the token immediately (cache invalidation)", async () => {
421
422 const ok = await app.request("GET", "/scim/v2/Users?count=1", { headers: scimHeaders(scimToken) });
423 expect(ok.status).toBe(200);
424
425 const dis = await app.request("PATCH", `/api/v1/orgs/${orgId}/scim`, {
426 headers: authHeaders(ownerToken),
427 body: { disable: true }
428 });
429 expect(dis.status).toBe(200);
430
431
432 const dead = await app.request("GET", "/scim/v2/Users?count=1", { headers: scimHeaders(scimToken) });
433 expect(dead.status).toBe(401);
434 expect((await j(dead)).scimType).toBe("invalidCredentials");
435
436
437 const get = await j(await app.request("GET", `/api/v1/orgs/${orgId}/scim`, { headers: authHeaders(ownerToken) }));
438 expect(get.enabled).toBe(false);
439 });
440});
441