Redesign FiltersTab with active/inactive layout and assign/create dialogs (Tasks 2.3/2.4)

- Rewrite FiltersTab: use fetchFilters() for FilterConfig[] with embedded active
  status; show 'Active — sshd, apache-auth' badge labels; FilterDetail sub-
  component with source_file/override badges, FilterForm, Assign button, raw
  config section
- New AssignFilterDialog: selects jail from enabled-jails list, calls
  POST /config/jails/{name}/filter with optional fail2ban reload
- New CreateFilterDialog: name+failregex+ignoreregex form, calls
  POST /config/filters, closes and selects new filter on success
- Extend ConfigListDetail: add listHeader (for Create button) and
  itemBadgeLabel (for custom badge text) optional props
- Fix updateFilterFile bug: was PUT /config/filters/{name} (structured
  endpoint), now correctly PUT /config/filters/{name}/raw
- Fix createFilterFile bug: was POST /config/filters, now POST /config/filters/raw
- Add updateFilter, createFilter, deleteFilter, assignFilterToJail to api/config.ts
- Add FilterUpdateRequest, FilterCreateRequest, AssignFilterRequest to types/config.ts
- Add configFiltersRaw, configJailFilter endpoints
- Tests: 24 new tests across FiltersTab, AssignFilterDialog, CreateFilterDialog
  (all 89 frontend tests passing)
This commit is contained in:
2026-03-13 18:46:45 +01:00
parent e15ad8fb62
commit 2f60b0915e
12 changed files with 1358 additions and 83 deletions

View File

