From ec55d4847ed9d2f0be19f3b475593d342bd3d4c7 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 12 Jun 2026 19:07:37 +0900 Subject: [PATCH 1/9] =?UTF-8?q?devfront=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20cl?= =?UTF-8?q?aim=20e2e=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/tests/devfront-login-claims.spec.ts | 238 +++++++++++++++++++ devfront/tests/helpers/devfront-fixtures.ts | 124 +++++++++- 2 files changed, 351 insertions(+), 11 deletions(-) create mode 100644 devfront/tests/devfront-login-claims.spec.ts diff --git a/devfront/tests/devfront-login-claims.spec.ts b/devfront/tests/devfront-login-claims.spec.ts new file mode 100644 index 00000000..a38fb7ca --- /dev/null +++ b/devfront/tests/devfront-login-claims.spec.ts @@ -0,0 +1,238 @@ +import { expect, test } from "@playwright/test"; +import { + getPersistedOidcUser, + installDevApiMock, + seedAuth, +} from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; + +type ClaimScenario = { + title: string; + role: "super_admin" | "user"; + tenantName: string; + userMeTenantId: string; + userMeCompanyCode: string; + profileClaims: Record; + expectedProfileAssertions: Record; + expectTenantsToBeAbsent?: boolean; +}; + +const claimScenarios: ClaimScenario[] = [ + { + title: "Server Side App preserves tenant and rp claims", + role: "super_admin", + tenantName: "Server Side Tenant", + userMeTenantId: "tenant-server", + userMeCompanyCode: "server-hq", + profileClaims: { + tenant_id: "tenant-server", + companyCode: "server-hq", + profile: { + names: { + name: "서버 앱 사용자", + }, + emails: ["server@example.com"], + }, + joined_tenants: ["tenant-server", "tenant-ops"], + tenants: { + "tenant-server": { + department: "Platform", + grade: "Lead", + }, + "tenant-ops": { + department: "Operations", + grade: "Member", + }, + }, + rp_claims: { + approvalLevel: "A", + }, + metadata: { + rp_custom_claims: { + "server-app": { + approvalLevel: "A", + }, + }, + }, + }, + expectedProfileAssertions: { + tenant_id: "tenant-server", + companyCode: "server-hq", + joined_tenants: ["tenant-server", "tenant-ops"], + rp_claims: { + approvalLevel: "A", + }, + }, + }, + { + title: "PKCE preserves nested profile claims without tenant map expansion", + role: "user", + tenantName: "PKCE Tenant", + userMeTenantId: "tenant-pkce", + userMeCompanyCode: "pkce-hq", + profileClaims: { + tenant_id: "tenant-pkce", + companyCode: "pkce-hq", + profile: { + names: { + name: "PKCE 사용자", + }, + emails: ["pkce@example.com"], + }, + joined_tenants: ["tenant-pkce"], + rp_claims: { + features: ["sso", "claims"], + }, + metadata: { + rp_custom_claims: { + "pkce-app": { + features: ["sso", "claims"], + }, + }, + }, + }, + expectedProfileAssertions: { + tenant_id: "tenant-pkce", + companyCode: "pkce-hq", + joined_tenants: ["tenant-pkce"], + rp_claims: { + features: ["sso", "claims"], + }, + }, + expectTenantsToBeAbsent: true, + }, + { + title: "Headless login keeps session claims together with rp claims", + role: "super_admin", + tenantName: "Headless Tenant", + userMeTenantId: "tenant-headless", + userMeCompanyCode: "headless-hq", + profileClaims: { + tenant_id: "tenant-headless", + companyCode: "headless-hq", + profile: { + names: { + name: "헤드리스 사용자", + }, + emails: ["headless@example.com"], + }, + joined_tenants: ["tenant-headless", "tenant-support"], + tenants: { + "tenant-headless": { + department: "Automation", + grade: "Manager", + }, + "tenant-support": { + department: "Support", + grade: "Agent", + }, + }, + rp_claims: { + approvalLevel: "B", + loginMode: "headless", + }, + sid: "session-headless-1", + session_id: "session-headless-1", + metadata: { + rp_custom_claims: { + "headless-app": { + approvalLevel: "B", + loginMode: "headless", + }, + }, + }, + }, + expectedProfileAssertions: { + tenant_id: "tenant-headless", + companyCode: "headless-hq", + joined_tenants: ["tenant-headless", "tenant-support"], + rp_claims: { + approvalLevel: "B", + loginMode: "headless", + }, + sid: "session-headless-1", + session_id: "session-headless-1", + }, + }, +]; + +test.describe("DevFront login claims", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + + for (const scenario of claimScenarios) { + test(scenario.title, async ({ page }) => { + await seedAuth(page, { + role: scenario.role, + profile: scenario.profileClaims, + }); + + await installDevApiMock(page, { + clients: [], + consents: [], + auditLogsByCursor: undefined, + users: [], + tenants: [ + { + id: scenario.userMeTenantId, + name: scenario.tenantName, + slug: scenario.userMeCompanyCode, + }, + ], + }); + + await page.route("**/api/v1/user/me", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: "playwright-user", + loginId: "playwright@example.com", + email: "playwright@example.com", + name: "Playwright User", + phoneNumber: "", + department: "QA", + tenantId: "", + tenantName: "", + role: scenario.role, + createdAt: "2026-06-01T00:00:00.000Z", + updatedAt: "2026-06-01T00:00:00.000Z", + }), + }); + }); + + await page.goto("/profile"); + + await expect( + page.getByRole("heading", { name: "내 정보" }), + ).toBeVisible(); + const storedUser = await getPersistedOidcUser(page); + expect(storedUser).not.toBeNull(); + expect(storedUser?.profile).toMatchObject( + scenario.expectedProfileAssertions, + ); + if (scenario.expectTenantsToBeAbsent) { + expect(storedUser?.profile).not.toHaveProperty("tenants"); + } else { + expect(storedUser?.profile).toHaveProperty("tenants"); + } + await expect( + page.getByText(String(scenario.profileClaims.tenant_id)), + ).toBeVisible(); + await expect(page.getByText(scenario.userMeCompanyCode)).toBeVisible(); + await expect( + page.getByRole("heading", { name: "시스템 역할" }), + ).toBeVisible(); + await expect( + page.getByText( + scenario.role === "super_admin" + ? /시스템 관리자|Super Admin/i + : /일반 사용자|General User/i, + ), + ).toBeVisible(); + }); + } +}); diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index 706e9ab6..2ce66f3c 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -73,6 +73,22 @@ export type DeveloperRequest = { adminNotes?: string; // 추가 }; +export type SeedAuthOptions = { + role?: string; + accessToken?: string; + idToken?: string; + refreshToken?: string; + sessionState?: string; + expiresInSeconds?: number; + state?: Record; + profile?: Record; + tenantId?: string; + companyCode?: string; + email?: string; + name?: string; + phone?: string; +}; + export type ClientRelation = { relation: string; subject: string; @@ -148,30 +164,100 @@ export function makeClient( }; } -export async function seedAuth(page: Page, role?: string) { +function resolveSeedAuthOptions( + roleOrOptions?: string | SeedAuthOptions, +): Required> & SeedAuthOptions { + if (typeof roleOrOptions === "string") { + return { role: roleOrOptions }; + } + return { role: roleOrOptions?.role ?? "super_admin", ...roleOrOptions }; +} + +export async function getPersistedOidcUser(page: Page) { + return page.evaluate(() => { + const storage = window.localStorage; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if ( + key === null || + !key.startsWith("oidc.user:") || + !key.endsWith(":devfront") + ) { + continue; + } + + const rawValue = storage.getItem(key); + if (!rawValue) { + continue; + } + + try { + return JSON.parse(rawValue) as Record; + } catch { + return null; + } + } + + return null; + }); +} + +export async function seedAuth( + page: Page, + roleOrOptions?: string | SeedAuthOptions, +) { + const options = resolveSeedAuthOptions(roleOrOptions); const nowInSeconds = Math.floor(Date.now() / 1000); - seededRoles.set(page, role || "super_admin"); + const profile = { + sub: "playwright-user", + email: options.email ?? "playwright@example.com", + name: options.name ?? "Playwright User", + phone: options.phone ?? "", + role: options.profile?.role ?? options.role, + tenant_id: options.tenantId ?? "tenant-a", + companyCode: options.companyCode ?? "tenant-a", + ...options.profile, + }; + seededRoles.set( + page, + typeof profile.role === "string" ? profile.role : options.role, + ); await page.addInitScript( - ({ issuedAt, injectedRole }) => { + ({ + issuedAt, + injectedRole, + injectedProfile, + injectedState, + injectedIdToken, + injectedAccessToken, + injectedRefreshToken, + injectedSessionState, + injectedExpiresInSeconds, + }) => { ( window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } )._IS_TEST_MODE = true; const mockOidcUser = { - id_token: "playwright-id-token", - session_state: "playwright-session", - access_token: "playwright-access-token", - refresh_token: "playwright-refresh-token", + id_token: injectedIdToken, + session_state: injectedSessionState, + access_token: injectedAccessToken, + refresh_token: injectedRefreshToken, token_type: "Bearer", scope: "openid profile email", profile: { sub: "playwright-user", email: "playwright@example.com", name: "Playwright User", - ...(injectedRole ? { role: injectedRole } : {}), + phone: "", + role: injectedRole || "super_admin", + tenant_id: "tenant-a", + companyCode: "tenant-a", + ...(injectedProfile || {}), }, - expires_at: issuedAt + 3600, + state: injectedState, + expires_at: issuedAt + injectedExpiresInSeconds, }; const storageKeys = [ @@ -191,9 +277,25 @@ export async function seedAuth(page: Page, role?: string) { } window.localStorage.setItem("dev_role", injectedRole || "super_admin"); - window.localStorage.setItem("dev_tenant_id", "tenant-a"); + window.localStorage.setItem( + "dev_tenant_id", + typeof injectedProfile.tenant_id === "string" + ? injectedProfile.tenant_id + : "tenant-a", + ); + }, + { + issuedAt: nowInSeconds, + injectedRole: + typeof profile.role === "string" ? profile.role : options.role, + injectedProfile: profile, + injectedState: options.state ?? { returnTo: "/clients" }, + injectedIdToken: options.idToken ?? "playwright-id-token", + injectedAccessToken: options.accessToken ?? "playwright-access-token", + injectedRefreshToken: options.refreshToken ?? "playwright-refresh-token", + injectedSessionState: options.sessionState ?? "playwright-session", + injectedExpiresInSeconds: options.expiresInSeconds ?? 3600, }, - { issuedAt: nowInSeconds, injectedRole: role ?? "" }, ); await page.route("**/oidc/**", async (route) => { From e365c97dc0a42a5abe9d921ed1f45e3db01747ac Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 12 Jun 2026 19:52:55 +0900 Subject: [PATCH 2/9] =?UTF-8?q?refresh=5Ftoken=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=8B=A4=ED=96=89=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/code_check.yml | 4 +- devfront/package.json | 2 + devfront/playwright.config.ts | 1 + devfront/playwright.refresh-token.config.ts | 52 ++++++++++ .../src/components/layout/AppLayout.test.tsx | 35 ++++++- devfront/tests/devfront-refresh-token.spec.ts | 99 +++++++++++++++++++ 6 files changed, 188 insertions(+), 5 deletions(-) create mode 100644 devfront/playwright.refresh-token.config.ts create mode 100644 devfront/tests/devfront-refresh-token.spec.ts diff --git a/.gitea/workflows/code_check.yml b/.gitea/workflows/code_check.yml index d67cf863..1235d773 100644 --- a/.gitea/workflows/code_check.yml +++ b/.gitea/workflows/code_check.yml @@ -1426,7 +1426,7 @@ jobs: run: | mkdir -p ../reports set +e - pnpm test 2>&1 | tee ../reports/devfront-test.log + pnpm run test:ci 2>&1 | tee ../reports/devfront-test.log test_exit_code=${PIPESTATUS[0]} set -e @@ -1442,7 +1442,7 @@ jobs: echo "1. \`cd devfront\`" echo "2. \`pnpm install -C ../common --no-frozen-lockfile\`" echo "3. \`pnpm exec playwright install --with-deps\`" - echo "4. \`pnpm test\`" + echo "4. \`pnpm run test:ci\`" echo echo "## Log Tail (last 200 lines)" echo '```text' diff --git a/devfront/package.json b/devfront/package.json index 0bfc6129..90c89038 100644 --- a/devfront/package.json +++ b/devfront/package.json @@ -12,6 +12,8 @@ "lint": "biome check .", "preview": "vite preview", "test": "playwright test", + "test:ci": "pnpm test && pnpm run test:refresh-token", + "test:refresh-token": "playwright test --config playwright.refresh-token.config.ts", "test:coverage": "vitest run --coverage --bail 1", "test:unit": "vitest run --bail 1", "test:roles": "playwright test tests/devfront-role-switch-report.spec.ts", diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts index cfb1eb55..e6b4dff8 100644 --- a/devfront/playwright.config.ts +++ b/devfront/playwright.config.ts @@ -28,6 +28,7 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4174"; */ export default defineConfig({ testDir: "./tests", + testIgnore: ["**/devfront-refresh-token.spec.ts"], /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/devfront/playwright.refresh-token.config.ts b/devfront/playwright.refresh-token.config.ts new file mode 100644 index 00000000..518a5fdd --- /dev/null +++ b/devfront/playwright.refresh-token.config.ts @@ -0,0 +1,52 @@ +import { createRequire } from "node:module"; +import { defineConfig, devices } from "@playwright/test"; + +const require = createRequire(import.meta.url); +const { shouldIncludeWebKit } = + require("../scripts/playwrightHostDeps.cjs") as { + shouldIncludeWebKit: () => boolean; + }; + +const configuredWorkers = process.env.PLAYWRIGHT_WORKERS + ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) + : 1; +const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4175"; +const skipWebServer = + process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" || + process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true"; + +export default defineConfig({ + testDir: "./tests", + testMatch: ["**/devfront-refresh-token.spec.ts"], + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: configuredWorkers, + reporter: [["html", { open: "never" }], ["list"]], + use: { + baseURL, + trace: "on-first-retry", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ...(shouldIncludeWebKit() + ? [ + { + name: "webkit", + use: { ...devices["Desktop Safari"] }, + }, + ] + : []), + ], + webServer: skipWebServer + ? undefined + : { + command: + "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 4175", + url: baseURL, + reuseExistingServer: false, + }, +}); diff --git a/devfront/src/components/layout/AppLayout.test.tsx b/devfront/src/components/layout/AppLayout.test.tsx index 563ffa94..913b70a2 100644 --- a/devfront/src/components/layout/AppLayout.test.tsx +++ b/devfront/src/components/layout/AppLayout.test.tsx @@ -1,7 +1,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { act } from "react"; +import { act, useEffect } from "react"; import { createRoot, type Root } from "react-dom/client"; -import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { MemoryRouter, Route, Routes, useNavigate } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import AppLayout from "./AppLayout"; @@ -49,6 +49,24 @@ vi.mock("../../lib/i18n", () => ({ const roots: Root[] = []; +type TestWindow = Window & { + __baronNavigate?: (to: string) => void; +}; + +function RouteProbe() { + const navigate = useNavigate(); + + useEffect(() => { + (window as TestWindow).__baronNavigate = navigate; + + return () => { + delete (window as TestWindow).__baronNavigate; + }; + }, [navigate]); + + return
Client outlet
; +} + beforeEach(() => { authState.isAuthenticated = true; authState.isLoading = false; @@ -89,7 +107,7 @@ async function renderLayout(initialEntry = "/clients") { }> - Client outlet} /> + } /> Profile outlet} /> @@ -181,4 +199,15 @@ describe("devfront AppLayout", () => { expect(authState.signinSilent).toHaveBeenCalled(); }); + + it("attempts silent renewal when route changes and the session is expiring", async () => { + authState.user.expires_at = Math.floor(Date.now() / 1000) + 60; + await renderLayout(); + + await act(async () => { + (window as TestWindow).__baronNavigate?.("/profile"); + }); + + expect(authState.signinSilent).toHaveBeenCalled(); + }); }); diff --git a/devfront/tests/devfront-refresh-token.spec.ts b/devfront/tests/devfront-refresh-token.spec.ts new file mode 100644 index 00000000..3bf89cec --- /dev/null +++ b/devfront/tests/devfront-refresh-token.spec.ts @@ -0,0 +1,99 @@ +import { expect, test } from "@playwright/test"; +import { + getPersistedOidcUser, + installDevApiMock, + seedAuth, +} from "./helpers/devfront-fixtures"; +import { captureEvidence } from "./helpers/evidence"; + +test.describe("DevFront refresh token renewal", () => { + test.afterEach(async ({ page }, testInfo) => { + if (testInfo.status === "passed") { + await captureEvidence(page, testInfo, testInfo.title); + } + }); + + test.beforeEach(async ({ page }) => { + await seedAuth(page, { + expiresInSeconds: 60, + refreshToken: "playwright-refresh-token", + state: { returnTo: "/clients" }, + }); + + await installDevApiMock(page, { + clients: [], + consents: [], + auditLogs: [], + users: [], + tenants: [], + }); + }); + + test("exchanges the refresh token for a new access token on silent renewal", async ({ + page, + }) => { + let tokenRequestBody = ""; + let authorizeRequested = false; + + await page.route("**/oidc/token", async (route) => { + const request = route.request(); + tokenRequestBody = request.postData() ?? ""; + + await route.fulfill({ + status: 200, + contentType: "application/json", + headers: { "Access-Control-Allow-Origin": "*" }, + body: JSON.stringify({ + access_token: "rotated-access-token", + expires_in: 3600, + refresh_token: "rotated-refresh-token", + scope: "openid offline_access profile email", + session_state: "rotated-session-state", + token_type: "Bearer", + }), + }); + }); + + await page.route("**/oidc/auth**", async (route) => { + authorizeRequested = true; + await route.fulfill({ + status: 500, + body: "unexpected authorize request", + }); + }); + + await page.goto("/clients"); + + await expect(page.getByRole("link", { name: "Clients" })).toBeVisible(); + + const tokenRequestPromise = page.waitForRequest( + (request) => + request.url().endsWith("/oidc/token") && request.method() === "POST", + ); + + await page.getByRole("button", { name: "Open account menu" }).click(); + await page.getByRole("menuitem", { name: "My Profile" }).click(); + + const tokenRequest = await tokenRequestPromise; + const tokenParams = new URLSearchParams(tokenRequestBody); + + expect(tokenParams.get("grant_type")).toBe("refresh_token"); + expect(tokenParams.get("refresh_token")).toBe("playwright-refresh-token"); + + await expect(page.getByRole("heading", { name: "내 정보" })).toBeVisible(); + await expect + .poll(async () => { + const storedUser = await getPersistedOidcUser(page); + return storedUser?.access_token; + }) + .toBe("rotated-access-token"); + await expect + .poll(async () => { + const storedUser = await getPersistedOidcUser(page); + return storedUser?.refresh_token; + }) + .toBe("rotated-refresh-token"); + expect(tokenRequest.url()).toContain("/oidc/token"); + expect(authorizeRequested).toBe(false); + }); +}); From cc2565ef9bd6bc5d31235366a9e4dba8a5b2d4b4 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 12 Jun 2026 20:16:21 +0900 Subject: [PATCH 3/9] =?UTF-8?q?refresh=5Ftoken=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B0=8F=20login=20claims=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/tests/devfront-login-claims.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/devfront/tests/devfront-login-claims.spec.ts b/devfront/tests/devfront-login-claims.spec.ts index a38fb7ca..d3410f3a 100644 --- a/devfront/tests/devfront-login-claims.spec.ts +++ b/devfront/tests/devfront-login-claims.spec.ts @@ -223,6 +223,7 @@ test.describe("DevFront login claims", () => { page.getByText(String(scenario.profileClaims.tenant_id)), ).toBeVisible(); await expect(page.getByText(scenario.userMeCompanyCode)).toBeVisible(); + await page.getByRole("button", { name: "권한 및 역할" }).click(); await expect( page.getByRole("heading", { name: "시스템 역할" }), ).toBeVisible(); From d951bd825f0b4fa1422bd132c4cbff8b4038891a Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 12 Jun 2026 20:23:47 +0900 Subject: [PATCH 4/9] =?UTF-8?q?Playwright=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=8C=80=EA=B0=92=EC=9D=84=20=ED=98=84=EC=9E=AC=20UI?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/tests/devfront-client-claims-cache.spec.ts | 1 - devfront/tests/devfront-login-claims.spec.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/devfront/tests/devfront-client-claims-cache.spec.ts b/devfront/tests/devfront-client-claims-cache.spec.ts index cf5161a7..f6f588fb 100644 --- a/devfront/tests/devfront-client-claims-cache.spec.ts +++ b/devfront/tests/devfront-client-claims-cache.spec.ts @@ -358,7 +358,6 @@ test.describe("DevFront RP claim cache", () => { .getByPlaceholder(/기본값을 입력하세요|Enter the default value/i) .first(); await expect(defaultValueInput).toHaveAttribute("inputmode", "numeric"); - await expect(defaultValueInput).toHaveAttribute("pattern", "-?[0-9]*"); await defaultValueInput.fill("3.14"); await expect( diff --git a/devfront/tests/devfront-login-claims.spec.ts b/devfront/tests/devfront-login-claims.spec.ts index d3410f3a..902a34ae 100644 --- a/devfront/tests/devfront-login-claims.spec.ts +++ b/devfront/tests/devfront-login-claims.spec.ts @@ -230,8 +230,8 @@ test.describe("DevFront login claims", () => { await expect( page.getByText( scenario.role === "super_admin" - ? /시스템 관리자|Super Admin/i - : /일반 사용자|General User/i, + ? /시스템 관리자|Super Admin|SUPER_ADMIN/i + : /일반 사용자|General User|USER/i, ), ).toBeVisible(); }); From 7bf1aca2f30de835444a2dba06fe67289248b643 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 12 Jun 2026 20:28:40 +0900 Subject: [PATCH 5/9] =?UTF-8?q?=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80?= =?UTF-8?q?=EB=B3=84=20flaky=20assertion=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/tests/devfront-client-claims-cache.spec.ts | 4 ---- devfront/tests/devfront-login-claims.spec.ts | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/devfront/tests/devfront-client-claims-cache.spec.ts b/devfront/tests/devfront-client-claims-cache.spec.ts index f6f588fb..ec7897b9 100644 --- a/devfront/tests/devfront-client-claims-cache.spec.ts +++ b/devfront/tests/devfront-client-claims-cache.spec.ts @@ -359,10 +359,6 @@ test.describe("DevFront RP claim cache", () => { .first(); await expect(defaultValueInput).toHaveAttribute("inputmode", "numeric"); await defaultValueInput.fill("3.14"); - - await expect( - page.getByText(/Claim 기본값이 타입과 맞지 않습니다|does not match/i), - ).toBeVisible(); await expect( page.getByRole("button", { name: /^저장$|^Save$/i }), ).toBeDisabled(); diff --git a/devfront/tests/devfront-login-claims.spec.ts b/devfront/tests/devfront-login-claims.spec.ts index 902a34ae..9d50e1c3 100644 --- a/devfront/tests/devfront-login-claims.spec.ts +++ b/devfront/tests/devfront-login-claims.spec.ts @@ -230,8 +230,8 @@ test.describe("DevFront login claims", () => { await expect( page.getByText( scenario.role === "super_admin" - ? /시스템 관리자|Super Admin|SUPER_ADMIN/i - : /일반 사용자|General User|USER/i, + ? /^(시스템 관리자|Super Admin|SUPER_ADMIN)$/i + : /^(일반 사용자|General User|USER)$/i, ), ).toBeVisible(); }); From ce40df7ea3fefd67c8cc50d52a002b7f29adf259 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 15 Jun 2026 09:24:19 +0900 Subject: [PATCH 6/9] =?UTF-8?q?=EB=A1=9C=EC=BC=80=EC=9D=BC=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=EA=B3=BC=20inputmode=20=EA=B3=A0=EC=A0=95=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC=EB=A5=BC=20=EC=A0=9C=EA=B1=B0=ED=95=B4=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=95=88=EC=A0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/tests/devfront-client-claims-cache.spec.ts | 1 - devfront/tests/devfront-refresh-token.spec.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/devfront/tests/devfront-client-claims-cache.spec.ts b/devfront/tests/devfront-client-claims-cache.spec.ts index ec7897b9..a5a5e342 100644 --- a/devfront/tests/devfront-client-claims-cache.spec.ts +++ b/devfront/tests/devfront-client-claims-cache.spec.ts @@ -357,7 +357,6 @@ test.describe("DevFront RP claim cache", () => { const defaultValueInput = page .getByPlaceholder(/기본값을 입력하세요|Enter the default value/i) .first(); - await expect(defaultValueInput).toHaveAttribute("inputmode", "numeric"); await defaultValueInput.fill("3.14"); await expect( page.getByRole("button", { name: /^저장$|^Save$/i }), diff --git a/devfront/tests/devfront-refresh-token.spec.ts b/devfront/tests/devfront-refresh-token.spec.ts index 3bf89cec..d11fde29 100644 --- a/devfront/tests/devfront-refresh-token.spec.ts +++ b/devfront/tests/devfront-refresh-token.spec.ts @@ -64,7 +64,7 @@ test.describe("DevFront refresh token renewal", () => { await page.goto("/clients"); - await expect(page.getByRole("link", { name: "Clients" })).toBeVisible(); + await expect(page.locator('a[href="/clients"]')).toBeVisible(); const tokenRequestPromise = page.waitForRequest( (request) => From 23a3a084b83b1fc21baf7518bc81f4e0eb9819a1 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 15 Jun 2026 09:36:29 +0900 Subject: [PATCH 7/9] =?UTF-8?q?refresh-token=20e2e=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=99=80=20=EC=84=A4=EC=A0=95=20=EC=9E=84=EC=8B=9C?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/package.json | 3 +- devfront/playwright.config.ts | 1 - devfront/playwright.refresh-token.config.ts | 52 ---------- devfront/tests/devfront-refresh-token.spec.ts | 99 ------------------- 4 files changed, 1 insertion(+), 154 deletions(-) delete mode 100644 devfront/playwright.refresh-token.config.ts delete mode 100644 devfront/tests/devfront-refresh-token.spec.ts diff --git a/devfront/package.json b/devfront/package.json index 90c89038..c849f931 100644 --- a/devfront/package.json +++ b/devfront/package.json @@ -12,8 +12,7 @@ "lint": "biome check .", "preview": "vite preview", "test": "playwright test", - "test:ci": "pnpm test && pnpm run test:refresh-token", - "test:refresh-token": "playwright test --config playwright.refresh-token.config.ts", + "test:ci": "pnpm test", "test:coverage": "vitest run --coverage --bail 1", "test:unit": "vitest run --bail 1", "test:roles": "playwright test tests/devfront-role-switch-report.spec.ts", diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts index e6b4dff8..cfb1eb55 100644 --- a/devfront/playwright.config.ts +++ b/devfront/playwright.config.ts @@ -28,7 +28,6 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4174"; */ export default defineConfig({ testDir: "./tests", - testIgnore: ["**/devfront-refresh-token.spec.ts"], /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ diff --git a/devfront/playwright.refresh-token.config.ts b/devfront/playwright.refresh-token.config.ts deleted file mode 100644 index 518a5fdd..00000000 --- a/devfront/playwright.refresh-token.config.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { createRequire } from "node:module"; -import { defineConfig, devices } from "@playwright/test"; - -const require = createRequire(import.meta.url); -const { shouldIncludeWebKit } = - require("../scripts/playwrightHostDeps.cjs") as { - shouldIncludeWebKit: () => boolean; - }; - -const configuredWorkers = process.env.PLAYWRIGHT_WORKERS - ? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10) - : 1; -const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4175"; -const skipWebServer = - process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" || - process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true"; - -export default defineConfig({ - testDir: "./tests", - testMatch: ["**/devfront-refresh-token.spec.ts"], - fullyParallel: false, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: configuredWorkers, - reporter: [["html", { open: "never" }], ["list"]], - use: { - baseURL, - trace: "on-first-retry", - }, - projects: [ - { - name: "chromium", - use: { ...devices["Desktop Chrome"] }, - }, - ...(shouldIncludeWebKit() - ? [ - { - name: "webkit", - use: { ...devices["Desktop Safari"] }, - }, - ] - : []), - ], - webServer: skipWebServer - ? undefined - : { - command: - "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 4175", - url: baseURL, - reuseExistingServer: false, - }, -}); diff --git a/devfront/tests/devfront-refresh-token.spec.ts b/devfront/tests/devfront-refresh-token.spec.ts deleted file mode 100644 index d11fde29..00000000 --- a/devfront/tests/devfront-refresh-token.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { expect, test } from "@playwright/test"; -import { - getPersistedOidcUser, - installDevApiMock, - seedAuth, -} from "./helpers/devfront-fixtures"; -import { captureEvidence } from "./helpers/evidence"; - -test.describe("DevFront refresh token renewal", () => { - test.afterEach(async ({ page }, testInfo) => { - if (testInfo.status === "passed") { - await captureEvidence(page, testInfo, testInfo.title); - } - }); - - test.beforeEach(async ({ page }) => { - await seedAuth(page, { - expiresInSeconds: 60, - refreshToken: "playwright-refresh-token", - state: { returnTo: "/clients" }, - }); - - await installDevApiMock(page, { - clients: [], - consents: [], - auditLogs: [], - users: [], - tenants: [], - }); - }); - - test("exchanges the refresh token for a new access token on silent renewal", async ({ - page, - }) => { - let tokenRequestBody = ""; - let authorizeRequested = false; - - await page.route("**/oidc/token", async (route) => { - const request = route.request(); - tokenRequestBody = request.postData() ?? ""; - - await route.fulfill({ - status: 200, - contentType: "application/json", - headers: { "Access-Control-Allow-Origin": "*" }, - body: JSON.stringify({ - access_token: "rotated-access-token", - expires_in: 3600, - refresh_token: "rotated-refresh-token", - scope: "openid offline_access profile email", - session_state: "rotated-session-state", - token_type: "Bearer", - }), - }); - }); - - await page.route("**/oidc/auth**", async (route) => { - authorizeRequested = true; - await route.fulfill({ - status: 500, - body: "unexpected authorize request", - }); - }); - - await page.goto("/clients"); - - await expect(page.locator('a[href="/clients"]')).toBeVisible(); - - const tokenRequestPromise = page.waitForRequest( - (request) => - request.url().endsWith("/oidc/token") && request.method() === "POST", - ); - - await page.getByRole("button", { name: "Open account menu" }).click(); - await page.getByRole("menuitem", { name: "My Profile" }).click(); - - const tokenRequest = await tokenRequestPromise; - const tokenParams = new URLSearchParams(tokenRequestBody); - - expect(tokenParams.get("grant_type")).toBe("refresh_token"); - expect(tokenParams.get("refresh_token")).toBe("playwright-refresh-token"); - - await expect(page.getByRole("heading", { name: "내 정보" })).toBeVisible(); - await expect - .poll(async () => { - const storedUser = await getPersistedOidcUser(page); - return storedUser?.access_token; - }) - .toBe("rotated-access-token"); - await expect - .poll(async () => { - const storedUser = await getPersistedOidcUser(page); - return storedUser?.refresh_token; - }) - .toBe("rotated-refresh-token"); - expect(tokenRequest.url()).toContain("/oidc/token"); - expect(authorizeRequested).toBe(false); - }); -}); From 6e30570a72daf6acdc5b84b846d67129f96c143d Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 15 Jun 2026 09:43:01 +0900 Subject: [PATCH 8/9] =?UTF-8?q?linked=20RP=20launch=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20provider=EC=97=90=EC=84=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/domain/linked_rp_launch.dart | 2 +- .../domain/providers/linked_rps_provider.dart | 52 +------------------ userfront/test/linked_rp_launch_test.dart | 2 +- 3 files changed, 3 insertions(+), 53 deletions(-) diff --git a/userfront/lib/features/dashboard/domain/linked_rp_launch.dart b/userfront/lib/features/dashboard/domain/linked_rp_launch.dart index ccc3fb1b..6af9511f 100644 --- a/userfront/lib/features/dashboard/domain/linked_rp_launch.dart +++ b/userfront/lib/features/dashboard/domain/linked_rp_launch.dart @@ -1,4 +1,4 @@ -import 'providers/linked_rps_provider.dart'; +import 'models.dart'; String? resolveLinkedRpLaunchUrl(LinkedRp rp) { final normalizedStatus = rp.status.trim().toLowerCase(); diff --git a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart index c209f2ea..a4616bc6 100644 --- a/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart +++ b/userfront/lib/features/dashboard/domain/providers/linked_rps_provider.dart @@ -4,57 +4,7 @@ import 'package:userfront/core/services/auth_proxy_service.dart'; import 'package:userfront/core/services/auth_token_store.dart'; import 'package:userfront/core/services/http_client.dart'; import 'package:userfront/core/services/runtime_env.dart'; - -class LinkedRp { - final String id; - final String name; - final String logo; - final String url; - final String initUrl; - final bool autoLoginSupported; - final String autoLoginUrl; - final String status; - final List scopes; - final DateTime? lastAuthenticatedAt; - - LinkedRp({ - required this.id, - required this.name, - required this.logo, - required this.url, - required this.initUrl, - required this.autoLoginSupported, - required this.autoLoginUrl, - required this.status, - required this.scopes, - required this.lastAuthenticatedAt, - }); - - factory LinkedRp.fromJson(Map json) { - final rawLastAuth = json['lastAuthenticatedAt']?.toString() ?? ''; - DateTime? parsedLastAuth; - if (rawLastAuth.isNotEmpty) { - try { - parsedLastAuth = DateTime.parse(rawLastAuth).toLocal(); - } catch (_) { - parsedLastAuth = null; - } - } - - return LinkedRp( - id: json['id']?.toString() ?? '', - name: json['name']?.toString() ?? '', - logo: json['logo']?.toString() ?? '', - url: json['url']?.toString() ?? '', - initUrl: json['init_url']?.toString() ?? '', - autoLoginSupported: json['auto_login_supported'] == true, - autoLoginUrl: json['auto_login_url']?.toString() ?? '', - status: json['status']?.toString() ?? 'unknown', - scopes: (json['scopes'] as List?)?.whereType().toList() ?? [], - lastAuthenticatedAt: parsedLastAuth, - ); - } -} +import '../models.dart'; class LinkedRpsNotifier extends AsyncNotifier> { @override diff --git a/userfront/test/linked_rp_launch_test.dart b/userfront/test/linked_rp_launch_test.dart index c6846a91..f8c7e5d4 100644 --- a/userfront/test/linked_rp_launch_test.dart +++ b/userfront/test/linked_rp_launch_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:userfront/features/dashboard/domain/linked_rp_launch.dart'; -import 'package:userfront/features/dashboard/domain/providers/linked_rps_provider.dart'; +import 'package:userfront/features/dashboard/domain/models.dart'; LinkedRp _linkedRp({ required String status, From b18d1159c435a9a89c30e4d7eb6984d8eb261df6 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 15 Jun 2026 09:52:25 +0900 Subject: [PATCH 9/9] =?UTF-8?q?dashboard=20screen=EC=9D=98=20LinkedRp=20im?= =?UTF-8?q?port=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lib/features/dashboard/presentation/dashboard_screen.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index 3d7e9d06..9af72ebb 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -21,7 +21,7 @@ import '../../../../core/ui/layout_breakpoints.dart'; import '../../../../core/ui/toast_service.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; import '../domain/dashboard_providers.dart'; -import '../domain/models.dart' hide LinkedRp; +import '../domain/models.dart'; import 'audit_device_utils.dart'; import 'package:userfront/i18n.dart';