1
0
forked from baron/baron-sso

custom claim 권한체크 확인

This commit is contained in:
2026-06-11 08:29:25 +09:00
parent 839ca9d407
commit 4d77060b5d
79 changed files with 4268 additions and 670 deletions

View File

@@ -8,6 +8,7 @@ export type OrgPickerSelection = {
type: OrgPickerObjectType;
id: string;
name: string;
email?: string;
};
export type OrgPickerResult = {

View 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",
},
],
},
},
"*",
);
});
});

View File

@@ -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;

View File

@@ -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,
}) => {