import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { MemoryRouter, Route, Routes } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ClientDetailResponse } from "../../lib/devApi"; import ClientGeneralPage from "./ClientGeneralPage"; const navigateMock = vi.fn(); const fetchClientMock = vi.fn(); const updateClientMock = vi.fn(); const fetchClientRelationsMock = vi.fn(); const fetchMyTenantsMock = vi.fn(); const fetchMeMock = vi.fn(); let authState = { user: { access_token: "access-token", profile: { sub: "admin-user", role: "super_admin", name: "Dev Admin", }, }, }; vi.mock("react-oidc-context", () => ({ useAuth: () => authState, })); vi.mock("react-router-dom", async () => { const actual = await vi.importActual( "react-router-dom", ); return { ...actual, useNavigate: () => navigateMock, }; }); vi.mock("../../lib/devApi", () => ({ createClient: vi.fn(), deleteClient: vi.fn(), fetchClient: (...args: unknown[]) => fetchClientMock(...args), fetchClientRelations: (...args: unknown[]) => fetchClientRelationsMock(...args), fetchMyTenants: (...args: unknown[]) => fetchMyTenantsMock(...args), refreshHeadlessJwksCache: vi.fn(), revokeHeadlessJwksCache: vi.fn(), updateClient: (...args: unknown[]) => updateClientMock(...args), updateClientStatus: vi.fn(), })); vi.mock("../auth/authApi", () => ({ fetchMe: (...args: unknown[]) => fetchMeMock(...args), })); vi.mock("../../lib/i18n", () => ({ t: (key: string, fallback?: string, vars?: Record) => { let text = fallback ?? key; for (const [name, value] of Object.entries(vars ?? {})) { text = text.replaceAll(`{{${name}}}`, String(value)); } return text; }, })); const roots: Root[] = []; function makeClientDetail( claimKey: string, options?: { includeTenantScope?: boolean; tenantAccessRestricted?: boolean; tenantScopeMandatory?: boolean; }, ): ClientDetailResponse { const includeTenantScope = options?.includeTenantScope ?? false; const tenantAccessRestricted = options?.tenantAccessRestricted ?? false; const tenantScopeMandatory = options?.tenantScopeMandatory ?? false; const structuredScopes = [ { id: "1", name: "openid", description: "", mandatory: true, }, ]; if (includeTenantScope) { structuredScopes.push({ id: "2", name: "tenant", description: "Tenant access", mandatory: tenantScopeMandatory, locked: tenantAccessRestricted, }); } return { client: { id: "client-claims", name: "Claims App", type: "private", status: "active", redirectUris: ["https://rp.example.com/callback"], scopes: includeTenantScope ? ["openid", "tenant", "profile"] : ["openid", "profile"], tokenEndpointAuthMethod: "client_secret_basic", metadata: { description: "Claims app", tenant_access_restricted: tenantAccessRestricted, structured_scopes: structuredScopes, id_token_claims: [ { namespace: "rp_claims", key: claimKey, value: "A", valueType: "text", readPermission: "admin_only", writePermission: "admin_only", }, ], }, }, endpoints: { discovery: "https://issuer/.well-known/openid-configuration", issuer: "https://issuer", authorization: "https://issuer/oauth2/auth", token: "https://issuer/oauth2/token", userinfo: "https://issuer/userinfo", }, }; } async function flush() { await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); }); } async function setInputValue(input: HTMLInputElement, value: string) { const descriptor = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", ); descriptor?.set?.call(input, value); input.dispatchEvent(new Event("input", { bubbles: true })); await flush(); } async function setTextareaValue(textarea: HTMLTextAreaElement, value: string) { const descriptor = Object.getOwnPropertyDescriptor( HTMLTextAreaElement.prototype, "value", ); descriptor?.set?.call(textarea, value); textarea.dispatchEvent(new Event("input", { bubbles: true })); await flush(); } async function setSelectValue(select: HTMLSelectElement, value: string) { const descriptor = Object.getOwnPropertyDescriptor( HTMLSelectElement.prototype, "value", ); descriptor?.set?.call(select, value); select.dispatchEvent(new Event("change", { bubbles: true })); await flush(); } async function renderPage() { const container = document.createElement("div"); document.body.appendChild(container); const root = createRoot(container); roots.push(root); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false }, }, }); await act(async () => { root.render( } /> , ); }); await flush(); return { container, queryClient }; } describe("ClientGeneralPage RP claims", () => { beforeEach(() => { authState = { user: { access_token: "access-token", profile: { sub: "admin-user", role: "super_admin", name: "Dev Admin", }, }, }; fetchClientMock.mockResolvedValue(makeClientDetail("old_claim")); updateClientMock.mockResolvedValue(makeClientDetail("new_claim")); fetchClientRelationsMock.mockResolvedValue({ items: [] }); fetchMyTenantsMock.mockResolvedValue([]); fetchMeMock.mockResolvedValue({ id: "admin-user", role: "super_admin", name: "Dev Admin", }); navigateMock.mockReset(); }); afterEach(() => { for (const root of roots.splice(0)) { act(() => { root.unmount(); }); } vi.restoreAllMocks(); vi.clearAllMocks(); document.body.innerHTML = ""; }); it("updates the client detail cache with saved RP claims before stale data can rehydrate the form", async () => { const { container, queryClient } = await renderPage(); const claimKeyInput = container.querySelector( 'input[placeholder="e.g. locale"]', ); expect(claimKeyInput).not.toBeNull(); expect(claimKeyInput?.value).toBe("old_claim"); await setInputValue(claimKeyInput as HTMLInputElement, "new_claim"); const saveButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("저장") || button.textContent?.includes("Save"), ); expect(saveButton).toBeDefined(); await act(async () => { saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); const cached = queryClient.getQueryData([ "client", "client-claims", ]); expect(cached?.client.metadata?.id_token_claims).toEqual([ { namespace: "rp_claims", key: "new_claim", value: "A", valueType: "text", readPermission: "admin_only", writePermission: "admin_only", }, ]); }); it("forces user read permission on when user write permission is enabled for RP claims", async () => { const { container } = await renderPage(); const switches = Array.from( container.querySelectorAll('[role="switch"]'), ); const readSwitch = switches.find((button) => /Read|읽기/.test(button.getAttribute("aria-label") ?? ""), ); const writeSwitch = switches.find((button) => /Write|쓰기/.test(button.getAttribute("aria-label") ?? ""), ); expect(readSwitch).toBeDefined(); expect(writeSwitch).toBeDefined(); expect(readSwitch?.getAttribute("aria-checked")).toBe("false"); expect(writeSwitch?.getAttribute("aria-checked")).toBe("false"); await act(async () => { writeSwitch?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(readSwitch?.getAttribute("aria-checked")).toBe("true"); expect(writeSwitch?.getAttribute("aria-checked")).toBe("true"); const saveButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("저장") || button.textContent?.includes("Save"), ); expect(saveButton).toBeDefined(); await act(async () => { saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(updateClientMock).toHaveBeenCalledWith( "client-claims", expect.objectContaining({ metadata: expect.objectContaining({ id_token_claims: [ expect.objectContaining({ readPermission: "user_and_admin", writePermission: "user_and_admin", }), ], }), }), ); }); it("preserves tenant scope mandatory state when tenant access restriction is off", async () => { fetchClientMock.mockResolvedValue( makeClientDetail("old_claim", { includeTenantScope: true, tenantAccessRestricted: false, tenantScopeMandatory: true, }), ); updateClientMock.mockResolvedValue( makeClientDetail("old_claim", { includeTenantScope: true, tenantAccessRestricted: false, tenantScopeMandatory: false, }), ); const { container } = await renderPage(); const tenantScopeRow = Array.from(container.querySelectorAll("tr")).find( (row) => Array.from(row.querySelectorAll("input")).some( (input) => (input as HTMLInputElement).value === "tenant", ), ); expect(tenantScopeRow).toBeDefined(); const mandatorySwitch = tenantScopeRow?.querySelector('[role="switch"]'); expect(mandatorySwitch).toBeDefined(); expect(mandatorySwitch?.getAttribute("aria-checked")).toBe("true"); await act(async () => { mandatorySwitch?.dispatchEvent( new MouseEvent("click", { bubbles: true }), ); }); await flush(); expect(mandatorySwitch?.getAttribute("aria-checked")).toBe("false"); const saveButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("저장") || button.textContent?.includes("Save"), ); expect(saveButton).toBeDefined(); await act(async () => { saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(updateClientMock).toHaveBeenCalledWith( "client-claims", expect.objectContaining({ metadata: expect.objectContaining({ tenant_access_restricted: false, structured_scopes: expect.arrayContaining([ expect.objectContaining({ name: "tenant", mandatory: false, locked: false, }), ]), }), }), ); }); it("keeps nullable and default value as separate RP claim settings", async () => { const { container } = await renderPage(); expect(container.textContent).toContain("Nullable"); expect(container.textContent).toContain("Default Value"); expect(container.textContent).not.toContain("Nullable/default"); expect(container.textContent).toContain( "RP 전용 확장 claim을 구분해서 관리합니다", ); }); it("shows supported scopes and custom claims without integrated offline_access from the add scope button", async () => { const { container } = await renderPage(); const addScopeButton = Array.from( container.querySelectorAll("button"), ).find((button) => button.textContent?.includes("Scope 추가")); expect(addScopeButton).toBeDefined(); await act(async () => { addScopeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(container.textContent).not.toContain("offline_access"); expect(container.textContent).toContain("old_claim"); const customClaimButton = Array.from( container.querySelectorAll("button"), ).find((button) => button.textContent?.includes("old_claim")); expect(customClaimButton).toBeDefined(); await act(async () => { customClaimButton?.dispatchEvent( new MouseEvent("click", { bubbles: true }), ); }); await flush(); const scopeInputs = Array.from( container.querySelectorAll( 'input[placeholder="e.g. profile"]', ), ); expect(scopeInputs.some((input) => input.value === "old_claim")).toBe(true); }); it("blocks saving a number RP claim default value that is not numeric", async () => { const { container } = await renderPage(); const valueTypeSelect = container.querySelector( 'select[aria-label="Claim 값 타입"]', ); expect(valueTypeSelect).not.toBeNull(); await setSelectValue(valueTypeSelect as HTMLSelectElement, "number"); const saveButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("저장") || button.textContent?.includes("Save"), ); expect(saveButton).toBeDefined(); await act(async () => { saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(updateClientMock).not.toHaveBeenCalled(); }); it("blocks saving a number RP claim default value that is not an integer", async () => { const { container } = await renderPage(); const valueTypeSelect = container.querySelector( 'select[aria-label="Claim 값 타입"]', ); expect(valueTypeSelect).not.toBeNull(); await setSelectValue(valueTypeSelect as HTMLSelectElement, "number"); const defaultValueInput = container.querySelector( 'input[placeholder="Enter the default value"]', ); expect(defaultValueInput).not.toBeNull(); await setInputValue(defaultValueInput as HTMLInputElement, "3.14"); expect(container.textContent).toContain( "Claim 기본값이 타입과 맞지 않습니다", ); const saveButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("저장") || button.textContent?.includes("Save"), ); expect(saveButton).toBeDefined(); await act(async () => { saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(updateClientMock).not.toHaveBeenCalled(); }); it("saves a float RP claim default value", async () => { const { container } = await renderPage(); const valueTypeSelect = container.querySelector( 'select[aria-label="Claim 값 타입"]', ); expect(valueTypeSelect).not.toBeNull(); expect( valueTypeSelect?.querySelector('option[value="float"]'), ).not.toBeNull(); await setSelectValue(valueTypeSelect as HTMLSelectElement, "float"); const defaultValueInput = container.querySelector( 'input[placeholder="Enter the default value"]', ); expect(defaultValueInput).not.toBeNull(); expect(defaultValueInput?.getAttribute("inputmode")).toBe("decimal"); await setInputValue(defaultValueInput as HTMLInputElement, "3.14"); const saveButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("저장") || button.textContent?.includes("Save"), ); expect(saveButton).toBeDefined(); await act(async () => { saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(updateClientMock).toHaveBeenCalledWith( "client-claims", expect.objectContaining({ metadata: expect.objectContaining({ id_token_claims: [ expect.objectContaining({ value: "3.14", valueType: "float", }), ], }), }), ); }); it("renders constrained default value controls for boolean and date RP claims", async () => { const { container } = await renderPage(); const valueTypeSelect = container.querySelector( 'select[aria-label="Claim 값 타입"]', ); expect(valueTypeSelect).not.toBeNull(); await setSelectValue(valueTypeSelect as HTMLSelectElement, "boolean"); const booleanDefaultSelect = Array.from( container.querySelectorAll("select"), ).find((select) => Array.from(select.options).some((option) => option.value === "false"), ); expect(booleanDefaultSelect).toBeDefined(); await setSelectValue(valueTypeSelect as HTMLSelectElement, "date"); expect(container.querySelector('input[type="date"]')).not.toBeNull(); expect( container.querySelector('select[aria-label="Claim 기본값 시간대"]'), ).not.toBeNull(); }); it("saves date RP claim default values as Unix seconds for the selected timezone", async () => { vi.spyOn(Intl.DateTimeFormat.prototype, "resolvedOptions").mockReturnValue({ locale: "ko-KR", calendar: "gregory", numberingSystem: "latn", timeZone: "Asia/Seoul", } as Intl.ResolvedDateTimeFormatOptions); const { container } = await renderPage(); const valueTypeSelect = container.querySelector( 'select[aria-label="Claim 값 타입"]', ); expect(valueTypeSelect).not.toBeNull(); await setSelectValue(valueTypeSelect as HTMLSelectElement, "date"); const defaultValueInput = container.querySelector('input[type="date"]'); expect(defaultValueInput).not.toBeNull(); await setInputValue(defaultValueInput as HTMLInputElement, "2026-06-10"); const saveButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("저장") || button.textContent?.includes("Save"), ); expect(saveButton).toBeDefined(); await act(async () => { saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(updateClientMock).toHaveBeenCalledWith( "client-claims", expect.objectContaining({ metadata: expect.objectContaining({ id_token_claims: [ expect.objectContaining({ value: 1781017200, valueType: "date", }), ], }), }), ); }); it("blocks saving an object RP claim default value that is not a JSON object", async () => { const { container } = await renderPage(); const valueTypeSelect = container.querySelector( 'select[aria-label="Claim 값 타입"]', ); expect(valueTypeSelect).not.toBeNull(); await setSelectValue(valueTypeSelect as HTMLSelectElement, "object"); const defaultValueInput = container.querySelector( 'textarea[placeholder="{\\"key\\": \\"value\\"}"]', ); expect(defaultValueInput).not.toBeNull(); await setTextareaValue( defaultValueInput as HTMLTextAreaElement, "not-json", ); const saveButton = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent?.includes("저장") || button.textContent?.includes("Save"), ); expect(saveButton).toBeDefined(); await act(async () => { saveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flush(); expect(updateClientMock).not.toHaveBeenCalled(); }); });