Backend:
- Add JailBannedIpsResponse Pydantic model (ban.py)
- Add get_jail_banned_ips() service: server-side pagination, optional
IP substring search, geo enrichment on page slice only (jail_service.py)
- Add GET /api/jails/{name}/banned endpoint with page/page_size/search
query params, 400/404/502 error handling (routers/jails.py)
- 23 new tests: 13 service tests + 10 router tests (all passing)
Frontend:
- Add JailBannedIpsResponse TS interface (types/jail.ts)
- Add jailBanned endpoint helper (api/endpoints.ts)
- Add fetchJailBannedIps() API function (api/jails.ts)
- Add BannedIpsSection component: Fluent UI DataGrid, debounced search
(300 ms), prev/next pagination, page-size dropdown, per-row unban
button, loading spinner, empty state, error MessageBar (BannedIpsSection.tsx)
- Mount BannedIpsSection in JailDetailPage between stats and patterns
- 12 new Vitest tests for BannedIpsSection (all passing)
252 lines
7.5 KiB
TypeScript
252 lines
7.5 KiB
TypeScript
/**
|
|
* 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<JailBannedIpsResponse>
|
|
>(),
|
|
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(
|
|
<FluentProvider theme={webLightTheme}>
|
|
<BannedIpsSection jailName={jailName} />
|
|
</FluentProvider>,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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");
|
|
});
|
|
});
|
|
});
|