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..c849f931 100644 --- a/devfront/package.json +++ b/devfront/package.json @@ -12,6 +12,7 @@ "lint": "biome check .", "preview": "vite preview", "test": "playwright test", + "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/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-client-claims-cache.spec.ts b/devfront/tests/devfront-client-claims-cache.spec.ts index a33da642..66f57694 100644 --- a/devfront/tests/devfront-client-claims-cache.spec.ts +++ b/devfront/tests/devfront-client-claims-cache.spec.ts @@ -361,13 +361,7 @@ test.describe("DevFront RP claim cache", () => { const defaultValueInput = page .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( - 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 new file mode 100644 index 00000000..9d50e1c3 --- /dev/null +++ b/devfront/tests/devfront-login-claims.spec.ts @@ -0,0 +1,239 @@ +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 page.getByRole("button", { name: "권한 및 역할" }).click(); + await expect( + page.getByRole("heading", { name: "시스템 역할" }), + ).toBeVisible(); + await expect( + page.getByText( + scenario.role === "super_admin" + ? /^(시스템 관리자|Super Admin|SUPER_ADMIN)$/i + : /^(일반 사용자|General User|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) => { 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/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'; 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,