import { expect, test } from "@playwright/test"; import { type ClientStatus, type Consent, installDevApiMock, makeClient, seedAuth, } from "./helpers/devfront-fixtures"; const appNamePlaceholder = /My Awesome Application|예: 멋진 애플리케이션/i; const jwksUri = "https://rp.example.com/.well-known/jwks.json"; test.describe("DevFront clients lifecycle", () => { 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("pkce headless login uses jwks uri only and shows cache actions", async ({ page, }) => { const state = { clients: [ makeClient("client-headless-login", { name: "Headless Login App", type: "pkce", metadata: { request_object_signing_alg: "RS256", }, 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 page .getByRole("switch", { name: /Headless Login \(자체 로그인 UI 사용\)|Headless Login \(Custom Login UI\)/i, }) .click(); 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("none"); 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("pkce 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: "pkce", metadata: { headless_login_enabled: true, request_object_signing_alg: "RS256", }, 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("pkce 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: "pkce", metadata: { headless_login_enabled: true, 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(); }); });