├── data └── .empty ├── .tool-versions ├── test ├── data │ ├── endpoints.file │ ├── cli.getKey.pem │ ├── cli.getPfx.pfx │ ├── endpoints-1.file │ ├── cli.getCert.pem │ ├── endpoints-2.file │ ├── binary.file │ ├── cli.getData.yaml │ ├── cli.getData.json │ └── e2e.yaml ├── .eslintrc ├── global.js ├── helpers │ ├── buffer-equal.js │ ├── compare-objects.js │ ├── waits-for.js │ └── create-request.js ├── prettyprint.js ├── main.js ├── e2e.admin.js ├── args.js ├── cli.js ├── endpoint.js ├── admin.js ├── contract.js ├── e2e.stubs.js └── endpoints.js ├── .tern-project ├── .gitignore ├── .travis.yml ├── src ├── lib │ └── clone.js ├── console │ ├── prettyprint.js │ ├── colorsafe.js │ ├── out.js │ ├── watch.js │ ├── args.js │ └── cli.js ├── portals │ ├── stubs.js │ ├── portal.js │ └── admin.js ├── models │ ├── endpoints.js │ ├── contract.js │ └── endpoint.js └── main.js ├── bin └── stubby ├── webroot ├── status │ └── index.html ├── css │ ├── highlight.pack.css │ └── styles.css └── js │ ├── scripts.js │ └── external │ ├── highlight.pack.js │ └── _.js ├── CONTRIBUTING.md ├── CONTRIBUTERS.md ├── package.json ├── man └── stubby.1 ├── tls ├── cert.pem └── key.pem ├── CHANGELOG.md └── LICENSE /data/.empty: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 14.16.0 2 | -------------------------------------------------------------------------------- /test/data/endpoints.file: -------------------------------------------------------------------------------- 1 | file contents! 2 | -------------------------------------------------------------------------------- /test/data/cli.getKey.pem: -------------------------------------------------------------------------------- 1 | some generated key 2 | -------------------------------------------------------------------------------- /test/data/cli.getPfx.pfx: -------------------------------------------------------------------------------- 1 | some generated pfx 2 | -------------------------------------------------------------------------------- /test/data/endpoints-1.file: -------------------------------------------------------------------------------- 1 | endpoints-1.file 2 | -------------------------------------------------------------------------------- /test/data/cli.getCert.pem: -------------------------------------------------------------------------------- 1 | some generated certificate 2 | -------------------------------------------------------------------------------- /test/data/endpoints-2.file: -------------------------------------------------------------------------------- 1 | endpoints-<% url[1] %>.file 2 | -------------------------------------------------------------------------------- /.tern-project: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "node": {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | data/* 4 | .idea 5 | yarn.lock 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - "lts/*" 5 | - "node" 6 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | --- 2 | env: 3 | mocha: true 4 | 5 | rules: 6 | no-invalid-this: 0 7 | -------------------------------------------------------------------------------- /test/data/binary.file: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mrak/stubby4node/HEAD/test/data/binary.file -------------------------------------------------------------------------------- /src/lib/clone.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function clone (x) { 4 | return JSON.parse(JSON.stringify(x)); 5 | }; 6 | -------------------------------------------------------------------------------- /test/global.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sinon = require('sinon'); 4 | const assert = require('assert'); 5 | 6 | if (!assert.deepStrictEqual) { 7 | assert.deepStrictEqual = assert.deepEqual; /* eslint-disable-line */ 8 | } 9 | 10 | beforeEach(function () { this.sandbox = sinon.createSandbox(); }); 11 | 12 | afterEach(function () { this.sandbox.restore(); }); 13 | -------------------------------------------------------------------------------- /test/helpers/buffer-equal.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function bufferEqual (l, r) { 4 | if (!Buffer.isBuffer(l)) return undefined; 5 | if (!Buffer.isBuffer(r)) return undefined; 6 | if (typeof l.equals === 'function') return l.equals(r); 7 | if (l.length !== r.length) return false; 8 | for (let i = 0; i < l.length; i++) if (l[i] !== r[i]) return false; 9 | return true; 10 | }; 11 | -------------------------------------------------------------------------------- /test/data/cli.getData.yaml: -------------------------------------------------------------------------------- 1 | - request: 2 | url: /testput 3 | method: PUT 4 | post: test data 5 | response: 6 | headers: 7 | content-type: text/plain 8 | status: 404 9 | latency: 2000 10 | body: test response 11 | - request: 12 | url: /testdelete 13 | method: DELETE 14 | post: null 15 | response: 16 | headers: 17 | content-type: text/plain 18 | status: 204 19 | body: null 20 | -------------------------------------------------------------------------------- /bin/stubby: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | var CLI = require('../src/console/cli'); 4 | var stubby = new (require('../src/main').Stubby); 5 | var options = CLI.getArgs(); 6 | 7 | stubby.start(options, function(errors) { 8 | if(Array.isArray(errors)) { 9 | console.log('Stubby is being stopped because of errors:'); 10 | errors.map(console.log); 11 | process.exit(1); 12 | } 13 | }); 14 | 15 | process.on('SIGHUP', function() { 16 | stubby.delete(function () { stubby.start(options); }); 17 | }) 18 | -------------------------------------------------------------------------------- /test/data/cli.getData.json: -------------------------------------------------------------------------------- 1 | [{ 2 | "request": { 3 | "url": "/testput", 4 | "method": "PUT", 5 | "post": "test data" 6 | }, 7 | "response": { 8 | "headers": { 9 | "content-type": "text/plain" 10 | }, 11 | "status": 404, 12 | "latency": 2000, 13 | "body": "test response" 14 | } 15 | },{ 16 | "request": { 17 | "url": "/testdelete", 18 | "method": "DELETE", 19 | "post": null 20 | }, 21 | "response": { 22 | "headers": { 23 | "content-type": "text/plain" 24 | }, 25 | "status": 204, 26 | "body": null 27 | } 28 | }] 29 | -------------------------------------------------------------------------------- /test/helpers/compare-objects.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function compareOneWay (left, right) { 4 | let key, value; 5 | 6 | for (key in left) { 7 | if (!Object.prototype.hasOwnProperty.call(left, key)) { continue; } 8 | 9 | value = left[key]; 10 | 11 | if (right[key] !== value) { continue; } 12 | 13 | if (typeof value === 'object') { 14 | if (!compareObjects(value, right[key])) { continue; } 15 | } 16 | 17 | return false; 18 | } 19 | 20 | return true; 21 | } 22 | 23 | function compareObjects (one, two) { 24 | return compareOneWay(one, two) && compareOneWay(two, one); 25 | } 26 | 27 | module.exports = compareObjects; 28 | -------------------------------------------------------------------------------- /webroot/status/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Stubby Status 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/console/prettyprint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function prettyPrint (tokens, continuation = 0, columns = process.stdout.columns) { 4 | let wrapped; 5 | 6 | if (continuation + tokens.join(' ').length <= columns) { return tokens.join(' '); } 7 | 8 | wrapped = ''; 9 | const gutter = ''.padEnd(continuation); 10 | 11 | tokens.forEach(function (token) { 12 | const lengthSoFar = continuation + wrapped.replace(/\n/g, '').length % columns || columns; 13 | 14 | if (lengthSoFar + token.length > columns) { 15 | wrapped += '\n' + gutter + token; 16 | } else { 17 | wrapped += ' ' + token; 18 | } 19 | }); 20 | 21 | return wrapped.trim(); 22 | } 23 | 24 | module.exports = prettyPrint; 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | Fork, modify, request a pull. If changes are significant or touch more than one 5 | part of the system, tests are suggested. 6 | 7 | If large pull requests do not have tests there may be some push back until 8 | functionality can be verified :) 9 | 10 | This project uses [ESLint](http://eslint.org) for code style and conformity. 11 | 12 | ## Running Tests 13 | 14 | npm test 15 | 16 | ## Relevant tools 17 | 18 | - [node-inspector](https://github.com/node-inspector/node-inspector) 19 | - For using chrome's devtools for debugging 20 | - [eslint](http://eslint.org) 21 | - For linting and code-style 22 | - [mocha](https://mochajs.org) 23 | - BDD-style testing framework 24 | -------------------------------------------------------------------------------- /src/console/colorsafe.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* eslint-disable no-console */ 3 | 4 | function stripper (args) { 5 | let key, value; 6 | for (key in args) { 7 | if (!Object.prototype.hasOwnProperty.call(args, key)) { continue; } 8 | 9 | value = args[key]; 10 | args[key] = value.replace(/\u001b\[(\d+;?)+m/g, ''); /* eslint-disable-line */ 11 | } 12 | return args; 13 | } 14 | 15 | function colorsafe (console) { 16 | if (process.stdout.isTTY) { return true; } 17 | 18 | console.raw = {}; 19 | 20 | ['log', 'warn', 'info', 'error'].forEach(function (fn) { 21 | console.raw[fn] = console[fn]; 22 | console[fn] = function () { console.raw[fn].apply(console, stripper(arguments)); }; 23 | }); 24 | 25 | return false; 26 | } 27 | 28 | module.exports = colorsafe; 29 | -------------------------------------------------------------------------------- /test/helpers/waits-for.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const assert = require('assert'); 3 | 4 | module.exports = function waitsFor (fn, message, range, finish, time) { 5 | const min = range[0] != null ? range[0] : 0; 6 | const max = range[1] != null ? range[1] : range; 7 | 8 | if (time == null) { time = process.hrtime(); } 9 | 10 | const temp = time == null ? process.hrtime() : process.hrtime(time); 11 | const seconds = temp[0]; 12 | const nanoseconds = temp[1]; 13 | const elapsed = seconds * 1000 + nanoseconds / 1000000; 14 | 15 | assert(elapsed < max, 'Timed out waiting ' + max + 'ms for ' + message); 16 | 17 | if (fn()) { 18 | assert(elapsed > min, 'Condition succeeded before ' + min + 'ms were up'); 19 | return finish(); 20 | } 21 | 22 | setTimeout(function () { 23 | waitsFor(fn, message, range, finish, time); 24 | }, 1); 25 | }; 26 | -------------------------------------------------------------------------------- /CONTRIBUTERS.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | [Eric Mrak](https://github.com/mrak) 4 | 5 | [Andrew Yurisich](https://github.com/Droogans) 6 | 7 | [Aparna Sathyanarayan](https://github.com/aparna-sath) 8 | 9 | [Diego Santos da Silveira](https://github.com/diegosilveira) 10 | 11 | [Esco Obong](https://github.com/esco) 12 | 13 | [Hadi Michael](https://github.com/hadimichael) 14 | 15 | [JJ4TH](https://github.com/jj4th) 16 | 17 | [James Murphy](https://github.com/murphyj) 18 | 19 | [Jay Parlar](https://github.com/parlarjb) 20 | 21 | [Josh Price](https://github.com/joshprice) 22 | 23 | [Josh Stuart](https://github.com/joshystuart) 24 | 25 | [Mark Campbell](https://github.com/Nitrodist) 26 | 27 | [Tomás Aparicio](https://github.com/h2non) 28 | 29 | [Vincent Lemeunier](https://github.com/kombucha) 30 | 31 | # Special Thanks 32 | 33 | Alexander Zagniotov 34 | 35 | Isa Goksu 36 | -------------------------------------------------------------------------------- /test/helpers/create-request.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const http = require('http'); 4 | const qs = require('querystring'); 5 | 6 | module.exports = function (context, callback) { 7 | const options = { 8 | port: context.port, 9 | method: context.method, 10 | path: context.url, 11 | headers: context.requestHeaders 12 | }; 13 | 14 | context.done = false; 15 | 16 | if (context.query != null) { 17 | options.path += '?' + qs.stringify(context.query); 18 | } 19 | 20 | const request = http.request(options, function (response) { 21 | let data = ''; 22 | 23 | response.on('data', function (chunk) { 24 | data += chunk; 25 | }); 26 | 27 | response.on('end', function () { 28 | response.data = data; 29 | callback(response); 30 | }); 31 | }); 32 | 33 | if (context.post != null) { 34 | request.write(context.post); 35 | } 36 | 37 | request.end(); 38 | return request; 39 | }; 40 | -------------------------------------------------------------------------------- /test/prettyprint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sut = require('../src/console/prettyprint'); 4 | const assert = require('assert'); 5 | 6 | describe('prettyprint', function () { 7 | describe('wrap', function () { 8 | it('should linebreak at word instead of character given tokens', function () { 9 | const continuationIndent = 0; 10 | const columns = 25; 11 | const words = 'one fish, two fish, red fish, blue fish'.split(' '); 12 | const actual = sut(words, continuationIndent, columns); 13 | 14 | assert.strictEqual(actual, 'one fish, two fish, red\nfish, blue fish'); 15 | }); 16 | 17 | it('should indent before subsequent lines', function () { 18 | const continuationIndent = 5; 19 | const columns = 25; 20 | const words = 'one fish, two fish, red fish, blue fish'.split(' '); 21 | const actual = sut(words, continuationIndent, columns); 22 | 23 | assert.strictEqual(actual, 'one fish, two fish,\n red fish, blue fish'); 24 | }); 25 | 26 | it('should wrap past multiple lines', function () { 27 | const continuationIndent = 5; 28 | const columns = 15; 29 | const words = 'one fish, two fish, red fish, blue fish'.split(' '); 30 | const actual = sut(words, continuationIndent, columns); 31 | 32 | assert.strictEqual(actual, 'one fish,\n two fish,\n red fish,\n blue fish'); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /webroot/css/highlight.pack.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Orginal Style from ethanschoonover.com/solarized (c) Jeremy Hull 4 | 5 | */ 6 | 7 | pre code { 8 | display: block; padding: 0.5em; 9 | background: #002b36; color: #839496; 10 | } 11 | 12 | pre .comment, 13 | pre .template_comment, 14 | pre .diff .header, 15 | pre .doctype, 16 | pre .pi, 17 | pre .lisp .string, 18 | pre .javadoc { 19 | color: #586e75; 20 | font-style: italic; 21 | } 22 | 23 | pre .keyword, 24 | pre .winutils, 25 | pre .method, 26 | pre .addition, 27 | pre .css .tag, 28 | pre .request, 29 | pre .status, 30 | pre .nginx .title { 31 | color: #859900; 32 | } 33 | 34 | pre .number, 35 | pre .command, 36 | pre .string, 37 | pre .tag .value, 38 | pre .phpdoc, 39 | pre .tex .formula, 40 | pre .regexp, 41 | pre .hexcolor { 42 | color: #2aa198; 43 | } 44 | 45 | pre .title, 46 | pre .localvars, 47 | pre .chunk, 48 | pre .decorator, 49 | pre .built_in, 50 | pre .identifier, 51 | pre .vhdl .literal, 52 | pre .id { 53 | color: #268bd2; 54 | } 55 | 56 | pre .attribute, 57 | pre .variable, 58 | pre .lisp .body, 59 | pre .smalltalk .number, 60 | pre .constant, 61 | pre .class .title, 62 | pre .parent, 63 | pre .haskell .type { 64 | color: #b58900; 65 | } 66 | 67 | pre .preprocessor, 68 | pre .preprocessor .keyword, 69 | pre .shebang, 70 | pre .symbol, 71 | pre .symbol .string, 72 | pre .diff .change, 73 | pre .special, 74 | pre .attr_selector, 75 | pre .important, 76 | pre .subst, 77 | pre .cdata, 78 | pre .clojure .title { 79 | color: #cb4b16; 80 | } 81 | 82 | pre .deletion { 83 | color: #dc322f; 84 | } 85 | 86 | pre .tex .formula { 87 | background: #073642; 88 | } 89 | -------------------------------------------------------------------------------- /src/console/out.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | /* eslint-disable no-console */ 3 | 4 | const BOLD = '\x1B[1m'; 5 | const BLACK = '\x1B[30m'; 6 | const BLUE = '\x1B[34m'; 7 | const CYAN = '\x1B[36m'; 8 | const GREEN = '\x1B[32m'; 9 | const MAGENTA = '\x1B[35m'; 10 | const RED = '\x1B[31m'; 11 | const YELLOW = '\x1B[33m'; 12 | const RESET = '\x1B[0m'; 13 | 14 | const out = { 15 | quiet: false, 16 | log: function (msg) { 17 | if (this.quiet) { return; } 18 | console.log(msg); 19 | }, 20 | status: function (msg) { 21 | if (this.quiet) { return; } 22 | console.log(BOLD + BLACK + msg + RESET); 23 | }, 24 | dump: function (data) { 25 | if (this.quiet) { return; } 26 | console.dir(data); 27 | }, 28 | info: function (msg) { 29 | if (this.quiet) { return; } 30 | console.info(BLUE + msg + RESET); 31 | }, 32 | ok: function (msg) { 33 | if (this.quiet) { return; } 34 | console.log(GREEN + msg + RESET); 35 | }, 36 | error: function (msg) { 37 | if (this.quiet) { return; } 38 | console.error(RED + msg + RESET); 39 | }, 40 | warn: function (msg) { 41 | if (this.quiet) { return; } 42 | console.warn(YELLOW + msg + RESET); 43 | }, 44 | incoming: function (msg) { 45 | if (this.quiet) { return; } 46 | console.log(CYAN + msg + RESET); 47 | }, 48 | notice: function (msg) { 49 | if (this.quiet) { return; } 50 | console.log(MAGENTA + msg + RESET); 51 | }, 52 | trace: function () { 53 | if (this.quiet) { return; } 54 | console.log(RED); 55 | console.trace(); 56 | console.log(RESET); 57 | } 58 | }; 59 | 60 | require('./colorsafe')(out); 61 | 62 | module.exports = out; 63 | -------------------------------------------------------------------------------- /webroot/css/styles.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | body { 7 | background: #333333; 8 | color: #DDDDDD; 9 | font-family: "Helvetica", "Arial", "sans serif" 10 | } 11 | 12 | a { 13 | text-decoration: none; 14 | font-weight: bold; 15 | color: #6EBEFF; 16 | } 17 | 18 | a:hover { 19 | text-decoration: underline; 20 | color: green; 21 | } 22 | 23 | li { list-style: none; } 24 | 25 | #endpoints { 26 | width: 960px; 27 | margin: auto; 28 | } 29 | 30 | #endpoints > li { 31 | margin: 25px 0; 32 | border-radius: 6px; 33 | overflow: hidden; 34 | border: 10px solid black; 35 | } 36 | 37 | table { 38 | border: 1px solid lightgray; 39 | width: 100%; 40 | border-collapse: collapse; 41 | } 42 | 43 | dd, dt { 44 | display: inline; 45 | } 46 | 47 | dt::after { 48 | content: ":"; 49 | } 50 | 51 | caption { 52 | text-align: left; 53 | padding: 10px; 54 | background: black; 55 | } 56 | 57 | th, td { 58 | border: 1px solid black; 59 | padding: 10px; 60 | background: black; 61 | } 62 | 63 | td { 64 | font-family: monospace; 65 | } 66 | 67 | th { 68 | vertical-align: top; 69 | width: 115px; 70 | } 71 | 72 | th.section::after, th.property::after { 73 | content: ":"; 74 | } 75 | 76 | th.section { 77 | color: rgb(207, 117, 186); 78 | } 79 | 80 | th.property { 81 | color: #009900; 82 | } 83 | 84 | th.property::before { 85 | content: ""; 86 | display: inline-block; 87 | width: 40px; 88 | } 89 | 90 | td > pre { 91 | border-radius: 3px; 92 | overflow: hidden; 93 | } 94 | 95 | .query-params li { 96 | margin: 0; 97 | } 98 | 99 | .query-params td, .query-params th { 100 | padding: 0; 101 | } 102 | -------------------------------------------------------------------------------- /src/portals/stubs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Portal = require('./portal').Portal; 4 | const qs = require('querystring'); 5 | 6 | class Stubs extends Portal { 7 | constructor (endpoints) { 8 | super(); 9 | this.server = this.server.bind(this); 10 | this.Endpoints = endpoints; 11 | this.name = '[stubs]'; 12 | } 13 | 14 | server (request, response) { 15 | let data = null; 16 | 17 | request.on('data', function (chunk) { 18 | data = data != null ? data : ''; 19 | data += chunk; 20 | 21 | return data; 22 | }); 23 | 24 | request.on('end', () => { 25 | this.received(request, response); 26 | 27 | const criteria = { 28 | url: request.url.replace(/(.*)\?.*/, '$1'), 29 | method: request.method, 30 | post: data, 31 | headers: request.headers, 32 | query: qs.parse(request.url.replace(/^.*\?(.*)$/, '$1')) 33 | }; 34 | 35 | try { 36 | const endpointResponse = this.Endpoints.find(criteria); 37 | const finalize = () => { 38 | this.writeHead(response, endpointResponse.status, endpointResponse.headers); 39 | response.write(endpointResponse.body); 40 | this.responded(endpointResponse.status, request.url); 41 | response.end(); 42 | }; 43 | if (parseInt(endpointResponse.latency, 10)) setTimeout(finalize, endpointResponse.latency); 44 | else finalize(); 45 | } catch (e) { 46 | this.writeHead(response, 404, {}); 47 | this.responded(404, request.url, 'is not a registered endpoint'); 48 | // response.statusCode = 500; 49 | // self.responded(500, request.url, 'unexpectedly generated a server error: ' + e.message); 50 | response.end(); 51 | } 52 | }); 53 | } 54 | } 55 | 56 | module.exports.Stubs = Stubs; 57 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stubby", 3 | "version": "5.1.0", 4 | "author": { 5 | "name": "Eric Mrak", 6 | "email": "mail@ericmrak.info" 7 | }, 8 | "bugs": { 9 | "url": "https://github.com/mrak/stubby4node" 10 | }, 11 | "description": "a lightweight server for stubbing external systems and endpoints", 12 | "keywords": [ 13 | "server", 14 | "stub", 15 | "mock", 16 | "testing", 17 | "service", 18 | "endpoint", 19 | "http", 20 | "https", 21 | "api", 22 | "rest" 23 | ], 24 | "homepage": "https://github.com/mrak/stubby4node", 25 | "contributors": [ 26 | { 27 | "name": "Eric Mrak", 28 | "email": "mail@ericmrak.info" 29 | } 30 | ], 31 | "directories": { 32 | "bin": "./bin", 33 | "lib": "./src", 34 | "man": "./man" 35 | }, 36 | "files": [ 37 | "bin", 38 | "man", 39 | "src", 40 | "tls", 41 | "webroot" 42 | ], 43 | "scripts": { 44 | "lint": "semistandard", 45 | "test": "npm run lint && mocha --recursive test --reporter dot", 46 | "start": "bin/stubby" 47 | }, 48 | "bin": { 49 | "stubby": "bin/stubby" 50 | }, 51 | "main": "src/main.js", 52 | "repository": { 53 | "type": "git", 54 | "url": "https://github.com/mrak/stubby4node.git" 55 | }, 56 | "dependencies": { 57 | "ejs": "^3.1.6", 58 | "isutf8": "^3.1.1", 59 | "js-yaml": "^4.0.0", 60 | "node-static": "^0.7.11" 61 | }, 62 | "devDependencies": { 63 | "mocha": "^8.3.1", 64 | "semistandard": "^16.0.0", 65 | "sinon": "^10.0.0" 66 | }, 67 | "license": "Apache-2.0", 68 | "engine": { 69 | "node": ">=6.17.1" 70 | }, 71 | "semistandard": { 72 | "ignore": [ 73 | "webroot/js/external" 74 | ], 75 | "globals": [ 76 | "it", 77 | "describe", 78 | "beforeEach", 79 | "afterEach" 80 | ] 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/console/watch.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const crypto = require('crypto'); 5 | const contract = require('../models/contract'); 6 | const out = require('./out'); 7 | const yaml = require('js-yaml'); 8 | 9 | const interval = 3000; 10 | 11 | class Watcher { 12 | constructor (endpoints, filename) { 13 | this.endpoints = endpoints; 14 | this.filename = filename; 15 | this.watching = false; 16 | this.intervalId = null; 17 | 18 | const shasum = crypto.createHash('sha1'); 19 | shasum.update(fs.readFileSync(this.filename, 'utf8')); 20 | 21 | this.sha = shasum.digest('hex'); 22 | this.activate(); 23 | } 24 | 25 | deactivate () { 26 | this.watching = false; 27 | return clearInterval(this.intervalId); 28 | } 29 | 30 | activate () { 31 | if (this.watching) { return; } 32 | 33 | this.watching = true; 34 | out.status('Watching for changes in ' + this.filename + '...'); 35 | this.intervalId = setInterval(this.refresh.bind(this), interval); 36 | } 37 | 38 | refresh () { 39 | let errors; 40 | const shasum = crypto.createHash('sha1'); 41 | let data = fs.readFileSync(this.filename, 'utf8'); 42 | 43 | shasum.update(data); 44 | const sha = shasum.digest('hex'); 45 | 46 | if (sha !== this.sha) { 47 | try { 48 | data = yaml.load(data); 49 | errors = contract(data); 50 | 51 | if (errors) { 52 | out.error(errors); 53 | } else { 54 | this.endpoints.db = []; 55 | this.endpoints.create(data, function () {}); 56 | out.notice('"' + this.filename + '" was changed. It has been reloaded.'); 57 | } 58 | } catch (e) { 59 | out.warn('Couldn\'t parse "' + this.filename + '" due to syntax errors:'); 60 | out.log(e.message); 61 | } 62 | } 63 | 64 | this.sha = sha; 65 | } 66 | } 67 | 68 | module.exports = Watcher; 69 | -------------------------------------------------------------------------------- /man/stubby.1: -------------------------------------------------------------------------------- 1 | .TH STUBBY "1" "2015" "" "" 2 | 3 | 4 | .SH "NAME" 5 | stubby \- a small web server for stubbing external systems during development. 6 | 7 | .SH SYNOPSIS 8 | 9 | .B stubby 10 | [ 11 | .B \-w 12 | | 13 | .B \-\-watch 14 | ] 15 | [ 16 | .B \-d 17 | | 18 | .B \-\-data 19 | .I file 20 | ] 21 | .br 22 | [ 23 | .B \-v 24 | | 25 | .B \-\-version 26 | ] 27 | .br 28 | [ 29 | .B \-h 30 | | 31 | .B \-\-help 32 | ] 33 | 34 | .SH DESCRIPTION 35 | stubby can be invoved either programmatically or through the command line. 36 | Each invocation starts a new instance, given that the supplied port numbers do not overlap. 37 | Three servers are set up: One for reacting to and supplied the configured request and response, respectively. 38 | A second one identical to the first except that it operates over https. 39 | And finally, a third "admin" portal that allows viewing configured data and changing it through RESTful api calls. 40 | 41 | .SH OPTIONS 42 | \-a, \-\-admin Port for admin portal. Defaults to 8889. 43 | 44 | \-c, \-\-cert Certificate file. Use with \-\-key. 45 | 46 | \-d, \-\-data Data file to pre-load endpoints. YAML or JSON format. 47 | 48 | \-h, \-\-help This help text. 49 | 50 | \-k, \-\-key Private key file. Use with \-\-cert. 51 | 52 | \-l, \-\-location Hostname at which to bind stubby. 53 | 54 | \-p, \-\-pfx PFX file. Ignored if used with \-\-key/\-\-cert. 55 | 56 | \-q, \-\-quiet Prevent stubby from printing to the console. 57 | 58 | \-s, \-\-stubs Port for stubs portal. Defaults to 8882. 59 | 60 | \-t, \-\-tls Port for https stubs portal. Defaults to 7443. 61 | 62 | \-v, \-\-version Prints stubby's version number. 63 | 64 | \-w, \-\-watch Auto-reload data file when edits are made. 65 | 66 | .SH RESOURCES AND DOCUMENTATION 67 | github: http://github.com/mrak/stubby4node 68 | -------------------------------------------------------------------------------- /tls/cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFuTCCA6ECFAY5SH0vPYOySLpDtUABzrxMYU99MA0GCSqGSIb3DQEBCwUAMIGY 3 | MQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2Fu 4 | IEZyYW5jaXNjbzEPMA0GA1UECgwGU3R1YmJ5MRQwEgYDVQQLDAtzdHViYnk0bm9k 5 | ZTEUMBIGA1UEAwwLc3R1YmJ5NG5vZGUxHzAdBgkqhkiG9w0BCQEWEGVubXJha0Bn 6 | bWFpbC5jb20wHhcNMjEwMzI2MTczODE1WhcNMzEwMzI0MTczODE1WjCBmDELMAkG 7 | A1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFu 8 | Y2lzY28xDzANBgNVBAoMBlN0dWJieTEUMBIGA1UECwwLc3R1YmJ5NG5vZGUxFDAS 9 | BgNVBAMMC3N0dWJieTRub2RlMR8wHQYJKoZIhvcNAQkBFhBlbm1yYWtAZ21haWwu 10 | Y29tMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1zI7QK82bJP5YKy0 11 | wHsHFTsi9X7GUjkPN50JVfUIrXlJwAlHqfkW5NGNBJJEUKPcQQC3U9SXCt1OUukL 12 | H0BK2PFisAgK+pfY+r6MRCPLU04cRVagB57hW04umlXD6BEPjIbQlPzDkeVViPYv 13 | C8/SzArMlP1KGkHtSvpBnShwq9eR9TxbDFV4XSYg74HCEQ13qPZu+4DrXCJIWVKA 14 | w4m3fCtZqAQFCruvMBNekJNfch3vToiXpPRwGTFlPD+uD9Q7ObRziV0WmTadqrlL 15 | q+Ao9PHJgkpcKcuM6xVWmrwDsVxlJvoVKZZL9UbXJGGttxPCdjD/TTyRJFOogAzR 16 | 3evQZLaRcYH8/0D6lWZisWV2fkJ5FcGLtUxaTC6IIRdW7DubWxukGBCW/MuR4efr 17 | rt9dTvEApkkwQ4A3NWgzucVeofPq6cizCRJzrY3s0cwHxjcv6lr5LN4+lUwNuYzC 18 | oLLYQNekguI7hU6hyDSjXJNveurEFhDgjmBuNxK8iRIZRL/xQiFJYyrpOYCBvZH/ 19 | iG+SLP58PxiZ4EmU7lh5dAH2JVTOFHIeeol+U2nfCHkxhWeacg1Ax1f5rKXBEvGj 20 | hV0L4DWIPZ3PlY9ysPqb63g6E2dpKeYfAFHNIc5QkRndqWLoYheed6meWGOvLBaw 21 | frVQHqF1w6/REudxcgl9R5jDmXMCAwEAATANBgkqhkiG9w0BAQsFAAOCAgEABTpL 22 | Uq+Y+dOG4HvozDT257oBPe9PoBStnW1+Y1/t0ywtdE3F8wEXuRfvGvWQxmG1m4AN 23 | jwpQTdLy5ZK1YLbHOqVpWI79Inwe91l67dGfgtQo/xwqfkHoJO0e1LhwjmsR8WoE 24 | HQGr+T/v5UZkASFVU8WmVaVjlhkz1fuU4lIm2ApfAr+rakQ82vbFyFprfmNtClIf 25 | e/G1IETHKuD3cixN76CcxRphXdNGKUNM58S19iKNdjivpM4Cxrhl+kBx7+atfdJA 26 | zsrFzq3xGYmCqd5qofGofPWXxc5VBI6hHXetg4nHOcsbutqduHm6Cjax9z7shtkr 27 | eJHJFqxPWE5wcNQXEF9omgbhNrUz0ifSICJz4IydtWnoA5i69emxpttlsnjy2vLu 28 | C2h8vfpSLoVnMagTZCvWN8nlQ2MIcqAcRybhJHKmoAE7RxeJyvnRQVH9zJuMrwJf 29 | NZndNl1ik4hqrdwOWlOqJjJQ9pLB6baZqr+fsxaUKwJOG8tb9NONMlqjrkpOtfgP 30 | lU08yV4MwAo80YZTqFxlhWRGraDNS0pAQkCNJHLWg2N/6Yf0GVP8VHyGna5NuCJn 31 | 8IXa9EUGdKoo27aUKFeWTLOzuAFlAaDoecPWEzDHQRg8DEiT9VKVvPIxdPZRtYKY 32 | meuQmeKUUX0xpbF5aCnOGtXz3GdqRva2PdZZaBw= 33 | -----END CERTIFICATE----- 34 | -------------------------------------------------------------------------------- /src/portals/portal.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const CLI = require('../console/cli'); 4 | const out = require('../console/out'); 5 | const http = require('http'); 6 | 7 | class Portal { 8 | constructor () { this.name = 'portal'; } 9 | 10 | writeHead (response, statusCode, headers) { 11 | if (!response.headersSent) { 12 | response.writeHead(statusCode, headers); 13 | } 14 | return response; 15 | } 16 | 17 | received (request, response) { 18 | const date = new Date(); 19 | const hours = ('0' + date.getHours()).slice(-2); 20 | const minutes = ('0' + date.getMinutes()).slice(-2); 21 | const seconds = ('0' + date.getSeconds()).slice(-2); 22 | 23 | out.incoming(hours + ':' + minutes + ':' + seconds + ' -> ' + request.method + ' ' + this.name + request.url); 24 | response.setHeader('Server', 'stubby/' + CLI.version() + ' node/' + process.version + ' (' + process.platform + ' ' + process.arch + ')'); 25 | 26 | if (request.headers.origin != null) { 27 | response.setHeader('Access-Control-Allow-Origin', request.headers.origin); 28 | response.setHeader('Access-Control-Allow-Credentials', true); 29 | 30 | if (request.headers['access-control-request-headers'] != null) { 31 | response.setHeader('Access-Control-Allow-Headers', request.headers['access-control-request-headers']); 32 | } 33 | 34 | if (request.headers['access-control-request-method'] != null) { 35 | response.setHeader('Access-Control-Allow-Methods', request.headers['access-control-request-method']); 36 | } 37 | 38 | if (request.method === 'OPTIONS') { 39 | this.writeHead(response, 200, response.headers); 40 | response.end(); 41 | } 42 | } 43 | 44 | return response; 45 | } 46 | 47 | responded (status, url, message) { 48 | let fn; 49 | const date = new Date(); 50 | const hours = ('0' + date.getHours()).slice(-2); 51 | const minutes = ('0' + date.getMinutes()).slice(-2); 52 | const seconds = ('0' + date.getSeconds()).slice(-2); 53 | 54 | if (url == null) { url = ''; } 55 | if (message == null) { message = http.STATUS_CODES[status]; } 56 | 57 | switch (true) { 58 | case status >= 400 && status < 600: 59 | fn = 'error'; 60 | break; 61 | case status >= 300: 62 | fn = 'warn'; 63 | break; 64 | case status >= 200: 65 | fn = 'ok'; 66 | break; 67 | case status >= 100: 68 | fn = 'info'; 69 | break; 70 | default: 71 | fn = 'log'; 72 | } 73 | 74 | out[fn](hours + ':' + minutes + ':' + seconds + ' <- ' + status + ' ' + this.name + url + ' ' + message); 75 | } 76 | } 77 | 78 | module.exports.Portal = Portal; 79 | -------------------------------------------------------------------------------- /src/console/args.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const pp = require('./prettyprint'); 4 | const UNARY_FLAGS = /^-[a-zA-Z]+$/; 5 | const ANY_FLAG = /^-.+$/; 6 | 7 | function findOption (option, argv) { 8 | let argIndex = -1; 9 | 10 | if (option.flag != null) { 11 | argIndex = indexOfFlag(option, argv); 12 | } 13 | 14 | if (argIndex === -1 && option.name != null) { 15 | argIndex = argv.indexOf('--' + option.name); 16 | } 17 | 18 | return argIndex; 19 | } 20 | 21 | function indexOfFlag (option, argv) { 22 | let index = -1; 23 | 24 | argv.forEach(function (flag) { 25 | if (!UNARY_FLAGS.test(flag)) { return; } 26 | if (flag.indexOf(option.flag) === -1) { return; } 27 | index = argv.indexOf(flag); 28 | }); 29 | 30 | return index; 31 | } 32 | 33 | function optionSkipped (index, argv) { 34 | return ANY_FLAG.test(argv[index + 1]); 35 | } 36 | 37 | function unaryCheck (option, argv) { 38 | if (option.name != null && argv.indexOf('--' + option.name) !== -1) { 39 | return true; 40 | } 41 | if (option.flag == null) { 42 | return false; 43 | } 44 | return indexOfFlag(option, argv) !== -1; 45 | } 46 | 47 | function pullPassedValue (option, argv) { 48 | if (option.param == null) { return unaryCheck(option, argv); } 49 | 50 | const argIndex = findOption(option, argv); 51 | 52 | if (argIndex === -1) { return option.default; } 53 | if (argv[argIndex + 1] == null) { return option.default; } 54 | if (!optionSkipped(argIndex, argv)) { return argv[argIndex + 1]; } 55 | 56 | return option.default; 57 | } 58 | 59 | function parse (options, argv) { 60 | const args = {}; 61 | 62 | if (argv == null) { argv = process.argv; } 63 | 64 | options.forEach(function (option) { 65 | if (option.default == null) { option.default = null; } 66 | args[option.name || option.flag] = pullPassedValue(option, argv); 67 | }); 68 | 69 | return args; 70 | } 71 | 72 | function helpText (options, programName) { 73 | const inlineList = []; 74 | const firstColumn = {}; 75 | const helpLines = []; 76 | let gutter = 3; 77 | 78 | options.forEach(function (option) { 79 | const param = option.param != null 80 | ? ' <' + option.param + '>' 81 | : ''; 82 | 83 | firstColumn[option.name] = '-' + option.flag + ', --' + option.name + param; 84 | inlineList.push('[-' + option.flag + param + ']'); 85 | gutter = Math.max(gutter, firstColumn[option.name].length + 3); 86 | }); 87 | 88 | options.forEach(function (option) { 89 | let helpLine = firstColumn[option.name]; 90 | helpLine += helpLine.padEnd(gutter - helpLine.length, ' '); 91 | helpLine += pp(option.description.split(' '), gutter); 92 | helpLines.push(helpLine); 93 | }); 94 | 95 | return programName + ' ' + pp(inlineList, programName.length + 1) + '\n\n' + helpLines.join('\n'); 96 | } 97 | 98 | module.exports = { 99 | parse: parse, 100 | helpText: helpText 101 | }; 102 | -------------------------------------------------------------------------------- /test/data/e2e.yaml: -------------------------------------------------------------------------------- 1 | - request: 2 | url: /basic/get 3 | 4 | - request: 5 | url: /basic/put 6 | method: PUT 7 | 8 | - request: 9 | url: /basic/post 10 | method: post 11 | 12 | - request: 13 | url: /basic/delete 14 | method: DELETE 15 | 16 | - request: 17 | url: /basic/head 18 | method: HEAD 19 | 20 | - request: 21 | url: /basic/options 22 | method: OPTIONS 23 | 24 | - request: 25 | url: /basic/all 26 | method: [put, post, get, delete, head] 27 | 28 | 29 | 30 | - request: 31 | url: /get/body 32 | method: GET 33 | response: 34 | body: "plain text" 35 | 36 | - request: 37 | url: /get/json 38 | method: GET 39 | response: 40 | headers: 41 | content-type: application/json 42 | body: > 43 | {"property":"value"} 44 | 45 | - request: 46 | url: /get/420 47 | method: GET 48 | response: 49 | status: 420 50 | 51 | - request: 52 | url: /get/query 53 | method: GET 54 | query: 55 | first: value1 with spaces! 56 | second: value2 57 | response: 58 | status: 200 59 | 60 | 61 | 62 | - request: 63 | url: /post/auth 64 | method: POST 65 | post: some=data 66 | headers: 67 | authorization: Basic c3R1YmJ5OnBhc3N3b3Jk 68 | response: 69 | status: 201 70 | headers: 71 | location: /some/endpoint/id 72 | body: "resource has been created" 73 | 74 | - request: 75 | url: /post/auth/pair 76 | method: POST 77 | post: some=data 78 | headers: 79 | authorization: Basic stubby:passwordZ0r 80 | response: 81 | status: 201 82 | headers: 83 | location: /some/endpoint/id 84 | body: "resource has been created" 85 | 86 | - request: 87 | url: /put/latency 88 | method: PUT 89 | response: 90 | status: 200 91 | latency: 2000 92 | body: "updated" 93 | 94 | - request: 95 | url: /file/dynamic/(.+) 96 | response: 97 | file: test/data/endpoints-<% url[1] %>.file 98 | 99 | - request: 100 | url: /file/body/missingfile 101 | response: 102 | file: test/data/endpoints-nonexistant.file 103 | body: body contents! 104 | 105 | - request: 106 | url: /file/body 107 | response: 108 | file: test/data/endpoints.file 109 | body: body contents! 110 | 111 | 112 | 113 | - request: 114 | url: /file/post/missingfile 115 | method: POST 116 | file: test/data/endpoints-nonexistant.file 117 | post: post contents! 118 | response: {} 119 | 120 | - request: 121 | url: /file/post 122 | method: POST 123 | file: test/data/endpoints.file 124 | post: post contents! 125 | response: {} 126 | 127 | - request: 128 | url: /search 129 | response: http://google.com 130 | 131 | - request: 132 | url: /duplicated/header 133 | response: 134 | headers: 135 | Content-Type: 'application/json' 136 | Set-Cookie: ['type=ninja', 'language=coffeescript'] 137 | 138 | - request: 139 | url: /query/array 140 | query: 141 | array: one,two 142 | response: 143 | body: query as array works! 144 | 145 | 146 | 147 | - request: 148 | url: /post/decoded/character 149 | query: 150 | q: '{' 151 | method: POST 152 | response: 153 | - body: 'decoded matched!' 154 | status: 200 155 | -------------------------------------------------------------------------------- /tls/key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIJKQIBAAKCAgEA1zI7QK82bJP5YKy0wHsHFTsi9X7GUjkPN50JVfUIrXlJwAlH 3 | qfkW5NGNBJJEUKPcQQC3U9SXCt1OUukLH0BK2PFisAgK+pfY+r6MRCPLU04cRVag 4 | B57hW04umlXD6BEPjIbQlPzDkeVViPYvC8/SzArMlP1KGkHtSvpBnShwq9eR9Txb 5 | DFV4XSYg74HCEQ13qPZu+4DrXCJIWVKAw4m3fCtZqAQFCruvMBNekJNfch3vToiX 6 | pPRwGTFlPD+uD9Q7ObRziV0WmTadqrlLq+Ao9PHJgkpcKcuM6xVWmrwDsVxlJvoV 7 | KZZL9UbXJGGttxPCdjD/TTyRJFOogAzR3evQZLaRcYH8/0D6lWZisWV2fkJ5FcGL 8 | tUxaTC6IIRdW7DubWxukGBCW/MuR4efrrt9dTvEApkkwQ4A3NWgzucVeofPq6ciz 9 | CRJzrY3s0cwHxjcv6lr5LN4+lUwNuYzCoLLYQNekguI7hU6hyDSjXJNveurEFhDg 10 | jmBuNxK8iRIZRL/xQiFJYyrpOYCBvZH/iG+SLP58PxiZ4EmU7lh5dAH2JVTOFHIe 11 | eol+U2nfCHkxhWeacg1Ax1f5rKXBEvGjhV0L4DWIPZ3PlY9ysPqb63g6E2dpKeYf 12 | AFHNIc5QkRndqWLoYheed6meWGOvLBawfrVQHqF1w6/REudxcgl9R5jDmXMCAwEA 13 | AQKCAgEAkfmZB//Sw0R9XX7J/ed2nohG5pDgptXd5LO5qX1WLZag2Ity+XHmfcFI 14 | D1nIzYrZ1Z5Ovabv7obAoq1CxigunYrgjthl2ic3IKAxNeex/Qh3VPG2DHVSGUWn 15 | hzm2cq1gBstMokPzxfvzUbztnO2U6KRKsqY+piEifkHUUG8mPus0L06J1IwF4eoD 16 | XXR/2GFyUkMYdbK19QGsYir8/adlt+0QHFDdurIl3E7HTvspO5fixTsS4uqNQAmp 17 | ynzH7iThJ/uog/2JXtUVjsGy+xxLrBx3vNIEQrW5GZ6X6M6xEpbCZvYtdNBnJjKv 18 | rhY0aIQW3WXPSO1U53d4TaRkmE1mRizAowko0lS4xKfc/9F1OYHcy3fkqUX7Ie9q 19 | 3QofNAA5G0puzbJ+KfgKO9JyiCNa6vQW9ubWEFDTC4ZWyySAiseAHJOhmBXWg3nK 20 | 89FY94vWRDvqxfludfebx1csKHKmAFMyPwMLagJhWZpN59v9BpfiEG0kwJ1QqHwj 21 | 8QlzrCtTL6z0ot4F0Fpocs/ycSSlS1po88KeHkcNbjkowwAQ+baFijvcP70zyp6H 22 | DD6hrCv2CLlEgs33kvjEGZuh5QV2B5lZ9VzdZWB+Z3j7b6poV7VviybYbhtqAATx 23 | K7IYZRl7ncWNESqeXE6x4vKFR2SWF7zlY+tu9L8Nvgq0IYZcpoECggEBAOyO3d7Y 24 | suxBM6plupJ5ixpfxdTCjH2p1sDT0WVOcjb/xl+kLwyEjML+0xOcSzYDhAFztN9b 25 | PM5dbA+nPcNgq63LniP5KjTOhlAu4DYfxpw+r0OLuoDr6H//Y0Ds3ei8AlATjLvW 26 | +yBA1faedXENLFAGVGLImRrgrjtwf2WzqcyU/tLuTwgwhGSDJGVWgPOx5AwIYvn6 27 | jzS7MypbizAmjfeBJ3+h2+hjQzSzjXWSyr2IrkfEet9mEmQv1XIsgYC6Ef4Cv4TJ 28 | 0f6fx55f4KHL40C351yjOHqEF0gRSGOrh9DkdGZXZ52dFB06CaV3Fb2qCTr6X09k 29 | zXoRX9Lxg6cciHsCggEBAOjh6mtI/olFGSCjIiMSQyLPkpq85gojQr0wq1eTwEZo 30 | 1wGBFY5kXfBpHSEpOudLbeJRZ+m8BB8PBB7ngZvuHmYfdM6FMYPd2v8wzhajiUZf 31 | v2LkhYz6Uep/NbWKfOlC4NH/U+n7zE0tya5tjpSmkKNCkaK7JNkZ9mjBHOYXz2G+ 32 | O3lELxHy5pXLM9KRKONyLrCkhKl15XubXun88mWey4QvqwQMJZmrHnm1n2ALYzHd 33 | fyJUGpxzKk8SFC0q0JczL+5DqzSCXXHvwBY9jxC02CDBAwS9rOpDxQOLnXVDLlIF 34 | 0lJE29qH1muBs5+RQC+6X+vRw9MpbOtDzVa8KJaWLWkCggEAK0kZICXW15Q2t/9V 35 | XGgRJApSUNQokPX67d3Uew0XqSi0S9vnIAtBFo06YmQ+/us1xG4/sn8naTt/An4/ 36 | tviBcZPIpBx7tJ31xcZOEJlP5lcyCtIOlniUWpeIueCd2nBMnAp3vgdgfE5g2He2 37 | VbFrYllGJ609FsX2G7pCFYICmATLfB1PKNh34Ux95G0nu2/ZoroGImfOvH9kpWOK 38 | 3IyxfZCMgbZEXwmKyqHwSItLvdK80TUKpsYup1Z5YCL0r3FcyXYHRW0Il+b0x+4T 39 | VIfSVY1RMIpNU13IRM+kx1gjyf2DrxZfR1/uxkpQaY6Y09PBTKFzvztavfNf7WF0 40 | DagS8QKCAQB9NLEW+0i2Pqsyy+JXnsLvnVAB15uKScjg9KC6VhqjAEzQZzlStA15 41 | atoZcRRVNE2gxwmqNca1eBpMD0W0swIu/6yKODwV3JqYZwH2U/hDGMlJ/vj6gb62 42 | 56vVrd5Yfvdmj/LqstD1ka+EEyz8KM1MqUapihpWisnbKjBCWPzLXT6WhR0VdlWY 43 | pYZ4KEkmd8Y4fEtC3LsDiNYv0Sn1jrl2mTZF184DX2fkIczNE3JCZgarSzJ/eL9/ 44 | gYXNft5k3EVhwYnzkj9xOWg5zOD1J3Wnrn3T97JWxRbQQCjo2bS2rqCUUKcvR6b1 45 | Z+dN3Ab5YieeHso85mHD31Ev17uuspE5AoIBAQDFtyDNEakxMduww/C2l6qrivXr 46 | 2MKPFHvcfHq/6a4xPpern6eb5aPTs71b/8j5oGIzu94mYfSHItfpFu+Zq1IyYvhZ 47 | 4cxaVPZ+cGqLj2JMzKE43HcPkC9Fwh7owabYiXhwTNAOpsmRDM9hOzADxHw2QJXF 48 | QTo4qFl7cCRXxy8QMqfllOU4iOqF2nKyFd+I7jFWLlmKy1T5z9v8m/SLk41Dwaxu 49 | xE/GmIQKfw++v2KSOOEqKswWTre3F6/BK/IoLX76Ig3Ur902TL94H/MdO0zRgSZ9 50 | B7D100KtXNIY4NvxPjCQvo/B9ZPEfM4cCMfAgKUA34VWaPQjUoxiXKv30OlI 51 | -----END RSA PRIVATE KEY----- 52 | -------------------------------------------------------------------------------- /src/models/endpoints.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const ejs = require('ejs'); 5 | const path = require('path'); 6 | const isutf8 = require('isutf8'); 7 | const Endpoint = require('./endpoint'); 8 | const clone = require('../lib/clone'); 9 | const NOT_FOUND = "Endpoint with the given id doesn't exist."; 10 | const NO_MATCH = "Endpoint with given request doesn't exist."; 11 | 12 | class Endpoints { 13 | constructor (data, datadir) { 14 | if (datadir == null) { datadir = process.cwd(); } 15 | 16 | this.caseSensitiveHeaders = false; 17 | this.datadir = datadir; 18 | this.db = {}; 19 | this.lastId = 0; 20 | this.create(data); 21 | } 22 | 23 | create (data) { 24 | const self = this; 25 | 26 | function insert (item) { 27 | item = new Endpoint(item, self.datadir, self.caseSensitiveHeaders); 28 | item.id = ++self.lastId; 29 | self.db[item.id] = item; 30 | return item; 31 | } 32 | 33 | if (data instanceof Array) { 34 | return data.map(insert); 35 | } else if (data) { 36 | return insert(data); 37 | } 38 | } 39 | 40 | retrieve (id) { 41 | if (!this.db[id]) throw new Error(NOT_FOUND); 42 | 43 | return clone(this.db[id]); 44 | } 45 | 46 | update (id, data) { 47 | if (!this.db[id]) throw new Error(NOT_FOUND); 48 | 49 | const endpoint = new Endpoint(data, this.datadir); 50 | endpoint.id = id; 51 | this.db[endpoint.id] = endpoint; 52 | } 53 | 54 | delete (id) { 55 | if (!this.db[id]) throw new Error(NOT_FOUND); 56 | 57 | delete this.db[id]; 58 | } 59 | 60 | deleteAll () { 61 | delete this.db; 62 | this.db = {}; 63 | } 64 | 65 | gather () { 66 | let id; 67 | const all = []; 68 | 69 | for (id in this.db) { 70 | if (Object.prototype.hasOwnProperty.call(this.db, id)) { 71 | all.push(this.db[id]); 72 | } 73 | } 74 | 75 | return clone(all); 76 | } 77 | 78 | find (data) { 79 | let id, endpoint, captures, matched; 80 | 81 | for (id in this.db) { 82 | if (!Object.prototype.hasOwnProperty.call(this.db, id)) { continue; } 83 | 84 | endpoint = this.db[id]; 85 | captures = endpoint.matches(data); 86 | 87 | if (!captures) { continue; } 88 | 89 | endpoint.hits++; 90 | matched = clone(endpoint); 91 | return this.found(matched, captures); 92 | } 93 | 94 | throw new Error(NO_MATCH); 95 | } 96 | 97 | found (endpoint, captures) { 98 | let filename; 99 | const response = endpoint.response[endpoint.hits % endpoint.response.length]; 100 | const _ref = response.body; 101 | 102 | response.body = _ref != null ? Buffer.from(_ref, 'utf8') : Buffer.alloc(0); 103 | response.headers['x-stubby-resource-id'] = endpoint.id; 104 | 105 | if (response.file != null) { 106 | filename = applyCaptures(response.file, captures); 107 | try { 108 | response.body = fs.readFileSync(path.resolve(this.datadir, filename)); 109 | } catch (e) { /* ignored */ } 110 | } 111 | 112 | applyCaptures(response, captures); 113 | 114 | return response; 115 | } 116 | } 117 | 118 | function applyCaptures (obj, captures) { 119 | let key, value; 120 | if (typeof obj === 'string') { 121 | return ejs.render(obj.replace(/<%/g, '<%='), captures); 122 | } 123 | 124 | const results = []; 125 | for (key in obj) { 126 | if (!Object.prototype.hasOwnProperty.call(obj, key)) { continue; } 127 | 128 | value = obj[key]; 129 | 130 | // if a buffer looks like valid UTF-8, treat it as a string for capture replacement: 131 | if (value instanceof Buffer && isutf8(value)) { 132 | value = value.toString(); 133 | } 134 | 135 | if (typeof value === 'string') { 136 | results.push(obj[key] = ejs.render(value.replace(/<%/g, '<%='), captures)); 137 | } else { 138 | results.push(applyCaptures(value, captures)); 139 | } 140 | } 141 | 142 | return results; 143 | } 144 | 145 | module.exports.Endpoints = Endpoints; 146 | -------------------------------------------------------------------------------- /test/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const CLI = require('../src/console/cli'); 4 | const defaults = CLI.getArgs([]); 5 | const assert = require('assert'); 6 | const Stubby = require('../src/main').Stubby; 7 | 8 | describe('main', function () { 9 | let sut, options; 10 | 11 | async function stopStubby () { 12 | if (sut != null) await sut.stop(); 13 | } 14 | 15 | beforeEach(async () => { 16 | await stopStubby(); 17 | sut = new Stubby(); 18 | }); 19 | 20 | afterEach(stopStubby); 21 | 22 | describe('put', function () { 23 | it('should throw warning when the contract is violated', async () => { 24 | sut.endpoints = { update: function () {} }; 25 | 26 | await assert.rejects(async () => { 27 | await sut.put('42', { 28 | request: { 29 | url: '/somewhere' 30 | }, 31 | response: { 32 | status: 800 33 | } 34 | }); 35 | }, { 36 | message: "The supplied endpoint data couldn't be saved" 37 | }); 38 | }); 39 | 40 | it('should not return warning when the contract is upheld', async () => { 41 | sut.endpoints = { update: function () {} }; 42 | 43 | await sut.put('42', { 44 | request: { 45 | url: '/somewhere' 46 | }, 47 | response: { 48 | status: 200 49 | } 50 | }); 51 | }); 52 | }); 53 | 54 | describe('post', function () { 55 | it('should throw warning when the contract is violated', async () => { 56 | await assert.rejects(async () => { 57 | await sut.post({ 58 | request: { 59 | url: '/somewhere' 60 | }, 61 | response: { 62 | status: 800 63 | } 64 | }); 65 | }, { 66 | message: "The supplied endpoint data couldn't be saved" 67 | }); 68 | }); 69 | 70 | it('should not throw warning when the contract is upheld', async () => { 71 | await sut.post({ 72 | request: { 73 | url: '/somewhere' 74 | }, 75 | response: { 76 | status: 200 77 | } 78 | }); 79 | }); 80 | }); 81 | 82 | describe('delete', function () { 83 | it('should call delete all when no id is passed', function (done) { 84 | sut.endpoints = { deleteAll: done }; 85 | 86 | sut.delete(); 87 | }); 88 | 89 | it('should call delete when an id is passed', function (done) { 90 | sut.endpoints = { 91 | delete: function (id) { 92 | assert.strictEqual(id, '1'); 93 | done(); 94 | } 95 | }; 96 | 97 | sut.delete('1'); 98 | }); 99 | }); 100 | 101 | describe('start', function () { 102 | beforeEach(function () { 103 | options = {}; 104 | }); 105 | 106 | describe('callback', function () { 107 | it('should not fail to start a server without options', async () => { 108 | await sut.start(); 109 | }); 110 | }); 111 | 112 | describe('options', function () { 113 | it('should default stub port to CLI port default', async () => { 114 | await sut.start(options); 115 | assert.strictEqual(options.stubs, defaults.stubs); 116 | }); 117 | 118 | it('should default admin port to CLI port default', async () => { 119 | await sut.start(options); 120 | assert.strictEqual(options.admin, defaults.admin); 121 | }); 122 | 123 | it('should default location to CLI default', async () => { 124 | await sut.start(options); 125 | assert.strictEqual(options.location, defaults.location); 126 | }); 127 | 128 | it('should default data to empty array', async () => { 129 | await sut.start(options); 130 | assert(options.data instanceof Array); 131 | assert.strictEqual(options.data.length, 0); 132 | }); 133 | 134 | it('should default key to null', async () => { 135 | await sut.start(options); 136 | assert.strictEqual(options.key, defaults.key); 137 | }); 138 | 139 | it('should default cert to null', async () => { 140 | await sut.start(options); 141 | assert.strictEqual(options.cert, defaults.cert); 142 | }); 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /test/e2e.admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Stubby = require('../src/main').Stubby; 4 | const fs = require('fs'); 5 | const yaml = require('js-yaml'); 6 | const clone = require('../src/lib/clone'); 7 | const endpointData = yaml.load((fs.readFileSync('test/data/e2e.yaml', 'utf8')).trim()); 8 | const assert = require('assert'); 9 | const createRequest = require('./helpers/create-request'); 10 | 11 | describe('End 2 End Admin Test Suite', function () { 12 | let sut; 13 | const port = 8889; 14 | 15 | async function stopStubby () { 16 | if (sut != null) await sut.stop(); 17 | } 18 | 19 | beforeEach(async function () { 20 | this.context = { 21 | done: false, 22 | port: port 23 | }; 24 | 25 | await stopStubby(); 26 | 27 | sut = new Stubby(); 28 | await sut.start({ data: endpointData }); 29 | }); 30 | 31 | afterEach(stopStubby); 32 | 33 | it('should react to /ping', function (done) { 34 | this.context.url = '/ping'; 35 | 36 | createRequest(this.context, function (response) { 37 | assert.strictEqual(response.data, 'pong'); 38 | return done(); 39 | }); 40 | }); 41 | 42 | it('should be able to retreive an endpoint through GET', function (done) { 43 | const id = 3; 44 | const endpoint = clone(endpointData[id - 1]); 45 | endpoint.id = id; 46 | this.context.url = '/' + id; 47 | this.context.method = 'get'; 48 | 49 | createRequest(this.context, function (response) { 50 | let prop, value; 51 | const returned = JSON.parse(response.data); 52 | const req = endpoint.req; 53 | 54 | for (prop in req) { 55 | if (!Object.prototype.hasOwnProperty.call(req, prop)) { continue; } 56 | 57 | value = req[prop]; 58 | assert.strictEqual(value, returned.request[prop]); 59 | } 60 | 61 | done(); 62 | }); 63 | }); 64 | 65 | it('should be able to edit an endpoint through PUT', function (done) { 66 | const self = this; 67 | const id = 2; 68 | const endpoint = clone(endpointData[id - 1]); 69 | this.context.url = '/' + id; 70 | endpoint.request.url = '/munchkin'; 71 | this.context.method = 'put'; 72 | this.context.post = JSON.stringify(endpoint); 73 | 74 | createRequest(this.context, function () { 75 | endpoint.id = id; 76 | self.context.method = 'get'; 77 | self.context.post = null; 78 | 79 | createRequest(self.context, function (response) { 80 | const returned = JSON.parse(response.data); 81 | 82 | assert.strictEqual(returned.request.url, endpoint.request.url); 83 | 84 | done(); 85 | }); 86 | }); 87 | }); 88 | 89 | it('should be about to create an endpoint through POST', function (done) { 90 | const self = this; 91 | const endpoint = { 92 | request: { 93 | url: '/posted/endpoint' 94 | }, 95 | response: { 96 | status: 200 97 | } 98 | }; 99 | this.context.url = '/'; 100 | this.context.method = 'post'; 101 | this.context.post = JSON.stringify(endpoint); 102 | 103 | createRequest(this.context, function (response) { 104 | const id = response.headers.location.replace(/localhost:8889\/([0-9]+)/, '$1'); 105 | 106 | assert.strictEqual(response.statusCode, 201); 107 | 108 | self.context = { 109 | port: port, 110 | done: false, 111 | url: '/' + id, 112 | method: 'get' 113 | }; 114 | 115 | createRequest(self.context, function (response2) { 116 | const returned = JSON.parse(response2.data); 117 | 118 | assert.strictEqual(returned.request.url, endpoint.request.url); 119 | done(); 120 | }); 121 | }); 122 | }); 123 | 124 | it('should be about to delete an endpoint through DELETE', function (done) { 125 | const self = this; 126 | this.context.url = '/2'; 127 | this.context.method = 'delete'; 128 | 129 | createRequest(this.context, function (response) { 130 | assert.strictEqual(response.statusCode, 204); 131 | 132 | self.context = { 133 | port: port, 134 | done: false, 135 | url: '/2', 136 | method: 'get' 137 | }; 138 | 139 | createRequest(self.context, function (response2) { 140 | assert.strictEqual(response2.statusCode, 404); 141 | done(); 142 | }); 143 | }); 144 | }); 145 | }); 146 | -------------------------------------------------------------------------------- /src/console/cli.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const yaml = require('js-yaml'); 6 | const out = require('./out'); 7 | const args = require('./args'); 8 | 9 | const options = [{ 10 | name: 'admin', 11 | flag: 'a', 12 | param: 'port', 13 | default: 8889, 14 | description: 'Port for admin portal. Defaults to 8889.' 15 | }, { 16 | name: 'cert', 17 | flag: 'c', 18 | param: 'file', 19 | default: path.resolve(__dirname, '../../tls/cert.pem'), 20 | description: 'Certificate file. Use with --key.' 21 | }, { 22 | name: 'data', 23 | flag: 'd', 24 | param: 'file', 25 | description: 'Data file to pre-load endoints. YAML or JSON format.' 26 | }, { 27 | name: 'help', 28 | flag: 'h', 29 | default: false, 30 | description: 'This help text.' 31 | }, { 32 | name: 'key', 33 | flag: 'k', 34 | param: 'file', 35 | default: path.resolve(__dirname, '../../tls/key.pem'), 36 | description: 'Private key file. Use with --cert.' 37 | }, { 38 | name: 'location', 39 | flag: 'l', 40 | param: 'hostname', 41 | default: '0.0.0.0', 42 | description: 'Hostname at which to bind stubby.' 43 | }, { 44 | name: 'quiet', 45 | flag: 'q', 46 | description: 'Prevent stubby from printing to the console.' 47 | }, { 48 | name: 'pfx', 49 | flag: 'p', 50 | param: 'file', 51 | description: 'PFX file. Ignored if used with --key/--cert' 52 | }, { 53 | name: 'stubs', 54 | flag: 's', 55 | param: 'port', 56 | default: 8882, 57 | description: 'Port for stubs portal. Defaults to 8882.' 58 | }, { 59 | name: 'tls', 60 | flag: 't', 61 | param: 'port', 62 | default: 7443, 63 | description: 'Port for https stubs portal. Defaults to 7443.' 64 | }, { 65 | name: 'version', 66 | flag: 'v', 67 | description: "Prints stubby's version number." 68 | }, { 69 | name: 'watch', 70 | flag: 'w', 71 | description: 'Auto-reload data file when edits are made.' 72 | }, { 73 | name: 'case-sensitive-headers', 74 | flag: 'H', 75 | description: 'Do no automatically lower-case response headers.' 76 | }]; 77 | 78 | function help (go) { 79 | if (go == null) { go = false; } 80 | if (!go) { return; } 81 | 82 | out.log(args.helpText(options, 'stubby')); 83 | process.exit(); 84 | } 85 | 86 | function version (go) { 87 | const ver = (require('../../package.json')).version; 88 | 89 | if (!go) { return ver; } 90 | 91 | out.log(ver); 92 | process.exit(); 93 | } 94 | 95 | function data (filename) { 96 | let filedata; 97 | 98 | if (filename === null) { return []; } 99 | 100 | try { 101 | filedata = (fs.readFileSync(filename, 'utf8')).trim(); 102 | } catch (e) { 103 | out.warn('File "' + filename + '" could not be found. Ignoring...'); 104 | return []; 105 | } 106 | 107 | try { 108 | return yaml.load(filedata); 109 | } catch (e) { 110 | out.warn('Couldn\'t parse "' + filename + '" due to syntax errors:'); 111 | out.log(e.message); 112 | process.exit(0); 113 | } 114 | } 115 | 116 | function key (file) { return readFile('k', 'key', file, 'pem'); } 117 | function cert (file) { return readFile('c', 'cert', file, 'pem'); } 118 | function pfx (file) { return readFile('p', 'pfx', file, 'pfx'); } 119 | 120 | function readFile (flag, option, filename, type) { 121 | if (filename === null) { return null; } 122 | 123 | const filedata = fs.readFileSync(filename, 'utf8'); 124 | const extension = filename.replace(/^.*\.([a-zA-Z0-9]+)$/, '$1'); 125 | 126 | if (!filedata) { return null; } 127 | 128 | if (extension !== type) { 129 | out.warn('[-' + flag + ', --' + option + '] only takes files of type .' + type + '. Ignoring...'); 130 | return null; 131 | } 132 | 133 | return filedata.trim(); 134 | } 135 | 136 | function getArgs (argv) { 137 | var self = this; // eslint-disable-line 138 | 139 | if (argv == null) { argv = process.argv; } 140 | 141 | const params = args.parse(options, argv); 142 | params.datadir = path.resolve(path.dirname(params.data || '.')); 143 | 144 | if (params.watch) { params.watch = params.data; } 145 | 146 | options.forEach(function (option) { 147 | if (self[option.name] != null) { 148 | params[option.name] = self[option.name](params[option.name]); 149 | } 150 | }); 151 | 152 | return params; 153 | } 154 | 155 | module.exports = { 156 | options: options, 157 | help: help, 158 | version: version, 159 | data: data, 160 | key: key, 161 | cert: cert, 162 | pfx: pfx, 163 | readFile: readFile, 164 | getArgs: getArgs 165 | }; 166 | -------------------------------------------------------------------------------- /src/models/contract.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const httpMethods = [ 4 | 'GET', 5 | 'PUT', 6 | 'POST', 7 | 'HEAD', 8 | 'PATCH', 9 | 'TRACE', 10 | 'DELETE', 11 | 'CONNECT', 12 | 'OPTIONS' 13 | ]; 14 | 15 | const messages = { 16 | json: 'An unparseable JSON string was supplied.', 17 | request: { 18 | missing: "'request' object is required.", 19 | url: "'request.url' is required.", 20 | query: { 21 | type: "'request.query', if supplied, must be an object." 22 | }, 23 | method: "'request.method' must be one of " + httpMethods + '.', 24 | headers: { 25 | type: "'request.headers', if supplied, must be an object." 26 | } 27 | }, 28 | response: { 29 | headers: { 30 | type: "'response.headers', if supplied, must be an object." 31 | }, 32 | status: { 33 | type: "'response.status' must be integer-like.", 34 | small: "'response.status' must be >= 100.", 35 | large: "'response.status' must be < 600." 36 | }, 37 | latency: { 38 | type: "'response.latency' must be integer-like." 39 | } 40 | } 41 | }; 42 | 43 | const response = { 44 | status: function (status) { 45 | if (!status) { return null; } 46 | 47 | const parsed = parseInt(status, 10); 48 | 49 | if (!parsed) { return messages.response.status.type; } 50 | if (parsed < 100) { return messages.response.status.small; } 51 | if (parsed >= 600) { return messages.response.status.large; } 52 | 53 | return null; 54 | }, 55 | headers: function (headers) { 56 | if (!headers) { return null; } 57 | 58 | if (headers instanceof Array || typeof headers !== 'object') { 59 | return messages.response.headers.type; 60 | } 61 | 62 | return null; 63 | }, 64 | latency: function (latency) { 65 | if (!latency) { return null; } 66 | if (!parseInt(latency, 10)) { return messages.response.latency.type; } 67 | 68 | return null; 69 | } 70 | }; 71 | 72 | const request = { 73 | url: function (url) { 74 | if (url) { return null; } 75 | 76 | return messages.request.url; 77 | }, 78 | headers: function (headers) { 79 | if (!headers) { return null; } 80 | 81 | if (headers instanceof Array || typeof headers !== 'object') { 82 | return messages.request.headers.type; 83 | } 84 | 85 | return null; 86 | }, 87 | method: function (method) { 88 | let i; 89 | if (!method) { return null; } 90 | 91 | if (!(method instanceof Array)) { 92 | if (httpMethods.indexOf(method.toUpperCase()) !== -1) { 93 | return null; 94 | } 95 | 96 | return messages.request.method; 97 | } 98 | 99 | for (i = 0; i < method.length; i++) { 100 | if (httpMethods.indexOf(method[i].toUpperCase()) === -1) { 101 | return messages.request.method; 102 | } 103 | } 104 | 105 | return null; 106 | }, 107 | query: function (query) { 108 | if (!query) { return null; } 109 | 110 | if (query instanceof Array || typeof query !== 'object') { 111 | return messages.request.query.type; 112 | } 113 | 114 | return null; 115 | } 116 | }; 117 | 118 | function contract (endpoint) { 119 | let results, property; 120 | let errors = []; 121 | 122 | if (typeof endpoint === 'string') { 123 | try { 124 | endpoint = JSON.parse(endpoint); 125 | } catch (e) { 126 | return [messages.json]; 127 | } 128 | } 129 | 130 | if (endpoint instanceof Array) { 131 | results = endpoint.map(function (it) { return contract(it); }); 132 | results = results.filter(function (result) { return result !== null; }); 133 | 134 | if (results.length === 0) { return null; } 135 | 136 | return results; 137 | } 138 | 139 | if (!endpoint.request) { 140 | errors.push(messages.request.missing); 141 | } else { 142 | for (property in request) { 143 | if (Object.prototype.hasOwnProperty.call(request, property)) { 144 | errors.push(request[property](endpoint.request[property])); 145 | } 146 | } 147 | } 148 | 149 | if (endpoint.response) { 150 | if (!(endpoint.response instanceof Array)) { 151 | endpoint.response = [endpoint.response]; 152 | } 153 | 154 | endpoint.response.forEach(function (incoming) { 155 | for (property in response) { 156 | if (Object.prototype.hasOwnProperty.call(response, property)) { 157 | errors.push(response[property](incoming[property])); 158 | } 159 | } 160 | }); 161 | } 162 | 163 | errors = errors.filter(function (error) { return error !== null; }); 164 | if (errors.length === 0) { errors = null; } 165 | return errors; 166 | } 167 | 168 | module.exports = contract; 169 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Admin = require('./portals/admin').Admin; 4 | const Stubs = require('./portals/stubs').Stubs; 5 | const Endpoints = require('./models/endpoints').Endpoints; 6 | const Watcher = require('./console/watch'); 7 | const CLI = require('./console/cli'); 8 | const out = require('./console/out'); 9 | const http = require('http'); 10 | const https = require('https'); 11 | const contract = require('./models/contract'); 12 | const couldNotSave = "The supplied endpoint data couldn't be saved"; 13 | 14 | function onListening (portal, port, protocol, location) { 15 | if (protocol == null) { protocol = 'http'; } 16 | out.status(portal + ' portal running at ' + protocol + '://' + location + ':' + port); 17 | } 18 | 19 | function onError (err, port, location) { 20 | let msg; 21 | 22 | switch (err.code) { 23 | case 'EACCES': 24 | msg = 'Permission denied for use of port ' + port + '. Exiting...'; 25 | break; 26 | case 'EADDRINUSE': 27 | msg = 'Port ' + port + ' is already in use! Exiting...'; 28 | break; 29 | case 'EADDRNOTAVAIL': 30 | msg = 'Host "' + location + '" is not available! Exiting...'; 31 | break; 32 | default: 33 | msg = err.message + '. Exiting...'; 34 | } 35 | out.error(msg); 36 | console.dir(err); // eslint-disable-line 37 | process.exit(); 38 | } 39 | 40 | function setupStartOptions (options) { 41 | let key; 42 | 43 | options = options == null ? {} : options; 44 | 45 | if (options.quiet == null) { options.quiet = true; } 46 | 47 | const defaults = CLI.getArgs([]); 48 | for (key in defaults) { 49 | if (options[key] == null) { 50 | options[key] = defaults[key]; 51 | } 52 | } 53 | 54 | out.quiet = options.quiet; 55 | return options; 56 | } 57 | 58 | function createHttpsOptions (options) { 59 | const httpsOptions = options._httpsOptions || {}; 60 | 61 | if (options.key && options.cert) { 62 | httpsOptions.key = options.key; 63 | httpsOptions.cert = options.cert; 64 | } else if (options.pfx) { 65 | httpsOptions.pfx = options.pfx; 66 | } 67 | 68 | return httpsOptions; 69 | } 70 | 71 | class Stubby { 72 | constructor () { 73 | this.endpoints = new Endpoints(); 74 | this.stubsPortal = null; 75 | this.tlsPortal = null; 76 | this.adminPortal = null; 77 | } 78 | 79 | async start (o) { 80 | const options = setupStartOptions(o); 81 | 82 | await this.stop(); 83 | 84 | const errors = contract(options.data); 85 | 86 | if (errors) { throw new Error(errors); } 87 | if (options.datadir != null) { this.endpoints.datadir = options.datadir; } 88 | if (options['case-sensitive-headers'] != null) { this.endpoints.caseSensitiveHeaders = options['case-sensitive-headers']; } 89 | 90 | this.endpoints.create(options.data).forEach((endpoint) => { 91 | out.notice('Loaded: ' + endpoint.request.method + ' ' + endpoint.request.url); 92 | }); 93 | 94 | this.tlsPortal = https.createServer(createHttpsOptions(options), new Stubs(this.endpoints).server); 95 | this.tlsPortal.on('listening', function () { onListening('Stubs', options.tls, 'https', options.location); }); 96 | this.tlsPortal.on('error', function (err) { onError(err, options.tls, options.location); }); 97 | await new Promise((resolve) => this.tlsPortal.listen(options.tls, options.location, resolve)); 98 | 99 | this.stubsPortal = http.createServer(new Stubs(this.endpoints).server); 100 | this.stubsPortal.on('listening', function () { onListening('Stubs', options.stubs, 'http', options.location); }); 101 | this.stubsPortal.on('error', function (err) { onError(err, options.stubs, options.location); }); 102 | await new Promise((resolve) => this.stubsPortal.listen(options.stubs, options.location, resolve)); 103 | 104 | this.adminPortal = http.createServer(new Admin(this.endpoints).server); 105 | this.adminPortal.on('listening', function () { onListening('Admin', options.admin, 'http', options.location); }); 106 | this.adminPortal.on('error', function (err) { onError(err, options.admin, options.location); }); 107 | await new Promise((resolve) => this.adminPortal.listen(options.admin, options.location, resolve)); 108 | 109 | if (options.watch) { this.watcher = new Watcher(this.endpoints, options.watch); } 110 | 111 | out.info('\nQuit: ctrl-c\n'); 112 | } 113 | 114 | async stop () { 115 | if (this.watcher != null) { this.watcher.deactivate(); } 116 | 117 | if (this.adminPortal && this.adminPortal.address()) await new Promise((resolve) => (this.adminPortal.close(resolve))); 118 | if (this.stubsPortal && this.stubsPortal.address()) await new Promise((resolve) => (this.stubsPortal.close(resolve))); 119 | if (this.tlsPortal && this.tlsPortal.address()) await new Promise((resolve) => (this.tlsPortal.close(resolve))); 120 | } 121 | 122 | post (data) { 123 | if (contract(data)) { 124 | throw new Error(couldNotSave); 125 | } else { 126 | this.endpoints.create(data); 127 | } 128 | } 129 | 130 | get (id) { 131 | if (id == null) return this.endpoints.gather(); 132 | else return this.endpoints.retrieve(id); 133 | } 134 | 135 | put (id, data) { 136 | if (contract(data)) throw new Error(couldNotSave); 137 | else return this.endpoints.update(id, data); 138 | } 139 | 140 | delete (id) { 141 | if (id == null) this.endpoints.deleteAll(); 142 | else this.endpoints.delete(id); 143 | } 144 | } 145 | 146 | module.exports.Stubby = Stubby; 147 | -------------------------------------------------------------------------------- /webroot/js/scripts.js: -------------------------------------------------------------------------------- 1 | /* global _, hljs */ 2 | (function (window) { 3 | const stubby = window.stubby || {}; 4 | const template = [ // eww 5 | '
  • ', 6 | ' ', 7 | ' ', 8 | ' ', 9 | ' ', 10 | ' ', 11 | ' ', 12 | ' ', 13 | ' ', 14 | ' ', 15 | ' <% if(request.method) { %>', 16 | ' ', 17 | ' ', 18 | ' ', 19 | ' ', 20 | ' <% } if(request.query) { %>', 21 | ' ', 22 | ' ', 23 | ' ', 24 | ' ', 25 | ' ', 26 | ' ', 27 | ' ', 37 | ' ', 38 | ' <% } if(request.headers && Object.keys(request.headers).length > 0) { %>', 39 | ' ', 40 | ' ', 41 | ' ', 51 | ' ', 52 | ' <% } if(request.post) { %>', 53 | ' ', 54 | ' ', 55 | ' ', 56 | ' ', 57 | ' <% } if(request.file) { %>', 58 | ' ', 59 | ' ', 60 | ' ', 61 | ' ', 62 | ' <% } %>', 63 | ' ', 64 | ' ', 65 | ' ', 66 | ' <% if(response.status) { %>', 67 | ' ', 68 | ' ', 69 | ' ', 70 | ' ', 71 | ' <% } if(response.headers && Object.keys(response.headers).length > 0) { %>', 72 | ' ', 73 | ' ', 74 | ' ', 84 | ' ', 85 | ' <% } if(response[0].body) { %>', 86 | ' ', 87 | ' ', 88 | ' ', 89 | ' ', 90 | ' <% } if(response[0].file) { %>', 91 | ' ', 92 | ' ', 93 | ' ', 94 | ' ', 95 | ' <% } if(response[0].latency) { %>', 96 | ' ', 97 | ' ', 98 | ' ', 99 | ' ', 100 | ' <% } %>', 101 | ' ', 102 | ' ', 103 | ' ', 104 | ' ', 105 | '
    Endpoint <%= id %>
    request
    url<%= request.url %>
    method<%= request.method %>
    query<% print(queryParams(request.query)); %>
    ', 28 | '
      ', 29 | ' <% _.each(_.keys(request.query), function(key) { %>', 30 | '
    • ', 31 | '
      <%= key %>
      ', 32 | '
      <%= request.query[key] %>
      ', 33 | '
    • ', 34 | ' <% }); %>', 35 | '
    ', 36 | '
    headers', 42 | '
      ', 43 | ' <% _.each(_.keys(request.headers), function(key) { %>', 44 | '
    • ', 45 | '
      <%= key %>
      ', 46 | '
      <%= request.headers[key] %>
      ', 47 | '
    • ', 48 | ' <% }); %>', 49 | '
    ', 50 | '
    post
    <%= request.post %>
    file<%= request.file %>
    response
    status<%= response.status %>
    headers', 75 | '
      ', 76 | ' <% _.each(_.keys(response.headers), function(key) { %>', 77 | '
    • ', 78 | '
      <%= key %>
      ', 79 | '
      <%= response[0].headers[key] %>
      ', 80 | '
    • ', 81 | ' <% }); %>', 82 | '
    ', 83 | '
    body
    <%= response[0].body %>
    file<%= response[0].file %>
    latency<%= response[0].latency %>
    hits<%= hits %>
    ', 106 | '
  • ' 107 | ].join('\n'); 108 | 109 | function queryParams (query) { 110 | let result = '?'; 111 | 112 | for (const key in query) { 113 | const value = query[key]; 114 | 115 | result += encodeURIComponent(key); 116 | result += '='; 117 | result += encodeURIComponent(value); 118 | result += '&'; 119 | } 120 | 121 | return result.replace(/&$/, ''); 122 | } 123 | 124 | let ajax = null; 125 | let list = null; 126 | 127 | function success () { 128 | let endpoint; 129 | const endpoints = JSON.parse(ajax.responseText); 130 | 131 | for (let i = 0; i < endpoints.length; i++) { 132 | endpoint = endpoints[i]; 133 | 134 | endpoint.queryParams = queryParams; 135 | endpoint.adminUrl = window.location.href.replace(/status/, endpoint.id); 136 | 137 | const html = _.template(template)(endpoint); 138 | list.innerHTML += html; 139 | } 140 | 141 | hljs.initHighlighting(); 142 | } 143 | 144 | function complete (e) { 145 | if (ajax.readyState !== 4) { return; } 146 | 147 | if (ajax.status === 200) { return success(); } else { return console.error(ajax.statusText); } 148 | } 149 | 150 | stubby.status = function () { 151 | list = document.getElementById('endpoints'); 152 | 153 | ajax = new window.XMLHttpRequest(); 154 | ajax.open('GET', '/', true); 155 | ajax.onreadystatechange = complete; 156 | ajax.send(null); 157 | }; 158 | 159 | window.stubby = stubby; 160 | })(this); 161 | -------------------------------------------------------------------------------- /src/portals/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const contract = require('../models/contract'); 4 | const Portal = require('./portal').Portal; 5 | const ns = require('node-static'); 6 | const path = require('path'); 7 | const status = new ns.Server(path.resolve(__dirname, '../../webroot')); 8 | const urlPattern = /^\/([1-9][0-9]*)?$/; 9 | 10 | class Admin extends Portal { 11 | constructor (endpoints) { 12 | super(); 13 | this.server = this.server.bind(this); 14 | this.endpoints = endpoints; 15 | this.contract = contract; 16 | this.name = '[admin]'; 17 | } 18 | 19 | goPong (response) { 20 | this.writeHead(response, 200, { 21 | 'Content-Type': 'text/plain' 22 | }); 23 | 24 | response.end('pong'); 25 | } 26 | 27 | goPUT (request, response) { 28 | const id = this.getId(request.url); 29 | let data = ''; 30 | 31 | if (!id) { return this.notSupported(response); } 32 | 33 | request.on('data', function (chunk) { data += chunk; }); 34 | request.on('end', () => { this.processPUT(id, data, response); }); 35 | } 36 | 37 | goPOST (request, response) { 38 | const id = this.getId(request.url); 39 | let data = ''; 40 | 41 | if (id) { return this.notSupported(response); } 42 | 43 | request.on('data', function (chunk) { data += chunk; }); 44 | request.on('end', () => { this.processPOST(data, response, request); }); 45 | } 46 | 47 | goDELETE (request, response) { 48 | const id = this.getId(request.url); 49 | 50 | if (id) { 51 | try { 52 | this.endpoints.delete(id); 53 | this.noContent(response); 54 | } catch { this.notFound(response); } 55 | } else if (request.url === '/') { 56 | try { 57 | this.endpoints.deleteAll(); 58 | this.noContent(response); 59 | } catch { this.notFound(response); } 60 | } else { 61 | this.notSupported(response); 62 | } 63 | } 64 | 65 | goGET (request, response) { 66 | const id = this.getId(request.url); 67 | 68 | if (id) { 69 | try { 70 | const endpoint = this.endpoints.retrieve(id); 71 | this.ok(response, endpoint); 72 | } catch (err) { this.notFound(response); } 73 | } else { 74 | const data = this.endpoints.gather(); 75 | if (data.length === 0) { this.noContent(response); } else { this.ok(response, data); } 76 | } 77 | } 78 | 79 | processPUT (id, data, response) { 80 | try { data = JSON.parse(data); } catch (e) { return this.badRequest(response); } 81 | 82 | const errors = this.contract(data); 83 | if (errors) { return this.badRequest(response, errors); } 84 | 85 | try { 86 | this.endpoints.update(id, data); 87 | this.noContent(response); 88 | } catch (_) { this.notFound(response); } 89 | } 90 | 91 | processPOST (data, response, request) { 92 | try { data = JSON.parse(data); } catch (e) { return this.badRequest(response); } 93 | 94 | const errors = this.contract(data); 95 | if (errors) { return this.badRequest(response, errors); } 96 | 97 | const endpoint = this.endpoints.create(data); 98 | this.created(response, request, endpoint.id); 99 | } 100 | 101 | ok (response, result) { 102 | this.writeHead(response, 200, { 103 | 'Content-Type': 'application/json' 104 | }); 105 | 106 | if (result != null) { return response.end(JSON.stringify(result)); } 107 | return response.end(); 108 | } 109 | 110 | created (response, request, id) { 111 | this.writeHead(response, 201, { 112 | Location: request.headers.host + '/' + id 113 | }); 114 | 115 | response.end(); 116 | } 117 | 118 | noContent (response) { 119 | response.statusCode = 204; 120 | response.end(); 121 | } 122 | 123 | badRequest (response, errors) { 124 | this.writeHead(response, 400, { 125 | 'Content-Type': 'application/json' 126 | }); 127 | 128 | response.end(JSON.stringify(errors)); 129 | } 130 | 131 | notSupported (response) { 132 | response.statusCode = 405; 133 | response.end(); 134 | } 135 | 136 | notFound (response) { 137 | this.writeHead(response, 404, { 138 | 'Content-Type': 'text/plain' 139 | }); 140 | 141 | response.end(); 142 | } 143 | 144 | saveError (response) { 145 | this.writeHead(response, 422, { 146 | 'Content-Type': 'text/plain' 147 | }); 148 | 149 | response.end(); 150 | } 151 | 152 | serverError (response) { 153 | this.writeHead(response, 500, { 154 | 'Content-Type': 'text/plain' 155 | }); 156 | 157 | response.end(); 158 | } 159 | 160 | urlValid (url) { 161 | return url.match(urlPattern) != null; 162 | } 163 | 164 | getId (url) { 165 | return url.replace(urlPattern, '$1'); 166 | } 167 | 168 | server (request, response) { 169 | this.received(request, response); 170 | 171 | response.on('finish', () => { 172 | this.responded(response.statusCode, request.url); 173 | }); 174 | 175 | if (request.url === '/ping') { return this.goPong(response); } 176 | if (/^\/(status|js|css)(\/.*)?$/.test(request.url)) { return status.serve(request, response); } 177 | 178 | if (this.urlValid(request.url)) { 179 | switch (request.method.toUpperCase()) { 180 | case 'PUT': 181 | return this.goPUT(request, response); 182 | case 'POST': 183 | return this.goPOST(request, response); 184 | case 'DELETE': 185 | return this.goDELETE(request, response); 186 | case 'GET': 187 | return this.goGET(request, response); 188 | default: 189 | return this.notSupported(response); 190 | } 191 | } else { 192 | return this.notFound(response); 193 | } 194 | } 195 | } 196 | 197 | module.exports.Admin = Admin; 198 | -------------------------------------------------------------------------------- /test/args.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const sut = require('../src/console/args'); 5 | 6 | describe('args', function () { 7 | describe('parse', function () { 8 | describe('flags', function () { 9 | it('should parse a flag without parameters', function () { 10 | const options = [{ 11 | name: 'flag', 12 | flag: 'f' 13 | }]; 14 | 15 | const result = sut.parse(options, ['-f']); 16 | 17 | assert.strictEqual(result.flag, true); 18 | }); 19 | 20 | it('should parse two flags without parameters', function () { 21 | const options = [{ 22 | name: 'one', 23 | flag: 'o' 24 | }, { 25 | name: 'two', 26 | flag: 't' 27 | }]; 28 | 29 | const result = sut.parse(options, ['-ot']); 30 | 31 | assert.strictEqual(result.one, true); 32 | assert.strictEqual(result.two, true); 33 | }); 34 | 35 | it('should default to false for flag without parameters', function () { 36 | const options = [{ 37 | name: 'flag', 38 | flag: 'f' 39 | }]; 40 | 41 | const result = sut.parse(options, []); 42 | 43 | assert.strictEqual(result.flag, false); 44 | }); 45 | 46 | it('should parse a flag with parameters', function () { 47 | const expected = 'a_value'; 48 | const options = [{ 49 | name: 'flag', 50 | flag: 'f', 51 | param: 'anything' 52 | }]; 53 | 54 | const result = sut.parse(options, ['-f', expected]); 55 | 56 | assert.strictEqual(result.flag, expected); 57 | }); 58 | 59 | it('should parse two flags with parameters', function () { 60 | const options = [{ 61 | name: 'one', 62 | flag: 'o', 63 | param: 'named' 64 | }, { 65 | name: 'two', 66 | flag: 't', 67 | param: 'named' 68 | }]; 69 | 70 | const result = sut.parse(options, ['-o', 'one', '-t', 'two']); 71 | 72 | assert.strictEqual(result.one, 'one'); 73 | assert.strictEqual(result.two, 'two'); 74 | }); 75 | 76 | it('should be default if flag not supplied', function () { 77 | const expected = 'a_value'; 78 | const options = [{ 79 | name: 'flag', 80 | flag: 'f', 81 | param: 'anything', 82 | default: expected 83 | }]; 84 | 85 | const result = sut.parse(options, []); 86 | 87 | assert.strictEqual(result.flag, expected); 88 | }); 89 | 90 | it('should be default if flag parameter not supplied', function () { 91 | const expected = 'a_value'; 92 | const options = [{ 93 | name: 'flag', 94 | flag: 'f', 95 | param: 'anything', 96 | default: expected 97 | }]; 98 | 99 | const result = sut.parse(options, ['-f']); 100 | 101 | assert.strictEqual(result.flag, expected); 102 | }); 103 | 104 | it('should be default if flag parameter skipped', function () { 105 | const expected = 'a_value'; 106 | const options = [{ 107 | name: 'flag', 108 | flag: 'f', 109 | param: 'anything', 110 | default: expected 111 | }]; 112 | 113 | const result = sut.parse(options, ['-f', '-z']); 114 | 115 | assert.strictEqual(result.flag, expected); 116 | }); 117 | 118 | it('should parse a flag with parameters combined with a flag without parameters', function () { 119 | const options = [{ 120 | name: 'one', 121 | flag: 'o', 122 | param: 'named' 123 | }, { 124 | name: 'two', 125 | flag: 't' 126 | }]; 127 | 128 | const result = sut.parse(options, ['-ot', 'one']); 129 | 130 | assert.strictEqual(result.one, 'one'); 131 | assert.strictEqual(result.two, true); 132 | }); 133 | }); 134 | 135 | describe('names', function () { 136 | it('should parse a name without parameters', function () { 137 | const options = [{ 138 | name: 'flag', 139 | flag: 'f' 140 | }]; 141 | 142 | const result = sut.parse(options, ['--flag']); 143 | 144 | assert.strictEqual(result.flag, true); 145 | }); 146 | 147 | it('should parse two names without parameters', function () { 148 | const options = [{ 149 | name: 'one', 150 | flag: 'o' 151 | }, { 152 | name: 'two', 153 | flag: 't' 154 | }]; 155 | 156 | const result = sut.parse(options, ['--one', '--two']); 157 | 158 | assert.strictEqual(result.one, true); 159 | assert.strictEqual(result.two, true); 160 | }); 161 | 162 | it('should default to false for name without parameters', function () { 163 | const options = [{ 164 | name: 'flag', 165 | flag: 'f' 166 | }]; 167 | 168 | const result = sut.parse(options, []); 169 | 170 | assert.strictEqual(result.flag, false); 171 | }); 172 | 173 | it('should parse a name with parameters', function () { 174 | const expected = 'a_value'; 175 | const options = [{ 176 | name: 'flag', 177 | flag: 'f', 178 | param: 'anything' 179 | }]; 180 | 181 | const result = sut.parse(options, ['--flag', expected]); 182 | 183 | assert.strictEqual(result.flag, expected); 184 | }); 185 | 186 | it('should parse two names with parameters', function () { 187 | const options = [{ 188 | name: 'one', 189 | flag: 'o', 190 | param: 'named' 191 | }, { 192 | name: 'two', 193 | flag: 't', 194 | param: 'named' 195 | }]; 196 | 197 | const result = sut.parse(options, ['--one', 'one', '--two', 'two']); 198 | 199 | assert.strictEqual(result.one, 'one'); 200 | assert.strictEqual(result.two, 'two'); 201 | }); 202 | 203 | it('should be default if name not supplied', function () { 204 | const expected = 'a_value'; 205 | const options = [{ 206 | name: 'flag', 207 | flag: 'f', 208 | param: 'anything', 209 | default: expected 210 | }]; 211 | 212 | const result = sut.parse(options, []); 213 | 214 | assert.strictEqual(result.flag, expected); 215 | }); 216 | 217 | it('should be default if name parameter not supplied', function () { 218 | const expected = 'a_value'; 219 | const options = [{ 220 | name: 'flag', 221 | flag: 'f', 222 | param: 'anything', 223 | default: expected 224 | }]; 225 | 226 | const result = sut.parse(options, ['--flag']); 227 | 228 | assert.strictEqual(result.flag, expected); 229 | }); 230 | 231 | it('should be default if name parameter skipped', function () { 232 | const expected = 'a_value'; 233 | const options = [{ 234 | name: 'flag', 235 | flag: 'f', 236 | param: 'anything', 237 | default: expected 238 | }]; 239 | 240 | const result = sut.parse(options, ['--flag', '--another-flag']); 241 | 242 | assert.strictEqual(result.flag, expected); 243 | }); 244 | }); 245 | }); 246 | }); 247 | -------------------------------------------------------------------------------- /src/models/endpoint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const http = require('http'); 6 | const q = require('querystring'); 7 | const out = require('../console/out'); 8 | 9 | class Endpoint { 10 | constructor (endpoint, datadir, caseSensitiveHeaders) { 11 | if (endpoint == null) { endpoint = {}; } 12 | if (datadir == null) { datadir = process.cwd(); } 13 | 14 | Object.defineProperty(this, 'datadir', { value: datadir }); 15 | 16 | this.request = purifyRequest(endpoint.request); 17 | this.response = purifyResponse(this, endpoint.response, caseSensitiveHeaders); 18 | this.hits = 0; 19 | } 20 | 21 | matches (request) { 22 | let file, json, upperMethods; 23 | const matches = {}; 24 | 25 | matches.url = matchRegex(this.request.url, request.url); 26 | if (!matches.url) { return null; } 27 | 28 | matches.headers = compareHashMaps(this.request.headers, request.headers); 29 | if (!matches.headers) { return null; } 30 | 31 | matches.query = compareHashMaps(this.request.query, request.query); 32 | if (!matches.query) { return null; } 33 | 34 | file = null; 35 | if (this.request.file != null) { 36 | try { 37 | file = fs.readFileSync(path.resolve(this.datadir, this.request.file), 'utf8'); 38 | } catch (e) { /* ignored */ } 39 | } 40 | 41 | const post = file || this.request.post; 42 | if (post && request.post) { 43 | matches.post = matchRegex(normalizeEOL(post), normalizeEOL(request.post)); 44 | if (!matches.post) { return null; } 45 | } else if (this.request.json && request.post) { 46 | try { 47 | json = JSON.parse(request.post); 48 | if (!compareObjects(this.request.json, json)) { return null; } 49 | } catch (e) { 50 | return null; 51 | } 52 | } else if (this.request.form && request.post) { 53 | matches.post = compareHashMaps(this.request.form, q.decode(request.post)); 54 | if (!matches.post) { return null; } 55 | } 56 | 57 | if (this.request.method instanceof Array) { 58 | upperMethods = this.request.method.map(function (it) { return it.toUpperCase(); }); 59 | if (upperMethods.indexOf(request.method) === -1) { return null; } 60 | } else if (this.request.method.toUpperCase() !== request.method) { 61 | return null; 62 | } 63 | 64 | return matches; 65 | } 66 | } 67 | 68 | function record (me, urlToRecord) { 69 | const recording = {}; 70 | const parsed = new URL(urlToRecord); 71 | const options = { 72 | method: me.request.method == null ? 'GET' : me.request.method, 73 | hostname: parsed.hostname, 74 | headers: me.request.headers, 75 | port: parsed.port, 76 | path: parsed.pathname + '?' 77 | }; 78 | 79 | if (parsed.query != null) { 80 | options.path += parsed.query + '&'; 81 | } 82 | if (me.request.query != null) { 83 | options.path += q.stringify(me.request.query); 84 | } 85 | 86 | const recorder = http.request(options, function (res) { 87 | recording.status = res.statusCode; 88 | recording.headers = res.headers; 89 | recording.body = ''; 90 | res.on('data', function (chunk) { recording.body += chunk; }); 91 | res.on('end', function () { out.notice('recorded ' + urlToRecord); }); 92 | }); 93 | 94 | recorder.on('error', function (e) { out.warn('error recording response ' + urlToRecord + ': ' + e.message); }); 95 | recording.post = me.request.post == null ? Buffer.alloc(0) : Buffer.from(me.request.post, 'utf8'); 96 | 97 | if (me.request.file != null) { 98 | try { 99 | recording.post = fs.readFileSync(path.resolve(me.datadir, me.request.file)); 100 | } catch (e) { /* ignored */ } 101 | } 102 | 103 | recorder.write(recording.post); 104 | recorder.end(); 105 | 106 | return recording; 107 | } 108 | 109 | function normalizeEOL (string) { 110 | return string.replace(/\r\n/g, '\n').replace(/\s*$/, ''); 111 | } 112 | 113 | function purifyRequest (incoming) { 114 | let outgoing; 115 | 116 | if (incoming == null) { incoming = {}; } 117 | 118 | outgoing = { 119 | url: incoming.url, 120 | method: incoming.method == null ? 'GET' : incoming.method, 121 | headers: purifyHeaders(incoming.headers), 122 | query: incoming.query, 123 | file: incoming.file, 124 | post: incoming.post, 125 | form: incoming.form 126 | }; 127 | 128 | if (incoming.json) { 129 | outgoing.json = JSON.parse(incoming.json); 130 | } 131 | 132 | outgoing.headers = purifyAuthorization(outgoing.headers); 133 | outgoing = pruneUndefined(outgoing); 134 | return outgoing; 135 | } 136 | 137 | function purifyResponse (me, incoming, caseSensitiveHeaders) { 138 | const outgoing = []; 139 | 140 | if (incoming == null) { incoming = []; } 141 | if (!(incoming instanceof Array)) { incoming = [incoming]; } 142 | if (incoming.length === 0) { incoming.push({}); } 143 | 144 | incoming.forEach(function (response) { 145 | if (typeof response === 'string') { 146 | outgoing.push(record(me, response)); 147 | } else { 148 | outgoing.push(pruneUndefined({ 149 | headers: purifyHeaders(response.headers, caseSensitiveHeaders), 150 | status: parseInt(response.status, 10) || 200, 151 | latency: parseInt(response.latency, 10) || null, 152 | file: response.file, 153 | body: purifyBody(response.body) 154 | })); 155 | } 156 | }); 157 | 158 | return outgoing; 159 | } 160 | 161 | function purifyHeaders (incoming, caseSensitiveHeaders) { 162 | let prop; 163 | const outgoing = {}; 164 | 165 | for (prop in incoming) { 166 | if (Object.prototype.hasOwnProperty.call(incoming, prop)) { 167 | if (caseSensitiveHeaders) { 168 | outgoing[prop] = incoming[prop]; 169 | } else { 170 | outgoing[prop.toLowerCase()] = incoming[prop]; 171 | } 172 | } 173 | } 174 | 175 | return outgoing; 176 | } 177 | 178 | function purifyAuthorization (headers) { 179 | let userpass; 180 | 181 | if (headers == null || headers.authorization == null) { return headers; } 182 | 183 | const auth = headers.authorization || ''; 184 | 185 | if (/^Basic .+:.+$/.test(auth)) { 186 | userpass = auth.substr(6); 187 | headers.authorization = 'Basic ' + Buffer.from(userpass).toString('base64'); 188 | } 189 | 190 | return headers; 191 | } 192 | 193 | function purifyBody (body) { 194 | if (body == null) { body = ''; } 195 | 196 | if (typeof body === 'object') { 197 | return JSON.stringify(body); 198 | } 199 | 200 | return body; 201 | } 202 | 203 | function pruneUndefined (incoming) { 204 | let key, value; 205 | const outgoing = {}; 206 | 207 | for (key in incoming) { 208 | if (!Object.prototype.hasOwnProperty.call(incoming, key)) { continue; } 209 | 210 | value = incoming[key]; 211 | if (value != null) { outgoing[key] = value; } 212 | } 213 | 214 | return outgoing; 215 | } 216 | 217 | function compareHashMaps (configured, incoming) { 218 | let key; 219 | const headers = {}; 220 | if (configured == null) { configured = {}; } 221 | if (incoming == null) { incoming = {}; } 222 | 223 | for (key in configured) { 224 | if (!Object.prototype.hasOwnProperty.call(configured, key)) { continue; } 225 | headers[key] = matchRegex(configured[key], incoming[key]); 226 | if (!headers[key]) { return null; } 227 | } 228 | 229 | return headers; 230 | } 231 | 232 | function compareObjects (configured, incoming) { 233 | let key; 234 | 235 | for (key in configured) { 236 | if (typeof configured[key] !== typeof incoming[key]) { return false; } 237 | 238 | if (typeof configured[key] === 'object') { 239 | if (!compareObjects(configured[key], incoming[key])) { return false; } 240 | } else if (configured[key] !== incoming[key]) { return false; } 241 | } 242 | 243 | return true; 244 | } 245 | 246 | function matchRegex (compileMe, testMe) { 247 | if (testMe == null) { testMe = ''; } 248 | return String(testMe).match(RegExp(compileMe, 'm')); 249 | } 250 | 251 | module.exports = Endpoint; 252 | -------------------------------------------------------------------------------- /test/cli.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const assert = require('assert'); 4 | const sut = require('../src/console/cli'); 5 | const out = require('../src/console/out'); 6 | 7 | describe('CLI', function () { 8 | beforeEach(function () { 9 | this.sandbox.stub(process, 'exit'); 10 | this.sandbox.stub(out, 'log'); 11 | }); 12 | 13 | describe('version', function () { 14 | it('should return the version of stubby in package.json', function () { 15 | const expected = require('../package.json').version; 16 | 17 | sut.version(true); 18 | 19 | assert(out.log.calledWith(expected)); 20 | }); 21 | }); 22 | 23 | describe('help', function () { 24 | it('should return help text', function () { 25 | sut.help(true); 26 | 27 | assert(out.log.calledOnce); 28 | }); 29 | }); 30 | 31 | describe('getArgs', function () { 32 | describe('-a, --admin', function () { 33 | it('should return default if no flag provided', function () { 34 | const expected = 8889; 35 | 36 | const actual = sut.getArgs([]); 37 | 38 | assert.strictEqual(actual.admin, expected); 39 | }); 40 | 41 | it('should return supplied value when provided', function () { 42 | const expected = '81'; 43 | 44 | const actual = sut.getArgs(['-a', expected]); 45 | 46 | assert.strictEqual(actual.admin, expected); 47 | }); 48 | 49 | it('should return supplied value when provided with full flag', function () { 50 | const expected = '81'; 51 | const actual = sut.getArgs(['--admin', expected]); 52 | assert.strictEqual(actual.admin, expected); 53 | }); 54 | }); 55 | 56 | describe('-s, --stubs', function () { 57 | it('should return default if no flag provided', function () { 58 | const expected = 8882; 59 | 60 | const actual = sut.getArgs([]); 61 | 62 | assert.strictEqual(actual.stubs, expected); 63 | }); 64 | 65 | it('should return supplied value when provided', function () { 66 | const expected = '80'; 67 | 68 | const actual = sut.getArgs(['-s', expected]); 69 | 70 | assert.strictEqual(actual.stubs, expected); 71 | }); 72 | 73 | it('should return supplied value when provided with full flag', function () { 74 | const expected = '80'; 75 | const actual = sut.getArgs(['--stubs', expected]); 76 | assert.strictEqual(actual.stubs, expected); 77 | }); 78 | }); 79 | 80 | describe('-t, --tls', function () { 81 | it('should return default if no flag provided', function () { 82 | const expected = 7443; 83 | 84 | const actual = sut.getArgs([]); 85 | 86 | assert.strictEqual(actual.tls, expected); 87 | }); 88 | 89 | it('should return supplied value when provided', function () { 90 | const expected = '443'; 91 | 92 | const actual = sut.getArgs(['-t', expected]); 93 | 94 | assert.strictEqual(actual.tls, expected); 95 | }); 96 | 97 | it('should return supplied value when provided with full flag', function () { 98 | const expected = '443'; 99 | 100 | const actual = sut.getArgs(['--tls', expected]); 101 | 102 | assert.strictEqual(actual.tls, expected); 103 | }); 104 | }); 105 | 106 | describe('-l, --location', function () { 107 | it('should return default if no flag provided', function () { 108 | const expected = '0.0.0.0'; 109 | 110 | const actual = sut.getArgs([]); 111 | 112 | assert.strictEqual(actual.location, expected); 113 | }); 114 | 115 | it('should return supplied value when provided', function () { 116 | const expected = 'stubby.com'; 117 | 118 | const actual = sut.getArgs(['-l', expected]); 119 | 120 | assert.strictEqual(actual.location, expected); 121 | }); 122 | 123 | it('should return supplied value when provided with full flag', function () { 124 | const expected = 'stubby.com'; 125 | 126 | const actual = sut.getArgs(['--location', expected]); 127 | 128 | assert.strictEqual(actual.location, expected); 129 | }); 130 | }); 131 | 132 | describe('-v, --version', function () { 133 | it('should exit the process', function () { 134 | sut.getArgs(['--version']); 135 | 136 | assert(process.exit.calledOnce); 137 | }); 138 | it('should print out version info', function () { 139 | const version = require('../package.json').version; 140 | 141 | sut.getArgs(['-v']); 142 | 143 | assert(out.log.calledWith(version)); 144 | }); 145 | }); 146 | 147 | describe('-h, --help', function () { 148 | it('should exit the process', function () { 149 | sut.getArgs(['--help']); 150 | 151 | assert(process.exit.calledOnce); 152 | }); 153 | 154 | it('should print out help text', function () { 155 | sut.help(); 156 | sut.getArgs(['-h']); 157 | 158 | assert(out.log.calledOnce); 159 | }); 160 | }); 161 | }); 162 | 163 | describe('data', function () { 164 | const expected = [{ 165 | request: { 166 | url: '/testput', 167 | method: 'PUT', 168 | post: 'test data' 169 | }, 170 | response: { 171 | headers: { 172 | 'content-type': 'text/plain' 173 | }, 174 | status: 404, 175 | latency: 2000, 176 | body: 'test response' 177 | } 178 | }, { 179 | request: { 180 | url: '/testdelete', 181 | method: 'DELETE', 182 | post: null 183 | }, 184 | response: { 185 | headers: { 186 | 'content-type': 'text/plain' 187 | }, 188 | status: 204, 189 | body: null 190 | } 191 | }]; 192 | 193 | it('should be about to parse json file with array', function () { 194 | const actual = sut.getArgs(['-d', 'test/data/cli.getData.json']); 195 | assert.deepStrictEqual(actual.data, expected); 196 | }); 197 | 198 | it('should be about to parse yaml file with array', function () { 199 | const actual = sut.getArgs(['-d', 'test/data/cli.getData.yaml']); 200 | assert.deepStrictEqual(actual.data, expected); 201 | }); 202 | }); 203 | 204 | describe('key', function () { 205 | it('should return contents of file', function () { 206 | const expected = 'some generated key'; 207 | 208 | const actual = sut.key('test/data/cli.getKey.pem'); 209 | 210 | assert.strictEqual(actual, expected); 211 | }); 212 | }); 213 | 214 | describe('cert', function () { 215 | const expected = 'some generated certificate'; 216 | 217 | it('should return contents of file', function () { 218 | const actual = sut.cert('test/data/cli.getCert.pem'); 219 | 220 | assert.strictEqual(actual, expected); 221 | }); 222 | }); 223 | 224 | describe('pfx', function () { 225 | it('should return contents of file', function () { 226 | const expected = 'some generated pfx'; 227 | 228 | const actual = sut.pfx('test/data/cli.getPfx.pfx'); 229 | 230 | assert.strictEqual(actual, expected); 231 | }); 232 | }); 233 | 234 | describe('-H, --case-sensitive-headers', function () { 235 | it('should return default if no flag provided', function () { 236 | const expected = false; 237 | const actual = sut.getArgs([]); 238 | assert.strictEqual(actual['case-sensitive-headers'], expected); 239 | }); 240 | 241 | it('should return supplied value when provided', function () { 242 | const expected = true; 243 | const actual = sut.getArgs(['-H', expected]); 244 | assert.strictEqual(actual['case-sensitive-headers'], expected); 245 | }); 246 | 247 | it('should return supplied value when provided with full flag', function () { 248 | const expected = true; 249 | const actual = sut.getArgs(['--case-sensitive-headers', expected]); 250 | assert.strictEqual(actual['case-sensitive-headers'], expected); 251 | }); 252 | }); 253 | 254 | describe('getArgs', function () { 255 | it('should gather all arguments', function () { 256 | const filename = 'file.txt'; 257 | const expected = { 258 | data: 'a file', 259 | stubs: '88', 260 | admin: '90', 261 | location: 'stubby.com', 262 | key: 'a key', 263 | cert: 'a certificate', 264 | pfx: 'a pfx', 265 | tls: '443', 266 | quiet: true, 267 | watch: filename, 268 | datadir: process.cwd(), 269 | 'case-sensitive-headers': true, 270 | help: undefined, // eslint-disable-line no-undefined 271 | version: (require('../package.json')).version 272 | }; 273 | this.sandbox.stub(sut, 'data').returns(expected.data); 274 | this.sandbox.stub(sut, 'key').returns(expected.key); 275 | this.sandbox.stub(sut, 'cert').returns(expected.cert); 276 | this.sandbox.stub(sut, 'pfx').returns(expected.pfx); 277 | 278 | const actual = sut.getArgs(['-s', expected.stubs, '-a', expected.admin, '-d', filename, '-l', expected.location, '-k', 'mocked', '-c', 'mocked', '-p', 'mocked', '-t', expected.tls, '-q', '-w', '-H']); 279 | 280 | assert.deepStrictEqual(actual, expected); 281 | }); 282 | }); 283 | }); 284 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 5.1.0 4 | 5 | * You can now submit `DELETE /` to the admin endpoint to delete all configured stubbed endpoints (#89) 6 | 7 | ## 5.0.0 8 | 9 | * __BREAKING CHANGES from 4.x__ 10 | * The exit code for the `stubby` bin script will reflect the expected/error 11 | state of stubby. 12 | * Dependencies have been updated to prevent vulnerabilities. 13 | * `acorn` updated from 7.0.0 to 7.1.1 14 | * updated node versions for testing 15 | * lodash updated from 4.17.15 to 4.17.19 16 | 17 | ## 4.1.1 18 | 19 | * Updates depenencies with reported vulnerabilities 20 | 21 | ## 4.1.0 22 | 23 | * Adds support for POST forms as a hashmap 24 | 25 | ## 4.0.0 26 | 27 | This project has been stable for some time, best we move to actual semver and 28 | not prerelease versioning. This release on the old versioning system would have been release `0.4.0`. It is now `4.0.0` instead. 29 | 30 | * __BREAKING CHANGES from 0.3.x__ 31 | * The `mute` option has been renamed `quiet` to be more consistent with other cli tools 32 | 33 | * __New features__ 34 | * Adds `hits` to the endpoint data that is returned from the admin portal that represents the amount of times that endpoint has been hit from the stubs portal. 35 | 36 | ## 0.3.1 37 | 38 | * Fixes `path` errors in Node 6 39 | 40 | ## 0.3.0 41 | 42 | * __BREAKING CHANGES from 0.2.x__ 43 | * In `0.2.x` and below, you could pass `request.headers.authorization` as a `username:password` string to signify Basic auth and stubby would automatically prefix `Basic ` and base64-encode the user/pass string. This breaks other forms of web auth that uses the `Authorization` header. 44 | ```yaml 45 | # Before 46 | request: 47 | headers: 48 | authorization: 'username:password' 49 | # Now 50 | request: 51 | headers: 52 | authorization: 'Basic username:password' 53 | ``` 54 | Stubby will still base64-encode the `username:password` if it sees that `Basic ` is specified and the `:` character is present. Otherwise it will take it as-is. 55 | * __New features__ 56 | * `json:` option for endpoints -- instead of using `post:` or `file:` for matching the body of incoming requests, you can specify `json: ` with a JSON string and its content will be deeply matched for incoming request bodies. 57 | 58 | ## 0.2.13 59 | 60 | * fixes a crash when using `start()` without any options 61 | 62 | ## 0.2.12 63 | 64 | * fixes array representations in query strings 65 | 66 | ## 0.2.11 67 | 68 | * fixes several scope-binding issues caused by the JavaScript rewrite (sorry!) 69 | * clarify use of `PUT` and the admin portal 70 | * added `_httpsOptions` to pass through options to the underlying tls server. 71 | 72 | ## 0.2.10 73 | 74 | * fix colorsafe console wrapper errors (Esco Obong) 75 | 76 | ## 0.2.9 77 | 78 | * Rewrote library in JavaScript (was CoffeeScript) 79 | 80 | ## 0.2.8 81 | 82 | * fixes the status page display (Hadi Michael) 83 | 84 | ## 0.2.7 85 | 86 | * fixes unhelpful output when the location given cannot be bound to 87 | 88 | ## 0.2.6 89 | 90 | * fixes an issue where identical URLs with different request methods may throw 91 | an exception 92 | 93 | ## 0.2.5 94 | 95 | * token replacement from regex capture groups is now usable for dynamic `file:` 96 | usage 97 | 98 | ## 0.2.3 99 | 100 | * added recording feature. If a `response` object uses a string in place of an object (or a sequence of objects/string) the strings will be interpreted as a url to record the response from. Details configured in the `request` object (such as `method`, `headers`, etc) will be used to make the recording request to the specified url 101 | * improved CORS compliance with request/response headers 102 | * added dynamic templating features for interpolating data captured in request regular expressions into response strings 103 | 104 | ## 0.2.2 105 | 106 | * CORS compliance as per W3C specifications. Using `*` as the `--location` will instruct stubby to listen on all interfaces. Implemented by [Tomás Aparicio](https://github.com/h2non) 107 | 108 | ## 0.2.1 109 | 110 | * bugfix for "Could not render headers to client" from `h2non` 111 | 112 | ## 0.2.0 113 | 114 | * added cyclic responses. `response` can now be a yaml sequence of responses. Backward compatible, thus the minor version bump 115 | * all string values for `response` criteria are matched as regular expressions against the incoming request 116 | 117 | ## 0.1.50 118 | 119 | * bugfix: admin and programmatic APIs correctly parse incoming data 120 | 121 | ## 0.1.49 122 | 123 | * updating styling of status page. 124 | 125 | ## 0.1.48 126 | 127 | * fixed a bug with the latest version of node where status page was no longer showing. 128 | 129 | ## 0.1.47 130 | 131 | * urls are now matched via regular expressions. If you want an exact match, remember to prefix your urls with `^` and postfix with `$` 132 | 133 | ## 0.1.46 134 | 135 | * binary data files are working correctly when used as a response body 136 | * fixed a bug were stubby's version number was appearing as `undefined` in the `Server` header 137 | 138 | ## 0.1.45 139 | 140 | * fixed a bug involving recursive use of `process.nextTick` 141 | 142 | ## 0.1.44 143 | 144 | * line endings are normalized to `\n` and trailing whitespace is trimmed from the end when matching request's post/file contents 145 | 146 | ## 0.1.43 147 | 148 | * `response.file` and `request.file` are now relative paths from the root data.yaml instead of being relative from the source of execution 149 | 150 | ## 0.1.42 151 | 152 | * `request.headers.authorization` can now take values such as `username:password` which will automatically be converted to `Basic dXNlcm5hbWU6cGFzc3dvcmQ=`. 153 | * parameterized flags can now be combined with non-parameterized flags. Example: `-dw data.yaml` is equivalent to `--watch --data data.yaml`. 154 | * switched from handlebars to underscore for client-side templating 155 | 156 | ## 0.1.41 157 | 158 | * added `PATCH` to acceptable HTTP verbs. 159 | * bugfix where `--watch` flag was always active. 160 | * added `man` page support 161 | 162 | ## 0.1.40 163 | 164 | * bugfixes related to command line parsing 165 | * fixed bug where query params were not being saved 166 | * added `status` endpoint on admin portal. 167 | 168 | ## 0.1.39 169 | 170 | * main `stubby` module now correctly accepts all options availabel via the command line in it's first argument. 171 | * added `-w, --watch` flag. Monitors the supplied `-d, --data` file for changes and reloads the file if necessary. 172 | * for the `require('stubby')` module, a filename is passed as `options.watch` for the `start(options, callback)` function. 173 | 174 | ## 0.1.38 175 | 176 | * made method definitions (`PUT`, `POST`, etc.) case insensitive. You could use `post`, `get`, etc. instead. 177 | * made `response` object completely **optional**. Defaults to `200` status with an empty `body`. 178 | * you can now specify an array of acceptible `method`s in your YAML: 179 | 180 | ```yaml 181 | - request: 182 | url: /anything 183 | method: [get, head] 184 | ``` 185 | 186 | 187 | ## 0.1.37 188 | 189 | * added /ping endpoint to admin portal 190 | 191 | ## 0.1.36 192 | 193 | * running stubs portal at both http and https by default 194 | * addition of `-t, --tls` option to specifying https port for stubs portal 195 | 196 | ## 0.1.35 197 | 198 | * added `file` parameter to `request` object. When matching a request, if it has `file` specified it will load it's contents from the filesystem as the `post` value. If the `file` cannot be found, it falls back to `post`. 199 | 200 | ## 0.1.34 201 | 202 | * added `query` parameter for `request` objects to allow comparison by variable instead of static querystring 203 | 204 | ## 0.1.33 205 | 206 | * fixed severe issue where request headers were not being matched by the stubs portal 207 | * renamed "stub" option to "stubs" 208 | * __NEW__: `request.file` can be used to specify a file whose contents will be used as the response body. If the file cannot be found, it falls back to whatever was specified in `response.body` 209 | 210 | ## 0.1.32 211 | 212 | * stubby can now be muted 213 | 214 | ## 0.1.31 215 | 216 | * removed coffee-script as a dependency 217 | * errors and warnings for missing or unparsable data files have been improved 218 | * re-write of code operating the command-line interface 219 | 220 | ## 0.1.30 221 | 222 | * admin portal console logging for responses 223 | * reworked API contract failures for admin portal. Upon BAD REQUEST server returns an array of errors describing the endpoint validations that were violated. 224 | 225 | ## 0.1.29 226 | 227 | * logging messages fixes for stub portal 228 | 229 | ## 0.1.28 230 | 231 | * fixed callback parameters for stubby interface 232 | 233 | ## 0.1.27 234 | 235 | * you can now make as many instances of stubby as you want by: require("stubby").Stubby and var stubby = new Stubby() 236 | 237 | ## 0.1.26 238 | 239 | * callbacks now give copies of endoints instead of refernces. This prevents unexpected changes to endpoints outside of stubby 240 | 241 | ## 0.1.25 242 | 243 | * bug fixes. optional dependency js-yaml is now *really* optional 244 | 245 | ## 0.1.24 246 | 247 | * serval bugs fixed that were found while experimenting with a main module 248 | 249 | ## 0.1.23beta 250 | 251 | * fixed but with endpoints with undefined headers not being accepted 252 | 253 | ## 0.1.22beta 254 | 255 | * added -k, --key and -c, --cert and -p, -pfx options for stating stubby as an https server 256 | * retired -f, --file option in lieu of -d, --data to prevent confusion between suppling files for data files versus ssl key/certificate files 257 | 258 | 259 | ## 0.1.21beta 260 | 261 | * added -l flag for starting stubby at a particular address 262 | 263 | ## 0.1.20beta 264 | 265 | * added -v --version command line option 266 | 267 | ## 0.1.19beta 268 | 269 | * gracefully exits on error when starting stubby 270 | 271 | ## 0.1.17beta 272 | 273 | * removed node-inspector as a dependency 274 | * removed jasmine-node as a dependency 275 | 276 | ## 0.1.16beta 277 | 278 | * default stub portal is now 8882 (from 80) 279 | * default admin portal is now 8889 (from 81) 280 | 281 | ## 0.1.15beta 282 | 283 | * initial release 284 | -------------------------------------------------------------------------------- /webroot/js/external/highlight.pack.js: -------------------------------------------------------------------------------- 1 | var hljs=new function(){function l(o){return o.replace(/&/gm,"&").replace(//gm,">")}function b(p){for(var o=p.firstChild;o;o=o.nextSibling){if(o.nodeName=="CODE"){return o}if(!(o.nodeType==3&&o.nodeValue.match(/\s+/))){break}}}function h(p,o){return Array.prototype.map.call(p.childNodes,function(q){if(q.nodeType==3){return o?q.nodeValue.replace(/\n/g,""):q.nodeValue}if(q.nodeName=="BR"){return"\n"}return h(q,o)}).join("")}function a(q){var p=(q.className+" "+q.parentNode.className).split(/\s+/);p=p.map(function(r){return r.replace(/^language-/,"")});for(var o=0;o"}while(x.length||v.length){var u=t().splice(0,1)[0];y+=l(w.substr(p,u.offset-p));p=u.offset;if(u.event=="start"){y+=s(u.node);r.push(u.node)}else{if(u.event=="stop"){var o,q=r.length;do{q--;o=r[q];y+=("")}while(o!=u.node);r.splice(q,1);while(q'+L[0]+""}else{r+=L[0]}N=A.lR.lastIndex;L=A.lR.exec(K)}return r+K.substr(N)}function z(){if(A.sL&&!e[A.sL]){return l(w)}var r=A.sL?d(A.sL,w):g(w);if(A.r>0){v+=r.keyword_count;B+=r.r}return''+r.value+""}function J(){return A.sL!==undefined?z():G()}function I(L,r){var K=L.cN?'':"";if(L.rB){x+=K;w=""}else{if(L.eB){x+=l(r)+K;w=""}else{x+=K;w=r}}A=Object.create(L,{parent:{value:A}});B+=L.r}function C(K,r){w+=K;if(r===undefined){x+=J();return 0}var L=o(r,A);if(L){x+=J();I(L,r);return L.rB?0:r.length}var M=s(A,r);if(M){if(!(M.rE||M.eE)){w+=r}x+=J();do{if(A.cN){x+=""}A=A.parent}while(A!=M.parent);if(M.eE){x+=l(r)}w="";if(M.starts){I(M.starts,"")}return M.rE?0:r.length}if(t(r,A)){throw"Illegal"}w+=r;return r.length||1}var F=e[D];f(F);var A=F;var w="";var B=0;var v=0;var x="";try{var u,q,p=0;while(true){A.t.lastIndex=p;u=A.t.exec(E);if(!u){break}q=C(E.substr(p,u.index-p),u[0]);p=u.index+q}C(E.substr(p));return{r:B,keyword_count:v,value:x,language:D}}catch(H){if(H=="Illegal"){return{r:0,keyword_count:0,value:l(E)}}else{throw H}}}function g(s){var o={keyword_count:0,r:0,value:l(s)};var q=o;for(var p in e){if(!e.hasOwnProperty(p)){continue}var r=d(p,s);r.language=p;if(r.keyword_count+r.r>q.keyword_count+q.r){q=r}if(r.keyword_count+r.r>o.keyword_count+o.r){q=o;o=r}}if(q.language){o.second_best=q}return o}function i(q,p,o){if(p){q=q.replace(/^((<[^>]+>|\t)+)/gm,function(r,v,u,t){return v.replace(/\t/g,p)})}if(o){q=q.replace(/\n/g,"
    ")}return q}function m(r,u,p){var v=h(r,p);var t=a(r);if(t=="no-highlight"){return}var w=t?d(t,v):g(v);t=w.language;var o=c(r);if(o.length){var q=document.createElement("pre");q.innerHTML=w.value;w.value=j(o,c(q),v)}w.value=i(w.value,u,p);var s=r.className;if(!s.match("(\\s|^)(language-)?"+t+"(\\s|$)")){s=s?(s+" "+t):t}r.innerHTML=w.value;r.className=s;r.result={language:t,kw:w.keyword_count,re:w.r};if(w.second_best){r.second_best={language:w.second_best.language,kw:w.second_best.keyword_count,re:w.second_best.r}}}function n(){if(n.called){return}n.called=true;Array.prototype.map.call(document.getElementsByTagName("pre"),b).filter(Boolean).forEach(function(o){m(o,hljs.tabReplace)})}function k(){window.addEventListener("DOMContentLoaded",n,false);window.addEventListener("load",n,false)}var e={};this.LANGUAGES=e;this.highlight=d;this.highlightAuto=g;this.fixMarkup=i;this.highlightBlock=m;this.initHighlighting=n;this.initHighlightingOnLoad=k;this.IR="[a-zA-Z][a-zA-Z0-9_]*";this.UIR="[a-zA-Z_][a-zA-Z0-9_]*";this.NR="\\b\\d+(\\.\\d+)?";this.CNR="(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)";this.BNR="\\b(0b[01]+)";this.RSR="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|\\.|-|-=|/|/=|:|;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~";this.BE={b:"\\\\[\\s\\S]",r:0};this.ASM={cN:"string",b:"'",e:"'",i:"\\n",c:[this.BE],r:0};this.QSM={cN:"string",b:'"',e:'"',i:"\\n",c:[this.BE],r:0};this.CLCM={cN:"comment",b:"//",e:"$"};this.CBLCLM={cN:"comment",b:"/\\*",e:"\\*/"};this.HCM={cN:"comment",b:"#",e:"$"};this.NM={cN:"number",b:this.NR,r:0};this.CNM={cN:"number",b:this.CNR,r:0};this.BNM={cN:"number",b:this.BNR,r:0};this.inherit=function(q,r){var o={};for(var p in q){o[p]=q[p]}if(r){for(var p in r){o[p]=r[p]}}return o}}();hljs.LANGUAGES.javascript=function(a){return{k:{keyword:"in if for while finally var new function do return void else break catch instanceof with throw case default try this switch continue typeof delete let yield const",literal:"true false null undefined NaN Infinity"},c:[a.ASM,a.QSM,a.CLCM,a.CBLCLM,a.CNM,{b:"("+a.RSR+"|\\b(case|return|throw)\\b)\\s*",k:"return throw case",c:[a.CLCM,a.CBLCLM,{cN:"regexp",b:"/",e:"/[gim]*",i:"\\n",c:[{b:"\\\\/"}]},{b:"<",e:">;",sL:"xml"}],r:0},{cN:"function",bWK:true,e:"{",k:"function",c:[{cN:"title",b:"[A-Za-z$_][0-9A-Za-z$_]*"},{cN:"params",b:"\\(",e:"\\)",c:[a.CLCM,a.CBLCLM],i:"[\"'\\(]"}],i:"\\[|%"}]}}(hljs);hljs.LANGUAGES.css=function(a){var b={cN:"function",b:a.IR+"\\(",e:"\\)",c:[a.NM,a.ASM,a.QSM]};return{cI:true,i:"[=/|']",c:[a.CBLCLM,{cN:"id",b:"\\#[A-Za-z0-9_-]+"},{cN:"class",b:"\\.[A-Za-z0-9_-]+",r:0},{cN:"attr_selector",b:"\\[",e:"\\]",i:"$"},{cN:"pseudo",b:":(:)?[a-zA-Z0-9\\_\\-\\+\\(\\)\\\"\\']+"},{cN:"at_rule",b:"@(font-face|page)",l:"[a-z-]+",k:"font-face page"},{cN:"at_rule",b:"@",e:"[{;]",eE:true,k:"import page media charset",c:[b,a.ASM,a.QSM,a.NM]},{cN:"tag",b:a.IR,r:0},{cN:"rules",b:"{",e:"}",i:"[^\\s]",r:0,c:[a.CBLCLM,{cN:"rule",b:"[^\\s]",rB:true,e:";",eW:true,c:[{cN:"attribute",b:"[A-Z\\_\\.\\-]+",e:":",eE:true,i:"[^\\s]",starts:{cN:"value",eW:true,eE:true,c:[b,a.NM,a.QSM,a.ASM,a.CBLCLM,{cN:"hexcolor",b:"\\#[0-9A-F]+"},{cN:"important",b:"!important"}]}}]}]}]}}(hljs);hljs.LANGUAGES.xml=function(a){var c="[A-Za-z0-9\\._:-]+";var b={eW:true,c:[{cN:"attribute",b:c,r:0},{b:'="',rB:true,e:'"',c:[{cN:"value",b:'"',eW:true}]},{b:"='",rB:true,e:"'",c:[{cN:"value",b:"'",eW:true}]},{b:"=",c:[{cN:"value",b:"[^\\s/>]+"}]}]};return{cI:true,c:[{cN:"pi",b:"<\\?",e:"\\?>",r:10},{cN:"doctype",b:"",r:10,c:[{b:"\\[",e:"\\]"}]},{cN:"comment",b:"",r:10},{cN:"cdata",b:"<\\!\\[CDATA\\[",e:"\\]\\]>",r:10},{cN:"tag",b:"|$)",e:">",k:{title:"style"},c:[b],starts:{e:"",rE:true,sL:"css"}},{cN:"tag",b:"|$)",e:">",k:{title:"script"},c:[b],starts:{e:"<\/script>",rE:true,sL:"javascript"}},{b:"<%",e:"%>",sL:"vbscript"},{cN:"tag",b:"",c:[{cN:"title",b:"[^ />]+"},b]}]}}(hljs);hljs.LANGUAGES.http=function(a){return{i:"\\S",c:[{cN:"status",b:"^HTTP/[0-9\\.]+",e:"$",c:[{cN:"number",b:"\\b\\d{3}\\b"}]},{cN:"request",b:"^[A-Z]+ (.*?) HTTP/[0-9\\.]+$",rB:true,e:"$",c:[{cN:"string",b:" ",e:" ",eB:true,eE:true}]},{cN:"attribute",b:"^\\w",e:": ",eE:true,i:"\\n|\\s|=",starts:{cN:"string",e:"$"}},{b:"\\n\\n",starts:{sL:"",eW:true}}]}}(hljs);hljs.LANGUAGES.php=function(a){var e={cN:"variable",b:"\\$+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*"};var b=[a.inherit(a.ASM,{i:null}),a.inherit(a.QSM,{i:null}),{cN:"string",b:'b"',e:'"',c:[a.BE]},{cN:"string",b:"b'",e:"'",c:[a.BE]}];var c=[a.BNM,a.CNM];var d={cN:"title",b:a.UIR};return{cI:true,k:"and include_once list abstract global private echo interface as static endswitch array null if endwhile or const for endforeach self var while isset public protected exit foreach throw elseif include __FILE__ empty require_once do xor return implements parent clone use __CLASS__ __LINE__ else break print eval new catch __METHOD__ case exception php_user_filter default die require __FUNCTION__ enddeclare final try this switch continue endfor endif declare unset true false namespace trait goto instanceof insteadof __DIR__ __NAMESPACE__ __halt_compiler",c:[a.CLCM,a.HCM,{cN:"comment",b:"/\\*",e:"\\*/",c:[{cN:"phpdoc",b:"\\s@[A-Za-z]+"}]},{cN:"comment",eB:true,b:"__halt_compiler.+?;",eW:true},{cN:"string",b:"<<<['\"]?\\w+['\"]?$",e:"^\\w+;",c:[a.BE]},{cN:"preprocessor",b:"<\\?php",r:10},{cN:"preprocessor",b:"\\?>"},e,{cN:"function",bWK:true,e:"{",k:"function",i:"\\$|\\[|%",c:[d,{cN:"params",b:"\\(",e:"\\)",c:["self",e,a.CBLCLM].concat(b).concat(c)}]},{cN:"class",bWK:true,e:"{",k:"class",i:"[:\\(\\$]",c:[{bWK:true,eW:true,k:"extends",c:[d]},d]},{b:"=>"}].concat(b).concat(c)}}(hljs);hljs.LANGUAGES.coffeescript=function(c){var b={keyword:"in if for while finally new do return else break catch instanceof throw try this switch continue typeof delete debugger super then unless until loop of by when and or is isnt not",literal:"true false null undefined yes no on off ",reserved:"case default function var void with const let enum export import native __hasProp __extends __slice __bind __indexOf"};var a="[A-Za-z$_][0-9A-Za-z$_]*";var e={cN:"title",b:a};var d={cN:"subst",b:"#\\{",e:"}",k:b,c:[c.BNM,c.CNM]};return{k:b,c:[c.BNM,c.CNM,c.ASM,{cN:"string",b:'"""',e:'"""',c:[c.BE,d]},{cN:"string",b:'"',e:'"',c:[c.BE,d],r:0},{cN:"comment",b:"###",e:"###"},c.HCM,{cN:"regexp",b:"///",e:"///",c:[c.HCM]},{cN:"regexp",b:"//[gim]*"},{cN:"regexp",b:"/\\S(\\\\.|[^\\n])*/[gim]*"},{b:"`",e:"`",eB:true,eE:true,sL:"javascript"},{cN:"function",b:a+"\\s*=\\s*(\\(.+\\))?\\s*[-=]>",rB:true,c:[e,{cN:"params",b:"\\(",e:"\\)"}]},{cN:"class",bWK:true,k:"class",e:"$",i:":",c:[{bWK:true,k:"extends",eW:true,i:":",c:[e]},e]},{cN:"property",b:"@"+a}]}}(hljs);hljs.LANGUAGES.json=function(a){var e={literal:"true false null"};var d=[a.QSM,a.CNM];var c={cN:"value",e:",",eW:true,eE:true,c:d,k:e};var b={b:"{",e:"}",c:[{cN:"attribute",b:'\\s*"',e:'"\\s*:\\s*',eB:true,eE:true,c:[a.BE],i:"\\n",starts:c}],i:"\\S"};var f={b:"\\[",e:"\\]",c:[a.inherit(c,{cN:null})],i:"\\S"};d.splice(d.length,0,b,f);return{c:d,k:e,i:"\\S"}}(hljs); -------------------------------------------------------------------------------- /test/endpoint.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Endpoint = require('../src/models/endpoint'); 4 | const assert = require('assert'); 5 | const waitsFor = require('./helpers/waits-for'); 6 | 7 | describe('Endpoint', function () { 8 | beforeEach(function () { 9 | this.data = { 10 | request: {} 11 | }; 12 | }); 13 | 14 | describe('matches', function () { 15 | it('should return regex captures for url', function () { 16 | this.data.request.url = '/capture/(.*)/$'; 17 | const endpoint = new Endpoint(this.data); 18 | 19 | const actual = endpoint.matches({ 20 | url: '/capture/me/', 21 | method: 'GET' 22 | }); 23 | 24 | assert.strictEqual(actual.url[0], '/capture/me/'); 25 | assert.strictEqual(actual.url[1], 'me'); 26 | }); 27 | 28 | it('should return regex captures for post', function () { 29 | this.data.request.url = '/'; 30 | this.data.request.post = 'some sentence with a (\\w+) in it'; 31 | const endpoint = new Endpoint(this.data); 32 | 33 | const actual = endpoint.matches({ 34 | url: '/', 35 | method: 'GET', 36 | post: 'some sentence with a word in it' 37 | }); 38 | 39 | assert.strictEqual(actual.post[1], 'word'); 40 | }); 41 | 42 | it('should return regex captures for headers', function () { 43 | this.data.request.url = '/'; 44 | this.data.request.headers = { 45 | 'content-type': 'application/(\\w+)' 46 | }; 47 | const endpoint = new Endpoint(this.data); 48 | 49 | const actual = endpoint.matches({ 50 | url: '/', 51 | method: 'GET', 52 | headers: { 53 | 'content-type': 'application/json' 54 | } 55 | }); 56 | 57 | assert.strictEqual(actual.headers['content-type'][1], 'json'); 58 | }); 59 | 60 | it('should return regex captures for query', function () { 61 | this.data.request.url = '/'; 62 | this.data.request.query = { 63 | variable: '.*' 64 | }; 65 | const endpoint = new Endpoint(this.data); 66 | 67 | const actual = endpoint.matches({ 68 | url: '/', 69 | method: 'GET', 70 | query: { 71 | variable: 'value' 72 | } 73 | }); 74 | 75 | assert.strictEqual(actual.query.variable[0], 'value'); 76 | }); 77 | }); 78 | 79 | describe('recording', function () { 80 | it('should fill in a string response with the recorded endpoint', function (done) { 81 | const waitTime = 10000; 82 | this.timeout(waitTime); 83 | this.data.response = 'http://google.com'; 84 | 85 | const actual = new Endpoint(this.data); 86 | 87 | waitsFor(function () { 88 | return actual.response[0].status === 301; 89 | }, 'endpoint to record', waitTime, done); 90 | }); 91 | 92 | it('should fill in a string reponse with the recorded endpoint in series', function (done) { 93 | const waitTime = 10000; 94 | this.timeout(waitTime); 95 | this.data.response = ['http://google.com', 'http://example.com']; 96 | 97 | const actual = new Endpoint(this.data); 98 | 99 | waitsFor(function () { 100 | return actual.response[0].status === 301 && actual.response[1].status === 200; 101 | }, 'endpoint to record', waitTime, done); 102 | }); 103 | 104 | it('should fill in a string reponse with the recorded endpoint in series', function (done) { 105 | const waitTime = 10000; 106 | this.timeout(waitTime); 107 | const data = { 108 | request: { 109 | url: '/', 110 | method: 'GET', 111 | query: { 112 | s: 'value' 113 | } 114 | }, 115 | response: [ 116 | 'http://google.com', 117 | { status: 420 } 118 | ] 119 | }; 120 | 121 | const actual = new Endpoint(data); 122 | 123 | waitsFor(function () { 124 | return actual.response[0].status === 301 && actual.response[1].status === 420; 125 | }, 'endpoint to record', waitTime, done); 126 | }); 127 | }); 128 | 129 | describe('constructor', function () { 130 | it('should at least copy over valid data', function () { 131 | const data = { 132 | hits: 0, 133 | request: { 134 | url: '/', 135 | method: 'post', 136 | query: { 137 | variable: 'value' 138 | }, 139 | headers: { 140 | header: 'string' 141 | }, 142 | post: 'data', 143 | file: 'file.txt', 144 | json: '{"key":"value"}' 145 | }, 146 | response: [{ 147 | latency: 3000, 148 | body: 'contents', 149 | file: 'another.file', 150 | status: 420, 151 | headers: { 152 | 'access-control-allow-origin': '*' 153 | } 154 | }] 155 | }; 156 | const actual = new Endpoint(data); 157 | const actualbody = actual.response[0].body.toString(); 158 | const actualJSON = actual.request.json; 159 | 160 | delete actual.response[0].body; 161 | const expectedBody = data.response[0].body; 162 | delete data.response[0].body; 163 | 164 | delete actual.request.json; 165 | const expectedJSON = JSON.parse(data.request.json); 166 | delete data.request.json; 167 | 168 | ['hits', 'request', 'response'].forEach(key => { 169 | assert.deepStrictEqual(actual[key], data[key]); 170 | }); 171 | assert.strictEqual(expectedBody, actualbody); 172 | assert.deepStrictEqual(actualJSON, expectedJSON); 173 | }); 174 | 175 | it('should default method to GET', function () { 176 | const expected = 'GET'; 177 | 178 | const actual = new Endpoint(this.data); 179 | 180 | assert.strictEqual(actual.request.method, expected); 181 | }); 182 | 183 | it('should default status to 200', function () { 184 | const expected = 200; 185 | const actual = new Endpoint(this.data); 186 | 187 | assert.strictEqual(actual.response[0].status, expected); 188 | }); 189 | 190 | it('should lower case headers properties', function () { 191 | this.data.request = { 192 | headers: { 193 | 'Content-Type': 'application/json' 194 | } 195 | }; 196 | this.data.response = { 197 | headers: { 198 | 'Content-Type': 'application/json' 199 | } 200 | }; 201 | const expected = { 202 | request: { 203 | 'content-type': 'application/json' 204 | }, 205 | response: { 206 | 'content-type': 'application/json' 207 | } 208 | }; 209 | 210 | const actual = new Endpoint(this.data); 211 | 212 | assert.deepStrictEqual(actual.response[0].headers, expected.response); 213 | assert.deepStrictEqual(actual.request.headers, expected.request); 214 | }); 215 | 216 | it('should not lower case response headers properties if caseSensitiveHeaders is true', function () { 217 | this.data.request = { 218 | headers: { 219 | 'Content-Type': 'application/json' 220 | } 221 | }; 222 | this.data.response = { 223 | headers: { 224 | 'Content-Type': 'application/json' 225 | } 226 | }; 227 | const expected = { 228 | request: { 229 | 'content-type': 'application/json' 230 | }, 231 | response: { 232 | 'Content-Type': 'application/json' 233 | } 234 | }; 235 | 236 | const actual = new Endpoint(this.data, null, true); 237 | 238 | assert.deepStrictEqual(actual.response[0].headers, expected.response); 239 | assert.deepStrictEqual(actual.request.headers, expected.request); 240 | }); 241 | 242 | it('should define multiple headers with same name', function () { 243 | this.data.request = { 244 | headers: { 245 | 'Content-Type': 'application/json' 246 | } 247 | }; 248 | this.data.response = { 249 | headers: { 250 | 'Content-Type': 'application/json', 251 | 'Set-Cookie': ['type=ninja', 'language=coffeescript'] 252 | } 253 | }; 254 | const expected = { 255 | request: { 256 | 'content-type': 'application/json' 257 | }, 258 | response: { 259 | 'content-type': 'application/json', 260 | 'set-cookie': ['type=ninja', 'language=coffeescript'] 261 | } 262 | }; 263 | 264 | const actual = new Endpoint(this.data); 265 | 266 | assert.deepStrictEqual(actual.response[0].headers, expected.response); 267 | assert.deepStrictEqual(actual.request.headers, expected.request); 268 | }); 269 | 270 | it('should base64 encode authorization headers if not encoded', function () { 271 | const expected = 'Basic dXNlcm5hbWU6cGFzc3dvcmQ='; 272 | this.data.request.headers = { 273 | authorization: 'Basic username:password' 274 | }; 275 | 276 | const actual = new Endpoint(this.data); 277 | 278 | assert.strictEqual(actual.request.headers.authorization, expected); 279 | }); 280 | 281 | it('should not encode authorization headers if encoded', function () { 282 | const expected = 'Basic dXNlcm5hbWU6cGFzc3dvc='; 283 | this.data.request.headers = { 284 | authorization: 'Basic dXNlcm5hbWU6cGFzc3dvc=' 285 | }; 286 | 287 | const actual = new Endpoint(this.data); 288 | 289 | assert.strictEqual(actual.request.headers.authorization, expected); 290 | }); 291 | 292 | it('should stringify object body in response', function () { 293 | const expected = '{"property":"value"}'; 294 | this.data.response = { 295 | body: { 296 | property: 'value' 297 | } 298 | }; 299 | 300 | const actual = new Endpoint(this.data); 301 | 302 | assert.strictEqual(actual.response[0].body.toString(), expected); 303 | }); 304 | 305 | it('should JSON parse the object json in request', function () { 306 | const expected = { 307 | key: 'value' 308 | }; 309 | this.data.request = { 310 | json: '{"key":"value"}' 311 | }; 312 | 313 | const actual = new Endpoint(this.data); 314 | assert.deepStrictEqual(actual.request.json, expected); 315 | }); 316 | 317 | it('should get the Origin header', function () { 318 | const expected = 'http://example.org'; 319 | this.data.request.headers = { 320 | Origin: 'http://example.org' 321 | }; 322 | 323 | const actual = new Endpoint(this.data); 324 | 325 | assert.strictEqual(actual.request.headers.origin, expected); 326 | }); 327 | 328 | it('should define aditional Cross-Origin headers', function () { 329 | const expected = 'http://example.org'; 330 | this.data.request.headers = { 331 | Origin: 'http://example.org', 332 | 'Access-Control-Request-Method': 'POST', 333 | 'Access-Control-Request-Headers': 'Content-Type, origin' 334 | }; 335 | 336 | const actual = new Endpoint(this.data); 337 | 338 | assert.strictEqual(actual.request.headers.origin, expected); 339 | assert.strictEqual(actual.request.headers['access-control-request-method'], 'POST'); 340 | assert.strictEqual(actual.request.headers['access-control-request-headers'], 'Content-Type, origin'); 341 | }); 342 | }); 343 | }); 344 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright 2025 Eric Mrak 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /test/admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Admin = require('../src/portals/admin').Admin; 4 | const assert = require('assert'); 5 | 6 | require('../src/console/out').quiet = true; 7 | 8 | describe('Admin', function () { 9 | let endpoints, request, response, sut; 10 | 11 | beforeEach(function () { 12 | this.sandbox.spy(console, 'info'); 13 | 14 | endpoints = { 15 | create: this.sandbox.stub(), 16 | retrieve: this.sandbox.spy(), 17 | update: this.sandbox.spy(), 18 | delete: this.sandbox.spy(), 19 | deleteAll: this.sandbox.spy(), 20 | gather: this.sandbox.stub() 21 | }; 22 | sut = new Admin(endpoints, true); 23 | request = { 24 | url: '/', 25 | method: 'POST', 26 | headers: {}, 27 | on: this.sandbox.spy() 28 | }; 29 | response = { 30 | setHeader: this.sandbox.spy(), 31 | writeHead: this.sandbox.spy(), 32 | write: this.sandbox.spy(), 33 | end: this.sandbox.spy(), 34 | on: this.sandbox.spy() 35 | }; 36 | }); 37 | 38 | describe('urlValid', function () { 39 | it('should accept the root url', function () { 40 | const url = '/'; 41 | const result = sut.urlValid(url); 42 | 43 | assert(result); 44 | }); 45 | 46 | it('should not accept urls with a-z in them', function () { 47 | const url = '/abcdefhijklmnopqrstuvwxyz'; 48 | const result = sut.urlValid(url); 49 | 50 | assert(!result); 51 | }); 52 | 53 | it('should accept urls of digits', function () { 54 | const url = '/1'; 55 | const result = sut.urlValid(url); 56 | 57 | assert(result); 58 | }); 59 | 60 | it('should not accept urls not beginning in /', function () { 61 | const url = '123456'; 62 | const result = sut.urlValid(url); 63 | 64 | assert(!result); 65 | }); 66 | 67 | it('should not accept urls beginning with 0', function () { 68 | const url = '/012'; 69 | const result = sut.urlValid(url); 70 | 71 | assert(!result); 72 | }); 73 | }); 74 | 75 | describe('getId', function () { 76 | it('should get valid id from url', function () { 77 | const id = '123'; 78 | const url = '/' + id; 79 | const actual = sut.getId(url); 80 | 81 | assert.strictEqual(actual, id); 82 | }); 83 | 84 | it('should return nothing for root url', function () { 85 | const url = '/'; 86 | const actual = sut.getId(url); 87 | assert(!actual); 88 | }); 89 | }); 90 | 91 | describe('notSupported', function () { 92 | it('should status code with "405 Not Supported" code and end response', function () { 93 | sut.notSupported(response); 94 | 95 | assert.strictEqual(response.statusCode, 405); 96 | assert(response.end.calledOnce); 97 | }); 98 | }); 99 | 100 | describe('notFound', function () { 101 | it('should write header with "404 Not Found" code and end response', function () { 102 | sut.notFound(response); 103 | 104 | assert(response.writeHead.calledWith(404)); 105 | assert(response.end.calledOnce); 106 | }); 107 | }); 108 | 109 | describe('serverError', function () { 110 | it('should write header with "500 Server Error" code and end response', function () { 111 | sut.serverError(response); 112 | 113 | assert(response.writeHead.calledWith(500)); 114 | assert(response.end.calledOnce); 115 | }); 116 | }); 117 | 118 | describe('saveError', function () { 119 | it('should write header with "422 Uprocessable Entity" code and end response', function () { 120 | sut.saveError(response); 121 | 122 | assert(response.writeHead.calledWith(422)); 123 | assert(response.end.calledOnce); 124 | }); 125 | }); 126 | 127 | describe('noContent', function () { 128 | it('should write header with "204 No Content" code and end response', function () { 129 | sut.noContent(response); 130 | 131 | assert.strictEqual(response.statusCode, 204); 132 | assert(response.end.calledOnce); 133 | }); 134 | }); 135 | 136 | describe('ok', function () { 137 | it('should write header with "200 OK" code and end response', function () { 138 | sut.ok(response); 139 | 140 | assert(response.writeHead.calledWith(200)); 141 | assert(response.end.calledOnce); 142 | }); 143 | 144 | it('should write JSON content if supplied', function () { 145 | const content = {}; 146 | sut.ok(response, content); 147 | assert(response.end.calledOnce); 148 | assert.strictEqual(response.end.args[0].length, 1); 149 | }); 150 | 151 | it('should write nothing if content is null', function () { 152 | const content = null; 153 | sut.ok(response, content); 154 | assert.strictEqual(response.write.callCount, 0); 155 | }); 156 | 157 | it('should write nothing if content is undefined', function () { 158 | sut.ok(response); 159 | assert.strictEqual(response.write.callCount, 0); 160 | }); 161 | }); 162 | 163 | describe('created', function () { 164 | let id = null; 165 | 166 | beforeEach(function () { 167 | request.headers.host = 'testHost'; 168 | id = '42'; 169 | }); 170 | 171 | it('should write header with "201 Content Created" code and end response', function () { 172 | sut.created(response, request, id); 173 | 174 | assert(response.writeHead.calledWith(201)); 175 | assert(response.end.calledOnce); 176 | }); 177 | 178 | it('should write header with Location set', function () { 179 | const expected = { 180 | Location: request.headers.host + '/' + id 181 | }; 182 | 183 | sut.created(response, request, id); 184 | 185 | assert.deepStrictEqual(response.writeHead.args[0][1], expected); 186 | }); 187 | }); 188 | 189 | describe('server', function () { 190 | it('should call notFound if url not valid', function () { 191 | this.sandbox.stub(sut, 'urlValid').returns(false); 192 | this.sandbox.spy(sut, 'notFound'); 193 | 194 | sut.server(request, response); 195 | 196 | assert(sut.notFound.calledOnce); 197 | }); 198 | 199 | it('should call goPOST if method is POST', function () { 200 | this.sandbox.stub(sut, 'urlValid').returns(true); 201 | this.sandbox.spy(sut, 'goPOST'); 202 | request.method = 'POST'; 203 | 204 | sut.server(request, response); 205 | 206 | assert(sut.goPOST.calledOnce); 207 | }); 208 | 209 | it('should call goPUT if method is PUT', function () { 210 | this.sandbox.stub(sut, 'urlValid').returns(true); 211 | this.sandbox.stub(sut, 'goPUT'); 212 | request.method = 'PUT'; 213 | 214 | sut.server(request, response); 215 | 216 | assert(sut.goPUT.calledOnce); 217 | }); 218 | 219 | it('should call goGET if method is GET', function () { 220 | this.sandbox.stub(sut, 'urlValid').returns(true); 221 | this.sandbox.stub(sut, 'goGET'); 222 | 223 | request.method = 'GET'; 224 | sut.server(request, response); 225 | assert(sut.goGET.calledOnce); 226 | }); 227 | 228 | it('should call goDELETE if method is DELETE', function () { 229 | this.sandbox.stub(sut, 'urlValid').returns(true); 230 | this.sandbox.stub(sut, 'goDELETE'); 231 | request.method = 'DELETE'; 232 | 233 | sut.server(request, response); 234 | 235 | assert(sut.goDELETE.calledOnce); 236 | }); 237 | }); 238 | 239 | describe('POST data handlers', function () { 240 | beforeEach(function () { 241 | request.on = function (event, callback) { 242 | callback(); 243 | }; 244 | this.sandbox.stub(sut, 'contract').returns(null); 245 | }); 246 | 247 | describe('goPUT', function () { 248 | it('should send not supported if there is no id in the url', function () { 249 | this.sandbox.stub(sut, 'getId').returns(''); 250 | this.sandbox.spy(sut, 'notSupported'); 251 | 252 | sut.goPUT(request, response); 253 | 254 | assert(sut.notSupported.calledOnce); 255 | }); 256 | }); 257 | 258 | describe('processPUT', function () { 259 | it('should update item if data is JSON parsable', function () { 260 | const data = '{"property":"value"}'; 261 | 262 | sut.processPUT('any id', data, response); 263 | 264 | assert(endpoints.update.calledOnce); 265 | }); 266 | 267 | it('should not update item if data isnt JSON parsable', function () { 268 | const data = '= 100."]; 356 | data.response.status = 99; 357 | 358 | const actual = sut(data); 359 | 360 | assert.deepStrictEqual(actual, expected); 361 | }); 362 | 363 | it('should return erros when greater than or equal to 500', function () { 364 | const expected = ["'response.status' must be < 600."]; 365 | data.response.status = 666; 366 | 367 | const actual = sut(data); 368 | 369 | assert.deepStrictEqual(actual, expected); 370 | }); 371 | }); 372 | 373 | describe('latency', function () { 374 | it('should return no errors when it is a number', function () { 375 | data.response.latency = 4000; 376 | 377 | const result = sut(data); 378 | 379 | assert.strictEqual(result, null); 380 | }); 381 | 382 | it('should return no errors when it a string representation of a number', function () { 383 | data.response.latency = '4000'; 384 | 385 | const result = sut(data); 386 | 387 | assert.strictEqual(result, null); 388 | }); 389 | 390 | it('should return an error when a string cannot be parsed as a number', function () { 391 | const expected = ["'response.latency' must be integer-like."]; 392 | data.response.latency = 'fred'; 393 | 394 | const actual = sut(data); 395 | 396 | assert.deepStrictEqual(actual, expected); 397 | }); 398 | }); 399 | 400 | it('should return no errors for an empty body', function () { 401 | let result; 402 | 403 | data.response.body = null; 404 | result = sut(data); 405 | assert.strictEqual(result, null); 406 | 407 | delete data.response.body; 408 | result = sut(data); 409 | assert.strictEqual(result, null); 410 | }); 411 | }); 412 | }); 413 | -------------------------------------------------------------------------------- /test/e2e.stubs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Stubby = require('../src/main').Stubby; 4 | const fs = require('fs'); 5 | const yaml = require('js-yaml'); 6 | const endpointData = yaml.load((fs.readFileSync('test/data/e2e.yaml', 'utf8')).trim()); 7 | const assert = require('assert'); 8 | const createRequest = require('./helpers/create-request'); 9 | 10 | describe('End 2 End Stubs Test Suite', function () { 11 | let sut = null; 12 | const port = 8882; 13 | 14 | async function stopStubby () { 15 | if (sut != null) await sut.stop(); 16 | } 17 | 18 | beforeEach(async function () { 19 | this.context = { 20 | done: false, 21 | port: port 22 | }; 23 | 24 | await stopStubby(); 25 | sut = new Stubby(); 26 | await sut.start({ data: endpointData }); 27 | }); 28 | 29 | afterEach(stopStubby); 30 | 31 | describe('basics', function () { 32 | it('should return a basic GET endpoint', function (done) { 33 | this.context.url = '/basic/get'; 34 | this.context.method = 'get'; 35 | 36 | createRequest(this.context, function (response) { 37 | assert.strictEqual(response.statusCode, 200); 38 | done(); 39 | }); 40 | }); 41 | 42 | it('should return a basic PUT endpoint', function (done) { 43 | this.context.url = '/basic/put'; 44 | this.context.method = 'put'; 45 | 46 | createRequest(this.context, function (response) { 47 | assert.strictEqual(response.statusCode, 200); 48 | done(); 49 | }); 50 | }); 51 | 52 | it('should return a basic POST endpoint', function (done) { 53 | this.context.url = '/basic/post'; 54 | this.context.method = 'post'; 55 | 56 | createRequest(this.context, function (response) { 57 | assert.strictEqual(response.statusCode, 200); 58 | done(); 59 | }); 60 | }); 61 | 62 | it('should return a basic DELETE endpoint', function (done) { 63 | this.context.url = '/basic/delete'; 64 | this.context.method = 'delete'; 65 | 66 | createRequest(this.context, function (response) { 67 | assert.strictEqual(response.statusCode, 200); 68 | done(); 69 | }); 70 | }); 71 | 72 | it('should return a basic HEAD endpoint', function (done) { 73 | this.context.url = '/basic/head'; 74 | this.context.method = 'head'; 75 | 76 | createRequest(this.context, function (response) { 77 | assert.strictEqual(response.statusCode, 200); 78 | done(); 79 | }); 80 | }); 81 | 82 | it('should return a basic OPTIONS endpoint', function (done) { 83 | this.context.url = '/basic/options'; 84 | this.context.method = 'options'; 85 | 86 | createRequest(this.context, function (response) { 87 | assert.strictEqual(response.statusCode, 200); 88 | done(); 89 | }); 90 | }); 91 | 92 | it('should return a response for an endpoint with multiple methods', function (done) { 93 | const self = this; 94 | this.context.url = '/basic/all'; 95 | this.context.method = 'delete'; 96 | 97 | createRequest(self.context, function (response) { 98 | assert.strictEqual(response.statusCode, 200); 99 | self.context.finished = false; 100 | self.context.url = '/basic/all'; 101 | self.context.method = 'get'; 102 | 103 | createRequest(self.context, function (response2) { 104 | assert.strictEqual(response2.statusCode, 200); 105 | 106 | self.context.finished = false; 107 | self.context.url = '/basic/all'; 108 | self.context.method = 'put'; 109 | 110 | createRequest(self.context, function (response3) { 111 | assert.strictEqual(response3.statusCode, 200); 112 | 113 | self.context.finished = false; 114 | self.context.url = '/basic/all'; 115 | self.context.method = 'post'; 116 | 117 | createRequest(self.context, function (response4) { 118 | assert.strictEqual(response4.statusCode, 200); 119 | 120 | self.context.port = 8889; 121 | self.context.finished = false; 122 | self.context.url = '/7'; 123 | self.context.method = 'get'; 124 | 125 | createRequest(self.context, function (response5) { 126 | assert.strictEqual(response5.statusCode, 200); 127 | assert.strictEqual(JSON.parse(response5.data).hits, 4); 128 | done(); 129 | }); 130 | }); 131 | }); 132 | }); 133 | }); 134 | }); 135 | 136 | it('should return the CORS headers', function (done) { 137 | const expected = 'http://example.org'; 138 | this.context.url = '/basic/get'; 139 | this.context.method = 'get'; 140 | this.context.requestHeaders = { 141 | origin: expected 142 | }; 143 | 144 | createRequest(this.context, function (response) { 145 | const headers = response.headers; 146 | 147 | assert.strictEqual(headers['access-control-allow-origin'], expected); 148 | assert.strictEqual(headers['access-control-allow-credentials'], 'true'); 149 | done(); 150 | }); 151 | }); 152 | 153 | it('should return multiple headers with the same name', function (done) { 154 | const expected = ['type=ninja', 'language=coffeescript']; 155 | this.context.url = '/duplicated/header'; 156 | this.context.method = 'get'; 157 | 158 | createRequest(this.context, function (response) { 159 | const headers = response.headers; 160 | 161 | assert.deepStrictEqual(headers['set-cookie'], expected); 162 | done(); 163 | }); 164 | }); 165 | }); 166 | 167 | describe('GET', function () { 168 | it('should return a body from a GET endpoint', function (done) { 169 | this.context.url = '/get/body'; 170 | this.context.method = 'get'; 171 | 172 | createRequest(this.context, function (response) { 173 | assert.strictEqual(response.data, 'plain text'); 174 | done(); 175 | }); 176 | }); 177 | 178 | it('should return a body from a json GET endpoint', function (done) { 179 | this.context.url = '/get/json'; 180 | this.context.method = 'get'; 181 | 182 | createRequest(this.context, function (response) { 183 | assert.strictEqual(response.data.trim(), '{"property":"value"}'); 184 | assert.strictEqual(response.headers['content-type'], 'application/json'); 185 | done(); 186 | }); 187 | }); 188 | 189 | it('should return a 420 GET endpoint', function (done) { 190 | this.context.url = '/get/420'; 191 | this.context.method = 'get'; 192 | 193 | createRequest(this.context, function (response) { 194 | assert.strictEqual(response.statusCode, 420); 195 | done(); 196 | }); 197 | }); 198 | 199 | it('should be able to handle query params', function (done) { 200 | this.context.url = '/get/query'; 201 | this.context.query = { 202 | first: 'value1 with spaces!', 203 | second: 'value2' 204 | }; 205 | this.context.method = 'get'; 206 | 207 | createRequest(this.context, function (response) { 208 | assert.strictEqual(response.statusCode, 200); 209 | done(); 210 | }); 211 | }); 212 | 213 | it('should return 404 if query params are not matched', function (done) { 214 | this.context.url = '/get/query'; 215 | this.context.query = { 216 | first: 'invalid value', 217 | second: 'value2' 218 | }; 219 | this.context.method = 'get'; 220 | 221 | createRequest(this.context, function (response) { 222 | assert.strictEqual(response.statusCode, 404); 223 | done(); 224 | }); 225 | }); 226 | 227 | it('should comma-separate repeated query params', function (done) { 228 | this.context.url = '/query/array?array=one&array=two'; 229 | this.context.method = 'get'; 230 | 231 | createRequest(this.context, function (response) { 232 | assert.strictEqual(response.statusCode, 200); 233 | assert.strictEqual(response.data, 'query as array works!'); 234 | done(); 235 | }); 236 | }); 237 | }); 238 | 239 | describe('post', function () { 240 | it('should be able to handle authorized posts', function (done) { 241 | this.context.url = '/post/auth'; 242 | this.context.method = 'post'; 243 | this.context.post = 'some=data'; 244 | this.context.requestHeaders = { 245 | authorization: 'Basic c3R1YmJ5OnBhc3N3b3Jk' 246 | }; 247 | 248 | createRequest(this.context, function (response) { 249 | assert.strictEqual(response.statusCode, 201); 250 | assert.strictEqual(response.headers.location, '/some/endpoint/id'); 251 | assert.strictEqual(response.data, 'resource has been created'); 252 | done(); 253 | }); 254 | }); 255 | 256 | it('should be able to handle authorized posts where the yaml wasnt pre-encoded', function (done) { 257 | this.context.url = '/post/auth/pair'; 258 | this.context.method = 'post'; 259 | this.context.post = 'some=data'; 260 | this.context.requestHeaders = { 261 | authorization: 'Basic c3R1YmJ5OnBhc3N3b3JkWjBy' 262 | }; 263 | 264 | createRequest(this.context, function (response) { 265 | assert.strictEqual(response.statusCode, 201); 266 | assert.strictEqual(response.headers.location, '/some/endpoint/id'); 267 | assert.strictEqual(response.data, 'resource has been created'); 268 | done(); 269 | }); 270 | }); 271 | }); 272 | 273 | describe('put', function () { 274 | it('should wait if a 2000ms latency is specified', function (done) { 275 | const start = new Date(); 276 | 277 | this.timeout(3500); 278 | this.context.url = '/put/latency'; 279 | this.context.method = 'put'; 280 | 281 | createRequest(this.context, function (response) { 282 | const elapsed = new Date() - start; 283 | 284 | assert(elapsed > 1800 && elapsed < 3200); 285 | assert.strictEqual(response.data, 'updated'); 286 | 287 | done(); 288 | }); 289 | }); 290 | }); 291 | 292 | describe('file use', function () { 293 | describe('response', function () { 294 | it('should handle file name interpolation', function (done) { 295 | this.context.url = '/file/dynamic/1'; 296 | 297 | createRequest(this.context, function (response) { 298 | assert.strictEqual(response.data.trim(), 'endpoints-1.file'); 299 | done(); 300 | }); 301 | }); 302 | 303 | it('should handle file content interpolation', function (done) { 304 | this.context.url = '/file/dynamic/2'; 305 | 306 | createRequest(this.context, function (response) { 307 | assert.strictEqual(response.data.trim(), 'endpoints-2.file'); 308 | done(); 309 | }); 310 | }); 311 | 312 | it('should handle fallback to body if specified response file cannot be found', function (done) { 313 | this.context.url = '/file/body/missingfile'; 314 | 315 | createRequest(this.context, function (response) { 316 | assert.strictEqual(response.data, 'body contents!'); 317 | done(); 318 | }); 319 | }); 320 | 321 | it('should handle file response when file can be found', function (done) { 322 | this.context.url = '/file/body'; 323 | 324 | createRequest(this.context, function (response) { 325 | assert.strictEqual(response.data.trim(), 'file contents!'); 326 | done(); 327 | }); 328 | }); 329 | }); 330 | 331 | describe('request', function () { 332 | it('should handle fallback to post if specified request file cannot be found', function (done) { 333 | this.context.url = '/file/post/missingfile'; 334 | this.context.method = 'post'; 335 | this.context.post = 'post contents!'; 336 | 337 | createRequest(this.context, function (response) { 338 | assert.strictEqual(response.statusCode, 200); 339 | done(); 340 | }); 341 | }); 342 | 343 | it('should handle file request when file can be found', function (done) { 344 | this.context.url = '/file/post'; 345 | this.context.method = 'post'; 346 | this.context.post = 'file contents!'; 347 | 348 | createRequest(this.context, function (response) { 349 | assert.strictEqual(response.statusCode, 200); 350 | done(); 351 | }); 352 | }); 353 | }); 354 | }); 355 | 356 | describe('encoded special character query params', function () { 357 | it('should handle a query param that has been configured as decoded, sent as encoded', function (done) { 358 | this.context.url = '/post/decoded/character?q=%7B'; 359 | this.context.method = 'post'; 360 | 361 | createRequest(this.context, function (response) { 362 | assert.strictEqual(response.data, 'decoded matched!'); 363 | done(); 364 | }); 365 | }); 366 | 367 | it('should handle a query param that has been configured as decoded, sent as decoded', function (done) { 368 | this.context.url = '/post/decoded/character?q={'; 369 | this.context.method = 'post'; 370 | 371 | createRequest(this.context, function (response) { 372 | assert.strictEqual(response.data, 'decoded matched!'); 373 | done(); 374 | }); 375 | }); 376 | }); 377 | }); 378 | -------------------------------------------------------------------------------- /webroot/js/external/_.js: -------------------------------------------------------------------------------- 1 | // Underscore.js 1.7.0 2 | // http://underscorejs.org 3 | // (c) 2009-2014 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors 4 | // Underscore may be freely distributed under the MIT license. 5 | (function(){var n=this,t=n._,r=Array.prototype,e=Object.prototype,u=Function.prototype,i=r.push,a=r.slice,o=r.concat,l=e.toString,c=e.hasOwnProperty,f=Array.isArray,s=Object.keys,p=u.bind,h=function(n){return n instanceof h?n:this instanceof h?void(this._wrapped=n):new h(n)};"undefined"!=typeof exports?("undefined"!=typeof module&&module.exports&&(exports=module.exports=h),exports._=h):n._=h,h.VERSION="1.7.0";var g=function(n,t,r){if(t===void 0)return n;switch(null==r?3:r){case 1:return function(r){return n.call(t,r)};case 2:return function(r,e){return n.call(t,r,e)};case 3:return function(r,e,u){return n.call(t,r,e,u)};case 4:return function(r,e,u,i){return n.call(t,r,e,u,i)}}return function(){return n.apply(t,arguments)}};h.iteratee=function(n,t,r){return null==n?h.identity:h.isFunction(n)?g(n,t,r):h.isObject(n)?h.matches(n):h.property(n)},h.each=h.forEach=function(n,t,r){if(null==n)return n;t=g(t,r);var e,u=n.length;if(u===+u)for(e=0;u>e;e++)t(n[e],e,n);else{var i=h.keys(n);for(e=0,u=i.length;u>e;e++)t(n[i[e]],i[e],n)}return n},h.map=h.collect=function(n,t,r){if(null==n)return[];t=h.iteratee(t,r);for(var e,u=n.length!==+n.length&&h.keys(n),i=(u||n).length,a=Array(i),o=0;i>o;o++)e=u?u[o]:o,a[o]=t(n[e],e,n);return a};var v="Reduce of empty array with no initial value";h.reduce=h.foldl=h.inject=function(n,t,r,e){null==n&&(n=[]),t=g(t,e,4);var u,i=n.length!==+n.length&&h.keys(n),a=(i||n).length,o=0;if(arguments.length<3){if(!a)throw new TypeError(v);r=n[i?i[o++]:o++]}for(;a>o;o++)u=i?i[o]:o,r=t(r,n[u],u,n);return r},h.reduceRight=h.foldr=function(n,t,r,e){null==n&&(n=[]),t=g(t,e,4);var u,i=n.length!==+n.length&&h.keys(n),a=(i||n).length;if(arguments.length<3){if(!a)throw new TypeError(v);r=n[i?i[--a]:--a]}for(;a--;)u=i?i[a]:a,r=t(r,n[u],u,n);return r},h.find=h.detect=function(n,t,r){var e;return t=h.iteratee(t,r),h.some(n,function(n,r,u){return t(n,r,u)?(e=n,!0):void 0}),e},h.filter=h.select=function(n,t,r){var e=[];return null==n?e:(t=h.iteratee(t,r),h.each(n,function(n,r,u){t(n,r,u)&&e.push(n)}),e)},h.reject=function(n,t,r){return h.filter(n,h.negate(h.iteratee(t)),r)},h.every=h.all=function(n,t,r){if(null==n)return!0;t=h.iteratee(t,r);var e,u,i=n.length!==+n.length&&h.keys(n),a=(i||n).length;for(e=0;a>e;e++)if(u=i?i[e]:e,!t(n[u],u,n))return!1;return!0},h.some=h.any=function(n,t,r){if(null==n)return!1;t=h.iteratee(t,r);var e,u,i=n.length!==+n.length&&h.keys(n),a=(i||n).length;for(e=0;a>e;e++)if(u=i?i[e]:e,t(n[u],u,n))return!0;return!1},h.contains=h.include=function(n,t){return null==n?!1:(n.length!==+n.length&&(n=h.values(n)),h.indexOf(n,t)>=0)},h.invoke=function(n,t){var r=a.call(arguments,2),e=h.isFunction(t);return h.map(n,function(n){return(e?t:n[t]).apply(n,r)})},h.pluck=function(n,t){return h.map(n,h.property(t))},h.where=function(n,t){return h.filter(n,h.matches(t))},h.findWhere=function(n,t){return h.find(n,h.matches(t))},h.max=function(n,t,r){var e,u,i=-1/0,a=-1/0;if(null==t&&null!=n){n=n.length===+n.length?n:h.values(n);for(var o=0,l=n.length;l>o;o++)e=n[o],e>i&&(i=e)}else t=h.iteratee(t,r),h.each(n,function(n,r,e){u=t(n,r,e),(u>a||u===-1/0&&i===-1/0)&&(i=n,a=u)});return i},h.min=function(n,t,r){var e,u,i=1/0,a=1/0;if(null==t&&null!=n){n=n.length===+n.length?n:h.values(n);for(var o=0,l=n.length;l>o;o++)e=n[o],i>e&&(i=e)}else t=h.iteratee(t,r),h.each(n,function(n,r,e){u=t(n,r,e),(a>u||1/0===u&&1/0===i)&&(i=n,a=u)});return i},h.shuffle=function(n){for(var t,r=n&&n.length===+n.length?n:h.values(n),e=r.length,u=Array(e),i=0;e>i;i++)t=h.random(0,i),t!==i&&(u[i]=u[t]),u[t]=r[i];return u},h.sample=function(n,t,r){return null==t||r?(n.length!==+n.length&&(n=h.values(n)),n[h.random(n.length-1)]):h.shuffle(n).slice(0,Math.max(0,t))},h.sortBy=function(n,t,r){return t=h.iteratee(t,r),h.pluck(h.map(n,function(n,r,e){return{value:n,index:r,criteria:t(n,r,e)}}).sort(function(n,t){var r=n.criteria,e=t.criteria;if(r!==e){if(r>e||r===void 0)return 1;if(e>r||e===void 0)return-1}return n.index-t.index}),"value")};var m=function(n){return function(t,r,e){var u={};return r=h.iteratee(r,e),h.each(t,function(e,i){var a=r(e,i,t);n(u,e,a)}),u}};h.groupBy=m(function(n,t,r){h.has(n,r)?n[r].push(t):n[r]=[t]}),h.indexBy=m(function(n,t,r){n[r]=t}),h.countBy=m(function(n,t,r){h.has(n,r)?n[r]++:n[r]=1}),h.sortedIndex=function(n,t,r,e){r=h.iteratee(r,e,1);for(var u=r(t),i=0,a=n.length;a>i;){var o=i+a>>>1;r(n[o])t?[]:a.call(n,0,t)},h.initial=function(n,t,r){return a.call(n,0,Math.max(0,n.length-(null==t||r?1:t)))},h.last=function(n,t,r){return null==n?void 0:null==t||r?n[n.length-1]:a.call(n,Math.max(n.length-t,0))},h.rest=h.tail=h.drop=function(n,t,r){return a.call(n,null==t||r?1:t)},h.compact=function(n){return h.filter(n,h.identity)};var y=function(n,t,r,e){if(t&&h.every(n,h.isArray))return o.apply(e,n);for(var u=0,a=n.length;a>u;u++){var l=n[u];h.isArray(l)||h.isArguments(l)?t?i.apply(e,l):y(l,t,r,e):r||e.push(l)}return e};h.flatten=function(n,t){return y(n,t,!1,[])},h.without=function(n){return h.difference(n,a.call(arguments,1))},h.uniq=h.unique=function(n,t,r,e){if(null==n)return[];h.isBoolean(t)||(e=r,r=t,t=!1),null!=r&&(r=h.iteratee(r,e));for(var u=[],i=[],a=0,o=n.length;o>a;a++){var l=n[a];if(t)a&&i===l||u.push(l),i=l;else if(r){var c=r(l,a,n);h.indexOf(i,c)<0&&(i.push(c),u.push(l))}else h.indexOf(u,l)<0&&u.push(l)}return u},h.union=function(){return h.uniq(y(arguments,!0,!0,[]))},h.intersection=function(n){if(null==n)return[];for(var t=[],r=arguments.length,e=0,u=n.length;u>e;e++){var i=n[e];if(!h.contains(t,i)){for(var a=1;r>a&&h.contains(arguments[a],i);a++);a===r&&t.push(i)}}return t},h.difference=function(n){var t=y(a.call(arguments,1),!0,!0,[]);return h.filter(n,function(n){return!h.contains(t,n)})},h.zip=function(n){if(null==n)return[];for(var t=h.max(arguments,"length").length,r=Array(t),e=0;t>e;e++)r[e]=h.pluck(arguments,e);return r},h.object=function(n,t){if(null==n)return{};for(var r={},e=0,u=n.length;u>e;e++)t?r[n[e]]=t[e]:r[n[e][0]]=n[e][1];return r},h.indexOf=function(n,t,r){if(null==n)return-1;var e=0,u=n.length;if(r){if("number"!=typeof r)return e=h.sortedIndex(n,t),n[e]===t?e:-1;e=0>r?Math.max(0,u+r):r}for(;u>e;e++)if(n[e]===t)return e;return-1},h.lastIndexOf=function(n,t,r){if(null==n)return-1;var e=n.length;for("number"==typeof r&&(e=0>r?e+r+1:Math.min(e,r+1));--e>=0;)if(n[e]===t)return e;return-1},h.range=function(n,t,r){arguments.length<=1&&(t=n||0,n=0),r=r||1;for(var e=Math.max(Math.ceil((t-n)/r),0),u=Array(e),i=0;e>i;i++,n+=r)u[i]=n;return u};var d=function(){};h.bind=function(n,t){var r,e;if(p&&n.bind===p)return p.apply(n,a.call(arguments,1));if(!h.isFunction(n))throw new TypeError("Bind must be called on a function");return r=a.call(arguments,2),e=function(){if(!(this instanceof e))return n.apply(t,r.concat(a.call(arguments)));d.prototype=n.prototype;var u=new d;d.prototype=null;var i=n.apply(u,r.concat(a.call(arguments)));return h.isObject(i)?i:u}},h.partial=function(n){var t=a.call(arguments,1);return function(){for(var r=0,e=t.slice(),u=0,i=e.length;i>u;u++)e[u]===h&&(e[u]=arguments[r++]);for(;r=e)throw new Error("bindAll must be passed function names");for(t=1;e>t;t++)r=arguments[t],n[r]=h.bind(n[r],n);return n},h.memoize=function(n,t){var r=function(e){var u=r.cache,i=t?t.apply(this,arguments):e;return h.has(u,i)||(u[i]=n.apply(this,arguments)),u[i]};return r.cache={},r},h.delay=function(n,t){var r=a.call(arguments,2);return setTimeout(function(){return n.apply(null,r)},t)},h.defer=function(n){return h.delay.apply(h,[n,1].concat(a.call(arguments,1)))},h.throttle=function(n,t,r){var e,u,i,a=null,o=0;r||(r={});var l=function(){o=r.leading===!1?0:h.now(),a=null,i=n.apply(e,u),a||(e=u=null)};return function(){var c=h.now();o||r.leading!==!1||(o=c);var f=t-(c-o);return e=this,u=arguments,0>=f||f>t?(clearTimeout(a),a=null,o=c,i=n.apply(e,u),a||(e=u=null)):a||r.trailing===!1||(a=setTimeout(l,f)),i}},h.debounce=function(n,t,r){var e,u,i,a,o,l=function(){var c=h.now()-a;t>c&&c>0?e=setTimeout(l,t-c):(e=null,r||(o=n.apply(i,u),e||(i=u=null)))};return function(){i=this,u=arguments,a=h.now();var c=r&&!e;return e||(e=setTimeout(l,t)),c&&(o=n.apply(i,u),i=u=null),o}},h.wrap=function(n,t){return h.partial(t,n)},h.negate=function(n){return function(){return!n.apply(this,arguments)}},h.compose=function(){var n=arguments,t=n.length-1;return function(){for(var r=t,e=n[t].apply(this,arguments);r--;)e=n[r].call(this,e);return e}},h.after=function(n,t){return function(){return--n<1?t.apply(this,arguments):void 0}},h.before=function(n,t){var r;return function(){return--n>0?r=t.apply(this,arguments):t=null,r}},h.once=h.partial(h.before,2),h.keys=function(n){if(!h.isObject(n))return[];if(s)return s(n);var t=[];for(var r in n)h.has(n,r)&&t.push(r);return t},h.values=function(n){for(var t=h.keys(n),r=t.length,e=Array(r),u=0;r>u;u++)e[u]=n[t[u]];return e},h.pairs=function(n){for(var t=h.keys(n),r=t.length,e=Array(r),u=0;r>u;u++)e[u]=[t[u],n[t[u]]];return e},h.invert=function(n){for(var t={},r=h.keys(n),e=0,u=r.length;u>e;e++)t[n[r[e]]]=r[e];return t},h.functions=h.methods=function(n){var t=[];for(var r in n)h.isFunction(n[r])&&t.push(r);return t.sort()},h.extend=function(n){if(!h.isObject(n))return n;for(var t,r,e=1,u=arguments.length;u>e;e++){t=arguments[e];for(r in t)c.call(t,r)&&(n[r]=t[r])}return n},h.pick=function(n,t,r){var e,u={};if(null==n)return u;if(h.isFunction(t)){t=g(t,r);for(e in n){var i=n[e];t(i,e,n)&&(u[e]=i)}}else{var l=o.apply([],a.call(arguments,1));n=new Object(n);for(var c=0,f=l.length;f>c;c++)e=l[c],e in n&&(u[e]=n[e])}return u},h.omit=function(n,t,r){if(h.isFunction(t))t=h.negate(t);else{var e=h.map(o.apply([],a.call(arguments,1)),String);t=function(n,t){return!h.contains(e,t)}}return h.pick(n,t,r)},h.defaults=function(n){if(!h.isObject(n))return n;for(var t=1,r=arguments.length;r>t;t++){var e=arguments[t];for(var u in e)n[u]===void 0&&(n[u]=e[u])}return n},h.clone=function(n){return h.isObject(n)?h.isArray(n)?n.slice():h.extend({},n):n},h.tap=function(n,t){return t(n),n};var b=function(n,t,r,e){if(n===t)return 0!==n||1/n===1/t;if(null==n||null==t)return n===t;n instanceof h&&(n=n._wrapped),t instanceof h&&(t=t._wrapped);var u=l.call(n);if(u!==l.call(t))return!1;switch(u){case"[object RegExp]":case"[object String]":return""+n==""+t;case"[object Number]":return+n!==+n?+t!==+t:0===+n?1/+n===1/t:+n===+t;case"[object Date]":case"[object Boolean]":return+n===+t}if("object"!=typeof n||"object"!=typeof t)return!1;for(var i=r.length;i--;)if(r[i]===n)return e[i]===t;var a=n.constructor,o=t.constructor;if(a!==o&&"constructor"in n&&"constructor"in t&&!(h.isFunction(a)&&a instanceof a&&h.isFunction(o)&&o instanceof o))return!1;r.push(n),e.push(t);var c,f;if("[object Array]"===u){if(c=n.length,f=c===t.length)for(;c--&&(f=b(n[c],t[c],r,e)););}else{var s,p=h.keys(n);if(c=p.length,f=h.keys(t).length===c)for(;c--&&(s=p[c],f=h.has(t,s)&&b(n[s],t[s],r,e)););}return r.pop(),e.pop(),f};h.isEqual=function(n,t){return b(n,t,[],[])},h.isEmpty=function(n){if(null==n)return!0;if(h.isArray(n)||h.isString(n)||h.isArguments(n))return 0===n.length;for(var t in n)if(h.has(n,t))return!1;return!0},h.isElement=function(n){return!(!n||1!==n.nodeType)},h.isArray=f||function(n){return"[object Array]"===l.call(n)},h.isObject=function(n){var t=typeof n;return"function"===t||"object"===t&&!!n},h.each(["Arguments","Function","String","Number","Date","RegExp"],function(n){h["is"+n]=function(t){return l.call(t)==="[object "+n+"]"}}),h.isArguments(arguments)||(h.isArguments=function(n){return h.has(n,"callee")}),"function"!=typeof/./&&(h.isFunction=function(n){return"function"==typeof n||!1}),h.isFinite=function(n){return isFinite(n)&&!isNaN(parseFloat(n))},h.isNaN=function(n){return h.isNumber(n)&&n!==+n},h.isBoolean=function(n){return n===!0||n===!1||"[object Boolean]"===l.call(n)},h.isNull=function(n){return null===n},h.isUndefined=function(n){return n===void 0},h.has=function(n,t){return null!=n&&c.call(n,t)},h.noConflict=function(){return n._=t,this},h.identity=function(n){return n},h.constant=function(n){return function(){return n}},h.noop=function(){},h.property=function(n){return function(t){return t[n]}},h.matches=function(n){var t=h.pairs(n),r=t.length;return function(n){if(null==n)return!r;n=new Object(n);for(var e=0;r>e;e++){var u=t[e],i=u[0];if(u[1]!==n[i]||!(i in n))return!1}return!0}},h.times=function(n,t,r){var e=Array(Math.max(0,n));t=g(t,r,1);for(var u=0;n>u;u++)e[u]=t(u);return e},h.random=function(n,t){return null==t&&(t=n,n=0),n+Math.floor(Math.random()*(t-n+1))},h.now=Date.now||function(){return(new Date).getTime()};var _={"&":"&","<":"<",">":">",'"':""","'":"'","`":"`"},w=h.invert(_),j=function(n){var t=function(t){return n[t]},r="(?:"+h.keys(n).join("|")+")",e=RegExp(r),u=RegExp(r,"g");return function(n){return n=null==n?"":""+n,e.test(n)?n.replace(u,t):n}};h.escape=j(_),h.unescape=j(w),h.result=function(n,t){if(null==n)return void 0;var r=n[t];return h.isFunction(r)?n[t]():r};var x=0;h.uniqueId=function(n){var t=++x+"";return n?n+t:t},h.templateSettings={evaluate:/<%([\s\S]+?)%>/g,interpolate:/<%=([\s\S]+?)%>/g,escape:/<%-([\s\S]+?)%>/g};var A=/(.)^/,k={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},O=/\\|'|\r|\n|\u2028|\u2029/g,F=function(n){return"\\"+k[n]};h.template=function(n,t,r){!t&&r&&(t=r),t=h.defaults({},t,h.templateSettings);var e=RegExp([(t.escape||A).source,(t.interpolate||A).source,(t.evaluate||A).source].join("|")+"|$","g"),u=0,i="__p+='";n.replace(e,function(t,r,e,a,o){return i+=n.slice(u,o).replace(O,F),u=o+t.length,r?i+="'+\n((__t=("+r+"))==null?'':_.escape(__t))+\n'":e?i+="'+\n((__t=("+e+"))==null?'':__t)+\n'":a&&(i+="';\n"+a+"\n__p+='"),t}),i+="';\n",t.variable||(i="with(obj||{}){\n"+i+"}\n"),i="var __t,__p='',__j=Array.prototype.join,"+"print=function(){__p+=__j.call(arguments,'');};\n"+i+"return __p;\n";try{var a=new Function(t.variable||"obj","_",i)}catch(o){throw o.source=i,o}var l=function(n){return a.call(this,n,h)},c=t.variable||"obj";return l.source="function("+c+"){\n"+i+"}",l},h.chain=function(n){var t=h(n);return t._chain=!0,t};var E=function(n){return this._chain?h(n).chain():n};h.mixin=function(n){h.each(h.functions(n),function(t){var r=h[t]=n[t];h.prototype[t]=function(){var n=[this._wrapped];return i.apply(n,arguments),E.call(this,r.apply(h,n))}})},h.mixin(h),h.each(["pop","push","reverse","shift","sort","splice","unshift"],function(n){var t=r[n];h.prototype[n]=function(){var r=this._wrapped;return t.apply(r,arguments),"shift"!==n&&"splice"!==n||0!==r.length||delete r[0],E.call(this,r)}}),h.each(["concat","join","slice"],function(n){var t=r[n];h.prototype[n]=function(){return E.call(this,t.apply(this._wrapped,arguments))}}),h.prototype.value=function(){return this._wrapped},"function"==typeof define&&define.amd&&define("underscore",[],function(){return h})}).call(this); 6 | //# sourceMappingURL=underscore-min.map -------------------------------------------------------------------------------- /test/endpoints.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | let sut; 4 | const Endpoints = require('../src/models/endpoints').Endpoints; 5 | const sinon = require('sinon'); 6 | const assert = require('assert'); 7 | const bufferEqual = require('./helpers/buffer-equal'); 8 | 9 | describe('Endpoints', function () { 10 | beforeEach(function () { 11 | sut = new Endpoints(); 12 | }); 13 | 14 | describe('operations', function () { 15 | afterEach(function () { 16 | sinon.restore(); 17 | }); 18 | 19 | describe('create', function () { 20 | let data; 21 | 22 | beforeEach(function () { 23 | data = { 24 | request: { 25 | url: '' 26 | } 27 | }; 28 | }); 29 | 30 | it('should assign id to entered endpoint', () => { 31 | sut.create(data); 32 | 33 | assert.notStrictEqual(sut.db[1], undefined); 34 | assert.strictEqual(sut.db[2], undefined); 35 | }); 36 | 37 | it('should call return created item', () => { 38 | const item = sut.create(data); 39 | 40 | assert(item != null); 41 | }); 42 | 43 | it('should assign ids to entered endpoints', () => { 44 | sut.create([data, data]); 45 | 46 | assert.notStrictEqual(sut.db[1], undefined); 47 | assert.notStrictEqual(sut.db[2], undefined); 48 | assert.strictEqual(sut.db[3], undefined); 49 | }); 50 | 51 | it('should call callback for each supplied endpoint', () => { 52 | const results = sut.create([data, data]); 53 | 54 | assert(results.length === 2); 55 | }); 56 | }); 57 | 58 | describe('retrieve', function () { 59 | const id = 'any id'; 60 | 61 | it('should resolve row if operation returns a row', () => { 62 | const row = { 63 | request: {}, 64 | response: {} 65 | }; 66 | sut.db[id] = row; 67 | 68 | const actual = sut.retrieve(id); 69 | 70 | assert(actual); 71 | }); 72 | 73 | it('should throw with error msg if operation does not find item', () => { 74 | sut.db = []; 75 | 76 | assert.throws(() => { sut.retrieve(id); }, { 77 | message: "Endpoint with the given id doesn't exist." 78 | }); 79 | }); 80 | }); 81 | 82 | describe('update', function () { 83 | const id = 'any id'; 84 | const data = { 85 | request: { 86 | url: '' 87 | } 88 | }; 89 | 90 | it('should not throw when database updates', () => { 91 | sut.db[id] = {}; 92 | 93 | sut.update(id, data); 94 | assert(Object.prototype.hasOwnProperty.call(sut.db[id], 'request')); 95 | }); 96 | 97 | it('should reject with error msg if operation does not find item', async () => { 98 | assert.rejects(async () => { await sut.update(id, data); }, { 99 | message: "Endpoint with the given id doesn't exist." 100 | }); 101 | }); 102 | }); 103 | 104 | describe('delete', function () { 105 | const id = 'any id'; 106 | 107 | it('should resolve when database updates', async () => { 108 | sut.db[id] = {}; 109 | 110 | await sut.delete(id); 111 | }); 112 | 113 | it('should reject with error message if operation does not find item', async () => { 114 | assert.rejects(async () => { await sut.delete(id); }, { 115 | message: "Endpoint with the given id doesn't exist." 116 | }); 117 | }); 118 | }); 119 | 120 | describe('gather', function () { 121 | it('should resolve with rows if operation returns some rows', async () => { 122 | const data = [{}, {}]; 123 | sut.db = data; 124 | 125 | const actual = await sut.gather(); 126 | 127 | assert.deepStrictEqual(actual, data); 128 | }); 129 | 130 | it('should resolve with empty array if operation does not find item', async () => { 131 | sut.db = []; 132 | 133 | const actual = await sut.gather(); 134 | 135 | assert.deepStrictEqual(actual, []); 136 | }); 137 | }); 138 | 139 | describe('find', function () { 140 | let data = { 141 | method: 'GET' 142 | }; 143 | 144 | it('should resolve with row if operation returns a row', async () => { 145 | await sut.create({}); 146 | assert(await sut.find(data)); 147 | }); 148 | 149 | it('should reject with error if operation does not find item', async () => { 150 | await assert.rejects(async () => { await sut.find(data); }, { 151 | message: "Endpoint with given request doesn't exist." 152 | }); 153 | }); 154 | 155 | describe('dynamic templating', function () { 156 | it('should replace all captures in body', async () => { 157 | await sut.create({ 158 | request: { 159 | url: '/', 160 | post: '.*' 161 | }, 162 | response: { 163 | body: 'you posted "<% post[0] %>" and "<% post[0] %>"' 164 | } 165 | }); 166 | 167 | data = { 168 | url: '/', 169 | method: 'GET', 170 | post: 'hello, there!' 171 | }; 172 | 173 | const match = await sut.find(data); 174 | assert.strictEqual(match.body, 'you posted "hello, there!" and "hello, there!"'); 175 | }); 176 | 177 | it('should replace captures in a text file', async () => { 178 | const expected = 'file contents!'; 179 | data = { 180 | url: '/', 181 | method: 'GET', 182 | post: 'endpoints' 183 | }; 184 | 185 | await sut.create({ 186 | request: { 187 | url: '/', 188 | post: '.*' 189 | }, 190 | response: { 191 | file: 'test/data/<% post[0] %>.file' 192 | } 193 | }); 194 | 195 | const found = await sut.find(data); 196 | 197 | assert.strictEqual(found.body.toString().trim(), expected); 198 | }); 199 | 200 | it('should return binary data unmolested', async () => { 201 | const expected = Buffer.from([0x80, 0x81, 0x82, 0xab, 0xcd, 0xef, 0x3c, 0x25, 0x20, 0x70, 0x6f, 0x73, 0x74, 0x5b, 0x30, 0x5d, 0x20, 0x25, 0x3e, 0xfe, 0xdc, 0xba, 0x82, 0x81, 0x80]); 202 | data = { 203 | url: '/', 204 | method: 'GET', 205 | post: 'binary' 206 | }; 207 | 208 | await sut.create({ 209 | request: { 210 | url: '/', 211 | post: '.*' 212 | }, 213 | response: { 214 | file: 'test/data/<% post[0] %>.file' 215 | } 216 | }); 217 | 218 | const found = await sut.find(data); 219 | const body = found.body; 220 | 221 | assert(body instanceof Buffer); 222 | assert(bufferEqual(body, expected)); 223 | }); 224 | }); 225 | 226 | describe('request json versus post or file', function () { 227 | it('should not match response if the request json does not match the incoming post', async () => { 228 | const expected = 'Endpoint with given request doesn\'t exist.'; 229 | 230 | await sut.create({ 231 | request: { 232 | url: '/testing', 233 | json: '{"key2":"value2", "key1":"value1"}', 234 | method: 'post' 235 | }, 236 | response: 200 237 | }); 238 | 239 | data = { 240 | method: 'POST', 241 | url: '/testing', 242 | post: '{"key1": "value1", "key3":"value3"}' 243 | }; 244 | 245 | await assert.rejects(async () => { await sut.find(data); }, { 246 | message: expected 247 | }); 248 | }); 249 | 250 | it('should match response with json if json is supplied and neither post nor file are supplied', async () => { 251 | const expected = { 252 | status: 200 253 | }; 254 | await sut.create({ 255 | request: { 256 | url: '/testing', 257 | json: '{"key2":"value2", "key1":"value1"}', 258 | method: 'post' 259 | }, 260 | response: expected 261 | }); 262 | data = { 263 | method: 'POST', 264 | url: '/testing', 265 | post: '{"key1": "value1", "key2":"value2"}' 266 | }; 267 | 268 | const found = await sut.find(data); 269 | 270 | assert.equal(found.status, 200); 271 | }); 272 | 273 | it('should match response with post if post is supplied', async () => { 274 | const expected = { 275 | status: 200 276 | }; 277 | await sut.create({ 278 | request: { 279 | url: '/testing', 280 | json: '{"key":"value"}', 281 | post: 'the post!', 282 | method: 'post' 283 | }, 284 | response: expected 285 | }); 286 | data = { 287 | method: 'POST', 288 | url: '/testing', 289 | post: 'the post!' 290 | }; 291 | const found = await sut.find(data); 292 | 293 | assert.equal(found.status, 200); 294 | }); 295 | 296 | it('should match response with file if file is supplied', async () => { 297 | const expected = { 298 | status: 200 299 | }; 300 | await sut.create({ 301 | request: { 302 | url: '/testing', 303 | file: 'test/data/endpoints.file', 304 | json: '{"key":"value"}', 305 | method: 'post' 306 | }, 307 | response: expected 308 | }); 309 | data = { 310 | method: 'POST', 311 | url: '/testing', 312 | post: 'file contents!' 313 | }; 314 | 315 | await sut.find(data); 316 | }); 317 | }); 318 | 319 | describe('request post versus file', function () { 320 | it('should match response with post if file is not supplied', async () => { 321 | const expected = { 322 | status: 200 323 | }; 324 | await sut.create({ 325 | request: { 326 | url: '/testing', 327 | post: 'the post!', 328 | method: 'post' 329 | }, 330 | response: expected 331 | }); 332 | data = { 333 | method: 'POST', 334 | url: '/testing', 335 | post: 'the post!' 336 | }; 337 | await sut.find(data); 338 | }); 339 | 340 | it('should match response with post file is supplied but cannot be found', async () => { 341 | const expected = { 342 | status: 200 343 | }; 344 | await sut.create({ 345 | request: { 346 | url: '/testing', 347 | file: 'test/data/endpoints-nonexistant.file', 348 | post: 'post data!', 349 | method: 'post' 350 | }, 351 | response: expected 352 | }); 353 | data = { 354 | method: 'POST', 355 | url: '/testing', 356 | post: 'post data!' 357 | }; 358 | assert(await sut.find(data)); 359 | }); 360 | 361 | it('should match response with file if file is supplied and exists', async () => { 362 | const expected = { 363 | status: 200 364 | }; 365 | await sut.create({ 366 | request: { 367 | url: '/testing', 368 | file: 'test/data/endpoints.file', 369 | post: 'post data!', 370 | method: 'post' 371 | }, 372 | response: expected 373 | }); 374 | data = { 375 | url: '/testing', 376 | post: 'file contents!', 377 | method: 'POST' 378 | }; 379 | assert(await sut.find(data)); 380 | }); 381 | }); 382 | 383 | describe('post versus form', function () { 384 | it('should match response with form params', async () => { 385 | const expected = { 386 | status: 200 387 | }; 388 | await sut.create({ 389 | request: { 390 | url: '/testing', 391 | form: { email: 'name@mail.com', var2: 'val2' }, 392 | method: 'post' 393 | }, 394 | response: expected 395 | }); 396 | data = { 397 | url: '/testing', 398 | post: 'email=name%40mail.com&var2=val2', 399 | method: 'POST' 400 | }; 401 | assert(await sut.find(data)); 402 | }); 403 | 404 | it('should not match response with incorrect form params', async () => { 405 | const expected = { 406 | status: 200 407 | }; 408 | await sut.create({ 409 | request: { 410 | url: '/testing', 411 | form: { email: 'name@mail.com' }, 412 | method: 'post' 413 | }, 414 | response: expected 415 | }); 416 | data = { 417 | url: '/testing', 418 | post: 'email=fail%40mail.com', 419 | method: 'POST' 420 | }; 421 | await assert.rejects(async () => { await sut.find(data); }, { 422 | message: 'Endpoint with given request doesn\'t exist.' 423 | }); 424 | }); 425 | 426 | it('should match response with extra form params', async () => { 427 | const expected = { 428 | status: 200 429 | }; 430 | await sut.create({ 431 | request: { 432 | url: '/testing', 433 | form: { email: 'name@mail.com' }, 434 | method: 'post' 435 | }, 436 | response: expected 437 | }); 438 | data = { 439 | url: '/testing', 440 | post: 'email=name%40mail.com&var2=val2', 441 | method: 'POST' 442 | }; 443 | assert(await sut.find(data)); 444 | }); 445 | 446 | it('should not match response with form params, if params not supplied', async () => { 447 | const expected = { 448 | status: 200 449 | }; 450 | await sut.create({ 451 | request: { 452 | url: '/testing', 453 | form: { var1: 'val1', var2: 'val2' }, 454 | method: 'post' 455 | }, 456 | response: expected 457 | }); 458 | data = { 459 | url: '/testing', 460 | post: 'var3=val3', 461 | method: 'POST' 462 | }; 463 | await assert.rejects(async () => { await sut.find(data); }, { 464 | message: "Endpoint with given request doesn't exist." 465 | }); 466 | }); 467 | }); 468 | 469 | describe('response body versus file', function () { 470 | it('should return response with body as content if file is not supplied', async () => { 471 | const expected = 'the body!'; 472 | await sut.create({ 473 | request: { 474 | url: '/testing' 475 | }, 476 | response: { 477 | body: expected 478 | } 479 | }); 480 | data = { 481 | url: '/testing', 482 | method: 'GET' 483 | }; 484 | const found = await sut.find(data); 485 | 486 | assert.strictEqual(found.body.toString(), expected); 487 | }); 488 | 489 | it('should return response with body as content if file is supplied but cannot be found', async () => { 490 | const expected = 'the body!'; 491 | await sut.create({ 492 | request: { 493 | url: '/testing' 494 | }, 495 | response: { 496 | body: expected, 497 | file: 'test/data/endpoints-nonexistant.file' 498 | } 499 | }); 500 | data = { 501 | url: '/testing', 502 | method: 'GET' 503 | }; 504 | const found = await sut.find(data); 505 | 506 | assert.strictEqual(found.body.toString(), expected); 507 | }); 508 | 509 | it('should return response with file as content if file is supplied and exists', async () => { 510 | const expected = 'file contents!'; 511 | await sut.create({ 512 | request: { 513 | url: '/testing' 514 | }, 515 | response: { 516 | body: 'body contents!', 517 | file: 'test/data/endpoints.file' 518 | } 519 | }); 520 | data = { 521 | url: '/testing', 522 | method: 'GET' 523 | }; 524 | const found = await sut.find(data); 525 | 526 | assert.strictEqual(found.body.toString().trim(), expected); 527 | }); 528 | }); 529 | 530 | describe('method', function () { 531 | it('should return response even if cases match', async () => { 532 | await sut.create({ 533 | request: { 534 | method: 'POST' 535 | }, 536 | response: {} 537 | }); 538 | data = { 539 | method: 'POST' 540 | }; 541 | assert(await sut.find(data) != null); 542 | }); 543 | 544 | it('should return response even if cases do not match', async () => { 545 | await sut.create({ 546 | request: { 547 | method: 'post' 548 | }, 549 | response: {} 550 | }); 551 | data = { 552 | method: 'POST' 553 | }; 554 | assert(await sut.find(data) != null); 555 | }); 556 | 557 | it('should return response if method matches any of the defined', async () => { 558 | await sut.create({ 559 | request: { 560 | method: ['post', 'put'] 561 | }, 562 | response: {} 563 | }); 564 | data = { 565 | method: 'POST' 566 | }; 567 | assert(await sut.find(data) != null); 568 | }); 569 | 570 | it('should call callback with error if none of the methods match', async () => { 571 | await sut.create({ 572 | request: { 573 | method: ['post', 'put'] 574 | }, 575 | response: {} 576 | }); 577 | data = { 578 | method: 'GET' 579 | }; 580 | await assert.rejects(async () => { await sut.find(data); }, { 581 | message: "Endpoint with given request doesn't exist." 582 | }); 583 | }); 584 | }); 585 | 586 | describe('headers', function () { 587 | it('should return response if all headers of request match', async () => { 588 | await sut.create({ 589 | request: { 590 | headers: { 591 | 'content-type': 'application/json' 592 | } 593 | }, 594 | response: {} 595 | }); 596 | data = { 597 | method: 'GET', 598 | headers: { 599 | 'content-type': 'application/json' 600 | } 601 | }; 602 | await sut.find(data); 603 | }); 604 | 605 | it('should call callback with error if all headers of request dont match', async () => { 606 | await sut.create({ 607 | request: { 608 | headers: { 609 | 'content-type': 'application/json' 610 | } 611 | }, 612 | response: {} 613 | }); 614 | data = { 615 | method: 'GET', 616 | headers: { 617 | authentication: 'Basic gibberish:password' 618 | } 619 | }; 620 | await assert.rejects(async () => { await sut.find(data); }, { 621 | message: "Endpoint with given request doesn't exist." 622 | }); 623 | }); 624 | }); 625 | 626 | describe('query', function () { 627 | it('should return response if all query of request match', async () => { 628 | await sut.create({ 629 | request: { 630 | query: { 631 | first: 'value1' 632 | } 633 | }, 634 | response: {} 635 | }); 636 | data = { 637 | method: 'GET', 638 | query: { 639 | first: 'value1' 640 | } 641 | }; 642 | await sut.find(data); 643 | }); 644 | 645 | it('should reject with error if all query of request dont match', async () => { 646 | await sut.create({ 647 | request: { 648 | query: { 649 | first: 'value1' 650 | } 651 | }, 652 | response: {} 653 | }); 654 | data = { 655 | method: 'GET', 656 | query: { 657 | unknown: 'good question' 658 | } 659 | }; 660 | await assert.rejects(async () => { await sut.find(data); }, { 661 | message: "Endpoint with given request doesn't exist." 662 | }); 663 | }); 664 | }); 665 | }); 666 | }); 667 | }); 668 | --------------------------------------------------------------------------------