Repository: mcollina/autocannon (js)

Issue:
Supporting Basic HTTP auth in URL
Hi 👋

I have a staging server, which is protected with [basic HTTP authentication](https://en.wikipedia.org/wiki/Basic_access_authentication) to avoid accidental indexing by search engines. For manual testing, I simply enter login and password in the browser and for tools like [HTTPie](https://httpie.org/) or [Lighthouse](https://developers.google.com/web/tools/lighthouse/) I prepend my login and password to the URL like this:

```bash
http https://login:password@staging.example.com/
lighthouse --view https://login:password@staging.example.com/
```

All works – the response is 200.

However, `autocannon` seems to ignore my auth credentials written the same way:

```bash
autocannon https://login:password@staging.example.com/
```
```
Running 10s test @ https://login:password@staging.example.com/
10 connections

┌─────────┬───────┬───────┬────────┬────────┬─────────┬──────────┬───────────┐
│ Stat    │ 2.5%  │ 50%   │ 97.5%  │ 99%    │ Avg     │ Stdev    │ Max       │
├─────────┼───────┼───────┼────────┼────────┼─────────┼──────────┼───────────┤
│ Latency │ 77 ms │ 89 ms │ 117 ms │ 144 ms │ 93.8 ms │ 30.49 ms │ 394.45 ms │
└─────────┴───────┴───────┴────────┴────────┴─────────┴──────────┴───────────┘
┌───────────┬─────────┬─────────┬─────────┬─────────┬───────┬─────────┬─────────┐
│ Stat      │ 1%      │ 2.5%    │ 50%     │ 97.5%   │ Avg   │ Stdev   │ Min     │
├───────────┼─────────┼─────────┼─────────┼─────────┼───────┼─────────┼─────────┤
│ Req/Sec   │ 71      │ 71      │ 110     │ 117     │ 105.6 │ 12.54   │ 71      │
├───────────┼─────────┼─────────┼─────────┼─────────┼───────┼─────────┼─────────┤
│ Bytes/Sec │ 12.1 kB │ 12.1 kB │ 18.7 kB │ 19.9 kB │ 18 kB │ 2.13 kB │ 12.1 kB │
└───────────┴─────────┴─────────┴─────────┴─────────┴───────┴─────────┴─────────┘

Req/Bytes counts sampled once per second.

0 2xx responses, 1056 non 2xx responses
1k requests in 10.05s, 180 kB read
```

↑ `1056 non 2xx responses` suggests that HTTP auth was ignored and `401 Unauthorized` was returned in all cases. Could autocannon be improved so that it worked just like other tools with regards to basic auth?

Pull request:
adds support for HTTP basic authentication
fixes #213 

Patch:
diff --git a/README.md b/README.md
index e7c824f..fc62c13 100644
--- a/README.md
+++ b/README.md
@@ -278,7 +278,7 @@ Checkout [this example](./samples/track-run.js) to see it in use, as well.
 Because an autocannon instance is an `EventEmitter`, it emits several events. these are below:
 
 * `start`: Emitted once everything has been setup in your autocannon instance and it has started. Useful for if running the instance forever.
-* `tick`: Emitted every second this autocannon is running a benchmark. Useful for displaying stats, etc. Used by the `track` function.
+* `tick`: Emitted every second this autocannon is running a benchmark. Useful for displaying stats, etc. Used by the `track` function. The `tick` event propagates  an object containing the `counter` and `bytes` values, which can be used for extended reports.
 * `done`: Emitted when the autocannon finishes a benchmark. passes the `results` as an argument to the callback.
 * `response`: Emitted when the autocannons http-client gets a http response from the server. This passes the following arguments to the callback:
     * `client`: The `http-client` itself. Can be used to modify the headers and body the client will send to the server. API below.
diff --git a/autocannon.js b/autocannon.js
index 3f200c5..56c8173 100755
--- a/autocannon.js
+++ b/autocannon.js
@@ -29,7 +29,7 @@ module.exports.parseArguments = parseArguments
 
 function parseArguments (argvs) {
   const argv = minimist(argvs, {
-    boolean: ['json', 'n', 'help', 'renderLatencyTable', 'renderProgressBar', 'forever', 'idReplacement', 'excludeErrorStats', 'onPort'],
+    boolean: ['json', 'n', 'help', 'renderLatencyTable', 'renderProgressBar', 'forever', 'idReplacement', 'excludeErrorStats', 'onPort', 'debug'],
     alias: {
       connections: 'c',
       pipelining: 'p',
@@ -71,7 +71,8 @@ function parseArguments (argvs) {
       forever: false,
       method: 'GET',
       idReplacement: false,
-      excludeErrorStats: false
+      excludeErrorStats: false,
+      debug: false
     },
     '--': true
   })
diff --git a/help.txt b/help.txt
index b9a1405..2a6508f 100644
--- a/help.txt
+++ b/help.txt
@@ -61,6 +61,8 @@ Available options:
         Server name for the SNI (Server Name Indication) TLS extension.
   -x/--excludeErrorStats
         Exclude error statistics (non 2xx http responses) from the final latency and bytes per second averages. default: false.
+  --debug
+        Print connection errors to stderr.
   -v/--version
         Print the version number.
   -h/--help
diff --git a/lib/httpClient.js b/lib/httpClient.js
index e751e20..496a53c 100644
--- a/lib/httpClient.js
+++ b/lib/httpClient.js
@@ -22,6 +22,7 @@ function Client (opts) {
   this.timeout = (this.opts.timeout || 10) * 1000
   this.ipc = !!this.opts.socketPath
   this.secure = this.opts.protocol === 'https:'
+  this.auth = this.opts.auth || null
 
   if (this.secure && this.opts.port === 80) {
     this.opts.port = 443
diff --git a/lib/httpRequestBuilder.js b/lib/httpRequestBuilder.js
index 701e20f..eee4693 100644
--- a/lib/httpRequestBuilder.js
+++ b/lib/httpRequestBuilder.js
@@ -43,6 +43,11 @@ function requestBuilder (defaults) {
       host = hostname + ':' + port
     }
 
+    if (reqData.auth) {
+      const encodedAuth = Buffer.from(reqData.auth).toString('base64')
+      headers.Authorization = `Basic ${encodedAuth}`
+    }
+
     if (methods.indexOf(method) < 0) {
       throw new Error(`${method} HTTP method is not supported`)
     }
diff --git a/lib/run.js b/lib/run.js
index 69575f6..829a2db 100644
--- a/lib/run.js
+++ b/lib/run.js
@@ -134,9 +134,9 @@ function _run (opts, cb, tracker) {
     totalCompletedRequests += counter
     requests.recordValue(counter)
     throughput.recordValue(bytes)
+    tracker.emit('tick', { counter, bytes })
     counter = 0
     bytes = 0
-    tracker.emit('tick')
 
     if (stop) {
       if (stopTimer) clearTimeout(stopTimer)
@@ -241,6 +241,7 @@ function _run (opts, cb, tracker) {
     function onError (error) {
       for (let i = 0; i < opts.pipelining; i++) tracker.emit('reqError', error)
       errors++
+      if (opts.debug) console.error(error)
       if (opts.bailout && errors >= opts.bailout) stop = true
     }
 
diff --git a/package.json b/package.json
index d2d06a3..ccd496d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "autocannon",
-  "version": "4.1.1",
+  "version": "4.2.0",
   "description": "Fast HTTP benchmarking tool written in Node.js",
   "main": "autocannon.js",
   "bin": {
@@ -41,7 +41,7 @@
     "pre-commit": "^1.1.2",
     "split2": "^3.1.1",
     "standard": "^14.0.0",
-    "tap": "^14.6.1"
+    "tap": "^14.6.7"
   },
   "dependencies": {
     "chalk": "^2.4.2",


Test patch:
diff --git a/test/basic-auth.test.js b/test/basic-auth.test.js
new file mode 100644
index 0000000..ad61c81
--- /dev/null
+++ b/test/basic-auth.test.js
@@ -0,0 +1,54 @@
+'use strict'
+
+const t = require('tap')
+const split = require('split2')
+const path = require('path')
+const childProcess = require('child_process')
+const helper = require('./helper')
+
+const lines = [
+  /Running 1s test @ .*$/,
+  /10 connections.*$/,
+  /$/,
+  /.*/,
+  /Stat.*2\.5%.*50%.*97\.5%.*99%.*Avg.*Stdev.*Max.*$/,
+  /.*/,
+  /Latency.*$/,
+  /$/,
+  /.*/,
+  /Stat.*1%.*2\.5%.*50%.*97\.5%.*Avg.*Stdev.*Min.*$/,
+  /.*/,
+  /Req\/Sec.*$/,
+  /.*/,
+  /Bytes\/Sec.*$/,
+  /.*/,
+  /$/,
+  /Req\/Bytes counts sampled once per second.*$/,
+  /$/,
+  /.* requests in ([0-9]|\.)+s, .* read/
+]
+
+t.plan(lines.length * 2)
+
+const server = helper.startBasicAuthServer()
+const url = 'http://foo:bar@localhost:' + server.address().port
+
+const child = childProcess.spawn(process.execPath, [path.join(__dirname, '..'), '-d', '1', url], {
+  cwd: __dirname,
+  env: process.env,
+  stdio: ['ignore', 'pipe', 'pipe'],
+  detached: false
+})
+
+t.tearDown(() => {
+  child.kill()
+})
+
+child
+  .stderr
+  .pipe(split())
+  .on('data', (line) => {
+    const regexp = lines.shift()
+    t.ok(regexp, 'we are expecting this line')
+    t.ok(regexp.test(line), 'line matches ' + regexp)
+  })
diff --git a/test/debug.test.js b/test/debug.test.js
new file mode 100644
index 0000000..fb25f8c
--- /dev/null
+++ b/test/debug.test.js
@@ -0,0 +1,22 @@
+'use strict'
+
+const test = require('tap').test
+const Autocannon = require('../autocannon')
+
+test('debug works', (t) => {
+  t.plan(5)
+
+  var args = Autocannon.parseArguments([
+    '-H', 'X-Http-Method-Override=GET',
+    '-m', 'POST',
+    '-b', 'the body',
+    '--debug',
+    'http://localhost/foo/bar'
+  ])
+
+  t.equal(args.url, 'http://localhost/foo/bar')
+  t.strictSame(args.headers, { 'X-Http-Method-Override': 'GET' })
+  t.equal(args.method, 'POST')
+  t.equal(args.body, 'the body')
+  t.equal(args.debug, true)
+})
diff --git a/test/helper.js b/test/helper.js
index e8906ce..8d9e23b 100644
--- a/test/helper.js
+++ b/test/helper.js
@@ -131,11 +131,32 @@ function startTlsServer () {
   return server
 }
 
+function startBasicAuthServer () {
+  const server = http.createServer(handle)
+
+  function handle (req, res) {
+    if (!req.headers.authorization || req.headers.authorization.indexOf('Basic ') === -1) {
+      res.writeHead(401)
+      return res.end()
+    }
+
+    res.writeHead(200)
+    res.end('hello world')
+  }
+
+  server.listen(0)
+
+  server.unref()
+
+  return server
+}
+
 module.exports.startServer = startServer
 module.exports.startTimeoutServer = startTimeoutServer
 module.exports.startSocketDestroyingServer = startSocketDestroyingServer
 module.exports.startHttpsServer = startHttpsServer
 module.exports.startTrailerServer = startTrailerServer
 module.exports.startTlsServer = startTlsServer
+module.exports.startBasicAuthServer = startBasicAuthServer
 
 function noop () {}
diff --git a/test/httpClient.test.js b/test/httpClient.test.js
index 749ac07..538236a 100644
--- a/test/httpClient.test.js
+++ b/test/httpClient.test.js
@@ -487,3 +487,20 @@ test('client should have 2 different requests it iterates over', (t) => {
     }
   })
 })
+
+test('client supports http basic authentication', (t) => {
+  t.plan(2)
+
+  const opts = server.address()
+  opts.auth = 'username:password'
+  const client = new Client(opts)
+
+  server.once('request', (req, res) => {
+    t.equal(req.headers.authorization, 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=', 'authorization header matches')
+  })
+
+  client.on('response', (statusCode, length) => {
+    t.equal(statusCode, 200, 'status code matches')
+    client.destroy()
+  })
+})
diff --git a/test/httpRequestBuilder.test.js b/test/httpRequestBuilder.test.js
index 27d8b8c..b1c2685 100644
--- a/test/httpRequestBuilder.test.js
+++ b/test/httpRequestBuilder.test.js
@@ -129,3 +129,17 @@ test('request builder should add a Content-Length header with value "[<contentLe
     Buffer.from(`POST / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nContent-Length: 33\r\n\r\n[<id>]\r\n`),
     'request is okay')
 })
+
+test('request builder should allow http basic authentication', (t) => {
+  t.plan(1)
+
+  const opts = server.address()
+  opts.auth = 'username:password'
+
+  const build = RequestBuilder(opts)
+
+  const result = build()
+  t.same(result,
+    Buffer.from(`GET / HTTP/1.1\r\nHost: localhost:${server.address().port}\r\nConnection: keep-alive\r\nAuthorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=\r\n\r\n`),
+    'request is okay')
+})
diff --git a/test/run.test.js b/test/run.test.js
index c3222b3..283b651 100644
--- a/test/run.test.js
+++ b/test/run.test.js
@@ -439,6 +439,23 @@ test('tracker will emit reqError with error message on error', (t) => {
   })
 })
 
+test('tracker will emit tick with current counter value', (t) => {
+  t.plan(1)
+
+  const server = helper.startSocketDestroyingServer()
+
+  const tracker = run({
+    url: `http://localhost:${server.address().port}`,
+    connections: 10,
+    duration: 10
+  })
+
+  tracker.once('tick', (counter) => {
+    t.type(counter, 'object')
+    tracker.stop()
+  })
+})
+
 test('throw if connections is greater than amount', (t) => {
   t.plan(1)