forked from baron/baron-sso
devfront 테스트 커버리지 추가 보강
This commit is contained in:
148
common/core/components/audit/AuditLogTable.test.tsx
Normal file
148
common/core/components/audit/AuditLogTable.test.tsx
Normal file
@@ -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<typeof AuditLogTable>[0]) {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
roots.push(root);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(<AuditLogTable {...props} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -79,4 +79,29 @@ describe("CopyButton", () => {
|
|||||||
expect(execCommand).toHaveBeenCalledWith("copy");
|
expect(execCommand).toHaveBeenCalledWith("copy");
|
||||||
expect(onCopy).toHaveBeenCalledTimes(1);
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
208
devfront/src/features/audit/AuditLogsPage.test.tsx
Normal file
208
devfront/src/features/audit/AuditLogsPage.test.tsx
Normal file
@@ -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;
|
||||||
|
}) => (
|
||||||
|
<div>
|
||||||
|
<div>table:{logs.length}</div>
|
||||||
|
<button type="button" onClick={onLoadMore}>
|
||||||
|
Load more
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/common/ForbiddenMessage", () => ({
|
||||||
|
ForbiddenMessage: ({ resourceToken }: { resourceToken: string }) => (
|
||||||
|
<div>Forbidden:{resourceToken}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuditLogsPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
19
devfront/src/features/auth/authApi.test.ts
Normal file
19
devfront/src/features/auth/authApi.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
276
devfront/src/features/clients/ClientsPage.test.tsx
Normal file
276
devfront/src/features/clients/ClientsPage.test.tsx
Normal file
@@ -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<typeof import("react-router-dom")>(
|
||||||
|
"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<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[] = [];
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<ClientsPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
65
devfront/src/features/clients/components/ClientLogo.test.tsx
Normal file
65
devfront/src/features/clients/components/ClientLogo.test.tsx
Normal file
@@ -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 }) => (
|
||||||
|
<div data-testid="avatar">{children}</div>
|
||||||
|
),
|
||||||
|
AvatarImage: (props: ComponentProps<"img">) => <img {...props} />,
|
||||||
|
AvatarFallback: ({ children }: { children: ReactNode }) => (
|
||||||
|
<div data-testid="fallback">{children}</div>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const roots: Root[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const root of roots.splice(0)) {
|
||||||
|
act(() => {
|
||||||
|
root.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.body.innerHTML = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderLogo(client: Parameters<typeof ClientLogo>[0]["client"]) {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
roots.push(root);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(<ClientLogo client={client} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("react-router-dom")>(
|
||||||
|
"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<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[] = [];
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<ClientFederationPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
devfront/src/features/coverage/AuditLogTable.test.tsx
Normal file
123
devfront/src/features/coverage/AuditLogTable.test.tsx
Normal file
@@ -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<typeof AuditLogTable>[0]) {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const root = createRoot(container);
|
||||||
|
roots.push(root);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root.render(<AuditLogTable {...props} />);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,11 @@ import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
|
|||||||
import DeveloperRequestPage from "../developer-request/DeveloperRequestPage";
|
import DeveloperRequestPage from "../developer-request/DeveloperRequestPage";
|
||||||
import GlobalOverviewPage from "../overview/GlobalOverviewPage";
|
import GlobalOverviewPage from "../overview/GlobalOverviewPage";
|
||||||
import ProfilePage from "../profile/ProfilePage";
|
import ProfilePage from "../profile/ProfilePage";
|
||||||
|
import {
|
||||||
|
approveDeveloperRequest,
|
||||||
|
cancelDeveloperRequestApproval,
|
||||||
|
rejectDeveloperRequest,
|
||||||
|
} from "../../lib/devApi";
|
||||||
|
|
||||||
const authProfile = {
|
const authProfile = {
|
||||||
sub: "user-1",
|
sub: "user-1",
|
||||||
@@ -282,6 +287,19 @@ vi.mock("../../lib/devApi", () => ({
|
|||||||
createdAt: "2026-05-01T00:00:00Z",
|
createdAt: "2026-05-01T00:00:00Z",
|
||||||
updatedAt: "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" })),
|
approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })),
|
||||||
rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })),
|
rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })),
|
||||||
@@ -348,6 +366,16 @@ async function renderPage(
|
|||||||
return container;
|
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", () => {
|
describe("devfront coverage smoke pages", () => {
|
||||||
it("renders overview, client list, audit, developer request, and profile pages", async () => {
|
it("renders overview, client list, audit, developer request, and profile pages", async () => {
|
||||||
const overview = await renderPage(<GlobalOverviewPage />);
|
const overview = await renderPage(<GlobalOverviewPage />);
|
||||||
@@ -397,4 +425,65 @@ describe("devfront coverage smoke pages", () => {
|
|||||||
});
|
});
|
||||||
expect(relations.textContent).toContain("Dev Admin");
|
expect(relations.textContent).toContain("Dev Admin");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("covers developer request actions", async () => {
|
||||||
|
const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => undefined);
|
||||||
|
const requests = await renderPage(<DeveloperRequestPage />);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<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[] = [];
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<DeveloperRequestPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
84
devfront/src/lib/apiClient.test.ts
Normal file
84
devfront/src/lib/apiClient.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
76
devfront/src/lib/oidcStorage.test.ts
Normal file
76
devfront/src/lib/oidcStorage.test.ts
Normal file
@@ -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<string, string>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
33
devfront/src/lib/role.test.ts
Normal file
33
devfront/src/lib/role.test.ts
Normal file
@@ -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("");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user