Files
factorio-clusterio/test/controller/routes.js
T
BastiaanandClaude Fable 5.1 1a10b38743 Only report a missing plugin web build when one is expected (#1013)
Plugins with only host, instance or ctl code have no reason to ship a
webpack build, yet the plugin list showed "Missing dist/web/manifest.json"
as an error for them and the controller logged a warning on every start.

Decide whether a build is expected from the plugin declaration, using the
same rule the create tool uses to decide when to generate one: a web or
controller entrypoint, or any config fields. Plugins outside that rule get
no bundle path and no error, the web UI skips loading them, and the plugin
pages show them as having no web module instead of an error or a prompt to
reload.

Fixes #1004

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 01:59:16 +01:00

420 lines
15 KiB
JavaScript

import assert from "node:assert/strict";
import events from "node:events";
import FormData from "form-data";
import http from "node:http";
import { wait } from "@clusterio/lib";
import * as routes from "@clusterio/controller/dist/node/src/routes.js";
import * as mock from "../mock.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);
});
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("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.plugins = new Map([["foo", {}]]);
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: { "foo.js": "static/foo.abc.js" },
}];
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": "remoteEntry.js" } },
];
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");
assert.equal(data[1].web.error, "Missing bar.js entry in manifest.json");
assert.equal(data[2].web.error, "Incompatible old remoteEntry.js entrypoint.");
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", "totally a zip file", { filename: "test.zip", contentType: "text/plain" });
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
body: form.getBuffer(),
});
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { request_errors: ["invalid file Content-Type"] });
form = new FormData();
form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
form.append("instance_id", "123");
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
body: form.getBuffer(),
});
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", "totally a zip file", { filename: "test.txt", contentType: "application/zip" });
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
body: form.getBuffer(),
});
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", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
body: form.getBuffer(),
});
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", "totally a zip file", { filename: "test.zip", contentType: "application/zip" });
response = await fetch(endpoint, {
method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
body: form.getBuffer(),
});
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);
});
});
});