UNPKG

@contexts/http

Version:

The Http(s) Testing Context For Super-Test Style Assertions. Includes Standard Assertions (get, set, assert), And Allows To Be Extended With JSDocumented Custom Assertions.

465 lines (457 loc) 14.8 kB
const { createServer } = require('http'); const { createServer: createSecureServer, Server: HttpsServer } = require('https'); const { join } = require('path'); const { readFileSync } = require('fs'); const cleanStack = require('@artdeco/clean-stack'); const { c } = require('erte'); const { equal, ok } = require('assert'); const aqt = require('@rqt/aqt'); const erotic = require('erotic'); const { format } = require('url'); const deepEqual = require('@zoroaster/deep-equal'); const Form = require('@multipart/form'); const { parseSetCookie, wasExpectedError, didNotMatchValue, wasNotExpected } = require('./lib'); class Server { constructor() { /** * The constructor for the tester that will be returned by the `start` method. Additional assertions can be implemented by extending the `Tester` class that comes with the server. */ this.TesterConstructor = Tester /** * The HTTP(S) server will be set on the tester after the `start` method is called. It will be automatically destroyed by the end of the test. * @type {http.Server|https.Server} */ this.server = null /** * After the request listener is called, the `response` will be set to the server response which comes as the second argument to the request listener callback. Is not set when using the `listen` method. * @type {http.ServerResponse} */ this.response = null /** * The map of connections to the server. Used to finish any unended requests. * @type {Object<string, net.Socket>} */ this._connections = {} /** * The tester created after the start, startPlain or listen methods. */ this.tester = null /** * Whether to manage the session cookies. */ this.session = false } /** * Call to switch on printing of debug messages and error stacks in the response body. * @param {boolean} [on] Whether to switch on debugging. Default `true`. */ debug(on = true) { this._debug = on } /** * Returns the server response after the request listener finished. */ getResponse() { return this.response } /** * Creates a server and wraps the supplied listener in the handler that will set status code `500` if the listener threw and the body to the error text. * @param {function(http.IncomingMessage, http.ServerResponse)} fn The server callback. * @param {boolean} [secure=false] Whether to start an https server. */ start(fn, secure = false) { const handler = async (req, res) => { try { this.response = res await fn(req, res) } catch (err) { res.statusCode = 500 res.write(err.message) if (this._debug) console.error(c(cleanStack(err.stack), 'yellow')) } finally { res.end() } } return this._start(handler, secure) } /** * Creates a server with the supplied listener. * @param {function(http.IncomingMessage, http.ServerResponse)} fn The server callback. * @param {boolean} [secure=false] Whether to start an https server. */ startPlain(fn, secure) { /** @type {function(http.IncomingMessage, http.ServerResponse)} */ const handler = async (req, res) => { try { this.response = res await fn(req, res) } catch (err) { if (this._debug) console.error(c(cleanStack(err.stack), 'yellow')) throw err } } return this._start(handler, secure) } /** * @private */ _start(handler, secure) { let server // const handler if (secure) { const CERT = readFileSync(join(__dirname, 'server.crt'), 'ascii') const KEY = readFileSync(join(__dirname, 'server.key'), 'ascii') process.env.NODE_TLS_REJECT_UNAUTHORIZED=0 server = createSecureServer({ cert: CERT, key: KEY }, handler) } else { server = createServer(handler) } return this.listen(server) } async _destroy() { this._destroyed = true if (this.server) await new Promise(r => { this.server.close(r) for (let k in this._connections) { this._connections[k].destroy() } }) } /** * Calls the `listen` method on the server to accept incoming connections. * @param {http.Server|https.Server} server The server to start. */ listen(server) { const secure = server instanceof HttpsServer const tester = new this.TesterConstructor() tester._addLink(async () => { await new Promise(r => server.listen(r)) if (this._destroyed) { // guard against errors in the test case server.close() return false } tester.server = server tester.url = format({ protocol: secure ? 'https' : 'http', hostname: '0.0.0.0', port: server.address().port, }) this.server = server }) tester.context = this if (this.session) tester._session = true server.on('connection', (con) => { const { remoteAddress, remotePort } = con const k = [remoteAddress, remotePort].join(':') this._connections[k] = con con.on('close', () => { delete this._connections[k] }) }) this.tester = tester return tester } /** * The method will be called prior to making further requests. */ _reset() { } } /** * The tester for assertions. */ class Tester extends Promise { constructor() { super(() => {}) /** * The headers to send with the request, must be set before the `get` method is called using the `set` method. * @type {http.OutgoingHttpHeaders} */ this._headers = {} /** * @private */ this._chain = Promise.resolve(true) /** * The reference to the parent context that started the server. * @type {Server} */ this.context = null /** * The response saved after requests. * @type {import('@rqt/aqt').AqtReturn} */ this.res = null /** * Whether the cookies will be maintained by the tester. */ this._session = false /** * When using the session, stores the cookies. */ this._cookies = {} } /** * Adds the action to the list. * @private */ _addLink(fn, e) { this._chain = this._chain.then(async (res) => { if (res === false) return false try { return await fn() } catch (err) { if (e) throw e(err) throw err } }) } /** * Save the result. * @param {AqtReturn} res * @param {string} path The request path. * @private */ _assignRes(res, path) { this.res = res if (this._session) { const ch = res.headers['set-cookie'] || [] const parsed = ch.map(parseSetCookie) parsed.forEach(({ name, expires, value }) => { if (!value) { if (this.context._debug) console.error( c(path, 'grey'), c(`Server deleted cookie ${name}`, 'yellow')) delete this._cookies[name] return } if (expires && (new Date(expires) <= new Date())) { if (this.context._debug) console.error( c(path, 'grey'), c(`Cookie ${name} expired`, 'yellow')) delete this._cookies[name] return } this._cookies[name] = value if (this.context._debug) console.error( c(path, 'grey'), c('Setting cookie', 'yellow'), c(name, 'blue'), c(`to ${value}`, 'yellow'), expires ? c(`(expires ${expires})`, 'grey') : '') }) } } /** * @private */ then(Ok, notOk) { return this._chain.then(() => { Ok() }, (err) => { notOk(err) }) } /** * Navigate to the path and store the result status code, body and headers in an internal state. * @param {string} path The path to navigate, empty by default. */ get(path = '') { this._addLink(async () => { this.context._reset() const res = await aqt(`${this.url}${path}`, { headers: this.headers, }) this._assignRes(res, path) }) return this } /** * Send a request for the `Allow` and CORS pre-flight headers. * @param {string} path The path to navigate, empty by default. */ options(path = '') { this._addLink(async () => { this.context._reset() const res = await aqt(`${this.url}${path}`, { headers: this.headers, method: 'OPTIONS', }) this._assignRes(res, path) }) return this } /** * Send PUT request. * @param {string} [path] The path to send request to, empty by default. * @param {*} [data] The data to send. If an object is passed, the default content-type is `application/json`, and if a string is passed, it's `text/plain`. This can be overridden with the `type` option. * @param {!AqtOptions} [options] The options for the request library. */ put(path = '', data = undefined, options = {}) { this._addLink(async () => { this.context._reset() const res = await aqt(`${this.url}${path}`, { headers: this.headers, method: 'PUT', data, type: typeof data == 'string' ? 'text/plain' : undefined, ...options, }) this._assignRes(res, path) }) return this } /** * Post data to the path. By default, sends JSON-stringified body with `application/json` content type, unless specified otherwise in options (e.g., pass `{ type: form }` for `application/x-www-form-urlencoded` content-type). * @param {string} [path] The path to navigate, empty by default. * @param {*} [data] The data to send. If an object is passed, the default content-type is `application/json`, and if a string is passed, it's `text/plain`. This can be overridden with the `type` option. * @param {!AqtOptions} [options] The options for the request library. */ post(path = '', data = undefined, options = {}) { this._addLink(async () => { this.context._reset() const res = await aqt(`${this.url}${path}`, { headers: this.headers, data, type: typeof data == 'string' ? 'text/plain' : undefined, ...options, }) this._assignRes(res, path) }) return this } /** * Post form-data to the path. * @param {string} path The path to post data to, empty by default. * @param {(form: Form) => Promise<void>|void} [cb] The callback with the form. * @param {!AqtOptions} [options] The options for the request library. */ postForm(path = '', cb, options = {}) { this._addLink(async () => { this.context._reset() const form = new Form() await cb(form) const { buffer } = form const res = await aqt(`${this.url}${path}`, { headers: { ...this.headers, 'Content-Type': `multipart/form-data; boundary=${form.boundary}` }, type: null, data: buffer, ...options, }) this._assignRes(res, path) }) return this } /** * Send a `HEAD` request to the server. * @param {string} path The path to navigate, empty by default. */ head(path = '') { this._addLink(async () => { const res = await aqt(`${this.url}${path}`, { headers: this.headers, method: 'HEAD', }) this._assignRes(res) }) return this } get headers() { const cookies = Object.keys(this._cookies).map(((k) => { const val = this._cookies[k] return `${k}=${val}` })) if (this._headers.Cookie) cookies.push(this._headers.Cookie) const Cookie = cookies.join(';') return { ...this._headers, ...(Cookie ? { Cookie } : {}), } } /** * Assert on the status code and body when a number is given. * Assert on the header when the string is given. If the second arg is null, asserts on the absence of the header. * @param {number|string|function(AqtReturn)} code The number of the status code, or name of the header, or the custom assertion function. * @param {String} message The body or header value (or null for no header). */ assert(code, message) { const e = erotic(true) this._addLink(async () => { if (typeof code == 'function') { await code(this.res) return } if (typeof code == 'string') { const header = this.res.headers[code.toLowerCase()] if (message instanceof RegExp) { ok(message.test(header), `Header ${c(code, 'blue')} did not match RexExp: ${c(`- ${message}`, 'red')} ${c(`+ ${header}`, 'green')}`) return } else if (message && !header){ throw new Error(wasExpectedError('Header', code, message)) } else if (message) { equal(header, message, didNotMatchValue('Header', code, message, header)) return } else if (message === null) { if (header) throw new Error(wasNotExpected('Header', code, header)) else return } throw new Error('Nothing was tested') } // if we're here means code assertion try { equal(this.res.statusCode, code) } catch (err) { err.message = err.message + ' ' + this.res.body || '' throw err } if (message instanceof RegExp) { ok(message.test(this.res.body), `The body does not match ${message}`) } else if (typeof message == 'object') { deepEqual(this.res.body, message) } else if (message !== undefined) deepEqual(this.res.body, message) }, e) return this } /** * Sets the value for the header in the upcoming request. * @param {string} name The name of the header to set. * @param {string|function(): (Promise<string>|string)} value The value to set. */ set(name, value) { this._addLink(async () => { if (typeof value == 'function') { value = await value() } this._headers[name] = value }) return this } /** * Start the session, by keeping track of the cookies that are send via the `set-cookies` header (added, removed). */ session() { this._session = true return this } } /** * @typedef {import('http').IncomingMessage} http.IncomingMessage * @typedef {import('http').ServerResponse} http.ServerResponse * @typedef {import('http').OutgoingHttpHeaders} http.OutgoingHttpHeaders * @typedef {import('http').IncomingHttpHeaders} http.IncomingHttpHeaders * @typedef {import('http').Server} http.Server * @typedef {import('https').Server} https.Server * @typedef {import('net').Socket} net.Socket * @typedef {import('@rqt/aqt').AqtReturn} AqtReturn * @typedef {import('@rqt/aqt').AqtOptions} AqtOptions */ /** @typedef {import('./types').TestSuite} TestSuite */ module.exports = Server module.exports.Tester = Tester