1
0
forked from baron/baron-sso
Files
baron-sso/devfront/src/features/clients/ClientGeneralPage.claims.test.tsx

655 lines
20 KiB
TypeScript

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<typeof import("react-router-dom")>(
"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<string, unknown>) => {
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(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/clients/client-claims/settings"]}>
<Routes>
<Route
path="/clients/:id/settings"
element={<ClientGeneralPage />}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
});
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<HTMLInputElement>(
'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<ClientDetailResponse>([
"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<HTMLButtonElement>('[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<HTMLButtonElement>('[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 including offline_access and custom claims 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).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<HTMLInputElement>(
'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<HTMLSelectElement>(
'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<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "number");
const defaultValueInput = container.querySelector<HTMLInputElement>(
'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<HTMLSelectElement>(
'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<HTMLInputElement>(
'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<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "boolean");
const booleanDefaultSelect = Array.from(
container.querySelectorAll<HTMLSelectElement>("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<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "date");
const defaultValueInput =
container.querySelector<HTMLInputElement>('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<HTMLSelectElement>(
'select[aria-label="Claim 값 타입"]',
);
expect(valueTypeSelect).not.toBeNull();
await setSelectValue(valueTypeSelect as HTMLSelectElement, "object");
const defaultValueInput = container.querySelector<HTMLTextAreaElement>(
'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();
});
});