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:
2026-03-14 19:50:55 +01:00
parent 936946010f
commit 4b6e118a88
4 changed files with 369 additions and 2 deletions

View File

@@ -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"}

View File

@@ -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();
});
});