Skip to content
Permalink
Browse files
feat(local-server): Support for testing HTTPS servers
  • Loading branch information
jan-molak committed Apr 11, 2019
1 parent 9b0ea01 commit 569d1bc57c7fefa775fd131ec7e0c74f77c8250c
Showing 11 changed files with 239 additions and 38 deletions.
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDFDCCAfwCCQD1O/bbEs7PkjANBgkqhkiG9w0BAQsFADBLMQswCQYDVQQGEwJV
UzEMMAoGA1UECAwDRm9vMQwwCgYDVQQHDANCYXIxDDAKBgNVBAoMA0JhejESMBAG
A1UEAwwJbG9jYWxob3N0MCAXDTE5MDQxMTA4NDcyMloYDzIxMTkwMzE4MDg0NzIy
WjBLMQswCQYDVQQGEwJVUzEMMAoGA1UECAwDRm9vMQwwCgYDVQQHDANCYXIxDDAK
BgNVBAoMA0JhejESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEA09JDbHVg8E+eg/GpVjHBR6vitWcJ6Pdn7gSLD34CGWKe
mYYv74M3jzqci9lstdxQyawxwkDkVQl8eK69p32Tf0ZGZS7NWtr85Tm5G2jPsHN9
BL+07t1S6WlZbjOiqShL3sR1E7P1Eu0fMROa5BMyBfm6KhmsoUoU3Y3fU4cdpsI3
P5YrDYgcr/WuwgEHyB49CRtpNehVHf4PBoeSPGHTIaALS49oVLFns0DF1re9ErA4
KvBZvYzjkM38vSrb5WEO7TwQmJDLuS3VNZ+5xw1GKfdJvDbPB+9uvs590Gpn2OAA
aDqcg7t3DCRY/+tz3Z+VTcozX87G8Zkkhg4QZ1Xw4wIDAQABMA0GCSqGSIb3DQEB
CwUAA4IBAQDAHnax4XBWrwfHK+pYL8nhfbnLGGccZUwb3/wou3Dokvs9lAnY+E3P
SQ7przaN3eRw0q1elDZ68fiQEfuIiK8D4Ak2ecPf+6rKLE+mUxYMfvJZH8cB6sZE
FYuwIhKwDExVsdERq18iaE7pldelUNRTO+Pdz8pWZG7rqT5XBVTBwsSl6rX9mcr4
/vPg+X8uEF/dUm6MLYSQpOGvgEeNIB2gf3FKF28xtvjRrDc3h5jb2RkdTZpbn0zv
yVumirKVSmHW+la69JD5HtKOIuvKbVcV3nQ1t+G1fmzv34BNChjatc3utsdXsoLt
yRt2s5shyEatwO0kxC0dhkEXjI9vRkZX
-----END CERTIFICATE-----
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA09JDbHVg8E+eg/GpVjHBR6vitWcJ6Pdn7gSLD34CGWKemYYv
74M3jzqci9lstdxQyawxwkDkVQl8eK69p32Tf0ZGZS7NWtr85Tm5G2jPsHN9BL+0
7t1S6WlZbjOiqShL3sR1E7P1Eu0fMROa5BMyBfm6KhmsoUoU3Y3fU4cdpsI3P5Yr
DYgcr/WuwgEHyB49CRtpNehVHf4PBoeSPGHTIaALS49oVLFns0DF1re9ErA4KvBZ
vYzjkM38vSrb5WEO7TwQmJDLuS3VNZ+5xw1GKfdJvDbPB+9uvs590Gpn2OAAaDqc
g7t3DCRY/+tz3Z+VTcozX87G8Zkkhg4QZ1Xw4wIDAQABAoIBAD9mkAfGml1Td37G
toi2G8P4DsN9M9onM1Rqx7S7YqV2f0I0h+SIwfh88p5pVcCZURUMFVivU6igTkFR
DDM1wxA1WJywhYbMRSXXQTCTDpch+imRt/ZHCKoUflAd5HH9PbhP1TswggpILy3h
UFsz46UmOjKfvKwKIHSwIkg+CQIAVB0TCjo5cv81ZYLpTFNZ3b2s/Xd0Qx0Gc8kS
1skdJ+faGxLRqOWrSjhyA6i428xG8eDLYXI4hPGiZnwWpxvNFmzwB11yH7RcL8i7
sKtdk6ihjUEymJO2XkaEA45QRpG7JlMuN1cakhmrrNrSz0lrCEtNEYZP8EtRMl2P
SvaOZgECgYEA99jZn1DPAvh1GBKqD3REcJxUpggbCZda3Uv79Cj2eYf+Mknh6i3g
e9KCaa9BjfS8ygzkzl5vXPQ34/m11eR6NDhlPPW7h7ManFKQMIZ/l5wGcbzcyfIV
Usczdn6Do4bxnmwv0chO9QjA7K5cdHMzeo/sGV81LxPL69G+3FJB+9sCgYEA2soJ
StVQhfnLR9r/EkN7nIJaPrxlYQQ3OrazSwXLmQNDbYKOOjFpJQr44Jua4B7AFlEH
wbF3Au3tEG5M4++EDvyV1LaLzVXk0AAk67fObOhJgsR4K2yFg/6s2tAuUx6KrPgD
+QSw4IxlbRGq2KBegm/pay9ap8w+AjisOJmMsZkCgYBWaAN2x3VkU7p+6gLf4Gj7
2YSpXaoPbfT/sb3lIWLMe9zjK17Xhab9hCZzMeZo1yn6RwR97e5lOb9Ce4wpRb5U
9lRVLFZ0uLxOQ3qBcGKLOJoGjRFsVjmY4lnOtcyu9hzGXnFNccgVJTgdS6xv7LnF
wOdO8SJZh01QqY8gwIzAgQKBgQCz3ruq+Ro9OvKjfWiMJEygjA4TW6FhFC0vqPpX
6EjM4AD0LAwvzWVq3c1kIqk+LimvbyiYVgTItMBb7MJr9gK0q3WmrfjbdA0r76Jq
4+7iXEnrJwjAcnSF4r9LGTGshgRuVWw2smOUB/hupcK2W4m3ZLgatZCrON+Vxe/Y
jGw9qQKBgDDqlgfY3oVmDRiuONT99PQ/+uIg7vnRD8Y1nRTIoCjMTQjZxHy+INFG
uQYu+hI4XW0atyapRpIjLEKx5Ae1lb1yWRw9yKUel8zGfPWM4X06ucmDtNLSjfJn
0CYn+YnLZM5mg3I5/ByBiIZSfGPhqtjKcEp89Ujsy08XKVA8AeMO
-----END RSA PRIVATE KEY-----
@@ -0,0 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';

