1
24import { test, expect, request } from "@playwright/test";
25import type { PrismaClient } from "@prisma/client";
26
27const API = process.env.E2E_API_URL ?? "http://localhost:4000";
28const WEB = process.env.E2E_BASE_URL ?? "http://localhost:3000";
29const DSN = process.env.DATABASE_URL ?? "postgresql://factory:factory@localhost:5432/factory";
30const RUN = Math.random().toString(36).slice(2, 8);
31
32const cleanups: Array<() => Promise<void>> = [];
33test.afterAll(async () => {
34 const { PrismaClient: PC } = await import("@prisma/client");
35 const prisma = new PC({ datasources: { db: { url: DSN } } });
36 for (const fn of cleanups.splice(0).reverse()) {
37 try {
38 await fn();
39 } catch {
40
41 }
42 }
43 await prisma.$disconnect();
44});
45
46async function prisma(): Promise<PrismaClient> {
47 const { PrismaClient: PC } = await import("@prisma/client");
48 return new PC({ datasources: { db: { url: DSN } } });
49}
50
51
52async function createOwner(): Promise<{ email: string; password: string; token: string }> {
53 const email = `scim-owner-${RUN}-${Math.random().toString(36).slice(2, 6)}@factory.local`;
54 const password = `Pw!${RUN}${Date.now() % 100000}`;
55 const ctx = await request.newContext();
56 const reg = await ctx.post(`${API}/api/v1/auth/register`, {
57 data: { name: "Scim Owner", email, password }
58 });
59 expect(reg.status(), "register owner").toBe(201);
60 const login = await ctx.post(`${API}/api/v1/auth/login`, {
61 data: { email, password }
62 });
63 expect(login.status(), "login owner").toBe(200);
64 const token = ((await login.json()) as { accessToken: string }).accessToken;
65 await ctx.dispose();
66 return { email, password, token };
67}
68
69
70async function setupOrg(token: string): Promise<{ orgId: string; wsId: string }> {
71 const orgRes = (await (
72 await fetch(`${API}/api/v1/orgs`, {
73 method: "POST",
74 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
75 body: JSON.stringify({ name: `scim-e2e-${RUN}-${Math.random().toString(36).slice(2, 6)}` })
76 })
77 ).json()) as { id: string };
78 const wsRes = (await (
79 await fetch(`${API}/api/v1/workspaces`, {
80 method: "POST",
81 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
82 body: JSON.stringify({ name: `Scim WS ${RUN}` })
83 })
84 ).json()) as { id: string };
85 const p = await prisma();
86 await p.workspace.update({ where: { id: wsRes.id }, data: { organizationId: orgRes.id } });
87 await p.$disconnect();
88 cleanups.push(async () => {
89 const pc = await prisma();
90 await pc.$transaction([
91 pc.organizationMember.deleteMany({ where: { organizationId: orgRes.id } }),
92 pc.workspaceMember.deleteMany({ where: { workspaceId: wsRes.id } }),
93 pc.workspace.delete({ where: { id: wsRes.id } }),
94 pc.organization.delete({ where: { id: orgRes.id } })
95 ]);
96 await pc.$disconnect();
97 });
98 return { orgId: orgRes.id, wsId: wsRes.id };
99}
100
101
102async function enableScim(token: string, orgId: string, userEmailDomain = "scim-e2e.local"): Promise<string> {
103 const res = await fetch(`${API}/api/v1/orgs/${orgId}/scim`, {
104 method: "POST",
105 headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
106 body: JSON.stringify({ userEmailDomain, defaultRole: "MEMBER", autoJoinOrgWorkspaces: true })
107 });
108 expect(res.status, "enable scim").toBe(201);
109 const body = (await res.json()) as { token: string };
110 return body.token;
111}
112
113
114class ScimClient {
115 constructor(
116 private scimToken: string
117 ) {}
118 private async call(method: string, path: string, body?: unknown, ifMatch?: string) {
119 const res = await fetch(`${API}/scim/v2${path}`, {
120 method,
121 headers: {
122 authorization: `Bearer ${this.scimToken}`,
123 "content-type": "application/scim+json",
124 ...(ifMatch ? { "if-match": ifMatch } : {})
125 },
126 body: body !== undefined ? JSON.stringify(body) : undefined
127 });
128 const json = (await res.json().catch(() => ({}))) as Record<string, unknown>;
129 return { status: res.status, json, headers: res.headers };
130 }
131 createUserName = `scim.user.${RUN}@scim-e2e.local`;
132 create(email: string, password: string) {
133 return this.call("POST", "/Users", {
134 schemas: ["urn:ietf:params:scim:schemas:core:2.0:User"],
135 userName: email,
136 name: { givenName: "Scim", familyName: `E2E ${RUN}` },
137 password,
138 active: true
139 });
140 }
141 etag(id: string) {
142 return this.call("GET", `/Users/${id}`).then((r) => r.headers.get("etag") as string | null);
143 }
144 deactivate(id: string, ifMatch: string) {
145 return this.call("PATCH", `/Users/${id}`, {
146 schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
147 Operations: [{ op: "replace", path: "active", value: false }]
148 }, ifMatch);
149 }
150 list(filter?: string) {
151 return this.call("GET", `/Users${filter ? `?filter=${encodeURIComponent(filter)}` : ""}`);
152 }
153}
154
155test.describe("TASK-0132 SCIM", () => {
156 test("lifecycle: SCIM create (with password) → browser login → SCIM deactivation blocks in-flight + fresh login", async ({ page, browser }) => {
157 const owner = await createOwner();
158 const { orgId } = await setupOrg(owner.token);
159 const scimToken = await enableScim(owner.token, orgId);
160 const client = new ScimClient(scimToken);
161
162
163 const created = await client.create(client.createUserName, "Scim-Pw-12345!");
164 expect(created.status, `create user (${JSON.stringify(created.json)})`).toBe(201);
165 const userId = created.json.id as string;
166
167
168 cleanups.push(async () => {
169 const pc = await prisma();
170 await pc.user.deleteMany({ where: { id: userId } });
171 await pc.$disconnect();
172 });
173 expect(created.json.userName).toBe(client.createUserName);
174 expect(created.headers.get("location") ?? "").toContain("/scim/v2/Users/");
175
176
177
178
179
180 let accessToken: string | null = null;
181 page.on("response", async (res) => {
182 if (res.url().includes("/api/v1/auth/login") && res.status() === 200) {
183 try {
184 accessToken = ((await res.json()) as { accessToken?: string }).accessToken ?? null;
185 } catch {
186
187 }
188 }
189 });
190 await page.goto(`${WEB}/login`);
191 await page.getByTestId("auth-email").fill(client.createUserName);
192 await page.getByTestId("auth-password").fill("Scim-Pw-12345!");
193 await page.getByTestId("auth-submit").click();
194
195 await expect(page).toHaveURL(/\/w\
196
197 await expect
198 .poll(() => accessToken, { timeout: 10_000, message: "login access token captured" })
199 .not.toBeNull();
200
201
202 const me = await page.request.get(`${API}/api/v1/users/me`, {
203 headers: { authorization: `Bearer ${accessToken}` }
204 });
205 expect(me.status(), "in-flight session alive").toBe(200);
206
207
208 const etag = await client.etag(userId);
209 expect(etag, "GET etag").toBeTruthy();
210 const patched = await client.deactivate(userId, etag!);
211 expect(patched.status, `deactivate (${JSON.stringify(patched.json)})`).toBe(200);
212 expect(patched.json.active).toBe(false);
213
214
215 const blocked = await page.request.get(`${API}/api/v1/users/me`, {
216 headers: { authorization: `Bearer ${accessToken}` }
217 });
218 expect(blocked.status(), "in-flight session blocked after deactivation").toBe(403);
219 const blockedBody = (await blocked.json()) as { error?: { details?: Array<{ reason?: string }> } };
220 expect(blockedBody.error?.details?.[0]?.reason).toBe("account_suspended");
221
222
223
224
225 const freshCtx = await browser.newContext();
226 const freshPage = await freshCtx.newPage();
227 await freshPage.goto(`${WEB}/login`);
228 const freshEmail = freshPage.getByTestId("auth-email");
229 await freshEmail.waitFor({ state: "visible", timeout: 18_000 });
230 await freshEmail.fill(client.createUserName);
231 await freshPage.getByTestId("auth-password").fill("Scim-Pw-12345!");
232 await freshPage.getByTestId("auth-submit").click();
233 await expect(freshPage.getByTestId("auth-error")).toContainText("Account suspended.", { timeout: 15_000 });
234 await freshCtx.close();
235
236
237 const p = await prisma();
238 const row = await p.user.findUnique({ where: { id: userId } });
239 expect(row?.status).toBe("SUSPENDED");
240 expect(await p.organizationMember.findUnique({ where: { organizationId_userId: { organizationId: orgId, userId } } })).not.toBeNull();
241 const audit = await p.auditLog.findMany({ where: { action: { in: ["scim.user.created", "scim.user.deactivated"] }, actorId: orgId } });
242 expect(audit.some((a) => a.action === "scim.user.created" && a.actorType === "INTEGRATION")).toBe(true);
243 expect(audit.some((a) => a.action === "scim.user.deactivated" && a.actorType === "INTEGRATION")).toBe(true);
244 await p.$disconnect();
245
246
247 const list = await client.list("active eq false");
248 expect(list.status).toBe(200);
249 const resources = (list.json.Resources as Array<{ id: string }>) ?? [];
250 expect(resources.some((r) => r.id === userId)).toBe(true);
251 });
252
253 test("admin UI: enable → reveal-once token + copy, test-connection, rotate, disable", async ({ page, context }) => {
254
255
256 await context.grantPermissions(["clipboard-read", "clipboard-write"], { origin: WEB });
257 const owner = await createOwner();
258 const { orgId } = await setupOrg(owner.token);
259
260
261 const loginRes = await page.request.post(`${API}/api/v1/auth/login`, {
262 data: { email: owner.email, password: owner.password }
263 });
264 expect(loginRes.status(), "login owner").toBe(200);
265
266 await page.goto(`${WEB}/org/${orgId}/admin/integrations`);
267 await expect(page.getByTestId("scim-enable")).toBeVisible({ timeout: 15_000 });
268
269
270 await page.getByTestId("scim-user-email-domain").fill("scim-ui.local");
271 await page.getByTestId("scim-default-role").selectOption("GUEST");
272 await page.getByTestId("scim-auto-join").uncheck();
273 await page.getByTestId("scim-enable").click();
274
275
276 const tokenPanel = page.getByTestId("scim-token-panel");
277 await expect(tokenPanel).toBeVisible({ timeout: 15_000 });
278 const tokenEl = page.getByTestId("scim-token");
279 const rawToken = ((await tokenEl.innerText()) ?? "").trim();
280 expect(rawToken, "token shape").toMatch(/^scim_[A-Za-z0-9_-]{32,}$/);
281 await page.getByTestId("scim-copy-token").click();
282 await expect(page.getByTestId("scim-copy-token")).toContainText("Copied", { timeout: 5_000 });
283
284
285 await expect(page.getByTestId("scim-endpoint")).toContainText("/scim/v2");
286 await expect(page.getByTestId("scim-curl")).toContainText("Bearer");
287 await expect(page.getByText("Guest")).toBeVisible();
288
289
290 await expect(page.getByTestId("scim-test-token")).toHaveValue(rawToken, { timeout: 5_000 });
291 await page.getByTestId("scim-test").click();
292 await expect(page.getByTestId("scim-test-result")).toContainText("Connected", { timeout: 20_000 });
293
294
295 await page.getByTestId("scim-test-token").fill("scim_wrong_token_0000000000");
296 await page.getByTestId("scim-test").click();
297 await expect(page.getByTestId("scim-test-result")).toContainText("Failed", { timeout: 20_000 });
298
299
300 const oldToken = rawToken;
301 await page.getByTestId("scim-rotate").click();
302 await expect(page.getByTestId("scim-token-panel")).toBeVisible({ timeout: 15_000 });
303 const newToken = ((await page.getByTestId("scim-token").innerText()) ?? "").trim();
304 expect(newToken).not.toBe(oldToken);
305
306 const oldProbe = await fetch(`${API}/scim/v2/Users?count=1`, {
307 headers: { authorization: `Bearer ${oldToken}` }
308 });
309 expect(oldProbe.status, "old token invalid after rotate").toBe(401);
310 const newProbe = await fetch(`${API}/scim/v2/Users?count=1`, {
311 headers: { authorization: `Bearer ${newToken}` }
312 });
313 expect(newProbe.status, "new token works").toBe(200);
314
315
316 await page.getByTestId("scim-disable").click();
317 await page.getByRole("dialog").getByRole("button", { name: "Disable" }).click();
318 await expect(page.getByTestId("scim-enable")).toBeVisible({ timeout: 15_000 });
319 await expect(page.getByTestId("scim-token-panel")).toHaveCount(0);
320
321 const deadProbe = await fetch(`${API}/scim/v2/Users?count=1`, {
322 headers: { authorization: `Bearer ${newToken}` }
323 });
324 expect(deadProbe.status, "token invalid after disable").toBe(401);
325
326 await context.clearCookies();
327 });
328});
329