import { expect, test } from "@playwright/test"; import { type ClientStatus, type Consent, installDevApiMock, makeClient, seedAuth, } from "./helpers/devfront-fixtures"; import { captureEvidence } from "./helpers/evidence"; const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i; const jwksUri = "https://rp.example.com/.well-known/jwks.json"; test.describe("DevFront clients lifecycle", () => { test.afterEach(async ({ page }, testInfo) => { if (testInfo.status === "passed") { await captureEvidence(page, testInfo, testInfo.title); } }); test.beforeEach(async ({ page }) => { page.on("dialog", async (dialog) => { await dialog.accept(); }); await seedAuth(page); }); test("create, update status, and delete", async ({ page }) => { const state = { clients: [makeClient("existing-client", { name: "Existing app" })], consents: [] as Consent[], updatedStatus: "active" as ClientStatus, auditLogsByCursor: undefined, onUpdateStatus(status: ClientStatus) { this.updatedStatus = status; }, }; await installDevApiMock(page, state); await page.goto("/clients"); await expect(page.getByText("Existing app")).toBeVisible(); await page .getByRole("button", { name: /연동 앱 추가|새 클라이언트|Create/i }) .click(); await expect(page).toHaveURL(/\/clients\/new$/); await page .getByPlaceholder(appNamePlaceholder) .fill("Playwright Created App"); await page .getByPlaceholder(/https:\/\/app\.example\.com\/callback/i) .fill("https://playwright.example.com/callback"); await page .getByRole("button", { name: /앱 생성|클라이언트 생성|Create/i }) .click(); await expect(page).toHaveURL(/\/clients\/client-\d+\/settings$/); await expect( page.getByRole("heading", { name: /연동 앱 설정|클라이언트 설정|Client Settings/i, }), ).toBeVisible(); await page.getByRole("button", { name: /비활성|Inactive/i }).click(); await page.getByRole("button", { name: /^저장$|^Save$/i }).click(); await expect.poll(() => state.updatedStatus).toBe("inactive"); await page.getByRole("button", { name: /삭제|Delete/i }).click(); await expect(page).toHaveURL(/\/clients$/); await expect(page.getByText("Playwright Created App")).not.toBeVisible(); }); test("rotate secret shows new value", async ({ page }) => { let rotatedSecret = ""; const state = { clients: [makeClient("client-rotate", { name: "Rotate app" })], consents: [] as Consent[], auditLogsByCursor: undefined, onRotateSecret(newSecret: string) { rotatedSecret = newSecret; }, }; await installDevApiMock(page, state); await page.goto("/clients/client-rotate"); await expect( page.getByRole("heading", { name: "Rotate app", exact: true }), ).toBeVisible(); await page.getByTitle(/비밀키 재발급|Rotate/i).click(); await expect.poll(() => rotatedSecret).toBe("client-rotate-rotated-secret"); await expect(page.getByText("client-rotate-rotated-secret")).toBeVisible(); }); test("update name and redirect URI should be persisted", async ({ page }) => { const state = { clients: [ makeClient("client-edit", { name: "Before Name", redirectUris: ["https://before.example.com/callback"], }), ], consents: [] as Consent[], auditLogsByCursor: undefined, }; await installDevApiMock(page, state); await page.goto("/clients/client-edit/settings"); await page.getByPlaceholder(appNamePlaceholder).fill("After Name"); await page.getByRole("button", { name: /^저장$|^Save$/i }).click(); await expect.poll(() => state.clients[0]?.name).toBe("After Name"); await page.goto("/clients/client-edit"); await page .getByRole("textbox", { name: /인증 콜백 URL|Callback/i }) .fill("https://after.example.com/callback"); await page .getByRole("button", { name: /Redirect URIs 저장|Save/i }) .click(); await expect .poll(() => state.clients[0]?.redirectUris[0]) .toBe("https://after.example.com/callback"); await page.reload(); await expect( page.getByRole("textbox", { name: /인증 콜백 URL|Callback/i }), ).toHaveValue(/https:\/\/after\.example\.com\/callback/); }); test("id token claims should be persisted and restored", async ({ page }) => { const state = { clients: [ makeClient("client-claims", { name: "Claims app", metadata: {}, }), ], consents: [] as Consent[], auditLogsByCursor: undefined, }; await installDevApiMock(page, state); await page.goto("/clients/client-claims/settings"); await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click(); await page.getByPlaceholder(/e\.g\. locale|예: locale/i).fill("locale"); await page .getByLabel(/Claim namespace|Claim 네임스페이스/i) .first() .selectOption("top_level"); await page .getByLabel(/Claim value type|Claim 값 타입/i) .first() .selectOption("text"); await page .getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i) .first() .fill("ko-KR"); await page.getByRole("button", { name: /Claim 추가|Add Claim/i }).click(); await page .getByPlaceholder(/e\.g\. locale|예: locale/i) .nth(1) .fill("tier"); await page .getByLabel(/Claim namespace|Claim 네임스페이스/i) .nth(1) .selectOption("rp_claims"); await page .getByLabel(/Claim value type|Claim 값 타입/i) .nth(1) .selectOption("number"); await page .getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i) .nth(1) .fill("2"); await page.getByRole("button", { name: /^저장$|^Save$/i }).click(); await expect .poll(() => state.clients[0]?.metadata?.id_token_claims) .toBeDefined(); await expect .poll( () => ( state.clients[0]?.metadata?.id_token_claims as | Array<{ namespace?: string; key?: string; value?: string; valueType?: string; }> | undefined )?.length, ) .toBe(2); await expect .poll( () => ( state.clients[0]?.metadata?.id_token_claims as | Array<{ namespace?: string; key?: string; value?: string; valueType?: string; }> | undefined )?.[0]?.namespace, ) .toBe("top_level"); await expect .poll( () => ( state.clients[0]?.metadata?.id_token_claims as | Array<{ namespace?: string; key?: string; value?: string; valueType?: string; }> | undefined )?.[0]?.key, ) .toBe("locale"); await expect .poll( () => ( state.clients[0]?.metadata?.id_token_claims as | Array<{ namespace?: string; key?: string; value?: string; valueType?: string; }> | undefined )?.[1]?.namespace, ) .toBe("rp_claims"); await expect .poll( () => ( state.clients[0]?.metadata?.id_token_claims as | Array<{ namespace?: string; key?: string; value?: string; valueType?: string; }> | undefined )?.[1]?.key, ) .toBe("tier"); await page.reload(); await expect( page.getByPlaceholder(/e\.g\. locale|예: locale/i), ).toHaveCount(2); await expect( page.getByPlaceholder(/e\.g\. locale|예: locale/i).first(), ).toHaveValue("locale"); await expect( page.getByPlaceholder(/e\.g\. locale|예: locale/i).nth(1), ).toHaveValue("tier"); await expect( page.getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i), ).toHaveCount(2); await expect( page .getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i) .first(), ).toHaveValue("ko-KR"); await expect( page .getByPlaceholder(/Claim 값을 입력하세요|Enter the claim value/i) .nth(1), ).toHaveValue("2"); }); test("headless login uses jwks uri only and shows cache actions", async ({ page, }) => { const state = { clients: [ makeClient("client-headless-login", { name: "Headless Login App", type: "private", metadata: { headless_login_enabled: true, headless_token_endpoint_auth_method: "private_key_jwt", headless_jwks_uri: jwksUri, }, headlessJwksCache: { clientId: "client-headless-login", jwksUri, cachedAt: "2026-03-31T00:00:00.000Z", expiresAt: "2026-04-01T00:00:00.000Z", lastCheckedAt: "2026-03-31T12:00:00.000Z", lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z", lastRefreshStatus: "success", lastError: "", consecutiveFailures: 0, cachedKids: ["kid-1"], etag: 'W/"cache-etag"', lastModified: "Tue, 31 Mar 2026 00:00:00 GMT", parsedKeys: [ { kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256", n: "voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ", }, ], }, }), ], consents: [] as Consent[], auditLogsByCursor: undefined, onRefreshHeadlessJwks(clientId: string) { if (this.clients[0].headlessJwksCache) { this.clients[0].headlessJwksCache = { ...this.clients[0].headlessJwksCache, lastRefreshStatus: "success", lastCheckedAt: "2026-04-01T00:00:00.000Z", }; } expect(clientId).toBe("client-headless-login"); }, onRevokeHeadlessJwksCache(clientId: string) { expect(clientId).toBe("client-headless-login"); }, }; await installDevApiMock(page, state); await page.goto("/clients/client-headless-login/settings"); await expect( page.getByRole("heading", { name: /공개키 등록|Public Key Registration/i, }), ).toBeVisible(); await expect( page.getByText(/Request Object Signing Algorithm/i), ).toHaveCount(0); await expect( page.getByText(/Allowed algorithms|허용 알고리즘/i), ).toHaveCount(0); await page .getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i) .fill(jwksUri); await page.getByRole("button", { name: /^저장$|^Save$/i }).click(); await expect .poll(() => state.clients[0]?.tokenEndpointAuthMethod) .toBe("private_key_jwt"); await expect .poll(() => state.clients[0]?.metadata?.headless_login_enabled) .toBe(true); await expect .poll( () => state.clients[0]?.metadata?.headless_token_endpoint_auth_method, ) .toBe("private_key_jwt"); await expect .poll(() => state.clients[0]?.metadata?.headless_jwks_uri) .toBe(jwksUri); await expect .poll(() => state.clients[0]?.metadata?.request_object_signing_alg) .toBeUndefined(); await expect( page.getByText(/cached at|캐시됨|last refresh|마지막 갱신/i), ).toBeVisible(); await expect(page.getByText(/Parsed Keys|파싱된 키/i)).toBeVisible(); await expect(page.getByText(/^KID$/i)).toBeVisible(); await expect(page.getByText("kid-1", { exact: true }).last()).toBeVisible(); await expect( page.getByText( "voVbHlo_UHkjtT7Q_8owyjZ2omE8n8mbGlpraZziStHPfe08q_RGiEXO6Pyiz42NVi-Yo0c7qiaqRwB4h9s5phpT2wwcUxnkrQeRhe7BpigInZPzpwq1hsaB2zyhE7zTRCC3hinGtFdVpNzTVKYKGPbXfeEXaRL3P838vi-_iB4IN3WQk_pAakUQvajL2H-vcWSMSNslMGPDZxobqE9MHSWocNXemrcmtCeE7ruUND0qHZOb8k-hHUBqsNoJ63WKdapzGYF6e2qgDRveYrjgOCBigZPi8npN0xStQ0YcrH_RxeTogsdRZ8SuXmLqavryVDnrT8czPkkJ-EHb8PiTCQ", { exact: true }, ), ).toBeVisible(); await expect( page.getByRole("button", { name: /refresh|새로고침/i }), ).toBeVisible(); await expect( page.getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i }), ).toBeVisible(); await page.getByRole("button", { name: /refresh|새로고침/i }).click(); await expect .poll(() => state.clients[0]?.headlessJwksCache?.lastCheckedAt) .toBe("2026-04-01T00:00:00.000Z"); page.removeAllListeners("dialog"); page.once("dialog", async (dialog) => { expect(dialog.message()).toMatch(/revoke|삭제|cache/i); await dialog.accept(); }); await page .getByRole("button", { name: /^(캐시 삭제|Revoke Cache)$/i }) .click(); await expect .poll(() => state.clients[0]?.headlessJwksCache) .toBeUndefined(); await page.reload(); await expect( page.getByRole("heading", { name: /공개키 등록|Public Key Registration/i, }), ).toBeVisible(); await expect( page.getByRole("textbox", { name: /JWKS URI|JWKS URI/i }), ).toHaveValue(jwksUri); }); test("auto login settings are stored in client metadata", async ({ page, }) => { const autoLoginUrl = "https://rp.example.com/login?auto=1"; const state = { clients: [makeClient("client-auto-login", { name: "Auto Login app" })], consents: [] as Consent[], auditLogsByCursor: undefined, }; await installDevApiMock(page, state); await page.goto("/clients/client-auto-login/settings"); await page .getByRole("switch", { name: /자동 로그인 지원|Auto Login/i }) .click(); await page .getByPlaceholder(/https:\/\/app\.example\.com\/login\?auto=1/i) .fill(autoLoginUrl); await page.getByRole("button", { name: /^저장$|^Save$/i }).click(); await expect .poll(() => state.clients[0]?.metadata?.auto_login_supported) .toBe(true); await expect .poll(() => state.clients[0]?.metadata?.auto_login_url) .toBe(autoLoginUrl); }); test("headless login blocks save when parsed jwks algorithm is unsupported", async ({ page, }) => { const state = { clients: [ makeClient("client-headless-unsupported", { name: "Unsupported Headless Login App", type: "private", metadata: { headless_login_enabled: true, headless_token_endpoint_auth_method: "private_key_jwt", headless_jwks_uri: jwksUri, }, headlessJwksCache: { clientId: "client-headless-unsupported", jwksUri, cachedAt: "2026-03-31T00:00:00.000Z", expiresAt: "2026-04-01T00:00:00.000Z", lastCheckedAt: "2026-03-31T12:00:00.000Z", lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z", lastRefreshStatus: "success", lastError: "", consecutiveFailures: 0, cachedKids: ["kid-unsupported"], parsedKeys: [ { kid: "kid-unsupported", kty: "RSA", use: "sig", alg: "HS256", n: "unsupported-n-value", }, ], }, }), ], consents: [] as Consent[], auditLogsByCursor: undefined, }; await installDevApiMock(page, state); await page.goto("/clients/client-headless-unsupported/settings"); await page .getByPlaceholder(/https:\/\/rp\.example\.com\/\.well-known\/jwks\.json/i) .fill(jwksUri); await expect( page.getByText("지원하지 않는 알고리즘이 감지되었습니다.", { exact: true, }), ).toBeVisible(); await expect( page.getByRole("button", { name: /^저장$|^Save$/i }), ).toBeDisabled(); }); test("headless login blocks save when parsed jwks algorithm is missing", async ({ page, }) => { const state = { clients: [ makeClient("client-headless-missing-alg", { name: "Missing Alg Headless Login App", type: "private", metadata: { headless_login_enabled: true, headless_token_endpoint_auth_method: "private_key_jwt", headless_jwks_uri: jwksUri, }, headlessJwksCache: { clientId: "client-headless-missing-alg", jwksUri, cachedAt: "2026-03-31T00:00:00.000Z", expiresAt: "2026-04-01T00:00:00.000Z", lastCheckedAt: "2026-03-31T12:00:00.000Z", lastSuccessfulVerificationAt: "2026-03-31T12:00:00.000Z", lastRefreshStatus: "success", lastError: "", consecutiveFailures: 0, cachedKids: ["kid-missing-alg"], parsedKeys: [ { kid: "kid-missing-alg", kty: "RSA", use: "sig", alg: "", n: "missing-alg-n-value", }, ], }, }), ], consents: [] as Consent[], auditLogsByCursor: undefined, }; await installDevApiMock(page, state); await page.goto("/clients/client-headless-missing-alg/settings"); await expect( page.getByText(/알고리즘이 선언되지 않았습니다|algorithm is missing/i), ).toBeVisible(); await expect( page.getByRole("button", { name: /^저장$|^Save$/i }), ).toBeDisabled(); }); });