Skip to content

Commit 046418f

Browse files
authored
fix(http): process file uploads correctly (#3232)
1 parent a231b3e commit 046418f

27 files changed

+305
-18
lines changed
File renamed without changes.

examples/file-uploads/README.md examples/http-file-uploads/README.md

+1-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
# HTTP file uploads with Artillery Pro
1+
# HTTP file uploads
22

33
This example shows you how to perform HTTP file uploads from an Artillery test script.
44

5-
The file upload functionality is a part of Artillery Pro, which [needs to be installed](https://artillery.io/docs/guides/getting-started/installing-artillery-pro.html) before running the tests in this directory.
6-
75
## Running the HTTP server
86

97
This example includes an Express.js application running an HTTP server.
File renamed without changes.

examples/file-uploads/file-uploads.yml examples/http-file-uploads/file-uploads.yml

+1-3
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,7 @@ config:
33
phases:
44
- duration: 10min
55
arrivalRate: 25
6-
# Enables the file upload plugin from Artillery Pro.
7-
plugins:
8-
http-file-uploads: {}
6+
97
# To randomize the files to upload during the test scenario,
108
# set up variables with the names of the files to use. These
119
# files are placed in the `/files` directory.
File renamed without changes.
File renamed without changes.

packages/artillery/lib/cmds/run.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -498,10 +498,12 @@ async function prepareTestExecutionPlan(inputFiles, flags, args) {
498498

499499
const script3 = await addOverrides(script2, flags);
500500
const script4 = await addVariables(script3, flags);
501+
// The resolveConfigTemplates function expects the config and script path to be passed explicitly because it is used in Fargate as well where the two arguments will not be available on the script
501502
const script5 = await resolveConfigTemplates(
502503
script4,
503504
flags,
504-
script4._configPath
505+
script4._configPath,
506+
script4._scriptPath
505507
);
506508

507509
if (!script5.config.target) {

packages/artillery/lib/platform/aws-ecs/legacy/bom.js

+5-2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ function _convertToPosixPath(p) {
2323
return p.split(path.sep).join(path.posix.sep);
2424
}
2525

26+
// NOTE: absoluteScriptPath here is actually the absolute path to the config file
2627
function createBOM(absoluteScriptPath, extraFiles, opts, callback) {
2728
A.waterfall(
2829
[
@@ -34,7 +35,8 @@ function createBOM(absoluteScriptPath, extraFiles, opts, callback) {
3435
opts: {
3536
scriptData,
3637
absoluteScriptPath,
37-
flags: opts.flags
38+
flags: opts.flags,
39+
scenarioPath: opts.scenarioPath // Absolute path to the file that holds scenarios
3840
},
3941
localFilePaths: [absoluteScriptPath],
4042
npmModules: []
@@ -157,7 +159,8 @@ function applyScriptChanges(context, next) {
157159
resolveConfigTemplates(
158160
context.opts.scriptData,
159161
context.opts.flags,
160-
context.opts.absoluteScriptPath
162+
context.opts.absoluteScriptPath,
163+
context.opts.scenarioPath
161164
).then((resolvedConfig) => {
162165
context.opts.scriptData = resolvedConfig;
163166
return next(null, context);

packages/artillery/lib/platform/aws-ecs/legacy/create-test.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,11 @@ function prepareManifest(context, callback) {
8383
createBOM(
8484
fileToAnalyse,
8585
extraFiles,
86-
{ packageJsonPath: context.packageJsonPath, flags: context.flags},
86+
{
87+
packageJsonPath: context.packageJsonPath,
88+
flags: context.flags,
89+
scenarioPath: context.scriptPath
90+
},
8791
(err, bom) => {
8892
debug(err);
8993
debug(bom);

packages/artillery/lib/platform/aws-lambda/dependencies.js

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const _createLambdaBom = async (
1313
let createBomOpts = {};
1414
let entryPoint = absoluteScriptPath;
1515
let extraFiles = [];
16+
createBomOpts.scenarioPath = absoluteScriptPath;
1617
if (absoluteConfigPath) {
1718
entryPoint = absoluteConfigPath;
1819
extraFiles.push(absoluteScriptPath);

packages/artillery/lib/util.js

+3-2
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,12 @@ function addDefaultPlugins(script) {
9898
return finalScript;
9999
}
100100

101-
async function resolveConfigTemplates(script, flags, configPath) {
101+
async function resolveConfigTemplates(script, flags, configPath, scriptPath) {
102102
const cliVariables = flags.variables ? JSON.parse(flags.variables) : {};
103103

104104
script.config = engineUtil.template(script.config, {
105105
vars: {
106+
$scenarioFile: scriptPath,
106107
$dirname: path.dirname(configPath),
107108
$testId: global.artillery.testRunId,
108109
$processEnvironment: process.env,
@@ -198,7 +199,7 @@ async function checkConfig(script, scriptPath, flags) {
198199
);
199200
payloadSpec.path = resolvedPathToPayload;
200201
});
201-
202+
script._scriptPath = absoluteScriptPath;
202203
return script;
203204
}
204205

packages/artillery/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
},
4545
"scripts": {
4646
"test:unit": "tap --timeout=420 test/unit/*.test.js",
47-
"test:acceptance": "tap --timeout=420 test/cli/*.test.js && bash test/lib/run.sh && tap --timeout=420 test/publish-metrics/**/*.test.js",
47+
"test:acceptance": "tap --timeout=420 test/cli/*.test.js && bash test/lib/run.sh && tap --timeout=420 test/publish-metrics/**/*.test.js && tap --timeout=420 test/integration/**/*.test.js",
4848
"test": " npm run test:unit && npm run test:acceptance",
4949
"test:windows": "npm run test:unit && tap --timeout=420 test/cli/*.test.js",
5050
"test:aws": "tap --timeout=4200 test/cloud-e2e/**/*.test.js",
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
'use strict';
2+
3+
function getResponse(req, res, context, ee, next) {
4+
// We log the response body here so we can access it from the output
5+
console.log('RESPONSE BODY: ', res.body, ' RESPONSE BODY END');
6+
next();
7+
}
8+
9+
module.exports = {
10+
getResponse
11+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
config:
2+
phases:
3+
- duration: 1
4+
arrivalRate: 1
5+
processor: "../fixtures/http-file-upload-processor.js"
6+
7+
variables:
8+
filename:
9+
- "artillery-installation.pdf"
10+
scenarios:
11+
- name: "Hello"
12+
flow:
13+
- post:
14+
url: "/upload"
15+
afterResponse: "getResponse"
16+
formData:
17+
name: "Artillery"
18+
logo:
19+
fromFile: "./files/artillery-logo.jpg"
20+
guide:
21+
fromFile: "./files/{{ filename }}"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
'use strict';
2+
3+
const { createTestServer } = require('../../targets/http-file-upload-server');
4+
const { test, beforeEach, afterEach } = require('tap');
5+
const fs = require('fs');
6+
const crypto = require('crypto');
7+
const { $ } = require('zx');
8+
9+
let server;
10+
let port;
11+
12+
beforeEach(async () => {
13+
server = await createTestServer();
14+
port = server.info.port;
15+
});
16+
17+
afterEach((t) => {
18+
server.stop();
19+
});
20+
21+
async function calculateFileHash(filePath) {
22+
return new Promise((resolve, reject) => {
23+
const hash = crypto.createHash('sha256');
24+
const stream = fs.createReadStream(filePath);
25+
26+
stream.on('data', (data) => hash.update(data));
27+
stream.on('end', () => resolve(hash.digest('hex')));
28+
stream.on('error', reject);
29+
});
30+
}
31+
32+
test('HTTP engine successfully handles file uploads', async (t) => {
33+
const expectedFiles = [
34+
{
35+
fieldName: 'guide',
36+
fileName: 'artillery-installation.pdf',
37+
contentType: 'application/pdf'
38+
},
39+
{
40+
fieldName: 'logo',
41+
fileName: 'artillery-logo.jpg',
42+
contentType: 'image/jpeg'
43+
}
44+
];
45+
46+
const expectedOtherFields = {
47+
name: 'Artillery'
48+
};
49+
50+
const override = {
51+
config: {
52+
target: `http://127.0.0.1:${port}`
53+
}
54+
};
55+
56+
/// Run the test
57+
let output;
58+
try {
59+
output =
60+
await $`artillery run ${__dirname}/fixtures/http-file-upload.yml --overrides ${JSON.stringify(
61+
override
62+
)}`;
63+
} catch (err) {
64+
console.error('There has been an error in test run execution: ', err);
65+
t.fail(err);
66+
}
67+
// We log the response body from the processor so we can parse it from output
68+
const match = output.stdout.match(/RESPONSE BODY: (.*) RESPONSE BODY END/s);
69+
let data;
70+
if (match) {
71+
try {
72+
data = JSON.parse(match[1].trim());
73+
} catch (err) {
74+
console.error('Error parsing response body: ', err);
75+
}
76+
} else {
77+
console.error('Response body not found in output');
78+
}
79+
80+
const files = data?.files;
81+
const fields = data?.fields;
82+
t.ok(
83+
data?.files && data?.fields,
84+
'Should successfully upload a combination of file and non-file form fields'
85+
);
86+
t.equal(data.status, 'success', 'Should have a success status');
87+
t.equal(
88+
files.length,
89+
expectedFiles.length,
90+
`${expectedFiles.length} files should be uploaded`
91+
);
92+
t.match(fields, expectedOtherFields, 'Should have the expected other fields');
93+
94+
for (const expectedFile of expectedFiles) {
95+
const uploadedFile = files.find(
96+
(f) => f.fieldName === expectedFile.fieldName
97+
);
98+
99+
if (!uploadedFile) {
100+
t.fail(
101+
`Could not find uploaded file with fieldName ${expectedFile.fieldName}`
102+
);
103+
continue;
104+
}
105+
106+
const expectedHash = await calculateFileHash(
107+
`${__dirname}/fixtures/files/${expectedFile.fileName}`
108+
);
109+
110+
t.equal(
111+
uploadedFile.originalFilename,
112+
expectedFile.fileName,
113+
`Should have uploaded the ${expectedFile.fileName} file under the correct field`
114+
);
115+
t.equal(
116+
uploadedFile.fileHash,
117+
expectedHash,
118+
'Uploaded file should match the sent file'
119+
);
120+
t.equal(
121+
uploadedFile.headers['content-type'],
122+
expectedFile.contentType,
123+
'Should have uploaded file with correct content type'
124+
);
125+
}
126+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
const Hapi = require('@hapi/hapi');
2+
const path = require('path');
3+
const crypto = require('crypto');
4+
5+
const createTestServer = async (port) => {
6+
const server = Hapi.server({
7+
port: port || 0,
8+
host: '127.0.0.1'
9+
});
10+
11+
server.route({
12+
method: 'GET',
13+
path: '/',
14+
handler: (request, h) => {
15+
return {
16+
status: 'success',
17+
message: 'Hello!'
18+
};
19+
}
20+
});
21+
22+
server.route({
23+
method: 'POST',
24+
path: '/upload',
25+
options: {
26+
payload: {
27+
maxBytes: 10485760, // 10 MB
28+
output: 'stream',
29+
parse: true,
30+
multipart: {
31+
output: 'stream'
32+
}
33+
}
34+
},
35+
handler: async (request, h) => {
36+
const data = request.payload;
37+
const files = [];
38+
const fields = {};
39+
40+
for (const key in data) {
41+
if (!data[key].hapi || !data[key]._data) {
42+
// Handle non-file fields
43+
fields[key] = data[key];
44+
continue;
45+
}
46+
47+
// Handle file fields
48+
const file = data[key];
49+
const filename = path.basename(file.hapi.filename);
50+
51+
// calculate a hash of the file so it can be compared in tests
52+
const hash = crypto.createHash('sha256');
53+
await new Promise((resolve, reject) => {
54+
file.on('end', () => resolve());
55+
file.on('error', (err) => reject(err));
56+
file.on('data', (chunk) => {
57+
hash.update(chunk);
58+
});
59+
});
60+
61+
files.push({
62+
fieldName: key,
63+
originalFilename: filename,
64+
fileHash: hash.digest('hex'),
65+
headers: file.hapi.headers
66+
});
67+
}
68+
69+
return {
70+
status: 'success',
71+
message: 'Files and fields uploaded successfully',
72+
files,
73+
fields
74+
};
75+
}
76+
});
77+
78+
await server.start();
79+
console.log(`File upload server listening on ${server.info.uri}`);
80+
return server;
81+
};
82+
83+
module.exports = {
84+
createTestServer
85+
};

0 commit comments

Comments
 (0)