From 6bb38dbd8c4ed15d848956867adba0df32f42d7d Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 14 Mar 2026 20:24:49 +0100 Subject: [PATCH] Add ignore-self toggle to Jail Detail page Implements the missing UI control for POST /api/jails/{name}/ignoreself: - Add jailIgnoreSelf endpoint constant to endpoints.ts - Add toggleIgnoreSelf(name, on) API function to jails.ts - Expose toggleIgnoreSelf action from useJailDetail hook - Replace read-only 'ignore self' badge with a Fluent Switch in IgnoreListSection to allow enabling/disabling the flag per jail - Add 5 vitest tests for checked/unchecked state and toggle behaviour --- Docs/Tasks.md | 132 ++++++++++++ frontend/src/api/endpoints.ts | 1 + frontend/src/api/jails.ts | 18 ++ frontend/src/hooks/useJails.ts | 9 + frontend/src/pages/JailDetailPage.tsx | 30 ++- .../__tests__/JailDetailIgnoreSelf.test.tsx | 188 ++++++++++++++++++ 6 files changed, 370 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx diff --git a/Docs/Tasks.md b/Docs/Tasks.md index 02d3778..e885d36 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -468,3 +468,135 @@ Write tests covering: .venv/bin/mypy backend/app/ --strict ``` Zero errors. + +--- + +## Task 8 — Add "ignore self" toggle to Jail Detail page + +**Status:** done + +**Summary:** Added `jailIgnoreSelf` endpoint constant to `endpoints.ts`; added `toggleIgnoreSelf(name, on)` API function to `jails.ts`; extended `useJailDetail` return type and hook implementation to expose `toggleIgnoreSelf`; replaced the read-only "ignore self" badge in `IgnoreListSection` (`JailDetailPage.tsx`) with a Fluent UI `Switch` that calls the toggle action and surfaces any error in the existing `opError` message bar; added 5 new tests in `JailDetailIgnoreSelf.test.tsx` covering checked/unchecked rendering, toggle-on, toggle-off, and error display. + +**Page:** `/jails/:name` — rendered by `frontend/src/pages/JailDetailPage.tsx` + +### Problem description + +`Features.md` §5 (Jail Management / IP Whitelist) requires: "Toggle the 'ignore self' option per jail, which automatically excludes the server's own IP addresses." + +The backend already exposes `POST /api/jails/{name}/ignoreself` (accepts a JSON boolean `on`). The `useJailDetail` hook reads `ignore_self` from the `GET /api/jails/{name}` response and exposes it as `ignoreSelf: boolean`. However: + +1. **No API wrapper** — `frontend/src/api/jails.ts` has no `toggleIgnoreSelf` function, and `frontend/src/api/endpoints.ts` has no `jailIgnoreSelf` entry. +2. **No hook action** — `UseJailDetailResult` in `useJails.ts` does not expose a `toggleIgnoreSelf` helper. +3. **Read-only UI** — `IgnoreListSection` in `JailDetailPage.tsx` shows an "ignore self" badge when enabled but has no control to change the setting. + +As a result, users must use the fail2ban CLI to manage this flag even though the backend is ready. + +### What to do + +#### Part A — Add endpoint constant (frontend) + +**File:** `frontend/src/api/endpoints.ts` + +Inside the Jails block, after `jailIgnoreIp`, add: + +```ts +jailIgnoreSelf: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreself`, +``` + +#### Part B — Add API wrapper function (frontend) + +**File:** `frontend/src/api/jails.ts` + +After the `delIgnoreIp` function in the "Ignore list" section, add: + +```ts +/** + * Enable or disable the `ignoreself` flag for a jail. + * + * When enabled, fail2ban automatically adds the server's own IP addresses to + * the ignore list so the host can never ban itself. + * + * @param name - Jail name. + * @param on - `true` to enable, `false` to disable. + * @returns A {@link JailCommandResponse} confirming the change. + * @throws {ApiError} On non-2xx responses. + */ +export async function toggleIgnoreSelf( + name: string, + on: boolean, +): Promise { + return post(ENDPOINTS.jailIgnoreSelf(name), on); +} +``` + +#### Part C — Expose toggle action from hook (frontend) + +**File:** `frontend/src/hooks/useJails.ts` + +1. Import `toggleIgnoreSelf` at the top (alongside the other API imports). +2. Add `toggleIgnoreSelf: (on: boolean) => Promise` to the `UseJailDetailResult` interface with a JSDoc comment: `/** Enable or disable the ignoreself option for this jail. */`. +3. Inside `useJailDetail`, add the implementation: + +```ts +const toggleIgnoreSelf = async (on: boolean): Promise => { + await toggleIgnoreSelfApi(name, on); + load(); +}; +``` + + Alias the import as `toggleIgnoreSelfApi` to avoid shadowing the local function name. + +4. Add the function to the returned object. + +#### Part D — Add toggle control to UI (frontend) + +**File:** `frontend/src/pages/JailDetailPage.tsx` + +1. Accept `toggleIgnoreSelf: (on: boolean) => Promise` in the `IgnoreListSectionProps` interface. +2. Pass the function from `useJailDetail` down via the existing destructuring and JSX prop. +3. Inside `IgnoreListSection`, render a `` next to (or in place of) the read-only badge: + +```tsx + { + toggleIgnoreSelf(data.checked).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + setOpError(msg); + }); + }} +/> +``` + + Import `Switch` from `"@fluentui/react-components"`. Remove the existing read-only badge (it is replaced by the labelled switch, which is self-explanatory). Keep the existing `opError` state and `` for error display. + +### Tests to add + +**File:** `frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx` (new file) + +Write tests that render the `IgnoreListSection` component (or the full `JailDetailPage` via a shallow-enough render) and cover: + +1. **`test_ignore_self_switch_is_checked_when_ignore_self_true`** — when `ignoreSelf=true`, the switch is checked. +2. **`test_ignore_self_switch_is_unchecked_when_ignore_self_false`** — when `ignoreSelf=false`, the switch is unchecked. +3. **`test_toggling_switch_calls_toggle_ignore_self`** — clicking the switch calls `toggleIgnoreSelf` with `false` (when it was `true`). +4. **`test_toggle_error_shows_message_bar`** — when `toggleIgnoreSelf` rejects, the error message bar is rendered. + +### Verification + +1. Run frontend type check and lint: + ```bash + cd frontend && npx tsc --noEmit && npx eslint src/api/jails.ts src/api/endpoints.ts src/hooks/useJails.ts src/pages/JailDetailPage.tsx + ``` + Zero errors and zero warnings. + +2. Run frontend tests: + ```bash + cd frontend && npx vitest run src/pages/__tests__/JailDetailIgnoreSelf + ``` + All 4 new tests pass. + +3. **Manual test with running server:** + - Go to `/jails`, click a running jail. + - On the Jail Detail page, scroll to "Ignore List (IP Whitelist)". + - Toggle the "Ignore self" switch on and off — the switch should reflect the live state and the change should survive a page refresh. diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index 2df2c13..c3f7037 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -45,6 +45,7 @@ export const ENDPOINTS = { jailReload: (name: string): string => `/jails/${encodeURIComponent(name)}/reload`, jailsReloadAll: "/jails/reload-all", jailIgnoreIp: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreip`, + jailIgnoreSelf: (name: string): string => `/jails/${encodeURIComponent(name)}/ignoreself`, // ------------------------------------------------------------------------- // Bans diff --git a/frontend/src/api/jails.ts b/frontend/src/api/jails.ts index bc5db30..c141c32 100644 --- a/frontend/src/api/jails.ts +++ b/frontend/src/api/jails.ts @@ -149,6 +149,24 @@ export async function delIgnoreIp( return del(ENDPOINTS.jailIgnoreIp(name), { ip }); } +/** + * Enable or disable the `ignoreself` flag for a jail. + * + * When enabled, fail2ban automatically adds the server's own IP addresses to + * the ignore list so the host can never ban itself. + * + * @param name - Jail name. + * @param on - `true` to enable, `false` to disable. + * @returns A {@link JailCommandResponse} confirming the change. + * @throws {ApiError} On non-2xx responses. + */ +export async function toggleIgnoreSelf( + name: string, + on: boolean, +): Promise { + return post(ENDPOINTS.jailIgnoreSelf(name), on); +} + // --------------------------------------------------------------------------- // Ban / unban // --------------------------------------------------------------------------- diff --git a/frontend/src/hooks/useJails.ts b/frontend/src/hooks/useJails.ts index 019174a..eec77c3 100644 --- a/frontend/src/hooks/useJails.ts +++ b/frontend/src/hooks/useJails.ts @@ -20,6 +20,7 @@ import { setJailIdle, startJail, stopJail, + toggleIgnoreSelf as toggleIgnoreSelfApi, unbanAllBans, unbanIp, } from "../api/jails"; @@ -150,6 +151,8 @@ export interface UseJailDetailResult { addIp: (ip: string) => Promise; /** Remove an IP from the ignore list. */ removeIp: (ip: string) => Promise; + /** Enable or disable the ignoreself option for this jail. */ + toggleIgnoreSelf: (on: boolean) => Promise; } /** @@ -208,6 +211,11 @@ export function useJailDetail(name: string): UseJailDetailResult { load(); }; + const toggleIgnoreSelf = async (on: boolean): Promise => { + await toggleIgnoreSelfApi(name, on); + load(); + }; + return { jail, ignoreList, @@ -217,6 +225,7 @@ export function useJailDetail(name: string): UseJailDetailResult { refresh: load, addIp, removeIp, + toggleIgnoreSelf, }; } diff --git a/frontend/src/pages/JailDetailPage.tsx b/frontend/src/pages/JailDetailPage.tsx index 1fca0df..665c6d2 100644 --- a/frontend/src/pages/JailDetailPage.tsx +++ b/frontend/src/pages/JailDetailPage.tsx @@ -17,6 +17,7 @@ import { MessageBar, MessageBarBody, Spinner, + Switch, Text, Tooltip, makeStyles, @@ -443,6 +444,7 @@ interface IgnoreListSectionProps { ignoreSelf: boolean; onAdd: (ip: string) => Promise; onRemove: (ip: string) => Promise; + onToggleIgnoreSelf: (on: boolean) => Promise; } function IgnoreListSection({ @@ -451,6 +453,7 @@ function IgnoreListSection({ ignoreSelf, onAdd, onRemove, + onToggleIgnoreSelf, }: IgnoreListSectionProps): React.JSX.Element { const styles = useStyles(); const [inputVal, setInputVal] = useState(""); @@ -494,17 +497,27 @@ function IgnoreListSection({ Ignore List (IP Whitelist) - {ignoreSelf && ( - - - ignore self - - - )} {String(ignoreList.length)} + {/* Ignore-self toggle */} + { + onToggleIgnoreSelf(data.checked).catch((err: unknown) => { + const msg = + err instanceof ApiError + ? `${String(err.status)}: ${err.body}` + : err instanceof Error + ? err.message + : String(err); + setOpError(msg); + }); + }} + /> + {opError && ( {opError} @@ -579,7 +592,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 } = + const { jail, ignoreList, ignoreSelf, loading, error, refresh, addIp, removeIp, toggleIgnoreSelf } = useJailDetail(name); if (loading && !jail) { @@ -634,6 +647,7 @@ export function JailDetailPage(): React.JSX.Element { ignoreSelf={ignoreSelf} onAdd={addIp} onRemove={removeIp} + onToggleIgnoreSelf={toggleIgnoreSelf} /> ); diff --git a/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx b/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx new file mode 100644 index 0000000..0765f7d --- /dev/null +++ b/frontend/src/pages/__tests__/JailDetailIgnoreSelf.test.tsx @@ -0,0 +1,188 @@ +/** + * Tests for the "Ignore self" toggle in `JailDetailPage`. + * + * Verifies that: + * - The switch is checked when `ignoreSelf` is `true`. + * - The switch is unchecked when `ignoreSelf` is `false`. + * - Toggling the switch calls `toggleIgnoreSelf` with the correct boolean. + * - A failed toggle shows an error message bar. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { JailDetailPage } from "../JailDetailPage"; +import type { Jail } from "../../types/jail"; +import type { UseJailDetailResult } from "../../hooks/useJails"; + +// --------------------------------------------------------------------------- +// Module mocks +// --------------------------------------------------------------------------- + +/** + * Stable mock function refs created before vi.mock() is hoisted. + * We need `mockToggleIgnoreSelf` to be a vi.fn() that tests can inspect + * and the rest to be no-ops that prevent real network calls. + */ +const { + mockToggleIgnoreSelf, + mockAddIp, + mockRemoveIp, + mockRefresh, +} = vi.hoisted(() => ({ + mockToggleIgnoreSelf: vi.fn<(on: boolean) => Promise>(), + mockAddIp: vi.fn<(ip: string) => Promise>().mockResolvedValue(undefined), + mockRemoveIp: vi.fn<(ip: string) => Promise>().mockResolvedValue(undefined), + mockRefresh: vi.fn(), +})); + +// Mock the jail detail hook — tests control the returned state directly. +vi.mock("../../hooks/useJails", () => ({ + useJailDetail: vi.fn(), +})); + +// Mock API functions used by JailInfoSection control buttons to avoid side effects. +vi.mock("../../api/jails", () => ({ + startJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }), + stopJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }), + reloadJail: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }), + setJailIdle: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }), + toggleIgnoreSelf: vi.fn().mockResolvedValue({ message: "ok", jail: "sshd" }), +})); + +// Stub BannedIpsSection to prevent its own fetchJailBannedIps calls. +vi.mock("../../components/jail/BannedIpsSection", () => ({ + BannedIpsSection: () =>
, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +import { useJailDetail } from "../../hooks/useJails"; + +/** Minimal `Jail` fixture. */ +function makeJail(): Jail { + return { + name: "sshd", + running: true, + idle: false, + backend: "systemd", + log_paths: ["/var/log/auth.log"], + fail_regex: ["^Failed .+ from "], + ignore_regex: [], + date_pattern: "", + log_encoding: "UTF-8", + actions: ["iptables-multiport"], + find_time: 600, + ban_time: 3600, + max_retry: 5, + status: { + currently_banned: 2, + total_banned: 10, + currently_failed: 0, + total_failed: 50, + }, + bantime_escalation: null, + }; +} + +/** Wire `useJailDetail` to return the given `ignoreSelf` value. */ +function mockHook(ignoreSelf: boolean): void { + const result: UseJailDetailResult = { + jail: makeJail(), + ignoreList: ["10.0.0.0/8"], + ignoreSelf, + loading: false, + error: null, + refresh: mockRefresh, + addIp: mockAddIp, + removeIp: mockRemoveIp, + toggleIgnoreSelf: mockToggleIgnoreSelf, + }; + vi.mocked(useJailDetail).mockReturnValue(result); +} + +/** Render the JailDetailPage with a fake `/jails/sshd` route. */ +function renderPage() { + return render( + + + + } /> + + + , + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("JailDetailPage — ignore self toggle", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockToggleIgnoreSelf.mockResolvedValue(undefined); + }); + + it("renders the switch checked when ignoreSelf is true", async () => { + mockHook(true); + renderPage(); + + const switchEl = await screen.findByRole("switch", { name: /ignore self/i }); + expect(switchEl).toBeChecked(); + }); + + it("renders the switch unchecked when ignoreSelf is false", async () => { + mockHook(false); + renderPage(); + + const switchEl = await screen.findByRole("switch", { name: /ignore self/i }); + expect(switchEl).not.toBeChecked(); + }); + + it("calls toggleIgnoreSelf(false) when switch is toggled off", async () => { + mockHook(true); + renderPage(); + const user = userEvent.setup(); + + const switchEl = await screen.findByRole("switch", { name: /ignore self/i }); + await user.click(switchEl); + + await waitFor(() => { + expect(mockToggleIgnoreSelf).toHaveBeenCalledOnce(); + expect(mockToggleIgnoreSelf).toHaveBeenCalledWith(false); + }); + }); + + it("calls toggleIgnoreSelf(true) when switch is toggled on", async () => { + mockHook(false); + renderPage(); + const user = userEvent.setup(); + + const switchEl = await screen.findByRole("switch", { name: /ignore self/i }); + await user.click(switchEl); + + await waitFor(() => { + expect(mockToggleIgnoreSelf).toHaveBeenCalledOnce(); + expect(mockToggleIgnoreSelf).toHaveBeenCalledWith(true); + }); + }); + + it("shows an error message bar when toggleIgnoreSelf rejects", async () => { + mockHook(false); + mockToggleIgnoreSelf.mockRejectedValue(new Error("Connection refused")); + renderPage(); + const user = userEvent.setup(); + + const switchEl = await screen.findByRole("switch", { name: /ignore self/i }); + await user.click(switchEl); + + await waitFor(() => { + expect(screen.getByText(/Connection refused/i)).toBeInTheDocument(); + }); + }); +});