/** * Tests for the `BannedIpsSection` component. * * Verifies: * - Renders the section header and total count badge. * - Shows a spinner while loading. * - Renders a table with IP rows on success. * - Shows an empty-state message when there are no banned IPs. * - Displays an error message bar when the API call fails. * - Search input re-fetches with the search parameter after debounce. * - Unban button calls `unbanIp` and refreshes the list. * - Pagination buttons are shown and change the page. */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor, act, fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { FluentProvider, webLightTheme } from "@fluentui/react-components"; import { BannedIpsSection } from "../BannedIpsSection"; import type { JailBannedIpsResponse } from "../../../types/jail"; // --------------------------------------------------------------------------- // Module mocks // --------------------------------------------------------------------------- const { mockFetchJailBannedIps, mockUnbanIp } = vi.hoisted(() => ({ mockFetchJailBannedIps: vi.fn< ( jailName: string, page?: number, pageSize?: number, search?: string, ) => Promise >(), mockUnbanIp: vi.fn< (ip: string, jail?: string) => Promise<{ message: string; jail: string }> >(), })); vi.mock("../../../api/jails", () => ({ fetchJailBannedIps: mockFetchJailBannedIps, unbanIp: mockUnbanIp, })); // --------------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------------- function makeBan(ip: string) { return { ip, jail: "sshd", banned_at: "2025-01-01T10:00:00+00:00", expires_at: "2025-01-01T10:10:00+00:00", ban_count: 1, country: "US", }; } function makeResponse( ips: string[] = ["1.2.3.4", "5.6.7.8"], total = 2, ): JailBannedIpsResponse { return { items: ips.map(makeBan), total, page: 1, page_size: 25, }; } const EMPTY_RESPONSE: JailBannedIpsResponse = { items: [], total: 0, page: 1, page_size: 25, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function renderSection(jailName = "sshd") { return render( , ); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("BannedIpsSection", () => { beforeEach(() => { vi.clearAllMocks(); vi.useRealTimers(); mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" }); }); it("renders section header with 'Currently Banned IPs' title", async () => { mockFetchJailBannedIps.mockResolvedValue(makeResponse()); renderSection(); await waitFor(() => { expect(screen.getByText("Currently Banned IPs")).toBeTruthy(); }); }); it("shows the total count badge", async () => { mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"], 2)); renderSection(); await waitFor(() => { expect(screen.getByText("2")).toBeTruthy(); }); }); it("shows a spinner while loading", () => { // Never resolves during this test so we see the spinner. mockFetchJailBannedIps.mockReturnValue(new Promise(() => void 0)); renderSection(); expect(screen.getByText("Loading banned IPs…")).toBeTruthy(); }); it("renders IP rows when banned IPs exist", async () => { mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4", "5.6.7.8"])); renderSection(); await waitFor(() => { expect(screen.getByText("1.2.3.4")).toBeTruthy(); expect(screen.getByText("5.6.7.8")).toBeTruthy(); }); }); it("shows empty-state message when no IPs are banned", async () => { mockFetchJailBannedIps.mockResolvedValue(EMPTY_RESPONSE); renderSection(); await waitFor(() => { expect( screen.getByText("No IPs currently banned in this jail."), ).toBeTruthy(); }); }); it("shows an error message bar on API failure", async () => { mockFetchJailBannedIps.mockRejectedValue(new Error("socket dead")); renderSection(); await waitFor(() => { expect(screen.getByText(/socket dead/i)).toBeTruthy(); }); }); it("calls fetchJailBannedIps with the jail name", async () => { mockFetchJailBannedIps.mockResolvedValue(makeResponse()); renderSection("nginx"); await waitFor(() => { expect(mockFetchJailBannedIps).toHaveBeenCalledWith( "nginx", expect.any(Number), expect.any(Number), undefined, ); }); }); it("search input re-fetches after debounce with the search term", async () => { vi.useFakeTimers(); mockFetchJailBannedIps.mockResolvedValue(makeResponse()); renderSection(); // Flush pending async work from the initial render (no timer advancement needed). await act(async () => {}); mockFetchJailBannedIps.mockClear(); mockFetchJailBannedIps.mockResolvedValue( makeResponse(["1.2.3.4"], 1), ); // fireEvent is synchronous — avoids hanging with fake timers. const input = screen.getByPlaceholderText("e.g. 192.168"); act(() => { fireEvent.change(input, { target: { value: "1.2.3" } }); }); // Advance just past the 300ms debounce delay and flush promises. await act(async () => { await vi.advanceTimersByTimeAsync(350); }); expect(mockFetchJailBannedIps).toHaveBeenLastCalledWith( "sshd", expect.any(Number), expect.any(Number), "1.2.3", ); vi.useRealTimers(); }); it("calls unbanIp when the unban button is clicked", async () => { mockFetchJailBannedIps.mockResolvedValue(makeResponse(["1.2.3.4"])); renderSection(); await waitFor(() => { expect(screen.getByText("1.2.3.4")).toBeTruthy(); }); const unbanBtn = screen.getByLabelText("Unban 1.2.3.4"); await userEvent.click(unbanBtn); expect(mockUnbanIp).toHaveBeenCalledWith("1.2.3.4", "sshd"); }); it("refreshes list after successful unban", async () => { mockFetchJailBannedIps .mockResolvedValueOnce(makeResponse(["1.2.3.4"])) .mockResolvedValue(EMPTY_RESPONSE); mockUnbanIp.mockResolvedValue({ message: "ok", jail: "sshd" }); renderSection(); await waitFor(() => { expect(screen.getByText("1.2.3.4")).toBeTruthy(); }); const unbanBtn = screen.getByLabelText("Unban 1.2.3.4"); await userEvent.click(unbanBtn); await waitFor(() => { expect(mockFetchJailBannedIps).toHaveBeenCalledTimes(2); }); }); it("shows pagination controls when total > 0", async () => { mockFetchJailBannedIps.mockResolvedValue( makeResponse(["1.2.3.4", "5.6.7.8"], 50), ); renderSection(); await waitFor(() => { expect(screen.getByLabelText("Next page")).toBeTruthy(); expect(screen.getByLabelText("Previous page")).toBeTruthy(); }); }); it("previous page button is disabled on page 1", async () => { mockFetchJailBannedIps.mockResolvedValue( makeResponse(["1.2.3.4"], 50), ); renderSection(); await waitFor(() => { const prevBtn = screen.getByLabelText("Previous page"); expect(prevBtn).toHaveAttribute("disabled"); }); }); });