forked from baron/baron-sso
custom claim 권한체크 확인
This commit is contained in:
@@ -8,6 +8,7 @@ export type OrgPickerSelection = {
|
||||
type: OrgPickerObjectType;
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
};
|
||||
|
||||
export type OrgPickerResult = {
|
||||
|
||||
266
orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx
Normal file
266
orgfront/src/features/orgchart/routes/OrgPickerPage.test.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { OrgPickerEmbedPage } from "./OrgPickerPage";
|
||||
|
||||
const adminApiMocks = vi.hoisted(() => ({
|
||||
fetchAllTenants: vi.fn(),
|
||||
fetchOrgChartSnapshot: vi.fn(),
|
||||
fetchPublicOrgChart: vi.fn(),
|
||||
fetchUsers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../lib/adminApi", () => adminApiMocks);
|
||||
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const now = "2026-06-10T00:00:00.000Z";
|
||||
|
||||
function tenant({
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
type,
|
||||
parentId,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
type: string;
|
||||
parentId?: string;
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
slug,
|
||||
description: "",
|
||||
status: "active",
|
||||
parentId,
|
||||
memberCount: 0,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
function user({
|
||||
id,
|
||||
name,
|
||||
tenantSlug,
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
tenantSlug: string;
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
email: `${id}@example.com`,
|
||||
name,
|
||||
role: "user",
|
||||
status: "active",
|
||||
tenantSlug,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
tenants: [
|
||||
tenant({
|
||||
id: "group-1",
|
||||
name: "Hanmac Family",
|
||||
slug: "hanmac-family",
|
||||
type: "COMPANY_GROUP",
|
||||
}),
|
||||
tenant({
|
||||
id: "company-1",
|
||||
name: "Snapshot Company",
|
||||
slug: "snapshot-company",
|
||||
type: "COMPANY",
|
||||
parentId: "group-1",
|
||||
}),
|
||||
],
|
||||
users: [
|
||||
user({
|
||||
id: "user-1",
|
||||
name: "Snapshot User",
|
||||
tenantSlug: "snapshot-company",
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
const renderedPickers: ReturnType<typeof renderPicker>[] = [];
|
||||
|
||||
function renderPicker(initialEntry: string) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<OrgPickerEmbedPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
const rendered = { container, queryClient, root };
|
||||
renderedPickers.push(rendered);
|
||||
return rendered;
|
||||
}
|
||||
|
||||
async function flushQueries() {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForExpect(assertion: () => void) {
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await flushQueries();
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
function cleanupRendered({
|
||||
container,
|
||||
queryClient,
|
||||
root,
|
||||
}: {
|
||||
container: HTMLDivElement;
|
||||
queryClient: QueryClient;
|
||||
root: Root;
|
||||
}) {
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
}
|
||||
|
||||
describe("OrgPickerEmbedPage orgchart data source", () => {
|
||||
afterEach(() => {
|
||||
while (renderedPickers.length > 0) {
|
||||
const rendered = renderedPickers.pop();
|
||||
if (rendered) cleanupRendered(rendered);
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("uses the authenticated orgchart snapshot instead of legacy tenant and user list APIs", async () => {
|
||||
adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
|
||||
adminApiMocks.fetchPublicOrgChart.mockResolvedValue(snapshot);
|
||||
adminApiMocks.fetchAllTenants.mockResolvedValue({
|
||||
items: [],
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
total: 0,
|
||||
});
|
||||
adminApiMocks.fetchUsers.mockResolvedValue({
|
||||
items: [],
|
||||
limit: 5000,
|
||||
offset: 0,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const rendered = renderPicker("/embed/picker?select=both");
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(adminApiMocks.fetchOrgChartSnapshot).toHaveBeenCalledTimes(1);
|
||||
expect(adminApiMocks.fetchPublicOrgChart).not.toHaveBeenCalled();
|
||||
expect(adminApiMocks.fetchAllTenants).not.toHaveBeenCalled();
|
||||
expect(adminApiMocks.fetchUsers).not.toHaveBeenCalledWith(5000, 0);
|
||||
expect(rendered.container.textContent).toContain("Snapshot Company");
|
||||
expect(rendered.container.textContent).toContain("Snapshot User");
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the public orgchart snapshot when a share token is present", async () => {
|
||||
adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
|
||||
adminApiMocks.fetchPublicOrgChart.mockResolvedValue(snapshot);
|
||||
|
||||
const rendered = renderPicker(
|
||||
"/embed/picker?token=public-token&select=user",
|
||||
);
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(adminApiMocks.fetchPublicOrgChart).toHaveBeenCalledTimes(1);
|
||||
expect(adminApiMocks.fetchPublicOrgChart).toHaveBeenCalledWith(
|
||||
"public-token",
|
||||
);
|
||||
expect(adminApiMocks.fetchOrgChartSnapshot).not.toHaveBeenCalled();
|
||||
expect(adminApiMocks.fetchAllTenants).not.toHaveBeenCalled();
|
||||
expect(adminApiMocks.fetchUsers).not.toHaveBeenCalledWith(5000, 0);
|
||||
expect(rendered.container.textContent).toContain("Snapshot User");
|
||||
});
|
||||
});
|
||||
|
||||
it("allows tenant checks in user-only multi mode but confirms descendant users only", async () => {
|
||||
adminApiMocks.fetchOrgChartSnapshot.mockResolvedValue(snapshot);
|
||||
const postMessageSpy = vi.spyOn(window.parent, "postMessage");
|
||||
|
||||
const rendered = renderPicker(
|
||||
"/embed/picker?mode=multiple&select=user&includeDescendants=true",
|
||||
);
|
||||
|
||||
await waitForExpect(() => {
|
||||
expect(rendered.container.textContent).toContain("Snapshot Company");
|
||||
expect(rendered.container.textContent).toContain("Snapshot User");
|
||||
});
|
||||
|
||||
const tenantCheckbox = rendered.container.querySelector<HTMLInputElement>(
|
||||
'input[aria-label="Snapshot Company 선택"]',
|
||||
);
|
||||
expect(tenantCheckbox).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
tenantCheckbox?.click();
|
||||
});
|
||||
|
||||
const confirmButton = Array.from(
|
||||
rendered.container.querySelectorAll("button"),
|
||||
).find((button) => button.textContent?.includes("선택 완료"));
|
||||
expect(confirmButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
confirmButton?.click();
|
||||
});
|
||||
|
||||
expect(postMessageSpy).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
mode: "multiple",
|
||||
selections: [
|
||||
{
|
||||
type: "user",
|
||||
id: "user-1",
|
||||
name: "Snapshot User",
|
||||
email: "user-1@example.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,10 @@ import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { fetchAllTenants, fetchUsers } from "../../../lib/adminApi";
|
||||
import {
|
||||
fetchOrgChartSnapshot,
|
||||
fetchPublicOrgChart,
|
||||
} from "../../../lib/adminApi";
|
||||
import { buildOrgPickerTree, flattenDescendants } from "../pickerTree";
|
||||
import {
|
||||
buildOrgPickerEmbedSrc,
|
||||
@@ -26,7 +29,27 @@ function canSelectNode(
|
||||
return select === "both" || select === node.type;
|
||||
}
|
||||
|
||||
function canToggleNode(
|
||||
node: OrgPickerTreeNode,
|
||||
mode: OrgPickerMode,
|
||||
select: OrgPickerSelectableType,
|
||||
) {
|
||||
return (
|
||||
canSelectNode(node, select) ||
|
||||
(mode === "multiple" && select === "user" && node.type === "tenant")
|
||||
);
|
||||
}
|
||||
|
||||
function toSelection(node: OrgPickerTreeNode): OrgPickerSelection {
|
||||
if (node.type === "user") {
|
||||
return {
|
||||
type: node.type,
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
email: node.user?.email,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: node.type,
|
||||
id: node.id,
|
||||
@@ -48,8 +71,10 @@ function collectSelectedNodes({
|
||||
const selected = new Map<string, OrgPickerTreeNode>();
|
||||
const visit = (node: OrgPickerTreeNode) => {
|
||||
const key = nodeKey(node);
|
||||
if (selectedKeys.has(key) && canSelectNode(node, select)) {
|
||||
selected.set(key, node);
|
||||
if (selectedKeys.has(key)) {
|
||||
if (canSelectNode(node, select)) {
|
||||
selected.set(key, node);
|
||||
}
|
||||
if (includeDescendants && node.type === "tenant") {
|
||||
for (const descendant of flattenDescendants(node)) {
|
||||
if (canSelectNode(descendant, select)) {
|
||||
@@ -230,6 +255,7 @@ function OrgPickerTreeItem({
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = React.useState(true);
|
||||
const selectable = canSelectNode(node, select);
|
||||
const toggleable = canToggleNode(node, mode, select);
|
||||
const hasChildren = node.children.length > 0;
|
||||
const key = nodeKey(node);
|
||||
const checked = selectedKeys.has(key);
|
||||
@@ -280,7 +306,7 @@ function OrgPickerTreeItem({
|
||||
<span className="h-6 w-6 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
|
||||
{mode === "multiple" && selectable ? (
|
||||
{mode === "multiple" && toggleable ? (
|
||||
<input
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
@@ -333,6 +359,7 @@ export function OrgPickerEmbedPage() {
|
||||
const mode = parseOrgPickerMode(searchParams.get("mode"));
|
||||
const select = parseOrgPickerSelectableType(searchParams.get("select"));
|
||||
const rootTenantId = searchParams.get("rootTenantId") || undefined;
|
||||
const shareToken = searchParams.get("token") || undefined;
|
||||
const includeInternal = searchParams.get("includeInternal") === "true";
|
||||
const tenantId =
|
||||
searchParams.get("tenantSlug") ||
|
||||
@@ -349,13 +376,14 @@ export function OrgPickerEmbedPage() {
|
||||
() => new Set(),
|
||||
);
|
||||
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["org-picker-tenants"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ["org-picker-users"],
|
||||
queryFn: () => fetchUsers(5000, 0),
|
||||
const orgChartQuery = useQuery({
|
||||
queryKey: [
|
||||
"org-picker-orgchart",
|
||||
shareToken ? "public" : "authenticated",
|
||||
shareToken,
|
||||
],
|
||||
queryFn: () =>
|
||||
shareToken ? fetchPublicOrgChart(shareToken) : fetchOrgChartSnapshot(),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -365,19 +393,12 @@ export function OrgPickerEmbedPage() {
|
||||
const tree = React.useMemo(() => {
|
||||
return buildOrgPickerTree({
|
||||
includeInternal,
|
||||
tenants: tenantsQuery.data?.items ?? [],
|
||||
users: select === "tenant" ? [] : (usersQuery.data?.items ?? []),
|
||||
tenants: orgChartQuery.data?.tenants ?? [],
|
||||
users: select === "tenant" ? [] : (orgChartQuery.data?.users ?? []),
|
||||
rootTenantId,
|
||||
tenantId,
|
||||
});
|
||||
}, [
|
||||
includeInternal,
|
||||
rootTenantId,
|
||||
select,
|
||||
tenantId,
|
||||
tenantsQuery.data,
|
||||
usersQuery.data,
|
||||
]);
|
||||
}, [includeInternal, orgChartQuery.data, rootTenantId, select, tenantId]);
|
||||
|
||||
const selectedItems = React.useMemo(
|
||||
() =>
|
||||
@@ -430,8 +451,8 @@ export function OrgPickerEmbedPage() {
|
||||
postPickerMessage({ type: "orgfront:picker:cancel" });
|
||||
};
|
||||
|
||||
const isLoading = tenantsQuery.isLoading || usersQuery.isLoading;
|
||||
const isError = tenantsQuery.isError || usersQuery.isError;
|
||||
const isLoading = orgChartQuery.isLoading;
|
||||
const isError = orgChartQuery.isError;
|
||||
|
||||
React.useEffect(() => {
|
||||
const htmlOverflow = document.documentElement.style.overflow;
|
||||
|
||||
@@ -178,6 +178,10 @@ async function installOrgPickerApiMock(
|
||||
}),
|
||||
user("user-sales", "Sales User", "sales"),
|
||||
];
|
||||
const orgChartSnapshot = {
|
||||
tenants,
|
||||
users,
|
||||
};
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
await route.fulfill({
|
||||
@@ -202,6 +206,20 @@ async function installOrgPickerApiMock(
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(orgChartSnapshot),
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ...orgChartSnapshot, sharedWith: "playwright" }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -294,6 +312,18 @@ test("picker defaults to the hanmac-family company-group when no tenant id is su
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ tenants, users: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ tenants, users: [], sharedWith: "playwright" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(withShareToken("/picker"));
|
||||
|
||||
@@ -351,6 +381,18 @@ test("embed preview picker orders hanmac-family tenants by the shared policy", a
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ tenants, users: [] }),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ tenants, users: [], sharedWith: "playwright" }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(withShareToken("/embed-preview?select=tenant"));
|
||||
|
||||
@@ -644,6 +686,25 @@ test("embed picker posts a single user selection with type, id, and name", async
|
||||
await expect(output).not.toContainText("tenantId");
|
||||
});
|
||||
|
||||
test("embed picker lets user-only multi selection check tenants but posts descendant users only", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(withShareToken("/embed-preview?mode=multiple&select=user"));
|
||||
|
||||
const picker = page.frameLocator("iframe");
|
||||
await picker.getByLabel("Engineering 선택", { exact: true }).check();
|
||||
await expect(
|
||||
picker.getByLabel("Platform User 책임 선택", { exact: true }),
|
||||
).toBeChecked();
|
||||
await picker.getByRole("button", { name: "선택 완료" }).click();
|
||||
|
||||
const output = page.getByTestId("embed-preview-output");
|
||||
await expect(output).toContainText('"id": "user-eng"');
|
||||
await expect(output).toContainText('"id": "user-platform"');
|
||||
await expect(output).not.toContainText('"id": "dept-eng"');
|
||||
await expect(output).not.toContainText('"id": "team-platform"');
|
||||
});
|
||||
|
||||
test("embed picker single selection counts only the selected node without descendants", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user