Files
gatehouse-ui/tests/NetworksPage.test.tsx
T

679 lines
21 KiB
TypeScript

// @vitest-environment jsdom
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, within, fireEvent, cleanup } from "@testing-library/react";
import React from "react";
import { MemoryRouter } from "react-router-dom";
// ── Shared mock state (vi.hoisted avoids TDZ with vi.mock hoisting) ────────────
const H = vi.hoisted(() => ({
mockNavigate: vi.fn(),
mockListNetworks: vi.fn(),
mockListAvailableZtNetworks: vi.fn(),
mockToast: vi.fn(),
state: {
orgId: "org-test-123" as string | null,
},
navigateCalls: [] as string[],
}));
vi.mock("react-router-dom", async () => {
const actual = await vi.importActual("react-router-dom");
return {
...actual,
useNavigate: () => {
const fn = (path: string) => {
H.navigateCalls.push(path);
H.mockNavigate(path);
};
return fn;
},
useParams: () => ({ orgId: H.state.orgId }),
};
});
vi.mock("@/hooks/useCurrentOrganization", () => ({
useCurrentOrganizationId: () => ({
orgId: H.state.orgId,
isLoading: false,
}),
useCurrentOrganization: () => ({
org: {
id: H.state.orgId,
name: "Test Org",
slug: "test-org",
description: null,
logo_url: null,
is_active: true,
role: "admin",
created_at: "2024-01-01",
updated_at: "2024-01-01",
},
isLoading: false,
}),
}));
vi.mock("@/hooks/use-toast", () => ({
useToast: () => ({
toast: H.mockToast,
dismiss: () => {},
toasts: [],
}),
}));
vi.mock("@/lib/api", () => ({
api: {
zerotier: {
listNetworks: H.mockListNetworks,
listAvailableZtNetworks: H.mockListAvailableZtNetworks,
createNetwork: vi.fn(),
updateNetwork: vi.fn(),
deleteNetwork: vi.fn(),
},
},
ApiError: class ApiError extends Error {
code: number;
type: string;
details: Record<string, unknown>;
constructor(message: string, code: number, type: string, details: Record<string, unknown> = {}) {
super(message);
this.name = "ApiError";
this.code = code;
this.type = type;
this.details = details;
}
},
}));
import NetworksPage from "../src/pages/org/NetworksPage";
// ── Test data ──────────────────────────────────────────────────────────────────
const MOCK_NETWORKS = [
{
id: "net-001",
organization_id: "org-test-123",
name: "Production VPN",
description: "Main production network",
owner_user_id: "user-1",
zerotier_network_id: "d6578dd03c894448",
environment: "production" as const,
request_mode: "approval_required" as const,
default_activation_lifetime_minutes: 480,
max_activation_lifetime_minutes: null,
is_active: true,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
deleted_at: null,
approved_user_count: 25,
active_membership_count: 12,
},
{
id: "net-002",
organization_id: "org-test-123",
name: "Dev Network",
description: "Development and staging",
owner_user_id: "user-1",
zerotier_network_id: "abcdef1234567890",
environment: "development" as const,
request_mode: "open" as const,
default_activation_lifetime_minutes: 240,
max_activation_lifetime_minutes: 1440,
is_active: false,
created_at: "2024-01-02T00:00:00Z",
updated_at: "2024-01-02T00:00:00Z",
deleted_at: null,
approved_user_count: 5,
active_membership_count: 0,
},
];
const MOCK_ZT_NETWORKS = [
{
id: "zt-net-001",
name: "External ZeroTier",
description: "An external ZT network",
owner_id: null,
online_member_count: 3,
authorized_member_count: 10,
total_member_count: 10,
already_managed: false,
portal_network_id: null,
portal_network_name: null,
},
];
// ── Helpers ────────────────────────────────────────────────────────────────────
function renderPage() {
return render(
<MemoryRouter>
<NetworksPage />
</MemoryRouter>,
);
}
// Default: all API calls return never-resolving promise (loading state)
// Individual tests override BEFORE calling renderPage().
beforeEach(() => {
vi.clearAllMocks();
H.navigateCalls.length = 0;
H.mockListNetworks.mockImplementation(() => new Promise(() => {}));
H.mockListAvailableZtNetworks.mockImplementation(() => new Promise(() => {}));
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ═══════════════════════════════════════════════════════════════════════════════
// HAPPY PATH: Data Loading
// ═══════════════════════════════════════════════════════════════════════════════
describe("NetworksPage — Data Loading", () => {
test("renders loading state while fetching networks", () => {
renderPage();
expect(screen.getByText("Loading networks…")).toBeDefined();
});
test("renders network data when API resolves", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
expect(screen.getByText("Dev Network")).toBeDefined();
expect(screen.getByText("d6578dd03c894448")).toBeDefined();
expect(screen.getByText("abcdef1234567890")).toBeDefined();
});
test("renders error state when API fails", async () => {
H.mockListNetworks.mockRejectedValue(new Error("Network error"));
renderPage();
await waitFor(() => {
expect(
screen.getByText("Failed to load networks. Please try again."),
).toBeDefined();
});
});
test("renders empty state when no networks exist", async () => {
H.mockListNetworks.mockResolvedValue({ networks: [], count: 0 });
renderPage();
await waitFor(() => {
expect(
screen.getByText("No networks configured yet. Add one to get started."),
).toBeDefined();
});
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// NAVIGATION: Row Click
// ═══════════════════════════════════════════════════════════════════════════════
describe("NetworksPage — Row Click Navigation", () => {
test("clicking a network row navigates to /org/zerotier/networks/{networkId}", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
const productionRow = screen.getByText("Production VPN").closest("button");
expect(productionRow).not.toBeNull();
fireEvent.click(productionRow!);
expect(H.mockNavigate).toHaveBeenCalledTimes(1);
expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-001");
expect(H.navigateCalls).toEqual(["/org/zerotier/networks/net-001"]);
});
test("clicking second network row navigates to its URL", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Dev Network")).toBeDefined();
});
const devRow = screen.getByText("Dev Network").closest("button");
expect(devRow).not.toBeNull();
fireEvent.click(devRow!);
expect(H.mockNavigate).toHaveBeenCalledTimes(1);
expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-002");
});
test("navigate NOT called before any click", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
expect(H.mockNavigate).not.toHaveBeenCalled();
expect(H.navigateCalls).toEqual([]);
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// NAVIGATION: Dropdown "View details"
// ═══════════════════════════════════════════════════════════════════════════════
describe("NetworksPage — Dropdown View details Navigation", () => {
test('"View details" dropdown item navigates to /org/zerotier/networks/{networkId}', async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
// The MoreHorizontal button is a CHILD of the row button (nested inside it).
// Find it by looking for the button with the MoreHorizontal icon inside the row.
const productionRow = screen.getByText("Production VPN").closest("button")!;
// Find ALL nested buttons within the row
const nestedButtons = productionRow.querySelectorAll("button");
// The first nested button should be the MoreHorizontal dropdown trigger
expect(nestedButtons.length).toBeGreaterThan(0);
// Radix DropdownMenu opens on pointerdown
fireEvent.pointerDown(nestedButtons[0]);
// DropdownMenuContent renders in a portal, screen.getByText searches the whole document
await waitFor(() => {
expect(screen.getByText("View details")).toBeDefined();
});
fireEvent.click(screen.getByText("View details"));
expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-001");
});
test('"View details" for second network navigates to its URL', async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Dev Network")).toBeDefined();
});
const devRow = screen.getByText("Dev Network").closest("button")!;
const nestedButtons = devRow.querySelectorAll("button");
expect(nestedButtons.length).toBeGreaterThan(0);
fireEvent.pointerDown(nestedButtons[0]);
await waitFor(() => {
expect(screen.getByText("View details")).toBeDefined();
});
fireEvent.click(screen.getByText("View details"));
expect(H.mockNavigate).toHaveBeenCalledWith("/org/zerotier/networks/net-002");
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// CARD DESCRIPTION TEXT
// ═══════════════════════════════════════════════════════════════════════════════
describe("NetworksPage — Card Description", () => {
test("CardDescription reflects page navigation (not old drawer text)", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
const description = screen.getByText(
"Click a network to manage members, devices, and access requests",
);
expect(description).toBeDefined();
});
test("old drawer-related text is absent", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
// The old Sheet content (Network Details, member list) should NOT be present
expect(screen.queryByText("Network Details")).toBeNull();
expect(screen.queryByText("Members")).toBeNull();
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// ZERO TIER NETWORK PICKER SHEET
// ═══════════════════════════════════════════════════════════════════════════════
describe("NetworksPage — ZeroTier Picker Sheet", () => {
test('"Import from ZeroTier" button is present', async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
const importButton = screen.getByRole("button", {
name: /import from zerotier/i,
});
expect(importButton).toBeDefined();
});
test('clicking "Import from ZeroTier" opens the ZT Picker Sheet', async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
H.mockListAvailableZtNetworks.mockResolvedValue({
networks: MOCK_ZT_NETWORKS,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
const importButton = screen.getByRole("button", {
name: /import from zerotier/i,
});
fireEvent.click(importButton);
// Wait for the Sheet to render with its content
await waitFor(() => {
expect(screen.getByText("External ZeroTier")).toBeDefined();
});
expect(screen.getByText("zt-net-001")).toBeDefined();
expect(screen.getByText("Import")).toBeDefined();
});
test("ZT Picker calls API with correct orgId", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
H.mockListAvailableZtNetworks.mockResolvedValue({
networks: MOCK_ZT_NETWORKS,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
const importButton = screen.getByRole("button", {
name: /import from zerotier/i,
});
fireEvent.click(importButton);
await waitFor(() => {
expect(screen.getByText("External ZeroTier")).toBeDefined();
});
expect(H.mockListAvailableZtNetworks).toHaveBeenCalledTimes(1);
expect(H.mockListAvailableZtNetworks).toHaveBeenCalledWith("org-test-123");
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// DATA DISPLAY
// ═══════════════════════════════════════════════════════════════════════════════
describe("NetworksPage — Data Display", () => {
test("displays network count badge", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
// The CardTitle contains "Portal Networks" and the count badge
expect(screen.getByText("Portal Networks")).toBeDefined();
// Badge with count "2" should be present
expect(screen.getByText("2")).toBeDefined();
});
test("displays approved user counts", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
expect(screen.getByText("25")).toBeDefined();
expect(screen.getByText("5")).toBeDefined();
});
test("displays active device counts", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
expect(screen.getByText("12")).toBeDefined();
expect(screen.getByText("0")).toBeDefined();
});
test("displays environment badges", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
expect(screen.getByText("Production")).toBeDefined();
expect(screen.getByText("Development")).toBeDefined();
});
test("displays request mode badges", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
expect(screen.getByText("Approval Required")).toBeDefined();
expect(screen.getByText("Open")).toBeDefined();
});
test('displays "Inactive" badge for inactive networks', async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Dev Network")).toBeDefined();
});
expect(screen.getByText("Inactive")).toBeDefined();
});
test("renders search-empty state when filter matches nothing", async () => {
H.mockListNetworks.mockResolvedValue({
networks: MOCK_NETWORKS,
count: MOCK_NETWORKS.length,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
const searchInput = screen.getByPlaceholderText("Search networks…");
fireEvent.change(searchInput, { target: { value: "zzzz_nonexistent" } });
await waitFor(() => {
expect(
screen.getByText("No networks match your search."),
).toBeDefined();
});
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// EDGE CASES / ADVERSARIAL INPUTS
// ═══════════════════════════════════════════════════════════════════════════════
describe("NetworksPage — Adversarial Inputs", () => {
test("handles XSS-like network name as text", async () => {
H.mockListNetworks.mockResolvedValue({
networks: [
{
...MOCK_NETWORKS[0],
name: 'VPN <script>alert("xss")</script>',
description: "desc ${injection}",
zerotier_network_id: "../../etc/passwd",
},
],
count: 1,
});
renderPage();
await waitFor(() => {
expect(
screen.getByText('VPN <script>alert("xss")</script>'),
).toBeDefined();
});
});
test("handles very long network name", async () => {
const longName = "A".repeat(500);
H.mockListNetworks.mockResolvedValue({
networks: [{ ...MOCK_NETWORKS[0], name: longName, id: "net-long" }],
count: 1,
});
renderPage();
await waitFor(() => {
expect(screen.getByText(longName)).toBeDefined();
});
});
test("handles Unicode network name", async () => {
const unicodeName = "ネットワーク \u{1F525} 测试";
H.mockListNetworks.mockResolvedValue({
networks: [
{ ...MOCK_NETWORKS[0], name: unicodeName, id: "net-unicode" },
],
count: 1,
});
renderPage();
await waitFor(() => {
expect(screen.getByText(unicodeName)).toBeDefined();
});
});
test("handles missing optional counts (undefined)", async () => {
H.mockListNetworks.mockResolvedValue({
networks: [
{
...MOCK_NETWORKS[0],
approved_user_count: undefined,
active_membership_count: undefined,
},
],
count: 1,
});
renderPage();
await waitFor(() => {
expect(screen.getByText("Production VPN")).toBeDefined();
});
// Should show "0" for undefined counts (nullish coalescing: ?? 0)
const zeros = screen.getAllByText("0");
expect(zeros.length).toBeGreaterThanOrEqual(2);
});
});