export const certificates = {
key: fs.readFileSync(path.resolve(__dirname, '../certs/key.pem')),
cert: fs.readFileSync(path.resolve(__dirname, '../certs/certificate.pem')),
};
@@ -1,3 +1,4 @@
export * from './certificates';
export * from './child-process-reporter';
export * from './expect';
export * from './spawner';
@@ -62,7 +62,7 @@
"@types/express": "4.16.0",
"@types/hapi": "17.6.3",
"@types/restify": "7.2.6",
"axios": "0.18.0",
"axios": "^0.18.0",
"express": "4.16.4",
"hapi": "17.7.0",
"koa": "2.6.2",
@@ -18,7 +18,7 @@ describe('@serenity-js/local-server', () => {
class Actors implements DressingRoom {
prepare(actor: Actor): Actor {
return actor.whoCan(
ManageALocalServer.running(function(request, response) {
ManageALocalServer.runningAHttpListener(function(request, response) {
response.setHeader('Connection', 'close');
response.end('Hello World!');
}),
@@ -1,10 +1,11 @@
import 'mocha';

import { expect, stage } from '@integration/testing-tools';
import { certificates, expect, stage } from '@integration/testing-tools';
import { Ensure, equals, startsWith } from '@serenity-js/assertions';
import { Actor, DressingRoom } from '@serenity-js/core';
import { CallAnApi, GetRequest, LastResponse, Send } from '@serenity-js/rest';
import axios from 'axios';
import * as https from 'https';
import { given } from 'mocha-testdata';
import { satisfies } from 'semver'; // tslint:disable-line:no-implicit-dependencies

@@ -16,33 +17,149 @@ describe('ManageALocalServer', () => {

let Nadia: Actor;

given(servers).
it('allows the Actor to start, stop and access the location of a', function({ handler, node }) {
if (! satisfies(process.versions.node, node)) {
return this.skip();
}

class Actors implements DressingRoom {
prepare(actor: Actor): Actor {
return actor.whoCan(
ManageALocalServer.running(handler()),
CallAnApi.using(axios.create()),
);
}
}

Nadia = stage(new Actors()).theActorCalled('Nadia').whoCan(
ManageALocalServer.running(handler()),
CallAnApi.using(axios.create()),
);

return expect(Nadia.attemptsTo(
StartLocalServer.onRandomPort(),
Ensure.that(LocalServer.url(), startsWith('http://127.0.0.1')),
describe('when working with HTTP', () => {

given(servers).
it('allows the Actor to start, stop and access the location of a HTTP', function({ handler, node }) {
if (! satisfies(process.versions.node, node)) {
return this.skip();
}

class Actors implements DressingRoom {
prepare(actor: Actor): Actor {
return actor.whoCan(
ManageALocalServer.runningAHttpListener(handler()),
CallAnApi.using(axios.create()),
);
}
}

Nadia = stage(new Actors()).theActorCalled('Nadia');

return expect(Nadia.attemptsTo(
StartLocalServer.onRandomPort(),
Ensure.that(LocalServer.url(), startsWith('http://127.0.0.1')),
Send.a(GetRequest.to(LocalServer.url())),
Ensure.that(LastResponse.status(), equals(200)),
Ensure.that(LastResponse.body(), equals('Hello World!')),
)).to.be.fulfilled; // tslint:disable-line:no-unused-expression
});

});

// ---

describe('when working with HTTPS', () => {

const testHttpsServer = [
StartLocalServer.onOneOfThePreferredPorts([ 8443, 9443 ]),
Ensure.that(LocalServer.url(), startsWith('https://127.0.0.1')),
Send.a(GetRequest.to(LocalServer.url())),
Ensure.that(LastResponse.status(), equals(200)),
Ensure.that(LastResponse.body(), equals('Hello World!')),
)).to.be.fulfilled; // tslint:disable-line:no-unused-expression
];

given(
require('./servers/barebones'),
require('./servers/express'),
require('./servers/koa'),
).
it('allows the Actor to start, stop and access the location of a HTTPS', function({ handler, node }) {
if (! satisfies(process.versions.node, node)) {
return this.skip();
}

class Actors implements DressingRoom {
prepare(actor: Actor): Actor {
return actor.whoCan(
ManageALocalServer.runningAHttpsListener(handler(), {
cert: certificates.cert,
key: certificates.key,
requestCert: true,
rejectUnauthorized: false,
}),
CallAnApi.using(axios.create({
proxy: false,
httpsAgent: new https.Agent({
cert: certificates.cert,
key: certificates.key,
rejectUnauthorized: false,
}),
})),
);
}
}

Nadia = stage(new Actors()).theActorCalled('Nadia');

return expect(Nadia.attemptsTo(...testHttpsServer)).to.be.fulfilled; // tslint:disable-line:no-unused-expression
});

it('allows the Actor to start, stop and access the location of a HTTPS Hapi app', function() {
const hapi = require('./servers/hapi');

if (! satisfies(process.versions.node, hapi.node)) {
return this.skip();
}

class Actors implements DressingRoom {
prepare(actor: Actor): Actor {
return actor.whoCan(
ManageALocalServer.runningAHttpsListener(hapi.handler({
tls: {
cert: certificates.cert,
key: certificates.key,
requestCert: true,
rejectUnauthorized: false,
},
})),
CallAnApi.using(axios.create({
proxy: false,
httpsAgent: new https.Agent({
cert: certificates.cert,
key: certificates.key,
rejectUnauthorized: false,
}),
})),
);
}
}

Nadia = stage(new Actors()).theActorCalled('Nadia');

return expect(Nadia.attemptsTo(...testHttpsServer)).to.be.fulfilled; // tslint:disable-line:no-unused-expression
});

it('allows the Actor to start, stop and access the location of a Restify app', function() {
const restify = require('./servers/restify');

if (! satisfies(process.versions.node, restify.node)) {
return this.skip();
}

class Actors implements DressingRoom {
prepare(actor: Actor): Actor {
return actor.whoCan(
ManageALocalServer.runningAHttpsListener(restify.handler({
certificate: certificates.cert,
key: certificates.key,
})),
CallAnApi.using(axios.create({
proxy: false,
httpsAgent: new https.Agent({
cert: certificates.cert,
key: certificates.key,
rejectUnauthorized: false,
}),
})),
);
}
}

Nadia = stage(new Actors()).theActorCalled('Nadia');

return expect(Nadia.attemptsTo(...testHttpsServer)).to.be.fulfilled; // tslint:disable-line:no-unused-expression
});
});

afterEach(() => Nadia.attemptsTo(
@@ -3,9 +3,9 @@
export = {
node: '>= 8.12',
description: 'Hapi app',
handler: () => {
handler: (options?: any) => {
const hapi = require('hapi'); // tslint:disable-line:no-var-requires Requiring Hapi breaks the build on Node 6
const server = new hapi.Server();
const server = new hapi.Server(options);

server.route({ method: 'GET', path: '/', handler: (req, h) => 'Hello World!' });

@@ -4,8 +4,8 @@ import * as restify from 'restify';
export = {
node: '>= 6.9',
description: 'Restify app',
handler: () => {
const server = restify.createServer();
handler: (options?: any) => {
const server = restify.createServer(options);

server.get('/', (req, res, next) => {
res.send('Hello World!');
@@ -2,6 +2,7 @@ import { Ability, UsesAbilities } from '@serenity-js/core';
import getPort = require('get-port');
import * as http from 'http';
import withShutdownSupport = require('http-shutdown');
import * as https from 'https';
import * as net from 'net';

/**
@@ -43,18 +44,37 @@ import * as net from 'net';
*/
export class ManageALocalServer implements Ability {

/**
* @private
*/
private readonly server: net.Server & { shutdown: (callback: (error?: Error) => void) => void };

/**
* @desc
* Ability to manage a Node.js HTTP server using a given server requestListener.
*
* @returns {ManageALocalServer}
*/
static running(listener: (request: http.IncomingMessage, response: http.ServerResponse) => void | net.Server) {
static runningAHttpListener(listener: (request: http.IncomingMessage, response: http.ServerResponse) => void | net.Server) {
const server = typeof listener === 'function'
? http.createServer(listener)
: listener;

return new ManageALocalServer(withShutdownSupport(server));
return new ManageALocalServer(SupportedProtocols.HTTP, server);
}

/**
* @desc
* Ability to manage a Node.js HTTPS server using a given server requestListener.
*
* @returns {ManageALocalServer}
*/
static runningAHttpsListener(listener: (request: http.IncomingMessage, response: http.ServerResponse) => void | https.Server, options: https.ServerOptions = {}) {
const server = typeof listener === 'function'
? https.createServer(options, listener)
: listener;

return new ManageALocalServer(SupportedProtocols.HTTPS, server);
}

/**
@@ -70,12 +90,16 @@ export class ManageALocalServer implements Ability {
}

/**
* @param {string} protocol
* Protocol to be used when communicating with the running server.
*
* @param {net~Server} server
* A Node.js server requestListener, with support for server shutdown.
*
* @see https://www.npmjs.com/package/http-shutdown
*/
constructor(private readonly server: net.Server & { shutdown: (callback: (error?: Error) => void) => void }) {
constructor(private readonly protocol: SupportedProtocols, server: net.Server) {
this.server = withShutdownSupport(server);
}

/**
@@ -104,7 +128,12 @@ export class ManageALocalServer implements Ability {
* @param fn
* @returns {T}
*/
mapInstance<T>(fn: (server: net.Server & { shutdown: (callback: (error?: Error) => void) => void }) => T): T {
return fn(this.server);
mapInstance<T>(fn: (server: net.Server & { shutdown: (callback: (error?: Error) => void) => void }, protocol?: SupportedProtocols) => T): T {
return fn(this.server, this.protocol);
}
}

enum SupportedProtocols {
HTTP = 'http',
HTTPS = 'https',
}
@@ -10,15 +10,16 @@ export class LocalServer {
*/
static url() {
return Question.about<string>('the URL of the local server', actor => {
return ManageALocalServer.as(actor).mapInstance(server => {
return ManageALocalServer.as(actor).mapInstance((server, protocol) => {
const info = server.address();

if (! isAddressInfo(info)) {
throw new LogicError(`A pipe or UNIX domain socket server does not have a URL`);
}

return [
'http://',
protocol,
'://',
`${ info.family }`.toLowerCase() === 'ipv6' ? `[${ info.address }]` : info.address,
':',
info.port,

0 comments on commit 569d1bc

Please sign in to comment.