1
0
forked from baron/baron-sso

devfront 테스트 커버리지 추가 보강

This commit is contained in:
2026-06-01 17:37:13 +09:00
parent a4d457073a
commit 38605ac8a3
13 changed files with 1513 additions and 0 deletions

View File

@@ -79,4 +79,29 @@ describe("CopyButton", () => {
expect(execCommand).toHaveBeenCalledWith("copy");
expect(onCopy).toHaveBeenCalledTimes(1);
});
it("keeps running when the fallback copy flow fails", async () => {
const execCommand = vi.fn(() => false);
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
Object.defineProperty(document, "execCommand", {
value: execCommand,
configurable: true,
});
Object.defineProperty(window, "isSecureContext", {
value: false,
configurable: true,
});
const { container, onCopy } = renderCopyButton("client-secret");
const button = container.querySelector("button");
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(execCommand).toHaveBeenCalledWith("copy");
expect(onCopy).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalled();
});
});

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

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

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

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

View File

@@ -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",
}),
);
});
});

View 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);
});
});

View File

@@ -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();
});
});

View File

@@ -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",
});
});
});

View 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();
});
});

View 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();
});
});

View 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("");
});
});