Fix BanUnbanForm floating promises and add submit guards

This commit is contained in:
2026-04-19 19:42:39 +02:00
parent 76c9f388a8
commit 082dcc7ee1
3 changed files with 132 additions and 37 deletions

View File

@@ -53,7 +53,11 @@ Issues are grouped by category and ordered roughly by severity. Each entry descr
--- ---
### TASK-003 — `BanUnbanForm` floating promises and no double-submit guard ### TASK-003 — `BanUnbanForm` floating promises and no double-submit guard (done)
**Where fixed:** `frontend/src/pages/jails/BanUnbanForm.tsx`, `frontend/src/pages/jails/__tests__/BanUnbanForm.test.tsx`
**Summary:** Converted ban and unban handlers to async functions with separate submit states and disabled submit buttons while requests are in flight.
**Where found:** `frontend/src/pages/jails/BanUnbanForm.tsx``handleBan` and `handleUnban` are synchronous functions that call `onBan(…).then(…).catch(…)`. The returned promise is not awaited and is not assigned to anything. **Where found:** `frontend/src/pages/jails/BanUnbanForm.tsx``handleBan` and `handleUnban` are synchronous functions that call `onBan(…).then(…).catch(…)`. The returned promise is not awaited and is not assigned to anything.

View File

@@ -29,54 +29,59 @@ export function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps):
const [unbanJail, setUnbanJail] = useState(""); const [unbanJail, setUnbanJail] = useState("");
const [formError, setFormError] = useState<string | null>(null); const [formError, setFormError] = useState<string | null>(null);
const [formSuccess, setFormSuccess] = useState<string | null>(null); const [formSuccess, setFormSuccess] = useState<string | null>(null);
const [isBanning, setIsBanning] = useState(false);
const [isUnbanning, setIsUnbanning] = useState(false);
const handleBan = (): void => { const formatErrorMessage = (err: unknown): string =>
err instanceof ApiError
? `${String(err.status)}: ${err.body}`
: err instanceof Error
? err.message
: String(err);
const handleBan = async (): Promise<void> => {
setFormError(null); setFormError(null);
setFormSuccess(null); setFormSuccess(null);
if (!banIpVal.trim() || !banJail) { if (!banIpVal.trim() || !banJail) {
setFormError("Both IP address and jail are required."); setFormError("Both IP address and jail are required.");
return; return;
} }
onBan(banJail, banIpVal.trim())
.then(() => { setIsBanning(true);
setFormSuccess(`${banIpVal.trim()} banned in ${banJail}.`); try {
setBanIpVal(""); const ip = banIpVal.trim();
}) await onBan(banJail, ip);
.catch((err: unknown) => { setFormSuccess(`${ip} banned in ${banJail}.`);
const msg = setBanIpVal("");
err instanceof ApiError } catch (err: unknown) {
? `${String(err.status)}: ${err.body}` setFormError(formatErrorMessage(err));
: err instanceof Error } finally {
? err.message setIsBanning(false);
: String(err); }
setFormError(msg);
});
}; };
const handleUnban = (fromAllJails: boolean): void => { const handleUnban = async (fromAllJails: boolean): Promise<void> => {
setFormError(null); setFormError(null);
setFormSuccess(null); setFormSuccess(null);
if (!unbanIpVal.trim()) { if (!unbanIpVal.trim()) {
setFormError("IP address is required."); setFormError("IP address is required.");
return; return;
} }
const jail = fromAllJails ? undefined : unbanJail || undefined;
onUnban(unbanIpVal.trim(), jail) setIsUnbanning(true);
.then(() => { try {
const scope = jail ?? "all jails"; const ip = unbanIpVal.trim();
setFormSuccess(`${unbanIpVal.trim()} unbanned from ${scope}.`); const jail = fromAllJails ? undefined : unbanJail || undefined;
setUnbanIpVal(""); await onUnban(ip, jail);
setUnbanJail(""); const scope = jail ?? "all jails";
}) setFormSuccess(`${ip} unbanned from ${scope}.`);
.catch((err: unknown) => { setUnbanIpVal("");
const msg = setUnbanJail("");
err instanceof ApiError } catch (err: unknown) {
? `${String(err.status)}: ${err.body}` setFormError(formatErrorMessage(err));
: err instanceof Error } finally {
? err.message setIsUnbanning(false);
: String(err); }
setFormError(msg);
});
}; };
return ( return (
@@ -130,7 +135,9 @@ export function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps):
</Select> </Select>
</Field> </Field>
</div> </div>
<Button appearance="primary" icon={<LockClosedRegular />} onClick={handleBan}> <Button appearance="primary" icon={<LockClosedRegular />} onClick={() => {
void handleBan();
}} disabled={isBanning}>
Ban Ban
</Button> </Button>
</div> </div>
@@ -167,10 +174,14 @@ export function BanUnbanForm({ jailNames, onBan, onUnban }: BanUnbanFormProps):
</Select> </Select>
</Field> </Field>
</div> </div>
<Button appearance="secondary" icon={<LockOpenRegular />} onClick={() => { handleUnban(false); }}> <Button appearance="secondary" icon={<LockOpenRegular />} onClick={() => {
void handleUnban(false);
}} disabled={isUnbanning}>
Unban Unban
</Button> </Button>
<Button appearance="outline" icon={<LockOpenRegular />} onClick={() => { handleUnban(true); }}> <Button appearance="outline" icon={<LockOpenRegular />} onClick={() => {
void handleUnban(true);
}} disabled={isUnbanning}>
Unban from All Jails Unban from All Jails
</Button> </Button>
</div> </div>

View File

@@ -0,0 +1,80 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { BanUnbanForm } from "../BanUnbanForm";
const getRequiredElement = <T extends Element>(elements: T[], index: number): T => {
const element = elements[index];
if (!element) {
throw new Error(`Expected element at index ${String(index)}`);
}
return element;
};
describe("BanUnbanForm", () => {
it("disables the ban button while the ban request is pending and shows success", async () => {
let resolveBan: () => void = () => undefined;
const onBan = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveBan = resolve;
}),
);
const onUnban = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<BanUnbanForm jailNames={["sshd"]} onBan={onBan} onUnban={onUnban} />);
const banIpInputs = screen.getAllByPlaceholderText("e.g. 192.168.1.100");
const banIpInput = getRequiredElement(banIpInputs, 0);
await user.type(banIpInput, "192.168.1.100");
const selectElements = screen.getAllByRole("combobox");
const banSelect = getRequiredElement(selectElements, 0);
await user.selectOptions(banSelect, "sshd");
const banButton = screen.getByRole("button", { name: /^ban$/i });
await user.click(banButton);
expect(onBan).toHaveBeenCalledWith("sshd", "192.168.1.100");
expect(banButton).toBeDisabled();
resolveBan();
await waitFor(() => {
expect(screen.getByText(/192\.168\.1\.100 banned in sshd\./i)).toBeInTheDocument();
expect(banButton).not.toBeDisabled();
});
});
it("shows an error when unban fails and keeps the unban button disabled during the request", async () => {
let rejectUnban: (reason?: unknown) => void = () => undefined;
const onBan = vi.fn().mockResolvedValue(undefined);
const onUnban = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectUnban = reject;
}),
);
const user = userEvent.setup();
render(<BanUnbanForm jailNames={["sshd"]} onBan={onBan} onUnban={onUnban} />);
const unbanIpInputs = screen.getAllByPlaceholderText("e.g. 192.168.1.100");
const unbanIpInput = getRequiredElement(unbanIpInputs, 1);
await user.type(unbanIpInput, "10.0.0.1");
const unbanButton = screen.getByRole("button", { name: /^unban$/i });
await user.click(unbanButton);
await waitFor(() => {
expect(unbanButton).toBeDisabled();
});
rejectUnban(new Error("unban failed"));
await waitFor(() => {
expect(screen.getByText(/unban failed/i)).toBeInTheDocument();
expect(unbanButton).not.toBeDisabled();
});
});
});