249 lines
9.3 KiB
TypeScript
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([]);
|
|
});
|
|
});
|