gameservers-control/test/api.test.js
2026-06-12 14:34:44 +02:00

388 lines
12 KiB
JavaScript

import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "gameservers-control-"));
process.env.STORAGE_DIR = tmpRoot;
process.env.GAMESERVERS_STATUS_FILE = path.join(tmpRoot, "status.json");
fs.mkdirSync(path.join(tmpRoot, "server-images"), { recursive: true });
const { createApp } = await import("../src/app.js");
const { hashPassword } = await import("../src/services/crypto.js");
const { pruneLogs } = await import("../src/services/storage.js");
const servers = [
{
id: "offline-server",
game: "Offline Server",
address: "games.example:1111",
port: 1111,
startProfile: "TEST",
secrets: { password: "secret-1" },
},
{
id: "online-server",
game: "Online Server",
address: "games.example:2222",
port: 2222,
startProfile: "PALWORLD",
secrets: { serverPassword: "secret-2", adminSecret: "admin-secret" },
},
];
const adminLoginPassword = "admin-password";
const playerPassword = "player-password";
fs.writeFileSync(
path.join(tmpRoot, "servers.json"),
`${JSON.stringify(servers, null, 2)}\n`
);
fs.writeFileSync(
process.env.GAMESERVERS_STATUS_FILE,
`${JSON.stringify(
{
servers: [
{ id: "offline-server", game: "Offline Server", address: "games.example:1111", port: 1111, active: false },
{ id: "online-server", game: "Online Server", address: "games.example:2222", port: 2222, active: true },
],
},
null,
2
)}\n`
);
const originalStatus = fs.readFileSync(process.env.GAMESERVERS_STATUS_FILE, "utf8");
fs.writeFileSync(
path.join(tmpRoot, "groups.json"),
`${JSON.stringify(
[
{
id: "admin",
name: "Admin",
admin: true,
passwordHash: await hashPassword(adminLoginPassword),
permissions: { servers: servers.map((server) => server.id) },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
{
id: "players",
name: "Players",
admin: false,
passwordHash: await hashPassword(playerPassword),
permissions: { servers: ["offline-server"] },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
null,
2
)}\n`
);
fs.writeFileSync(path.join(tmpRoot, "launch-logs.json"), "[]\n");
fs.writeFileSync(path.join(tmpRoot, "server-images.json"), "{}\n");
const app = createApp();
const server = app.listen(0);
await new Promise((resolve) => server.once("listening", resolve));
const baseUrl = `http://127.0.0.1:${server.address().port}`;
test.after(() => server.close());
test("rejects invalid password and accepts group password", async () => {
const rejected = await request("/api/login", {
method: "POST",
body: { password: "wrong" },
});
assert.equal(rejected.status, 401);
const accepted = await request("/api/login", {
method: "POST",
body: { password: playerPassword },
});
assert.equal(accepted.status, 200);
assert.deepEqual(accepted.body.session, { admin: false });
assert.equal(accepted.body.group, undefined);
assert.ok(accepted.body.token);
});
test("does not expose group details through non-admin session", async () => {
const token = await login(playerPassword);
const response = await request("/api/session", { token });
assert.equal(response.status, 200);
assert.deepEqual(response.body.session, { admin: false });
assert.equal(response.body.group, undefined);
});
test("filters servers and shows secrets for visible non-admin servers", async () => {
const token = await login(playerPassword);
const response = await request("/api/servers", { token });
assert.equal(response.status, 200);
assert.equal(response.body.servers.length, 1);
assert.equal(response.body.servers[0].id, "offline-server");
assert.equal(response.body.servers[0].secrets.password, "secret-1");
assert.equal(response.body.servers[0].hasSecrets, true);
});
test("admin sees all servers and secrets", async () => {
const token = await login(adminLoginPassword);
const response = await request("/api/servers", { token });
assert.equal(response.status, 200);
assert.equal(response.body.servers.length, 2);
assert.equal(response.body.servers[0].secrets.password, "secret-1");
assert.equal(response.body.servers[1].secrets.serverPassword, "secret-2");
assert.deepEqual(Object.keys(response.body.servers[1].secrets), ["serverPassword"]);
});
test("status file is authoritative for server list and names", async () => {
fs.writeFileSync(
process.env.GAMESERVERS_STATUS_FILE,
`${JSON.stringify(
{
servers: [
{
id: "offline-server",
game: "Renamed Server",
address: "games.example:1111",
port: 1111,
active: false,
},
{
id: "new-server",
game: "New Server",
address: "games.example:3333",
port: 3333,
active: false,
},
],
},
null,
2
)}\n`
);
const token = await login(adminLoginPassword);
const response = await request("/api/servers", { token });
const names = response.body.servers.map((server) => server.game);
assert.deepEqual(names, ["Renamed Server", "New Server"]);
assert.equal(response.body.servers[0].id, "offline-server");
assert.equal(response.body.servers[0].canStart, true);
assert.equal(response.body.servers[1].id, "new-server");
assert.equal(response.body.servers[1].canStart, false);
fs.writeFileSync(process.env.GAMESERVERS_STATUS_FILE, originalStatus);
});
test("starts an offline authorized server and writes a log", async () => {
const token = await login(playerPassword);
const start = await request("/api/servers/offline-server/start", {
method: "POST",
token,
});
assert.equal(start.status, 200);
assert.equal(start.body.status, "started");
const adminToken = await login(adminLoginPassword);
const logs = await request("/api/logs", { token: adminToken });
assert.equal(logs.status, 200);
assert.equal(logs.body.logs[0].groupId, "players");
assert.equal(logs.body.logs[0].serverId, "offline-server");
});
test("refuses to start online or unauthorized servers", async () => {
const playerToken = await login(playerPassword);
const unauthorized = await request("/api/servers/online-server/start", {
method: "POST",
token: playerToken,
});
assert.equal(unauthorized.status, 404);
const adminToken = await login(adminLoginPassword);
const online = await request("/api/servers/online-server/start", {
method: "POST",
token: adminToken,
});
assert.equal(online.status, 409);
});
test("admin can create, update, regenerate, and delete a group", async () => {
const token = await login(adminLoginPassword);
const created = await request("/api/admin/groups", {
method: "POST",
token,
body: { name: "Guests", password: "manual-password", permissions: { servers: [] } },
});
assert.equal(created.status, 201);
assert.equal(created.body.password, "manual-password");
const manualLoginToken = await login("manual-password");
assert.ok(manualLoginToken);
const groupId = created.body.group.id;
const updated = await request(`/api/admin/groups/${groupId}/permissions`, {
method: "PATCH",
token,
body: { servers: ["offline-server"] },
});
assert.equal(updated.status, 200);
assert.deepEqual(updated.body.group.permissions.servers, ["offline-server"]);
const regenerated = await request(`/api/admin/groups/${groupId}/regenerate-password`, {
method: "POST",
token,
});
assert.equal(regenerated.status, 200);
assert.ok(regenerated.body.password);
const manualPassword = await request(`/api/admin/groups/${groupId}/password`, {
method: "PATCH",
token,
body: { password: "replacement-password" },
});
assert.equal(manualPassword.status, 200);
assert.equal(manualPassword.body.password, "replacement-password");
const replacementLoginToken = await login("replacement-password");
assert.ok(replacementLoginToken);
const deleted = await request(`/api/admin/groups/${groupId}`, {
method: "DELETE",
token,
});
assert.equal(deleted.status, 200);
});
test("admin can upload and delete a server image", async () => {
const token = await login(adminLoginPassword);
const formData = new FormData();
formData.set(
"image",
new Blob([Buffer.from("fake-image")], { type: "image/png" }),
"server.png"
);
const uploaded = await request("/api/admin/servers/offline-server/image", {
method: "POST",
token,
body: formData,
});
assert.equal(uploaded.status, 200);
assert.match(uploaded.body.heroImage, /^\/server-images\/offline-server-\d+\.png$/);
const serversResponse = await request("/api/servers", { token });
assert.equal(serversResponse.body.servers[0].heroImage, uploaded.body.heroImage);
const deleted = await request("/api/admin/servers/offline-server/image", {
method: "DELETE",
token,
});
assert.equal(deleted.status, 200);
const refreshed = await request("/api/servers", { token });
assert.equal(refreshed.body.servers[0].heroImage, null);
});
test("admin can update a server password", async () => {
const token = await login(adminLoginPassword);
const updated = await request("/api/admin/servers/offline-server/password", {
method: "PATCH",
token,
body: { password: "new-server-password" },
});
assert.equal(updated.status, 200);
assert.equal(updated.body.hasPassword, true);
const serversResponse = await request("/api/servers", { token });
const server = serversResponse.body.servers.find((entry) => entry.id === "offline-server");
assert.equal(server.secrets.password, "new-server-password");
});
test("admin can cleanup local config for servers absent from status", async () => {
const token = await login(adminLoginPassword);
fs.writeFileSync(
path.join(tmpRoot, "servers.json"),
`${JSON.stringify(
[
...servers,
{
id: "deleted-server",
game: "Deleted Server",
address: "games.example:4444",
port: 4444,
startProfile: "DELETED",
secrets: { password: "old" },
},
],
null,
2
)}\n`
);
fs.writeFileSync(
path.join(tmpRoot, "server-images.json"),
`${JSON.stringify({ "deleted-server": { filename: "deleted-server.png", url: "/server-images/deleted-server.png" } })}\n`
);
fs.writeFileSync(path.join(tmpRoot, "server-images", "deleted-server.png"), "fake");
const cleaned = await request("/api/admin/servers/cleanup", {
method: "POST",
token,
});
assert.equal(cleaned.status, 200);
assert.deepEqual(cleaned.body.removedServers, [
{ id: "deleted-server", game: "Deleted Server" },
]);
assert.deepEqual(cleaned.body.removedImages, ["deleted-server"]);
const nextServers = JSON.parse(fs.readFileSync(path.join(tmpRoot, "servers.json"), "utf8"));
assert.equal(nextServers.some((server) => server.id === "deleted-server"), false);
assert.equal(fs.existsSync(path.join(tmpRoot, "server-images", "deleted-server.png")), false);
});
test("prunes launch logs older than seven days", () => {
const logsFile = path.join(tmpRoot, "launch-logs.json");
fs.writeFileSync(
logsFile,
JSON.stringify(
[
{ createdAt: new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString(), groupId: "old" },
{ createdAt: new Date().toISOString(), groupId: "new" },
],
null,
2
)
);
const logs = pruneLogs();
assert.equal(logs.length, 1);
assert.equal(logs[0].groupId, "new");
});
async function login(password) {
const response = await request("/api/login", {
method: "POST",
body: { password },
});
assert.equal(response.status, 200);
return response.body.token;
}
async function request(pathname, { method = "GET", token, body } = {}) {
const headers = {};
if (token) headers.Authorization = `Bearer ${token}`;
if (body && !(body instanceof FormData)) headers["Content-Type"] = "application/json";
const response = await fetch(`${baseUrl}${pathname}`, {
method,
headers,
body: body && !(body instanceof FormData) ? JSON.stringify(body) : body,
});
const payload = await response.json();
return { status: response.status, body: payload };
}