From 7627ae7edb1e1e7d66f52f60d5e330e17d3f5b28 Mon Sep 17 00:00:00 2001 From: Lukas Date: Fri, 20 Mar 2026 13:58:01 +0100 Subject: [PATCH] Add jail control actions to useJailDetail hook Implement TASK F-2: Wrap JailDetailPage jail-control API calls in a hook. Changes: - Add start(), stop(), reload(), and setIdle() methods to useJailDetail hook - Update JailDetailPage to use hook control methods instead of direct API imports - Update error handling to remove dependency on ApiError type - Add comprehensive tests for new control methods (8 tests) - Update existing test to include new hook methods in mock The control methods handle refetching jail data after each operation, consistent with the pattern used in useJails hook. --- .../src/hooks/__tests__/useJailDetail.test.ts | 207 ++++++++++++++++++ frontend/src/hooks/useJails.ts | 32 +++ frontend/src/pages/JailDetailPage.tsx | 54 ++--- .../__tests__/JailDetailIgnoreSelf.test.tsx | 4 + 4 files changed, 260 insertions(+), 37 deletions(-) create mode 100644 frontend/src/hooks/__tests__/useJailDetail.test.ts diff --git a/frontend/src/hooks/__tests__/useJailDetail.test.ts b/frontend/src/hooks/__tests__/useJailDetail.test.ts new file mode 100644 index 0000000..9df7055 --- /dev/null +++ b/frontend/src/hooks/__tests__/useJailDetail.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import * as jailsApi from "../../api/jails"; +import { useJailDetail } from "../useJails"; +import type { Jail } from "../../types/jail"; + +// Mock the API module +vi.mock("../../api/jails"); + +const mockJail: Jail = { + name: "sshd", + running: true, + idle: false, + backend: "pyinotify", + log_paths: ["/var/log/auth.log"], + fail_regex: ["^\\[.*\\]\\s.*Failed password"], + ignore_regex: [], + date_pattern: "%b %d %H:%M:%S", + log_encoding: "UTF-8", + actions: [], + find_time: 600, + ban_time: 600, + max_retry: 5, + status: null, + bantime_escalation: null, +}; + +describe("useJailDetail control methods", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(jailsApi.fetchJail).mockResolvedValue({ + jail: mockJail, + ignore_list: [], + ignore_self: false, + }); + }); + + it("calls start() and refetches jail data", async () => { + vi.mocked(jailsApi.startJail).mockResolvedValue(undefined); + + const { result } = renderHook(() => useJailDetail("sshd")); + + // Wait for initial fetch + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + expect(result.current.jail?.name).toBe("sshd"); + expect(jailsApi.startJail).not.toHaveBeenCalled(); + + // Call start() + await act(async () => { + await result.current.start(); + }); + + expect(jailsApi.startJail).toHaveBeenCalledWith("sshd"); + expect(jailsApi.fetchJail).toHaveBeenCalledTimes(2); // Initial fetch + refetch after start + }); + + it("calls stop() and refetches jail data", async () => { + vi.mocked(jailsApi.stopJail).mockResolvedValue(undefined); + + const { result } = renderHook(() => useJailDetail("sshd")); + + // Wait for initial fetch + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // Call stop() + await act(async () => { + await result.current.stop(); + }); + + expect(jailsApi.stopJail).toHaveBeenCalledWith("sshd"); + expect(jailsApi.fetchJail).toHaveBeenCalledTimes(2); // Initial fetch + refetch after stop + }); + + it("calls reload() and refetches jail data", async () => { + vi.mocked(jailsApi.reloadJail).mockResolvedValue(undefined); + + const { result } = renderHook(() => useJailDetail("sshd")); + + // Wait for initial fetch + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // Call reload() + await act(async () => { + await result.current.reload(); + }); + + expect(jailsApi.reloadJail).toHaveBeenCalledWith("sshd"); + expect(jailsApi.fetchJail).toHaveBeenCalledTimes(2); // Initial fetch + refetch after reload + }); + + it("calls setIdle() with correct parameter and refetches jail data", async () => { + vi.mocked(jailsApi.setJailIdle).mockResolvedValue(undefined); + + const { result } = renderHook(() => useJailDetail("sshd")); + + // Wait for initial fetch + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // Call setIdle(true) + await act(async () => { + await result.current.setIdle(true); + }); + + expect(jailsApi.setJailIdle).toHaveBeenCalledWith("sshd", true); + expect(jailsApi.fetchJail).toHaveBeenCalledTimes(2); + + // Reset mock to verify second call + vi.mocked(jailsApi.setJailIdle).mockClear(); + vi.mocked(jailsApi.fetchJail).mockResolvedValue({ + jail: { ...mockJail, idle: true }, + ignore_list: [], + ignore_self: false, + }); + + // Call setIdle(false) + await act(async () => { + await result.current.setIdle(false); + }); + + expect(jailsApi.setJailIdle).toHaveBeenCalledWith("sshd", false); + }); + + it("propagates errors from start()", async () => { + const error = new Error("Failed to start jail"); + vi.mocked(jailsApi.startJail).mockRejectedValue(error); + + const { result } = renderHook(() => useJailDetail("sshd")); + + // Wait for initial fetch + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // Call start() and expect it to throw + await expect( + act(async () => { + await result.current.start(); + }), + ).rejects.toThrow("Failed to start jail"); + }); + + it("propagates errors from stop()", async () => { + const error = new Error("Failed to stop jail"); + vi.mocked(jailsApi.stopJail).mockRejectedValue(error); + + const { result } = renderHook(() => useJailDetail("sshd")); + + // Wait for initial fetch + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // Call stop() and expect it to throw + await expect( + act(async () => { + await result.current.stop(); + }), + ).rejects.toThrow("Failed to stop jail"); + }); + + it("propagates errors from reload()", async () => { + const error = new Error("Failed to reload jail"); + vi.mocked(jailsApi.reloadJail).mockRejectedValue(error); + + const { result } = renderHook(() => useJailDetail("sshd")); + + // Wait for initial fetch + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // Call reload() and expect it to throw + await expect( + act(async () => { + await result.current.reload(); + }), + ).rejects.toThrow("Failed to reload jail"); + }); + + it("propagates errors from setIdle()", async () => { + const error = new Error("Failed to set idle mode"); + vi.mocked(jailsApi.setJailIdle).mockRejectedValue(error); + + const { result } = renderHook(() => useJailDetail("sshd")); + + // Wait for initial fetch + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + // Call setIdle() and expect it to throw + await expect( + act(async () => { + await result.current.setIdle(true); + }), + ).rejects.toThrow("Failed to set idle mode"); + }); +}); diff --git a/frontend/src/hooks/useJails.ts b/frontend/src/hooks/useJails.ts index eec77c3..a47445f 100644 --- a/frontend/src/hooks/useJails.ts +++ b/frontend/src/hooks/useJails.ts @@ -153,6 +153,14 @@ export interface UseJailDetailResult { removeIp: (ip: string) => Promise; /** Enable or disable the ignoreself option for this jail. */ toggleIgnoreSelf: (on: boolean) => Promise; + /** Start the jail. */ + start: () => Promise; + /** Stop the jail. */ + stop: () => Promise; + /** Reload jail configuration. */ + reload: () => Promise; + /** Toggle idle mode on/off for the jail. */ + setIdle: (on: boolean) => Promise; } /** @@ -216,6 +224,26 @@ export function useJailDetail(name: string): UseJailDetailResult { load(); }; + const doStart = async (): Promise => { + await startJail(name); + load(); + }; + + const doStop = async (): Promise => { + await stopJail(name); + load(); + }; + + const doReload = async (): Promise => { + await reloadJail(name); + load(); + }; + + const doSetIdle = async (on: boolean): Promise => { + await setJailIdle(name, on); + load(); + }; + return { jail, ignoreList, @@ -226,6 +254,10 @@ export function useJailDetail(name: string): UseJailDetailResult { addIp, removeIp, toggleIgnoreSelf, + start: doStart, + stop: doStop, + reload: doReload, + setIdle: doSetIdle, }; } diff --git a/frontend/src/pages/JailDetailPage.tsx b/frontend/src/pages/JailDetailPage.tsx index 665c6d2..83d9f47 100644 --- a/frontend/src/pages/JailDetailPage.tsx +++ b/frontend/src/pages/JailDetailPage.tsx @@ -33,15 +33,8 @@ import { StopRegular, } from "@fluentui/react-icons"; import { Link, useNavigate, useParams } from "react-router-dom"; -import { - reloadJail, - setJailIdle, - startJail, - stopJail, -} from "../api/jails"; import { useJailDetail } from "../hooks/useJails"; import type { Jail } from "../types/jail"; -import { ApiError } from "../api/client"; import { BannedIpsSection } from "../components/jail/BannedIpsSection"; // --------------------------------------------------------------------------- @@ -186,9 +179,13 @@ function CodeList({ items, empty }: { items: string[]; empty: string }): React.J interface JailInfoProps { jail: Jail; onRefresh: () => void; + onStart: () => Promise; + onStop: () => Promise; + onSetIdle: (on: boolean) => Promise; + onReload: () => Promise; } -function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element { +function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload }: JailInfoProps): React.JSX.Element { const styles = useStyles(); const navigate = useNavigate(); const [ctrlError, setCtrlError] = useState(null); @@ -207,11 +204,9 @@ function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element }) .catch((err: unknown) => { const msg = - err instanceof ApiError - ? `${String(err.status)}: ${err.body}` - : err instanceof Error - ? err.message - : String(err); + err instanceof Error + ? err.message + : String(err); setCtrlError(msg); }); }; @@ -259,7 +254,7 @@ function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element @@ -269,7 +264,7 @@ function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element @@ -282,7 +277,7 @@ function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element @@ -467,12 +462,7 @@ function IgnoreListSection({ setInputVal(""); }) .catch((err: unknown) => { - const msg = - err instanceof ApiError - ? `${String(err.status)}: ${err.body}` - : err instanceof Error - ? err.message - : String(err); + const msg = err instanceof Error ? err.message : String(err); setOpError(msg); }); }; @@ -480,12 +470,7 @@ function IgnoreListSection({ const handleRemove = (ip: string): void => { setOpError(null); onRemove(ip).catch((err: unknown) => { - const msg = - err instanceof ApiError - ? `${String(err.status)}: ${err.body}` - : err instanceof Error - ? err.message - : String(err); + const msg = err instanceof Error ? err.message : String(err); setOpError(msg); }); }; @@ -507,12 +492,7 @@ function IgnoreListSection({ checked={ignoreSelf} onChange={(_e, data): void => { onToggleIgnoreSelf(data.checked).catch((err: unknown) => { - const msg = - err instanceof ApiError - ? `${String(err.status)}: ${err.body}` - : err instanceof Error - ? err.message - : String(err); + const msg = err instanceof Error ? err.message : String(err); setOpError(msg); }); }} @@ -592,7 +572,7 @@ function IgnoreListSection({ export function JailDetailPage(): React.JSX.Element { const styles = useStyles(); const { name = "" } = useParams<{ name: string }>(); - const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf } = + const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf, start, stop, reload, setIdle } = useJailDetail(name); if (loading && !jail) { @@ -637,7 +617,7 @@ export function JailDetailPage(): React.JSX.Element { - + diff --git a/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx b/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx index 0765f7d..eab2348 100644 --- a/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx +++ b/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx @@ -101,6 +101,10 @@ function mockHook(ignoreSelf: boolean): void { addIp: mockAddIp, removeIp: mockRemoveIp, toggleIgnoreSelf: mockToggleIgnoreSelf, + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + reload: vi.fn().mockResolvedValue(undefined), + setIdle: vi.fn().mockResolvedValue(undefined), }; vi.mocked(useJailDetail).mockReturnValue(result); }