forked from baron/baron-sso
420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
|
|
|
|
type RequestCapture = {
|
|
loginBody?: Record<string, unknown>;
|
|
resetBody?: Record<string, unknown>;
|
|
resetToken?: string | null;
|
|
clientLogs: string[];
|
|
};
|
|
|
|
const resetNewPasswordName = /^(새 비밀번호|ui\.userfront\.reset\.new_password)$/;
|
|
const resetConfirmPasswordName =
|
|
/^(새 비밀번호 확인|ui\.userfront\.reset\.confirm_password)$/;
|
|
const resetSubmitButtonName = /^(비밀번호 변경|ui\.userfront\.reset\.submit)$/;
|
|
|
|
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
|
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;
|
|
}
|
|
await page.waitForTimeout(300);
|
|
const placeholder = page.locator('flt-semantics-placeholder').first();
|
|
if (await placeholder.count()) {
|
|
await placeholder.evaluate((node) => {
|
|
(node as HTMLElement).click();
|
|
});
|
|
await page.waitForTimeout(800);
|
|
}
|
|
}
|
|
|
|
type ScreenCoords = {
|
|
signinPasswordTabX: number;
|
|
signinTabY: number;
|
|
signinLoginIdX: number;
|
|
signinLoginIdY: number;
|
|
signinPasswordX: number;
|
|
signinPasswordY: number;
|
|
signinSubmitX: number;
|
|
signinSubmitY: number;
|
|
resetNewPasswordX: number;
|
|
resetNewPasswordY: number;
|
|
resetConfirmPasswordX: number;
|
|
resetConfirmPasswordY: number;
|
|
resetSubmitX: number;
|
|
resetSubmitY: number;
|
|
};
|
|
|
|
const desktopCoords: ScreenCoords = {
|
|
signinPasswordTabX: 522,
|
|
signinTabY: 158,
|
|
signinLoginIdX: 640,
|
|
signinLoginIdY: 245,
|
|
signinPasswordX: 640,
|
|
signinPasswordY: 311,
|
|
signinSubmitX: 640,
|
|
signinSubmitY: 381,
|
|
resetNewPasswordX: 640,
|
|
resetNewPasswordY: 382,
|
|
resetConfirmPasswordX: 640,
|
|
resetConfirmPasswordY: 464,
|
|
resetSubmitX: 640,
|
|
resetSubmitY: 534,
|
|
};
|
|
|
|
const mobileCoords: ScreenCoords = {
|
|
signinPasswordTabX: 90,
|
|
signinTabY: 158,
|
|
signinLoginIdX: 206,
|
|
signinLoginIdY: 268,
|
|
signinPasswordX: 206,
|
|
signinPasswordY: 334,
|
|
signinSubmitX: 206,
|
|
signinSubmitY: 399,
|
|
resetNewPasswordX: 206,
|
|
resetNewPasswordY: 382,
|
|
resetConfirmPasswordX: 206,
|
|
resetConfirmPasswordY: 464,
|
|
resetSubmitX: 206,
|
|
resetSubmitY: 534,
|
|
};
|
|
|
|
function coordsFor(page: Page): ScreenCoords {
|
|
const viewport = page.viewportSize();
|
|
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
|
|
}
|
|
|
|
function isMobileProject(page: Page): boolean {
|
|
const viewport = page.viewportSize();
|
|
return (viewport?.width ?? 1280) <= 500;
|
|
}
|
|
|
|
async function clickPasswordTab(page: Page): Promise<void> {
|
|
if (isMobileProject(page)) {
|
|
return;
|
|
}
|
|
const coords = coordsFor(page);
|
|
await page.waitForTimeout(900);
|
|
const pane = page.locator('flt-glass-pane');
|
|
await pane.click({
|
|
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
|
|
force: true,
|
|
});
|
|
await page.waitForTimeout(120);
|
|
await pane.click({
|
|
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
|
|
force: true,
|
|
});
|
|
await page.waitForTimeout(200);
|
|
}
|
|
|
|
async function fillAt(page: Page, x: number, y: number, value: string): Promise<void> {
|
|
const pane = page.locator('flt-glass-pane');
|
|
await pane.click({ position: { x, y }, force: true });
|
|
await page.waitForTimeout(100);
|
|
await page.keyboard.press('Control+A');
|
|
await page.keyboard.press('Backspace');
|
|
await page.keyboard.type(value);
|
|
}
|
|
|
|
async function typeIntoAccessibleField(
|
|
page: Page,
|
|
field: Locator,
|
|
value: string,
|
|
): Promise<void> {
|
|
await field.click({ force: true });
|
|
await page.waitForTimeout(100);
|
|
await page.keyboard.press('Control+A');
|
|
await page.keyboard.press('Backspace');
|
|
await page.keyboard.type(value);
|
|
}
|
|
|
|
async function fillPasswordLoginForm(
|
|
page: Page,
|
|
loginId: string,
|
|
password: string,
|
|
): Promise<void> {
|
|
if (isMobileProject(page)) {
|
|
await enableFlutterAccessibility(page);
|
|
const inputs = page.getByRole('textbox');
|
|
await inputs.nth(0).fill(loginId);
|
|
await inputs.nth(1).fill(password);
|
|
return;
|
|
}
|
|
const coords = coordsFor(page);
|
|
await fillAt(page, coords.signinLoginIdX, coords.signinLoginIdY, loginId);
|
|
await fillAt(page, coords.signinPasswordX, coords.signinPasswordY, password);
|
|
}
|
|
|
|
async function submitPasswordLogin(page: Page): Promise<void> {
|
|
if (isMobileProject(page)) {
|
|
await enableFlutterAccessibility(page);
|
|
await page.getByRole('button', { name: '로그인' }).click({ force: true });
|
|
return;
|
|
}
|
|
await page.keyboard.press('Enter');
|
|
}
|
|
|
|
async function fillResetPasswordForm(page: Page, password: string): Promise<void> {
|
|
await enableFlutterAccessibility(page);
|
|
const newPasswordInput = page.getByRole('textbox', {
|
|
name: resetNewPasswordName,
|
|
});
|
|
const confirmPasswordInput = page.getByRole('textbox', {
|
|
name: resetConfirmPasswordName,
|
|
});
|
|
if ((await newPasswordInput.count()) > 0 && (await confirmPasswordInput.count()) > 0) {
|
|
await typeIntoAccessibleField(page, newPasswordInput, password);
|
|
await typeIntoAccessibleField(page, confirmPasswordInput, password);
|
|
return;
|
|
}
|
|
if (isMobileProject(page)) {
|
|
await page.getByRole('textbox', { name: resetNewPasswordName }).fill(password);
|
|
await page.getByRole('textbox', { name: resetConfirmPasswordName }).fill(password);
|
|
return;
|
|
}
|
|
const coords = coordsFor(page);
|
|
await fillAt(page, coords.resetNewPasswordX, coords.resetNewPasswordY, password);
|
|
await fillAt(
|
|
page,
|
|
coords.resetConfirmPasswordX,
|
|
coords.resetConfirmPasswordY,
|
|
password,
|
|
);
|
|
}
|
|
|
|
async function submitResetPassword(page: Page): Promise<void> {
|
|
await enableFlutterAccessibility(page);
|
|
const submitButton = page.getByRole('button', { name: resetSubmitButtonName });
|
|
if ((await submitButton.count()) > 0) {
|
|
await submitButton.click({ force: true });
|
|
return;
|
|
}
|
|
if (isMobileProject(page)) {
|
|
return;
|
|
}
|
|
const coords = coordsFor(page);
|
|
await page.locator('flt-glass-pane').click({
|
|
position: { x: coords.resetSubmitX, y: coords.resetSubmitY },
|
|
force: true,
|
|
});
|
|
}
|
|
|
|
async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void> {
|
|
await page.route('**/api/v1/**', async (route: Route) => {
|
|
const requestUrl = new URL(route.request().url());
|
|
const path = requestUrl.pathname;
|
|
|
|
if (path.endsWith('/api/v1/auth/password/login')) {
|
|
capture.loginBody = (route.request().postDataJSON() ?? {}) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
|
|
const loginId = String(capture.loginBody.loginId ?? '');
|
|
const password = String(capture.loginBody.password ?? '');
|
|
if (loginId === 'e2e@example.com' && password === 'ValidPass1!') {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
sessionJwt: 'e30.e30.e30',
|
|
provider: 'ory',
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
await route.fulfill({
|
|
status: 401,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: 'password_or_email_mismatch' }),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path.endsWith('/api/v1/auth/password/policy')) {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({
|
|
minLength: 12,
|
|
minCharacterTypes: 3,
|
|
lowercase: true,
|
|
uppercase: true,
|
|
number: true,
|
|
nonAlphanumeric: true,
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path.endsWith('/api/v1/auth/password/reset/complete')) {
|
|
capture.resetBody = (route.request().postDataJSON() ?? {}) as Record<
|
|
string,
|
|
unknown
|
|
>;
|
|
capture.resetToken = requestUrl.searchParams.get('token');
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ status: 'ok' }),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path.endsWith('/api/v1/client-log')) {
|
|
const payload = (route.request().postDataJSON() ?? {}) as {
|
|
message?: string;
|
|
};
|
|
if (payload.message != null) {
|
|
capture.clientLogs.push(payload.message);
|
|
}
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ ok: true }),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (path.endsWith('/api/v1/user/me')) {
|
|
const authHeader = route.request().headers()['authorization'] ?? '';
|
|
if (!authHeader.startsWith('Bearer ')) {
|
|
await route.fulfill({
|
|
status: 401,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: 'unauthorized' }),
|
|
});
|
|
return;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({}),
|
|
});
|
|
});
|
|
}
|
|
|
|
test.describe('UserFront WASM password login and reset', () => {
|
|
test.skip(({ isMobile }) => isMobile, 'Desktop only (hardcoded coordinates)');
|
|
test('비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다', async ({ page }) => {
|
|
test.skip(
|
|
isMobileProject(page),
|
|
'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.',
|
|
);
|
|
const capture: RequestCapture = { clientLogs: [] };
|
|
await mockAuthApis(page, capture);
|
|
|
|
await page.goto('/ko/signin');
|
|
await clickPasswordTab(page);
|
|
await fillPasswordLoginForm(page, 'e2e@example.com', 'ValidPass1!');
|
|
await submitPasswordLogin(page);
|
|
|
|
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
|
|
|
expect(capture.loginBody?.loginId).toBe('e2e@example.com');
|
|
expect(capture.loginBody?.password).toBe('ValidPass1!');
|
|
|
|
const storedToken = await page.evaluate(() =>
|
|
window.localStorage.getItem('baron_auth_token'),
|
|
);
|
|
expect(storedToken).toBe('e30.e30.e30');
|
|
});
|
|
|
|
test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => {
|
|
test.skip(
|
|
isMobileProject(page),
|
|
'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.',
|
|
);
|
|
const capture: RequestCapture = { clientLogs: [] };
|
|
await mockAuthApis(page, capture);
|
|
|
|
await page.goto('/ko/signin');
|
|
await clickPasswordTab(page);
|
|
await fillPasswordLoginForm(page, 'e2e@example.com', 'WrongPass1!');
|
|
await submitPasswordLogin(page);
|
|
|
|
await expect(page).toHaveURL(/\/ko\/signin$/);
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
capture.clientLogs.some((message) =>
|
|
message.includes('password_or_email_mismatch'),
|
|
),
|
|
{ timeout: 10000 },
|
|
)
|
|
.toBe(true);
|
|
});
|
|
|
|
test('reset-password에서 변경 성공 시 signin으로 이동한다', async ({ page }) => {
|
|
const capture: RequestCapture = { clientLogs: [] };
|
|
await mockAuthApis(page, capture);
|
|
|
|
const policyLoaded = page.waitForResponse(
|
|
(response) =>
|
|
response.url().includes('/api/v1/auth/password/policy') &&
|
|
response.status() === 200,
|
|
);
|
|
await page.goto('/ko/reset-password?token=reset-token-e2e');
|
|
await policyLoaded;
|
|
await page.waitForTimeout(900);
|
|
await fillResetPasswordForm(page, 'ValidPass1!A');
|
|
await submitResetPassword(page);
|
|
|
|
await expect
|
|
.poll(
|
|
() => capture.resetBody?.newPassword as string | undefined,
|
|
{ timeout: 10000 },
|
|
)
|
|
.toBe('ValidPass1!A');
|
|
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/, { timeout: 10_000 });
|
|
expect(capture.resetToken).toBe('reset-token-e2e');
|
|
expect(capture.resetBody?.newPassword).toBe('ValidPass1!A');
|
|
});
|
|
});
|