2129 lines
77 KiB
TypeScript
2129 lines
77 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
|
|
import React from "react";
|
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
|
|
|
// ── Shared mock state (vi.hoisted avoids TDZ with vi.mock hoisting) ────────────
|
|
|
|
const H = vi.hoisted(() => ({
|
|
mockNavigate: vi.fn(),
|
|
mockGetNetwork: vi.fn(),
|
|
mockGetNetworkMembers: vi.fn(),
|
|
mockActivateMembership: vi.fn(),
|
|
mockDeactivateMembership: vi.fn(),
|
|
mockGetNetworkPendingRequests: vi.fn(),
|
|
mockApproveRequest: vi.fn(),
|
|
mockRejectRequest: vi.fn(),
|
|
mockToast: vi.fn(),
|
|
state: {
|
|
orgId: "org-1" as string | null,
|
|
networkId: "net-abc" as string | undefined,
|
|
},
|
|
}));
|
|
|
|
vi.mock("react-router-dom", async () => {
|
|
const actual = await vi.importActual("react-router-dom");
|
|
return {
|
|
...actual,
|
|
useNavigate: () => H.mockNavigate,
|
|
useParams: () => ({ networkId: H.state.networkId }),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/hooks/useCurrentOrganization", () => ({
|
|
useCurrentOrganizationId: () => ({
|
|
orgId: H.state.orgId,
|
|
isLoading: false,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/api")>();
|
|
return {
|
|
...actual,
|
|
api: {
|
|
zerotier: {
|
|
getNetwork: H.mockGetNetwork,
|
|
getNetworkMembers: H.mockGetNetworkMembers,
|
|
activateMembership: H.mockActivateMembership,
|
|
deactivateMembership: H.mockDeactivateMembership,
|
|
getNetworkPendingRequests: H.mockGetNetworkPendingRequests,
|
|
approveRequest: H.mockApproveRequest,
|
|
rejectRequest: H.mockRejectRequest,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
vi.mock("@/hooks/use-toast", () => ({
|
|
useToast: () => ({
|
|
toast: H.mockToast,
|
|
dismiss: () => {},
|
|
toasts: [],
|
|
}),
|
|
}));
|
|
|
|
import NetworkManagementPage from "../src/pages/org/NetworkManagementPage";
|
|
import { ApiError } from "@/lib/api";
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
|
|
const DEV_NETWORK = {
|
|
id: "net-abc",
|
|
organization_id: "org-1",
|
|
name: "Dev Network",
|
|
description: "Internal dev",
|
|
owner_user_id: "user-1",
|
|
zerotier_network_id: "zt-xxx",
|
|
environment: "development" as const,
|
|
request_mode: "open" as const,
|
|
default_activation_lifetime_minutes: 1440,
|
|
max_activation_lifetime_minutes: null as number | null,
|
|
is_active: true,
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-01T00:00:00Z",
|
|
deleted_at: null as string | null,
|
|
approved_user_count: 42,
|
|
active_membership_count: 7,
|
|
};
|
|
|
|
function renderWithRoute(networkId = "net-abc") {
|
|
return render(
|
|
React.createElement(
|
|
MemoryRouter,
|
|
{ initialEntries: [`/org/zerotier/networks/${networkId}`] },
|
|
React.createElement(Routes, null,
|
|
React.createElement(Route, {
|
|
path: "/org/zerotier/networks/:networkId",
|
|
element: React.createElement(NetworkManagementPage),
|
|
})
|
|
)
|
|
)
|
|
);
|
|
}
|
|
|
|
// ── Setup / Teardown ───────────────────────────────────────────────────────────
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
H.state.orgId = "org-1";
|
|
H.state.networkId = "net-abc";
|
|
// Default: getNetwork returns a pending promise (loading state)
|
|
H.mockGetNetwork.mockImplementation(() => new Promise(() => {}));
|
|
// Default: getNetworkPendingRequests returns a pending promise (loading state)
|
|
H.mockGetNetworkPendingRequests.mockImplementation(() => new Promise(() => {}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
// ── Tests ──────────────────────────────────────────────────────────────────────
|
|
|
|
describe("NetworkManagementPage", () => {
|
|
// ── HAPPY PATH: Loading → Success ────────────────────────────────────────────
|
|
|
|
test("renders skeleton placeholders during loading state", () => {
|
|
renderWithRoute();
|
|
|
|
const container = document.querySelector(".page-container");
|
|
expect(container).not.toBeNull();
|
|
|
|
const skeletons = document.querySelectorAll(".animate-pulse");
|
|
expect(skeletons.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
test("renders success state with network name and tabs when API resolves", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
expect(screen.getByText("Back to Networks")).toBeDefined();
|
|
expect(screen.getByRole("tab", { name: "Overview" })).toBeDefined();
|
|
expect(screen.getByRole("tab", { name: "Members" })).toBeDefined();
|
|
expect(screen.getByRole("tab", { name: "Requests" })).toBeDefined();
|
|
expect(
|
|
screen.getByText("Manage network members, devices, and access requests")
|
|
).toBeDefined();
|
|
});
|
|
|
|
test("renders fallback 'Network' when name is null", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, name: null as unknown as string },
|
|
});
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Network")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── ERROR PATH ───────────────────────────────────────────────────────────────
|
|
|
|
test("renders generic error when API throws a plain Error", async () => {
|
|
H.mockGetNetwork.mockRejectedValue(new Error("Network unreachable"));
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Failed to load network details.")).toBeDefined();
|
|
});
|
|
|
|
expect(screen.getByText("Back to Networks")).toBeDefined();
|
|
});
|
|
|
|
test("renders specific error message when API throws ApiError", async () => {
|
|
const apiError = new ApiError("Network not found in the controller", 404, "not_found");
|
|
|
|
H.mockGetNetwork.mockRejectedValue(apiError);
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText("Network not found in the controller")
|
|
).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders error when orgId is missing", async () => {
|
|
H.state.orgId = null;
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText("Organization or network ID is missing.")
|
|
).toBeDefined();
|
|
});
|
|
|
|
expect(H.mockGetNetwork).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("renders error when networkId is missing from URL", async () => {
|
|
H.state.networkId = undefined;
|
|
|
|
render(
|
|
React.createElement(
|
|
MemoryRouter,
|
|
{ initialEntries: ["/org/zerotier/networks/"] },
|
|
React.createElement(Routes, null,
|
|
React.createElement(Route, {
|
|
path: "/org/zerotier/networks/",
|
|
element: React.createElement(NetworkManagementPage),
|
|
})
|
|
)
|
|
)
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText("Organization or network ID is missing.")
|
|
).toBeDefined();
|
|
});
|
|
|
|
expect(H.mockGetNetwork).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ── NAVIGATION ───────────────────────────────────────────────────────────────
|
|
|
|
test("Back to Networks in success state navigates to /org/zerotier/networks", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText("Back to Networks"));
|
|
|
|
expect(H.mockNavigate).toHaveBeenCalledTimes(1);
|
|
expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks");
|
|
});
|
|
|
|
test("Back to Networks in error state navigates to /org/zerotier/networks", async () => {
|
|
H.mockGetNetwork.mockRejectedValue(new Error("fail"));
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Failed to load network details.")).toBeDefined();
|
|
});
|
|
|
|
fireEvent.click(screen.getByText("Back to Networks"));
|
|
|
|
expect(H.mockNavigate).toHaveBeenCalledTimes(1);
|
|
expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks");
|
|
});
|
|
|
|
// ── BOUNDARY / STATE ─────────────────────────────────────────────────────────
|
|
|
|
test("stops showing loading skeletons after API resolves", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
|
|
renderWithRoute();
|
|
|
|
expect(document.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
expect(document.querySelectorAll(".animate-pulse").length).toBe(0);
|
|
});
|
|
|
|
test("stops showing loading skeletons after API rejects", async () => {
|
|
H.mockGetNetwork.mockRejectedValue(new Error("fail"));
|
|
|
|
renderWithRoute();
|
|
|
|
expect(document.querySelectorAll(".animate-pulse").length).toBeGreaterThan(0);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Failed to load network details.")).toBeDefined();
|
|
});
|
|
|
|
expect(document.querySelectorAll(".animate-pulse").length).toBe(0);
|
|
});
|
|
|
|
test("passes correct orgId and networkId to getNetwork", async () => {
|
|
H.state.orgId = "org-2";
|
|
H.state.networkId = "net-specific";
|
|
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: {
|
|
...DEV_NETWORK,
|
|
id: "net-specific",
|
|
organization_id: "org-2",
|
|
name: "Specific Network",
|
|
},
|
|
});
|
|
|
|
renderWithRoute("net-specific");
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Specific Network")).toBeDefined();
|
|
});
|
|
|
|
expect(H.mockGetNetwork).toHaveBeenCalledTimes(1);
|
|
expect(H.mockGetNetwork).toHaveBeenCalledWith("org-2", "net-specific");
|
|
});
|
|
|
|
test("renders exactly 3 tabs", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const tabs = screen.getAllByRole("tab");
|
|
expect(tabs.length).toBe(3);
|
|
});
|
|
|
|
test("Overview tab is selected by default", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const overviewTab = screen.getByRole("tab", { name: "Overview" });
|
|
expect(overviewTab.getAttribute("data-state")).toBe("active");
|
|
});
|
|
|
|
// ── ADVERSARIAL ──────────────────────────────────────────────────────────────
|
|
|
|
test("handles very long network name (500 chars)", async () => {
|
|
const longName = "A".repeat(500);
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, name: longName },
|
|
});
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(longName)).toBeDefined();
|
|
});
|
|
|
|
expect(screen.getByText("Back to Networks")).toBeDefined();
|
|
expect(screen.getByRole("tab", { name: "Overview" })).toBeDefined();
|
|
});
|
|
|
|
test("handles network name with script tags (XSS-safe rendering)", async () => {
|
|
const specialName = "Dev/Network <script>alert(1)</script> & Co.";
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, name: specialName },
|
|
});
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(specialName)).toBeDefined();
|
|
});
|
|
|
|
expect(screen.getByText(specialName).textContent).toBe(specialName);
|
|
});
|
|
|
|
// ── OVERVIEW TAB CONTENT ──────────────────────────────────────────────────────
|
|
|
|
describe("Overview tab content", () => {
|
|
beforeEach(() => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
});
|
|
|
|
test("renders environment badge with correct color classes", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Development")).toBeDefined();
|
|
});
|
|
const badge = screen.getByText("Development");
|
|
expect(badge.className).toContain("bg-green-500/10");
|
|
expect(badge.className).toContain("text-green-600");
|
|
});
|
|
|
|
test("renders request mode badge for 'open' mode", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Open")).toBeDefined();
|
|
});
|
|
const badge = screen.getByText("Open");
|
|
expect(badge.className).toContain("text-green-600");
|
|
});
|
|
|
|
test("renders request mode badge for 'approval_required' mode", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, request_mode: "approval_required" },
|
|
});
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Approval Required")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders request mode badge for 'invite_only' mode", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, request_mode: "invite_only" },
|
|
});
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Invite Only")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders inactive badge when is_active is false", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, is_active: false },
|
|
});
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Inactive")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("does NOT render inactive badge when is_active is true", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Development")).toBeDefined();
|
|
});
|
|
expect(screen.queryByText("Inactive")).toBeNull();
|
|
});
|
|
|
|
test("renders description when present", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Internal dev")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("does NOT render description section when null", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, description: null },
|
|
});
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Development")).toBeDefined();
|
|
});
|
|
expect(screen.queryByText("Internal dev")).toBeNull();
|
|
});
|
|
|
|
test("renders ZeroTier Network ID", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("zt-xxx")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders default activation lifetime", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("1440 min")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders 'No limit' for null max activation lifetime", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("No limit")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders numeric max activation lifetime when set", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, max_activation_lifetime_minutes: 2880 },
|
|
});
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("2880 min")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders approved user count stat card", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Approved Users")).toBeDefined();
|
|
});
|
|
expect(screen.getByText("42")).toBeDefined();
|
|
});
|
|
|
|
test("renders active device count stat card", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Active Devices")).toBeDefined();
|
|
});
|
|
expect(screen.getByText("7")).toBeDefined();
|
|
});
|
|
|
|
test("renders approved user count as 0 when undefined", async () => {
|
|
const { approved_user_count, ...withoutApproved } = DEV_NETWORK as any;
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: withoutApproved,
|
|
});
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("0")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders active device count as 0 when undefined", async () => {
|
|
const { active_membership_count, ...withoutActive } = DEV_NETWORK as any;
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: withoutActive,
|
|
});
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Active Devices")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders '—' for null created_at date", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, created_at: null as unknown as string },
|
|
});
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("—")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders created_at as formatted date", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, created_at: "2025-06-15T10:30:00Z" },
|
|
});
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Development")).toBeDefined();
|
|
});
|
|
// Just verify it's not "—" (exact date format depends on locale)
|
|
const createdLabel = screen.getByText("Created");
|
|
expect(createdLabel).toBeDefined();
|
|
// The value is rendered in a sibling element; verify it exists and is not em-dash
|
|
const createdSection = createdLabel.closest("div");
|
|
expect(createdSection).not.toBeNull();
|
|
const valueElement = createdSection!.querySelector(".font-medium");
|
|
expect(valueElement).not.toBeNull();
|
|
expect(valueElement!.textContent).not.toBe("—");
|
|
});
|
|
|
|
test("renders request mode stat card with human-readable mode", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Request Mode")).toBeDefined();
|
|
});
|
|
// "open" → "open" → CSS capitalize → "Open"
|
|
expect(screen.getByText("open")).toBeDefined();
|
|
});
|
|
|
|
test("renders all overview metadata labels", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Development")).toBeDefined();
|
|
});
|
|
expect(screen.getByText("ZeroTier Network ID")).toBeDefined();
|
|
expect(screen.getByText("Default Activation")).toBeDefined();
|
|
expect(screen.getByText("Max Activation")).toBeDefined();
|
|
expect(screen.getByText("Created")).toBeDefined();
|
|
});
|
|
|
|
test("renders Network Details card title", async () => {
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Network Details")).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── OVERVIEW TAB ENVIRONMENT BADGE VARIANTS ───────────────────────────────────
|
|
|
|
describe("Environment badge variants", () => {
|
|
test.each([
|
|
["production", "Production", "bg-red-500/10", "text-red-600"],
|
|
["staging", "Staging", "bg-yellow-500/10", "text-yellow-600"],
|
|
["development", "Development", "bg-green-500/10", "text-green-600"],
|
|
["lab", "Lab", "bg-blue-500/10", "text-blue-600"],
|
|
])("renders %s environment with label '%s' and correct colors",
|
|
async (env, label, expectedBg, expectedText) => {
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, environment: env as any },
|
|
});
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText(label)).toBeDefined();
|
|
});
|
|
const badge = screen.getByText(label);
|
|
expect(badge.className).toContain(expectedBg);
|
|
expect(badge.className).toContain(expectedText);
|
|
});
|
|
});
|
|
|
|
// ── OVERVIEW TAB NULL/UNDEFINED HANDLING ──────────────────────────────────────
|
|
|
|
describe("Overview tab null edge cases", () => {
|
|
test("shows '—' for null created_at", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({
|
|
network: { ...DEV_NETWORK, created_at: null as unknown as string },
|
|
});
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("—")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("shows '—' for undefined created_at", async () => {
|
|
const { created_at, ...rest } = DEV_NETWORK;
|
|
H.mockGetNetwork.mockResolvedValue({ network: rest as any });
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("—")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("shows 0 for undefined approved_user_count", async () => {
|
|
const { approved_user_count, ...rest } = DEV_NETWORK;
|
|
H.mockGetNetwork.mockResolvedValue({ network: rest as any });
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Approved Users")).toBeDefined();
|
|
});
|
|
// The count displays as 0 via nullish coalescing: approved_user_count ?? 0
|
|
});
|
|
|
|
test("shows 0 for undefined active_membership_count", async () => {
|
|
const { active_membership_count, ...rest } = DEV_NETWORK;
|
|
H.mockGetNetwork.mockResolvedValue({ network: rest as any });
|
|
renderWithRoute();
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Active Devices")).toBeDefined();
|
|
});
|
|
});
|
|
});
|
|
|
|
// ── MEMBERS TAB ──────────────────────────────────────────────────────────────
|
|
|
|
describe("Members tab", () => {
|
|
// ── Fixtures ────────────────────────────────────────────────────────────────
|
|
|
|
const MEMBERSHIP_AUTHORIZED_WITH_SESSION = {
|
|
id: "mem-auth-1",
|
|
organization_id: "org-1",
|
|
user_id: "user-a",
|
|
user_name: "Alice Smith",
|
|
user_email: "alice@test.com",
|
|
device_id: "dev-laptop-1",
|
|
device_name: "Alice Laptop",
|
|
device_node_id: "abc123",
|
|
portal_network_id: "net-abc",
|
|
user_network_approval_id: null,
|
|
active: true,
|
|
status: "approved",
|
|
grant_type: "assigned",
|
|
granted_by_user_id: null,
|
|
justification: null,
|
|
join_seen: true,
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-01T00:00:00Z",
|
|
deleted_at: null,
|
|
active_session: {
|
|
id: "session-1",
|
|
organization_id: "org-1",
|
|
user_id: "user-a",
|
|
device_network_membership_id: "mem-auth-1",
|
|
authenticated_at: "2025-01-01T00:00:00Z",
|
|
expires_at: "2025-12-31T23:59:59Z",
|
|
ended_at: null,
|
|
end_reason: null,
|
|
created_by: "user-a",
|
|
created_at: "2025-01-01T00:00:00Z",
|
|
updated_at: "2025-01-01T00:00:00Z",
|
|
deleted_at: null,
|
|
is_expired: false,
|
|
is_active: true,
|
|
},
|
|
};
|
|
|
|
const MEMBERSHIP_UNAUTHORIZED_NO_SESSION = {
|
|
id: "mem-unauth-1",
|
|
organization_id: "org-1",
|
|
user_id: "user-a",
|
|
user_name: "Alice Smith",
|
|
user_email: "alice@test.com",
|
|
device_id: "dev-desktop-1",
|
|
device_name: "Alice Desktop",
|
|
device_node_id: "def456",
|
|
portal_network_id: "net-abc",
|
|
user_network_approval_id: null,
|
|
active: false,
|
|
status: "approved",
|
|
grant_type: "assigned",
|
|
granted_by_user_id: null,
|
|
justification: null,
|
|
join_seen: false,
|
|
created_at: "2025-02-01T00:00:00Z",
|
|
updated_at: "2025-02-01T00:00:00Z",
|
|
deleted_at: null,
|
|
active_session: null,
|
|
};
|
|
|
|
const MEMBERSHIP_SECOND_USER = {
|
|
id: "mem-auth-2",
|
|
organization_id: "org-1",
|
|
user_id: "user-b",
|
|
user_name: "Bob Jones",
|
|
user_email: "bob@test.com",
|
|
device_id: "dev-phone-1",
|
|
device_name: "Bob Phone",
|
|
device_node_id: "ghi789",
|
|
portal_network_id: "net-abc",
|
|
user_network_approval_id: null,
|
|
active: false,
|
|
status: "approved",
|
|
grant_type: "assigned",
|
|
granted_by_user_id: null,
|
|
justification: null,
|
|
join_seen: true,
|
|
created_at: "2025-03-01T00:00:00Z",
|
|
updated_at: "2025-03-01T00:00:00Z",
|
|
deleted_at: null,
|
|
active_session: null,
|
|
};
|
|
|
|
const MEMBERSHIP_PENDING_REQUEST = {
|
|
id: "mem-pending-1",
|
|
organization_id: "org-1",
|
|
user_id: "user-c",
|
|
user_name: "Charlie Brown",
|
|
user_email: "charlie@test.com",
|
|
device_id: "dev-server-1",
|
|
device_name: "Charlie Server",
|
|
device_node_id: "jkl012",
|
|
portal_network_id: "net-abc",
|
|
user_network_approval_id: null,
|
|
active: false,
|
|
status: "pending",
|
|
grant_type: "requested",
|
|
granted_by_user_id: null,
|
|
justification: null,
|
|
join_seen: false,
|
|
created_at: "2025-04-01T00:00:00Z",
|
|
updated_at: "2025-04-01T00:00:00Z",
|
|
deleted_at: null,
|
|
active_session: null,
|
|
};
|
|
|
|
// Helper: setup resolved network, then click Members tab
|
|
async function setupMembersTab(memberships: Array<Record<string, unknown>> = [MEMBERSHIP_AUTHORIZED_WITH_SESSION]) {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkMembers.mockResolvedValue({ memberships, count: memberships.length });
|
|
|
|
renderWithRoute();
|
|
|
|
// Wait for the page to render (network resolves first)
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
// Activate Members tab via keyboard (Radix uses focus + Enter)
|
|
const membersTab = screen.getByRole("tab", { name: "Members" });
|
|
membersTab.focus();
|
|
fireEvent.keyDown(membersTab, { key: "Enter" });
|
|
|
|
// Wait for members content
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Network Members")).toBeDefined();
|
|
});
|
|
}
|
|
|
|
// ── Loading / Error / Empty ─────────────────────────────────────────────────
|
|
|
|
test("renders loading spinner in Members tab while fetching members", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
// getNetworkMembers stays pending (never resolves)
|
|
H.mockGetNetworkMembers.mockImplementation(() => new Promise(() => {}));
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const membersTab = screen.getByRole("tab", { name: "Members" });
|
|
membersTab.focus();
|
|
fireEvent.keyDown(membersTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Network Members")).toBeDefined();
|
|
});
|
|
|
|
expect(screen.getByText("Loading members…")).toBeDefined();
|
|
});
|
|
|
|
test("renders error message when members API fails", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkMembers.mockRejectedValue(new Error("API unavailable"));
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const membersTab = screen.getByRole("tab", { name: "Members" });
|
|
membersTab.focus();
|
|
fireEvent.keyDown(membersTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Failed to load members.")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders ApiError message when members API throws ApiError", async () => {
|
|
const apiError = new ApiError("Membership lookup failed", 500, "internal_error");
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkMembers.mockRejectedValue(apiError);
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const membersTab = screen.getByRole("tab", { name: "Members" });
|
|
membersTab.focus();
|
|
fireEvent.keyDown(membersTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Membership lookup failed")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders 'No members' message when memberships array is empty", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkMembers.mockResolvedValue({ memberships: [], count: 0 });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const membersTab = screen.getByRole("tab", { name: "Members" });
|
|
membersTab.focus();
|
|
fireEvent.keyDown(membersTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("No members on this network yet.")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders 'No members' when memberships field is undefined", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkMembers.mockResolvedValue({ count: 0 });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const membersTab = screen.getByRole("tab", { name: "Members" });
|
|
membersTab.focus();
|
|
fireEvent.keyDown(membersTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("No members on this network yet.")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── Grouping by user_id ─────────────────────────────────────────────────────
|
|
|
|
test("groups memberships by user_id and renders user sections", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
// Both memberships belong to Alice Smith → one user section
|
|
expect(screen.getByText("Alice Smith")).toBeDefined();
|
|
// Should show "2 devices"
|
|
expect(screen.getByText(/2 devices/)).toBeDefined();
|
|
});
|
|
|
|
test("renders multiple user sections for different user_ids", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_SECOND_USER]);
|
|
|
|
expect(screen.getByText("Alice Smith")).toBeDefined();
|
|
expect(screen.getByText("Bob Jones")).toBeDefined();
|
|
});
|
|
|
|
test("displays user_name when available", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
|
|
|
expect(screen.getByText("Alice Smith")).toBeDefined();
|
|
});
|
|
|
|
// ── Expand / Collapse ───────────────────────────────────────────────────────
|
|
|
|
test("clicking user section expands to show devices", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
// Devices should NOT be visible before expanding
|
|
expect(screen.queryByText("Alice Laptop")).toBeNull();
|
|
expect(screen.queryByText("Alice Desktop")).toBeNull();
|
|
|
|
// Click the user section to expand
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
expect(userButton).not.toBeNull();
|
|
fireEvent.click(userButton!);
|
|
|
|
// Now devices should be visible
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Alice Laptop")).toBeDefined();
|
|
});
|
|
expect(screen.getByText("Alice Desktop")).toBeDefined();
|
|
});
|
|
|
|
test("clicking expanded user section collapses devices", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
expect(userButton).not.toBeNull();
|
|
fireEvent.click(userButton!);
|
|
|
|
// Devices should be visible
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Alice Laptop")).toBeDefined();
|
|
});
|
|
|
|
// Click again to collapse
|
|
fireEvent.click(userButton!);
|
|
|
|
// Devices should disappear
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("Alice Laptop")).toBeNull();
|
|
});
|
|
});
|
|
|
|
test("expand/collapse is independent per user section", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_SECOND_USER]);
|
|
|
|
const userAButton = screen.getByText("Alice Smith").closest("button")!;
|
|
const userBButton = screen.getByText("Bob Jones").closest("button")!;
|
|
|
|
// Expand Alice Smith only
|
|
fireEvent.click(userAButton);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Alice Laptop")).toBeDefined();
|
|
});
|
|
// Bob Jones' device should still be hidden
|
|
expect(screen.queryByText("Bob Phone")).toBeNull();
|
|
|
|
// Expand Bob Jones too
|
|
fireEvent.click(userBButton);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Bob Phone")).toBeDefined();
|
|
});
|
|
|
|
// Collapse Alice Smith — Bob Jones should remain expanded
|
|
fireEvent.click(userAButton);
|
|
await waitFor(() => {
|
|
expect(screen.queryByText("Alice Laptop")).toBeNull();
|
|
});
|
|
expect(screen.getByText("Bob Phone")).toBeDefined();
|
|
});
|
|
|
|
// ── Device Details ──────────────────────────────────────────────────────────
|
|
|
|
test("renders Active badge for active_authorized membership", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Active")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders Inactive badge for approved_inactive membership", async () => {
|
|
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Inactive")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders Inactive badge for pending_request membership", async () => {
|
|
await setupMembersTab([MEMBERSHIP_PENDING_REQUEST]);
|
|
|
|
const userButton = screen.getByText("Charlie Brown").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Inactive")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders Inactive badge for second user (approved_inactive)", async () => {
|
|
await setupMembersTab([MEMBERSHIP_SECOND_USER]);
|
|
|
|
const userButton = screen.getByText("Bob Jones").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Inactive")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── Authorization Status ────────────────────────────────────────────────────
|
|
|
|
test("shows 'Active' badge when membership is active", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
const actives = screen.getAllByText("Active");
|
|
expect(actives.length).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
test("shows 'Inactive' badge when membership is not active", async () => {
|
|
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Inactive")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── Active Session Info ─────────────────────────────────────────────────────
|
|
|
|
test("shows session progress bar when active_session is present and is_active", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/remaining/)).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("does NOT show session progress when active_session is null", async () => {
|
|
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Inactive")).toBeDefined();
|
|
});
|
|
|
|
expect(screen.queryByText(/remaining/)).toBeNull();
|
|
});
|
|
|
|
// ── Join Seen ───────────────────────────────────────────────────────────────
|
|
|
|
test("shows join_seen as 'Yes' when true", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Joined: Yes")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("shows join_seen as 'No' when false", async () => {
|
|
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Joined: No")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── Activate / Deactivate Buttons ───────────────────────────────────────────
|
|
|
|
test("renders Deactivate button for active memberships", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: "Deactivate" })).toBeDefined();
|
|
});
|
|
expect(screen.queryByRole("button", { name: "Activate" })).toBeNull();
|
|
});
|
|
|
|
test("renders Activate button for approved-but-inactive memberships", async () => {
|
|
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: "Activate" })).toBeDefined();
|
|
});
|
|
expect(screen.queryByRole("button", { name: "Deactivate" })).toBeNull();
|
|
});
|
|
|
|
test("clicking Activate opens dialog then calls api.zerotier.activateMembership with correct orgId, membershipId, and lifetime", async () => {
|
|
H.mockActivateMembership.mockResolvedValue({});
|
|
// Second call to getNetworkMembers (refresh after activate)
|
|
H.mockGetNetworkMembers
|
|
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 })
|
|
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 });
|
|
|
|
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
const activateBtn = await screen.findByRole("button", { name: "Activate" });
|
|
fireEvent.click(activateBtn);
|
|
|
|
// Dialog opens; click the Activate button in the dialog
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Set Activation Duration")).toBeDefined();
|
|
});
|
|
const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
|
|
fireEvent.click(dialogBtns[dialogBtns.length - 1]);
|
|
|
|
expect(H.mockActivateMembership).toHaveBeenCalledTimes(1);
|
|
expect(H.mockActivateMembership).toHaveBeenCalledWith("org-1", "mem-unauth-1", 480);
|
|
});
|
|
|
|
test("clicking Deactivate calls api.zerotier.deactivateMembership with correct orgId and membershipId", async () => {
|
|
H.mockDeactivateMembership.mockResolvedValue({});
|
|
// Second call to getNetworkMembers (refresh after deactivate)
|
|
H.mockGetNetworkMembers
|
|
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 })
|
|
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 });
|
|
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" });
|
|
fireEvent.click(deactivateBtn);
|
|
|
|
expect(H.mockDeactivateMembership).toHaveBeenCalledTimes(1);
|
|
expect(H.mockDeactivateMembership).toHaveBeenCalledWith("org-1", "mem-auth-1");
|
|
});
|
|
|
|
test("shows success toast after successful activation", async () => {
|
|
H.mockActivateMembership.mockResolvedValue({});
|
|
H.mockGetNetworkMembers
|
|
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 })
|
|
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 });
|
|
|
|
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
const activateBtn = await screen.findByRole("button", { name: "Activate" });
|
|
fireEvent.click(activateBtn);
|
|
|
|
// Confirm in dialog
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Set Activation Duration")).toBeDefined();
|
|
});
|
|
const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
|
|
fireEvent.click(dialogBtns[dialogBtns.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Membership activated" }));
|
|
});
|
|
});
|
|
|
|
test("shows success toast after successful deactivation", async () => {
|
|
H.mockDeactivateMembership.mockResolvedValue({});
|
|
H.mockGetNetworkMembers
|
|
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 })
|
|
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 });
|
|
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" });
|
|
fireEvent.click(deactivateBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Membership deactivated" }));
|
|
});
|
|
});
|
|
|
|
test("shows error toast when activation fails", async () => {
|
|
H.mockActivateMembership.mockRejectedValue(new Error("Activation failed"));
|
|
H.mockGetNetworkMembers.mockResolvedValue({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 });
|
|
|
|
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
const activateBtn = await screen.findByRole("button", { name: "Activate" });
|
|
fireEvent.click(activateBtn);
|
|
|
|
// Confirm in dialog
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Set Activation Duration")).toBeDefined();
|
|
});
|
|
const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
|
|
fireEvent.click(dialogBtns[dialogBtns.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({
|
|
variant: "destructive",
|
|
title: "Failed to activate",
|
|
}));
|
|
});
|
|
});
|
|
|
|
test("shows error toast when deactivation fails", async () => {
|
|
H.mockDeactivateMembership.mockRejectedValue(new Error("Deactivation failed"));
|
|
H.mockGetNetworkMembers.mockResolvedValue({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 });
|
|
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" });
|
|
fireEvent.click(deactivateBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({
|
|
variant: "destructive",
|
|
title: "Failed to deactivate",
|
|
}));
|
|
});
|
|
});
|
|
|
|
test("shows ApiError description in error toast when activation fails with ApiError", async () => {
|
|
const apiError = new ApiError("Member not found", 404, "not_found");
|
|
H.mockActivateMembership.mockRejectedValue(apiError);
|
|
H.mockGetNetworkMembers.mockResolvedValue({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 });
|
|
|
|
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
const activateBtn = await screen.findByRole("button", { name: "Activate" });
|
|
fireEvent.click(activateBtn);
|
|
|
|
// Confirm in dialog
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Set Activation Duration")).toBeDefined();
|
|
});
|
|
const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
|
|
fireEvent.click(dialogBtns[dialogBtns.length - 1]);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({
|
|
variant: "destructive",
|
|
title: "Failed to activate",
|
|
description: "Member not found",
|
|
}));
|
|
});
|
|
});
|
|
|
|
test("refresh after activate fetches updated members", async () => {
|
|
H.mockActivateMembership.mockResolvedValue({});
|
|
const updatedMembership = {
|
|
...MEMBERSHIP_UNAUTHORIZED_NO_SESSION,
|
|
active: true,
|
|
status: "approved",
|
|
};
|
|
H.mockGetNetworkMembers
|
|
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_UNAUTHORIZED_NO_SESSION], count: 1 })
|
|
.mockResolvedValueOnce({ memberships: [updatedMembership], count: 1 });
|
|
|
|
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
const activateBtn = await screen.findByRole("button", { name: "Activate" });
|
|
fireEvent.click(activateBtn);
|
|
|
|
// Confirm in dialog
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Set Activation Duration")).toBeDefined();
|
|
});
|
|
const dialogBtns = screen.getAllByRole("button", { name: "Activate" });
|
|
fireEvent.click(dialogBtns[dialogBtns.length - 1]);
|
|
|
|
// Verify getNetworkMembers was called twice: once on mount, once on refresh
|
|
await waitFor(() => {
|
|
expect(H.mockGetNetworkMembers).toHaveBeenCalledTimes(2);
|
|
});
|
|
expect(H.mockGetNetworkMembers).toHaveBeenNthCalledWith(1, "org-1", "net-abc");
|
|
expect(H.mockGetNetworkMembers).toHaveBeenNthCalledWith(2, "org-1", "net-abc");
|
|
});
|
|
|
|
test("refresh after deactivate fetches updated members", async () => {
|
|
H.mockDeactivateMembership.mockResolvedValue({});
|
|
const updatedMembership = {
|
|
...MEMBERSHIP_AUTHORIZED_WITH_SESSION,
|
|
active: false,
|
|
status: "approved",
|
|
};
|
|
H.mockGetNetworkMembers
|
|
.mockResolvedValueOnce({ memberships: [MEMBERSHIP_AUTHORIZED_WITH_SESSION], count: 1 })
|
|
.mockResolvedValueOnce({ memberships: [updatedMembership], count: 1 });
|
|
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION]);
|
|
|
|
const userButton = screen.getByText("Alice Smith").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
const deactivateBtn = await screen.findByRole("button", { name: "Deactivate" });
|
|
fireEvent.click(deactivateBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockGetNetworkMembers).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
// ── Members badge count ─────────────────────────────────────────────────────
|
|
|
|
test("shows membership count in the Members tab badge", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION, MEMBERSHIP_SECOND_USER]);
|
|
|
|
expect(screen.getByText("3 memberships")).toBeDefined();
|
|
});
|
|
|
|
test("shows '0 memberships' when empty", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkMembers.mockResolvedValue({ memberships: [], count: 0 });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const membersTab = screen.getByRole("tab", { name: "Members" });
|
|
membersTab.focus();
|
|
fireEvent.keyDown(membersTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("0 memberships")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── Active device count in user section ─────────────────────────────────────
|
|
|
|
test("shows active device count in user section header", async () => {
|
|
await setupMembersTab([MEMBERSHIP_AUTHORIZED_WITH_SESSION, MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
// 1 active + 1 inactive → "1 active"
|
|
expect(screen.getByText(/^1 active$/)).toBeDefined();
|
|
});
|
|
|
|
test("does not show active count when no devices are authorized", async () => {
|
|
await setupMembersTab([MEMBERSHIP_UNAUTHORIZED_NO_SESSION]);
|
|
|
|
// 0 active devices → no "active" count in user section
|
|
expect(screen.queryByText(/^\d+ active$/)).toBeNull();
|
|
});
|
|
|
|
// ── Adversarial: Unicode user_ids ───────────────────────────────────────────
|
|
|
|
test("renders users with Unicode user_ids", async () => {
|
|
const unicodeMembership = {
|
|
...MEMBERSHIP_AUTHORIZED_WITH_SESSION,
|
|
id: "mem-uni-1",
|
|
user_id: "user-äéîøü-中文",
|
|
user_name: "Unicode User",
|
|
device_id: "dev-utf8",
|
|
device_name: null,
|
|
};
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkMembers.mockResolvedValue({ memberships: [unicodeMembership], count: 1 });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const membersTab = screen.getByRole("tab", { name: "Members" });
|
|
membersTab.focus();
|
|
fireEvent.keyDown(membersTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Unicode User")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── Adversarial: device_id with special characters ──────────────────────────
|
|
|
|
test("renders device_id with HTML-like content safely", async () => {
|
|
const xssMembership = {
|
|
...MEMBERSHIP_AUTHORIZED_WITH_SESSION,
|
|
id: "mem-xss-1",
|
|
user_id: "user-xss",
|
|
user_name: null,
|
|
device_id: "<script>alert(1)</script>",
|
|
device_name: null,
|
|
};
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkMembers.mockResolvedValue({ memberships: [xssMembership], count: 1 });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const membersTab = screen.getByRole("tab", { name: "Members" });
|
|
membersTab.focus();
|
|
fireEvent.keyDown(membersTab, { key: "Enter" });
|
|
|
|
// User section should be visible
|
|
const userButton = screen.getByText("user-xss").closest("button");
|
|
fireEvent.click(userButton!);
|
|
|
|
await waitFor(() => {
|
|
// The device_id should be rendered as text, not executed as HTML
|
|
expect(screen.getByText("<script>alert(1)</script>")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── Adversarial: large membership list ──────────────────────────────────────
|
|
|
|
test("renders many user sections without crashing", async () => {
|
|
const manyMemberships = Array.from({ length: 50 }, (_, i) => ({
|
|
...MEMBERSHIP_AUTHORIZED_WITH_SESSION,
|
|
id: `mem-many-${i}`,
|
|
user_id: `user-${i}`,
|
|
user_name: `User ${i}`,
|
|
user_email: `user${i}@test.com`,
|
|
device_id: `dev-${i}`,
|
|
device_name: `Device ${i}`,
|
|
active_session: null,
|
|
}));
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkMembers.mockResolvedValue({ memberships: manyMemberships, count: 50 });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const membersTab = screen.getByRole("tab", { name: "Members" });
|
|
membersTab.focus();
|
|
fireEvent.keyDown(membersTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("50 memberships")).toBeDefined();
|
|
});
|
|
|
|
// Should find at least the first and last user
|
|
expect(screen.getByText("User 0")).toBeDefined();
|
|
expect(screen.getByText("User 49")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── REQUESTS TAB ─────────────────────────────────────────────────────────────
|
|
|
|
describe("Requests tab", () => {
|
|
// ── Fixtures ────────────────────────────────────────────────────────────────
|
|
|
|
const PENDING_REQUEST = {
|
|
id: "req-pending-1",
|
|
organization_id: "org-1",
|
|
user_id: "user-requestor",
|
|
user_name: "Requestor User",
|
|
user_email: "requestor@test.com",
|
|
portal_network_id: "net-abc",
|
|
granted_by_user_id: null,
|
|
grant_type: "requested" as const,
|
|
state: "pending" as const,
|
|
justification: "Need access for development work.",
|
|
created_at: "2025-06-01T10:00:00Z",
|
|
updated_at: "2025-06-01T10:00:00Z",
|
|
deleted_at: null,
|
|
};
|
|
|
|
const APPROVED_REQUEST = {
|
|
id: "req-approved-1",
|
|
organization_id: "org-1",
|
|
user_id: "user-approved",
|
|
user_name: "Approved User",
|
|
user_email: "approved@test.com",
|
|
portal_network_id: "net-abc",
|
|
granted_by_user_id: "user-admin",
|
|
grant_type: "assigned" as const,
|
|
state: "approved" as const,
|
|
justification: null,
|
|
created_at: "2025-05-15T08:00:00Z",
|
|
updated_at: "2025-05-15T09:00:00Z",
|
|
deleted_at: null,
|
|
};
|
|
|
|
const REJECTED_REQUEST = {
|
|
id: "req-rejected-1",
|
|
organization_id: "org-1",
|
|
user_id: "user-rejected",
|
|
user_name: "Rejected User",
|
|
user_email: "rejected@test.com",
|
|
portal_network_id: "net-abc",
|
|
granted_by_user_id: null,
|
|
grant_type: "requested" as const,
|
|
state: "rejected" as const,
|
|
justification: "This user should not have access.",
|
|
created_at: "2025-04-20T12:00:00Z",
|
|
updated_at: "2025-04-21T12:00:00Z",
|
|
deleted_at: null,
|
|
};
|
|
|
|
const REVOKED_REQUEST = {
|
|
id: "req-revoked-1",
|
|
organization_id: "org-1",
|
|
user_id: "user-revoked",
|
|
portal_network_id: "net-abc",
|
|
granted_by_user_id: "user-admin",
|
|
grant_type: "assigned" as const,
|
|
state: "revoked" as const,
|
|
justification: null,
|
|
created_at: "2025-03-01T00:00:00Z",
|
|
updated_at: "2025-03-15T00:00:00Z",
|
|
deleted_at: null,
|
|
};
|
|
|
|
const SUSPENDED_REQUEST = {
|
|
id: "req-suspended-1",
|
|
organization_id: "org-1",
|
|
user_id: "user-suspended",
|
|
portal_network_id: "net-abc",
|
|
granted_by_user_id: "user-admin",
|
|
grant_type: "assigned" as const,
|
|
state: "suspended" as const,
|
|
justification: "Policy violation review.",
|
|
created_at: "2025-02-10T00:00:00Z",
|
|
updated_at: "2025-02-12T00:00:00Z",
|
|
deleted_at: null,
|
|
};
|
|
|
|
// Helper: setup resolved network, then click Requests tab
|
|
async function setupRequestsTab(requestsList: Array<Record<string, unknown>> = [PENDING_REQUEST]) {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: requestsList, count: requestsList.length });
|
|
|
|
renderWithRoute();
|
|
|
|
// Wait for the page to render (network resolves first)
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
// Activate Requests tab via keyboard (Radix uses focus + Enter)
|
|
const requestsTab = screen.getByRole("tab", { name: "Requests" });
|
|
requestsTab.focus();
|
|
fireEvent.keyDown(requestsTab, { key: "Enter" });
|
|
|
|
// Wait for requests content
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Access Requests")).toBeDefined();
|
|
});
|
|
}
|
|
|
|
// ── Loading / Error / Empty ─────────────────────────────────────────────────
|
|
|
|
test("renders loading spinner in Requests tab while fetching requests", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
// getNetworkPendingRequests stays pending (never resolves)
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const requestsTab = screen.getByRole("tab", { name: "Requests" });
|
|
requestsTab.focus();
|
|
fireEvent.keyDown(requestsTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Access Requests")).toBeDefined();
|
|
});
|
|
|
|
expect(screen.getByText("Loading requests…")).toBeDefined();
|
|
});
|
|
|
|
test("renders error message when requests API fails", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkPendingRequests.mockRejectedValue(new Error("API unavailable"));
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const requestsTab = screen.getByRole("tab", { name: "Requests" });
|
|
requestsTab.focus();
|
|
fireEvent.keyDown(requestsTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Failed to load requests.")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders ApiError message when requests API throws ApiError", async () => {
|
|
const apiError = new ApiError("Pending request lookup failed", 500, "internal_error");
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkPendingRequests.mockRejectedValue(apiError);
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const requestsTab = screen.getByRole("tab", { name: "Requests" });
|
|
requestsTab.focus();
|
|
fireEvent.keyDown(requestsTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Pending request lookup failed")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders 'No pending requests' message when requests array is empty", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [], count: 0 });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const requestsTab = screen.getByRole("tab", { name: "Requests" });
|
|
requestsTab.focus();
|
|
fireEvent.keyDown(requestsTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("No pending requests for this network.")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
test("renders 'No pending requests' when requests field is undefined", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkPendingRequests.mockResolvedValue({ count: 0 });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const requestsTab = screen.getByRole("tab", { name: "Requests" });
|
|
requestsTab.focus();
|
|
fireEvent.keyDown(requestsTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("No pending requests for this network.")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── Request list display ────────────────────────────────────────────────────
|
|
|
|
test("displays user_name when available", async () => {
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
expect(screen.getByText("Requestor User")).toBeDefined();
|
|
});
|
|
|
|
test("displays grant_type badge", async () => {
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
expect(screen.getByText("requested")).toBeDefined();
|
|
});
|
|
|
|
test("displays grant_type badge", async () => {
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
expect(screen.getByText("requested")).toBeDefined();
|
|
});
|
|
|
|
test("displays justification in quotes when present", async () => {
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
expect(screen.getByText('"Need access for development work."')).toBeDefined();
|
|
});
|
|
|
|
test("does NOT display justification when null", async () => {
|
|
await setupRequestsTab([APPROVED_REQUEST]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Approved User")).toBeDefined();
|
|
});
|
|
|
|
expect(screen.queryByText(/"/)).toBeNull();
|
|
});
|
|
|
|
test("displays created_at formatted date", async () => {
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
// "Requested:" is part of a larger text node like "Requested: Jun 1, 2025"
|
|
const requestedEl = screen.getByText(/^Requested: /);
|
|
expect(requestedEl).toBeDefined();
|
|
// The element should contain text beyond just "Requested:"
|
|
expect(requestedEl.textContent?.length).toBeGreaterThan("Requested:".length);
|
|
// Verify it is NOT the em-dash fallback (which would mean formatDate failed)
|
|
expect(requestedEl.textContent).not.toBe("Requested: —");
|
|
});
|
|
|
|
// ── ApprovalStateBadge colors ────────────────────────────────────────────────
|
|
|
|
test("renders Pending badge with yellow colors for pending state", async () => {
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
const badge = screen.getByText("Pending");
|
|
expect(badge).toBeDefined();
|
|
expect(badge.className).toContain("bg-yellow-100");
|
|
expect(badge.className).toContain("text-yellow-700");
|
|
});
|
|
|
|
test("renders Approved badge with green colors for approved state", async () => {
|
|
await setupRequestsTab([APPROVED_REQUEST]);
|
|
|
|
const badge = screen.getByText("Approved");
|
|
expect(badge).toBeDefined();
|
|
expect(badge.className).toContain("bg-green-100");
|
|
expect(badge.className).toContain("text-green-700");
|
|
});
|
|
|
|
test("renders Rejected badge with red colors for rejected state", async () => {
|
|
await setupRequestsTab([REJECTED_REQUEST]);
|
|
|
|
const badge = screen.getByText("Rejected");
|
|
expect(badge).toBeDefined();
|
|
expect(badge.className).toContain("bg-red-100");
|
|
expect(badge.className).toContain("text-red-700");
|
|
});
|
|
|
|
test("renders Revoked badge with red colors for revoked state", async () => {
|
|
await setupRequestsTab([REVOKED_REQUEST]);
|
|
|
|
const badge = screen.getByText("Revoked");
|
|
expect(badge).toBeDefined();
|
|
expect(badge.className).toContain("bg-red-100");
|
|
expect(badge.className).toContain("text-red-700");
|
|
});
|
|
|
|
test("renders Suspended badge with orange colors for suspended state", async () => {
|
|
await setupRequestsTab([SUSPENDED_REQUEST]);
|
|
|
|
const badge = screen.getByText("Suspended");
|
|
expect(badge).toBeDefined();
|
|
expect(badge.className).toContain("bg-orange-100");
|
|
expect(badge.className).toContain("text-orange-700");
|
|
});
|
|
|
|
// ── Approve / Reject button visibility ───────────────────────────────────────
|
|
|
|
test("shows Approve and Reject buttons for pending state request", async () => {
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
expect(screen.getByRole("button", { name: "Approve" })).toBeDefined();
|
|
expect(screen.getByRole("button", { name: "Reject" })).toBeDefined();
|
|
});
|
|
|
|
test("does NOT show action buttons for approved state request", async () => {
|
|
await setupRequestsTab([APPROVED_REQUEST]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Approved")).toBeDefined();
|
|
});
|
|
|
|
expect(screen.queryByRole("button", { name: "Approve" })).toBeNull();
|
|
expect(screen.queryByRole("button", { name: "Reject" })).toBeNull();
|
|
});
|
|
|
|
test("does NOT show action buttons for rejected state request", async () => {
|
|
await setupRequestsTab([REJECTED_REQUEST]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Rejected")).toBeDefined();
|
|
});
|
|
|
|
expect(screen.queryByRole("button", { name: "Approve" })).toBeNull();
|
|
expect(screen.queryByRole("button", { name: "Reject" })).toBeNull();
|
|
});
|
|
|
|
test("does NOT show action buttons for revoked state request", async () => {
|
|
await setupRequestsTab([REVOKED_REQUEST]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Revoked")).toBeDefined();
|
|
});
|
|
|
|
expect(screen.queryByRole("button", { name: "Approve" })).toBeNull();
|
|
expect(screen.queryByRole("button", { name: "Reject" })).toBeNull();
|
|
});
|
|
|
|
test("does NOT show action buttons for suspended state request", async () => {
|
|
await setupRequestsTab([SUSPENDED_REQUEST]);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Suspended")).toBeDefined();
|
|
});
|
|
|
|
expect(screen.queryByRole("button", { name: "Approve" })).toBeNull();
|
|
expect(screen.queryByRole("button", { name: "Reject" })).toBeNull();
|
|
});
|
|
|
|
// ── Approve action ───────────────────────────────────────────────────────────
|
|
|
|
test("clicking Approve calls api.zerotier.approveRequest with correct orgId and approvalId", async () => {
|
|
H.mockApproveRequest.mockResolvedValue({});
|
|
// Second call to getNetworkPendingRequests (refresh after approve)
|
|
H.mockGetNetworkPendingRequests
|
|
.mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 })
|
|
.mockResolvedValueOnce({ requests: [], count: 0 });
|
|
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
const approveBtn = screen.getByRole("button", { name: "Approve" });
|
|
fireEvent.click(approveBtn);
|
|
|
|
expect(H.mockApproveRequest).toHaveBeenCalledTimes(1);
|
|
expect(H.mockApproveRequest).toHaveBeenCalledWith("org-1", "req-pending-1");
|
|
});
|
|
|
|
test("shows success toast after successful approval", async () => {
|
|
H.mockApproveRequest.mockResolvedValue({});
|
|
H.mockGetNetworkPendingRequests
|
|
.mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 })
|
|
.mockResolvedValueOnce({ requests: [], count: 0 });
|
|
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
const approveBtn = screen.getByRole("button", { name: "Approve" });
|
|
fireEvent.click(approveBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Request approved" }));
|
|
});
|
|
});
|
|
|
|
test("shows error toast when approval fails", async () => {
|
|
H.mockApproveRequest.mockRejectedValue(new Error("Approval failed"));
|
|
H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [PENDING_REQUEST], count: 1 });
|
|
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
const approveBtn = screen.getByRole("button", { name: "Approve" });
|
|
fireEvent.click(approveBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({
|
|
variant: "destructive",
|
|
title: "Failed to approve",
|
|
}));
|
|
});
|
|
});
|
|
|
|
test("shows ApiError description in error toast when approval fails with ApiError", async () => {
|
|
const apiError = new ApiError("Request not found", 404, "not_found");
|
|
H.mockApproveRequest.mockRejectedValue(apiError);
|
|
H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [PENDING_REQUEST], count: 1 });
|
|
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
const approveBtn = screen.getByRole("button", { name: "Approve" });
|
|
fireEvent.click(approveBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({
|
|
variant: "destructive",
|
|
title: "Failed to approve",
|
|
description: "Request not found",
|
|
}));
|
|
});
|
|
});
|
|
|
|
test("refresh after approve fetches updated requests", async () => {
|
|
H.mockApproveRequest.mockResolvedValue({});
|
|
H.mockGetNetworkPendingRequests
|
|
.mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 })
|
|
.mockResolvedValueOnce({ requests: [], count: 0 });
|
|
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
const approveBtn = screen.getByRole("button", { name: "Approve" });
|
|
fireEvent.click(approveBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockGetNetworkPendingRequests).toHaveBeenCalledTimes(2);
|
|
});
|
|
expect(H.mockGetNetworkPendingRequests).toHaveBeenNthCalledWith(1, "org-1", "net-abc");
|
|
expect(H.mockGetNetworkPendingRequests).toHaveBeenNthCalledWith(2, "org-1", "net-abc");
|
|
});
|
|
|
|
// ── Reject action ────────────────────────────────────────────────────────────
|
|
|
|
test("clicking Reject calls api.zerotier.rejectRequest with correct orgId and approvalId", async () => {
|
|
H.mockRejectRequest.mockResolvedValue({});
|
|
H.mockGetNetworkPendingRequests
|
|
.mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 })
|
|
.mockResolvedValueOnce({ requests: [], count: 0 });
|
|
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
const rejectBtn = screen.getByRole("button", { name: "Reject" });
|
|
fireEvent.click(rejectBtn);
|
|
|
|
expect(H.mockRejectRequest).toHaveBeenCalledTimes(1);
|
|
expect(H.mockRejectRequest).toHaveBeenCalledWith("org-1", "req-pending-1");
|
|
});
|
|
|
|
test("shows success toast after successful rejection", async () => {
|
|
H.mockRejectRequest.mockResolvedValue({});
|
|
H.mockGetNetworkPendingRequests
|
|
.mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 })
|
|
.mockResolvedValueOnce({ requests: [], count: 0 });
|
|
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
const rejectBtn = screen.getByRole("button", { name: "Reject" });
|
|
fireEvent.click(rejectBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: "Request rejected" }));
|
|
});
|
|
});
|
|
|
|
test("shows error toast when rejection fails", async () => {
|
|
H.mockRejectRequest.mockRejectedValue(new Error("Rejection failed"));
|
|
H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [PENDING_REQUEST], count: 1 });
|
|
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
const rejectBtn = screen.getByRole("button", { name: "Reject" });
|
|
fireEvent.click(rejectBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({
|
|
variant: "destructive",
|
|
title: "Failed to reject",
|
|
}));
|
|
});
|
|
});
|
|
|
|
test("shows ApiError description in error toast when rejection fails with ApiError", async () => {
|
|
const apiError = new ApiError("Request already processed", 409, "conflict");
|
|
H.mockRejectRequest.mockRejectedValue(apiError);
|
|
H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [PENDING_REQUEST], count: 1 });
|
|
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
const rejectBtn = screen.getByRole("button", { name: "Reject" });
|
|
fireEvent.click(rejectBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockToast).toHaveBeenCalledWith(expect.objectContaining({
|
|
variant: "destructive",
|
|
title: "Failed to reject",
|
|
description: "Request already processed",
|
|
}));
|
|
});
|
|
});
|
|
|
|
test("refresh after reject fetches updated requests", async () => {
|
|
H.mockRejectRequest.mockResolvedValue({});
|
|
H.mockGetNetworkPendingRequests
|
|
.mockResolvedValueOnce({ requests: [PENDING_REQUEST], count: 1 })
|
|
.mockResolvedValueOnce({ requests: [], count: 0 });
|
|
|
|
await setupRequestsTab([PENDING_REQUEST]);
|
|
|
|
const rejectBtn = screen.getByRole("button", { name: "Reject" });
|
|
fireEvent.click(rejectBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(H.mockGetNetworkPendingRequests).toHaveBeenCalledTimes(2);
|
|
});
|
|
expect(H.mockGetNetworkPendingRequests).toHaveBeenNthCalledWith(1, "org-1", "net-abc");
|
|
expect(H.mockGetNetworkPendingRequests).toHaveBeenNthCalledWith(2, "org-1", "net-abc");
|
|
});
|
|
|
|
// ── Requests badge count ─────────────────────────────────────────────────────
|
|
|
|
test("shows request count in the Requests tab badge", async () => {
|
|
await setupRequestsTab([PENDING_REQUEST, APPROVED_REQUEST, REJECTED_REQUEST]);
|
|
|
|
expect(screen.getByText("3 pending")).toBeDefined();
|
|
});
|
|
|
|
test("shows '0 pending' when empty", async () => {
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: [], count: 0 });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const requestsTab = screen.getByRole("tab", { name: "Requests" });
|
|
requestsTab.focus();
|
|
fireEvent.keyDown(requestsTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("0 pending")).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ── Multiple requests display ────────────────────────────────────────────────
|
|
|
|
test("renders multiple requests in the list", async () => {
|
|
await setupRequestsTab([PENDING_REQUEST, APPROVED_REQUEST]);
|
|
|
|
expect(screen.getByText("Requestor User")).toBeDefined();
|
|
expect(screen.getByText("Approved User")).toBeDefined();
|
|
});
|
|
|
|
test("mixed list: only pending requests have action buttons", async () => {
|
|
await setupRequestsTab([PENDING_REQUEST, APPROVED_REQUEST]);
|
|
|
|
// Requestor User (pending) should have action buttons
|
|
// Approved User (approved) should not
|
|
const approveButtons = screen.getAllByRole("button", { name: "Approve" });
|
|
expect(approveButtons.length).toBe(1);
|
|
|
|
const rejectButtons = screen.getAllByRole("button", { name: "Reject" });
|
|
expect(rejectButtons.length).toBe(1);
|
|
});
|
|
|
|
// ── Adversarial: Unicode user_ids in requests ───────────────────────────────
|
|
|
|
test("renders requests with Unicode user_ids", async () => {
|
|
const unicodeRequest = {
|
|
...PENDING_REQUEST,
|
|
id: "req-uni-1",
|
|
user_id: "user-äéîøü-中文-requestor",
|
|
user_name: "Unicode Requestor",
|
|
};
|
|
await setupRequestsTab([unicodeRequest]);
|
|
|
|
expect(screen.getByText("Unicode Requestor")).toBeDefined();
|
|
});
|
|
|
|
// ── Adversarial: XSS-safe justification ──────────────────────────────────────
|
|
|
|
test("renders justification with script tags safely", async () => {
|
|
const xssRequest = {
|
|
...PENDING_REQUEST,
|
|
id: "req-xss-1",
|
|
user_id: "user-xss-req",
|
|
justification: "<script>alert(1)</script>",
|
|
};
|
|
await setupRequestsTab([xssRequest]);
|
|
|
|
expect(screen.getByText('"<script>alert(1)</script>"')).toBeDefined();
|
|
});
|
|
|
|
// ── Adversarial: many requests ───────────────────────────────────────────────
|
|
|
|
test("renders many requests without crashing", async () => {
|
|
const manyRequests = Array.from({ length: 50 }, (_, i) => ({
|
|
...PENDING_REQUEST,
|
|
id: `req-many-${i}`,
|
|
user_id: `user-req-${i}`,
|
|
user_name: `Requestor ${i}`,
|
|
}));
|
|
H.mockGetNetwork.mockResolvedValue({ network: DEV_NETWORK });
|
|
H.mockGetNetworkPendingRequests.mockResolvedValue({ requests: manyRequests, count: 50 });
|
|
|
|
renderWithRoute();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Dev Network")).toBeDefined();
|
|
});
|
|
|
|
const requestsTab = screen.getByRole("tab", { name: "Requests" });
|
|
requestsTab.focus();
|
|
fireEvent.keyDown(requestsTab, { key: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText("50 pending")).toBeDefined();
|
|
});
|
|
|
|
expect(screen.getByText("Requestor 0")).toBeDefined();
|
|
expect(screen.getByText("Requestor 49")).toBeDefined();
|
|
});
|
|
});
|
|
});
|