1
0
forked from baron/baron-sso

Merge branch 'dev' into feature/issue-919-audit-logs-e2e

This commit is contained in:
2026-05-29 17:01:45 +09:00
44 changed files with 3981 additions and 217 deletions

View File

@@ -0,0 +1,145 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import AppLayout from "./AppLayout";
const authState = {
isAuthenticated: true,
isLoading: false,
user: {
access_token: "access-token",
expires_at: Math.floor(Date.now() / 1000) + 120,
profile: {
sub: "admin-1",
name: "Admin User",
email: "admin@example.com",
},
},
signinSilent: vi.fn(async () => undefined),
removeUser: vi.fn(),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({
id: "admin-1",
name: "Fetched Admin",
email: "fetched@example.com",
role: "super_admin",
tenantId: "tenant-1",
manageableTenants: [
{
id: "tenant-1",
name: "GPDTDC",
slug: "gpdtdc",
type: "COMPANY",
},
{
id: "tenant-2",
name: "기술연구팀",
slug: "gpdtdc-rnd",
type: "ORGANIZATION",
},
],
})),
}));
function renderLayout(entry = "/users") {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/" element={<AppLayout />}>
<Route path="users" element={<div>Users outlet</div>} />
<Route path="users/:id" element={<div>User detail outlet</div>} />
<Route
path="tenants/:tenantId"
element={<div>Tenant outlet</div>}
/>
<Route path="login" element={<div>Login outlet</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin AppLayout", () => {
beforeEach(() => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
authState.isAuthenticated = true;
authState.isLoading = false;
authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
authState.signinSilent.mockClear();
authState.removeUser.mockClear();
window.localStorage.clear();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders admin navigation, fetched profile, and outlet content", async () => {
renderLayout();
expect(await screen.findByText("Fetched Admin")).toBeInTheDocument();
expect(screen.getByText("Admin Control")).toBeInTheDocument();
expect(screen.getByText("Users outlet")).toBeInTheDocument();
expect(screen.getByText("Tenants")).toBeInTheDocument();
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
});
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
renderLayout();
const themeButton = await screen.findByRole("button", {
name: "테마 전환",
});
fireEvent.click(themeButton);
expect(document.documentElement.classList.contains("dark")).toBe(true);
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
expect(screen.getByText("Manageable Tenants")).toBeInTheDocument();
const sessionSwitch = screen.getByRole("switch");
fireEvent.click(sessionSwitch);
expect(window.localStorage.getItem("baron_session_expiry_enabled")).toBe(
"false",
);
fireEvent.click(screen.getByText("기술연구팀"));
expect(await screen.findByText("Tenant outlet")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
fireEvent.click(screen.getAllByText("내 정보")[0]);
expect(await screen.findByText("User detail outlet")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "계정 메뉴 열기" }));
fireEvent.click(screen.getAllByText("Logout")[1]);
expect(window.confirm).toHaveBeenCalled();
expect(authState.removeUser).toHaveBeenCalled();
}, 10_000);
it("attempts silent renewal on user activity when session is near expiry", async () => {
authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
renderLayout();
await screen.findByText("Fetched Admin");
fireEvent.keyDown(window, { key: "Tab" });
expect(authState.signinSilent).toHaveBeenCalled();
});
});

View File

