import assert from "node:assert/strict"; import events from "node:events"; import FormData from "form-data"; 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); }); 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: { "foo.js": "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": "remoteEntry.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"); 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); }); }); });