679 lines
21 KiB
TypeScript
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);
|
|
});
|
|
});
|