@@ -148,11 +148,7 @@ function AppLayout() {
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
);
const {
data: profile,
isLoading: _isProfileLoading,
error: _profileError,
} = useQuery({
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: async () => {
debugLog("[AppLayout] Fetching profile...");

View File

@@ -0,0 +1,49 @@
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { Avatar, AvatarFallback, AvatarImage } from "./avatar";
let container: HTMLDivElement | null = null;
const render = async (element: React.ReactElement) => {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(element);
});
return root;
};
afterEach(() => {
if (container) {
container.remove();
container = null;
}
});
describe("Avatar", () => {
it("renders image and fallback with merged classes", async () => {
const root = await render(
<Avatar className="custom-root" data-testid="avatar">
<AvatarImage
alt="Admin user"
className="custom-image"
src="/avatar.png"
/>
<AvatarFallback className="custom-fallback">AU</AvatarFallback>
</Avatar>,
);
const avatar = container?.querySelector("[data-testid='avatar']");
const fallback = container?.textContent;
expect(avatar?.className).toContain("custom-root");
expect(fallback).toContain("AU");
await act(async () => {
root.unmount();
});
});
});

View File

@@ -0,0 +1,41 @@
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { Separator } from "./separator";
let container: HTMLDivElement | null = null;
const render = async (element: React.ReactElement) => {
container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
await act(async () => {
root.render(element);
});
return root;
};
afterEach(() => {
if (container) {
container.remove();
container = null;
}
});
describe("Separator", () => {
it("renders a horizontal separator with custom classes", async () => {
const root = await render(
<Separator className="custom-separator" data-testid="separator" />,
);
const separator = container?.querySelector("[data-testid='separator']");
expect(separator?.className).toContain("h-px");
expect(separator?.className).toContain("custom-separator");
await act(async () => {
root.unmount();
});
});
});

View File

@@ -0,0 +1,192 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import AuditLogsPage from "../audit/AuditLogsPage";
import AuthCallbackPage from "../auth/AuthCallbackPage";
import AuthGuard from "../auth/AuthGuard";
const authState = {
isAuthenticated: true,
isLoading: false,
activeNavigator: undefined as string | undefined,
error: null as Error | null,
user: {
access_token: "access-token",
state: undefined as unknown,
},
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../../../common/core/components/audit", () => ({
AuditLogTable: ({
logs,
}: {
logs: Array<{ user_id: string; event_type: string }>;
}) => (
<div>
{logs.map((log) => (
<div key={`${log.user_id}-${log.event_type}`}>
<span>{log.user_id}</span>
<span>{log.event_type}</span>
</div>
))}
</div>
),
}));
vi.mock("../../lib/adminApi", () => ({
fetchAuditLogs: vi.fn(async () => ({
items: [
{
event_id: "event-1",
timestamp: "2026-05-01T00:00:00Z",
user_id: "admin-1",
event_type: "USER_UPDATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
details: JSON.stringify({ action: "USER_UPDATE", actor: "Admin" }),
},
{
event_id: "event-2",
timestamp: "2026-05-01T01:00:00Z",
user_id: "admin-2",
event_type: "LOGIN_FAILED",
status: "failure",
ip_address: "127.0.0.2",
user_agent: "Vitest",
details: "{}",
},
],
limit: 50,
})),
}));
function renderWithProviders(ui: React.ReactElement, entry = "/") {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin audit and auth coverage smoke", () => {
beforeEach(() => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = false;
authState.isAuthenticated = true;
authState.isLoading = false;
authState.activeNavigator = undefined;
authState.error = null;
authState.user = {
access_token: "access-token",
state: undefined,
};
window.localStorage.clear();
});
it("renders audit log table with fetched events", async () => {
renderWithProviders(<AuditLogsPage />);
expect(await screen.findByText("감사 로그")).toBeInTheDocument();
expect(screen.getByText("admin-1")).toBeInTheDocument();
expect(screen.getByText("USER_UPDATE")).toBeInTheDocument();
});
it("renders AuthGuard loading, error, redirect, test, and outlet states", async () => {
authState.isLoading = true;
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
</Routes>,
"/secure",
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
authState.isLoading = false;
authState.error = new Error("OIDC failed");
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
</Routes>,
"/secure",
);
expect(screen.getByText("인증 오류")).toBeInTheDocument();
authState.error = null;
authState.isAuthenticated = false;
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>,
"/secure?x=1",
);
expect(screen.getByText("Login outlet")).toBeInTheDocument();
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
</Routes>,
"/secure",
);
expect(screen.getByText("Secure outlet")).toBeInTheDocument();
});
it("stores callback token and navigates by auth result", async () => {
authState.isAuthenticated = true;
authState.user = {
access_token: "callback-token",
state: { returnTo: "/users" },
};
renderWithProviders(
<Routes>
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/users" element={<div>Users outlet</div>} />
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>,
"/auth/callback",
);
expect(await screen.findByText("Users outlet")).toBeInTheDocument();
expect(window.localStorage.getItem("admin_session")).toBe("callback-token");
authState.isAuthenticated = false;
authState.error = new Error("callback failed");
renderWithProviders(
<Routes>
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>,
"/auth/callback",
);
expect(await screen.findByText("Login outlet")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,297 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage";
import TenantListPage from "../tenants/routes/TenantListPage";
import UserCreatePage from "../users/UserCreatePage";
import UserDetailPage from "../users/UserDetailPage";
const tenantItems = [
{
id: "tenant-root",
type: "COMPANY_GROUP",
name: "한맥 가족",
slug: "hanmac-family",
description: "root",
status: "active",
memberCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-company",
type: "COMPANY",
parentId: "tenant-root",
name: "GPDTDC",
slug: "gpdtdc",
description: "company",
status: "active",
memberCount: 2,
config: {
userSchema: [
{
key: "employee_id",
label: "사번",
type: "text",
required: false,
},
],
},
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-leaf",
type: "ORGANIZATION",
parentId: "tenant-company",
name: "기술연구팀",
slug: "gpdtdc-rnd",
description: "leaf",
status: "active",
memberCount: 1,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
];
const userDetail = {
id: "user-1",
email: "engineer@example.com",
name: "Engineer User",
phone: "010-0000-0000",
role: "user",
status: "active",
tenantSlug: "gpdtdc-rnd",
tenantId: "tenant-leaf",
department: "기술연구팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: {
employee_id: "EMP001",
sub_email: ["engineer.sub@example.com"],
},
tenant: tenantItems[2],
appointments: [
{
tenantId: "tenant-leaf",
tenantSlug: "gpdtdc-rnd",
tenantName: "기술연구팀",
isPrimary: true,
isOwner: false,
isAdmin: false,
isManager: true,
department: "기술연구팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: { employee_id: "EMP001" },
},
],
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-02T00:00:00Z",
};
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../components/auth/RoleGuard", () => ({
RoleGuard: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({
id: "admin-1",
role: "super_admin",
name: "Admin User",
email: "admin@example.com",
})),
fetchAllTenants: vi.fn(async () => ({
items: tenantItems,
total: tenantItems.length,
})),
fetchTenants: vi.fn(async () => ({
items: tenantItems,
limit: 500,
offset: 0,
total: tenantItems.length,
nextCursor: null,
})),
fetchTenant: vi.fn(async (id: string) => {
return tenantItems.find((tenant) => tenant.id === id) ?? tenantItems[1];
}),
createUser: vi.fn(async () => ({
id: "created-user",
email: "created@example.com",
generatedPassword: "GeneratedPassword!1",
})),
fetchUser: vi.fn(async () => userDetail),
fetchUserRpHistory: vi.fn(async () => [
{
client_id: "orgfront",
client_name: "OrgFront",
last_login_at: "2026-05-01T00:00:00Z",
login_count: 3,
},
]),
fetchPasswordPolicy: vi.fn(async () => ({
minLength: 12,
lowercase: true,
uppercase: true,
number: true,
nonAlphanumeric: true,
minCharacterTypes: 3,
})),
updateUser: vi.fn(async () => userDetail),
deleteUser: vi.fn(async () => undefined),
updateTenant: vi.fn(async () => tenantItems[1]),
deleteTenantsBulk: vi.fn(async () => ({ deleted: 1 })),
exportTenantsCSV: vi.fn(async () => new Blob(["name,slug\nGPDTDC,gpdtdc"])),
importTenantsCSV: vi.fn(async () => ({
created: 1,
updated: 0,
failed: 0,
errors: [],
})),
fetchWorksmobileOverview: vi.fn(async () => ({
tenant: tenantItems[1],
config: {
enabled: true,
tokenConfigured: true,
adminTenantId: "works-admin",
domainMappings: { "example.com": 1001 },
},
recentJobs: [
{
id: "job-1",
resourceType: "USER",
resourceId: "user-1",
action: "SYNC",
status: "failed",
retryCount: 1,
lastError: "temporary failure",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:10:00Z",
},
],
})),
fetchWorksmobileComparison: vi.fn(async () => ({
users: [
{
resourceType: "USER",
baronId: "user-1",
baronName: "Engineer User",
baronEmail: "engineer@example.com",
baronPrimaryOrgId: "tenant-leaf",
baronPrimaryOrgName: "기술연구팀",
worksmobileId: "works-user-1",
worksmobileName: "Engineer User",
worksmobileEmail: "engineer@example.com",
worksmobilePrimaryOrgId: "works-org-1",
worksmobilePrimaryOrgName: "기술연구팀",
status: "matched",
},
{
resourceType: "USER",
baronId: "user-2",
baronName: "New User",
baronEmail: "new@example.com",
status: "baron_only",
},
],
groups: [
{
resourceType: "ORG_UNIT",
baronId: "tenant-leaf",
baronSlug: "gpdtdc-rnd",
baronName: "기술연구팀",
worksmobileId: "works-org-1",
worksmobileName: "기술연구팀",
status: "needs_update",
},
],
})),
enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })),
retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })),
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => new Blob(["id"])),
enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })),
enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })),
enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })),
}));
function renderWithProviders(ui: React.ReactElement, entry = "/") {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("adminfront large page coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders user creation form with tenant context", async () => {
renderWithProviders(
<Routes>
<Route path="/users/new" element={<UserCreatePage />} />
</Routes>,
"/users/new?tenantSlug=gpdtdc-rnd",
);
expect(await screen.findByText("사용자 추가")).toBeInTheDocument();
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
});
it("renders user detail form and RP history", async () => {
renderWithProviders(
<Routes>
<Route path="/users/:id" element={<UserDetailPage />} />
</Routes>,
"/users/user-1",
);
expect(await screen.findByDisplayValue("Engineer User")).toBeInTheDocument();
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
expect(screen.getByDisplayValue("engineer@example.com")).toBeInTheDocument();
});
it("renders tenant list hierarchy", async () => {
renderWithProviders(
<Routes>
<Route path="/tenants" element={<TenantListPage />} />
</Routes>,
"/tenants",
);
expect(await screen.findByText("GPDTDC")).toBeInTheDocument();
expect(screen.getByText("기술연구팀")).toBeInTheDocument();
});
it("renders worksmobile comparison screens", async () => {
cleanup();
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/worksmobile"
element={<TenantWorksmobilePage />}
/>
</Routes>,
"/tenants/tenant-company/worksmobile",
);
expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument();
expect(screen.getByText("Baron / Works 비교")).toBeInTheDocument();
expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,129 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import TenantCreatePage from "../tenants/routes/TenantCreatePage";
import { TenantProfilePage } from "../tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../tenants/routes/TenantSchemaPage";
const tenants = [
{
id: "tenant-root",
type: "COMPANY_GROUP",
name: "한맥 가족",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
domains: ["hmac.kr"],
config: { visibility: "public" },
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-company",
type: "COMPANY",
parentId: "tenant-root",
name: "GPDTDC",
slug: "gpdtdc",
description: "실 조직",
status: "active",
memberCount: 2,
domains: ["gpdtdc.example.com"],
config: {
visibility: "public",
userSchema: [
{
key: "employee_id",
label: "사번",
type: "text",
required: false,
adminOnly: false,
isLoginId: true,
indexed: true,
},
],
},
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
];
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({
id: "admin-1",
role: "super_admin",
})),
fetchAllTenants: vi.fn(async () => ({
items: tenants,
total: tenants.length,
})),
fetchTenant: vi.fn(async (id: string) => {
return tenants.find((tenant) => tenant.id === id) ?? tenants[1];
}),
createTenant: vi.fn(async () => tenants[1]),
updateTenant: vi.fn(async () => tenants[1]),
deleteTenant: vi.fn(async () => undefined),
approveTenant: vi.fn(async () => tenants[1]),
}));
function renderWithProviders(ui: React.ReactElement, entry: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin tenant detail page coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders tenant create page with parent context", async () => {
renderWithProviders(
<Routes>
<Route path="/tenants/new" element={<TenantCreatePage />} />
</Routes>,
"/tenants/new?parentId=tenant-root",
);
expect(await screen.findByText("테넌트 생성")).toBeInTheDocument();
expect(screen.getByText("Tenant Profile")).toBeInTheDocument();
expect(screen.getByText("정책 메모")).toBeInTheDocument();
});
it("renders tenant profile and schema management pages", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId"
element={
<>
<TenantProfilePage />
<TenantSchemaPage />
</>
}
/>
</Routes>,
"/tenants/tenant-company",
);
expect(await screen.findByDisplayValue("GPDTDC")).toBeInTheDocument();
expect(screen.getByDisplayValue("gpdtdc")).toBeInTheDocument();
expect(await screen.findByText("사용자 스키마 확장")).toBeInTheDocument();
expect(screen.getByDisplayValue("employee_id")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,116 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import TenantGroupsPage from "../tenants/routes/TenantGroupsPage";
const tenant = {
id: "tenant-company",
type: "COMPANY",
name: "GPDTDC",
slug: "gpdtdc",
description: "",
status: "active",
memberCount: 2,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
};
const members = [
{
id: "user-1",
name: "Member User",
email: "member@example.com",
},
];
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchTenant: vi.fn(async () => tenant),
fetchUsers: vi.fn(async () => ({
items: [
{
id: "user-1",
name: "Member User",
email: "member@example.com",
role: "user",
status: "active",
},
{
id: "user-2",
name: "Candidate User",
email: "candidate@example.com",
role: "user",
status: "active",
},
],
total: 2,
})),
fetchGroups: vi.fn(async () => [
{
id: "group-root",
tenantId: "tenant-company",
name: "연구소",
description: "root group",
members,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "group-child",
tenantId: "tenant-company",
parentId: "group-root",
name: "플랫폼팀",
description: "child group",
members: [],
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
createGroup: vi.fn(async () => undefined),
deleteGroup: vi.fn(async () => undefined),
addGroupMember: vi.fn(async () => undefined),
removeGroupMember: vi.fn(async () => undefined),
}));
function renderWithProviders(ui: React.ReactElement, entry: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantGroupsPage coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders group hierarchy and selected group members", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/groups"
element={<TenantGroupsPage />}
/>
</Routes>,
"/tenants/tenant-company/groups",
);
expect((await screen.findAllByText("연구소")).length).toBeGreaterThan(0);
expect(screen.getAllByText("플랫폼팀").length).toBeGreaterThan(0);
expect(screen.getByText("새 그룹 생성")).toBeInTheDocument();
expect(screen.getByText("조직 단위 레벨")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,162 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
const tenants = [
{
id: "tenant-root",
type: "COMPANY_GROUP",
name: "한맥 가족",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-company",
type: "COMPANY",
parentId: "tenant-root",
name: "GPDTDC",
slug: "gpdtdc",
description: "",
status: "active",
memberCount: 2,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-leaf",
type: "ORGANIZATION",
parentId: "tenant-company",
name: "기술연구팀",
slug: "gpdtdc-rnd",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
];
const users = [
{
id: "user-owner",
name: "Owner User",
email: "owner@example.com",
role: "tenant_admin",
status: "active",
},
{
id: "user-admin",
name: "Admin User",
email: "admin@example.com",
role: "tenant_admin",
status: "active",
},
{
id: "user-member",
name: "Member User",
email: "member@example.com",
role: "user",
status: "active",
tenantSlug: "gpdtdc-rnd",
tenant: tenants[2],
},
];
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("react-oidc-context", () => ({
useAuth: () => ({
user: {
profile: {
sub: "admin-1",
},
},
}),
}));
vi.mock("../../lib/adminApi", () => ({
fetchTenantOwners: vi.fn(async () => [users[0]]),
fetchTenantAdmins: vi.fn(async () => [users[1]]),
addTenantOwner: vi.fn(async () => undefined),
addTenantAdmin: vi.fn(async () => undefined),
removeTenantOwner: vi.fn(async () => undefined),
removeTenantAdmin: vi.fn(async () => undefined),
fetchUsers: vi.fn(async () => ({
items: users,
total: users.length,
})),
fetchAllTenants: vi.fn(async () => ({
items: tenants,
total: tenants.length,
})),
updateTenant: vi.fn(async () => tenants[2]),
updateUser: vi.fn(async () => users[2]),
exportTenantsCSV: vi.fn(async () => ({
blob: new Blob(["name,slug"]),
filename: "tenants.csv",
})),
}));
function renderWithProviders(ui: React.ReactElement, entry: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin tenant tab coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders tenant owners and admins lists", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/permissions"
element={<TenantAdminsAndOwnersTab />}
/>
</Routes>,
"/tenants/tenant-company/permissions",
);
expect(await screen.findByText("Owner User")).toBeInTheDocument();
expect(screen.getByText("Admin User")).toBeInTheDocument();
expect(screen.getByText("owner@example.com")).toBeInTheDocument();
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
});
it("renders tenant hierarchy and selected organization members", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/organization"
element={<TenantUserGroupsTab />}
/>
</Routes>,
"/tenants/tenant-company/organization",
);
expect((await screen.findAllByText("GPDTDC")).length).toBeGreaterThan(0);
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
expect(await screen.findByText("Member User")).toBeInTheDocument();
});
});

