From e365c97dc0a42a5abe9d921ed1f45e3db01747ac Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 12 Jun 2026 19:52:55 +0900 Subject: [PATCH] =?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); + }); +});