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.
This commit is contained in:
207
frontend/src/hooks/__tests__/useJailDetail.test.ts
Normal file
207
frontend/src/hooks/__tests__/useJailDetail.test.ts
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -153,6 +153,14 @@ export interface UseJailDetailResult {
|
|||||||
removeIp: (ip: string) => Promise<void>;
|
removeIp: (ip: string) => Promise<void>;
|
||||||
/** Enable or disable the ignoreself option for this jail. */
|
/** Enable or disable the ignoreself option for this jail. */
|
||||||
toggleIgnoreSelf: (on: boolean) => Promise<void>;
|
toggleIgnoreSelf: (on: boolean) => Promise<void>;
|
||||||
|
/** Start the jail. */
|
||||||
|
start: () => Promise<void>;
|
||||||
|
/** Stop the jail. */
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
/** Reload jail configuration. */
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
/** Toggle idle mode on/off for the jail. */
|
||||||
|
setIdle: (on: boolean) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -216,6 +224,26 @@ export function useJailDetail(name: string): UseJailDetailResult {
|
|||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const doStart = async (): Promise<void> => {
|
||||||
|
await startJail(name);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const doStop = async (): Promise<void> => {
|
||||||
|
await stopJail(name);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const doReload = async (): Promise<void> => {
|
||||||
|
await reloadJail(name);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const doSetIdle = async (on: boolean): Promise<void> => {
|
||||||
|
await setJailIdle(name, on);
|
||||||
|
load();
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jail,
|
jail,
|
||||||
ignoreList,
|
ignoreList,
|
||||||
@@ -226,6 +254,10 @@ export function useJailDetail(name: string): UseJailDetailResult {
|
|||||||
addIp,
|
addIp,
|
||||||
removeIp,
|
removeIp,
|
||||||
toggleIgnoreSelf,
|
toggleIgnoreSelf,
|
||||||
|
start: doStart,
|
||||||
|
stop: doStop,
|
||||||
|
reload: doReload,
|
||||||
|
setIdle: doSetIdle,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,15 +33,8 @@ import {
|
|||||||
StopRegular,
|
StopRegular,
|
||||||
} from "@fluentui/react-icons";
|
} from "@fluentui/react-icons";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
|
||||||
reloadJail,
|
|
||||||
setJailIdle,
|
|
||||||
startJail,
|
|
||||||
stopJail,
|
|
||||||
} from "../api/jails";
|
|
||||||
import { useJailDetail } from "../hooks/useJails";
|
import { useJailDetail } from "../hooks/useJails";
|
||||||
import type { Jail } from "../types/jail";
|
import type { Jail } from "../types/jail";
|
||||||
import { ApiError } from "../api/client";
|
|
||||||
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
|
import { BannedIpsSection } from "../components/jail/BannedIpsSection";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -186,9 +179,13 @@ function CodeList({ items, empty }: { items: string[]; empty: string }): React.J
|
|||||||
interface JailInfoProps {
|
interface JailInfoProps {
|
||||||
jail: Jail;
|
jail: Jail;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onStart: () => Promise<void>;
|
||||||
|
onStop: () => Promise<void>;
|
||||||
|
onSetIdle: (on: boolean) => Promise<void>;
|
||||||
|
onReload: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element {
|
function JailInfoSection({ jail, onRefresh, onStart, onStop, onSetIdle, onReload }: JailInfoProps): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [ctrlError, setCtrlError] = useState<string | null>(null);
|
const [ctrlError, setCtrlError] = useState<string | null>(null);
|
||||||
@@ -207,11 +204,9 @@ function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element
|
|||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const msg =
|
const msg =
|
||||||
err instanceof ApiError
|
err instanceof Error
|
||||||
? `${String(err.status)}: ${err.body}`
|
? err.message
|
||||||
: err instanceof Error
|
: String(err);
|
||||||
? err.message
|
|
||||||
: String(err);
|
|
||||||
setCtrlError(msg);
|
setCtrlError(msg);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -259,7 +254,7 @@ function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element
|
|||||||
<Button
|
<Button
|
||||||
appearance="secondary"
|
appearance="secondary"
|
||||||
icon={<StopRegular />}
|
icon={<StopRegular />}
|
||||||
onClick={handle(() => stopJail(jail.name).then(() => void 0))}
|
onClick={handle(onStop)}
|
||||||
>
|
>
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
@@ -269,7 +264,7 @@ function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element
|
|||||||
<Button
|
<Button
|
||||||
appearance="primary"
|
appearance="primary"
|
||||||
icon={<PlayRegular />}
|
icon={<PlayRegular />}
|
||||||
onClick={handle(() => startJail(jail.name).then(() => void 0))}
|
onClick={handle(onStart)}
|
||||||
>
|
>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
@@ -282,7 +277,7 @@ function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element
|
|||||||
<Button
|
<Button
|
||||||
appearance="outline"
|
appearance="outline"
|
||||||
icon={<PauseRegular />}
|
icon={<PauseRegular />}
|
||||||
onClick={handle(() => setJailIdle(jail.name, !jail.idle).then(() => void 0))}
|
onClick={handle(() => onSetIdle(!jail.idle))}
|
||||||
disabled={!jail.running}
|
disabled={!jail.running}
|
||||||
>
|
>
|
||||||
{jail.idle ? "Resume" : "Set Idle"}
|
{jail.idle ? "Resume" : "Set Idle"}
|
||||||
@@ -292,7 +287,7 @@ function JailInfoSection({ jail, onRefresh }: JailInfoProps): React.JSX.Element
|
|||||||
<Button
|
<Button
|
||||||
appearance="outline"
|
appearance="outline"
|
||||||
icon={<ArrowSyncRegular />}
|
icon={<ArrowSyncRegular />}
|
||||||
onClick={handle(() => reloadJail(jail.name).then(() => void 0))}
|
onClick={handle(onReload)}
|
||||||
>
|
>
|
||||||
Reload
|
Reload
|
||||||
</Button>
|
</Button>
|
||||||
@@ -467,12 +462,7 @@ function IgnoreListSection({
|
|||||||
setInputVal("");
|
setInputVal("");
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const msg =
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
err instanceof ApiError
|
|
||||||
? `${String(err.status)}: ${err.body}`
|
|
||||||
: err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: String(err);
|
|
||||||
setOpError(msg);
|
setOpError(msg);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -480,12 +470,7 @@ function IgnoreListSection({
|
|||||||
const handleRemove = (ip: string): void => {
|
const handleRemove = (ip: string): void => {
|
||||||
setOpError(null);
|
setOpError(null);
|
||||||
onRemove(ip).catch((err: unknown) => {
|
onRemove(ip).catch((err: unknown) => {
|
||||||
const msg =
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
err instanceof ApiError
|
|
||||||
? `${String(err.status)}: ${err.body}`
|
|
||||||
: err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: String(err);
|
|
||||||
setOpError(msg);
|
setOpError(msg);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -507,12 +492,7 @@ function IgnoreListSection({
|
|||||||
checked={ignoreSelf}
|
checked={ignoreSelf}
|
||||||
onChange={(_e, data): void => {
|
onChange={(_e, data): void => {
|
||||||
onToggleIgnoreSelf(data.checked).catch((err: unknown) => {
|
onToggleIgnoreSelf(data.checked).catch((err: unknown) => {
|
||||||
const msg =
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
err instanceof ApiError
|
|
||||||
? `${String(err.status)}: ${err.body}`
|
|
||||||
: err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: String(err);
|
|
||||||
setOpError(msg);
|
setOpError(msg);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -592,7 +572,7 @@ function IgnoreListSection({
|
|||||||
export function JailDetailPage(): React.JSX.Element {
|
export function JailDetailPage(): React.JSX.Element {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const { name = "" } = useParams<{ name: string }>();
|
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);
|
useJailDetail(name);
|
||||||
|
|
||||||
if (loading && !jail) {
|
if (loading && !jail) {
|
||||||
@@ -637,7 +617,7 @@ export function JailDetailPage(): React.JSX.Element {
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<JailInfoSection jail={jail} onRefresh={refresh} />
|
<JailInfoSection jail={jail} onRefresh={refresh} onStart={start} onStop={stop} onReload={reload} onSetIdle={setIdle} />
|
||||||
<BannedIpsSection jailName={name} />
|
<BannedIpsSection jailName={name} />
|
||||||
<PatternsSection jail={jail} />
|
<PatternsSection jail={jail} />
|
||||||
<BantimeEscalationSection jail={jail} />
|
<BantimeEscalationSection jail={jail} />
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ function mockHook(ignoreSelf: boolean): void {
|
|||||||
addIp: mockAddIp,
|
addIp: mockAddIp,
|
||||||
removeIp: mockRemoveIp,
|
removeIp: mockRemoveIp,
|
||||||
toggleIgnoreSelf: mockToggleIgnoreSelf,
|
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);
|
vi.mocked(useJailDetail).mockReturnValue(result);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user