1
0
forked from baron/baron-sso

ci: add code check badges and coverage reports

This commit is contained in:
2026-05-29 12:05:43 +09:00
parent c489c7c38f
commit a830242947
164 changed files with 9059 additions and 2012 deletions

View File

@@ -1,4 +1,4 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { expect, type Page, type Route, test } from "@playwright/test";
type MockOptions = {
sessionStatus?: number;
@@ -9,23 +9,23 @@ type MockOptions = {
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');
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');
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");
});
}
@@ -35,29 +35,29 @@ async function mockUserfrontApis(
): Promise<void> {
const sessionStatus = options.sessionStatus ?? 200;
await page.context().route('**/api/v1/**', async (route: Route) => {
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')) {
if (path.endsWith("/api/v1/user/me")) {
options.captureUserMe?.();
if (sessionStatus === 200) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({
id: 'e2e-user',
email: 'e2e@example.com',
name: 'E2E User',
phone: '+821012341234',
department: 'QA',
affiliationType: 'employee',
companyCode: 'BARON',
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',
id: "tenant-1",
name: "Baron",
slug: "baron",
description: "E2E tenant",
},
}),
});
@@ -66,32 +66,32 @@ async function mockUserfrontApis(
await route.fulfill({
status: sessionStatus,
contentType: 'application/json',
body: JSON.stringify({ error: 'unauthorized' }),
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
if (path.endsWith('/api/v1/user/rp/linked')) {
if (path.endsWith("/api/v1/user/rp/linked")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
return;
}
if (path.endsWith('/api/v1/audit/auth/timeline')) {
if (path.endsWith("/api/v1/audit/auth/timeline")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], next_cursor: '' }),
contentType: "application/json",
body: JSON.stringify({ items: [], next_cursor: "" }),
});
return;
}
if (path.endsWith('/api/v1/auth/qr/approve')) {
if (route.request().method() == 'POST') {
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 {
@@ -100,44 +100,55 @@ async function mockUserfrontApis(
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);
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`);
console.log(
`[E2E-MOCK] /api/v1/auth/qr/approve ${route.request().method()} request`,
);
}
await route.fulfill({
status: 200,
contentType: 'application/json',
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')
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>;
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' }),
contentType: "application/json",
body: JSON.stringify({
status: "approved",
pendingRef: "e2e-approved",
}),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({}),
});
});
@@ -145,15 +156,15 @@ async function mockUserfrontApis(
function collectClientFailures(page: Page): string[] {
const failures: string[] = [];
page.on('pageerror', (error) => {
page.on("pageerror", (error) => {
failures.push(error.message);
});
page.on('console', (message) => {
page.on("console", (message) => {
const text = message.text();
if (
message.type() === 'error' ||
message.type() === "error" ||
(/exception|verify_failed|verification failed|인증 실패/i.test(text) &&
!text.includes('Exception while loading service worker'))
!text.includes("Exception while loading service worker"))
) {
failures.push(text);
}
@@ -164,14 +175,14 @@ function collectClientFailures(page: Page): string[] {
async function makeWindowCloseNavigateToRoot(page: Page): Promise<void> {
await page.addInitScript(() => {
window.close = () => {
window.location.href = '/';
window.location.href = "/";
};
});
}
async function enableFlutterAccessibility(page: Page): Promise<void> {
await page.waitForTimeout(300);
const button = page.getByRole('button', { name: 'Enable accessibility' });
const button = page.getByRole("button", { name: "Enable accessibility" });
if (await button.count()) {
await button.first().evaluate((node) => {
(node as HTMLElement).click();
@@ -179,7 +190,7 @@ async function enableFlutterAccessibility(page: Page): Promise<void> {
await page.waitForTimeout(200);
return;
}
const placeholder = page.locator('flt-semantics-placeholder').first();
const placeholder = page.locator("flt-semantics-placeholder").first();
if (await placeholder.count()) {
await placeholder.evaluate((node) => {
(node as HTMLElement).click();
@@ -188,47 +199,51 @@ async function enableFlutterAccessibility(page: Page): Promise<void> {
}
}
test.describe('UserFront WASM auth routing', () => {
test('비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다', async ({ page }) => {
test.describe("UserFront WASM auth routing", () => {
test("비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다", async ({
page,
}) => {
await mockUserfrontApis(page, { sessionStatus: 401 });
await page.goto('/ko');
await page.goto("/ko");
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
});
test('로그인 상태 /ko 진입 후 새로고침해도 /ko/dashboard 를 유지한다', async ({
test("로그인 상태 /ko 진입 후 새로고침해도 /ko/dashboard 를 유지한다", async ({
page,
}) => {
await seedTokenLogin(page);
await mockUserfrontApis(page);
await page.goto('/ko');
await page.goto("/ko");
await expect(page).toHaveURL(/\/ko\/dashboard$/);
await page.reload();
await expect(page).toHaveURL(/\/ko\/dashboard$/);
});
test('sessionStorage 기반 로그인 상태에서도 /ko/dashboard 를 유지한다', async ({
test("sessionStorage 기반 로그인 상태에서도 /ko/dashboard 를 유지한다", async ({
page,
}) => {
await seedSessionTokenLogin(page);
await mockUserfrontApis(page);
await page.goto('/ko');
await page.goto("/ko");
await expect(page).toHaveURL(/\/ko\/dashboard$/);
});
test('비로그인 /ko/approve 는 signin(+notice)으로 이동한다', async ({ page }) => {
test("비로그인 /ko/approve 는 signin(+notice)으로 이동한다", async ({
page,
}) => {
await mockUserfrontApis(page, { sessionStatus: 401 });
await page.goto('/ko/approve?ref=e2e-ref');
await page.goto("/ko/approve?ref=e2e-ref");
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
});
test('로그인 상태 /ko/approve 는 승인 API 호출 후 dashboard로 이동한다', async ({
test("로그인 상태 /ko/approve 는 승인 API 호출 후 dashboard로 이동한다", async ({
page,
}) => {
let approvedRef: string | null = null;
@@ -240,15 +255,15 @@ test.describe('UserFront WASM auth routing', () => {
},
});
await page.goto('/ko/approve?ref=e2e-approve-ref');
await page.goto("/ko/approve?ref=e2e-approve-ref");
await expect(page).toHaveURL(/\/ko\/dashboard(?:\?.*)?$/, {
timeout: 10_000,
});
expect(approvedRef).toBe('e2e-approve-ref');
expect(approvedRef).toBe("e2e-approve-ref");
});
test('verifyOnly 승인 완료 화면의 상단 액션은 signin으로 이동시키지 않는다', async ({
test("verifyOnly 승인 완료 화면의 상단 액션은 signin으로 이동시키지 않는다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -269,20 +284,20 @@ test.describe('UserFront WASM auth routing', () => {
});
await makeWindowCloseNavigateToRoot(page);
await page.goto('/ko/l/AB123456');
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',
"/api/v1/auth/login/code/verify-short",
);
expect(verifyRequests[0].body).toMatchObject({
shortCode: 'AB123456',
shortCode: "AB123456",
verifyOnly: true,
});
await page.locator('flt-glass-pane').click({
await page.locator("flt-glass-pane").click({
position: { x: 30, y: 28 },
force: true,
});
@@ -293,7 +308,7 @@ test.describe('UserFront WASM auth routing', () => {
expect(clientFailures).toEqual([]);
});
test('verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다', async ({
test("verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -311,25 +326,25 @@ test.describe('UserFront WASM auth routing', () => {
});
await makeWindowCloseNavigateToRoot(page);
await page.goto('/ko/l/AB123456');
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();
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
expect(userMeCalls).toBe(0);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
expect(
clientFailures.filter(
(failure) => !failure.includes('401 (Unauthorized)'),
(failure) => !failure.includes("401 (Unauthorized)"),
),
).toEqual([]);
});
test('verifyOnly 원격 승인 완료는 로그인 창 이동 모달 CTA를 표시한다', async ({
test("verifyOnly 원격 승인 완료는 로그인 창 이동 모달 CTA를 표시한다", async ({
page,
}) => {
let verifyCalls = 0;
@@ -343,26 +358,26 @@ test.describe('UserFront WASM auth routing', () => {
});
await makeWindowCloseNavigateToRoot(page);
await page.goto('/ko/l/AB123456');
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('요청하신 로그인이 완료되었습니다'),
page.getByText("요청하신 로그인이 완료되었습니다"),
).toBeVisible();
await expect(page.getByRole('button', { name: '창 닫기' })).toHaveCount(0);
await expect(page.getByRole("button", { name: "창 닫기" })).toHaveCount(0);
await expect(
page.getByRole('button', { name: '로그인 창으로 이동하기' }),
page.getByRole("button", { name: "로그인 창으로 이동하기" }),
).toBeVisible();
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
expect(clientFailures).toEqual([]);
});
test('루트/로그인에 붙은 인증 payload는 전용 verify 라우트에서만 소비하고 완료 URL을 정리한다', async ({
test("루트/로그인에 붙은 인증 payload는 전용 verify 라우트에서만 소비하고 완료 URL을 정리한다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -383,26 +398,26 @@ test.describe('UserFront WASM auth routing', () => {
});
await page.goto(
'/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop',
"/?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].path).toContain("/api/v1/auth/login/code/verify");
expect(verifyRequests[0].body).toMatchObject({
loginId: 'e2e@example.com',
code: '654321',
pendingRef: 'pending-root',
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(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 ({
test("로그인 페이지에 붙은 인증 payload도 전용 verify 라우트로 넘긴다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -422,26 +437,26 @@ test.describe('UserFront WASM auth routing', () => {
},
});
await page.goto('/ko/signin?loginId=e2e%40example.com&code=999999');
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',
loginId: "e2e@example.com",
code: "999999",
verifyOnly: true,
});
expect(page.url()).not.toContain('loginId=');
expect(page.url()).not.toContain('code=');
expect(page.url()).not.toContain("loginId=");
expect(page.url()).not.toContain("code=");
expect(clientFailures).toEqual([]);
});
test('verifyOnly 승인 링크를 팝업에서 닫으면 창만 닫히고 부모는 이동하지 않는다', async ({
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.',
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;
@@ -458,16 +473,16 @@ test.describe('UserFront WASM auth routing', () => {
});
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();
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');
const popupPromise = page.waitForEvent("popup");
await page.evaluate((url) => {
window.open(url, '_blank');
window.open(url, "_blank");
}, popupURL);
const popup = await popupPromise;
@@ -477,10 +492,10 @@ test.describe('UserFront WASM auth routing', () => {
if (!popup.isClosed()) {
await enableFlutterAccessibility(popup);
const closePromise = popup.waitForEvent('close').catch(() => undefined);
const closePromise = popup.waitForEvent("close").catch(() => undefined);
try {
await popup
.getByRole('button', { name: '로그인 창으로 이동하기' })
.getByRole("button", { name: "로그인 창으로 이동하기" })
.click();
} catch (error) {
if (!popup.isClosed()) {
@@ -495,7 +510,7 @@ test.describe('UserFront WASM auth routing', () => {
expect(clientFailures).toEqual([]);
});
test('verifyOnly 승인 완료 버튼은 이메일 magic link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다', async ({
test("verifyOnly 승인 완료 버튼은 이메일 magic link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -516,26 +531,26 @@ test.describe('UserFront WASM auth routing', () => {
});
await makeWindowCloseNavigateToRoot(page);
await page.goto('/ko/verify/e2e-email-token');
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].path).toContain("/api/v1/auth/magic-link/verify");
expect(verifyRequests[0].body).toMatchObject({
token: 'e2e-email-token',
token: "e2e-email-token",
verifyOnly: true,
});
await enableFlutterAccessibility(page);
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
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 ({
test("verifyOnly 승인 완료 버튼은 이메일 code link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -557,22 +572,22 @@ test.describe('UserFront WASM auth routing', () => {
await makeWindowCloseNavigateToRoot(page);
await page.goto(
'/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-email',
"/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].path).toContain("/api/v1/auth/login/code/verify");
expect(verifyRequests[0].body).toMatchObject({
loginId: 'e2e@example.com',
code: '654321',
pendingRef: 'pending-email',
loginId: "e2e@example.com",
code: "654321",
pendingRef: "pending-email",
verifyOnly: true,
});
await enableFlutterAccessibility(page);
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
expect(userMeCalls).toBe(0);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);