(null);
const { data: profile } = useQuery({
queryKey: ["me"],
@@ -340,13 +321,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: () => {
@@ -725,25 +699,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 (
void;
onSelectAll: (checked: boolean) => void;
- onDelete: (tenantId: string, tenantName: string) => void;
- isDeletePending: boolean;
search: string;
deletableTenants: TenantSummary[];
statusMutation: UseMutationResult<
@@ -1368,8 +1319,6 @@ const TenantHierarchyView: React.FC<{
selectedIds,
onSelect,
onSelectAll,
- onDelete,
- isDeletePending,
search,
deletableTenants,
statusMutation,
diff --git a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx
index 5fbd5edd..1fcb5111 100644
--- a/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantSubTenantsPage.tsx
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
-import { ArrowRight, Building2, Plus } from "lucide-react";
+import { Building2, Plus } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
commonStickyTableHeaderClass,
diff --git a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx
index 8c8638ce..a6d61460 100644
--- a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx
@@ -1,14 +1,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
-import {
- Loader2,
- Mail,
- MoreHorizontal,
- Plus,
- User,
- UserMinus,
- UserPlus,
-} from "lucide-react";
+import { Loader2, Mail, Plus, User, UserPlus } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
@@ -19,12 +11,6 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "../../../components/ui/dropdown-menu";
import {
Table,
TableBody,
@@ -80,7 +66,7 @@ function TenantUsersPage() {
},
});
- const handleRemoveMember = (userId: string, userName: string) => {
+ const _handleRemoveMember = (userId: string, userName: string) => {
if (!tenantSlug) return;
if (
window.confirm(
diff --git a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx
index a4533e21..5fb3b1f1 100644
--- a/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx
+++ b/adminfront/src/features/user-groups/routes/GlobalUserGroupListPage.tsx
@@ -1,6 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Building2, Plus, Users } from "lucide-react";
-import { useState } from "react";
import { Link } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
diff --git a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx
index b46d00ae..a4b4a25f 100644
--- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx
+++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx
@@ -6,7 +6,6 @@ import {
Building2,
ChevronDown,
ChevronRight,
- CornerDownRight,
Download,
ExternalLink,
FolderOpen,
@@ -41,7 +40,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
- DialogTrigger,
} from "../../../components/ui/dialog";
import {
DropdownMenu,
@@ -52,7 +50,6 @@ import {
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import { Input } from "../../../components/ui/input";
-import { Label } from "../../../components/ui/label";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
Table,
@@ -62,15 +59,8 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
-import {
- Tabs,
- TabsContent,
- TabsList,
- TabsTrigger,
-} from "../../../components/ui/tabs";
import { toast } from "../../../components/ui/use-toast";
import {
- createUser,
exportTenantsCSV,
fetchAllTenants,
fetchUsers,
@@ -413,7 +403,7 @@ const MemberTable: React.FC<{
function TenantUserGroupsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
- const navigate = useNavigate();
+ const _navigate = useNavigate();
const queryClient = useQueryClient();
const [selectedNodeId, setSelectedNodeId] = useState(tenantId || "");
@@ -452,7 +442,7 @@ function TenantUserGroupsTab() {
queryFn: () => fetchAllTenants(),
});
- const { currentBase, subTree } = useMemo(() => {
+ const { currentBase } = useMemo(() => {
const allItems = allTenantsData?.items ?? [];
return buildTenantFullTree(allItems, tenantId);
}, [allTenantsData, tenantId]);
@@ -855,7 +845,7 @@ const UserAddDialog: React.FC<{
try {
const res = await fetchUsers(20, 0, userSearch);
setSearchResults(res.items);
- } catch (err) {
+ } catch (_err) {
toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패"));
} finally {
setIsSearching(false);
diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx
index d47a49c5..f7cccce0 100644
--- a/adminfront/src/features/users/UserCreatePage.tsx
+++ b/adminfront/src/features/users/UserCreatePage.tsx
@@ -8,7 +8,6 @@ import {
Plus,
Save,
Trash2,
- Mail,
X,
} from "lucide-react";
import * as React from "react";
@@ -22,7 +21,6 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
-import { Checkbox } from "../../components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -184,7 +182,7 @@ function UserCreatePage() {
if (e.key === "Enter" || e.key === "," || e.key === " ") {
e.preventDefault();
const value = newSubEmail.trim().replace(/,/g, "");
- if (value && value.includes("@") && !currentSubEmails.includes(value)) {
+ if (value?.includes("@") && !currentSubEmails.includes(value)) {
setValue("metadata.sub_email", [...currentSubEmails, value], {
shouldDirty: true,
});
@@ -667,8 +665,7 @@ function UserCreatePage() {
onClick={() => {
const value = newSubEmail.trim().replace(/,/g, "");
if (
- value &&
- value.includes("@") &&
+ value?.includes("@") &&
!currentSubEmails.includes(value)
) {
setValue(
diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx
index 02f1a721..58fa2659 100644
--- a/adminfront/src/features/users/UserDetailPage.tsx
+++ b/adminfront/src/features/users/UserDetailPage.tsx
@@ -35,7 +35,6 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
-import { Checkbox } from "../../components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -95,8 +94,8 @@ import { resolvePersonalTenant } from "./utils/personalTenant";
type UserFormValues = Omit & {
email: string;
- metadata: Record> & {
- sub_email?: string[];
+ metadata: Record & {
+ sub_email?: string | string[];
};
};
type UserCategory = "hanmac" | "external" | "personal";
@@ -109,6 +108,44 @@ type AppointmentDraft = UserAppointment & {
const PASSWORD_RESET_MIN_LENGTH = 12;
+function isMetadataRecord(value: unknown): value is Record {
+ 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()}`;
}
@@ -322,8 +359,8 @@ function UserDetailPage() {
const userId = params.id ?? "";
const navigate = useNavigate();
const queryClient = useQueryClient();
- const [error, setError] = React.useState(null);
- const [successMsg, setSuccessMsg] = React.useState(null);
+ const [_error, _setError] = React.useState(null);
+ const [_successMsg, _setSuccessMsg] = React.useState(null);
const [isPasswordResetOpen, setIsPasswordResetOpen] = React.useState(false);
const [generatedPassword, setGeneratedPassword] = React.useState<
string | null
@@ -419,7 +456,7 @@ function UserDetailPage() {
if (e.key === "Enter" || e.key === "," || e.key === " ") {
e.preventDefault();
const value = newSubEmail.trim().replace(/,/g, "");
- if (value && value.includes("@") && !currentSubEmails.includes(value)) {
+ if (value?.includes("@") && !currentSubEmails.includes(value)) {
setValue("metadata.sub_email", [...currentSubEmails, value], {
shouldDirty: true,
});
@@ -595,7 +632,7 @@ function UserDetailPage() {
);
};
- const setPrimaryAppointment = (targetIndex: number) => {
+ const _setPrimaryAppointment = (targetIndex: number) => {
setAdditionalAppointments((current) =>
current.map((appointment, index) => ({
...appointment,
@@ -774,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]];
}),
);
@@ -792,19 +831,11 @@ function UserDetailPage() {
sub_email: rawSubEmail,
...safeMetadata
} = cleanMetadata;
-
- // Parse sub_email
- let sub_email: string[] = [];
- if (typeof rawSubEmail === "string" && rawSubEmail.trim() !== "") {
- sub_email = rawSubEmail
- .split(/[;,\n\r\t]/)
- .map((e) => e.trim())
- .filter((e) => e.includes("@"));
- }
+ const subEmail = normalizeSubEmails(rawSubEmail);
const metadata: Record = {
...safeMetadata,
- ...(sub_email.length > 0 ? { sub_email } : { sub_email: [] }),
+ ...(subEmail.length > 0 ? { sub_email: subEmail } : { sub_email: [] }),
};
const payload: UserUpdateRequest = {
@@ -813,7 +844,7 @@ function UserDetailPage() {
};
// email cannot be updated directly via this API in current backend implementation,
// so we delete it from payload if it spread
- // @ts-ignore
+ // @ts-expect-error
delete payload.email;
payload.role = undefined;
@@ -989,8 +1020,7 @@ function UserDetailPage() {
{user.email}
- {user.metadata?.sub_email &&
- Array.isArray(user.metadata.sub_email) &&
+ {Array.isArray(user.metadata?.sub_email) &&
user.metadata.sub_email.length > 0 && (
@@ -1167,8 +1197,7 @@ function UserDetailPage() {
onClick={() => {
const value = newSubEmail.trim().replace(/,/g, "");
if (
- value &&
- value.includes("@") &&
+ value?.includes("@") &&
!currentSubEmails.includes(value)
) {
setValue(
diff --git a/adminfront/src/features/users/UserListPage.render.test.tsx b/adminfront/src/features/users/UserListPage.render.test.tsx
index 72c5145f..6694f252 100644
--- a/adminfront/src/features/users/UserListPage.render.test.tsx
+++ b/adminfront/src/features/users/UserListPage.render.test.tsx
@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx
index fa5d4795..a914b63a 100644
--- a/adminfront/src/features/users/UserListPage.tsx
+++ b/adminfront/src/features/users/UserListPage.tsx
@@ -13,7 +13,6 @@ import {
ChevronDown,
ChevronLeft,
ChevronRight,
- Download,
FileDown,
FileSpreadsheet,
LayoutDashboard,
@@ -268,7 +267,7 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
});
function UserListPage() {
- const navigate = useNavigate();
+ const _navigate = useNavigate();
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState("");
const [selectedCompany, setSelectedCompany] = React.useState("");
@@ -563,7 +562,7 @@ function UserListPage() {
},
});
- const handleApplyBulkStatus = () => {
+ const _handleApplyBulkStatus = () => {
if (selectedUserIds.length === 0 || !selectedBulkStatus) return;
bulkUpdateMutation.mutate({
userIds: selectedUserIds,
@@ -571,7 +570,7 @@ function UserListPage() {
});
};
- const handleApplyBulkPermission = () => {
+ const _handleApplyBulkPermission = () => {
if (selectedUserIds.length === 0 || !selectedBulkPermission) return;
bulkUpdateMutation.mutate({
userIds: selectedUserIds,
@@ -594,7 +593,7 @@ function UserListPage() {
}
};
- const handleDelete = (userId: string, userName: string) => {
+ const _handleDelete = (userId: string, userName: string) => {
if (
!window.confirm(
t(
diff --git a/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx b/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx
index 41232425..c045d2f9 100644
--- a/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx
+++ b/adminfront/src/features/users/components/UserBulkMoveGroupModal.tsx
@@ -19,8 +19,6 @@ import {
bulkUpdateUsers,
fetchAllTenants,
fetchGroups,
- type GroupSummary,
- type TenantSummary,
type UserSummary,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -49,7 +47,7 @@ export function UserBulkMoveGroupModal({
const [searchTerm, setSearchTerm] = React.useState("");
const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false);
- const queryClient = useQueryClient();
+ const _queryClient = useQueryClient();
const { data: tenantsData } = useQuery({
queryKey: ["tenants", "all"],
diff --git a/adminfront/src/features/users/orgChartPicker.ts b/adminfront/src/features/users/orgChartPicker.ts
index 7d8cbf2d..20f06746 100644
--- a/adminfront/src/features/users/orgChartPicker.ts
+++ b/adminfront/src/features/users/orgChartPicker.ts
@@ -102,7 +102,7 @@ export function isHanmacFamilyTenant(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
- if (!tenant || !tenant.id) return false;
+ if (!tenant?.id) return false;
const rootTenantId = resolveHanmacFamilyTenantId(
tenants,
diff --git a/adminfront/src/features/users/utils/csvParser.ts b/adminfront/src/features/users/utils/csvParser.ts
index db811b38..a6ab2f1c 100644
--- a/adminfront/src/features/users/utils/csvParser.ts
+++ b/adminfront/src/features/users/utils/csvParser.ts
@@ -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);
}
diff --git a/adminfront/src/lib/adminApi.contract.test.ts b/adminfront/src/lib/adminApi.contract.test.ts
new file mode 100644
index 00000000..5c6de00e
--- /dev/null
+++ b/adminfront/src/lib/adminApi.contract.test.ts
@@ -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",
+ );
+ });
+});
diff --git a/adminfront/src/lib/tenantTree.ts b/adminfront/src/lib/tenantTree.ts
index 1c6ae529..80606f19 100644
--- a/adminfront/src/lib/tenantTree.ts
+++ b/adminfront/src/lib/tenantTree.ts
@@ -24,7 +24,7 @@ export function buildTenantFullTree(
});
}
- const visitedDuringBuild = new Set();
+ const _visitedDuringBuild = new Set();
// Build initial children relations and prevent simple cycles
for (const t of allTenants) {
if (t.parentId && t.parentId !== t.id) {
diff --git a/adminfront/tests/tenants.spec.ts b/adminfront/tests/tenants.spec.ts
index f12eeba7..77a9d2dd 100644
--- a/adminfront/tests/tenants.spec.ts
+++ b/adminfront/tests/tenants.spec.ts
@@ -258,7 +258,7 @@ test.describe("Tenants Management", () => {
page,
}) => {
await page.setViewportSize({ width: 900, height: 700 });
- let requestCount = 0;
+ let _requestCount = 0;
await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() !== "GET") {
@@ -266,7 +266,7 @@ test.describe("Tenants Management", () => {
}
const url = new URL(route.request().url());
const cursor = url.searchParams.get("cursor");
- requestCount += 1;
+ _requestCount += 1;
if (!cursor) {
return route.fulfill({
diff --git a/adminfront/tests/users_bulk_secondary.spec.ts b/adminfront/tests/users_bulk_secondary.spec.ts
index d7a03e7b..ef1e814f 100644
--- a/adminfront/tests/users_bulk_secondary.spec.ts
+++ b/adminfront/tests/users_bulk_secondary.spec.ts
@@ -1,11 +1,21 @@
import { expect, test } from "@playwright/test";
+type BulkUsersRequest = {
+ users: Array<{
+ metadata: {
+ sub_email?: string[];
+ };
+ }>;
+};
+
test.describe("Users Bulk Upload Secondary Emails", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
- (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE = true;
+ (
+ window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
+ )._IS_TEST_MODE = true;
const authData = {
access_token: "fake-token",
@@ -13,12 +23,20 @@ test.describe("Users Bulk Upload Secondary Emails", () => {
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
};
- window.localStorage.setItem("oidc.user:http://localhost:5000/oidc:adminfront", JSON.stringify(authData));
+ window.localStorage.setItem(
+ "oidc.user:http://localhost:5000/oidc:adminfront",
+ JSON.stringify(authData),
+ );
});
await page.route("**/api/v1/user/me", async (route) => {
return route.fulfill({
- json: { id: "admin-user", name: "Admin", role: "super_admin", manageableTenants: [] },
+ json: {
+ id: "admin-user",
+ name: "Admin",
+ role: "super_admin",
+ manageableTenants: [],
+ },
headers: { "Access-Control-Allow-Origin": "*" },
});
});
@@ -31,7 +49,7 @@ test.describe("Users Bulk Upload Secondary Emails", () => {
});
await page.route("**/api/v1/admin/users*", async (route) => {
- if(route.request().url().includes("/bulk")) {
+ if (route.request().url().includes("/bulk")) {
return route.continue();
}
return route.fulfill({
@@ -45,14 +63,20 @@ test.describe("Users Bulk Upload Secondary Emails", () => {
});
});
- test("should parse secondary_emails and send to backend", async ({ page }) => {
- let bulkPayload: any = null;
+ test("should parse secondary_emails and send to backend", async ({
+ page,
+ }) => {
+ let bulkPayload: BulkUsersRequest | null = null;
await page.route("**/api/v1/admin/users/bulk", async (route) => {
if (route.request().method() === "POST") {
- bulkPayload = route.request().postDataJSON();
+ bulkPayload = route.request().postDataJSON() as BulkUsersRequest;
return route.fulfill({
- json: { results: [{ email: "test@example.com", success: true, userId: "u-1" }] },
+ json: {
+ results: [
+ { email: "test@example.com", success: true, userId: "u-1" },
+ ],
+ },
headers: { "Access-Control-Allow-Origin": "*" },
});
}
@@ -60,21 +84,26 @@ test.describe("Users Bulk Upload Secondary Emails", () => {
});
await page.goto("/users");
- await expect(page.getByTestId("page-title")).toContainText(/사용자|Users/i, { timeout: 20000 });
+ await expect(page.getByTestId("page-title")).toContainText(
+ /사용자|Users/i,
+ { timeout: 20000 },
+ );
await page.getByTestId("user-data-mgmt-btn").click();
- await page.getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i }).click();
+ await page
+ .getByRole("menuitem", { name: /일괄 임포트|일괄 등록|Bulk Import/i })
+ .click();
// Create a mock CSV with secondary_emails
const csvContent = `email,sub_email,name,phone,role,tenant_slug\ntest@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug`;
- const fileChooserPromise = page.waitForEvent('filechooser');
+ const fileChooserPromise = page.waitForEvent("filechooser");
await page.getByText(/파일 선택|Change file|Select file/i).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles({
- name: 'users_with_secondary.csv',
- mimeType: 'text/csv',
+ name: "users_with_secondary.csv",
+ mimeType: "text/csv",
buffer: Buffer.from(csvContent),
});
@@ -87,9 +116,9 @@ test.describe("Users Bulk Upload Secondary Emails", () => {
expect(bulkPayload).not.toBeNull();
expect(bulkPayload.users).toHaveLength(1);
-
+
// The most important check - does it parse to the metadata
expect(bulkPayload.users[0].metadata.sub_email).toContain("sub1@test.com");
expect(bulkPayload.users[0].metadata.sub_email).toContain("sub2@test.com");
});
-});
\ No newline at end of file
+});
diff --git a/backend/internal/handler/user_handler.go b/backend/internal/handler/user_handler.go
index 03faf338..a2ebbc9c 100644
--- a/backend/internal/handler/user_handler.go
+++ b/backend/internal/handler/user_handler.go
@@ -729,14 +729,14 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
req.CompanyCode = tenant.Slug
}
- // Collect and sync all custom login IDs based on tenant schemas
- loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
-
attributes["role"] = role
if tenantID != "" {
attributes["tenant_id"] = tenantID
}
+ // Collect and sync all custom login IDs based on tenant schemas
+ loginIDRecords := syncCustomLoginIDs(c.Context(), h.TenantService, attributes, req.Metadata, "")
+
if h.UserRepo != nil {
if err := h.ensureHanmacCreateEmailAllowed(c.Context(), email, req.CompanyCode, tenantID); err != nil {
if strings.Contains(err.Error(), "한맥가족") {
@@ -2050,7 +2050,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
// Validate all collected LoginIDs
userEmail := extractTraitString(traits, "email")
userPhone := extractTraitString(traits, "phone_number")
-
+
allEmails := []string{userEmail}
if secondaryRaw, exists := traits["sub_email"]; exists {
if secondaryEmails, ok := secondaryRaw.([]interface{}); ok {
@@ -2534,20 +2534,23 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
var allCustomIDs []string
idSet := make(map[string]bool)
+ normalizeCustomLoginIDsTrait(traits)
+
// Collect tenant IDs to check schemas for
tenantIDsToCheck := make(map[string]bool)
+ primaryTenantID := extractTraitString(traits, "tenant_id")
for k, v := range metadata {
- // Heuristic: if it's a map, it's likely namespaced metadata for a tenant
- if _, ok := v.(map[string]any); ok {
- tenantIDsToCheck[k] = true
- } else if _, ok := v.(map[string]interface{}); ok {
+ if isTenantMetadataNamespace(k, v, primaryTenantID) {
tenantIDsToCheck[k] = true
}
}
// Also check primary tenant if available
- if tid := extractTraitString(traits, "tenant_id"); tid != "" {
+ if tid := primaryTenantID; tid != "" && (len(metadata) > 0 || isMetadataMap(traits[tid])) {
tenantIDsToCheck[tid] = true
}
+ if len(tenantIDsToCheck) == 0 {
+ return nil
+ }
for tid := range tenantIDsToCheck {
tenant, err := tenantService.GetTenant(ctx, tid)
@@ -2629,6 +2632,66 @@ func syncCustomLoginIDs(ctx context.Context, tenantService service.TenantService
return loginIDRecords
}
+func isTenantMetadataNamespace(key string, value any, primaryTenantID string) bool {
+ return isTenantMetadataNamespaceKey(key, primaryTenantID) && isMetadataMap(value)
+}
+
+func isTenantMetadataNamespaceKey(key string, primaryTenantID string) bool {
+ if key == "" {
+ return false
+ }
+ if primaryTenantID != "" && key == primaryTenantID {
+ return true
+ }
+ if len(key) != 36 {
+ return false
+ }
+ for index, char := range key {
+ switch index {
+ case 8, 13, 18, 23:
+ if char != '-' {
+ return false
+ }
+ default:
+ if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f') || (char >= 'A' && char <= 'F')) {
+ return false
+ }
+ }
+ }
+ return true
+}
+
+func isMetadataMap(value any) bool {
+ if _, ok := value.(map[string]any); ok {
+ return true
+ }
+ if _, ok := value.(map[string]interface{}); ok {
+ return true
+ }
+ return false
+}
+
+func normalizeCustomLoginIDsTrait(traits map[string]interface{}) {
+ raw, exists := traits["custom_login_ids"]
+ if !exists {
+ return
+ }
+ switch values := raw.(type) {
+ case []string:
+ return
+ case []interface{}:
+ normalized := make([]string, 0, len(values))
+ for _, value := range values {
+ if text, ok := value.(string); ok && strings.TrimSpace(text) != "" {
+ normalized = append(normalized, text)
+ }
+ }
+ if len(normalized) > 0 {
+ traits["custom_login_ids"] = normalized
+ }
+ }
+}
+
func formatTime(value time.Time) string {
if value.IsZero() {
return ""
diff --git a/backend/internal/handler/user_handler_test.go b/backend/internal/handler/user_handler_test.go
index 1b38eb7d..12a86e51 100644
--- a/backend/internal/handler/user_handler_test.go
+++ b/backend/internal/handler/user_handler_test.go
@@ -707,13 +707,6 @@ func TestUserHandler_BulkCreateUsers_UsesEmailDomainTenantAsPrimaryWhenExplicitT
Status: domain.TenantStatusActive,
Config: domain.JSONMap{},
}, nil).Once()
- mockTenant.On("GetTenant", mock.Anything, "t-saman").Return(&domain.Tenant{
- ID: "t-saman",
- Slug: "saman",
- Name: "삼안",
- Status: domain.TenantStatusActive,
- Config: domain.JSONMap{},
- }, nil)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil)
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
_, hasCompanyCode := user.Attributes["companyCode"]
@@ -1183,6 +1176,41 @@ func TestUserHandler_UpdateUser_RejectsDeprecatedAdminRoles(t *testing.T) {
mockKratos.AssertExpectations(t)
}
+func TestSyncCustomLoginIDs_IgnoresFlatMetadataMaps(t *testing.T) {
+ mockTenant := new(MockTenantServiceForUser)
+ tenantID := "tenant-uuid"
+
+ mockTenant.On("GetTenant", mock.Anything, tenantID).Return(&domain.Tenant{
+ ID: tenantID,
+ Slug: "test-tenant",
+ Config: domain.JSONMap{
+ "userSchema": []interface{}{
+ map[string]interface{}{"key": "emp_no", "isLoginId": true},
+ },
+ },
+ }, nil).Once()
+
+ traits := map[string]interface{}{
+ "tenant_id": tenantID,
+ }
+ metadata := map[string]any{
+ tenantID: map[string]interface{}{
+ "emp_no": "E1001",
+ },
+ "worksmobileAliasEmails": map[string]interface{}{
+ "0": "alias@hanmaceng.co.kr",
+ },
+ }
+
+ records := syncCustomLoginIDs(context.Background(), mockTenant, traits, metadata, "user-1")
+
+ require.Len(t, records, 1)
+ require.Equal(t, tenantID, records[0].TenantID)
+ require.Equal(t, "E1001", records[0].LoginID)
+ mockTenant.AssertNotCalled(t, "GetTenant", mock.Anything, "worksmobileAliasEmails")
+ mockTenant.AssertExpectations(t)
+}
+
func TestUserHandler_UpdateUser_LoginIDSync(t *testing.T) {
t.Run("Success - Sync LoginID from namespaced metadata", func(t *testing.T) {
app := fiber.New()
@@ -1764,7 +1792,7 @@ func TestUserHandler_UpdateUserAcceptsTenantSlugAndRejectsCompanyCode(t *testing
ID: "new-tenant-id",
Slug: "new-tenant",
Config: domain.JSONMap{},
- }, nil).Twice()
+ }, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
_, hasCompanyCode := traits["companyCode"]
return !hasCompanyCode && traits["tenant_id"] == "new-tenant-id"
diff --git a/devfront/src/components/common/ForbiddenMessage.test.tsx b/devfront/src/components/common/ForbiddenMessage.test.tsx
new file mode 100644
index 00000000..4189d754
--- /dev/null
+++ b/devfront/src/components/common/ForbiddenMessage.test.tsx
@@ -0,0 +1,77 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ForbiddenMessage } from "./ForbiddenMessage";
+
+const authState = {
+ user: {
+ profile: {
+ role: "user",
+ },
+ },
+};
+
+vi.mock("react-oidc-context", () => ({
+ useAuth: () => authState,
+}));
+
+vi.mock("../../lib/i18n", () => ({
+ t: (key: string, fallback?: string, vars?: Record) => {
+ let text = fallback ?? key;
+ for (const [name, value] of Object.entries(vars ?? {})) {
+ text = text.replaceAll(`{{${name}}}`, String(value));
+ }
+ return text;
+ },
+}));
+
+const roots: Root[] = [];
+
+afterEach(() => {
+ for (const root of roots.splice(0)) {
+ act(() => {
+ root.unmount();
+ });
+ }
+});
+
+async function renderMessage(resourceToken: "audit" | "clients" | "consents") {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ roots.push(root);
+
+ await act(async () => {
+ root.render();
+ });
+
+ return container;
+}
+
+describe("ForbiddenMessage", () => {
+ it("renders resource-specific user guidance", async () => {
+ authState.user.profile.role = "user";
+
+ const audit = await renderMessage("audit");
+ expect(audit.textContent).toContain("Audit Logs");
+ expect(audit.textContent).toContain("audit read relationship");
+
+ const consents = await renderMessage("consents");
+ expect(consents.textContent).toContain("User Consent Grants");
+ expect(consents.textContent).toContain("consent read");
+
+ const clients = await renderMessage("clients");
+ expect(clients.textContent).toContain("Connected Applications");
+ expect(clients.textContent).toContain("target RP");
+ });
+
+ it("renders role-specific administrator guidance", async () => {
+ authState.user.profile.role = "rp_admin";
+ const rpAdmin = await renderMessage("clients");
+ expect(rpAdmin.textContent).toContain("RP administrators");
+
+ authState.user.profile.role = "tenant_admin";
+ const tenantAdmin = await renderMessage("clients");
+ expect(tenantAdmin.textContent).toContain("tenant administrator");
+ });
+});
diff --git a/devfront/src/components/layout/AppLayout.test.tsx b/devfront/src/components/layout/AppLayout.test.tsx
new file mode 100644
index 00000000..9bdac4e8
--- /dev/null
+++ b/devfront/src/components/layout/AppLayout.test.tsx
@@ -0,0 +1,166 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import AppLayout from "./AppLayout";
+
+const authState = {
+ isAuthenticated: true,
+ isLoading: false,
+ activeNavigator: undefined as string | undefined,
+ error: null as Error | null,
+ user: {
+ access_token: "access-token",
+ expires_at: Math.floor(Date.now() / 1000) + 120,
+ profile: {
+ sub: "user-1",
+ name: "Dev Admin",
+ email: "dev@example.com",
+ role: "super_admin",
+ },
+ },
+ signinSilent: vi.fn(),
+ removeUser: vi.fn(),
+};
+
+vi.mock("react-oidc-context", () => ({
+ useAuth: () => authState,
+}));
+
+vi.mock("../../features/auth/authApi", () => ({
+ fetchMe: vi.fn(async () => ({
+ id: "user-1",
+ name: "Fetched Dev Admin",
+ email: "fetched@example.com",
+ role: "super_admin",
+ })),
+}));
+
+vi.mock("../../lib/i18n", () => ({
+ t: (key: string, fallback?: string, vars?: Record) => {
+ let text = fallback ?? key;
+ for (const [name, value] of Object.entries(vars ?? {})) {
+ text = text.replaceAll(`{{${name}}}`, String(value));
+ }
+ return text;
+ },
+}));
+
+const roots: Root[] = [];
+
+beforeEach(() => {
+ authState.isAuthenticated = true;
+ authState.isLoading = false;
+ authState.activeNavigator = undefined;
+ authState.error = null;
+ authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
+ authState.signinSilent.mockReset();
+ authState.signinSilent.mockResolvedValue(undefined);
+ authState.removeUser.mockReset();
+ window.localStorage.clear();
+ vi.spyOn(window, "confirm").mockReturnValue(true);
+});
+
+afterEach(() => {
+ for (const root of roots.splice(0)) {
+ act(() => {
+ root.unmount();
+ });
+ }
+ vi.restoreAllMocks();
+});
+
+async function renderLayout(initialEntry = "/clients") {
+ 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(
+
+
+
+ }>
+ Client outlet
} />
+ Profile outlet} />
+
+
+
+ ,
+ );
+ });
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ return container;
+}
+
+describe("devfront AppLayout", () => {
+ it("renders shell navigation, profile summary, and outlet content", async () => {
+ const container = await renderLayout();
+
+ expect(container.textContent).toContain("Developer Console");
+ expect(container.textContent).toContain("Clients");
+ expect(container.textContent).toContain("Client outlet");
+ expect(container.textContent).toContain("Fetched Dev Admin");
+ expect(document.documentElement.classList.contains("light")).toBe(true);
+ });
+
+ it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
+ const container = await renderLayout();
+
+ const themeButton = container.querySelector(
+ 'button[aria-label="Toggle theme"]',
+ ) as HTMLButtonElement;
+ await act(async () => {
+ themeButton.click();
+ });
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
+
+ const profileButton = container.querySelector(
+ 'button[aria-label="Open account menu"]',
+ ) as HTMLButtonElement;
+ await act(async () => {
+ profileButton.click();
+ });
+ expect(container.textContent).toContain("My Profile");
+
+ const profileMenuItem = Array.from(
+ container.querySelectorAll('button[role="menuitem"]'),
+ ).find((button) => button.textContent?.includes("My Profile"));
+ await act(async () => {
+ (profileMenuItem as HTMLButtonElement).click();
+ });
+ expect(container.textContent).toContain("Profile outlet");
+
+ const logoutButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("Logout"),
+ );
+ await act(async () => {
+ (logoutButton as HTMLButtonElement).click();
+ });
+ expect(window.confirm).toHaveBeenCalled();
+ expect(authState.removeUser).toHaveBeenCalled();
+ });
+
+ it("attempts silent renewal after user action when the session is expiring", async () => {
+ authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
+ await renderLayout();
+
+ await act(async () => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
+ });
+
+ expect(authState.signinSilent).toHaveBeenCalled();
+ });
+});
diff --git a/devfront/src/features/auth/authPages.test.tsx b/devfront/src/features/auth/authPages.test.tsx
new file mode 100644
index 00000000..2c2cf98f
--- /dev/null
+++ b/devfront/src/features/auth/authPages.test.tsx
@@ -0,0 +1,161 @@
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import AuthCallbackPage from "./AuthCallbackPage";
+import AuthGuard from "./AuthGuard";
+import AuthPage from "./AuthPage";
+import LoginPage from "./LoginPage";
+
+const authState = {
+ isAuthenticated: false,
+ isLoading: false,
+ activeNavigator: undefined as string | undefined,
+ error: null as Error | null,
+ user: undefined as
+ | {
+ state?: unknown;
+ }
+ | undefined,
+ signinRedirect: vi.fn(),
+};
+
+vi.mock("react-oidc-context", () => ({
+ useAuth: () => authState,
+}));
+
+vi.mock("../../lib/auth", () => ({
+ userManager: {
+ signinPopupCallback: vi.fn(async () => undefined),
+ },
+}));
+
+const roots: Root[] = [];
+
+beforeEach(() => {
+ authState.isAuthenticated = false;
+ authState.isLoading = false;
+ authState.activeNavigator = undefined;
+ authState.error = null;
+ authState.user = undefined;
+ authState.signinRedirect.mockReset();
+ authState.signinRedirect.mockResolvedValue(undefined);
+});
+
+afterEach(() => {
+ for (const root of roots.splice(0)) {
+ act(() => {
+ root.unmount();
+ });
+ }
+});
+
+async function renderWithRouter(
+ element: React.ReactElement,
+ {
+ entry = "/",
+ path = "*",
+ }: {
+ entry?: string;
+ path?: string;
+ } = {},
+) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ roots.push(root);
+
+ await act(async () => {
+ root.render(
+
+
+
+ Protected outlet} />
+
+ Login route} />
+ Clients route} />
+ Profile route} />
+
+ ,
+ );
+ });
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ return container;
+}
+
+describe("devfront auth pages", () => {
+ it("renders the static auth planning page", async () => {
+ const container = await renderWithRouter();
+
+ expect(container.textContent).toContain("Admin auth guardrails");
+ expect(container.textContent).toContain("Device approval");
+ });
+
+ it("renders login page and starts SSO redirect from the action button", async () => {
+ const container = await renderWithRouter(, {
+ entry: "/login?returnTo=/profile",
+ path: "/login",
+ });
+
+ expect(container.textContent).toContain("개발자 포털 로그인");
+
+ const loginButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("SSO 계정으로 로그인"),
+ );
+ await act(async () => {
+ (loginButton as HTMLButtonElement).click();
+ });
+
+ expect(authState.signinRedirect).toHaveBeenCalledWith({
+ state: { returnTo: "/clients" },
+ });
+ });
+
+ it("shows AuthGuard loading, error, redirect, and protected outlet states", async () => {
+ authState.isLoading = true;
+ const loading = await renderWithRouter();
+ expect(loading.textContent).toContain("Loading...");
+
+ authState.isLoading = false;
+ authState.error = new Error("OIDC failed");
+ const error = await renderWithRouter();
+ expect(error.textContent).toContain("Authentication Error");
+
+ const retryButton = error.querySelector("button") as HTMLButtonElement;
+ await act(async () => {
+ retryButton.click();
+ });
+ expect(authState.signinRedirect).toHaveBeenCalled();
+
+ authState.error = null;
+ const redirected = await renderWithRouter();
+ expect(redirected.textContent).toContain("Login route");
+
+ authState.isAuthenticated = true;
+ const protectedPage = await renderWithRouter();
+ expect(protectedPage.textContent).toContain("Protected outlet");
+ });
+
+ it("navigates from callback by auth result and stored return target", async () => {
+ authState.isAuthenticated = true;
+ authState.user = { state: { returnTo: "/profile" } };
+
+ const authenticated = await renderWithRouter(, {
+ entry: "/auth/callback",
+ path: "/auth/callback",
+ });
+ expect(authenticated.textContent).toContain("Profile route");
+
+ authState.isAuthenticated = false;
+ authState.error = new Error("callback failed");
+ const failed = await renderWithRouter(, {
+ entry: "/auth/callback",
+ path: "/auth/callback",
+ });
+ expect(failed.textContent).toContain("Login route");
+ });
+});
diff --git a/devfront/src/features/coverage/commonSort.test.ts b/devfront/src/features/coverage/commonSort.test.ts
new file mode 100644
index 00000000..74276796
--- /dev/null
+++ b/devfront/src/features/coverage/commonSort.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it } from "vitest";
+import {
+ compareNullableValues,
+ sortItems,
+ toggleSort,
+} from "../../../../common/core/utils/sort";
+
+describe("common sort utilities in devfront coverage", () => {
+ it("keeps nullish values last and compares normalized primitive values", () => {
+ expect(compareNullableValues(null, "alpha", "asc")).toBe(1);
+ expect(compareNullableValues("alpha", undefined, "asc")).toBe(-1);
+ expect(compareNullableValues("Beta", "alpha", "asc")).toBe(1);
+ expect(compareNullableValues("Beta", "alpha", "desc")).toBe(-1);
+ expect(compareNullableValues(true, false, "asc")).toBe(1);
+ expect(
+ compareNullableValues(
+ new Date("2026-05-02T00:00:00Z"),
+ new Date("2026-05-01T00:00:00Z"),
+ "asc",
+ ),
+ ).toBe(1);
+ });
+
+ it("toggles sort direction and sorts with default and custom resolvers", () => {
+ const firstSort = toggleSort(null, "name");
+ expect(firstSort).toEqual({ key: "name", direction: "asc" });
+ expect(toggleSort(firstSort, "name")).toEqual({
+ key: "name",
+ direction: "desc",
+ });
+ expect(toggleSort(firstSort, "createdAt")).toEqual({
+ key: "createdAt",
+ direction: "asc",
+ });
+
+ const rows = [
+ { name: "charlie", rank: 3, nested: { score: 20 } },
+ { name: "Alpha", rank: 1, nested: { score: 30 } },
+ { name: "bravo", rank: 2, nested: { score: 10 } },
+ ];
+
+ expect(
+ sortItems(rows, { key: "name", direction: "asc" }).map(
+ (row) => row.name,
+ ),
+ ).toEqual(["Alpha", "bravo", "charlie"]);
+ expect(
+ sortItems(rows, { key: "score", direction: "desc" }, {
+ score: (row) => row.nested.score,
+ }).map((row) => row.name),
+ ).toEqual(["Alpha", "charlie", "bravo"]);
+ expect(sortItems(rows, null)).not.toBe(rows);
+ });
+});
diff --git a/devfront/src/features/coverage/pageSmoke.test.tsx b/devfront/src/features/coverage/pageSmoke.test.tsx
new file mode 100644
index 00000000..72ccaeab
--- /dev/null
+++ b/devfront/src/features/coverage/pageSmoke.test.tsx
@@ -0,0 +1,383 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import AuditLogsPage from "../audit/AuditLogsPage";
+import ClientConsentsPage from "../clients/ClientConsentsPage";
+import ClientDetailsPage from "../clients/ClientDetailsPage";
+import ClientGeneralPage from "../clients/ClientGeneralPage";
+import ClientRelationsPage from "../clients/ClientRelationsPage";
+import ClientsPage from "../clients/ClientsPage";
+import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
+import DeveloperRequestPage from "../developer-request/DeveloperRequestPage";
+import GlobalOverviewPage from "../overview/GlobalOverviewPage";
+import ProfilePage from "../profile/ProfilePage";
+
+const authProfile = {
+ sub: "user-1",
+ role: "super_admin",
+ tenant_id: "tenant-1",
+ companyCode: "HANMAC",
+};
+
+vi.mock("react-oidc-context", () => ({
+ useAuth: () => ({
+ isAuthenticated: true,
+ isLoading: false,
+ user: {
+ access_token: "access-token",
+ profile: authProfile,
+ },
+ signinRedirect: vi.fn(),
+ removeUser: vi.fn(),
+ }),
+}));
+
+vi.mock("../../lib/i18n", () => ({
+ t: (key: string, fallback?: string, vars?: Record) => {
+ let text = fallback ?? key;
+ for (const [name, value] of Object.entries(vars ?? {})) {
+ text = text.replaceAll(`{{${name}}}`, String(value));
+ }
+ return text;
+ },
+}));
+
+const clientSummary = {
+ id: "client-a",
+ name: "Console App",
+ type: "private" as const,
+ status: "active" as const,
+ createdAt: "2026-05-01T00:00:00Z",
+ redirectUris: ["https://app.example/callback"],
+ scopes: ["openid", "profile"],
+ tokenEndpointAuthMethod: "client_secret_basic",
+ metadata: {
+ headless_login_enabled: true,
+ headless_login_jwks_uri: "https://app.example/jwks.json",
+ id_token_claims: [
+ {
+ namespace: "rp_claims",
+ key: "employee_id",
+ value: "E001",
+ valueType: "text",
+ },
+ ],
+ },
+};
+
+const clientDetail = {
+ client: {
+ ...clientSummary,
+ clientSecret: "secret-value",
+ jwksUri: "https://app.example/jwks.json",
+ backchannelLogoutUri: "https://app.example/logout",
+ backchannelLogoutSessionRequired: true,
+ grantTypes: ["authorization_code"],
+ responseTypes: ["code"],
+ },
+ endpoints: {
+ discovery: "https://sso.example/.well-known/openid-configuration",
+ issuer: "https://sso.example",
+ authorization: "https://sso.example/oauth2/auth",
+ token: "https://sso.example/oauth2/token",
+ userinfo: "https://sso.example/userinfo",
+ },
+ headlessJwksCache: {
+ clientId: "client-a",
+ jwksUri: "https://app.example/jwks.json",
+ cachedAt: "2026-05-01T00:00:00Z",
+ expiresAt: "2026-05-02T00:00:00Z",
+ lastCheckedAt: "2026-05-01T01:00:00Z",
+ lastSuccessfulVerificationAt: "2026-05-01T01:00:00Z",
+ lastRefreshStatus: "success" as const,
+ cachedKids: ["kid-1"],
+ parsedKeys: [{ kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256" }],
+ },
+};
+
+vi.mock("../../lib/devApi", () => ({
+ fetchClients: vi.fn(async () => ({
+ items: [clientSummary],
+ limit: 100,
+ offset: 0,
+ })),
+ fetchDevStats: vi.fn(async () => ({
+ total_clients: 1,
+ active_sessions: 12,
+ auth_failures_24h: 2,
+ })),
+ fetchDevRPUsageDaily: vi.fn(async () => ({
+ days: 14,
+ period: "day",
+ items: [
+ {
+ date: "2026-05-01",
+ tenantId: "tenant-1",
+ tenantType: "COMPANY",
+ tenantName: "Hanmac",
+ clientId: "client-a",
+ clientName: "Console App",
+ loginRequests: 10,
+ otherRequests: 4,
+ uniqueSubjects: 3,
+ },
+ {
+ date: "2026-05-08",
+ tenantId: "tenant-1",
+ tenantType: "COMPANY",
+ tenantName: "Hanmac",
+ clientId: "client-a",
+ clientName: "Console App",
+ loginRequests: 8,
+ otherRequests: 5,
+ uniqueSubjects: 4,
+ },
+ ],
+ })),
+ fetchClient: vi.fn(async () => clientDetail),
+ fetchClientRelations: vi.fn(async () => ({
+ items: [
+ {
+ relation: "admins",
+ subject: "User:user-1",
+ subjectType: "User",
+ subjectId: "user-1",
+ userName: "Dev Admin",
+ userEmail: "dev@example.com",
+ },
+ ],
+ })),
+ fetchDevUsers: vi.fn(async () => ({
+ items: [
+ {
+ id: "user-2",
+ name: "Editor User",
+ email: "editor@example.com",
+ loginId: "editor",
+ },
+ ],
+ })),
+ addClientRelation: vi.fn(async () => ({
+ relation: "admins",
+ subject: "User:user-2",
+ subjectType: "User",
+ subjectId: "user-2",
+ })),
+ removeClientRelation: vi.fn(async () => undefined),
+ updateClientStatus: vi.fn(async () => clientDetail),
+ createClient: vi.fn(async () => clientDetail),
+ updateClient: vi.fn(async () => clientDetail),
+ rotateClientSecret: vi.fn(async () => clientDetail),
+ refreshHeadlessJwksCache: vi.fn(async () => clientDetail),
+ revokeHeadlessJwksCache: vi.fn(async () => undefined),
+ deleteClient: vi.fn(async () => undefined),
+ fetchConsents: vi.fn(async () => ({
+ items: [
+ {
+ subject: "user-1",
+ userName: "Consent User",
+ clientId: "client-a",
+ clientName: "Console App",
+ grantedScopes: ["openid", "profile"],
+ authenticatedAt: "2026-05-01T02:00:00Z",
+ createdAt: "2026-05-01T00:00:00Z",
+ status: "active",
+ tenantId: "tenant-1",
+ tenantName: "Hanmac",
+ },
+ ],
+ })),
+ revokeConsent: vi.fn(async () => undefined),
+ listIdpConfigsForClient: vi.fn(async () => [
+ {
+ 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",
+ },
+ ]),
+ createIdpConfigForClient: vi.fn(async (payload) => ({
+ id: "idp-1",
+ ...payload,
+ status: "active",
+ createdAt: "2026-05-01T00:00:00Z",
+ updatedAt: "2026-05-01T00:00:00Z",
+ })),
+ updateIdpConfig: vi.fn(async (_clientId, idpId, payload) => ({
+ id: idpId,
+ client_id: "client-a",
+ provider_type: "oidc",
+ display_name: "Provider",
+ status: "active",
+ createdAt: "2026-05-01T00:00:00Z",
+ updatedAt: "2026-05-01T00:00:00Z",
+ ...payload,
+ })),
+ deleteIdpConfig: vi.fn(async () => undefined),
+ fetchDevAuditLogs: vi.fn(async () => ({
+ items: [
+ {
+ event_id: "event-1",
+ timestamp: "2026-05-01T00:00:00Z",
+ user_id: "user-1",
+ event_type: "client.update",
+ status: "success",
+ ip_address: "127.0.0.1",
+ user_agent: "Vitest",
+ details: "{\"client_id\":\"client-a\"}",
+ },
+ ],
+ limit: 50,
+ })),
+ fetchMyTenants: vi.fn(async () => [
+ {
+ id: "tenant-1",
+ name: "Hanmac",
+ slug: "hanmac",
+ type: "COMPANY",
+ status: "active",
+ description: "",
+ memberCount: 10,
+ createdAt: "2026-05-01T00:00:00Z",
+ updatedAt: "2026-05-01T00:00:00Z",
+ },
+ ]),
+ fetchDeveloperRequestStatus: vi.fn(async () => ({ status: "approved" })),
+ requestDeveloperAccess: vi.fn(async () => ({ status: "pending" })),
+ fetchDeveloperRequests: vi.fn(async () => [
+ {
+ id: 1,
+ userId: "user-3",
+ tenantId: "tenant-1",
+ name: "Requester",
+ organization: "Hanmac",
+ email: "requester@example.com",
+ reason: "Need RP access",
+ status: "pending",
+ createdAt: "2026-05-01T00:00:00Z",
+ updatedAt: "2026-05-01T00:00:00Z",
+ },
+ ]),
+ approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })),
+ rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })),
+ cancelDeveloperRequestApproval: vi.fn(async () => ({ status: "cancelled" })),
+}));
+
+vi.mock("../auth/authApi", () => ({
+ fetchMe: vi.fn(async () => ({
+ id: "user-1",
+ email: "dev@example.com",
+ name: "Dev Admin",
+ role: "super_admin",
+ tenantId: "tenant-1",
+ })),
+}));
+
+const roots: Root[] = [];
+
+afterEach(() => {
+ for (const root of roots.splice(0)) {
+ act(() => {
+ root.unmount();
+ });
+ }
+});
+
+async function renderPage(
+ element: React.ReactElement,
+ {
+ path = "/",
+ entry = path,
+ }: {
+ path?: string;
+ entry?: string;
+ } = {},
+) {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ const root = createRoot(container);
+ roots.push(root);
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ await act(async () => {
+ root.render(
+
+
+
+
+
+
+ ,
+ );
+ });
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ return container;
+}
+
+describe("devfront coverage smoke pages", () => {
+ it("renders overview, client list, audit, developer request, and profile pages", async () => {
+ const overview = await renderPage();
+ expect(overview.textContent).toContain("Console App");
+
+ const clients = await renderPage();
+ expect(clients.textContent).toContain("Console App");
+
+ const audit = await renderPage();
+ expect(audit.textContent).toContain("client.update");
+
+ const requests = await renderPage();
+ expect(requests.textContent).toContain("Requester");
+
+ const profile = await renderPage();
+ expect(profile.textContent).toContain("Dev Admin");
+ });
+
+ it("renders client detail, settings, consent, federation, and relationship pages", async () => {
+ const details = await renderPage(, {
+ path: "/clients/:id",
+ entry: "/clients/client-a",
+ });
+ expect(details.textContent).toContain("Console App");
+
+ const settings = await renderPage(, {
+ path: "/clients/:id/settings",
+ entry: "/clients/client-a/settings",
+ });
+ expect(settings.textContent).toContain("Console App");
+
+ const consents = await renderPage(, {
+ path: "/clients/:id/consents",
+ entry: "/clients/client-a/consents",
+ });
+ expect(consents.textContent).toContain("Consent User");
+
+ const federation = await renderPage(, {
+ path: "/clients/:id/federation",
+ entry: "/clients/client-a/federation",
+ });
+ expect(federation.textContent).toContain("Workspace OIDC");
+
+ const relations = await renderPage(, {
+ path: "/clients/:id/relationships",
+ entry: "/clients/client-a/relationships",
+ });
+ expect(relations.textContent).toContain("Dev Admin");
+ });
+});
diff --git a/devfront/src/lib/devApi.test.ts b/devfront/src/lib/devApi.test.ts
new file mode 100644
index 00000000..2bd50b49
--- /dev/null
+++ b/devfront/src/lib/devApi.test.ts
@@ -0,0 +1,250 @@
+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(),
+};
+
+vi.mock("./apiClient", () => ({
+ default: apiClient,
+}));
+
+describe("devApi", () => {
+ beforeEach(() => {
+ apiClient.get.mockReset();
+ apiClient.post.mockReset();
+ apiClient.put.mockReset();
+ apiClient.patch.mockReset();
+ apiClient.delete.mockReset();
+ });
+
+ it("fetches list and detail resources with expected query parameters", async () => {
+ const {
+ fetchClients,
+ fetchDevStats,
+ fetchDevRPUsageDaily,
+ fetchTenants,
+ fetchClient,
+ fetchClientRelations,
+ fetchDevUsers,
+ fetchConsents,
+ fetchDevAuditLogs,
+ fetchMyTenants,
+ fetchDeveloperRequestStatus,
+ fetchDeveloperRequests,
+ listIdpConfigsForClient,
+ } = await import("./devApi");
+ apiClient.get.mockResolvedValue({ data: { ok: true } });
+
+ await fetchClients();
+ await fetchDevStats();
+ await fetchDevRPUsageDaily({ days: 30, period: "week" });
+ await fetchTenants(25, 50, "tenant-parent");
+ await fetchClient("client-a");
+ await fetchClientRelations("client-a");
+ await fetchDevUsers("admin", 5, "client-a");
+ await fetchConsents("user-a", "client-a", "active");
+ await fetchDevAuditLogs(10, "cursor-a", {
+ action: "client.update",
+ client_id: "client-a",
+ status: "success",
+ tenant_id: "tenant-a",
+ });
+ await fetchMyTenants();
+ await fetchDeveloperRequestStatus("tenant-a");
+ await fetchDeveloperRequests("pending");
+ await listIdpConfigsForClient("client-a");
+
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/clients");
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/stats");
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/rp-usage/daily", {
+ params: { days: 30, period: "week" },
+ });
+ expect(apiClient.get).toHaveBeenCalledWith("/tenants", {
+ params: { limit: 25, offset: 50, parentId: "tenant-parent" },
+ });
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a");
+ expect(apiClient.get).toHaveBeenCalledWith(
+ "/dev/clients/client-a/relations",
+ );
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/users", {
+ params: { search: "admin", limit: 5, clientId: "client-a" },
+ });
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
+ params: { subject: "user-a", client_id: "client-a", status: "active" },
+ });
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/audit-logs", {
+ params: {
+ limit: 10,
+ cursor: "cursor-a",
+ action: "client.update",
+ client_id: "client-a",
+ status: "success",
+ tenant_id: "tenant-a",
+ },
+ });
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/my-tenants");
+ expect(apiClient.get).toHaveBeenCalledWith(
+ "/dev/developer-request/status",
+ {
+ params: { tenantId: "tenant-a" },
+ },
+ );
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/developer-request/list", {
+ params: { status: "pending" },
+ });
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a/idps");
+ });
+
+ it("omits optional consent filters when they are empty or all", async () => {
+ const { fetchConsents, revokeConsent } = await import("./devApi");
+ apiClient.get.mockResolvedValue({ data: { items: [] } });
+ apiClient.delete.mockResolvedValue({ data: {} });
+
+ await fetchConsents("user-a", undefined, "all");
+ await revokeConsent("user-a");
+
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
+ params: { subject: "user-a" },
+ });
+ expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
+ params: { subject: "user-a" },
+ });
+ });
+
+ it("sends mutation requests to the documented dev endpoints", async () => {
+ const {
+ addClientRelation,
+ removeClientRelation,
+ updateClientStatus,
+ createClient,
+ updateClient,
+ rotateClientSecret,
+ refreshHeadlessJwksCache,
+ revokeHeadlessJwksCache,
+ deleteClient,
+ revokeConsent,
+ createIdpConfigForClient,
+ updateIdpConfig,
+ deleteIdpConfig,
+ requestDeveloperAccess,
+ approveDeveloperRequest,
+ rejectDeveloperRequest,
+ cancelDeveloperRequestApproval,
+ } = await import("./devApi");
+ apiClient.post.mockResolvedValue({ data: { ok: true } });
+ apiClient.put.mockResolvedValue({ data: { ok: true } });
+ apiClient.patch.mockResolvedValue({ data: { ok: true } });
+ apiClient.delete.mockResolvedValue({ data: {} });
+
+ await addClientRelation("client-a", {
+ relation: "admins",
+ userId: "user-a",
+ });
+ await removeClientRelation("client-a", "admins", "User:user-a");
+ await updateClientStatus("client-a", "inactive");
+ await createClient({ id: "client-a", name: "Console App" });
+ await updateClient("client-a", { name: "Console App Updated" });
+ await rotateClientSecret("client-a");
+ await refreshHeadlessJwksCache("client-a");
+ await revokeHeadlessJwksCache("client-a");
+ await deleteClient("client-a");
+ await revokeConsent("user-a", "client-a");
+ await createIdpConfigForClient({
+ client_id: "client-a",
+ provider_type: "oidc",
+ display_name: "OIDC Provider",
+ status: "active",
+ });
+ await updateIdpConfig("client-a", "idp-a", { status: "inactive" });
+ await deleteIdpConfig("client-a", "idp-a");
+ await requestDeveloperAccess({
+ name: "Dev User",
+ organization: "Hanmac",
+ reason: "Need RP access",
+ tenantId: "tenant-a",
+ });
+ await approveDeveloperRequest(1, "approved");
+ await rejectDeveloperRequest(2, "rejected");
+ await cancelDeveloperRequestApproval(3, "cancelled");
+
+ expect(apiClient.post).toHaveBeenCalledWith(
+ "/dev/clients/client-a/relations",
+ { relation: "admins", userId: "user-a" },
+ );
+ expect(apiClient.delete).toHaveBeenCalledWith(
+ "/dev/clients/client-a/relations",
+ {
+ params: { relation: "admins", subject: "User:user-a" },
+ },
+ );
+ expect(apiClient.patch).toHaveBeenCalledWith(
+ "/dev/clients/client-a/status",
+ {
+ status: "inactive",
+ },
+ );
+ expect(apiClient.post).toHaveBeenCalledWith("/dev/clients", {
+ id: "client-a",
+ name: "Console App",
+ });
+ expect(apiClient.put).toHaveBeenCalledWith("/dev/clients/client-a", {
+ name: "Console App Updated",
+ });
+ expect(apiClient.post).toHaveBeenCalledWith(
+ "/dev/clients/client-a/secret/rotate",
+ );
+ expect(apiClient.post).toHaveBeenCalledWith(
+ "/dev/clients/client-a/headless-jwks/refresh",
+ );
+ expect(apiClient.delete).toHaveBeenCalledWith(
+ "/dev/clients/client-a/headless-jwks/cache",
+ );
+ expect(apiClient.delete).toHaveBeenCalledWith("/dev/clients/client-a");
+ expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
+ params: { subject: "user-a", client_id: "client-a" },
+ });
+ expect(apiClient.post).toHaveBeenCalledWith("/dev/clients/client-a/idps", {
+ client_id: "client-a",
+ provider_type: "oidc",
+ display_name: "OIDC Provider",
+ status: "active",
+ });
+ expect(apiClient.put).toHaveBeenCalledWith(
+ "/dev/clients/client-a/idps/idp-a",
+ {
+ status: "inactive",
+ },
+ );
+ expect(apiClient.delete).toHaveBeenCalledWith(
+ "/dev/clients/client-a/idps/idp-a",
+ );
+ expect(apiClient.post).toHaveBeenCalledWith("/dev/developer-request", {
+ name: "Dev User",
+ organization: "Hanmac",
+ reason: "Need RP access",
+ tenantId: "tenant-a",
+ });
+ expect(apiClient.post).toHaveBeenCalledWith(
+ "/dev/developer-request/1/approve",
+ {
+ adminNotes: "approved",
+ },
+ );
+ expect(apiClient.post).toHaveBeenCalledWith(
+ "/dev/developer-request/2/reject",
+ {
+ adminNotes: "rejected",
+ },
+ );
+ expect(apiClient.post).toHaveBeenCalledWith(
+ "/dev/developer-request/3/cancel-approval",
+ {
+ adminNotes: "cancelled",
+ },
+ );
+ });
+});
diff --git a/orgfront/src/components/layout/AppLayout.test.tsx b/orgfront/src/components/layout/AppLayout.test.tsx
new file mode 100644
index 00000000..1871a72a
--- /dev/null
+++ b/orgfront/src/components/layout/AppLayout.test.tsx
@@ -0,0 +1,166 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import AppLayout from "./AppLayout";
+
+const authState = {
+ isAuthenticated: true,
+ isLoading: false,
+ activeNavigator: undefined as string | undefined,
+ error: null as Error | null,
+ user: {
+ access_token: "access-token",
+ expires_at: Math.floor(Date.now() / 1000) + 120,
+ profile: {
+ sub: "user-1",
+ name: "Org Admin",
+ email: "org@example.com",
+ role: "super_admin",
+ },
+ },
+ signinSilent: vi.fn(),
+ removeUser: vi.fn(),
+};
+
+vi.mock("react-oidc-context", () => ({
+ useAuth: () => authState,
+}));
+
+vi.mock("../../features/auth/authApi", () => ({
+ fetchMe: vi.fn(async () => ({
+ id: "user-1",
+ name: "Fetched Org Admin",
+ email: "fetched@example.com",
+ role: "super_admin",
+ })),
+}));
+
+vi.mock("../../lib/i18n", () => ({
+ t: (key: string, fallback?: string, vars?: Record) => {
+ let text = fallback ?? key;
+ for (const [name, value] of Object.entries(vars ?? {})) {
+ text = text.replaceAll(`{{${name}}}`, String(value));
+ }
+ return text;
+ },
+}));
+
+const roots: Root[] = [];
+
+beforeEach(() => {
+ authState.isAuthenticated = true;
+ authState.isLoading = false;
+ authState.activeNavigator = undefined;
+ authState.error = null;
+ authState.user.expires_at = Math.floor(Date.now() / 1000) + 120;
+ authState.signinSilent.mockReset();
+ authState.signinSilent.mockResolvedValue(undefined);
+ authState.removeUser.mockReset();
+ window.localStorage.clear();
+ vi.spyOn(window, "confirm").mockReturnValue(true);
+});
+
+afterEach(() => {
+ for (const root of roots.splice(0)) {
+ act(() => {
+ root.unmount();
+ });
+ }
+ vi.restoreAllMocks();
+});
+
+async function renderLayout(initialEntry = "/clients") {
+ 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(
+
+
+
+ }>
+ Client outlet} />
+ Profile outlet} />
+
+
+
+ ,
+ );
+ });
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ return container;
+}
+
+describe("orgfront AppLayout", () => {
+ it("renders shell navigation, profile summary, and outlet content", async () => {
+ const container = await renderLayout();
+
+ expect(container.textContent).toContain("Developer Console");
+ expect(container.textContent).toContain("Clients");
+ expect(container.textContent).toContain("Client outlet");
+ expect(container.textContent).toContain("Fetched Org Admin");
+ expect(document.documentElement.classList.contains("light")).toBe(true);
+ });
+
+ it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
+ const container = await renderLayout();
+
+ const themeButton = container.querySelector(
+ 'button[aria-label="테마 전환"]',
+ ) as HTMLButtonElement;
+ await act(async () => {
+ themeButton.click();
+ });
+ expect(document.documentElement.classList.contains("dark")).toBe(true);
+
+ const profileButton = container.querySelector(
+ 'button[aria-label="계정 메뉴 열기"]',
+ ) as HTMLButtonElement;
+ await act(async () => {
+ profileButton.click();
+ });
+ expect(container.textContent).toContain("Account");
+
+ const profileMenuItem = Array.from(
+ container.querySelectorAll('button[role="menuitem"]'),
+ ).find((button) => button.textContent?.includes("내 정보"));
+ await act(async () => {
+ (profileMenuItem as HTMLButtonElement).click();
+ });
+ expect(container.textContent).toContain("Profile outlet");
+
+ const logoutButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("Logout"),
+ );
+ await act(async () => {
+ (logoutButton as HTMLButtonElement).click();
+ });
+ expect(window.confirm).toHaveBeenCalled();
+ expect(authState.removeUser).toHaveBeenCalled();
+ });
+
+ it("attempts silent renewal after user action when the session is expiring", async () => {
+ authState.user.expires_at = Math.floor(Date.now() / 1000) + 60;
+ await renderLayout();
+
+ await act(async () => {
+ window.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab" }));
+ });
+
+ expect(authState.signinSilent).toHaveBeenCalled();
+ });
+});
diff --git a/orgfront/src/components/ui/basic.test.tsx b/orgfront/src/components/ui/basic.test.tsx
new file mode 100644
index 00000000..b13af471
--- /dev/null
+++ b/orgfront/src/components/ui/basic.test.tsx
@@ -0,0 +1,95 @@
+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";
+import { Badge } from "./badge";
+import { Input } from "./input";
+import { Label } from "./label";
+import { Separator } from "./separator";
+import { Switch } from "./switch";
+import {
+ Table,
+ TableBody,
+ TableCaption,
+ TableCell,
+ TableFooter,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "./table";
+import { Textarea } from "./textarea";
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+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("orgfront UI wrappers", () => {
+ it("renders form, badge, avatar, switch, separator, and table wrappers", async () => {
+ const root = await render(
+
+
+ Active
+
+
+
+ OU
+
+
+
+
+
+
+
+ Members
+
+
+ Name
+
+
+
+
+ Org User
+
+
+
+
+ Total
+
+
+
+
,
+ );
+
+ expect(container?.textContent).toContain("Active");
+ expect(container?.textContent).toContain("OU");
+ expect(container?.querySelector(".custom-input")).not.toBeNull();
+ expect(container?.querySelector(".custom-switch")).not.toBeNull();
+ expect(container?.querySelector(".custom-separator")).not.toBeNull();
+ expect(container?.textContent).toContain("Members");
+ expect(container?.textContent).toContain("Total");
+
+ await act(async () => {
+ root.unmount();
+ });
+ });
+});
diff --git a/orgfront/src/features/coverage/pageSmoke.test.tsx b/orgfront/src/features/coverage/pageSmoke.test.tsx
new file mode 100644
index 00000000..175c3ae3
--- /dev/null
+++ b/orgfront/src/features/coverage/pageSmoke.test.tsx
@@ -0,0 +1,307 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ForbiddenMessage } from "../../components/common/ForbiddenMessage";
+import AuditLogsPage from "../audit/AuditLogsPage";
+import AuthPage from "../auth/AuthPage";
+import ClientConsentsPage from "../clients/ClientConsentsPage";
+import ClientDetailsPage from "../clients/ClientDetailsPage";
+import ClientGeneralPage from "../clients/ClientGeneralPage";
+import ClientsPage from "../clients/ClientsPage";
+import { ClientFederationPage } from "../clients/routes/ClientFederationPage";
+import DashboardPage from "../dashboard/DashboardPage";
+import ProfilePage from "../profile/ProfilePage";
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+vi.mock("react-oidc-context", () => ({
+ useAuth: () => ({
+ isAuthenticated: true,
+ isLoading: false,
+ user: {
+ access_token: "access-token",
+ profile: {
+ sub: "user-1",
+ role: "super_admin",
+ tenant_id: "tenant-1",
+ },
+ },
+ signinRedirect: vi.fn(),
+ removeUser: vi.fn(),
+ }),
+}));
+
+vi.mock("../../lib/i18n", () => ({
+ t: (key: string, fallback?: string, vars?: Record) => {
+ let text = fallback ?? key;
+ for (const [name, value] of Object.entries(vars ?? {})) {
+ text = text.replaceAll(`{{${name}}}`, String(value));
+ }
+ return text;
+ },
+}));
+
+const clientSummary = {
+ id: "client-a",
+ name: "Console App",
+ type: "private" as const,
+ status: "active" as const,
+ createdAt: "2026-05-01T00:00:00Z",
+ redirectUris: ["https://app.example/callback"],
+ scopes: ["openid", "profile"],
+ tokenEndpointAuthMethod: "client_secret_basic",
+ metadata: {
+ headless_login_enabled: true,
+ headless_login_jwks_uri: "https://app.example/jwks.json",
+ },
+};
+
+const clientDetail = {
+ client: {
+ ...clientSummary,
+ clientSecret: "secret-value",
+ jwksUri: "https://app.example/jwks.json",
+ grantTypes: ["authorization_code"],
+ responseTypes: ["code"],
+ },
+ endpoints: {
+ discovery: "https://sso.example/.well-known/openid-configuration",
+ issuer: "https://sso.example",
+ authorization: "https://sso.example/oauth2/auth",
+ token: "https://sso.example/oauth2/token",
+ userinfo: "https://sso.example/userinfo",
+ },
+ headlessJwksCache: {
+ clientId: "client-a",
+ jwksUri: "https://app.example/jwks.json",
+ cachedAt: "2026-05-01T00:00:00Z",
+ expiresAt: "2026-05-02T00:00:00Z",
+ lastRefreshStatus: "success" as const,
+ cachedKids: ["kid-1"],
+ parsedKeys: [{ kid: "kid-1", kty: "RSA", use: "sig", alg: "RS256" }],
+ },
+};
+
+vi.mock("../../lib/devApi", () => ({
+ fetchClients: vi.fn(async () => ({
+ items: [clientSummary],
+ limit: 100,
+ offset: 0,
+ })),
+ fetchDevStats: vi.fn(async () => ({
+ total_clients: 1,
+ active_sessions: 12,
+ auth_failures_24h: 2,
+ })),
+ fetchClient: vi.fn(async () => clientDetail),
+ updateClientStatus: vi.fn(async () => clientDetail),
+ createClient: vi.fn(async () => clientDetail),
+ updateClient: vi.fn(async () => clientDetail),
+ rotateClientSecret: vi.fn(async () => clientDetail),
+ refreshHeadlessJwksCache: vi.fn(async () => clientDetail),
+ revokeHeadlessJwksCache: vi.fn(async () => undefined),
+ deleteClient: vi.fn(async () => undefined),
+ fetchConsents: vi.fn(async () => ({
+ items: [
+ {
+ subject: "user-1",
+ userName: "Consent User",
+ clientId: "client-a",
+ clientName: "Console App",
+ grantedScopes: ["openid", "profile"],
+ authenticatedAt: "2026-05-01T02:00:00Z",
+ createdAt: "2026-05-01T00:00:00Z",
+ status: "active",
+ tenantId: "tenant-1",
+ tenantName: "Hanmac",
+ },
+ ],
+ })),
+ revokeConsent: vi.fn(async () => undefined),
+ listIdpConfigsForClient: vi.fn(async () => [
+ {
+ 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",
+ },
+ ]),
+ createIdpConfigForClient: vi.fn(async (payload) => ({
+ id: "idp-1",
+ ...payload,
+ status: "active",
+ createdAt: "2026-05-01T00:00:00Z",
+ updatedAt: "2026-05-01T00:00:00Z",
+ })),
+ updateIdpConfig: vi.fn(async (_clientId, idpId, payload) => ({
+ id: idpId,
+ client_id: "client-a",
+ provider_type: "oidc",
+ display_name: "Provider",
+ status: "active",
+ createdAt: "2026-05-01T00:00:00Z",
+ updatedAt: "2026-05-01T00:00:00Z",
+ ...payload,
+ })),
+ deleteIdpConfig: vi.fn(async () => undefined),
+ fetchDevAuditLogs: vi.fn(async () => ({
+ items: [
+ {
+ event_id: "event-1",
+ timestamp: "2026-05-01T00:00:00Z",
+ user_id: "user-1",
+ event_type: "client.update",
+ status: "success",
+ ip_address: "127.0.0.1",
+ user_agent: "vitest",
+ details: JSON.stringify({
+ action: "client.update",
+ target_id: "client-a",
+ tenant_id: "tenant-1",
+ request_id: "req-1",
+ }),
+ },
+ ],
+ limit: 50,
+ })),
+ fetchMyTenants: vi.fn(async () => [
+ { id: "tenant-1", name: "Hanmac", slug: "hanmac" },
+ ]),
+}));
+
+vi.mock("../auth/authApi", () => ({
+ fetchMe: vi.fn(async () => ({
+ id: "user-1",
+ name: "Org User",
+ email: "org@example.com",
+ phone: "010-0000-0000",
+ role: "super_admin",
+ department: "Platform",
+ tenantId: "tenant-1",
+ tenant: { id: "tenant-1", name: "Hanmac", slug: "hanmac" },
+ })),
+}));
+
+const roots: Root[] = [];
+
+afterEach(() => {
+ for (const root of roots.splice(0)) {
+ act(() => {
+ root.unmount();
+ });
+ }
+});
+
+async function renderWithProviders(
+ element: React.ReactElement,
+ initialEntry = "/",
+) {
+ 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(
+
+ {element}
+ ,
+ );
+ });
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ return container;
+}
+
+describe("orgfront page smoke coverage", () => {
+ it("renders the dashboard content", async () => {
+ const container = await renderWithProviders();
+
+ expect(container.textContent).toContain("RP 등록 현황");
+ expect(container.textContent).toContain("Stack readiness");
+ });
+
+ it("renders static auth guidance and forbidden messages", async () => {
+ const auth = await renderWithProviders();
+ expect(auth.textContent).toContain("Admin auth guardrails");
+ expect(auth.textContent).toContain("TTL discipline");
+
+ const forbidden = await renderWithProviders(
+ ,
+ );
+ expect(forbidden.textContent).toContain("연동 앱 접근 권한 없음");
+ });
+
+ it("renders client list, detail, edit, consent, and federation pages", async () => {
+ const clients = await renderWithProviders(
+
+ } />
+ ,
+ "/clients",
+ );
+ expect(clients.textContent).toContain("Console App");
+
+ const details = await renderWithProviders(
+
+ } />
+ ,
+ "/clients/client-a",
+ );
+ expect(details.textContent).toContain("Client Secret");
+ expect(details.textContent).toContain("https://sso.example/oauth2/token");
+
+ const general = await renderWithProviders(
+
+ } />
+ ,
+ "/clients/client-a/edit",
+ );
+ expect(general.textContent).toContain("Console App");
+
+ const consents = await renderWithProviders(
+
+ } />
+ ,
+ "/clients/client-a/consents",
+ );
+ expect(consents.textContent).toContain("Consent User");
+
+ const federation = await renderWithProviders(
+
+ }
+ />
+ ,
+ "/clients/client-a/federation",
+ );
+ expect(federation.textContent).toContain("Workspace OIDC");
+ });
+
+ it("renders audit logs and profile pages", async () => {
+ const auditLogs = await renderWithProviders();
+ expect(auditLogs.textContent).toContain("client.update");
+ expect(auditLogs.textContent).toContain("Loaded 1 rows");
+
+ const profile = await renderWithProviders();
+ expect(profile.textContent).toContain("Org User");
+ expect(profile.textContent).toContain("Hanmac");
+ });
+});
diff --git a/orgfront/src/lib/adminApi.test.ts b/orgfront/src/lib/adminApi.test.ts
index e63e2358..b46f6f3f 100644
--- a/orgfront/src/lib/adminApi.test.ts
+++ b/orgfront/src/lib/adminApi.test.ts
@@ -1,23 +1,161 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiClient = {
+ get: vi.fn(),
post: vi.fn(),
put: 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("orgfront adminApi user tenant payloads", () => {
beforeEach(() => {
+ apiClient.get.mockReset();
apiClient.post.mockReset();
apiClient.put.mockReset();
+ apiClient.delete.mockReset();
+
+ apiClient.get.mockResolvedValue({ data: { ok: true } });
+ apiClient.post.mockResolvedValue({ data: { ok: true } });
+ apiClient.put.mockResolvedValue({ data: { ok: true } });
+ apiClient.delete.mockResolvedValue({ data: { ok: true } });
+ fetchAllCursorPages.mockClear();
+ window.localStorage.clear();
+ });
+
+ it("routes read APIs to their documented orgfront admin endpoints", async () => {
+ const adminApi = await import("./adminApi");
+
+ await adminApi.fetchAuditLogs(10, "cursor-a");
+ 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.fetchImportProgress("tenant-1", "progress-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.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");
+ await adminApi.fetchPublicOrgChart("public-token");
+
+ 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/organization/group-1/roles",
+ );
+ expect(apiClient.get).toHaveBeenCalledWith("/v1/public/orgchart", {
+ params: { token: "public-token" },
+ });
+ });
+
+ it("routes mutation APIs to their documented orgfront admin endpoints", async () => {
+ const adminApi = await import("./adminApi");
+
+ 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.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.importOrgChart(
+ "tenant-1",
+ new File(["name"], "org.csv"),
+ "progress-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.deleteApiKey("key-1");
+ await adminApi.createUser({ email: "user@example.com", name: "User" });
+ await adminApi.bulkCreateUsers([
+ { email: "user@example.com", name: "User", metadata: {} },
+ ]);
+ 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.post).toHaveBeenCalledWith(
+ "/v1/admin/tenants/tenant-1/organization/group-1/members",
+ { userId: "user-1" },
+ );
+ expect(apiClient.post).toHaveBeenCalledWith(
+ "/v1/admin/tenants/tenant-1/organization/import?progressId=progress-1",
+ expect.any(FormData),
+ { headers: { "Content-Type": "multipart/form-data" } },
+ );
+ expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", {
+ status: "active",
+ });
+ expect(apiClient.delete).toHaveBeenCalledWith(
+ "/v1/admin/relying-parties/client-1/owners/User:user-1",
+ );
});
it("sends tenantSlug without remapping it to companyCode when creating a user", async () => {
const { createUser } = await import("./adminApi");
- apiClient.post.mockResolvedValue({ data: {} });
await createUser({
email: "user@test.com",
@@ -34,7 +172,6 @@ describe("orgfront adminApi user tenant payloads", () => {
it("sends tenantSlug without remapping it to companyCode when updating a user", async () => {
const { updateUser } = await import("./adminApi");
- apiClient.put.mockResolvedValue({ data: {} });
await updateUser("user-id", { tenantSlug: "new-tenant" });
@@ -47,8 +184,6 @@ describe("orgfront adminApi user tenant payloads", () => {
it("keeps tenantSlug payloads unchanged for bulk user APIs", async () => {
const { bulkCreateUsers, bulkUpdateUsers } = await import("./adminApi");
- apiClient.post.mockResolvedValue({ data: {} });
- apiClient.put.mockResolvedValue({ data: {} });
await bulkCreateUsers([
{
diff --git a/orgfront/src/lib/devApi.test.ts b/orgfront/src/lib/devApi.test.ts
new file mode 100644
index 00000000..68d9bf28
--- /dev/null
+++ b/orgfront/src/lib/devApi.test.ts
@@ -0,0 +1,139 @@
+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(),
+};
+
+vi.mock("./apiClient", () => ({
+ default: apiClient,
+}));
+
+describe("orgfront devApi", () => {
+ beforeEach(() => {
+ apiClient.get.mockReset();
+ apiClient.post.mockReset();
+ apiClient.put.mockReset();
+ apiClient.patch.mockReset();
+ apiClient.delete.mockReset();
+
+ apiClient.get.mockResolvedValue({ data: { ok: true } });
+ apiClient.post.mockResolvedValue({ data: { ok: true } });
+ apiClient.put.mockResolvedValue({ data: { ok: true } });
+ apiClient.patch.mockResolvedValue({ data: { ok: true } });
+ apiClient.delete.mockResolvedValue({ data: { ok: true } });
+ });
+
+ it("fetches dev resources with expected query parameters", async () => {
+ const {
+ fetchClients,
+ fetchDevStats,
+ fetchClient,
+ fetchConsents,
+ fetchDevAuditLogs,
+ fetchMyTenants,
+ listIdpConfigsForClient,
+ } = await import("./devApi");
+
+ await fetchClients();
+ await fetchDevStats();
+ await fetchClient("client-a");
+ await fetchConsents("user-a", "client-a", "active");
+ await fetchDevAuditLogs(10, "cursor-a", {
+ action: "client.update",
+ client_id: "client-a",
+ status: "success",
+ tenant_id: "tenant-a",
+ });
+ await fetchMyTenants();
+ await listIdpConfigsForClient("client-a");
+
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/clients");
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/stats");
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a");
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
+ params: { subject: "user-a", client_id: "client-a", status: "active" },
+ });
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/audit-logs", {
+ params: {
+ limit: 10,
+ cursor: "cursor-a",
+ action: "client.update",
+ client_id: "client-a",
+ status: "success",
+ tenant_id: "tenant-a",
+ },
+ });
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/my-tenants");
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a/idps");
+ });
+
+ it("omits optional consent filters when they are empty or all", async () => {
+ const { fetchConsents, revokeConsent } = await import("./devApi");
+
+ await fetchConsents("user-a", undefined, "all");
+ await revokeConsent("user-a");
+
+ expect(apiClient.get).toHaveBeenCalledWith("/dev/consents", {
+ params: { subject: "user-a" },
+ });
+ expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
+ params: { subject: "user-a" },
+ });
+ });
+
+ it("sends mutation requests to the documented dev endpoints", async () => {
+ const {
+ updateClientStatus,
+ createClient,
+ updateClient,
+ rotateClientSecret,
+ refreshHeadlessJwksCache,
+ revokeHeadlessJwksCache,
+ deleteClient,
+ revokeConsent,
+ createIdpConfigForClient,
+ updateIdpConfig,
+ deleteIdpConfig,
+ } = await import("./devApi");
+
+ await updateClientStatus("client-a", "inactive");
+ await createClient({ id: "client-a", name: "Console App" });
+ await updateClient("client-a", { name: "Console App Updated" });
+ await rotateClientSecret("client-a");
+ await refreshHeadlessJwksCache("client-a");
+ await revokeHeadlessJwksCache("client-a");
+ await deleteClient("client-a");
+ await revokeConsent("user-a", "client-a");
+ await createIdpConfigForClient({
+ client_id: "client-a",
+ provider_type: "oidc",
+ display_name: "OIDC Provider",
+ status: "active",
+ });
+ await updateIdpConfig("client-a", "idp-a", { status: "inactive" });
+ await deleteIdpConfig("client-a", "idp-a");
+
+ expect(apiClient.patch).toHaveBeenCalledWith(
+ "/dev/clients/client-a/status",
+ { status: "inactive" },
+ );
+ expect(apiClient.post).toHaveBeenCalledWith("/dev/clients", {
+ id: "client-a",
+ name: "Console App",
+ });
+ expect(apiClient.put).toHaveBeenCalledWith("/dev/clients/client-a", {
+ name: "Console App Updated",
+ });
+ expect(apiClient.delete).toHaveBeenCalledWith("/dev/consents", {
+ params: { subject: "user-a", client_id: "client-a" },
+ });
+ expect(apiClient.put).toHaveBeenCalledWith(
+ "/dev/clients/client-a/idps/idp-a",
+ { status: "inactive" },
+ );
+ });
+});
diff --git a/userfront-e2e/tests/login-performance-budget.spec.ts b/userfront-e2e/tests/login-performance-budget.spec.ts
index a0423838..ef06daaf 100644
--- a/userfront-e2e/tests/login-performance-budget.spec.ts
+++ b/userfront-e2e/tests/login-performance-budget.spec.ts
@@ -208,11 +208,6 @@ test.describe("UserFront login performance budget", () => {
...cold.cacheControlByPath,
...warm.cacheControlByPath,
]);
- const contentEncodingByPath = new Map([
- ...cold.contentEncodingByPath,
- ...warm.contentEncodingByPath,
- ]);
-
const appShellCache = cacheControlByPath.get("/ko/signin") ?? "";
expect(appShellCache).toContain("no-cache");
const serviceWorkerState = await page.evaluate(async () => {
diff --git a/userfront-e2e/tests/password-and-reset.spec.ts b/userfront-e2e/tests/password-and-reset.spec.ts
index 21ad4a93..f4b86c96 100644
--- a/userfront-e2e/tests/password-and-reset.spec.ts
+++ b/userfront-e2e/tests/password-and-reset.spec.ts
@@ -314,7 +314,7 @@ async function mockAuthApis(
}
if (path.endsWith("/api/v1/user/me")) {
- const authHeader = route.request().headers()["authorization"] ?? "";
+ const authHeader = route.request().headers().authorization ?? "";
if (!authHeader.startsWith("Bearer ")) {
await route.fulfill({
status: 401,
diff --git a/userfront-e2e/tests/profile-department.spec.ts b/userfront-e2e/tests/profile-department.spec.ts
index 615171c6..5f203e5b 100644
--- a/userfront-e2e/tests/profile-department.spec.ts
+++ b/userfront-e2e/tests/profile-department.spec.ts
@@ -261,7 +261,7 @@ async function mockProfileApis(page: Page, state: ProfileState): Promise {
const method = request.method().toUpperCase();
if (path.endsWith("/api/v1/user/me") && method === "GET") {
- const authHeader = request.headers()["authorization"] ?? "";
+ const authHeader = request.headers().authorization ?? "";
if (!authHeader.startsWith("Bearer ")) {
await route.fulfill({
status: 401,
diff --git a/userfront-e2e/tests/route-inventory.spec.ts b/userfront-e2e/tests/route-inventory.spec.ts
index 3a2673e8..ac7fbc8f 100644
--- a/userfront-e2e/tests/route-inventory.spec.ts
+++ b/userfront-e2e/tests/route-inventory.spec.ts
@@ -16,7 +16,7 @@ async function mockInventoryApis(page: Page): Promise {
const method = route.request().method().toUpperCase();
if (path.endsWith("/api/v1/user/me")) {
- const authHeader = route.request().headers()["authorization"] ?? "";
+ const authHeader = route.request().headers().authorization ?? "";
if (authHeader.startsWith("Bearer ")) {
await route.fulfill({
status: 200,
diff --git a/userfront-e2e/tests/session-cross-browser-debug.spec.ts b/userfront-e2e/tests/session-cross-browser-debug.spec.ts
index 8ff74fbe..fbab56bc 100644
--- a/userfront-e2e/tests/session-cross-browser-debug.spec.ts
+++ b/userfront-e2e/tests/session-cross-browser-debug.spec.ts
@@ -23,23 +23,6 @@ function ensureCredentials(): void {
}
}
-async function enableFlutterAccessibility(page: Page): Promise {
- await page.waitForTimeout(300);
- const button = page.getByRole("button", { name: "Enable accessibility" });
- if (await button.count()) {
- try {
- await button.click({ force: true });
- } catch {
- return;
- }
- const placeholder = page.locator("flt-semantics-placeholder");
- if (await placeholder.count()) {
- await placeholder.first().click({ force: true });
- }
- await page.waitForTimeout(800);
- }
-}
-
async function clickPasswordTab(page: Page): Promise {
await page.waitForTimeout(900);
const pane = page.locator("flt-glass-pane");