MVP version
This commit is contained in:
commit
3f96943858
25 changed files with 3962 additions and 0 deletions
388
test/api.test.js
Normal file
388
test/api.test.js
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue