Add upload save feature

Add /api/upload-save endpoint that streams the data via the slave
assigned to the target instance to the instance saves directory, along
with a ctl command and web interface button to do the upload.
This commit is contained in:
Hornwitser committed 2021-06-01 16:27:02 +02:00
1 parent 0066a06107
commit 2e314ff098
18 files changed
+756 -36

No files matched your search

+8
View File
@@ -109,6 +109,14 @@ This should be less than both the timeout and collection interval configured for
Defaults to 8.
### master.stream_timeout
Timeout in seconds for proxy streams through the master that has been created but is not yet sending data to be automatically closed.
Streams that have started sending data before this timeout is not affected.
Defaults to 15.
### master.default_role_id
Role to automatically grant to new users.
+51
View File
@@ -378,6 +378,57 @@ instanceCommands.add(new libCommand.Command({
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["upload-save <instance> <filepath>", "Upload a save to an instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to upload to", type: "string" });
yargs.positional("filepath", { describe: "Path to save to upload", type: "string" });
yargs.options({
"name": { describe: "Name to give save on server", nargs: 1, type: "string" },
});
}],
handler: async function(args, control) {
let filename = args.name || path.basename(args.filepath);
if (!filename.endsWith(".zip")) {
throw new libErrors.CommandError("Save name must end with .zip");
}
// phin doesn't support streaming requests :(
let content = await fs.readFile(args.filepath);
let instanceId = await libCommand.resolveInstance(control, args.instance);
let url = new URL(control.config.get("control.master_url"));
url.pathname += "api/upload-save";
url.searchParams.append("instance_id", instanceId);
url.searchParams.append("filename", filename);
let result = await phin({
url, method: "POST",
headers: {
"X-Access-Token": control.config.get("control.master_token"),
"Content-Type": "application/zip",
},
core: { ca: control.tlsCa },
data: content,
parse: "json",
});
for (let error of result.body.errors || []) {
logger.error(error);
}
for (let requestError of result.body.request_errors || []) {
logger.error(error);
}
if (result.body.saves && result.body.saves.length) {
logger.info(`Successfully uploaded as ${result.body.saves[0]}`);
}
if ((result.body.errors || []).length || (result.body.request_errors || []).length) {
throw new libErrors.CommandError("Uploading save failed");
}
},
}));
instanceCommands.add(new libCommand.Command({
definition: ["export-data <instance>", "Export item icons and locale from instance", (yargs) => {
yargs.positional("instance", { describe: "Instance to export from", type: "string" });
+7
View File
@@ -101,6 +101,13 @@ MasterGroup.define({
type: "number",
initial_value: 8,
});
MasterGroup.define({
name: "proxy_stream_timeout",
title: "Proxy Stream Timeout",
description: "Timeout in seconds for proxy streams to start flowing",
type: "number",
initial_value: 15,
});
MasterGroup.define({
name: "default_role_id",
title: "Default role",
+13
View File
@@ -533,6 +533,19 @@ messages.createSave = new Request({
},
});
messages.pullSave = new Request({
type: "pull_save",
links: ["master-slave"],
requestProperties: {
"instance_id": { type: "integer" },
"stream_id": { type: "string" },
"filename": { type: "string" },
},
responseProperties: {
"save": { type: "string" },
},
});
messages.setSaveListSubscriptions = new Request({
type: "set_save_list_subscriptions",
links: ["control-master"],
+5
View File
@@ -381,6 +381,11 @@ definePermission({
title: "Create new instance save",
description: "Create new savegames on instances.",
});
definePermission({
name: "core.instance.save.upload",
title: "Upload save to instance",
description: "Upload savegames to instances.",
});
definePermission({
name: "core.instance.save.list_subscribe",
title: "Subscribe to save list updatess",
+1 -1
View File
@@ -20,9 +20,9 @@
},
"dependencies": {
"@clusterio/lib": "^2.0.0-alpha.6",
"busboy": "^0.3.1",
"compression": "^1.7.2",
"express": "^4.16.2",
"express-fileupload": "1.2.0",
"fs-extra": "^8.1.0",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",
+1 -19
View File
@@ -23,25 +23,7 @@ class BaseConnection extends libLink.Link {
}
async forwardRequestToInstance(message, request) {
let instance = this._master.instances.get(message.data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`);
}
let slaveId = instance.config.get("instance.assigned_slave");
if (slaveId === null) {
throw new libErrors.RequestError("Instance is not assigned to a slave");
}
let connection = this._master.wsServer.slaveConnections.get(slaveId);
if (!connection) {
throw new libErrors.RequestError("Slave containing instance is not connected");
}
if (request.plugin && !connection.plugins.has(request.plugin)) {
throw new libErrors.RequestError(`Slave containing instance does not have ${request.plugin} plugin`);
}
return await request.send(connection, message.data);
return await this._master.forwardRequestToInstance(request, message.data);
}
async forwardEventToInstance(message, event) {
+36 -2
View File
@@ -2,7 +2,6 @@
const compression = require("compression");
const events = require("events");
const express = require("express");
const expressFileupload = require("express-fileupload");
const fs = require("fs-extra");
const http = require("http");
const https = require("https");
@@ -49,6 +48,7 @@ class Master {
this.app = express();
this.app.locals.master = this;
this.app.locals.streams = new Map();
/**
* Mapping of slave id to slave info
@@ -336,7 +336,6 @@ class Master {
hitCounter.inc();
next();
});
app.use(expressFileupload());
app.use(compression());
// Set folder to serve static content from (the website)
@@ -492,6 +491,41 @@ class Master {
});
};
}
/**
* Forward the given request to the slave contaning the instance
*
* Finds the slave which the instance with the given instance ID is
* currently assigned to and forwards the request to that instance.
* The request data must have an instance_id parameter.
*
* @param {module:lib/link.Request} request - The request to send.
* @param {object} data - Data to pass with the request.
* @param {number} data.instance_id -
* ID of instance to send this request towards
* @returns {object} data returned from the request.
*/
async forwardRequestToInstance(request, data) {
let instance = this.instances.get(data.instance_id);
if (!instance) {
throw new libErrors.RequestError(`Instance with ID ${data.instance_id} does not exist`);
}
let slaveId = instance.config.get("instance.assigned_slave");
if (slaveId === null) {
throw new libErrors.RequestError("Instance is not assigned to a slave");
}
let connection = this.wsServer.slaveConnections.get(slaveId);
if (!connection) {
throw new libErrors.RequestError("Slave containing instance is not connected");
}
if (request.plugin && !connection.plugins.has(request.plugin)) {
throw new libErrors.RequestError(`Slave containing instance does not have ${request.plugin} plugin`);
}
return await request.send(connection, data);
}
}
module.exports = Master;
+226
View File
@@ -1,13 +1,18 @@
"use strict";
const Busboy = require("busboy");
const crypto = require("crypto");
const events = require("events");
const fs = require("fs-extra");
const JSZip = require("jszip");
const jwt = require("jsonwebtoken");
const path = require("path");
const util = require("util");
const libHelpers = require("@clusterio/lib/helpers");
const libLink = require("@clusterio/lib/link");
const libPlugin = require("@clusterio/lib/plugin");
const libPrometheus = require("@clusterio/lib/prometheus");
const { logger } = require("@clusterio/lib/logging");
const { endpointHitCounter } = require("./metrics");
@@ -106,6 +111,36 @@ function validateSlaveToken(req, res, next) {
next();
}
function validateUserToken(req, res, next) {
let token = req.header("x-access-token");
if (!token) {
res.sendStatus(401);
return;
}
try {
let tokenPayload = jwt.verify(
token,
req.app.locals.master.config.get("master.auth_secret"),
{ audience: "user" }
);
let user = req.app.locals.master.userManager.users.get(tokenPayload.user);
if (!user) {
throw new Error("invalid user");
}
if (tokenPayload.iat < user.tokenValidAfter) {
throw new Error("invalid token");
}
res.locals.user = user;
} catch (err) {
res.sendStatus(401);
return;
}
next();
}
// Handle an uploaded export package.
async function uploadExport(req, res) {
endpointHitCounter.labels(req.route.path).inc();
@@ -142,6 +177,191 @@ async function uploadExport(req, res) {
res.sendStatus(200);
}
async function createProxyStream(app) {
let asyncRandomBytes = util.promisify(crypto.randomBytes);
let id = (await asyncRandomBytes(8)).toString("hex");
let stream = {
id,
flowing: false,
size: null,
mime: null,
events: new events.EventEmitter(),
timeout: setTimeout(() => {
stream.events.emit("timeout");
}, app.locals.master.config.get("master.proxy_stream_timeout") * 1000),
};
stream.events.on("close", () => {
clearTimeout(stream.timeout);
app.locals.streams.delete(id);
});
stream.events.on("timeout", () => {
stream.events.emit("close");
});
app.locals.streams.set(id, stream);
return stream;
}
async function getStream(req, res) {
let stream = req.app.locals.streams.get(req.params.id);
if (!stream || stream.flowing) {
res.sendStatus(404);
return;
}
function startStream() {
res.append("Content-Type", stream.mime);
if (stream.size) {
res.append("Content-Length", stream.size);
}
stream.source.pipe(res);
stream.flowing = true;
res.on("finish", () => {
stream.events.emit("close");
});
clearTimeout(stream.timeout);
}
if (stream.source) {
startStream();
} else {
stream.events.on("source", startStream);
stream.events.on("timeout", () => {
res.sendStatus(500);
});
}
}
async function uploadSave(req, res) {
try {
res.locals.user.checkPermission("core.instance.save.upload");
} catch (err) {
res.status(403).json({ request_errors: [err.message] });
return;
}
let contentType = req.get("Content-Type");
let match = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+)/.exec(contentType);
if (!match) {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
let mimeType = match[1].toLowerCase();
let tasks = [];
let errors = [];
let requestErrors = [];
let saves = [];
async function handleFile(instanceId, stream, filename, streamMime) {
let proxyStream = await createProxyStream(req.app);
proxyStream.source = stream;
proxyStream.mime = streamMime;
let timeout = new Promise((_, reject) => {
proxyStream.events.on("timeout", () => {
stream.resume();
reject(new Error("Timed out establishing stream to slave"));
});
});
try {
let result = await Promise.race([
req.app.locals.master.forwardRequestToInstance(libLink.messages.pullSave, {
instance_id: instanceId,
stream_id: proxyStream.id,
filename,
}),
timeout,
]);
saves.push(result.save);
} catch (err) {
proxyStream.events.emit("close");
logger.error(`Error uploading save: ${err.message}`);
errors.push(err.message);
stream.resume();
}
}
if (mimeType === "multipart/form-data") {
await new Promise(resolve => {
let fields = {};
let busboy = new Busboy({ headers: req.headers });
busboy.on("file", (fieldname, stream, filename, transferEncoding, fileMime) => {
if (fields.instanceId === undefined) {
requestErrors.push("instance_id must come before files uploaded");
}
if (fileMime !== "application/zip") {
requestErrors.push("invalid file Content-Type");
}
if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
if (errors.length || requestErrors.length) {
stream.resume();
return;
}
tasks.push(handleFile(fields.instanceId, stream, filename, fileMime));
});
busboy.on("field", (fieldname, value, fieldnameTruncated, valueTruncated, transferEncoding, valueMime) => {
if (fieldname === "instance_id") {
fields.instanceId = Number.parseInt(value, 10);
if (Number.isNaN(fields.instanceId)) {
requestErrors.push("invalid instance_id");
}
}
});
busboy.on("finish", resolve);
busboy.on("error", (err) => {
logger.error(`Error parsing multipart request in upload-save:\n${err.stack}`);
errors.push(err.message);
});
req.pipe(busboy);
});
} else if (mimeType === "application/zip") {
let filename = req.query.filename;
if (typeof filename !== "string") {
requestErrors.push("Missing or invalid filename parameter");
} else if (!filename.endsWith(".zip")) {
requestErrors.push("filename must end with .zip");
}
let instanceId = Number.parseInt(req.query.instance_id, 10);
if (Number.isNaN(instanceId)) {
requestErrors.push("Missing or invalid instance_id parameter");
}
if (errors.length || requestErrors.length) {
req.resume();
} else {
tasks.push(handleFile(instanceId, req, filename, mimeType));
}
} else {
res.status(415).json({ request_errors: ["invalid Content-Type"] });
return;
}
await Promise.all(tasks);
if (errors.length) {
res.status(500);
res.json({ errors, request_errors: requestErrors });
return;
}
if (requestErrors.length) {
res.status(400);
res.json({ request_errors: requestErrors });
return;
}
res.json({ saves });
}
function addRouteHandlers(app) {
app.get("/metrics", (req, res, next) => getMetrics(req, res, next).catch(next));
app.get("/api/plugins", getPlugins);
@@ -149,6 +369,11 @@ function addRouteHandlers(app) {
validateSlaveToken,
(req, res, next) => uploadExport(req, res).catch(next)
);
app.get("/api/stream/:id", (req, res, next) => getStream(req, res).catch(next));
app.post("/api/upload-save",
validateUserToken,
(req, res, next) => uploadSave(req, res).catch(next)
);
}
// Routes used in the web interface and served by the master server
@@ -170,4 +395,5 @@ const webRoutes = [
module.exports = {
addRouteHandlers,
webRoutes,
createProxyStream,
};
+60
View File
@@ -37,6 +37,7 @@ const libConfig = require("@clusterio/lib/config");
const libSharedCommands = require("@clusterio/lib/shared_commands");
const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging");
const libLoggingUtils = require("@clusterio/lib/logging_utils");
const libHelpers = require("@clusterio/lib/helpers");
const instanceRconCommandDuration = new libPrometheus.Histogram(
@@ -1374,6 +1375,65 @@ class Slave extends libLink.Link {
await request.send(instanceConnection, message.data);
}
async pullSaveRequestHandler(message) {
let { instance_id, stream_id, filename } = message.data;
try {
checkFilename(filename);
} catch (err) {
throw new libErrors.RequestError(`Save name ${err.message}`);
}
let instanceInfo = this.instanceInfos.get(instance_id);
if (!instanceInfo) {
throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`);
}
let url = new URL(this.config.get("slave.master_url"));
url.pathname += `api/stream/${stream_id}`;
let response = await phin({
url, method: "GET",
core: { ca: this.tlsCa },
stream: true,
});
if (response.statusCode !== 200) {
let content = await libHelpers.readStream(response);
throw new libErrors.RequestError(`Stream returned ${response.statusCode}: ${content.toString()}`);
}
let savesDir = path.join(instanceInfo.path, "saves");
let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip");
let stream;
while (true) {
try {
stream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" });
await events.once(stream, "open");
break;
} catch (err) {
if (err.code === "EEXIST") {
tempFilename = await libFileOps.findUnusedName(savesDir, tempFilename, ".tmp.zip");
} else {
throw err;
}
}
}
response.pipe(stream);
await events.once(stream, "finish");
filename = await libFileOps.findUnusedName(savesDir, filename, ".zip");
await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, filename));
let instanceConnection = this.instanceConnections.get(instance_id);
let saveList;
if (instanceConnection) {
saveList = (await libLink.messages.listSaves.send(instanceConnection, { instance_id })).list;
} else {
saveList = await Instance.listSaves(savesDir, null);
}
libLink.messages.saveListUpdate.send(this, { instance_id, list: saveList });
return { save: filename };
}
async exportDataRequestHandler(message, request) {
let instanceId = message.data.instance_id;
let instanceConnection = await this._connectInstance(instanceId);
@@ -12,10 +12,8 @@ import InstanceConsole from "./InstanceConsole";
import InstanceRcon from "./InstanceRcon";
import AssignInstanceModal from "./AssignInstanceModal";
import StartStopInstanceButton from "./StartStopInstanceButton";
import CreateSaveModal from "./CreateSaveModal";
import LoadScenarioModal from "./LoadScenarioModal";
import SavesList from "./SavesList";
import SectionHeader from "./SectionHeader";
import { notifyErrorHandler } from "../util/notify";
import { useInstance } from "../model/instance";
import { useSlave } from "../model/slave";
@@ -116,7 +114,6 @@ export default function InstanceViewPage(props) {
<Descriptions.Item label="Status">{instance["status"]}</Descriptions.Item>
</Descriptions>
<SectionHeader title="Saves" extra=<CreateSaveModal instance={instance} /> />
<SavesList instance={instance} />
<Title level={5} style={{ marginTop: 16 }}>Console</Title>
<InstanceConsole id={instanceId} />
+30 -2
View File
@@ -1,11 +1,13 @@
import React, { useContext, useState } from "react";
import { Button, Space, Table, Tooltip } from "antd";
import { Button, Space, Table, Tooltip, Upload } from "antd";
import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined";
import LeftOutlined from "@ant-design/icons/LeftOutlined";
import libLink from "@clusterio/lib/link";
import ControlContext from "./ControlContext";
import CreateSaveModal from "./CreateSaveModal";
import SectionHeader from "./SectionHeader";
import { useSaves } from "../model/saves";
import { notifyErrorHandler } from "../util/notify";
@@ -26,7 +28,7 @@ export default function SavesList(props) {
let saves = useSaves(props.instance.id);
let [starting, setStarting] = useState(false);
return <Table
const saveTable = <Table
size="small"
columns={[
{
@@ -78,4 +80,30 @@ export default function SavesList(props) {
</Space>,
}}
/>;
let uploadProps = {
name: "file",
accept: ".zip",
headers: {
"X-Access-Token": control.connector.token,
},
data: {
instance_id: props.instance.id,
},
showUploadList: false,
action: `${webRoot}api/upload-save`,
// TODO: show progress to user.
};
return <div>
<SectionHeader title="Saves" extra=<Space>
<Upload {...uploadProps} >
<Button>Upload save</Button>
</Upload>
<CreateSaveModal instance={props.instance} />
</Space> />
<Upload.Dragger className="save-list-dragger" openFileDialogOnClick={false} {...uploadProps}>
{saveTable}
</Upload.Dragger>
</div>;
}
+4
View File
@@ -164,6 +164,10 @@ body {
white-space: pre-wrap;
}
.save-list-dragger .ant-upload.ant-upload-btn {
padding: 0;
}
.rcon-result code {
display: block;
background: #000;
+19
View File
@@ -358,6 +358,25 @@ describe("Integration of Clusterio", function() {
});
});
describe("instance upload-save", function() {
it("should upload a zip file", async function() {
await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test");
await execCtl("instance upload-save 44 upload.zip");
assert(
await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "upload.zip")),
"file not uploaded to saves directory"
);
});
it("should reject non-zip files", async function() {
await fs.outputFile(path.join("temp", "test", "invalid"), "a test");
await assert.rejects(execCtl("instance upload-save 44 invalid"));
});
it("should reject path traversal attacks", async function() {
await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test");
await assert.rejects(execCtl("instance upload-save 44 upload.zip --name ../traversal.zip"));
});
});
describe("instance delete", function() {
it("deletes the instance", async function() {
slowTest(this);
-9
View File
@@ -1,9 +0,0 @@
"use strict";
const assert = require("assert").strict;
const mock = require("./mock");
const master = require("@clusterio/master/master");
describe("Master testing", function() {
});
+290
View File
@@ -0,0 +1,290 @@
"use strict";
const assert = require("assert").strict;
const events = require("events");
const FormData = require("form-data");
const jwt = require("jsonwebtoken");
const http = require("http");
const phin = require("phin");
const routes = require("@clusterio/master/src/routes");
const mock = require("../mock");
describe("master/src/routes", function() {
let master;
let server;
let port;
beforeEach(async function() {
master = new mock.MockMaster();
routes.addRouteHandlers(master.app);
server = http.createServer(master.app);
server.listen(0, "localhost");
await events.once(server, "listening");
port = server.address().port;
});
afterEach(function() {
server.close();
});
describe("/api/stream/:id", function() {
it("should respond with 404 if stream doesn't exist", async function() {
let response = await phin(`http://localhost:${port}/api/stream/01020304`);
assert.equal(response.statusCode, 404);
});
it("should respond with 500 if stream times out", async function() {
let stream = await routes.createProxyStream(master.app);
let response = await phin(`http://localhost:${port}/api/stream/${stream.id}`);
assert.equal(response.statusCode, 500);
});
});
describe("/api/upload-save", function() {
it("should respond with 401 with invalid token", async function() {
let response;
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
headers: { "Content-Type": "application/zip" },
data: "totally a zip file",
});
assert.equal(response.statusCode, 401);
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": "invalid",
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 401);
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": jwt.sign({ aud: "user", user: "invalid" }, "TestSecretDoNotUse"),
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 401);
master.userManager.users.get("test").tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000);
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": jwt.sign({ aud: "user", user: "test" }, "TestSecretDoNotUse"),
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 401);
});
it("should respond with 403 when user has insufficient permission", async function() {
let response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
headers: {
"Content-Type": "application/zip",
"X-Access-Token": jwt.sign({ aud: "user", user: "player" }, "TestSecretDoNotUse"),
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 403);
});
let testToken = jwt.sign({ aud: "user", user: "test" }, "TestSecretDoNotUse");
it("should respond with 415 if content type is missing or invalid", async function() {
let response;
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
headers: {
"X-Access-Token": testToken,
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 415);
assert.deepEqual(JSON.parse(response.body), { request_errors: ["invalid Content-Type"] });
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "invalid",
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 415);
assert.deepEqual(JSON.parse(response.body), { request_errors: ["invalid Content-Type"] });
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "text/plain",
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 415);
assert.deepEqual(JSON.parse(response.body), { 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 phin({
url: `http://localhost:${port}/api/upload-save`, method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
data: form.getBuffer(),
});
assert.equal(response.statusCode, 400);
assert.deepEqual(JSON.parse(response.body), { 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 phin({
url: `http://localhost:${port}/api/upload-save`, method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
data: form.getBuffer(),
});
assert.equal(response.statusCode, 400);
assert.deepEqual(
JSON.parse(response.body), { 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 phin({
url: `http://localhost:${port}/api/upload-save`, method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
data: form.getBuffer(),
});
assert.equal(response.statusCode, 400);
assert.deepEqual(
JSON.parse(response.body), { 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 phin({
url: `http://localhost:${port}/api/upload-save`, method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
data: form.getBuffer(),
});
assert.equal(response.statusCode, 400);
assert.deepEqual(
JSON.parse(response.body), { request_errors: ["invalid instance_id"] }
);
});
it("should respond with 400 if invalid file request", async function() {
let response;
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123`, method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 400);
assert.deepEqual(JSON.parse(response.body), { request_errors: ["Missing or invalid filename parameter"] });
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.txt`, method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 400);
assert.deepEqual(JSON.parse(response.body), { request_errors: ["filename must end with .zip"] });
response = await phin({
url: `http://localhost:${port}/api/upload-save?filename=test.zip`, method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 400);
assert.deepEqual(
JSON.parse(response.body), { request_errors: ["Missing or invalid instance_id parameter"] }
);
});
it("should respond with 500 if the transfer failed", async function() {
master.forwardRequestToInstance = async (data, 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 phin({
url: `http://localhost:${port}/api/upload-save`, method: "POST",
headers: {
"X-Access-Token": testToken,
...form.getHeaders(),
},
data: form.getBuffer(),
});
assert.equal(response.statusCode, 500);
assert.deepEqual(JSON.parse(response.body), { errors: ["Something went wrong"], request_errors: [] });
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.zip`, method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 500);
assert.deepEqual(JSON.parse(response.body), { errors: ["Something went wrong"], request_errors: [] });
master.forwardRequestToInstance = async (data, request) => {
await new Promise(() => {});
};
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.zip`, method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
});
assert.equal(response.statusCode, 500);
assert.deepEqual(
JSON.parse(response.body), { errors: ["Timed out establishing stream to slave"], request_errors: [] }
);
});
it("should complete a valid transfer", async function() {
master.forwardRequestToInstance = async (request, data) => {
let stream = await phin({
url: `http://localhost:${port}/api/stream/${data.stream_id}`,
});
assert.equal(stream.body.toString(), "totally a zip file");
return { save: data.filename };
};
let response;
response = await phin({
url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.zip`, method: "POST",
headers: {
"X-Access-Token": testToken,
"Content-Type": "application/zip",
},
data: "totally a zip file",
});
assert.deepEqual(JSON.parse(response.body), { saves: ["test.zip"] });
assert.equal(response.statusCode, 200);
});
});
});
+4
View File
@@ -150,9 +150,12 @@ class MockControl extends libLink.Link {
class MockMaster {
constructor() {
this.app = express();
this.app.locals.master = this;
this.app.locals.streams = new Map();
this.mockConfigEntries = new Map([
["master.external_address", "test"],
["master.auth_secret", "TestSecretDoNotUse"],
["master.proxy_stream_timeout", 1],
]);
this.config = {
get: (name) => {
@@ -227,6 +230,7 @@ module.exports = {
MockInstance,
MockSlave,
MockControl,
MockMaster,
createMasterPlugin,
createInstancePlugin,
+1
View File
@@ -10,6 +10,7 @@
"@clusterio/master": "^2.0.0-alpha.6",
"@clusterio/slave": "^2.0.0-alpha.6",
"express": "^4.16.2",
"form-data": "^4.0.0",
"fs-extra": "^8.1.0",
"jsonwebtoken": "^8.2.1",
"jszip": "^3.2.2",