- Add has_local_override field to InactiveJail model
- Update _build_inactive_jail and list_inactive_jails to compute the field
- Add delete_jail_local_override() service function
- Add DELETE /api/config/jails/{name}/local router endpoint
- Surface has_local_override in frontend InactiveJail type
- Show Deactivate Jail button in JailsTab when has_local_override is true
- Add tests: TestBuildInactiveJail, TestListInactiveJails, TestDeleteJailLocalOverride
203 lines
6.2 KiB
TypeScript
203 lines
6.2 KiB
TypeScript
/**
|
|
* Tests for ActivateJailDialog (Task 7).
|
|
*
|
|
* Covers:
|
|
* - "Activate" button is disabled when pre-validation returns blocking issues.
|
|
* - "Activate" button is enabled when validation passes.
|
|
* - Dialog stays open and shows an error when the backend returns active=false.
|
|
* - `onActivated` is called and dialog closes when backend returns active=true.
|
|
*/
|
|
|
|
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 { ActivateJailDialog } from "../ActivateJailDialog";
|
|
import type { InactiveJail, JailActivationResponse, JailValidationResult } from "../../../types/config";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mocks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
vi.mock("../../../api/config", () => ({
|
|
activateJail: vi.fn(),
|
|
validateJailConfig: vi.fn(),
|
|
}));
|
|
|
|
import { activateJail, validateJailConfig } from "../../../api/config";
|
|
|
|
const mockActivateJail = vi.mocked(activateJail);
|
|
const mockValidateJailConfig = vi.mocked(validateJailConfig);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const baseJail: InactiveJail = {
|
|
name: "airsonic-auth",
|
|
filter: "airsonic-auth",
|
|
actions: ["iptables-multiport"],
|
|
port: "8080",
|
|
logpath: ["/var/log/airsonic/airsonic.log"],
|
|
bantime: "10m",
|
|
findtime: "10m",
|
|
maxretry: 5,
|
|
ban_time_seconds: 600,
|
|
find_time_seconds: 600,
|
|
log_encoding: "auto",
|
|
backend: "auto",
|
|
date_pattern: null,
|
|
use_dns: "warn",
|
|
prefregex: "",
|
|
fail_regex: ["Failed login.*from <HOST>"],
|
|
ignore_regex: [],
|
|
bantime_escalation: null,
|
|
source_file: "/config/fail2ban/jail.d/airsonic-auth.conf",
|
|
enabled: false,
|
|
has_local_override: false,
|
|
};
|
|
|
|
/** Successful activation response. */
|
|
const successResponse: JailActivationResponse = {
|
|
name: "airsonic-auth",
|
|
active: true,
|
|
message: "Jail activated successfully.",
|
|
fail2ban_running: true,
|
|
validation_warnings: [],
|
|
};
|
|
|
|
/** Response when backend blocks activation (e.g. missing logpath). */
|
|
const blockedResponse: JailActivationResponse = {
|
|
name: "airsonic-auth",
|
|
active: false,
|
|
message: "Jail 'airsonic-auth' cannot be activated: logpath does not exist.",
|
|
fail2ban_running: true,
|
|
validation_warnings: ["logpath: /var/log/airsonic/airsonic.log does not exist"],
|
|
};
|
|
|
|
/** Validation result with a logpath issue (should block the button). */
|
|
const validationWithLogpathIssue: JailValidationResult = {
|
|
jail_name: "airsonic-auth",
|
|
valid: false,
|
|
issues: [{ field: "logpath", message: "/var/log/airsonic/airsonic.log does not exist" }],
|
|
};
|
|
|
|
/** Validation result with no issues. */
|
|
const validationPassed: JailValidationResult = {
|
|
jail_name: "airsonic-auth",
|
|
valid: true,
|
|
issues: [],
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface DialogProps {
|
|
jail?: InactiveJail | null;
|
|
open?: boolean;
|
|
onClose?: () => void;
|
|
onActivated?: () => void;
|
|
}
|
|
|
|
function renderDialog({
|
|
jail = baseJail,
|
|
open = true,
|
|
onClose = vi.fn(),
|
|
onActivated = vi.fn(),
|
|
}: DialogProps = {}) {
|
|
return render(
|
|
<FluentProvider theme={webLightTheme}>
|
|
<ActivateJailDialog
|
|
jail={jail}
|
|
open={open}
|
|
onClose={onClose}
|
|
onActivated={onActivated}
|
|
/>
|
|
</FluentProvider>,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe("ActivateJailDialog", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("disables the Activate button when pre-validation returns blocking issues", async () => {
|
|
mockValidateJailConfig.mockResolvedValue(validationWithLogpathIssue);
|
|
|
|
renderDialog();
|
|
|
|
// Wait for validation to complete and the error message to appear.
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/configuration errors detected/i)).toBeInTheDocument();
|
|
});
|
|
|
|
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
|
|
expect(activateBtn).toBeDisabled();
|
|
});
|
|
|
|
it("enables the Activate button when validation passes", async () => {
|
|
mockValidateJailConfig.mockResolvedValue(validationPassed);
|
|
|
|
renderDialog();
|
|
|
|
// Wait for validation spinner to disappear.
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
|
|
expect(activateBtn).not.toBeDisabled();
|
|
});
|
|
|
|
it("keeps the dialog open and shows an error when backend returns active=false", async () => {
|
|
mockValidateJailConfig.mockResolvedValue(validationPassed);
|
|
mockActivateJail.mockResolvedValue(blockedResponse);
|
|
|
|
const onActivated = vi.fn();
|
|
renderDialog({ onActivated });
|
|
|
|
// Wait for validation to finish.
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
|
|
await userEvent.click(activateBtn);
|
|
|
|
// The server's error message should appear.
|
|
await waitFor(() => {
|
|
expect(
|
|
screen.getByText(/cannot be activated/i),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
// onActivated must NOT have been called.
|
|
expect(onActivated).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("calls onActivated when backend returns active=true", async () => {
|
|
mockValidateJailConfig.mockResolvedValue(validationPassed);
|
|
mockActivateJail.mockResolvedValue(successResponse);
|
|
|
|
const onActivated = vi.fn();
|
|
renderDialog({ onActivated });
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/validating configuration/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
const activateBtn = screen.getByRole("button", { name: /^activate$/i });
|
|
await userEvent.click(activateBtn);
|
|
|
|
await waitFor(() => {
|
|
expect(onActivated).toHaveBeenCalledOnce();
|
|
});
|
|
});
|
|
});
|