From 1696c7806561f75bdc16191bac2fb18c6b01de6b Mon Sep 17 00:00:00 2001 From: Gregory Cowan Date: Tue, 4 Aug 2015 17:38:41 +0200 Subject: [PATCH] feat(web-server): Allow running on https --- docs/config/01-configuration-file.md | 33 ++++++ lib/config.js | 10 ++ lib/executor.js | 4 +- lib/launcher.js | 6 +- lib/middleware/runner.js | 7 +- lib/reporter.js | 2 +- lib/server.js | 4 +- lib/web-server.js | 11 +- test/unit/certificates/server.crt | 13 +++ test/unit/certificates/server.key | 15 +++ test/unit/config.spec.js | 22 ++++ test/unit/launcher.spec.js | 18 +-- test/unit/middleware/runner.spec.js | 2 +- test/unit/web-server.spec.js | 157 ++++++++++++++++++--------- 14 files changed, 230 insertions(+), 74 deletions(-) create mode 100644 test/unit/certificates/server.crt create mode 100644 test/unit/certificates/server.key diff --git a/docs/config/01-configuration-file.md b/docs/config/01-configuration-file.md index d75c3b614..fef334b93 100644 --- a/docs/config/01-configuration-file.md +++ b/docs/config/01-configuration-file.md @@ -213,6 +213,23 @@ Additional information can be found in [plugins]. **Description:** Hostname to be used when capturing browsers. +## httpsServerOptions +**Type:** Object + +**Default:** `{}` + +**Description:** Options object to be used by Node's `https` class. + +Object description can be found in the [NodeJS.org API docs](https://nodejs.org/api/tls.html#tls_tls_createserver_options_secureconnectionlistener) + +**Example:** +```javascript +httpsServerOptions: { + key: fs.readFileSync('server.key', 'utf8'), + cert: fs.readFileSync('server.crt', 'utf8') +}, +``` + ## logLevel **Type:** Constant @@ -283,6 +300,22 @@ but your interactive debugging does not. Click here for more information. +## protocol +**Type:** String + +**Default:** `'http:'` + +**Possible Values:** + +* `http:` +* `https:` + +**Description:** Protocol used for running the Karma webserver. + +Determines the use of the Node `http` or `https` class. + +Note: Using `'https:'` requires you to specify `httpsServerOptions`. + ## proxies **Type:** Object diff --git a/lib/config.js b/lib/config.js index 318c1bbcd..6b3c8a028 100644 --- a/lib/config.js +++ b/lib/config.js @@ -118,6 +118,14 @@ var normalizeConfig = function (config, configFilePath) { // normalize urlRoot config.urlRoot = normalizeUrlRoot(config.urlRoot) + // force protocol to end with ':' + config.protocol = (config.protocol || 'http').split(':')[0] + ':' + if (config.protocol.match(/https?:/) === null) { + log.warn('"%s" is not a supported protocol, defaulting to "http:"', + config.protocol) + config.protocol = 'http:' + } + if (config.proxies && config.proxies.hasOwnProperty(config.urlRoot)) { log.warn('"%s" is proxied, you should probably change urlRoot to avoid conflicts', config.urlRoot) @@ -213,8 +221,10 @@ var Config = function () { // DEFAULT CONFIG this.frameworks = [] + this.protocol = 'http:' this.port = constant.DEFAULT_PORT this.hostname = constant.DEFAULT_HOSTNAME + this.httpsServerConfig = {} this.basePath = '' this.files = [] this.exclude = [] diff --git a/lib/executor.js b/lib/executor.js index a56fa1c1d..aa435fb96 100644 --- a/lib/executor.js +++ b/lib/executor.js @@ -10,8 +10,8 @@ var Executor = function (capturedBrowsers, config, emitter) { var nonReady = [] if (!capturedBrowsers.length) { - log.warn('No captured browser, open http://%s:%s%s', config.hostname, config.port, - config.urlRoot) + log.warn('No captured browser, open %s//%s:%s%s', config.protocol, config.hostname, + config.port, config.urlRoot) return false } diff --git a/lib/launcher.js b/lib/launcher.js index bebdefdb6..3385c51b9 100644 --- a/lib/launcher.js +++ b/lib/launcher.js @@ -31,9 +31,9 @@ var Launcher = function (emitter, injector) { return null } - this.launch = function (names, hostname, port, urlRoot) { + this.launch = function (names, protocol, hostname, port, urlRoot) { var browser - var url = 'http://' + hostname + ':' + port + urlRoot + var url = protocol + '//' + hostname + ':' + port + urlRoot lastStartTime = Date.now() @@ -92,7 +92,7 @@ var Launcher = function (emitter, injector) { return browsers } - this.launch.$inject = ['config.browsers', 'config.hostname', 'config.port', 'config.urlRoot'] + this.launch.$inject = ['config.browsers', 'config.protocol', 'config.hostname', 'config.port', 'config.urlRoot'] this.kill = function (id, callback) { var browser = getBrowserById(id) diff --git a/lib/middleware/runner.js b/lib/middleware/runner.js index 4fe08cdb4..8a2847260 100644 --- a/lib/middleware/runner.js +++ b/lib/middleware/runner.js @@ -12,7 +12,8 @@ var json = require('body-parser').json() // TODO(vojta): disable when single-run mode var createRunnerMiddleware = function (emitter, fileList, capturedBrowsers, reporter, executor, - /* config.hostname */ hostname, /* config.port */ port, /* config.urlRoot */ urlRoot, config) { + /* config.protocol */ protocol, /* config.hostname */ hostname, /* config.port */ + port, /* config.urlRoot */ urlRoot, config) { return function (request, response, next) { if (request.url !== '/__run__' && request.url !== urlRoot + 'run') { return next() @@ -22,7 +23,7 @@ var createRunnerMiddleware = function (emitter, fileList, capturedBrowsers, repo response.writeHead(200) if (!capturedBrowsers.length) { - var url = 'http://' + hostname + ':' + port + urlRoot + var url = protocol + '//' + hostname + ':' + port + urlRoot return response.end('No captured browser, open ' + url + '\n') } @@ -88,7 +89,7 @@ var createRunnerMiddleware = function (emitter, fileList, capturedBrowsers, repo } createRunnerMiddleware.$inject = ['emitter', 'fileList', 'capturedBrowsers', 'reporter', 'executor', - 'config.hostname', 'config.port', 'config.urlRoot', 'config'] + 'config.protocol', 'config.hostname', 'config.port', 'config.urlRoot', 'config'] // PUBLIC API exports.create = createRunnerMiddleware diff --git a/lib/reporter.js b/lib/reporter.js index 9d350f43f..f801dfd7b 100644 --- a/lib/reporter.js +++ b/lib/reporter.js @@ -21,7 +21,7 @@ var createErrorFormatter = function (basePath, emitter, SourceMapConsumer) { return null } - var URL_REGEXP = new RegExp('(?:http:\\/\\/[^\\/]*)?\\/?' + + var URL_REGEXP = new RegExp('(?:https?:\\/\\/[^\\/]*)?\\/?' + '(base|absolute)' + // prefix '((?:[A-z]\\:)?[^\\?\\s\\:]*)' + // path '(\\?\\w*)?' + // sha diff --git a/lib/server.js b/lib/server.js index a237f39df..6800fbd45 100644 --- a/lib/server.js +++ b/lib/server.js @@ -159,8 +159,8 @@ Server.prototype._start = function (config, launcher, preprocess, fileList, webS } webServer.listen(config.port, function () { - self.log.info('Karma v%s server started at http://%s:%s%s', constant.VERSION, config.hostname, - config.port, config.urlRoot) + self.log.info('Karma v%s server started at %s//%s:%s%s', constant.VERSION, + config.protocol, config.hostname, config.port, config.urlRoot) if (config.browsers && config.browsers.length) { self._injector.invoke(launcher.launch, launcher).forEach(function (browserLauncher) { diff --git a/lib/web-server.js b/lib/web-server.js index 1b9878ef0..f607ba230 100644 --- a/lib/web-server.js +++ b/lib/web-server.js @@ -1,5 +1,6 @@ var fs = require('fs') var http = require('http') +var https = require('https') var path = require('path') var connect = require('connect') var Promise = require('bluebird') @@ -29,6 +30,7 @@ var createCustomHandler = function (customFileHandlers, /* config.basePath */ ba createCustomHandler.$inject = ['customFileHandlers', 'config.basePath'] var createWebServer = function (injector, emitter) { + var config = injector.get('config') var serveStaticFile = common.createServeFile(fs, path.normalize(__dirname + '/../static')) var serveFile = common.createServeFile(fs) var filesPromise = new common.PromiseContainer() @@ -61,7 +63,14 @@ var createWebServer = function (injector, emitter) { common.serve404(response, request.url) }) - var server = http.createServer(handler) + var serverClass = http + var serverArguments = [handler] + + if (config.protocol === 'https:') { + serverClass = https + serverArguments.unshift(config.httpsServerOptions || {}) + } + var server = serverClass.createServer.apply(null, serverArguments) server.on('upgrade', function (req, socket, head) { log.debug('upgrade %s', req.url) diff --git a/test/unit/certificates/server.crt b/test/unit/certificates/server.crt new file mode 100644 index 000000000..194d20b71 --- /dev/null +++ b/test/unit/certificates/server.crt @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIICAzCCAWwCCQDlm49KXF45gzANBgkqhkiG9w0BAQUFADBGMQswCQYDVQQGEwJB +VTETMBEGA1UECBMKU29tZS1TdGF0ZTEQMA4GA1UEChMHR3J1bnRKUzEQMA4GA1UE +AxMHMC4wLjAuMDAeFw0xNDAyMTkyMzE1NDRaFw0xNTAyMTkyMzE1NDRaMEYxCzAJ +BgNVBAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMRAwDgYDVQQKEwdHcnVudEpT +MRAwDgYDVQQDEwcwLjAuMC4wMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCm +ipCqKyQ6aJJiVMvXZVoTw9sEC5dKFA35n15r9fG565/Zj8LVg/kgt79am1bnF+/H +F880f8kfDsgEaAC1qzo8XU8yqt+UoFOB2Ncw76g6B6ZiuC2R1uHyD/46sYtMejy3 +n8EcTk9jNmNlglF6Ig6/hWcz+0XH6QjJT0lAM06tswIDAQABMA0GCSqGSIb3DQEB +BQUAA4GBADnTBlN7+Aa8zj2zsUBSUv9w7iYut3ZDvrEY+IJt8EurwA6+Q7rQqVsY +an5ztiEESriWvqNIfvWb+Yekhv9sISJFMfApVbimmT6QseQcFEIlRNW5cfukHQVH +9dBI7upQO2vN7N9ABo4a3aBANMBxIvCnE+adiqNOTJF/8qkiAFY9 +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/unit/certificates/server.key b/test/unit/certificates/server.key new file mode 100644 index 000000000..c955ccd6f --- /dev/null +++ b/test/unit/certificates/server.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICWwIBAAKBgQCmipCqKyQ6aJJiVMvXZVoTw9sEC5dKFA35n15r9fG565/Zj8LV +g/kgt79am1bnF+/HF880f8kfDsgEaAC1qzo8XU8yqt+UoFOB2Ncw76g6B6ZiuC2R +1uHyD/46sYtMejy3n8EcTk9jNmNlglF6Ig6/hWcz+0XH6QjJT0lAM06tswIDAQAB +AoGATqG34hCSf11mWDUPNXjuCcz8eLF8Ugab/pMngrPR2OWOSKue4y73jmITYBVd +96iOlqMAOxpmfFp/R81PIHdi++Bax1NfSBT8tK0U7HHzkbHEXyvHiBSug78Y14h8 +Y/NMZXEvVapY7lapr5ZgOSf2rcKOlceMRsoohl6bGc+55BECQQDPZTw5WxDDe7/W +oYzHy7abLw+A92cP8A6qlwXBik9ko6jtYXvoI454OIr6RsHoFPU9bUkx5G1fvOUZ +J3sxfxMZAkEAzZJEwcvmxHizX/2NZZ8LvVyWGpao07bBcAEvDXDZFOZqKUujukOe +iilQD6JZDJTmW9RJmOgdQKeL9ZaTlX3MqwJASMJrbnPUXcB8fQAQM8f0OF06QzSI +o77EZnS1QEEVuWjxStZ4ceiHgwXTPBq2zIUNxI8irq5E8OGEPl7riWHbgQJARzqL +QGsaRrFb1cLRH4kAVFikWgoh7VnBpMGEQC/9x9QerLhcvsl3QYAXEZO7LzTYrLDd +33Ft0V08jZfjA0VXiQJAOwX6glfDKf79AK1sifFQc/v0Yu87LIOAwp0zLlsnO0Q9 +xQV3TdjlNQebfTG+Uw1tmbcCb2wcGFfD199IHpAzIA== +-----END RSA PRIVATE KEY----- \ No newline at end of file diff --git a/test/unit/config.spec.js b/test/unit/config.spec.js index 04fe920e8..835675603 100644 --- a/test/unit/config.spec.js +++ b/test/unit/config.spec.js @@ -226,6 +226,28 @@ describe('config', () => { expect(config.client.useIframe).to.not.be.undefined expect(config.client.args).to.not.be.undefined }) + + it('should validate and format the protocol', () => { + + var config = normalizeConfigWithDefaults({}) + expect(config.protocol).to.equal('http:') + + config = normalizeConfigWithDefaults({ protocol: 'http' }) + expect(config.protocol).to.equal('http:') + + config = normalizeConfigWithDefaults({ protocol: 'http:' }) + expect(config.protocol).to.equal('http:') + + config = normalizeConfigWithDefaults({ protocol: 'https' }) + expect(config.protocol).to.equal('https:') + + config = normalizeConfigWithDefaults({ protocol: 'https:' }) + expect(config.protocol).to.equal('https:') + + config = normalizeConfigWithDefaults({ protocol: 'unsupported:' }) + expect(config.protocol).to.equal('http:') + + }) }) describe('normalizeConfig', () => { diff --git a/test/unit/launcher.spec.js b/test/unit/launcher.spec.js index 206eb22ea..a7b460432 100644 --- a/test/unit/launcher.spec.js +++ b/test/unit/launcher.spec.js @@ -80,7 +80,7 @@ describe('launcher', () => { describe('launch', () => { it('should inject and start all browsers', () => { - l.launch(['Fake'], 'localhost', 1234, '/root/') + l.launch(['Fake'], 'http:', 'localhost', 1234, '/root/') var browser = FakeBrowser._instances.pop() expect(browser.start).to.have.been.calledWith('http://localhost:1234/root/') @@ -89,7 +89,7 @@ describe('launcher', () => { }) it('should allow launching a script', () => { - l.launch(['/usr/local/bin/special-browser'], 'localhost', 1234, '/') + l.launch(['/usr/local/bin/special-browser'], 'http:', 'localhost', 1234, '/') var script = ScriptBrowser._instances.pop() expect(script.start).to.have.been.calledWith('http://localhost:1234/') @@ -97,7 +97,7 @@ describe('launcher', () => { }) it('should use the non default host', () => { - l.launch(['Fake'], 'whatever', 1234, '/root/') + l.launch(['Fake'], 'http:', 'whatever', 1234, '/root/') var browser = FakeBrowser._instances.pop() expect(browser.start).to.have.been.calledWith('http://whatever:1234/root/') @@ -106,7 +106,7 @@ describe('launcher', () => { describe('restart', () => { it('should restart the browser', () => { - l.launch(['Fake'], 'localhost', 1234, '/root/') + l.launch(['Fake'], 'http:', 'localhost', 1234, '/root/') var browser = FakeBrowser._instances.pop() var returnedValue = l.restart(lastGeneratedId) @@ -115,7 +115,7 @@ describe('launcher', () => { }) it('should return false if the browser was not launched by launcher (manual)', () => { - l.launch([], 'localhost', 1234, '/') + l.launch([], 'http:', 'localhost', 1234, '/') expect(l.restart('manual-id')).to.equal(false) }) }) @@ -151,7 +151,7 @@ describe('launcher', () => { describe('killAll', () => { it('should kill all running processe', () => { - l.launch(['Fake', 'Fake'], 'localhost', 1234) + l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234) l.killAll() var browser = FakeBrowser._instances.pop() @@ -164,7 +164,7 @@ describe('launcher', () => { it('should call callback when all processes killed', () => { var exitSpy = sinon.spy() - l.launch(['Fake', 'Fake'], 'localhost', 1234) + l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234) l.killAll(exitSpy) expect(exitSpy).not.to.have.been.called @@ -195,7 +195,7 @@ describe('launcher', () => { describe('areAllCaptured', () => { it('should return true if only if all browsers captured', () => { - l.launch(['Fake', 'Fake'], 'localhost', 1234) + l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234) expect(l.areAllCaptured()).to.equal(false) @@ -209,7 +209,7 @@ describe('launcher', () => { describe('onExit', () => { it('should kill all browsers', done => { - l.launch(['Fake', 'Fake'], 'localhost', 1234, '/', 0, 1) + l.launch(['Fake', 'Fake'], 'http:', 'localhost', 1234, '/', 0, 1) emitter.emitAsync('exit').then(done) diff --git a/test/unit/middleware/runner.spec.js b/test/unit/middleware/runner.spec.js index d469d5e54..cfa854941 100644 --- a/test/unit/middleware/runner.spec.js +++ b/test/unit/middleware/runner.spec.js @@ -47,7 +47,7 @@ describe('middleware.runner', () => { config = {client: {}, basePath: '/'} handler = createRunnerMiddleware(emitter, fileListMock, capturedBrowsers, - new MultReporter([mockReporter]), executor, 'localhost', 8877, '/', config) + new MultReporter([mockReporter]), executor, 'http:', 'localhost', 8877, '/', config) }) it('should trigger test run and stream the reporter', (done) => { diff --git a/test/unit/web-server.spec.js b/test/unit/web-server.spec.js index 38bac6bcf..5b038b51d 100644 --- a/test/unit/web-server.spec.js +++ b/test/unit/web-server.spec.js @@ -3,6 +3,7 @@ import {EventEmitter} from 'events' import request from 'supertest-as-promised' import di from 'di' import mocks from 'mocks' +import fs from 'fs' describe('web-server', () => { var server @@ -36,71 +37,123 @@ describe('web-server', () => { emitter.emit('file_list_modified', {included: [], served: files}) } - beforeEach(() => { - customFileHandlers = [] - emitter = new EventEmitter() - - var injector = new di.Injector([{ - config: ['value', {basePath: '/base/path', urlRoot: '/'}], - customFileHandlers: ['value', customFileHandlers], - emitter: ['value', emitter], - fileList: ['value', null], - capturedBrowsers: ['value', null], - reporter: ['value', null], - executor: ['value', null], - proxies: ['value', null] - }]) - - server = injector.invoke(m.createWebServer) - }) + describe('request', () => { - it('should serve client.html', () => { - servedFiles(new Set()) + beforeEach(() => { + customFileHandlers = [] + emitter = new EventEmitter() - return request(server) - .get('/') - .expect(200, 'CLIENT HTML') - }) + var injector = new di.Injector([{ + config: ['value', {basePath: '/base/path', urlRoot: '/'}], + customFileHandlers: ['value', customFileHandlers], + emitter: ['value', emitter], + fileList: ['value', null], + capturedBrowsers: ['value', null], + reporter: ['value', null], + executor: ['value', null], + proxies: ['value', null] + }]) - it('should serve source files', () => { - servedFiles(new Set([new File('/base/path/one.js')])) + server = injector.invoke(m.createWebServer) + }) - return request(server) - .get('/base/one.js') - .expect(200, 'js-source') - }) + it('should serve client.html', () => { + servedFiles(new Set()) + + return request(server) + .get('/') + .expect(200, 'CLIENT HTML') + }) + + it('should serve source files', () => { + servedFiles(new Set([new File('/base/path/one.js')])) + + return request(server) + .get('/base/one.js') + .expect(200, 'js-source') + }) + + it('should serve updated source files on file_list_modified', () => { + servedFiles(new Set([new File('/base/path/one.js')])) + servedFiles(new Set([new File('/base/path/new.js')])) + + return request(server) + .get('/base/new.js') + .expect(200, 'new-js-source') + }) - it('should serve updated source files on file_list_modified', () => { - servedFiles(new Set([new File('/base/path/one.js')])) - servedFiles(new Set([new File('/base/path/new.js')])) + it('should load custom handlers', () => { + servedFiles(new Set()) + + // TODO(vojta): change this, only keeping because karma-dart is relying on it + customFileHandlers.push({ + urlRegex: /\/some\/weird/, + handler (request, response, staticFolder, adapterFolder, baseFolder, urlRoot) { + response.writeHead(222) + response.end('CONTENT') + } + }) + + return request(server) + .get('/some/weird/url') + .expect(222, 'CONTENT') + }) + + it('should serve 404 for non-existing files', () => { + servedFiles(new Set()) + + return request(server) + .get('/non/existing.html') + .expect(404) + }) - return request(server) - .get('/base/new.js') - .expect(200, 'new-js-source') }) - it('should load custom handlers', () => { - servedFiles(new Set()) + describe('https', () => { - // TODO(vojta): change this, only keeping because karma-dart is relying on it - customFileHandlers.push({ - urlRegex: /\/some\/weird/, - handler (request, response, staticFolder, adapterFolder, baseFolder, urlRoot) { - response.writeHead(222) - response.end('CONTENT') + beforeEach(() => { + + var credentials = { + key: fs.readFileSync(__dirname + '/certificates/server.key'), + cert: fs.readFileSync(__dirname + '/certificates/server.crt') } + + customFileHandlers = [] + emitter = new EventEmitter() + + var injector = new di.Injector([{ + config: ['value', {basePath: '/base/path', urlRoot: '/', protocol: 'https:', httpsServerOptions: credentials}], + customFileHandlers: ['value', customFileHandlers], + emitter: ['value', emitter], + fileList: ['value', null], + capturedBrowsers: ['value', null], + reporter: ['value', null], + executor: ['value', null], + proxies: ['value', null] + }]) + + server = injector.invoke(m.createWebServer) + }) - return request(server) - .get('/some/weird/url') - .expect(222, 'CONTENT') - }) + it('should be an instance of https.Server', () => { - it('should serve 404 for non-existing files', () => { - servedFiles(new Set()) + expect(server instanceof require('https').Server).to.equal(true) + + }) + + it('should serve client.html', () => { + + servedFiles(new Set()) + + process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0' + + return request(server) + .get('/') + .expect(200, 'CLIENT HTML') + + }) - return request(server) - .get('/non/existing.html') - .expect(404) }) + })