forked from baron/baron-sso
devfront 테스트 커버리지 추가 보강
This commit is contained in:
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 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(<GlobalOverviewPage />);
|
||||
@@ -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(<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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user