/** * Tests for the "Add Log Path" form inside JailAccordionPanel (ConfigPage). * * Verifies that: * - The add-log-path input and button are rendered inside the jail accordion. * - The Add button is disabled when the input is empty. * - Submitting a valid path calls `addLogPath` and appends the path to the list. * - An API error is surfaced as 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 { ConfigPage } from "../../pages/ConfigPage"; import type { JailConfig } from "../../types/config"; // --------------------------------------------------------------------------- // Module mocks — use vi.hoisted so refs are available when vi.mock runs // --------------------------------------------------------------------------- const { mockAddLogPath, mockDeleteLogPath, mockUpdateJailConfig, mockReloadConfig, mockFetchGlobalConfig, mockFetchServerSettings, mockFetchJailConfigs, mockFetchMapColorThresholds, mockFetchJailConfigFiles, mockFetchFilterFiles, mockFetchActionFiles, mockUpdateMapColorThresholds, mockUpdateGlobalConfig, mockUpdateServerSettings, mockFlushLogs, mockSetJailConfigFileEnabled, mockUpdateJailConfigFile, } = vi.hoisted(() => ({ mockAddLogPath: vi.fn<() => Promise>().mockResolvedValue(undefined), mockDeleteLogPath: vi.fn<() => Promise>().mockResolvedValue(undefined), mockUpdateJailConfig: vi.fn<() => Promise>().mockResolvedValue(undefined), mockReloadConfig: vi.fn<() => Promise>().mockResolvedValue(undefined), mockFetchGlobalConfig: vi.fn().mockResolvedValue({ config: { ban_time: 600, max_retry: 5, find_time: 300, backend: "auto", }, }), mockFetchServerSettings: vi.fn().mockResolvedValue({ settings: { log_level: "INFO", log_target: "STDOUT", syslog_socket: null, db_path: "/var/lib/fail2ban/fail2ban.sqlite3", db_purge_age: 86400, db_max_matches: 10, }, }), mockFetchJailConfigs: vi.fn(), mockFetchMapColorThresholds: vi.fn().mockResolvedValue({ threshold_high: 100, threshold_medium: 50, threshold_low: 20, }), mockFetchJailConfigFiles: vi.fn().mockResolvedValue({ files: [] }), mockFetchFilterFiles: vi.fn().mockResolvedValue({ files: [] }), mockFetchActionFiles: vi.fn().mockResolvedValue({ files: [] }), mockUpdateMapColorThresholds: vi.fn<() => Promise>().mockResolvedValue(undefined), mockUpdateGlobalConfig: vi.fn<() => Promise>().mockResolvedValue(undefined), mockUpdateServerSettings: vi.fn<() => Promise>().mockResolvedValue(undefined), mockFlushLogs: vi.fn().mockResolvedValue({ message: "ok" }), mockSetJailConfigFileEnabled: vi.fn<() => Promise>().mockResolvedValue(undefined), mockUpdateJailConfigFile: vi.fn<() => Promise>().mockResolvedValue(undefined), })); vi.mock("../../api/config", () => ({ addLogPath: mockAddLogPath, deleteLogPath: mockDeleteLogPath, fetchJailConfigs: mockFetchJailConfigs, fetchJailConfig: vi.fn(), updateJailConfig: mockUpdateJailConfig, reloadConfig: mockReloadConfig, fetchGlobalConfig: mockFetchGlobalConfig, updateGlobalConfig: mockUpdateGlobalConfig, fetchServerSettings: mockFetchServerSettings, updateServerSettings: mockUpdateServerSettings, flushLogs: mockFlushLogs, fetchMapColorThresholds: mockFetchMapColorThresholds, updateMapColorThresholds: mockUpdateMapColorThresholds, fetchJailConfigFiles: mockFetchJailConfigFiles, fetchJailConfigFileContent: vi.fn(), updateJailConfigFile: mockUpdateJailConfigFile, setJailConfigFileEnabled: mockSetJailConfigFileEnabled, fetchFilterFiles: mockFetchFilterFiles, fetchFilterFile: vi.fn(), updateFilterFile: vi.fn(), createFilterFile: vi.fn(), fetchFilters: vi.fn().mockResolvedValue({ filters: [], total: 0 }), fetchFilter: vi.fn(), fetchActionFiles: mockFetchActionFiles, fetchActionFile: vi.fn(), updateActionFile: vi.fn(), createActionFile: vi.fn(), previewLog: vi.fn(), testRegex: vi.fn(), fetchInactiveJails: vi.fn().mockResolvedValue({ jails: [], total: 0 }), activateJail: vi.fn(), deactivateJail: vi.fn(), fetchParsedFilter: vi.fn(), updateParsedFilter: vi.fn(), fetchParsedAction: vi.fn(), updateParsedAction: vi.fn(), fetchParsedJailFile: vi.fn(), updateParsedJailFile: vi.fn(), })); vi.mock("../../api/jails", () => ({ fetchJails: vi.fn().mockResolvedValue({ jails: [], total: 0 }), })); /** Minimal jail fixture used across tests. */ const MOCK_JAIL: JailConfig = { name: "sshd", ban_time: 600, max_retry: 3, find_time: 300, fail_regex: [], ignore_regex: [], log_paths: ["/var/log/auth.log"], date_pattern: null, log_encoding: "UTF-8", backend: "auto", use_dns: "warn", prefregex: "", actions: [], bantime_escalation: null, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function renderConfigPage() { return render( , ); } /** Waits for the sshd list item to appear and clicks it to open the detail pane. */ async function openSshdAccordion(user: ReturnType) { const listItem = await screen.findByRole("option", { name: /sshd/i }); await user.click(listItem); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("ConfigPage — Add Log Path", () => { beforeEach(() => { vi.clearAllMocks(); mockFetchJailConfigs.mockResolvedValue({ jails: [MOCK_JAIL], total: 1 }); mockAddLogPath.mockResolvedValue(undefined); }); it("renders the existing log path and the add-log-path input inside the accordion", async () => { const user = userEvent.setup(); renderConfigPage(); await openSshdAccordion(user); // Existing path from fixture — rendered as an value expect(screen.getByDisplayValue("/var/log/auth.log")).toBeInTheDocument(); // Add-log-path input placeholder expect( screen.getByPlaceholderText("/var/log/example.log"), ).toBeInTheDocument(); }); it("disables the Add button when the path input is empty", async () => { const user = userEvent.setup(); renderConfigPage(); await openSshdAccordion(user); const addBtn = screen.getByRole("button", { name: /add log path/i }); expect(addBtn).toBeDisabled(); }); it("enables the Add button when the path input has content", async () => { const user = userEvent.setup(); renderConfigPage(); await openSshdAccordion(user); const input = screen.getByPlaceholderText("/var/log/example.log"); await user.type(input, "/var/log/nginx/access.log"); const addBtn = screen.getByRole("button", { name: /add log path/i }); expect(addBtn).not.toBeDisabled(); }); it("calls addLogPath and appends the path on successful submission", async () => { const user = userEvent.setup(); renderConfigPage(); await openSshdAccordion(user); const input = screen.getByPlaceholderText("/var/log/example.log"); await user.type(input, "/var/log/nginx/access.log"); const addBtn = screen.getByRole("button", { name: /add log path/i }); await user.click(addBtn); await waitFor(() => { expect(mockAddLogPath).toHaveBeenCalledWith("sshd", { log_path: "/var/log/nginx/access.log", tail: true, }); }); // New path should appear in the list as an value expect(screen.getByDisplayValue("/var/log/nginx/access.log")).toBeInTheDocument(); // Input should be cleared expect(input).toHaveValue(""); }); it("shows a success message after adding a log path", async () => { const user = userEvent.setup(); renderConfigPage(); await openSshdAccordion(user); const input = screen.getByPlaceholderText("/var/log/example.log"); await user.type(input, "/var/log/nginx/access.log"); await user.click(screen.getByRole("button", { name: /add log path/i })); await waitFor(() => { expect( screen.getByText(/added log path.*\/var\/log\/nginx\/access\.log/i), ).toBeInTheDocument(); }); }); it("shows an error message when addLogPath fails", async () => { mockAddLogPath.mockRejectedValueOnce(new Error("Connection refused")); const user = userEvent.setup(); renderConfigPage(); await openSshdAccordion(user); const input = screen.getByPlaceholderText("/var/log/example.log"); await user.type(input, "/var/log/bad.log"); await user.click(screen.getByRole("button", { name: /add log path/i })); await waitFor(() => { expect( screen.getByText("Failed to add log path."), ).toBeInTheDocument(); }); }); });