Add log path to jail via inline form in ConfigPage

The JailAccordionPanel previously allowed deleting log paths but
had no UI to add new ones. The backend endpoint, API helper, and
hook all existed; only the UI was missing.

Changes:
- ConfigPage.tsx: import addLogPath/AddLogPathRequest; add state
  (newLogPath, newLogPathTail, addingLogPath) and handleAddLogPath
  callback to JailAccordionPanel; render inline form below the
  log-path list with Input, Switch (tail/head), and labeled Add
  button that appends on success and surfaces errors inline.
- ConfigPageLogPath.test.tsx: 6 tests covering render, disabled
  state, enabled state, successful add, success feedback, and API
  error handling. All 33 frontend tests pass.
This commit is contained in:
2026-03-12 19:16:20 +01:00
parent 28f7b1cfcd
commit 59464a1592
3 changed files with 896 additions and 26 deletions

View File

@@ -0,0 +1,229 @@
/**
* 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
// ---------------------------------------------------------------------------
/** 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",
actions: [],
};
const mockAddLogPath = vi.fn().mockResolvedValue(undefined);
const mockDeleteLogPath = vi.fn().mockResolvedValue(undefined);
const mockUpdateJailConfig = vi.fn().mockResolvedValue(undefined);
const mockReloadConfig = vi.fn().mockResolvedValue(undefined);
const mockFetchGlobalConfig = vi.fn().mockResolvedValue({
config: {
ban_time: 600,
max_retry: 5,
find_time: 300,
backend: "auto",
},
});
const 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,
},
});
const mockFetchJailConfigs = vi.fn().mockResolvedValue({
jails: [MOCK_JAIL],
total: 1,
});
const mockFetchMapColorThresholds = vi.fn().mockResolvedValue({
threshold_high: 100,
threshold_medium: 50,
threshold_low: 20,
});
const mockFetchJailConfigFiles = vi.fn().mockResolvedValue({ files: [] });
const mockFetchFilterFiles = vi.fn().mockResolvedValue({ files: [] });
const mockFetchActionFiles = vi.fn().mockResolvedValue({ files: [] });
const mockUpdateMapColorThresholds = vi.fn().mockResolvedValue({});
const mockUpdateGlobalConfig = vi.fn().mockResolvedValue(undefined);
const mockUpdateServerSettings = vi.fn().mockResolvedValue(undefined);
const mockFlushLogs = vi.fn().mockResolvedValue({ message: "ok" });
const mockSetJailConfigFileEnabled = vi.fn().mockResolvedValue(undefined);
vi.mock("../../api/config", () => ({
addLogPath: (...args: unknown[]) => mockAddLogPath(...args),
deleteLogPath: (...args: unknown[]) => mockDeleteLogPath(...args),
fetchJailConfigs: () => mockFetchJailConfigs(),
fetchJailConfig: vi.fn(),
updateJailConfig: (...args: unknown[]) => mockUpdateJailConfig(...args),
reloadConfig: () => mockReloadConfig(),
fetchGlobalConfig: () => mockFetchGlobalConfig(),
updateGlobalConfig: (...args: unknown[]) => mockUpdateGlobalConfig(...args),
fetchServerSettings: () => mockFetchServerSettings(),
updateServerSettings: (...args: unknown[]) => mockUpdateServerSettings(...args),
flushLogs: () => mockFlushLogs(),
fetchMapColorThresholds: () => mockFetchMapColorThresholds(),
updateMapColorThresholds: (...args: unknown[]) =>
mockUpdateMapColorThresholds(...args),
fetchJailConfigFiles: () => mockFetchJailConfigFiles(),
fetchJailConfigFileContent: vi.fn(),
setJailConfigFileEnabled: (...args: unknown[]) =>
mockSetJailConfigFileEnabled(...args),
fetchFilterFiles: () => mockFetchFilterFiles(),
fetchFilterFile: vi.fn(),
updateFilterFile: vi.fn(),
createFilterFile: vi.fn(),
fetchActionFiles: () => mockFetchActionFiles(),
fetchActionFile: vi.fn(),
updateActionFile: vi.fn(),
createActionFile: vi.fn(),
previewLog: vi.fn(),
testRegex: vi.fn(),
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderConfigPage() {
return render(
<FluentProvider theme={webLightTheme}>
<ConfigPage />
</FluentProvider>,
);
}
/** Waits for the sshd accordion button to appear and clicks it open. */
async function openSshdAccordion(user: ReturnType<typeof userEvent.setup>) {
const accordionBtn = await screen.findByRole("button", { name: /sshd/i });
await user.click(accordionBtn);
}
// ---------------------------------------------------------------------------
// 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
expect(screen.getByText("/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
expect(screen.getByText("/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();
});
});
});