From ec55d4847ed9d2f0be19f3b475593d342bd3d4c7 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 12 Jun 2026 19:07:37 +0900 Subject: [PATCH] =?UTF-8?q?devfront=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20claim?= =?UTF-8?q?=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) => {