import { expect, test, type Page, type Route } 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; } 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) => { failures.push(error.message); }); page.on('console', (message) => { const text = message.text(); 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 = '/'; }; }); } test.describe('UserFront WASM auth routing', () => { 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); await expect(page).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); 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\/verify-complete$/); await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/); expect(clientFailures).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); const viewport = page.viewportSize(); if (!viewport) throw new Error('viewport is required'); await page.locator('flt-glass-pane').click({ position: { x: Math.floor(viewport.width / 2), y: Math.floor(viewport.height * 0.66), }, force: true, }); await page.waitForTimeout(300); expect(userMeCalls).toBe(0); await expect(page).toHaveURL(/\/ko\/verify-complete$/); await expect(page).not.toHaveURL(/\/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(page).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); 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(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(page).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); expect(verifyRequests[0].body).toMatchObject({ loginId: 'e2e@example.com', code: '999999', verifyOnly: true, }); 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); const viewport = popup.viewportSize(); if (!viewport) throw new Error('viewport is required'); if (!popup.isClosed()) { const closePromise = popup.waitForEvent('close').catch(() => undefined); try { await popup.locator('flt-glass-pane').click({ position: { x: Math.floor(viewport.width / 2), y: Math.floor(viewport.height * 0.66), }, force: true, }); } 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); await expect(page).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); expect(verifyRequests[0].path).toContain('/api/v1/auth/magic-link/verify'); expect(verifyRequests[0].body).toMatchObject({ token: 'e2e-email-token', verifyOnly: true, }); const viewport = page.viewportSize(); if (!viewport) throw new Error('viewport is required'); await page.locator('flt-glass-pane').click({ position: { x: Math.floor(viewport.width / 2), y: Math.floor(viewport.height * 0.66), }, force: true, }); await page.waitForTimeout(300); expect(userMeCalls).toBe(0); await expect(page).toHaveURL(/\/ko\/verify-complete$/); await expect(page).not.toHaveURL(/\/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); await expect(page).toHaveURL(/\/ko\/verify-complete$/); expect(userMeCalls).toBe(0); 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, }); const viewport = page.viewportSize(); if (!viewport) throw new Error('viewport is required'); await page.locator('flt-glass-pane').click({ position: { x: Math.floor(viewport.width / 2), y: Math.floor(viewport.height * 0.66), }, force: true, }); await page.waitForTimeout(300); expect(userMeCalls).toBe(0); await expect(page).toHaveURL(/\/ko\/verify-complete$/); await expect(page).not.toHaveURL(/\/signin(?:\?.*)?$/); expect(clientFailures).toEqual([]); }); });