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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
233
frontend/src/components/config/__tests__/FiltersTab.test.tsx
Normal file
233
frontend/src/components/config/__tests__/FiltersTab.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user