diff --git a/frontend/src/hooks/__tests__/useActionConfig.test.ts b/frontend/src/hooks/__tests__/useActionConfig.test.ts new file mode 100644 index 0000000..aee008c --- /dev/null +++ b/frontend/src/hooks/__tests__/useActionConfig.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import * as configApi from "../../api/config"; +import { useActionConfig } from "../useActionConfig"; + +vi.mock("../../api/config"); + +describe("useActionConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(configApi.fetchAction).mockResolvedValue({ + name: "iptables", + filename: "iptables.conf", + source_file: "/etc/fail2ban/action.d/iptables.conf", + active: false, + used_by_jails: [], + before: null, + after: null, + actionstart: "", + actionstop: "", + actioncheck: "", + actionban: "", + actionunban: "", + actionflush: "", + definition_vars: {}, + init_vars: {}, + has_local_override: false, + }); + vi.mocked(configApi.updateAction).mockResolvedValue(undefined); + }); + + it("calls fetchAction exactly once for stable name and rerenders", async () => { + const { rerender } = renderHook( + ({ name }) => useActionConfig(name), + { initialProps: { name: "iptables" } }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(configApi.fetchAction).toHaveBeenCalledTimes(1); + + // Rerender with the same action name; fetch should not be called again. + rerender({ name: "iptables" }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(configApi.fetchAction).toHaveBeenCalledTimes(1); + }); + + it("calls fetchAction again when name changes", async () => { + const { rerender } = renderHook( + ({ name }) => useActionConfig(name), + { initialProps: { name: "iptables" } }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(configApi.fetchAction).toHaveBeenCalledTimes(1); + + rerender({ name: "ssh" }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(configApi.fetchAction).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/hooks/__tests__/useFilterConfig.test.ts b/frontend/src/hooks/__tests__/useFilterConfig.test.ts new file mode 100644 index 0000000..84d8f9a --- /dev/null +++ b/frontend/src/hooks/__tests__/useFilterConfig.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import * as configApi from "../../api/config"; +import { useFilterConfig } from "../useFilterConfig"; + +vi.mock("../../api/config"); + +describe("useFilterConfig", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(configApi.fetchParsedFilter).mockResolvedValue({ + name: "sshd", + filename: "sshd.conf", + source_file: "/etc/fail2ban/filter.d/sshd.conf", + active: false, + used_by_jails: [], + before: null, + after: null, + variables: {}, + prefregex: null, + failregex: [], + ignoreregex: [], + maxlines: null, + datepattern: null, + journalmatch: null, + has_local_override: false, + }); + vi.mocked(configApi.updateParsedFilter).mockResolvedValue(undefined); + }); + + it("calls fetchParsedFilter only once for stable name", async () => { + const { rerender } = renderHook( + ({ name }) => useFilterConfig(name), + { initialProps: { name: "sshd" } }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1); + + rerender({ name: "sshd" }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1); + }); + + it("calls fetchParsedFilter again when name changes", async () => { + const { rerender } = renderHook( + ({ name }) => useFilterConfig(name), + { initialProps: { name: "sshd" } }, + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(1); + + rerender({ name: "apache-auth" }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(configApi.fetchParsedFilter).toHaveBeenCalledTimes(2); + }); +}); diff --git a/frontend/src/hooks/useActionConfig.ts b/frontend/src/hooks/useActionConfig.ts index 0baf599..b8e8622 100644 --- a/frontend/src/hooks/useActionConfig.ts +++ b/frontend/src/hooks/useActionConfig.ts @@ -2,6 +2,7 @@ * React hook for loading and updating a single parsed action config. */ +import { useCallback } from "react"; import { useConfigItem } from "./useConfigItem"; import { fetchAction, updateAction } from "../api/config"; import type { ActionConfig, ActionConfigUpdate } from "../types/config"; @@ -23,12 +24,18 @@ export interface UseActionConfigResult { * @param name - Action base name (e.g. ``"iptables"``). */ export function useActionConfig(name: string): UseActionConfigResult { + const fetchFn = useCallback(() => fetchAction(name), [name]); + const saveFn = useCallback( + (update: ActionConfigUpdate) => updateAction(name, update), + [name], + ); + const { data, loading, error, saving, saveError, refresh, save } = useConfigItem< ActionConfig, ActionConfigUpdate >({ - fetchFn: () => fetchAction(name), - saveFn: (update) => updateAction(name, update), + fetchFn, + saveFn, mergeOnSave: (prev, update) => prev ? { diff --git a/frontend/src/hooks/useFilterConfig.ts b/frontend/src/hooks/useFilterConfig.ts index b4163d1..387cada 100644 --- a/frontend/src/hooks/useFilterConfig.ts +++ b/frontend/src/hooks/useFilterConfig.ts @@ -2,6 +2,7 @@ * React hook for loading and updating a single parsed filter config. */ +import { useCallback } from "react"; import { useConfigItem } from "./useConfigItem"; import { fetchParsedFilter, updateParsedFilter } from "../api/config"; import type { FilterConfig, FilterConfigUpdate } from "../types/config"; @@ -23,12 +24,18 @@ export interface UseFilterConfigResult { * @param name - Filter base name (e.g. ``"sshd"``). */ export function useFilterConfig(name: string): UseFilterConfigResult { + const fetchFn = useCallback(() => fetchParsedFilter(name), [name]); + const saveFn = useCallback( + (update: FilterConfigUpdate) => updateParsedFilter(name, update), + [name], + ); + const { data, loading, error, saving, saveError, refresh, save } = useConfigItem< FilterConfig, FilterConfigUpdate >({ - fetchFn: () => fetchParsedFilter(name), - saveFn: (update) => updateParsedFilter(name, update), + fetchFn, + saveFn, mergeOnSave: (prev, update) => prev ? { diff --git a/frontend/src/hooks/useJailFileConfig.ts b/frontend/src/hooks/useJailFileConfig.ts index a440bb2..dcfe40e 100644 --- a/frontend/src/hooks/useJailFileConfig.ts +++ b/frontend/src/hooks/useJailFileConfig.ts @@ -2,6 +2,7 @@ * React hook for loading and updating a single parsed jail.d config file. */ +import { useCallback } from "react"; import { useConfigItem } from "./useConfigItem"; import { fetchParsedJailFile, updateParsedJailFile } from "../api/config"; import type { JailFileConfig, JailFileConfigUpdate } from "../types/config"; @@ -21,12 +22,18 @@ export interface UseJailFileConfigResult { * @param filename - Filename including extension (e.g. ``"sshd.conf"``). */ export function useJailFileConfig(filename: string): UseJailFileConfigResult { + const fetchFn = useCallback(() => fetchParsedJailFile(filename), [filename]); + const saveFn = useCallback( + (update: JailFileConfigUpdate) => updateParsedJailFile(filename, update), + [filename], + ); + const { data, loading, error, refresh, save } = useConfigItem< JailFileConfig, JailFileConfigUpdate >({ - fetchFn: () => fetchParsedJailFile(filename), - saveFn: (update) => updateParsedJailFile(filename, update), + fetchFn, + saveFn, mergeOnSave: (prev, update) => update.jails != null && prev ? { ...prev, jails: { ...prev.jails, ...update.jails } }