Files
minecraft-account-manager/apps/web/src/app/admin/(console)/rcon/actions.test.ts
T
dmg ed3f3cd843
CI / validate (push) Successful in 6m38s
Release / release (push) Successful in 8m29s
feat(rcon): add audited command history
2026-08-13 21:26:24 -04:00

249 lines
9.3 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const actionState = vi.hoisted(() => ({
authorized: 0,
selected: [] as unknown[],
transactionSelected: [] as unknown[],
updates: [] as Record<string, unknown>[],
inserts: [] as unknown[],
audits: [] as Array<{ admin: unknown; subject: string; type: string; data: Record<string, unknown>; correlationId?: string }>,
auditFailure: false,
executions: [] as Array<{ connection: Record<string, unknown>; command: string }>,
gatewayResult: { ok: true, response: "private response" } as
| { ok: true; response: string }
| { ok: false; reason: "busy" | "timeout" | "unavailable" },
}));
vi.mock("@/lib/auth/require-admin", () => ({
requireAdminSession: async () => {
actionState.authorized += 1;
return { email: "admin@example.test", name: "Admin" };
},
}));
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
vi.mock("next/navigation", () => ({
redirect: (path: string) => {
throw new Error(`REDIRECT:${path}`);
},
}));
vi.mock("@/lib/database", () => {
function selection(result: unknown[]) {
const chain = {
from: () => chain,
where: () => chain,
limit: async () => result,
};
return chain;
}
const tx = {
execute: async () => undefined,
select: () => selection(actionState.transactionSelected),
update: () => ({
set: (value: Record<string, unknown>) => ({
where: async () => { actionState.updates.push(value); },
}),
}),
insert: () => ({
values: async (value: unknown) => { actionState.inserts.push(value); },
}),
};
return {
db: {
select: () => selection(actionState.selected),
transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx),
},
};
});
vi.mock("@/lib/rcon-validation", () => ({
validateRconCommand: (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null,
validateRconConnection: (input: { name?: string; host?: string; port?: number; password?: string }) => {
if (!input.name || !input.host || !input.port) return null;
return input;
},
}));
vi.mock("@/lib/rcon-credentials", () => ({
decryptRconPassword: () => "decrypted-password",
encryptRconPassword: vi.fn(),
rconCommandDigest: () => "hmac-sha256:v1:digest",
}));
vi.mock("@/lib/rcon-gateway", () => ({
executeRcon: async (connection: Record<string, unknown>, command: string) => {
actionState.executions.push({ connection, command });
return actionState.gatewayResult;
},
testRconConnection: vi.fn(),
}));
vi.mock("@/lib/audit", () => ({
recordAdminSubjectEvent: async (
admin: unknown,
subject: string,
type: string,
data: Record<string, unknown>,
options?: { correlationId?: string },
) => {
if (actionState.auditFailure) throw new Error("audit unavailable");
actionState.audits.push({ admin, subject, type, data, correlationId: options?.correlationId });
return "22222222-2222-4222-8222-222222222222";
},
}));
import {
createRconServer,
deleteRconServer,
executeRconCommand,
setRconServerEnabled,
testSavedRconServer,
updateRconServer,
} from "./actions";
const serverId = "11111111-1111-4111-8111-111111111111";
const savedServer = {
id: serverId,
name: "Season 4",
host: "season4.somc.svc.cluster.local",
port: 25575,
encryptedPassword: "ciphertext",
enabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
describe("RCON server actions", () => {
beforeEach(() => {
actionState.authorized = 0;
actionState.selected = [];
actionState.transactionSelected = [];
actionState.updates = [];
actionState.inserts = [];
actionState.audits = [];
actionState.auditFailure = false;
actionState.executions = [];
actionState.gatewayResult = { ok: true, response: "private response" };
});
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");
await expect(setRconServerEnabled(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=unknown-connection");
await expect(deleteRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=confirmation-required");
await expect(testSavedRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=connection-unavailable");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, new FormData())).resolves.toEqual({
status: "error",
message: "Enter one command of at most 1,024 bytes without control characters.",
serverId: "",
});
expect(actionState.authorized).toBe(6);
});
it("preserves the encrypted password on an unrelated connection update", async () => {
actionState.transactionSelected = [savedServer];
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("name", "Renamed server");
formData.set("host", "season4.somc.svc.cluster.local");
formData.set("port", "25575");
formData.set("password", "");
formData.set("enabled", "yes");
await expect(updateRconServer(formData)).rejects.toThrow("REDIRECT:/admin/rcon?saved=updated");
expect(actionState.updates).toEqual([
expect.objectContaining({ encryptedPassword: "ciphertext", enabled: true }),
]);
expect(JSON.stringify(actionState.inserts)).not.toContain("ciphertext");
expect(JSON.stringify(actionState.inserts)).not.toContain("decrypted-password");
});
it("rechecks enabled saved state and records complete command lifecycle audits", async () => {
actionState.selected = [savedServer];
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("command", "say private value");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
status: "success",
message: "private response",
serverId,
});
expect(actionState.authorized).toBe(1);
expect(actionState.executions).toEqual([{
connection: expect.objectContaining({ id: serverId, enabled: true, password: "decrypted-password" }),
command: "say private value",
}]);
expect(actionState.audits).toEqual([
expect.objectContaining({
admin: { email: "admin@example.test", name: "Admin" },
subject: `rcon-server/${serverId}`,
type: "games.minecraft.account-manager.rcon.command.requested",
data: expect.objectContaining({ command: "say private value", verb: "say", commandDigest: "hmac-sha256:v1:digest" }),
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
}),
expect.objectContaining({
subject: `rcon-server/${serverId}`,
type: "games.minecraft.account-manager.rcon.command.completed",
data: expect.objectContaining({ success: true, durationMs: expect.any(Number) }),
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
}),
]);
const serializedAudits = JSON.stringify(actionState.audits);
expect(actionState.audits[0]?.correlationId).toBe(actionState.audits[1]?.correlationId);
expect(serializedAudits).toContain("private value");
expect(serializedAudits).not.toContain("private response");
expect(serializedAudits).not.toContain("decrypted-password");
});
it.each([
["busy", "Another command is already running for this server."],
["timeout", "The RCON request timed out."],
["unavailable", "The RCON server was unavailable or rejected authentication."],
] as const)("returns a safe %s failure without exposing transport details", async (reason, message) => {
actionState.selected = [savedServer];
actionState.gatewayResult = { ok: false, reason };
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("command", "list");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
status: "error",
message,
serverId,
});
expect(JSON.stringify(actionState.audits)).not.toContain("decrypted-password");
});
it("does not send a command when its requested audit cannot be recorded", async () => {
actionState.selected = [savedServer];
actionState.auditFailure = true;
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("command", "list");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
status: "error",
message: "Command not sent because its audit record could not be created.",
serverId,
});
expect(actionState.executions).toEqual([]);
});
it("does not execute or audit when the enabled connection is unavailable", async () => {
const formData = new FormData();
formData.set("serverId", serverId);
formData.set("command", "list");
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
status: "error",
message: "That RCON connection is disabled or unavailable.",
serverId,
});
expect(actionState.executions).toEqual([]);
expect(actionState.audits).toEqual([]);
});
});