feat(rcon): discover commands and autocomplete server help
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { createElement } from "react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RconConsole } from "@/components/rcon-console";
|
||||
|
||||
const actionState = vi.hoisted(() => ({
|
||||
authorized: 0,
|
||||
authFailure: false,
|
||||
helpResponses: {} as Record<string, string>,
|
||||
requestedBeforeSend: [] as boolean[],
|
||||
selected: [] as unknown[],
|
||||
transactionSelected: [] as unknown[],
|
||||
updates: [] as Record<string, unknown>[],
|
||||
@@ -17,6 +25,7 @@ const actionState = vi.hoisted(() => ({
|
||||
vi.mock("@/lib/auth/require-admin", () => ({
|
||||
requireAdminSession: async () => {
|
||||
actionState.authorized += 1;
|
||||
if (actionState.authFailure) throw new Error("REDIRECT:/admin/login");
|
||||
return { email: "admin@example.test", name: "Admin" };
|
||||
},
|
||||
}));
|
||||
@@ -73,8 +82,9 @@ vi.mock("@/lib/rcon-credentials", () => ({
|
||||
|
||||
vi.mock("@/lib/rcon-gateway", () => ({
|
||||
executeRcon: async (connection: Record<string, unknown>, command: string) => {
|
||||
actionState.requestedBeforeSend.push(actionState.audits.at(-1)?.type.endsWith(".requested") === true);
|
||||
actionState.executions.push({ connection, command });
|
||||
return actionState.gatewayResult;
|
||||
return command in actionState.helpResponses ? { ok: true, response: actionState.helpResponses[command] } : actionState.gatewayResult;
|
||||
},
|
||||
testRconConnection: vi.fn(),
|
||||
}));
|
||||
@@ -115,8 +125,12 @@ const savedServer = {
|
||||
};
|
||||
|
||||
describe("RCON server actions", () => {
|
||||
afterEach(() => cleanup());
|
||||
beforeEach(() => {
|
||||
actionState.authorized = 0;
|
||||
actionState.authFailure = false;
|
||||
actionState.helpResponses = {};
|
||||
actionState.requestedBeforeSend = [];
|
||||
actionState.selected = [];
|
||||
actionState.transactionSelected = [];
|
||||
actionState.updates = [];
|
||||
@@ -127,6 +141,55 @@ describe("RCON server actions", () => {
|
||||
actionState.gatewayResult = { ok: true, response: "private response" };
|
||||
});
|
||||
|
||||
it("routes UI discovery and usage through independently authorized, audit-before-send RCON actions", async () => {
|
||||
actionState.selected = [savedServer];
|
||||
actionState.helpResponses = {
|
||||
help: "Help: Index (1/2)\n/leaf: Private protection description",
|
||||
"help 2": "Help: Index (2/2)\n/tyrant: Tyrant features",
|
||||
"help tyrant": "Usage: /tyrant <menu|status>",
|
||||
};
|
||||
render(createElement(RconConsole, { servers: [savedServer] }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
|
||||
await screen.findByText("2 commands discovered · best-effort server help");
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "tyrant " } });
|
||||
await screen.findByLabelText("Command usage");
|
||||
expect(actionState.executions.map((entry) => entry.command)).toEqual(["help", "help 2", "help tyrant"]);
|
||||
expect(actionState.authorized).toBe(3);
|
||||
expect(actionState.requestedBeforeSend).toEqual([true, true, true]);
|
||||
expect(actionState.audits).toHaveLength(6);
|
||||
for (let index = 0; index < 6; index += 2) {
|
||||
expect(actionState.audits[index]?.admin).toEqual({ email: "admin@example.test", name: "Admin" });
|
||||
expect(actionState.audits[index]?.correlationId).toBe(actionState.audits[index + 1]?.correlationId);
|
||||
}
|
||||
const audit = JSON.stringify(actionState.audits);
|
||||
expect(audit).not.toContain("Private protection description");
|
||||
expect(audit).not.toContain("/tyrant <menu|status>");
|
||||
expect(audit).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it.each(["authorization", "enabled-connection", "audit"])("does not send discovery requests when %s fails", async (failure) => {
|
||||
actionState.selected = failure === "enabled-connection" ? [] : [savedServer];
|
||||
actionState.authFailure = failure === "authorization";
|
||||
actionState.auditFailure = failure === "audit";
|
||||
render(createElement(RconConsole, { servers: [savedServer] }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
|
||||
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
|
||||
expect(actionState.authorized).toBe(1);
|
||||
expect(actionState.executions).toEqual([]);
|
||||
expect(document.body.textContent).not.toContain("REDIRECT:");
|
||||
});
|
||||
|
||||
it.each(["busy", "timeout", "unavailable"] as const)("preserves safe %s gateway outcomes for UI discovery", async (reason) => {
|
||||
actionState.selected = [savedServer];
|
||||
actionState.gatewayResult = { ok: false, reason };
|
||||
render(createElement(RconConsole, { servers: [savedServer] }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Refresh commands" }));
|
||||
await screen.findByText("0 commands discovered · incomplete; refresh to retry");
|
||||
expect(actionState.executions).toHaveLength(1);
|
||||
expect(actionState.audits[1]?.data).toEqual(expect.objectContaining({ success: false, reason }));
|
||||
expect((screen.getByLabelText("Command") as HTMLInputElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("independently authorizes every exported operation before accepting input", async () => {
|
||||
await expect(createRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
|
||||
await expect(updateRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
|
||||
|
||||
Reference in New Issue
Block a user