import { expect, type Page, type Route, test } from "@playwright/test"; type MockOptions = { sessionStatus?: number; captureApprove?: (pendingRef: string | null) => void; captureUserMe?: () => void; captureVerify?: (path: string, body: Record) => void; }; async function seedTokenLogin(page: Page): Promise { await page.addInitScript(() => { window.localStorage.setItem("baron_auth_token", "e30.e30.e30"); window.localStorage.setItem("baron_auth_provider", "ory"); window.localStorage.removeItem("baron_auth_cookie_mode"); window.localStorage.removeItem("baron_auth_pending_provider"); }); } async function seedSessionTokenLogin(page: Page): Promise { await page.addInitScript(() => { window.sessionStorage.setItem("baron_auth_token", "e30.e30.e30"); window.sessionStorage.setItem("baron_auth_provider", "ory"); window.sessionStorage.removeItem("baron_auth_cookie_mode"); window.sessionStorage.removeItem("baron_auth_pending_provider"); window.localStorage.removeItem("baron_auth_token"); window.localStorage.removeItem("baron_auth_provider"); window.localStorage.removeItem("baron_auth_cookie_mode"); window.localStorage.removeItem("baron_auth_pending_provider"); }); } async function mockUserfrontApis( page: Page, options: MockOptions = {}, ): Promise { const sessionStatus = options.sessionStatus ?? 200; await page.context().route("**/api/v1/**", async (route: Route) => { const requestUrl = new URL(route.request().url()); const path = requestUrl.pathname; if (path.endsWith("/api/v1/user/me")) { options.captureUserMe?.(); if (sessionStatus === 200) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ id: "e2e-user", email: "e2e@example.com", name: "E2E User", phone: "+821012341234", department: "QA", affiliationType: "employee", companyCode: "BARON", tenant: { id: "tenant-1", name: "Baron", slug: "baron", description: "E2E tenant", }, }), }); return; } await route.fulfill({ status: sessionStatus, contentType: "application/json", body: JSON.stringify({ error: "unauthorized" }), }); return; } if (path.endsWith("/api/v1/user/rp/linked")) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [] }), }); return; } if (path.endsWith("/api/v1/audit/auth/timeline")) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [], next_cursor: "" }), }); return; } if (path.endsWith("/api/v1/auth/qr/approve")) { if (route.request().method() === "POST") { let pendingRef: string | null = null; try { const body = (route.request().postDataJSON() ?? {}) as { pendingRef?: string; }; pendingRef = body.pendingRef ?? null; console.log(`[E2E-MOCK] /api/v1/auth/qr/approve POST body:`, body); } catch (e) { console.log( `[E2E-MOCK] /api/v1/auth/qr/approve POST body parse error:`, e, ); pendingRef = null; } options.captureApprove?.(pendingRef); } else { console.log( `[E2E-MOCK] /api/v1/auth/qr/approve ${route.request().method()} request`, ); } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }), }); return; } if ( path.endsWith("/api/v1/auth/magic-link/verify") || path.endsWith("/api/v1/auth/login/code/verify") || path.endsWith("/api/v1/auth/login/code/verify-short") ) { let body: Record = {}; try { body = (route.request().postDataJSON() ?? {}) as Record< string, unknown >; } catch { body = {}; } options.captureVerify?.(path, body); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "approved", pendingRef: "e2e-approved", }), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({}), }); }); } function collectClientFailures(page: Page): string[] { const failures: string[] = []; page.on("pageerror", (error) => { const text = error.message.trim(); if (text !== "") { failures.push(text); } }); page.on("console", (message) => { const text = message.text().trim(); if (text === "") { return; } if ( message.type() === "error" || (/exception|verify_failed|verification failed|인증 실패/i.test(text) && !text.includes("Exception while loading service worker")) ) { failures.push(text); } }); return failures; } async function makeWindowCloseNavigateToRoot(page: Page): Promise { await page.addInitScript(() => { window.close = () => { window.location.href = "/"; }; }); } async function enableFlutterAccessibility(page: Page): Promise { await page.waitForTimeout(300); const button = page.getByRole("button", { name: "Enable accessibility" }); const placeholder = page.locator("flt-semantics-placeholder").first(); await button.click({ force: true, timeout: 1_000 }).catch(async () => { await placeholder.click({ force: true, timeout: 1_000 }).catch(async () => { await placeholder.evaluate((node) => { (node as HTMLElement).click(); }); }); }); await page.waitForTimeout(500); } test.describe("UserFront WASM auth routing", () => { test.describe.configure({ mode: "default" }); test("비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다", async ({ page, }) => { await mockUserfrontApis(page, { sessionStatus: 401 }); await page.goto("/ko"); await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); }); test("로그인 상태 /ko 진입 후 새로고침해도 /ko/dashboard 를 유지한다", async ({ page, }) => { await seedTokenLogin(page); await mockUserfrontApis(page); await page.goto("/ko"); await expect(page).toHaveURL(/\/ko\/dashboard$/); await page.reload(); await expect(page).toHaveURL(/\/ko\/dashboard$/); }); test("sessionStorage 기반 로그인 상태에서도 /ko/dashboard 를 유지한다", async ({ page, }) => { await seedSessionTokenLogin(page); await mockUserfrontApis(page); await page.goto("/ko"); await expect(page).toHaveURL(/\/ko\/dashboard$/); }); test("비로그인 /ko/approve 는 signin(+notice)으로 이동한다", async ({ page, }) => { await mockUserfrontApis(page, { sessionStatus: 401 }); await page.goto("/ko/approve?ref=e2e-ref"); await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/); }); test("로그인 상태 /ko/approve 는 승인 API 호출 후 dashboard로 이동한다", async ({ page, }) => { let approvedRef: string | null = null; await seedTokenLogin(page); await mockUserfrontApis(page, { captureApprove: (pendingRef) => { approvedRef = pendingRef; }, }); await page.goto("/ko/approve?ref=e2e-approve-ref"); await expect(page).toHaveURL(/\/ko\/dashboard(?:\?.*)?$/, { timeout: 10_000, }); expect(approvedRef).toBe("e2e-approve-ref"); }); test("verifyOnly 승인 완료 화면의 상단 액션은 signin으로 복귀시킨다", async ({ page, }) => { let userMeCalls = 0; const clientFailures = collectClientFailures(page); const verifyRequests: Array<{ path: string; body: Record; }> = []; await mockUserfrontApis(page, { sessionStatus: 401, captureUserMe: () => { userMeCalls += 1; }, captureVerify: (path, body) => { verifyRequests.push({ path, body }); }, }); await makeWindowCloseNavigateToRoot(page); await page.goto("/ko/l/AB123456"); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); expect(verifyRequests[0].path).toContain( "/api/v1/auth/login/code/verify-short", ); expect(verifyRequests[0].body).toMatchObject({ shortCode: "AB123456", verifyOnly: true, }); await page.locator("flt-glass-pane").click({ position: { x: 30, y: 28 }, force: true, }); await page.waitForTimeout(300); await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); expect(userMeCalls).toBe(0); expect( clientFailures.filter( (failure) => !failure.includes("401 (Unauthorized)"), ), ).toEqual([]); }); test("verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({ page, }) => { let userMeCalls = 0; let verifyCalls = 0; const clientFailures = collectClientFailures(page); await mockUserfrontApis(page, { sessionStatus: 401, captureUserMe: () => { userMeCalls += 1; }, captureVerify: () => { verifyCalls += 1; }, }); await makeWindowCloseNavigateToRoot(page); await page.goto("/ko/l/AB123456"); await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1); await expect(page).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); await enableFlutterAccessibility(page); await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click(); expect(userMeCalls).toBe(0); await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); expect( clientFailures.filter( (failure) => !failure.includes("401 (Unauthorized)"), ), ).toEqual([]); }); test("verifyOnly 원격 승인 완료는 로그인 창 이동 CTA와 안내 문구를 표시한다", async ({ page, }) => { let verifyCalls = 0; const clientFailures = collectClientFailures(page); await mockUserfrontApis(page, { sessionStatus: 401, captureVerify: () => { verifyCalls += 1; }, }); await makeWindowCloseNavigateToRoot(page); await page.goto("/ko/l/AB123456"); await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1); await expect(page).toHaveURL(/\/ko\/verify-complete$/); await enableFlutterAccessibility(page); await expect(page.getByText("로그인 승인 완료")).toBeVisible(); await expect( page.getByText("요청하신 로그인이 완료되었습니다"), ).toBeVisible(); await expect(page.getByRole("button", { name: "창 닫기" })).toHaveCount(0); await expect( page.getByRole("button", { name: "로그인 창으로 이동하기" }), ).toBeVisible(); await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click(); await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); expect(clientFailures).toEqual([]); }); test("루트/로그인에 붙은 인증 payload는 전용 verify 라우트에서만 소비하고 완료 URL을 정리한다", async ({ page, }) => { let userMeCalls = 0; const verifyRequests: Array<{ path: string; body: Record; }> = []; const clientFailures = collectClientFailures(page); await mockUserfrontApis(page, { sessionStatus: 401, captureUserMe: () => { userMeCalls += 1; }, captureVerify: (path, body) => { verifyRequests.push({ path, body }); }, }); await page.goto( "/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop", ); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); await expect .poll(() => page.url(), { timeout: 10_000 }) .toContain("/ko/verify-complete"); expect(verifyRequests[0].path).toContain("/api/v1/auth/login/code/verify"); expect(verifyRequests[0].body).toMatchObject({ loginId: "e2e@example.com", code: "654321", pendingRef: "pending-root", verifyOnly: true, }); expect(page.url()).not.toContain("loginId="); expect(page.url()).not.toContain("code="); expect(page.url()).not.toContain("pendingRef="); expect(page.url()).not.toContain("utm="); expect(userMeCalls).toBe(0); expect(clientFailures).toEqual([]); }); test("로그인 페이지에 붙은 인증 payload도 전용 verify 라우트로 넘긴다", async ({ page, }) => { let userMeCalls = 0; const verifyRequests: Array<{ path: string; body: Record; }> = []; const clientFailures = collectClientFailures(page); await mockUserfrontApis(page, { sessionStatus: 401, captureUserMe: () => { userMeCalls += 1; }, captureVerify: (path, body) => { verifyRequests.push({ path, body }); }, }); await page.goto("/ko/signin?loginId=e2e%40example.com&code=999999"); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); await expect .poll(() => page.url(), { timeout: 10_000 }) .toContain("/ko/verify-complete"); expect(verifyRequests[0].body).toMatchObject({ loginId: "e2e@example.com", code: "999999", verifyOnly: true, }); expect(userMeCalls).toBe(0); expect(page.url()).not.toContain("loginId="); expect(page.url()).not.toContain("code="); expect(clientFailures).toEqual([]); }); test("verifyOnly 승인 링크를 팝업에서 닫으면 창만 닫히고 부모는 이동하지 않는다", async ({ page, }, testInfo) => { test.skip( testInfo.project.name === "webkit-mobile-webapp", "Mobile WebKit closes the opener page when this popup flow closes in headless mode.", ); let userMeCalls = 0; let verifyCalls = 0; const clientFailures = collectClientFailures(page); await mockUserfrontApis(page, { sessionStatus: 401, captureUserMe: () => { userMeCalls += 1; }, captureVerify: () => { verifyCalls += 1; }, }); const baseURL = testInfo.project.use.baseURL; if (typeof baseURL !== "string") throw new Error("baseURL is required"); const popupURL = new URL("/ko/l/AB123456", baseURL).toString(); const parentURL = new URL("/version.json", baseURL).toString(); await page.goto(parentURL); await expect(page).toHaveURL(parentURL); const popupPromise = page.waitForEvent("popup"); await page.evaluate((url) => { window.open(url, "_blank"); }, popupURL); const popup = await popupPromise; await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1); await expect(popup).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); if (!popup.isClosed()) { const closePromise = popup.waitForEvent("close").catch(() => undefined); try { await enableFlutterAccessibility(popup); await popup .getByRole("button", { name: "로그인 창으로 이동하기" }) .click(); } catch (error) { if (!popup.isClosed()) { throw error; } } await closePromise; } expect(userMeCalls).toBe(0); await expect(page).toHaveURL(parentURL); expect(clientFailures).toEqual([]); }); test("verifyOnly 승인 완료 버튼은 이메일 magic link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({ page, }) => { let userMeCalls = 0; const clientFailures = collectClientFailures(page); const verifyRequests: Array<{ path: string; body: Record; }> = []; await mockUserfrontApis(page, { sessionStatus: 401, captureUserMe: () => { userMeCalls += 1; }, captureVerify: (path, body) => { verifyRequests.push({ path, body }); }, }); await makeWindowCloseNavigateToRoot(page); await page.goto("/ko/verify/e2e-email-token"); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); expect(verifyRequests[0].path).toContain("/api/v1/auth/magic-link/verify"); expect(verifyRequests[0].body).toMatchObject({ token: "e2e-email-token", verifyOnly: true, }); await enableFlutterAccessibility(page); await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click(); expect(userMeCalls).toBe(0); await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); expect(clientFailures).toEqual([]); }); test("verifyOnly 승인 완료 버튼은 이메일 code link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({ page, }) => { let userMeCalls = 0; const clientFailures = collectClientFailures(page); const verifyRequests: Array<{ path: string; body: Record; }> = []; await mockUserfrontApis(page, { sessionStatus: 401, captureUserMe: () => { userMeCalls += 1; }, captureVerify: (path, body) => { verifyRequests.push({ path, body }); }, }); await makeWindowCloseNavigateToRoot(page); await page.goto( "/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-email", ); await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1); expect(verifyRequests[0].path).toContain("/api/v1/auth/login/code/verify"); expect(verifyRequests[0].body).toMatchObject({ loginId: "e2e@example.com", code: "654321", pendingRef: "pending-email", verifyOnly: true, }); await enableFlutterAccessibility(page); await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click(); expect(userMeCalls).toBe(0); await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/); expect(clientFailures).toEqual([]); }); });