Files
factorio-clusterio/test/controller/routes.js
T
Danielv123 06dcf70514 Stop reading rejected save uploads to the controller
When a host pushes a save to a stream that is gone, or that timed out
before anyone downloaded it, the controller answers 404 or 500 without
reading the body. Node then reads and throws away the rest of the body
to keep the connection open, so a proxy in front of the controller that
keeps passing the upload on made the whole save get uploaded for
nothing. Close the connection if the body hasn't ended a second after
the response.

Document turning off request buffering in reverse proxies, as a
buffering proxy holds the upload back until all of it has arrived.
2026-10-31 23:20:41 +01:00

535 lines
19 KiB
JavaScript

import assert from "node:assert/strict";
import events from "node:events";
import http from "node:http";
import { wait, expositionContentType } from "@clusterio/lib";
import * as routes from "@clusterio/controller/dist/node/src/routes.js";
import * as mock from "../mock.js";
import "../setup_logging.js";
describe("controller/src/routes", function() {
let controller;
let server;
let port;
beforeEach(async function() {
controller = new mock.MockController();
routes.addRouteHandlers(controller.app);
server = http.createServer(controller.app);
server.listen(0, "localhost");
await events.once(server, "listening");
port = server.address().port;
});
afterEach(function() {
server.close();
});
describe("/api/stream/:id", function() {
let endpoint;
beforeEach(function() {
endpoint = `http://localhost:${port}/api/stream`;
});
it("should respond with 404 if stream doesn't exist", async function() {
let response;
response = await fetch(`${endpoint}/01020304`);
assert.equal(response.status, 404);
response = await fetch(`${endpoint}/01020304`, {
method: "PUT", body: "test stream",
});
assert.equal(response.status, 404);
});
it("should respond with 500 if stream times out", async function() {
let stream = await routes.createProxyStream(controller.app);
let response = await fetch(`${endpoint}/${stream.id}`);
assert.equal(response.status, 500);
});
/**
* PUT a body at about 6 MiB/s whether or not the server reads it, the way
* a proxy in front of the controller passes on an upload, until the
* connection closes, and count what the server read.
* @param {string} url - URL to PUT to.
* @param {number} size - Size of the body in bytes.
* @returns {Promise<{ status: number|undefined, serverBytesRead: number }>} result
*/
async function putLargeBody(url, size) {
const sockets = [];
const onConnection = socket => sockets.push(socket);
server.on("connection", onConnection);
const request = http.request(url, { method: "PUT", headers: { "Content-Length": size } });
let status;
request.on("response", response => {
status = response.statusCode;
response.on("error", () => {});
response.resume();
});
// The server may close the connection while the body is being sent
request.on("error", () => {});
const closed = new Promise(resolve => request.on("close", resolve));
const chunk = Buffer.alloc(64 * 1024);
for (let sent = 0; sent < size && !request.closed; sent += chunk.length) {
request.write(chunk);
await wait(10);
}
if (!request.closed) {
request.end();
}
await closed;
server.off("connection", onConnection);
await Promise.all(sockets.map(
socket => socket.destroyed || new Promise(resolve => socket.on("close", resolve))
));
return { status, serverBytesRead: sockets.reduce((total, socket) => total + socket.bytesRead, 0) };
}
it("should not read the body of a PUT to a stream that doesn't exist", async function() {
this.timeout(30000);
const size = 64 * 1024 * 1024;
const result = await putLargeBody(`${endpoint}/01020304`, size);
assert.equal(result.status, 404);
assert(result.serverBytesRead < size / 4, `server read ${result.serverBytesRead} of ${size} bytes`);
});
it("should not read the body of a PUT to a stream that times out", async function() {
this.timeout(30000);
const size = 64 * 1024 * 1024;
const stream = await routes.createProxyStream(controller.app);
const result = await putLargeBody(`${endpoint}/${stream.id}`, size);
assert.equal(result.status, 500);
assert(result.serverBytesRead < size / 4, `server read ${result.serverBytesRead} of ${size} bytes`);
});
it("should passthrough a stream", async function() {
let stream;
let responses;
stream = await routes.createProxyStream(controller.app);
responses = await Promise.all([
(async function() {
await wait(100);
return await fetch(`${endpoint}/${stream.id}`, {
method: "PUT", body: "test content",
});
}()),
fetch(`${endpoint}/${stream.id}`),
]);
assert.equal(responses[0].status, 200);
assert.equal(responses[1].status, 200);
assert.equal(await responses[1].text(), "test content");
stream = await routes.createProxyStream(controller.app);
responses = await Promise.all([
fetch(`${endpoint}/${stream.id}`, {
method: "PUT", body: "test content",
}),
(async function() {
await wait(100);
return await fetch(`${endpoint}/${stream.id}`);
}()),
]);
assert.equal(responses[0].status, 200);
assert.equal(responses[1].status, 200);
assert.equal(await responses[1].text(), "test content");
});
});
describe("/metrics", function() {
let endpoint, testToken;
beforeEach(function() {
endpoint = `http://localhost:${port}/metrics`;
testToken = controller.users.signUserToken({ id: "test" });
});
it("should respond with 401 without a valid token", async function() {
let response;
response = await fetch(endpoint);
assert.equal(response.status, 401);
response = await fetch(endpoint, { headers: { "X-Access-Token": "invalid" } });
assert.equal(response.status, 401);
response = await fetch(endpoint, { headers: { "Authorization": "Bearer invalid" } });
assert.equal(response.status, 401);
response = await fetch(endpoint, { headers: { "Authorization": `Basic ${testToken}` } });
assert.equal(response.status, 401);
const user = controller.users.getOrCreateUser("test");
user.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
user.saveRecord();
response = await fetch(endpoint, { headers: { "Authorization": `Bearer ${testToken}` } });
assert.equal(response.status, 401);
});
it("should respond with 403 when user has insufficient permission", async function() {
const user = controller.users.getOrCreateUser("player");
let response = await fetch(endpoint, {
headers: { "Authorization": `Bearer ${controller.users.signUserToken(user)}` },
});
assert.equal(response.status, 403);
});
it("should respond with metrics to an authorized user", async function() {
for (const headers of [
{ "X-Access-Token": testToken },
{ "Authorization": `Bearer ${testToken}` },
{ "Authorization": `bearer ${testToken}` },
]) {
let response = await fetch(endpoint, { headers });
assert.equal(response.status, 200);
const contentType = response.headers.get("content-type");
for (const part of expositionContentType.split("; ")) {
assert(contentType.includes(part), `expected ${part} in content type ${contentType}`);
}
assert(/^# HELP /m.test(await response.text()), "expected exposition text");
}
});
it("should share one gather between concurrent requests", async function() {
let calls = 0;
controller.hooks.metrics.attach("test", async () => {
calls += 1;
await wait(100);
});
const headers = { "X-Access-Token": testToken };
let responses = await Promise.all([
fetch(endpoint, { headers }),
fetch(endpoint, { headers }),
fetch(endpoint, { headers }),
]);
for (const response of responses) {
assert.equal(response.status, 200);
}
assert.equal(calls, 1);
let response = await fetch(endpoint, { headers });
assert.equal(response.status, 200);
assert.equal(calls, 2);
});
});
describe("stripStaticPrefix()", function() {
it("should remove a leading static/", function() {
assert.equal(routes.stripStaticPrefix("static/main.abc.js"), "main.abc.js");
});
it("should leave other paths alone", function() {
assert.equal(routes.stripStaticPrefix("main.abc.js"), "main.abc.js");
assert.equal(routes.stripStaticPrefix("other/static/main.js"), "other/static/main.js");
});
});
describe("/api/plugins", function() {
let endpoint;
beforeEach(function() {
endpoint = `http://localhost:${port}/api/plugins`;
controller.mockConfigEntries.set("foo.load_plugin", true);
});
it("should strip static/ from the plugin bundle path", async function() {
controller.pluginInfos = [{
name: "foo", version: "1.0.0", npmPackage: "foo",
manifest: { main: "static/foo.abc.js" },
}];
controller.loadedPlugins = new Set(controller.pluginInfos);
let response = await fetch(endpoint);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), [{
name: "foo", version: "1.0.0", enabled: true, loaded: true, npmPackage: "foo",
web: { main: "foo.abc.js" },
}]);
});
it("should report missing manifest and entrypoint", async function() {
controller.pluginInfos = [
{ name: "foo", version: "1.0.0", npmPackage: "foo", webEntrypoint: "dist/web/index.js" },
{ name: "bar", version: "1.0.0", npmPackage: "bar", manifest: {} },
{ name: "baz", version: "1.0.0", npmPackage: "baz", manifest: { "baz.js": "static/baz.abc.js" } },
];
controller.loadedPlugins = new Set([controller.pluginInfos[0]]);
let response = await fetch(endpoint);
assert.equal(response.status, 200);
let data = await response.json();
assert.equal(data[0].web.error, "Missing dist/web/manifest.json");
const incompatible = "Incompatible dist/web/manifest.json, the plugin must be rebuilt with Vite";
assert.equal(data[1].web.error, incompatible);
assert.equal(data[2].web.error, incompatible);
assert.equal(data[1].loaded, false);
assert.equal(data[1].enabled, false);
});
it("should not report a missing manifest for plugins without a web build", async function() {
controller.pluginInfos = [
{ name: "foo", version: "1.0.0", npmPackage: "foo", hostEntrypoint: "dist/node/host.js" },
{ name: "bar", version: "1.0.0", npmPackage: "bar", controllerEntrypoint: "dist/node/controller.js" },
{ name: "baz", version: "1.0.0", npmPackage: "baz", instanceConfigFields: {} },
];
let response = await fetch(endpoint);
assert.equal(response.status, 200);
let data = await response.json();
assert.deepEqual(data[0].web, {});
assert.equal(data[1].web.error, "Missing dist/web/manifest.json");
assert.equal(data[2].web.error, "Missing dist/web/manifest.json");
});
});
describe("/api/cluster-name", function() {
let endpoint;
beforeEach(function() {
endpoint = `http://localhost:${port}/api/cluster-name`;
});
it("should return the cluster name from config", async function() {
controller.mockConfigEntries.set("controller.name", "Test Cluster");
let response = await fetch(endpoint);
assert.equal(response.status, 200);
let data = await response.json();
assert.deepEqual(data, { name: "Test Cluster" });
});
});
describe("/api/upload-save", function() {
let endpoint, testToken;
beforeEach(function() {
endpoint = `http://localhost:${port}/api/upload-save`;
testToken = controller.users.signUserToken({ id: "test" });
});
it("should respond with 401 with invalid token", async function() {
let response;
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: { "Content-Type": "application/zip" },
body: "totally a zip file",
});
assert.equal(response.status, 401);
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": "invalid",
},
body: "totally a zip file",
});
assert.equal(response.status, 401);
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": controller.users.signUserToken({ id: "invalid" }),
},
body: "totally a zip file",
});
assert.equal(response.status, 401);
const user = controller.users.getOrCreateUser("test");
user.tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
user.saveRecord();
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": testToken,
},
body: "totally a zip file",
});
assert.equal(response.status, 401);
});
it("should respond with 403 when user has insufficient permission", async function() {
const user = controller.users.getOrCreateUser("player");
let response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": controller.users.signUserToken(user),
},
body: "totally a zip file",
});
assert.equal(response.status, 403);
});
it("should respond with 415 if content type is missing or invalid", async function() {
let response;
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
},
body: "totally a zip file",
});
assert.equal(response.status, 415);
assert.deepEqual(await response.json(), { request_errors: ["invalid Content-Type"] });
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "invalid",
},
body: "totally a zip file",
});
assert.equal(response.status, 415);
assert.deepEqual(await response.json(), { request_errors: ["invalid Content-Type"] });
response = await fetch(`${endpoint}?instance_id=123&filename=file.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "text/plain",
},
body: "totally a zip file",
});
assert.equal(response.status, 415);
assert.deepEqual(await response.json(), { request_errors: ["invalid Content-Type"] });
});
it("should respond with 400 if invalid multipart request", async function() {
let response;
let form;
form = new FormData();
form.append("instance_id", "123");
form.append("file", new Blob(["totally a zip file"], { type: "text/plain" }), "test.zip");
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
},
body: form,
});
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { request_errors: ["invalid file Content-Type"] });
form = new FormData();
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
form.append("instance_id", "123");
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
},
body: form,
});
assert.equal(response.status, 400);
assert.deepEqual(
await response.json(), { request_errors: ["instance_id must come before files uploaded"] }
);
form = new FormData();
form.append("instance_id", "123");
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.txt");
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
},
body: form,
});
assert.equal(response.status, 400);
assert.deepEqual(
await response.json(), { request_errors: ["filename must end with .zip"] }
);
form = new FormData();
form.append("instance_id", "invalid");
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
},
body: form,
});
assert.equal(response.status, 400);
assert.deepEqual(
await response.json(), { request_errors: ["invalid instance_id"] }
);
});
it("should respond with 400 if invalid file request", async function() {
let response;
response = await fetch(`${endpoint}?instance_id=123`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
body: "totally a zip file",
});
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { request_errors: ["Missing or invalid filename parameter"] });
response = await fetch(`${endpoint}?instance_id=123&filename=test.txt`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
body: "totally a zip file",
});
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { request_errors: ["filename must end with .zip"] });
response = await fetch(`${endpoint}?filename=test.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
body: "totally a zip file",
});
assert.equal(response.status, 400);
assert.deepEqual(
await response.json(), { request_errors: ["Missing or invalid instance_id parameter"] }
);
});
it("should respond with 500 if the transfer failed", async function() {
controller.sendToHostByInstanceId = async (request) => {
throw new Error("Something went wrong");
};
let response;
let form;
form = new FormData();
form.append("instance_id", "123");
form.append("file", new Blob(["totally a zip file"], { type: "application/zip" }), "test.zip");
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
},
body: form,
});
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { errors: ["Something went wrong"], request_errors: [] });
response = await fetch(`${endpoint}?instance_id=123&filename=test.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
body: "totally a zip file",
});
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { errors: ["Something went wrong"], request_errors: [] });
controller.sendToHostByInstanceId = async (request) => {
await new Promise(() => {});
};
response = await fetch(`${endpoint}?instance_id=123&filename=test.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
body: "totally a zip file",
});
assert.equal(response.status, 500);
assert.deepEqual(
await response.json(), { errors: ["Timed out establishing stream to host"], request_errors: [] }
);
});
it("should complete a valid transfer", async function() {
controller.sendToHostByInstanceId = async (request) => {
const stream = await fetch(`http://localhost:${port}/api/stream/${request.streamId}`);
assert.equal(await stream.text(), "totally a zip file");
return request.name;
};
let response;
response = await fetch(`${endpoint}?instance_id=123&filename=test.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
body: "totally a zip file",
});
assert.deepEqual(await response.json(), { saves: ["test.zip"] });
assert.equal(response.status, 200);
});
it("should complete a valid transfer with non-standerd mime type", async function() {
controller.sendToHostByInstanceId = async (request) => {
const stream = await fetch(`http://localhost:${port}/api/stream/${request.streamId}`);
assert.equal(await stream.text(), "totally a zip file");
return request.name;
};
let response;
response = await fetch(`${endpoint}?instance_id=123&filename=test.zip`, {
method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/x-zip-compressed",
},
body: "totally a zip file",
});
assert.deepEqual(await response.json(), { saves: ["test.zip"] });
assert.equal(response.status, 200);
});
});
});