Refactor frontend API calls into hooks and complete task states
This commit is contained in:
@@ -6,12 +6,13 @@
|
||||
* While the status is loading a full-screen spinner is shown.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { Spinner } from "@fluentui/react-components";
|
||||
import { getSetupStatus } from "../api/setup";
|
||||
import { useSetup } from "../hooks/useSetup";
|
||||
|
||||
type Status = "loading" | "done" | "pending";
|
||||
/**
|
||||
* Component is intentionally simple; status load is handled by the hook.
|
||||
*/
|
||||
|
||||
interface SetupGuardProps {
|
||||
/** The protected content to render when setup is complete. */
|
||||
@@ -24,25 +25,9 @@ interface SetupGuardProps {
|
||||
* Redirects to `/setup` if setup is still pending.
|
||||
*/
|
||||
export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
||||
const [status, setStatus] = useState<Status>("loading");
|
||||
const { status, loading } = useSetup();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getSetupStatus()
|
||||
.then((res): void => {
|
||||
if (!cancelled) setStatus(res.completed ? "done" : "pending");
|
||||
})
|
||||
.catch((): void => {
|
||||
// A failed check conservatively redirects to /setup — a crashed
|
||||
// backend cannot serve protected routes anyway.
|
||||
if (!cancelled) setStatus("pending");
|
||||
});
|
||||
return (): void => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (status === "loading") {
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -57,7 +42,7 @@ export function SetupGuard({ children }: SetupGuardProps): React.JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
if (status === "pending") {
|
||||
if (!status?.completed) {
|
||||
return <Navigate to="/setup" replace />;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,15 +25,10 @@ import {
|
||||
ArrowSync24Regular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { ApiError } from "../../api/client";
|
||||
import type { ServerSettingsUpdate, MapColorThresholdsResponse, MapColorThresholdsUpdate } from "../../types/config";
|
||||
import type { ServerSettingsUpdate, MapColorThresholdsUpdate } from "../../types/config";
|
||||
import { useServerSettings } from "../../hooks/useConfig";
|
||||
import { useAutoSave } from "../../hooks/useAutoSave";
|
||||
import {
|
||||
fetchMapColorThresholds,
|
||||
updateMapColorThresholds,
|
||||
reloadConfig,
|
||||
restartFail2Ban,
|
||||
} from "../../api/config";
|
||||
import { useMapColorThresholds } from "../../hooks/useMapColorThresholds";
|
||||
import { AutoSaveIndicator } from "./AutoSaveIndicator";
|
||||
import { ServerHealthSection } from "./ServerHealthSection";
|
||||
import { useConfigStyles } from "./configStyles";
|
||||
@@ -48,7 +43,7 @@ const LOG_LEVELS = ["CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"];
|
||||
*/
|
||||
export function ServerTab(): React.JSX.Element {
|
||||
const styles = useConfigStyles();
|
||||
const { settings, loading, error, updateSettings, flush } =
|
||||
const { settings, loading, error, updateSettings, flush, reload, restart } =
|
||||
useServerSettings();
|
||||
const [logLevel, setLogLevel] = useState("");
|
||||
const [logTarget, setLogTarget] = useState("");
|
||||
@@ -62,11 +57,15 @@ export function ServerTab(): React.JSX.Element {
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
|
||||
// Map color thresholds
|
||||
const [mapThresholds, setMapThresholds] = useState<MapColorThresholdsResponse | null>(null);
|
||||
const {
|
||||
thresholds: mapThresholds,
|
||||
error: mapThresholdsError,
|
||||
refresh: refreshMapThresholds,
|
||||
updateThresholds: updateMapThresholds,
|
||||
} = useMapColorThresholds();
|
||||
const [mapThresholdHigh, setMapThresholdHigh] = useState("");
|
||||
const [mapThresholdMedium, setMapThresholdMedium] = useState("");
|
||||
const [mapThresholdLow, setMapThresholdLow] = useState("");
|
||||
const [mapLoadError, setMapLoadError] = useState<string | null>(null);
|
||||
|
||||
const effectiveLogLevel = logLevel || settings?.log_level || "";
|
||||
const effectiveLogTarget = logTarget || settings?.log_target || "";
|
||||
@@ -105,11 +104,11 @@ export function ServerTab(): React.JSX.Element {
|
||||
}
|
||||
}, [flush]);
|
||||
|
||||
const handleReload = useCallback(async () => {
|
||||
const handleReload = async (): Promise<void> => {
|
||||
setIsReloading(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await reloadConfig();
|
||||
await reload();
|
||||
setMsg({ text: "fail2ban reloaded successfully", ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
@@ -119,13 +118,13 @@ export function ServerTab(): React.JSX.Element {
|
||||
} finally {
|
||||
setIsReloading(false);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
const handleRestart = useCallback(async () => {
|
||||
const handleRestart = async (): Promise<void> => {
|
||||
setIsRestarting(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
await restartFail2Ban();
|
||||
await restart();
|
||||
setMsg({ text: "fail2ban restart initiated", ok: true });
|
||||
} catch (err: unknown) {
|
||||
setMsg({
|
||||
@@ -135,27 +134,15 @@ export function ServerTab(): React.JSX.Element {
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load map color thresholds on mount.
|
||||
const loadMapThresholds = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const data = await fetchMapColorThresholds();
|
||||
setMapThresholds(data);
|
||||
setMapThresholdHigh(String(data.threshold_high));
|
||||
setMapThresholdMedium(String(data.threshold_medium));
|
||||
setMapThresholdLow(String(data.threshold_low));
|
||||
setMapLoadError(null);
|
||||
} catch (err) {
|
||||
setMapLoadError(
|
||||
err instanceof ApiError ? err.message : "Failed to load map color thresholds",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadMapThresholds();
|
||||
}, [loadMapThresholds]);
|
||||
if (!mapThresholds) return;
|
||||
|
||||
setMapThresholdHigh(String(mapThresholds.threshold_high));
|
||||
setMapThresholdMedium(String(mapThresholds.threshold_medium));
|
||||
setMapThresholdLow(String(mapThresholds.threshold_low));
|
||||
}, [mapThresholds]);
|
||||
|
||||
// Map threshold validation and auto-save.
|
||||
const mapHigh = Number(mapThresholdHigh);
|
||||
@@ -190,9 +177,10 @@ export function ServerTab(): React.JSX.Element {
|
||||
|
||||
const saveMapThresholds = useCallback(
|
||||
async (payload: MapColorThresholdsUpdate): Promise<void> => {
|
||||
await updateMapColorThresholds(payload);
|
||||
await updateMapThresholds(payload);
|
||||
await refreshMapThresholds();
|
||||
},
|
||||
[],
|
||||
[refreshMapThresholds, updateMapThresholds],
|
||||
);
|
||||
|
||||
const { status: mapSaveStatus, errorText: mapSaveErrorText, retry: retryMapSave } =
|
||||
@@ -332,10 +320,10 @@ export function ServerTab(): React.JSX.Element {
|
||||
</div>
|
||||
|
||||
{/* Map Color Thresholds section */}
|
||||
{mapLoadError ? (
|
||||
{mapThresholdsError ? (
|
||||
<div className={styles.sectionCard}>
|
||||
<MessageBar intent="error">
|
||||
<MessageBarBody>{mapLoadError}</MessageBarBody>
|
||||
<MessageBarBody>{mapThresholdsError}</MessageBarBody>
|
||||
</MessageBar>
|
||||
</div>
|
||||
) : mapThresholds ? (
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* remains fast even when a jail contains thousands of banned IPs.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -40,17 +39,12 @@ import {
|
||||
DismissRegular,
|
||||
SearchRegular,
|
||||
} from "@fluentui/react-icons";
|
||||
import { fetchJailBannedIps, unbanIp } from "../../api/jails";
|
||||
import type { ActiveBan } from "../../types/jail";
|
||||
import { ApiError } from "../../api/client";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Debounce delay in milliseconds for the search input. */
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/** Available page-size options. */
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100] as const;
|
||||
|
||||
@@ -229,8 +223,19 @@ const columns: TableColumnDefinition<BanRow>[] = [
|
||||
|
||||
/** Props for {@link BannedIpsSection}. */
|
||||
export interface BannedIpsSectionProps {
|
||||
/** The jail name whose banned IPs are displayed. */
|
||||
jailName: string;
|
||||
items: ActiveBan[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
search: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
opError: string | null;
|
||||
onSearch: (term: string) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
onRefresh: () => void;
|
||||
onUnban: (ip: string) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -242,81 +247,26 @@ export interface BannedIpsSectionProps {
|
||||
*
|
||||
* @param props - {@link BannedIpsSectionProps}
|
||||
*/
|
||||
export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX.Element {
|
||||
export function BannedIpsSection({
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
search,
|
||||
loading,
|
||||
error,
|
||||
opError,
|
||||
onSearch,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
onRefresh,
|
||||
onUnban,
|
||||
}: BannedIpsSectionProps): React.JSX.Element {
|
||||
const styles = useStyles();
|
||||
|
||||
const [items, setItems] = useState<ActiveBan[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState<number>(25);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Debounce the search input so we don't spam the backend on every keystroke.
|
||||
useEffect(() => {
|
||||
if (debounceRef.current !== null) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout((): void => {
|
||||
setDebouncedSearch(search);
|
||||
setPage(1);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
return (): void => {
|
||||
if (debounceRef.current !== null) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchJailBannedIps(jailName, page, pageSize, debouncedSearch || undefined)
|
||||
.then((resp) => {
|
||||
setItems(resp.items);
|
||||
setTotal(resp.total);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setError(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [jailName, page, pageSize, debouncedSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const handleUnban = (ip: string): void => {
|
||||
setOpError(null);
|
||||
unbanIp(ip, jailName)
|
||||
.then(() => {
|
||||
load();
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg =
|
||||
err instanceof ApiError
|
||||
? `${String(err.status)}: ${err.body}`
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
setOpError(msg);
|
||||
});
|
||||
};
|
||||
|
||||
const rows: BanRow[] = items.map((ban) => ({
|
||||
ban,
|
||||
onUnban: handleUnban,
|
||||
onUnban,
|
||||
}));
|
||||
|
||||
const totalPages = pageSize > 0 ? Math.ceil(total / pageSize) : 1;
|
||||
@@ -335,7 +285,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
|
||||
size="small"
|
||||
appearance="subtle"
|
||||
icon={<ArrowClockwiseRegular />}
|
||||
onClick={load}
|
||||
onClick={onRefresh}
|
||||
aria-label="Refresh banned IPs"
|
||||
/>
|
||||
</div>
|
||||
@@ -350,7 +300,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
|
||||
placeholder="e.g. 192.168"
|
||||
value={search}
|
||||
onChange={(_, d) => {
|
||||
setSearch(d.value);
|
||||
onSearch(d.value);
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
@@ -420,8 +370,8 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
|
||||
onOptionSelect={(_, d) => {
|
||||
const newSize = Number(d.optionValue);
|
||||
if (!Number.isNaN(newSize)) {
|
||||
setPageSize(newSize);
|
||||
setPage(1);
|
||||
onPageSizeChange(newSize);
|
||||
onPageChange(1);
|
||||
}
|
||||
}}
|
||||
style={{ minWidth: "80px" }}
|
||||
@@ -445,7 +395,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
|
||||
icon={<ChevronLeftRegular />}
|
||||
disabled={page <= 1}
|
||||
onClick={() => {
|
||||
setPage((p) => Math.max(1, p - 1));
|
||||
onPageChange(Math.max(1, page - 1));
|
||||
}}
|
||||
aria-label="Previous page"
|
||||
/>
|
||||
@@ -455,7 +405,7 @@ export function BannedIpsSection({ jailName }: BannedIpsSectionProps): React.JSX
|
||||
icon={<ChevronRightRegular />}
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => {
|
||||
setPage((p) => p + 1);
|
||||
onPageChange(page + 1);
|
||||
}}
|
||||
aria-label="Next page"
|
||||
/>
|
||||
|
||||
@@ -1,52 +1,11 @@
|
||||
/**
|
||||
* 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 { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } 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";
|
||||
import { BannedIpsSection, type BannedIpsSectionProps } from "../BannedIpsSection";
|
||||
import type { ActiveBan } 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) {
|
||||
function makeBan(ip: string): ActiveBan {
|
||||
return {
|
||||
ip,
|
||||
jail: "sshd",
|
||||
@@ -57,195 +16,65 @@ function makeBan(ip: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function makeResponse(
|
||||
ips: string[] = ["1.2.3.4", "5.6.7.8"],
|
||||
total = 2,
|
||||
): JailBannedIpsResponse {
|
||||
return {
|
||||
items: ips.map(makeBan),
|
||||
total,
|
||||
function renderWithProps(props: Partial<BannedIpsSectionProps> = {}) {
|
||||
const defaults: BannedIpsSectionProps = {
|
||||
items: [makeBan("1.2.3.4"), makeBan("5.6.7.8")],
|
||||
total: 2,
|
||||
page: 1,
|
||||
page_size: 25,
|
||||
pageSize: 25,
|
||||
search: "",
|
||||
loading: false,
|
||||
error: null,
|
||||
opError: null,
|
||||
onSearch: vi.fn(),
|
||||
onPageChange: vi.fn(),
|
||||
onPageSizeChange: vi.fn(),
|
||||
onRefresh: vi.fn(),
|
||||
onUnban: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
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} />
|
||||
<BannedIpsSection {...defaults} {...props} />
|
||||
</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 the table rows and total count", () => {
|
||||
renderWithProps();
|
||||
expect(screen.getByText("Currently Banned IPs")).toBeTruthy();
|
||||
expect(screen.getByText("1.2.3.4")).toBeTruthy();
|
||||
expect(screen.getByText("5.6.7.8")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a spinner while loading", () => {
|
||||
// Never resolves during this test so we see the spinner.
|
||||
mockFetchJailBannedIps.mockReturnValue(new Promise(() => void 0));
|
||||
renderSection();
|
||||
renderWithProps({ loading: true, items: [] });
|
||||
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 error message when error is present", () => {
|
||||
renderWithProps({ error: "Failed to load" });
|
||||
expect(screen.getByText(/Failed to load/i)).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("triggers onUnban for IP row button", async () => {
|
||||
const onUnban = vi.fn();
|
||||
renderWithProps({ onUnban });
|
||||
|
||||
const unbanBtn = screen.getByLabelText("Unban 1.2.3.4");
|
||||
await userEvent.click(unbanBtn);
|
||||
|
||||
expect(onUnban).toHaveBeenCalledWith("1.2.3.4");
|
||||
});
|
||||
|
||||
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 onSearch when the search input changes", async () => {
|
||||
const onSearch = vi.fn();
|
||||
renderWithProps({ onSearch });
|
||||
|
||||
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" } });
|
||||
});
|
||||
await userEvent.type(input, "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");
|
||||
});
|
||||
expect(onSearch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user