forked from baron/baron-sso
582 lines
18 KiB
TypeScript
582 lines
18 KiB
TypeScript
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<string, unknown>) => void;
|
|
};
|
|
|
|
async function seedTokenLogin(page: Page): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, unknown> = {};
|
|
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) => {
|
|
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<void> {
|
|
await page.addInitScript(() => {
|
|
window.close = () => {
|
|
window.location.href = '/';
|
|
};
|
|
});
|
|
}
|
|
|
|
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
|
await page.waitForTimeout(300);
|
|
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
|
if (await button.count()) {
|
|
await button.first().evaluate((node) => {
|
|
(node as HTMLElement).click();
|
|
});
|
|
await page.waitForTimeout(200);
|
|
return;
|
|
}
|
|
const placeholder = page.locator('flt-semantics-placeholder').first();
|
|
if (await placeholder.count()) {
|
|
await placeholder.evaluate((node) => {
|
|
(node as HTMLElement).click();
|
|
});
|
|
await page.waitForTimeout(800);
|
|
}
|
|
}
|
|
|
|
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<string, unknown>;
|
|
}> = [];
|
|
|
|
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);
|
|
|
|
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.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<string, unknown>;
|
|
}> = [];
|
|
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<string, unknown>;
|
|
}> = [];
|
|
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);
|
|
|
|
if (!popup.isClosed()) {
|
|
await enableFlutterAccessibility(popup);
|
|
const closePromise = popup.waitForEvent('close').catch(() => undefined);
|
|
try {
|
|
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<string, unknown>;
|
|
}> = [];
|
|
|
|
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,
|
|
});
|
|
|
|
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<string, unknown>;
|
|
}> = [];
|
|
|
|
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,
|
|
});
|
|
|
|
await enableFlutterAccessibility(page);
|
|
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
|
|
|
|
expect(userMeCalls).toBe(0);
|
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
|
expect(clientFailures).toEqual([]);
|
|
});
|
|
});
|