- Backend: extend activate_jail() with pre-validation and 4-attempt post-reload
health probe; add validate_jail_config() and rollback_jail() service functions
- Backend: new endpoints POST /api/config/jails/{name}/validate,
GET /api/config/pending-recovery, POST /api/config/jails/{name}/rollback
- Backend: extend JailActivationResponse with fail2ban_running + validation_warnings;
add JailValidationIssue, JailValidationResult, PendingRecovery, RollbackResponse models
- Backend: health_check task tracks last_activation and creates PendingRecovery
record when fail2ban goes offline within 60 s of an activation
- Backend: add fail2ban_start_command setting (configurable start cmd for rollback)
- Frontend: ActivateJailDialog — pre-validation on open, crash-detected callback,
extended spinner text during activation+verify
- Frontend: JailsTab — Validate Config button for inactive jails, validation
result panels (blocking errors + advisory warnings)
- Frontend: RecoveryBanner component — polls pending-recovery, shows full-width
alert with Disable & Restart / View Logs buttons
- Frontend: MainLayout — mount RecoveryBanner at layout level
- Tests: 19 new backend service tests (validate, rollback, filter/action parsing)
+ 6 health_check crash-detection tests + 11 router tests; 5 RecoveryBanner
frontend tests; fix mock setup in existing activate_jail tests
142 lines
4.3 KiB
TypeScript
142 lines
4.3 KiB
TypeScript
/**
|
|
* Tests for RecoveryBanner (Task 3).
|
|
*/
|
|
|
|
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 { MemoryRouter } from "react-router-dom";
|
|
import { RecoveryBanner } from "../RecoveryBanner";
|
|
import type { PendingRecovery } from "../../../types/config";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock("../../../api/config", () => ({
|
|
fetchPendingRecovery: vi.fn(),
|
|
rollbackJail: vi.fn(),
|
|
}));
|
|
|
|
import { fetchPendingRecovery, rollbackJail } from "../../../api/config";
|
|
|
|
const mockFetchPendingRecovery = vi.mocked(fetchPendingRecovery);
|
|
const mockRollbackJail = vi.mocked(rollbackJail);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const pendingRecord: PendingRecovery = {
|
|
jail_name: "sshd",
|
|
activated_at: "2024-01-01T12:00:00Z",
|
|
detected_at: "2024-01-01T12:00:30Z",
|
|
recovered: false,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function renderBanner() {
|
|
return render(
|
|
<FluentProvider theme={webLightTheme}>
|
|
<MemoryRouter>
|
|
<RecoveryBanner />
|
|
</MemoryRouter>
|
|
</FluentProvider>,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("RecoveryBanner", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("renders nothing when pending recovery is null", async () => {
|
|
mockFetchPendingRecovery.mockResolvedValue(null);
|
|
|
|
renderBanner();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchPendingRecovery).toHaveBeenCalled();
|
|
});
|
|
|
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders warning when there is an unresolved pending recovery", async () => {
|
|
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
|
|
|
|
renderBanner();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/fail2ban stopped responding after activating jail/i)).toBeInTheDocument();
|
|
});
|
|
|
|
expect(screen.getByText(/sshd/i)).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
|
|
expect(screen.getByRole("button", { name: /view logs/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it("hides the banner when recovery is marked as recovered", async () => {
|
|
const recoveredRecord: PendingRecovery = { ...pendingRecord, recovered: true };
|
|
mockFetchPendingRecovery.mockResolvedValue(recoveredRecord);
|
|
|
|
renderBanner();
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchPendingRecovery).toHaveBeenCalled();
|
|
});
|
|
|
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("calls rollbackJail and hides banner on successful rollback", async () => {
|
|
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
|
|
mockRollbackJail.mockResolvedValue({
|
|
jail_name: "sshd",
|
|
disabled: true,
|
|
fail2ban_running: true,
|
|
active_jails: 0,
|
|
message: "Rolled back.",
|
|
});
|
|
|
|
renderBanner();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
|
|
});
|
|
|
|
await userEvent.click(
|
|
screen.getByRole("button", { name: /disable & restart/i }),
|
|
);
|
|
|
|
expect(mockRollbackJail).toHaveBeenCalledWith("sshd");
|
|
});
|
|
|
|
it("shows rollback error when rollbackJail fails", async () => {
|
|
mockFetchPendingRecovery.mockResolvedValue(pendingRecord);
|
|
mockRollbackJail.mockRejectedValue(new Error("Connection refused"));
|
|
|
|
renderBanner();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole("button", { name: /disable & restart/i })).toBeInTheDocument();
|
|
});
|
|
|
|
await userEvent.click(
|
|
screen.getByRole("button", { name: /disable & restart/i }),
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/rollback failed/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|