@@ -0,0 +1,190 @@
/**
* Tests for the AssignFilterDialog component (Task 2.3).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { AssignFilterDialog } from "../AssignFilterDialog";
import type { JailListResponse } from "../../../types/jail";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
assignFilterToJail: vi.fn(),
}));
vi.mock("../../../api/jails", () => ({
fetchJails: vi.fn(),
}));
import { assignFilterToJail } from "../../../api/config";
import { fetchJails } from "../../../api/jails";
const mockAssignFilterToJail = vi.mocked(assignFilterToJail);
const mockFetchJails = vi.mocked(fetchJails);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const mockJailsResponse: JailListResponse = {
jails: [
{
name: "sshd",
enabled: true,
running: true,
idle: false,
backend: "systemd",
find_time: 600,
ban_time: 3600,
max_retry: 5,
status: null,
},
{
name: "apache-auth",
enabled: false,
running: false,
idle: false,
backend: "polling",
find_time: 600,
ban_time: 3600,
max_retry: 5,
status: null,
},
],
total: 2,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderDialog(overrides: Partial<React.ComponentProps<typeof AssignFilterDialog>> = {}) {
const props = {
filterName: "nginx",
open: true,
onClose: vi.fn(),
onAssigned: vi.fn(),
...overrides,
};
return render(
<FluentProvider theme={webLightTheme}>
<AssignFilterDialog {...props} />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("AssignFilterDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchJails.mockResolvedValue(mockJailsResponse);
});
it("renders dialog title when open", async () => {
renderDialog();
await waitFor(() => {
expect(screen.getByText(/assign filter to jail/i)).toBeInTheDocument();
});
});
it("shows the filter name in the dialog body", async () => {
renderDialog({ filterName: "my-filter" });
await waitFor(() => {
expect(screen.getByText("my-filter")).toBeInTheDocument();
});
});
it("fetches jails when dialog opens", async () => {
renderDialog();
await waitFor(() => {
expect(mockFetchJails).toHaveBeenCalledTimes(1);
});
});
it("only shows enabled jails in the dropdown", async () => {
renderDialog();
await waitFor(() => {
// sshd is enabled, apache-auth is not
expect(screen.getByRole("option", { name: "sshd" })).toBeInTheDocument();
expect(
screen.queryByRole("option", { name: "apache-auth" }),
).not.toBeInTheDocument();
});
});
it("calls onClose when Cancel is clicked", async () => {
const onClose = vi.fn();
renderDialog({ onClose });
await waitFor(() => {
expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("Assign button is disabled when no jail is selected", async () => {
renderDialog();
await waitFor(() => {
const assignBtn = screen.getByRole("button", { name: /^assign$/i });
expect(assignBtn).toBeDisabled();
});
});
it("calls assignFilterToJail and onAssigned on confirm", async () => {
const onAssigned = vi.fn();
mockAssignFilterToJail.mockResolvedValue(undefined);
renderDialog({ filterName: "nginx", onAssigned });
// Wait for jails to load.
await waitFor(() => {
expect(screen.getByRole("option", { name: "sshd" })).toBeInTheDocument();
});
// Select a jail.
fireEvent.change(screen.getByRole("combobox"), { target: { value: "sshd" } });
// Click Assign.
fireEvent.click(screen.getByRole("button", { name: /^assign$/i }));
await waitFor(() => {
expect(mockAssignFilterToJail).toHaveBeenCalledWith(
"sshd",
{ filter_name: "nginx" },
false,
);
expect(onAssigned).toHaveBeenCalledTimes(1);
});
});
it("shows error message when assignment fails", async () => {
mockAssignFilterToJail.mockRejectedValue(new Error("Assignment failed"));
renderDialog({ filterName: "nginx" });
await waitFor(() => {
expect(screen.getByRole("option", { name: "sshd" })).toBeInTheDocument();
});
fireEvent.change(screen.getByRole("combobox"), { target: { value: "sshd" } });
fireEvent.click(screen.getByRole("button", { name: /^assign$/i }));
await waitFor(() => {
expect(screen.getByText(/failed to assign filter/i)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,171 @@
/**
* Tests for the CreateFilterDialog component (Task 2.3).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { CreateFilterDialog } from "../CreateFilterDialog";
import type { FilterConfig } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
createFilter: vi.fn(),
}));
import { createFilter } from "../../../api/config";
const mockCreateFilter = vi.mocked(createFilter);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const createdFilter: FilterConfig = {
name: "my-app",
filename: "my-app.local",
before: null,
after: null,
variables: {},
prefregex: null,
failregex: ["^<HOST> failed"],
ignoreregex: [],
maxlines: null,
datepattern: null,
journalmatch: null,
active: false,
used_by_jails: [],
source_file: "/etc/fail2ban/filter.d/my-app.local",
has_local_override: true,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderDialog(
overrides: Partial<React.ComponentProps<typeof CreateFilterDialog>> = {},
) {
const props = {
open: true,
onClose: vi.fn(),
onCreate: vi.fn(),
...overrides,
};
return render(
<FluentProvider theme={webLightTheme}>
<CreateFilterDialog {...props} />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("CreateFilterDialog", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders dialog title when open", () => {
renderDialog();
expect(screen.getByRole("heading", { name: /create filter/i })).toBeInTheDocument();
});
it("does not render when closed", () => {
renderDialog({ open: false });
expect(screen.queryByText(/create filter/i)).not.toBeInTheDocument();
});
it("Create Filter button is disabled when name is empty", () => {
renderDialog();
// Name field is empty by default.
expect(
screen.getByRole("button", { name: /create filter/i }),
).toBeDisabled();
});
it("Create Filter button is enabled after entering a name", () => {
renderDialog();
const nameInput = screen.getByRole("textbox", { name: /filter name/i });
fireEvent.change(nameInput, { target: { value: "my-app" } });
expect(
screen.getByRole("button", { name: /create filter/i }),
).not.toBeDisabled();
});
it("calls onClose when Cancel is clicked", () => {
const onClose = vi.fn();
renderDialog({ onClose });
fireEvent.click(screen.getByRole("button", { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
it("calls createFilter and onCreate on submit", async () => {
const onCreate = vi.fn();
mockCreateFilter.mockResolvedValue(createdFilter);
renderDialog({ onCreate });
const nameInput = screen.getByRole("textbox", { name: /filter name/i });
fireEvent.change(nameInput, { target: { value: "my-app" } });
fireEvent.click(screen.getByRole("button", { name: /create filter/i }));
await waitFor(() => {
expect(mockCreateFilter).toHaveBeenCalledWith({
name: "my-app",
failregex: [],
ignoreregex: [],
});
expect(onCreate).toHaveBeenCalledWith(createdFilter);
});
});
it("shows error message when createFilter fails", async () => {
mockCreateFilter.mockRejectedValue(new Error("Name already exists"));
renderDialog();
const nameInput = screen.getByRole("textbox", { name: /filter name/i });
fireEvent.change(nameInput, { target: { value: "sshd" } });
fireEvent.click(screen.getByRole("button", { name: /create filter/i }));
await waitFor(() => {
expect(screen.getByText(/failed to create filter/i)).toBeInTheDocument();
});
});
it("splits failregex textarea into lines on submit", async () => {
const onCreate = vi.fn();
mockCreateFilter.mockResolvedValue(createdFilter);
renderDialog({ onCreate });
const nameInput = screen.getByRole("textbox", { name: /filter name/i });
fireEvent.change(nameInput, { target: { value: "my-app" } });
const failregexArea = screen.getByRole("textbox", { name: /failregex/i });
fireEvent.change(failregexArea, {
target: { value: "^<HOST> failed\n^<HOST> error" },
});
fireEvent.click(screen.getByRole("button", { name: /create filter/i }));
await waitFor(() => {
expect(mockCreateFilter).toHaveBeenCalledWith(
expect.objectContaining({
failregex: ["^<HOST> failed", "^<HOST> error"],
}),
);
});
});
});

View File

@@ -0,0 +1,233 @@
/**
* Tests for the redesigned FiltersTab component (Task 2.3).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { FiltersTab } from "../FiltersTab";
import type { FilterConfig, FilterListResponse } from "../../../types/config";
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock("../../../api/config", () => ({
fetchFilters: vi.fn(),
fetchFilterFile: vi.fn(),
updateFilterFile: vi.fn(),
}));
// Mock child components to isolate FiltersTab rendering.
vi.mock("../FilterForm", () => ({
FilterForm: ({ name }: { name: string }) => (
<div data-testid={`filter-form-${name}`}>FilterForm:{name}</div>
),
}));
vi.mock("../RawConfigSection", () => ({
RawConfigSection: () => <div data-testid="raw-config-section">RawConfig</div>,
}));
vi.mock("../AssignFilterDialog", () => ({
AssignFilterDialog: ({ open }: { open: boolean }) =>
open ? <div data-testid="assign-dialog">AssignDialog</div> : null,
}));
vi.mock("../CreateFilterDialog", () => ({
CreateFilterDialog: ({ open }: { open: boolean }) =>
open ? <div data-testid="create-dialog">CreateDialog</div> : null,
}));
import { fetchFilters } from "../../../api/config";
const mockFetchFilters = vi.mocked(fetchFilters);
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const activeFilter: FilterConfig = {
name: "sshd",
filename: "sshd.conf",
before: null,
after: null,
variables: {},
prefregex: null,
failregex: ["^<HOST> port \\d+"],
ignoreregex: [],
maxlines: null,
datepattern: null,
journalmatch: null,
active: true,
used_by_jails: ["sshd"],
source_file: "/etc/fail2ban/filter.d/sshd.conf",
has_local_override: false,
};
const inactiveFilter: FilterConfig = {
name: "apache-auth",
filename: "apache-auth.conf",
before: null,
after: null,
variables: {},
prefregex: null,
failregex: [],
ignoreregex: [],
maxlines: null,
datepattern: null,
journalmatch: null,
active: false,
used_by_jails: [],
source_file: "/etc/fail2ban/filter.d/apache-auth.conf",
has_local_override: false,
};
const mockListResponse: FilterListResponse = {
filters: [activeFilter, inactiveFilter],
total: 2,
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function renderTab() {
return render(
<FluentProvider theme={webLightTheme}>
<FiltersTab />
</FluentProvider>,
);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("FiltersTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows a loading skeleton while fetching filters", () => {
// Never resolve the promise so loading stays true.
mockFetchFilters.mockReturnValue(new Promise(() => undefined));
renderTab();
expect(screen.getByLabelText(/loading filters/i)).toBeInTheDocument();
});
it("shows an error message when fetchFilters fails", async () => {
mockFetchFilters.mockRejectedValue(new Error("Network error"));
renderTab();
await waitFor(() => {
expect(screen.getByText(/network error/i)).toBeInTheDocument();
});
});
it("renders filter items after successful load", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
expect(screen.getByText("sshd")).toBeInTheDocument();
expect(screen.getByText("apache-auth")).toBeInTheDocument();
});
});
it("shows Active badge text for active filters", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
// Active filter used by "sshd" jail should show "Active — sshd"
expect(screen.getByText(/active — sshd/i)).toBeInTheDocument();
});
});
it("shows Inactive badge text for inactive filters", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
expect(screen.getByText(/inactive/i)).toBeInTheDocument();
});
});
it("shows Create Filter button", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
expect(
screen.getByRole("button", { name: /create filter/i }),
).toBeInTheDocument();
});
});
it("shows placeholder when no filter is selected", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
expect(
screen.getByText(/select an item from the list/i),
).toBeInTheDocument();
});
});
it("shows FilterForm when a filter is selected", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
const { getByText } = renderTab();
await waitFor(() => {
expect(getByText("sshd")).toBeInTheDocument();
});
getByText("sshd").click();
await waitFor(() => {
expect(screen.getByTestId("filter-form-sshd")).toBeInTheDocument();
});
});
it("shows AssignFilterDialog when Assign to Jail is clicked", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
const { getByText } = renderTab();
await waitFor(() => {
expect(getByText("sshd")).toBeInTheDocument();
});
getByText("sshd").click();
await waitFor(() => {
expect(
screen.getByRole("button", { name: /assign to jail/i }),
).toBeInTheDocument();
});
screen.getByRole("button", { name: /assign to jail/i }).click();
await waitFor(() => {
expect(screen.getByTestId("assign-dialog")).toBeInTheDocument();
});
});
it("calls fetchFilters once on mount", async () => {
mockFetchFilters.mockResolvedValue(mockListResponse);
renderTab();
await waitFor(() => {
expect(mockFetchFilters).toHaveBeenCalledTimes(1);
});
});
});