View File

@@ -79,7 +79,6 @@ import {
import { toast } from "../../../components/ui/use-toast";
import type { UserProfileResponse } from "../../../lib/adminApi";
import {
deleteTenant,
deleteTenantsBulk,
exportTenantsCSV,
fetchMe,
@@ -326,13 +325,6 @@ function TenantListPage() {
(profile?.manageableTenants?.length ?? 0) > 1),
});
const deleteMutation = useMutation({
mutationFn: (tenantId: string) => deleteTenant(tenantId),
onSuccess: () => {
query.refetch();
},
});
const deleteBulkMutation = useMutation({
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
onSuccess: () => {
@@ -711,25 +703,6 @@ function TenantListPage() {
importMutation.mutate(file);
};
const handleDelete = (tenantId: string, tenantName: string) => {
const tenant = allTenants.find((item) => item.id === tenantId);
if (tenant && isSeedTenant(tenant)) {
return;
}
if (
!window.confirm(
t(
"msg.admin.tenants.delete_confirm",
'테넌트 "{{name}}"를 삭제할까요?',
{ name: tenantName },
),
)
) {
return;
}
deleteMutation.mutate(tenantId);
};
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<PageHeader
@@ -948,8 +921,6 @@ function TenantListPage() {
selectedIds={selectedIds}
onSelect={handleSelect}
onSelectAll={handleSelectAll}
onDelete={handleDelete}
isDeletePending={deleteMutation.isPending}
search={search}
deletableTenants={deletableTenants}
statusMutation={statusMutation}
@@ -1333,8 +1304,6 @@ const TenantHierarchyView: React.FC<{
selectedIds: string[];
onSelect: (tenant: TenantSummary, checked: boolean) => void;
onSelectAll: (checked: boolean) => void;
onDelete: (tenantId: string, tenantName: string) => void;
isDeletePending: boolean;
search: string;
deletableTenants: TenantSummary[];
statusMutation: UseMutationResult<
@@ -1354,8 +1323,6 @@ const TenantHierarchyView: React.FC<{
selectedIds,
onSelect,
onSelectAll,
onDelete: _onDelete,
isDeletePending: _isDeletePending,
search,
deletableTenants,
statusMutation,

View File

@@ -442,7 +442,7 @@ function TenantUserGroupsTab() {
queryFn: () => fetchAllTenants(),
});
const { currentBase, subTree: _subTree } = useMemo(() => {
const { currentBase } = useMemo(() => {
const allItems = allTenantsData?.items ?? [];
return buildTenantFullTree(allItems, tenantId);
}, [allTenantsData, tenantId]);

View File

@@ -662,6 +662,7 @@ function UserCreatePage() {
variant="ghost"
size="sm"
className="absolute right-1 top-1 h-8 text-xs font-bold"
data-testid="add-sub-email-btn"
onClick={() => {
const value = newSubEmail.trim().replace(/,/g, "");
if (
@@ -678,7 +679,7 @@ function UserCreatePage() {
}}
>
{t("ui.common.add", "추가")}
</Button>
</Button>{" "}
</div>
<p className="text-[10px] text-muted-foreground mt-1">
* . .
@@ -877,6 +878,7 @@ function UserCreatePage() {
variant="outline"
size="sm"
onClick={addAppointment}
data-testid="add-appointment-btn"
>
<Plus className="mr-2 h-4 w-4" />
{t("ui.common.add", "추가")}

View File

@@ -94,8 +94,8 @@ import { resolvePersonalTenant } from "./utils/personalTenant";
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
email: string;
metadata: Record<string, Record<string, string | number | boolean>> & {
sub_email?: string[];
metadata: Record<string, unknown> & {
sub_email?: string | string[];
};
};
type UserCategory = "hanmac" | "external" | "personal";
@@ -108,6 +108,44 @@ type AppointmentDraft = UserAppointment & {
const PASSWORD_RESET_MIN_LENGTH = 12;
function isMetadataRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function cleanMetadataValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter(Boolean);
}
if (isMetadataRecord(value)) {
return Object.fromEntries(
Object.entries(value).filter(
([_, fieldValue]) =>
fieldValue !== undefined && fieldValue !== null && fieldValue !== "",
),
);
}
return value;
}
function normalizeSubEmails(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim())
.filter((item) => item.includes("@"));
}
if (typeof value === "string" && value.trim() !== "") {
return value
.split(/[;,\n\r\t]/)
.map((email) => email.trim())
.filter((email) => email.includes("@"));
}
return [];
}
function createDraftId() {
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
}
@@ -773,15 +811,17 @@ function UserDetailPage() {
});
const onSubmit = async (data: UserFormValues) => {
// Filter out undefined/null/empty strings from metadata
const cleanMetadata = Object.fromEntries(
Object.entries(data.metadata).map(([tenantId, fields]) => {
const cleanFields = Object.fromEntries(
Object.entries(fields).filter(
([_, v]) => v !== undefined && v !== null && v !== "",
),
);
return [tenantId, cleanFields];
Object.entries(data.metadata ?? {}).flatMap(([key, value]) => {
const cleanedValue = cleanMetadataValue(value);
if (
cleanedValue === undefined ||
cleanedValue === null ||
cleanedValue === ""
) {
return [];
}
return [[key, cleanedValue]];
}),
);
@@ -791,22 +831,11 @@ function UserDetailPage() {
sub_email: rawSubEmail,
...safeMetadata
} = cleanMetadata;
// Parse sub_email
let sub_email: string[] = [];
if (
typeof rawSubEmail === "string" &&
(rawSubEmail as string).trim() !== ""
) {
sub_email = (rawSubEmail as string)
.split(/[;,\n\r\t]/)
.map((e: string) => e.trim())
.filter((e: string) => e.includes("@"));
}
const subEmail = normalizeSubEmails(rawSubEmail);
const metadata: Record<string, unknown> = {
...safeMetadata,
...(sub_email.length > 0 ? { sub_email } : { sub_email: [] }),
...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }),
};
const payload: UserUpdateRequest = {
@@ -991,16 +1020,14 @@ function UserDetailPage() {
<Mail size={14} className="text-primary/70" />
{user.email}
</div>
{!!user.metadata?.sub_email &&
Array.isArray(user.metadata.sub_email) &&
(user.metadata.sub_email as unknown[]).length > 0 && (
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
<Mail size={14} className="text-primary/40" />
<span className="text-[10px] font-bold">
+{(user.metadata.sub_email as unknown[]).length}
</span>
</div>
)}
{normalizeSubEmails(user.metadata?.sub_email).length > 0 && (
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
<Mail size={14} className="text-primary/40" />
<span className="text-[10px] font-bold">
+{normalizeSubEmails(user.metadata?.sub_email).length}
</span>
</div>
)}
{user.phone && (
<div className="flex items-center gap-1.5 bg-background px-2.5 py-1 rounded-full border">
<Shield size={14} className="text-primary/70" />
@@ -1166,6 +1193,7 @@ function UserDetailPage() {
variant="ghost"
size="sm"
className="absolute right-1 top-1 h-9 text-xs font-bold"
data-testid="add-sub-email-btn"
onClick={() => {
const value = newSubEmail.trim().replace(/,/g, "");
if (
@@ -1319,6 +1347,7 @@ function UserDetailPage() {
variant="outline"
size="sm"
onClick={addAppointment}
data-testid="add-appointment-btn"
>
<Plus className="mr-2 h-4 w-4" />
{t("ui.common.add", "추가")}

View File

@@ -354,10 +354,12 @@ function applySecondaryEmailMetadata(
value: string,
) {
const emails = splitEmailTokens(value);
item.metadata.sub_email = uniqueEmails([
...metadataEmailList(item.metadata.sub_email),
const uniqueSecondaryEmails = uniqueEmails([
...metadataEmailList(item.metadata.secondary_emails),
...emails,
]);
item.metadata.sub_email = value;
item.metadata.secondary_emails = uniqueSecondaryEmails;
addWorksmobileAliasEmails(item, emails);
}

View File

@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiClient = {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
};
const fetchAllCursorPages = vi.fn(async () => ({
items: [{ id: "tenant-1", name: "Tenant", slug: "tenant" }],
total: 1,
}));
vi.mock("./apiClient", () => ({
default: apiClient,
}));
vi.mock("./auth", () => ({
userManager: {
getUser: vi.fn(async () => ({ access_token: "access-token" })),
},
}));
vi.mock("../../../common/core/pagination", () => ({
fetchAllCursorPages,
}));
describe("adminApi endpoint contracts", () => {
beforeEach(() => {
apiClient.get.mockReset();
apiClient.post.mockReset();
apiClient.put.mockReset();
apiClient.patch.mockReset();
apiClient.delete.mockReset();
apiClient.get.mockResolvedValue({
data: { ok: true },
headers: { "content-disposition": 'attachment; filename="export.csv"' },
});
apiClient.post.mockResolvedValue({ data: { ok: true } });
apiClient.put.mockResolvedValue({ data: { ok: true } });
apiClient.patch.mockResolvedValue({ data: { ok: true } });
apiClient.delete.mockResolvedValue({ data: { ok: true } });
fetchAllCursorPages.mockClear();
window.localStorage.clear();
});
it("routes read APIs to their documented admin endpoints", async () => {
const adminApi = await import("./adminApi");
await adminApi.fetchAuditLogs(10, "cursor-a");
await adminApi.fetchAdminOverviewStats();
await adminApi.fetchDataIntegrityReport();
await adminApi.fetchOrphanUserLoginIDs();
await adminApi.fetchUserProjectionStatus();
await adminApi.fetchAdminRPUsageDaily({
days: 30,
period: "week",
tenantId: "tenant-1",
});
await adminApi.fetchTenants(25, 50, "parent-1", "cursor-b");
await adminApi.fetchAllTenants({ pageSize: 200, parentId: "parent-1" });
await adminApi.fetchTenant("tenant-1");
await adminApi.fetchTenantAdmins("tenant-1");
await adminApi.fetchTenantOwners("tenant-1");
await adminApi.fetchGroups("tenant-1");
await adminApi.fetchGroup("tenant-1", "group-1");
await adminApi.fetchGroupRoles("tenant-1", "group-1");
await adminApi.fetchApiKeys(20, 40);
await adminApi.fetchUsers(30, 60, "admin", "tenant");
await adminApi.fetchUser("user-1");
await adminApi.fetchWorksmobileOverview("tenant-1");
await adminApi.fetchWorksmobileComparison("tenant-1", true);
await adminApi.downloadWorksmobileInitialPasswordsCSV("tenant-1");
await adminApi.fetchPasswordPolicy();
await adminApi.fetchUserRpHistory("user-1");
await adminApi.fetchMe();
await adminApi.fetchRelyingParties("tenant-1");
await adminApi.fetchAllRelyingParties();
await adminApi.fetchRelyingParty("client-1");
await adminApi.fetchRPOwners("client-1");
expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", {
params: { limit: 10, cursor: "cursor-a" },
});
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", {
params: {
limit: 25,
offset: 50,
parentId: "parent-1",
cursor: "cursor-b",
},
});
expect(fetchAllCursorPages).toHaveBeenCalledWith(
expect.objectContaining({
path: "/v1/admin/tenants",
pageSize: 200,
params: { parentId: "parent-1" },
}),
);
expect(apiClient.get).toHaveBeenCalledWith(
"/v1/admin/tenants/tenant-1/worksmobile/comparison",
{ params: { includeMatched: true } },
);
expect(await adminApi.exportTenantsCSV(true, "parent-1")).toMatchObject({
filename: "export.csv",
});
expect(
await adminApi.exportUsersCSV("admin", "tenant", true),
).toMatchObject({
filename: "export.csv",
});
});
it("routes mutation APIs to their documented admin endpoints", async () => {
const adminApi = await import("./adminApi");
await adminApi.deleteOrphanUserLoginIDs(["orphan-1"]);
await adminApi.reconcileUserProjection();
await adminApi.resetUserProjection();
await adminApi.createTenant({ name: "Tenant", slug: "tenant" });
await adminApi.updateTenant("tenant-1", { status: "inactive" });
await adminApi.deleteTenant("tenant-1");
await adminApi.deleteTenantsBulk(["tenant-1"]);
await adminApi.importTenantsCSV(new File(["name"], "tenants.csv"));
await adminApi.approveTenant("tenant-1");
await adminApi.addTenantAdmin("tenant-1", "user-1");
await adminApi.removeTenantAdmin("tenant-1", "user-1");
await adminApi.addTenantOwner("tenant-1", "user-1");
await adminApi.removeTenantOwner("tenant-1", "user-1");
await adminApi.createGroup("tenant-1", { name: "Group" });
await adminApi.deleteGroup("tenant-1", "group-1");
await adminApi.addGroupMember("tenant-1", "group-1", "user-1");
await adminApi.removeGroupMember("tenant-1", "group-1", "user-1");
await adminApi.assignGroupRole("tenant-1", "group-1", "tenant-2", "owner");
await adminApi.removeGroupRole("tenant-1", "group-1", "tenant-2", "owner");
await adminApi.createApiKey({ name: "key", scopes: ["read"] });
await adminApi.updateApiKeyScopes("key-1", { scopes: ["write"] });
await adminApi.rotateApiKeySecret("key-1");
await adminApi.deleteApiKey("key-1");
await adminApi.createUser({ email: "user@example.com", name: "User" });
await adminApi.bulkCreateUsers([
{ email: "user@example.com", name: "User", metadata: {} },
]);
await adminApi.enqueueWorksmobileBackfillDryRun("tenant-1");
await adminApi.enqueueWorksmobileOrgUnitSync("tenant-1", "org/unit");
await adminApi.enqueueWorksmobileOrgUnitDelete("tenant-1", "org/unit");
await adminApi.enqueueWorksmobileUserSync("tenant-1", "user-1");
await adminApi.retryWorksmobileJob("tenant-1", "job-1");
await adminApi.bulkUpdateUsers({ userIds: ["user-1"], status: "inactive" });
await adminApi.bulkDeleteUsers(["user-1"]);
await adminApi.updateUser("user-1", { status: "active" });
await adminApi.deleteUser("user-1");
await adminApi.createRelyingParty("tenant-1", {
client_name: "RP",
redirect_uris: ["https://rp.example/callback"],
});
await adminApi.updateRelyingParty("client-1", {
client_name: "RP",
redirect_uris: ["https://rp.example/callback"],
});
await adminApi.deleteRelyingParty("client-1");
await adminApi.addRPOwner("client-1", "User:user-1");
await adminApi.removeRPOwner("client-1", "User:user-1");
expect(apiClient.delete).toHaveBeenCalledWith(
"/v1/admin/integrity/orphan-user-login-ids",
{ data: { ids: ["orphan-1"] } },
);
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/projections/users/reconcile",
);
expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", {
status: "active",
});
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/tenants/tenant-1/worksmobile/orgunits/org%2Funit/sync",
);
expect(apiClient.delete).toHaveBeenCalledWith(
"/v1/admin/relying-parties/client-1/owners/User:user-1",
);
});
});

View File

@@ -1,5 +1,10 @@
import { describe, expect, it } from "vitest";
import { cn } from "./utils";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cn, generateSecurePassword } from "./utils";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("cn utility", () => {
it("merges class names correctly", () => {
@@ -11,3 +16,23 @@ describe("cn utility", () => {
expect(cn("px-2 py-2", "px-4")).toBe("py-2 px-4");
});
});
describe("generateSecurePassword", () => {
it("uses crypto random values when available", () => {
vi.stubGlobal("crypto", {
getRandomValues: vi.fn((values: Uint32Array) => {
values.set([0, 1, 2, 3]);
return values;
}),
});
expect(generateSecurePassword(4)).toBe("abcd");
});
it("falls back to Math.random when crypto is unavailable", () => {
vi.stubGlobal("crypto", undefined);
vi.spyOn(Math, "random").mockReturnValue(0);
expect(generateSecurePassword(3)).toBe("aaa");
});
});