feat: Task 4 — paginated banned-IPs section on jail detail page
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)
This commit is contained in:
251
frontend/src/components/jail/__tests__/BannedIpsSection.test.tsx
Normal file
251
frontend/src/components/jail/__tests__/BannedIpsSection.test.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user