From 38605ac8a32cea209354119b056fe8713675f87d Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 1 Jun 2026 17:37:13 +0900 Subject: [PATCH] =?UTF-8?q?devfront=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/audit/AuditLogTable.test.tsx | 148 ++++++++++ .../src/components/ui/copy-button.test.tsx | 25 ++ .../src/features/audit/AuditLogsPage.test.tsx | 208 +++++++++++++ devfront/src/features/auth/authApi.test.ts | 19 ++ .../src/features/clients/ClientsPage.test.tsx | 276 ++++++++++++++++++ .../clients/components/ClientLogo.test.tsx | 65 +++++ .../routes/ClientFederationPage.test.tsx | 181 ++++++++++++ .../features/coverage/AuditLogTable.test.tsx | 123 ++++++++ .../src/features/coverage/pageSmoke.test.tsx | 89 ++++++ .../DeveloperRequestPage.test.tsx | 186 ++++++++++++ devfront/src/lib/apiClient.test.ts | 84 ++++++ devfront/src/lib/oidcStorage.test.ts | 76 +++++ devfront/src/lib/role.test.ts | 33 +++ 13 files changed, 1513 insertions(+) create mode 100644 common/core/components/audit/AuditLogTable.test.tsx create mode 100644 devfront/src/features/audit/AuditLogsPage.test.tsx create mode 100644 devfront/src/features/auth/authApi.test.ts create mode 100644 devfront/src/features/clients/ClientsPage.test.tsx create mode 100644 devfront/src/features/clients/components/ClientLogo.test.tsx create mode 100644 devfront/src/features/clients/routes/ClientFederationPage.test.tsx create mode 100644 devfront/src/features/coverage/AuditLogTable.test.tsx create mode 100644 devfront/src/features/developer-request/DeveloperRequestPage.test.tsx create mode 100644 devfront/src/lib/apiClient.test.ts create mode 100644 devfront/src/lib/oidcStorage.test.ts create mode 100644 devfront/src/lib/role.test.ts diff --git a/common/core/components/audit/AuditLogTable.test.tsx b/common/core/components/audit/AuditLogTable.test.tsx new file mode 100644 index 00000000..d5e6e368 --- /dev/null +++ b/common/core/components/audit/AuditLogTable.test.tsx @@ -0,0 +1,148 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CommonAuditLog } from "../../audit"; +import { AuditLogTable } from "./AuditLogTable"; + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +function renderTable(props: Parameters[0]) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + act(() => { + root.render(); + }); + + return { container }; +} + +const logs: CommonAuditLog[] = [ + { + event_id: "evt-1", + timestamp: "2026-05-28T06:07:18.000Z", + user_id: "user-1", + event_type: "CLIENT_UPDATE", + status: "success", + ip_address: "127.0.0.1", + user_agent: "Vitest", + device_id: "device-1", + details: JSON.stringify({ + request_id: "req-1", + method: "POST", + path: "/api/v1/clients", + latency_ms: 120, + tenant_id: "tenant-1", + actor_id: "user-1", + action: "업데이트", + target_id: "client-a", + before: { status: "inactive" }, + after: { status: "active" }, + }), + }, +]; + +describe("AuditLogTable", () => { + it("renders loading and empty states", () => { + const { container: loadingContainer } = renderTable({ + logs: [], + t: (key, fallback) => fallback ?? key, + loading: true, + hasNextPage: false, + isFetchingNextPage: false, + onLoadMore: vi.fn(), + }); + + expect(loadingContainer.textContent).toContain("Loading audit logs..."); + + const { container: emptyContainer } = renderTable({ + logs: [], + t: (key, fallback) => fallback ?? key, + loading: false, + hasNextPage: false, + isFetchingNextPage: false, + onLoadMore: vi.fn(), + }); + + expect(emptyContainer.textContent).toContain("No audit logs found."); + expect(emptyContainer.textContent).toContain("End of audit feed"); + }); + + it("renders rows, expands details, copies fields, and loads more", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + const onLoadMore = vi.fn(); + const { container } = renderTable({ + logs, + t: (key, fallback, vars) => { + let text = fallback ?? key; + for (const [name, value] of Object.entries(vars ?? {})) { + text = text.replaceAll(`{{${name}}}`, String(value)); + } + return text; + }, + loading: false, + hasNextPage: true, + isFetchingNextPage: false, + onLoadMore, + }); + + expect(container.textContent).toContain("user-1"); + expect(container.textContent).toContain("업데이트"); + expect(container.textContent).toContain("client-a"); + expect(container.textContent).toContain("success"); + + const buttons = Array.from(container.querySelectorAll("button")); + const actorCopyButton = buttons.find( + (button) => button.getAttribute("aria-label") === "Copy User ID", + ); + const targetCopyButton = buttons.find( + (button) => button.getAttribute("aria-label") === "Copy Client ID", + ); + const expandButton = buttons.find( + (button) => !button.getAttribute("aria-label") && !button.textContent, + ); + const loadMoreButton = buttons.find( + (button) => button.textContent === "Load more", + ); + + expect(actorCopyButton).toBeTruthy(); + expect(targetCopyButton).toBeTruthy(); + expect(expandButton).toBeTruthy(); + expect(loadMoreButton).toBeTruthy(); + + await act(async () => { + actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(writeText).toHaveBeenCalledWith("user-1"); + expect(writeText).toHaveBeenCalledWith("client-a"); + expect(container.textContent).toContain("Request ID · req-1"); + expect(container.textContent).toContain("Actor"); + expect(container.textContent).toContain("Result"); + + await act(async () => { + loadMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/devfront/src/components/ui/copy-button.test.tsx b/devfront/src/components/ui/copy-button.test.tsx index 9ddea7e5..17fe1514 100644 --- a/devfront/src/components/ui/copy-button.test.tsx +++ b/devfront/src/components/ui/copy-button.test.tsx @@ -79,4 +79,29 @@ describe("CopyButton", () => { expect(execCommand).toHaveBeenCalledWith("copy"); expect(onCopy).toHaveBeenCalledTimes(1); }); + + it("keeps running when the fallback copy flow fails", async () => { + const execCommand = vi.fn(() => false); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + Object.defineProperty(document, "execCommand", { + value: execCommand, + configurable: true, + }); + Object.defineProperty(window, "isSecureContext", { + value: false, + configurable: true, + }); + + const { container, onCopy } = renderCopyButton("client-secret"); + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(onCopy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); }); diff --git a/devfront/src/features/audit/AuditLogsPage.test.tsx b/devfront/src/features/audit/AuditLogsPage.test.tsx new file mode 100644 index 00000000..8e7f3c70 --- /dev/null +++ b/devfront/src/features/audit/AuditLogsPage.test.tsx @@ -0,0 +1,208 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AuditLogsPage from "./AuditLogsPage"; + +const navigateMock = vi.fn(); +const fetchMeMock = vi.fn(); +const fetchDevAuditLogsMock = vi.fn(); +let gateState = { + hasDeveloperAccess: true, + isDeveloperRequestPending: false, + canRequestDeveloperAccess: false, + isLoadingDeveloperAccessGate: false, +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => ({ + isAuthenticated: true, + isLoading: false, + user: { + access_token: "access-token", + profile: { + role: "super_admin", + tenant_id: "tenant-1", + }, + }, + }), +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock("../developer-access/developerAccessGate", () => ({ + useDeveloperAccessGate: () => gateState, +})); + +vi.mock("../../lib/devApi", () => ({ + fetchDevAuditLogs: (...args: unknown[]) => fetchDevAuditLogsMock(...args), +})); + +vi.mock("../auth/authApi", () => ({ + fetchMe: (...args: unknown[]) => fetchMeMock(...args), +})); + +vi.mock("../../../../common/core/components/audit", () => ({ + AuditLogTable: ({ + logs, + onLoadMore, + }: { + logs: Array<{ event_id: string }>; + onLoadMore: () => void; + }) => ( +
+
table:{logs.length}
+ +
+ ), +})); + +vi.mock("../../components/common/ForbiddenMessage", () => ({ + ForbiddenMessage: ({ resourceToken }: { resourceToken: string }) => ( +
Forbidden:{resourceToken}
+ ), +})); + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +beforeEach(() => { + gateState = { + hasDeveloperAccess: true, + isDeveloperRequestPending: false, + canRequestDeveloperAccess: false, + isLoadingDeveloperAccessGate: false, + }; + fetchMeMock.mockResolvedValue({ + id: "user-1", + role: "super_admin", + }); + fetchDevAuditLogsMock.mockResolvedValue({ + items: [ + { + event_id: "evt-1", + timestamp: "2026-05-28T06:07:18.000Z", + user_id: "user-1", + event_type: "CLIENT_UPDATE", + status: "success", + ip_address: "127.0.0.1", + user_agent: "Vitest", + details: JSON.stringify({ + action: "업데이트", + target_id: "client-a", + }), + }, + ], + limit: 50, + }); +}); + +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 }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("AuditLogsPage", () => { + it("shows the loading gate state", async () => { + gateState = { + hasDeveloperAccess: false, + isDeveloperRequestPending: false, + canRequestDeveloperAccess: false, + isLoadingDeveloperAccessGate: true, + }; + + const container = await renderPage(); + expect(container.textContent).toContain("로딩 중..."); + }); + + it("renders the access request card when access is denied", async () => { + gateState = { + hasDeveloperAccess: false, + isDeveloperRequestPending: false, + canRequestDeveloperAccess: true, + isLoadingDeveloperAccessGate: false, + }; + + const container = await renderPage(); + expect(container.textContent).toContain("감사 로그는 개발자 권한이 있어야 볼 수 있습니다."); + + const button = Array.from(container.querySelectorAll("button")).find( + (item) => item.textContent?.includes("개발자 권한 신청"), + ); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(navigateMock).toHaveBeenCalledWith("/developer-requests"); + }); + + it("exports the fetched logs as CSV", async () => { + const createObjectURL = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:csv"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockReturnValue(); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + const container = await renderPage(); + expect(container.textContent).toContain("table:1"); + + const button = Array.from(container.querySelectorAll("button")).find( + (item) => item.textContent === "CSV 내보내기", + ); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(createObjectURL).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:csv"); + }); + + it("renders the forbidden state on 403 errors", async () => { + fetchDevAuditLogsMock.mockRejectedValueOnce({ + response: { status: 403 }, + message: "Forbidden", + }); + + const container = await renderPage(); + expect(container.textContent).toContain("Forbidden:audit"); + }); +}); diff --git a/devfront/src/features/auth/authApi.test.ts b/devfront/src/features/auth/authApi.test.ts new file mode 100644 index 00000000..d5efc516 --- /dev/null +++ b/devfront/src/features/auth/authApi.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchMe } from "./authApi"; + +const getMock = vi.fn(); + +vi.mock("../../lib/apiClient", () => ({ + default: { + get: (...args: unknown[]) => getMock(...args), + }, +})); + +describe("fetchMe", () => { + it("returns the response payload from the API client", async () => { + getMock.mockResolvedValueOnce({ data: { id: "user-1", name: "Dev" } }); + + await expect(fetchMe()).resolves.toEqual({ id: "user-1", name: "Dev" }); + expect(getMock).toHaveBeenCalledWith("/user/me"); + }); +}); diff --git a/devfront/src/features/clients/ClientsPage.test.tsx b/devfront/src/features/clients/ClientsPage.test.tsx new file mode 100644 index 00000000..eba48ea8 --- /dev/null +++ b/devfront/src/features/clients/ClientsPage.test.tsx @@ -0,0 +1,276 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ClientsPage from "./ClientsPage"; + +const navigateMock = vi.fn(); +const fetchClientsMock = vi.fn(); +const fetchMeMock = vi.fn(); +const fetchDeveloperRequestStatusMock = vi.fn(); +const fetchMyTenantsMock = vi.fn(); +const requestDeveloperAccessMock = vi.fn(); + +let authState = { + user: { + access_token: "access-token", + profile: { + role: "super_admin", + tenant_id: "tenant-1", + companyCode: "HANMAC", + name: "Dev Admin", + email: "dev@example.com", + phone: "010-0000-0000", + }, + }, +}; + +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", () => ({ + fetchClients: () => fetchClientsMock(), + fetchMe: () => fetchMeMock(), + fetchDeveloperRequestStatus: () => fetchDeveloperRequestStatusMock(), + fetchMyTenants: () => fetchMyTenantsMock(), + requestDeveloperAccess: (...args: unknown[]) => + requestDeveloperAccessMock(...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[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +beforeEach(() => { + authState = { + user: { + access_token: "access-token", + profile: { + role: "super_admin", + tenant_id: "tenant-1", + companyCode: "HANMAC", + name: "Dev Admin", + email: "dev@example.com", + phone: "010-0000-0000", + }, + }, + }; + + fetchClientsMock.mockResolvedValue({ + items: [], + limit: 100, + offset: 0, + }); + fetchMeMock.mockResolvedValue({ + role: "super_admin", + name: "Dev Admin", + email: "dev@example.com", + phone: "010-0000-0000", + }); + fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" }); + fetchMyTenantsMock.mockResolvedValue([ + { + id: "tenant-1", + name: "Hanmac", + slug: "hanmac", + type: "COMPANY", + parentId: null, + description: "", + status: "active", + memberCount: 10, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + ]); +}); + +function makeClients(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `client-${index + 1}`, + name: `App ${index + 1}`, + type: index % 2 === 0 ? "private" : "pkce", + status: index % 2 === 0 ? "active" : "inactive", + createdAt: `2026-05-${String(index + 1).padStart(2, "0")}T00:00:00Z`, + redirectUris: [], + scopes: [], + metadata: {}, + })); +} + +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 new Promise((resolve) => setTimeout(resolve, 0)); +} + +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 act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("ClientsPage", () => { + it("expands the list and applies search filters", async () => { + fetchClientsMock.mockResolvedValue({ + items: makeClients(6), + limit: 100, + offset: 0, + }); + + const container = await renderPage(); + expect(container.textContent).toContain("총 6개의 애플리케이션이 등록되어 있습니다."); + expect(container.textContent).toContain("App 6"); + expect(container.textContent).toContain("App 2"); + expect(container.textContent).not.toContain("App 1"); + + const moreButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "더보기", + ); + expect(moreButton).toBeTruthy(); + + await act(async () => { + moreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("App 6"); + expect(container.textContent).toContain("접기"); + + const advancedButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Advanced Filters", + ); + expect(advancedButton).toBeTruthy(); + + await act(async () => { + advancedButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + const searchInput = Array.from( + container.querySelectorAll("input"), + ).find((input) => + input.getAttribute("placeholder")?.includes("클라이언트 이름/ID로 검색"), + ) as HTMLInputElement | undefined; + expect(searchInput).toBeTruthy(); + + await act(async () => { + await setInputValue(searchInput!, "missing-client"); + }); + + expect(container.textContent).toContain("조건에 맞는 연동 앱이 없습니다."); + + const resetButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "초기화", + ); + expect(resetButton).toBeTruthy(); + + await act(async () => { + resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await act(async () => { + await setInputValue(searchInput!, ""); + }); + + expect(container.textContent).toContain("App 1"); + }); + + it("navigates to the developer request page from empty states", async () => { + authState = { + user: { + access_token: "access-token", + profile: { + role: "user", + tenant_id: "tenant-1", + companyCode: "HANMAC", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + }, + }, + }; + fetchClientsMock.mockResolvedValue({ + items: [], + limit: 100, + offset: 0, + }); + fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" }); + fetchMeMock.mockResolvedValue({ + role: "user", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + }); + + const container = await renderPage(); + expect(container.textContent).toContain("개발자 등록 신청하기"); + + const requestButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "개발자 등록 신청하기", + ); + expect(requestButton).toBeTruthy(); + + await act(async () => { + requestButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(navigateMock).toHaveBeenCalledWith("/developer-requests"); + }); +}); diff --git a/devfront/src/features/clients/components/ClientLogo.test.tsx b/devfront/src/features/clients/components/ClientLogo.test.tsx new file mode 100644 index 00000000..353c9f27 --- /dev/null +++ b/devfront/src/features/clients/components/ClientLogo.test.tsx @@ -0,0 +1,65 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import type { ReactNode, ComponentProps } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ClientLogo } from "./ClientLogo"; + +vi.mock("../../../components/ui/avatar", () => ({ + Avatar: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + AvatarImage: (props: ComponentProps<"img">) => , + AvatarFallback: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + document.body.innerHTML = ""; +}); + +function renderLogo(client: Parameters[0]["client"]) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + act(() => { + root.render(); + }); + + return container; +} + +describe("ClientLogo", () => { + it("renders the fallback icon when no logo url exists", () => { + const container = renderLogo({ + name: "", + type: "private", + metadata: {}, + }); + + expect(container.querySelectorAll("svg").length).toBeGreaterThan(0); + }); + + it("uses the logo image when a trimmed url is provided", () => { + const container = renderLogo({ + name: "Gitea", + type: "pkce", + metadata: { logo_url: " https://example.com/logo.png " }, + }); + + const image = container.querySelector("img"); + expect(image).not.toBeNull(); + expect(container.querySelector("[data-testid='fallback']")).not.toBeNull(); + expect(image?.getAttribute("alt")).toContain("Gitea"); + expect(image?.getAttribute("src")).toBe("https://example.com/logo.png"); + }); +}); diff --git a/devfront/src/features/clients/routes/ClientFederationPage.test.tsx b/devfront/src/features/clients/routes/ClientFederationPage.test.tsx new file mode 100644 index 00000000..d1dace77 --- /dev/null +++ b/devfront/src/features/clients/routes/ClientFederationPage.test.tsx @@ -0,0 +1,181 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ClientFederationPage } from "./ClientFederationPage"; + +let params: { id?: string } = { id: "client-a" }; +const listIdpConfigsMock = vi.fn(); +const createIdpConfigMock = vi.fn(); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual( + "react-router-dom", + ); + return { + ...actual, + useParams: () => params, + }; +}); + +vi.mock("../../../lib/devApi", () => ({ + listIdpConfigsForClient: (clientId: string) => + listIdpConfigsMock(clientId), + createIdpConfigForClient: (payload: unknown) => + createIdpConfigMock(payload), +})); + +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[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +beforeEach(() => { + params = { id: "client-a" }; + listIdpConfigsMock.mockResolvedValue([ + { + id: "idp-1", + client_id: "client-a", + provider_type: "oidc", + display_name: "Workspace OIDC", + status: "active", + issuer_url: "https://accounts.example", + oidc_client_id: "oidc-client", + scopes: "openid email profile", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + ]); + createIdpConfigMock.mockResolvedValue({ + id: "idp-2", + client_id: "client-a", + provider_type: "oidc", + display_name: "New Provider", + status: "active", + createdAt: "2026-05-02T00:00:00Z", + updatedAt: "2026-05-02T00:00:00Z", + }); +}); + +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 new Promise((resolve) => setTimeout(resolve, 0)); +} + +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 act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("ClientFederationPage", () => { + it("shows a missing client id message when no route param exists", async () => { + params = {}; + const container = await renderPage(); + expect(container.textContent).toContain("Client ID is missing"); + }); + + it("opens the create modal and submits a new IdP config", async () => { + const container = await renderPage(); + expect(container.textContent).toContain("Workspace OIDC"); + + const addButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Add Provider", + ); + expect(addButton).toBeTruthy(); + + await act(async () => { + addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Add Identity Provider"); + + const displayName = container.querySelector( + 'input[name="display_name"]', + ) as HTMLInputElement | null; + const issuerUrl = container.querySelector( + 'input[name="issuer_url"]', + ) as HTMLInputElement | null; + const clientId = container.querySelector( + 'input[name="oidc_client_id"]', + ) as HTMLInputElement | null; + const clientSecret = container.querySelector( + 'input[name="oidc_client_secret"]', + ) as HTMLInputElement | null; + + expect(displayName).toBeTruthy(); + expect(issuerUrl).toBeTruthy(); + expect(clientId).toBeTruthy(); + expect(clientSecret).toBeTruthy(); + + await act(async () => { + await setInputValue(displayName!, "New Provider"); + await setInputValue(issuerUrl!, "https://login.example"); + await setInputValue(clientId!, "client-oidc"); + await setInputValue(clientSecret!, "secret-value"); + }); + + const submitButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Save Configuration", + ); + expect(submitButton).toBeTruthy(); + + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(createIdpConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + client_id: "client-a", + display_name: "New Provider", + issuer_url: "https://login.example", + oidc_client_id: "client-oidc", + oidc_client_secret: "secret-value", + }), + ); + }); +}); diff --git a/devfront/src/features/coverage/AuditLogTable.test.tsx b/devfront/src/features/coverage/AuditLogTable.test.tsx new file mode 100644 index 00000000..92397481 --- /dev/null +++ b/devfront/src/features/coverage/AuditLogTable.test.tsx @@ -0,0 +1,123 @@ +import { act } from "../../../../common/node_modules/react-dom/test-utils"; +import { createRoot, type Root } from "../../../../common/node_modules/react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CommonAuditLog } from "../../../../common/core/audit"; +import { AuditLogTable } from "../../../../common/core/components/audit"; + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +function renderTable(props: Parameters[0]) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + act(() => { + root.render(); + }); + + return { container }; +} + +const logs: CommonAuditLog[] = [ + { + event_id: "evt-1", + timestamp: "2026-05-28T06:07:18.000Z", + user_id: "user-1", + event_type: "CLIENT_UPDATE", + status: "success", + ip_address: "127.0.0.1", + user_agent: "Vitest", + device_id: "device-1", + details: JSON.stringify({ + request_id: "req-1", + method: "POST", + path: "/api/v1/clients", + latency_ms: 120, + tenant_id: "tenant-1", + actor_id: "user-1", + action: "업데이트", + target_id: "client-a", + before: { status: "inactive" }, + after: { status: "active" }, + }), + }, +]; + +describe("AuditLogTable", () => { + it("renders rows, expands details, copies fields, and loads more", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + const onLoadMore = vi.fn(); + const { container } = renderTable({ + logs, + t: (key, fallback, vars) => { + let text = fallback ?? key; + for (const [name, value] of Object.entries(vars ?? {})) { + text = text.replaceAll(`{{${name}}}`, String(value)); + } + return text; + }, + loading: false, + hasNextPage: true, + isFetchingNextPage: false, + onLoadMore, + }); + + expect(container.textContent).toContain("user-1"); + expect(container.textContent).toContain("업데이트"); + expect(container.textContent).toContain("client-a"); + expect(container.textContent).toContain("success"); + + const buttons = Array.from(container.querySelectorAll("button")); + const actorCopyButton = buttons.find( + (button) => button.getAttribute("aria-label") === "Copy User ID", + ); + const targetCopyButton = buttons.find( + (button) => button.getAttribute("aria-label") === "Copy Client ID", + ); + const expandButton = buttons.find( + (button) => !button.getAttribute("aria-label") && !button.textContent, + ); + const loadMoreButton = buttons.find( + (button) => button.textContent === "Load more", + ); + + expect(actorCopyButton).toBeTruthy(); + expect(targetCopyButton).toBeTruthy(); + expect(expandButton).toBeTruthy(); + expect(loadMoreButton).toBeTruthy(); + + await act(async () => { + actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(writeText).toHaveBeenCalledWith("user-1"); + expect(writeText).toHaveBeenCalledWith("client-a"); + expect(container.textContent).toContain("Request ID · req-1"); + expect(container.textContent).toContain("Actor"); + expect(container.textContent).toContain("Result"); + + await act(async () => { + loadMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/devfront/src/features/coverage/pageSmoke.test.tsx b/devfront/src/features/coverage/pageSmoke.test.tsx index 2c7140f4..c7e5a44c 100644 --- a/devfront/src/features/coverage/pageSmoke.test.tsx +++ b/devfront/src/features/coverage/pageSmoke.test.tsx @@ -13,6 +13,11 @@ import { ClientFederationPage } from "../clients/routes/ClientFederationPage"; import DeveloperRequestPage from "../developer-request/DeveloperRequestPage"; import GlobalOverviewPage from "../overview/GlobalOverviewPage"; import ProfilePage from "../profile/ProfilePage"; +import { + approveDeveloperRequest, + cancelDeveloperRequestApproval, + rejectDeveloperRequest, +} from "../../lib/devApi"; const authProfile = { sub: "user-1", @@ -282,6 +287,19 @@ vi.mock("../../lib/devApi", () => ({ createdAt: "2026-05-01T00:00:00Z", updatedAt: "2026-05-01T00:00:00Z", }, + { + id: 2, + userId: "user-4", + tenantId: "tenant-1", + name: "Approved Requester", + organization: "Hanmac", + email: "approved@example.com", + reason: "Need elevated access", + status: "approved", + adminNotes: "Reviewed and approved", + createdAt: "2026-05-02T00:00:00Z", + updatedAt: "2026-05-02T00:00:00Z", + }, ]), approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })), rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })), @@ -348,6 +366,16 @@ async function renderPage( return container; } +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 new Promise((resolve) => setTimeout(resolve, 0)); +} + describe("devfront coverage smoke pages", () => { it("renders overview, client list, audit, developer request, and profile pages", async () => { const overview = await renderPage(); @@ -397,4 +425,65 @@ describe("devfront coverage smoke pages", () => { }); expect(relations.textContent).toContain("Dev Admin"); }); + + it("covers developer request actions", async () => { + const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => undefined); + const requests = await renderPage(); + + expect(requests.textContent).toContain("Requester"); + expect(requests.textContent).toContain("Approved Requester"); + + const pendingNote = Array.from( + requests.querySelectorAll("input"), + ).find((input) => input.getAttribute("placeholder") === "메모 입력 (선택)...") as HTMLInputElement | undefined; + const cancelNote = Array.from( + requests.querySelectorAll("input"), + ).find( + (input) => input.getAttribute("placeholder") === "승인 취소 사유 입력...", + ) as HTMLInputElement | undefined; + + expect(pendingNote).toBeTruthy(); + expect(cancelNote).toBeTruthy(); + + await act(async () => { + await setInputValue(pendingNote!, ""); + }); + + const buttons = Array.from(requests.querySelectorAll("button")); + const rejectButton = buttons.find((button) => button.textContent === "반려"); + const approveButton = buttons.find((button) => button.textContent === "승인"); + const cancelButton = buttons.find( + (button) => button.textContent === "승인 취소", + ); + + expect(rejectButton).toBeTruthy(); + expect(approveButton).toBeTruthy(); + expect(cancelButton).toBeTruthy(); + + await act(async () => { + rejectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(alertSpy).toHaveBeenCalledWith("반려 사유를 입력해주세요."); + + await act(async () => { + await setInputValue(pendingNote!, "Need more context"); + approveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await act(async () => { + await setInputValue(cancelNote!, "Approve needs revision"); + cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(approveDeveloperRequest).toHaveBeenCalledWith(1, "Need more context"); + expect(rejectDeveloperRequest).not.toHaveBeenCalled(); + expect(cancelDeveloperRequestApproval).toHaveBeenCalledWith( + 2, + "Approve needs revision", + ); + + alertSpy.mockRestore(); + }); }); diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx new file mode 100644 index 00000000..7616e525 --- /dev/null +++ b/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx @@ -0,0 +1,186 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import DeveloperRequestPage from "./DeveloperRequestPage"; + +const fetchDeveloperRequestsMock = vi.fn(); +const fetchMyTenantsMock = vi.fn(); +const fetchMeMock = vi.fn(); +const requestDeveloperAccessMock = vi.fn(); + +let authState = { + user: { + access_token: "access-token", + profile: { + role: "user", + tenant_id: "tenant-1", + companyCode: "HANMAC", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + }, + }, +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +vi.mock("../auth/authApi", () => ({ + fetchMe: () => fetchMeMock(), +})); + +vi.mock("../../lib/devApi", () => ({ + fetchDeveloperRequests: () => fetchDeveloperRequestsMock(), + fetchMyTenants: () => fetchMyTenantsMock(), + requestDeveloperAccess: (...args: unknown[]) => + requestDeveloperAccessMock(...args), + approveDeveloperRequest: vi.fn(), + rejectDeveloperRequest: vi.fn(), + cancelDeveloperRequestApproval: vi.fn(), +})); + +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[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +beforeEach(() => { + authState = { + user: { + access_token: "access-token", + profile: { + role: "user", + tenant_id: "tenant-1", + companyCode: "HANMAC", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + }, + }, + }; + + fetchDeveloperRequestsMock.mockResolvedValue([]); + fetchMyTenantsMock.mockResolvedValue([ + { + id: "tenant-1", + name: "Hanmac", + slug: "hanmac", + type: "COMPANY", + parentId: null, + description: "", + status: "active", + memberCount: 10, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + ]); + fetchMeMock.mockResolvedValue({ + id: "user-1", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + role: "user", + }); + requestDeveloperAccessMock.mockResolvedValue({ status: "pending" }); +}); + +async function setTextAreaValue(input: HTMLTextAreaElement, value: string) { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + ); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +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 act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("DeveloperRequestPage", () => { + it("opens the request modal and submits a request", async () => { + const container = await renderPage(); + expect(container.textContent).toContain("신규 신청하기"); + + const actionButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("신규 신청하기"), + ); + expect(actionButton).toBeTruthy(); + + await act(async () => { + actionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("개발자 등록 신청"); + + const reasonField = container.querySelector( + "textarea", + ) as HTMLTextAreaElement | null; + expect(reasonField).toBeTruthy(); + + await act(async () => { + await setTextAreaValue(reasonField!, "Need RP access"); + }); + + const submitButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "신청하기", + ); + expect(submitButton).toBeTruthy(); + + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(requestDeveloperAccessMock).toHaveBeenCalled(); + expect(requestDeveloperAccessMock.mock.calls[0]?.[0]).toEqual({ + name: "Requester", + organization: "Hanmac", + reason: "Need RP access", + tenantId: "tenant-1", + }); + }); +}); diff --git a/devfront/src/lib/apiClient.test.ts b/devfront/src/lib/apiClient.test.ts new file mode 100644 index 00000000..9458a557 --- /dev/null +++ b/devfront/src/lib/apiClient.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getUserMock = vi.fn(); +const findPersistedOidcUserMock = vi.fn(); +const removeUserMock = vi.fn(); +const shouldStartLoginRedirectMock = vi.fn(); +const shouldSuppressDevelopmentSessionRedirectMock = vi.fn(); + +vi.mock("./auth", () => ({ + userManager: { + getUser: (...args: unknown[]) => getUserMock(...args), + removeUser: (...args: unknown[]) => removeUserMock(...args), + }, +})); + +vi.mock("./oidcStorage", () => ({ + findPersistedOidcUser: (...args: unknown[]) => + findPersistedOidcUserMock(...args), +})); + +vi.mock("../../../common/core/auth", () => ({ + shouldStartLoginRedirect: (...args: unknown[]) => + shouldStartLoginRedirectMock(...args), +})); + +vi.mock("../../../common/core/session", () => ({ + shouldSuppressDevelopmentSessionRedirect: (...args: unknown[]) => + shouldSuppressDevelopmentSessionRedirectMock(...args), +})); + +describe("apiClient", () => { + beforeEach(() => { + vi.resetModules(); + vi.stubEnv("MODE", "test"); + (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE = + true; + window.localStorage.clear(); + getUserMock.mockResolvedValue(null); + findPersistedOidcUserMock.mockReturnValue(undefined); + removeUserMock.mockResolvedValue(undefined); + shouldStartLoginRedirectMock.mockReturnValue(true); + shouldSuppressDevelopmentSessionRedirectMock.mockReturnValue(false); + }); + + it("injects authorization and tenant headers into requests", async () => { + getUserMock.mockResolvedValueOnce({ access_token: "live-token" }); + window.localStorage.setItem("dev_tenant_id", "tenant-1"); + + const { default: apiClient } = await import("./apiClient"); + const requestHandler = apiClient.interceptors.request.handlers[0]?.fulfilled; + + const result = await requestHandler?.({ headers: {} }); + + expect(result.headers.Authorization).toBe("Bearer live-token"); + expect(result.headers["X-Tenant-ID"]).toBe("tenant-1"); + }); + + it("rejects non-auth response errors without redirecting", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { default: apiClient } = await import("./apiClient"); + const responseHandler = apiClient.interceptors.response.handlers[0]?.rejected; + const error = { response: { status: 500, data: { error: "boom" } } }; + + await expect(responseHandler?.(error)).rejects.toBe(error); + expect(warnSpy).not.toHaveBeenCalled(); + expect(removeUserMock).not.toHaveBeenCalled(); + }); + + it("warns and rejects auth failures in test mode", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { default: apiClient } = await import("./apiClient"); + const responseHandler = apiClient.interceptors.response.handlers[0]?.rejected; + const error = { + response: { + status: 403, + data: { error: "authentication required" }, + }, + }; + + await expect(responseHandler?.(error)).rejects.toBe(error); + expect(warnSpy).toHaveBeenCalled(); + expect(removeUserMock).not.toHaveBeenCalled(); + }); +}); diff --git a/devfront/src/lib/oidcStorage.test.ts b/devfront/src/lib/oidcStorage.test.ts new file mode 100644 index 00000000..c5c35f59 --- /dev/null +++ b/devfront/src/lib/oidcStorage.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { findPersistedOidcUser } from "./oidcStorage"; + +class MemoryStorage implements Storage { + private data = new Map(); + + get length() { + return this.data.size; + } + + clear(): void { + this.data.clear(); + } + + getItem(key: string): string | null { + return this.data.get(key) ?? null; + } + + key(index: number): string | null { + return Array.from(this.data.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.data.delete(key); + } + + setItem(key: string, value: string): void { + this.data.set(key, value); + } +} + +describe("findPersistedOidcUser", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the first valid, unexpired devfront user entry", () => { + const storage = new MemoryStorage(); + storage.setItem("oidc.user:issuer:other-client", JSON.stringify({})); + const expiresAt = Math.floor(Date.now() / 1000) + 3600; + storage.setItem( + "oidc.user:issuer:devfront", + JSON.stringify({ + access_token: "token-1", + expires_at: expiresAt, + profile: { name: "Dev Admin" }, + }), + ); + + expect(findPersistedOidcUser(storage)).toEqual({ + access_token: "token-1", + expires_at: expiresAt, + profile: { name: "Dev Admin" }, + }); + }); + + it("skips malformed, empty, and expired entries", () => { + const storage = new MemoryStorage(); + storage.setItem("random", "value"); + storage.setItem("oidc.user:issuer:devfront", "not-json"); + storage.setItem( + "oidc.user:issuer:devfront", + JSON.stringify({ + access_token: "expired", + expires_at: Math.floor(Date.now() / 1000) - 1, + }), + ); + + expect(findPersistedOidcUser(storage)).toBeNull(); + }); +}); diff --git a/devfront/src/lib/role.test.ts b/devfront/src/lib/role.test.ts new file mode 100644 index 00000000..f91d910d --- /dev/null +++ b/devfront/src/lib/role.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { normalizeRole, resolveProfileRole } from "./role"; + +describe("normalizeRole", () => { + it("normalizes known role aliases", () => { + expect(normalizeRole("tenant_member")).toBe("user"); + expect(normalizeRole("admin")).toBe("tenant_admin"); + expect(normalizeRole("superadmin")).toBe("super_admin"); + expect(normalizeRole("tenantadmin")).toBe("tenant_admin"); + expect(normalizeRole("rpadmin")).toBe("rp_admin"); + }); + + it("returns a trimmed lowercase role for unknown values", () => { + expect(normalizeRole(" custom_role ")).toBe("custom_role"); + expect(normalizeRole(123)).toBe(""); + }); +}); + +describe("resolveProfileRole", () => { + it("prefers the first non-empty normalized role candidate", () => { + expect( + resolveProfileRole({ + role: " ", + grade: "tenant_member", + "custom:role": "admin", + }), + ).toBe("user"); + }); + + it("returns an empty string when no role is present", () => { + expect(resolveProfileRole(undefined)).toBe(""); + }); +});