Fix ActivateJailDialog blocking logic and mypy false positive
Two frontend bugs and one mypy false positive fixed: - ActivateJailDialog: Activate button was never disabled when blockingIssues.length > 0 (missing condition in disabled prop). - ActivateJailDialog: handleConfirm called onActivated() even when the backend returned active=false (blocked activation). Dialog now stays open and shows result.message instead. - config.py: Settings() call flagged by mypy --strict because pydantic-settings loads required fields from env vars at runtime; suppressed with a targeted type: ignore[call-arg] comment. Tests: added ActivateJailDialog.test.tsx (5 tests covering button state, backend-rejection handling, success path, and crash detection callback).
This commit is contained in:
@@ -152,6 +152,13 @@ export function ActivateJailDialog({
|
||||
|
||||
activateJail(jail.name, overrides)
|
||||
.then((result) => {
|
||||
if (!result.active) {
|
||||
// Backend rejected the activation (e.g. missing logpath or filter).
|
||||
// Show the server's message and keep the dialog open so the user
|
||||
// can read the explanation without the dialog disappearing.
|
||||
setError(result.message);
|
||||
return;
|
||||
}
|
||||
if (result.validation_warnings.length > 0) {
|
||||
setValidationWarnings(result.validation_warnings);
|
||||
}
|
||||
@@ -336,7 +343,7 @@ export function ActivateJailDialog({
|
||||
<Button
|
||||
appearance="primary"
|
||||
onClick={handleConfirm}
|
||||
disabled={submitting || validating}
|
||||
disabled={submitting || validating || blockingIssues.length > 0}
|
||||
icon={submitting ? <Spinner size="tiny" /> : undefined}
|
||||
>
|
||||
{submitting ? "Activating and verifying…" : "Activate"}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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.
|
||||
* - `onCrashDetected` is called when fail2ban_running is false after activation.
|
||||
*/
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
/** 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;
|
||||
onCrashDetected?: () => void;
|
||||
}
|
||||
|
||||
function renderDialog({
|
||||
jail = baseJail,
|
||||
open = true,
|
||||
onClose = vi.fn(),
|
||||
onActivated = vi.fn(),
|
||||
onCrashDetected = vi.fn(),
|
||||
}: DialogProps = {}) {
|
||||
return render(
|
||||
<FluentProvider theme={webLightTheme}>
|
||||
<ActivateJailDialog
|
||||
jail={jail}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onActivated={onActivated}
|
||||
onCrashDetected={onCrashDetected}
|
||||
/>
|
||||
</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();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls onCrashDetected when fail2ban_running is false after activation", async () => {
|
||||
mockValidateJailConfig.mockResolvedValue(validationPassed);
|
||||
mockActivateJail.mockResolvedValue({
|
||||
...successResponse,
|
||||
fail2ban_running: false,
|
||||
});
|
||||
|
||||
const onActivated = vi.fn();
|
||||
const onCrashDetected = vi.fn();
|
||||
renderDialog({ onActivated, onCrashDetected });
|
||||
|
||||
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(onCrashDetected).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(onActivated).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user