forked from baron/baron-sso
655 lines
20 KiB
TypeScript
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 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<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();
|
|
});
|
|
});
|