Comparing version 0.2.1 to 0.3.0
@@ -8,2 +8,16 @@ # API Joe Changelog | ||
## [v0.3.0] - 2020-11-20 | ||
### Added | ||
- 'exposed' keyword to open service to requests on any path | ||
- multi-domain config for detecting service based on host header | ||
- ACME feature | ||
- Redis to WebSocket backend events feature | ||
### Fixed | ||
- Dockerfile building bug | ||
### Removed | ||
- websocket proxy support | ||
## [v0.2.1] - 2020-04-17 | ||
@@ -10,0 +24,0 @@ |
@@ -1,1 +0,1 @@ | ||
[{"categories":["Complexity"],"check_name":"method_lines","content":{"body":""},"description":"Function `pass` has 33 lines of code (exceeds 25 allowed). Consider refactoring.","fingerprint":"89e6ba40f4fb9a4bf4fd4f94d18a3844","location":{"path":"lib/proxy.js","lines":{"begin":5,"end":57}},"other_locations":[],"remediation_points":792000,"severity":"minor","type":"issue","engine_name":"structure"}] | ||
[{"categories":["Complexity"],"check_name":"method_lines","content":{"body":""},"description":"Function `pass` has 26 lines of code (exceeds 25 allowed). Consider refactoring.","fingerprint":"89e6ba40f4fb9a4bf4fd4f94d18a3844","location":{"path":"lib/proxy.js","lines":{"begin":5,"end":42}},"other_locations":[],"remediation_points":624000,"severity":"minor","type":"issue","engine_name":"structure"},{"categories":["Complexity"],"check_name":"method_lines","content":{"body":""},"description":"Function `SNICallback` has 33 lines of code (exceeds 25 allowed). Consider refactoring.","fingerprint":"729c97f3973cf54efe3e0a2b1f784874","location":{"path":"lib/acme.js","lines":{"begin":78,"end":124}},"other_locations":[],"remediation_points":792000,"severity":"minor","type":"issue","engine_name":"structure"},{"categories":["Complexity"],"check_name":"method_complexity","content":{"body":"# Cognitive Complexity\nCognitive Complexity is a measure of how difficult a unit of code is to intuitively understand. Unlike Cyclomatic Complexity, which determines how difficult your code will be to test, Cognitive Complexity tells you how difficult your code will be to read and comprehend.\n\n### A method's cognitive complexity is based on a few simple rules:\n* Code is not considered more complex when it uses shorthand that the language provides for collapsing multiple statements into one\n* Code is considered more complex for each \"break in the linear flow of the code\"\n* Code is considered more complex when \"flow breaking structures are nested\"\n\n### Further reading\n* [Cognitive Complexity docs](https://docs.codeclimate.com/v1.0/docs/cognitive-complexity)\n* [Cognitive Complexity: A new way of measuring understandability](https://www.sonarsource.com/docs/CognitiveComplexity.pdf)\n"},"description":"Function `exports` has a Cognitive Complexity of 15 (exceeds 5 allowed). Consider refactoring.","fingerprint":"8eb0c6526a4991ddc121b59c9052d162","location":{"path":"lib/main.js","lines":{"begin":23,"end":82}},"other_locations":[],"remediation_points":1150000,"severity":"minor","type":"issue","engine_name":"structure"},{"categories":["Complexity"],"check_name":"method_lines","content":{"body":""},"description":"Function `startup` has 29 lines of code (exceeds 25 allowed). Consider refactoring.","fingerprint":"1d2aa1cf4e5f1d6b3852b00ed1ad92f3","location":{"path":"lib/main.js","lines":{"begin":35,"end":74}},"other_locations":[],"remediation_points":696000,"severity":"minor","type":"issue","engine_name":"structure"},{"categories":["Complexity"],"check_name":"method_complexity","content":{"body":"# Cognitive Complexity\nCognitive Complexity is a measure of how difficult a unit of code is to intuitively understand. Unlike Cyclomatic Complexity, which determines how difficult your code will be to test, Cognitive Complexity tells you how difficult your code will be to read and comprehend.\n\n### A method's cognitive complexity is based on a few simple rules:\n* Code is not considered more complex when it uses shorthand that the language provides for collapsing multiple statements into one\n* Code is considered more complex for each \"break in the linear flow of the code\"\n* Code is considered more complex when \"flow breaking structures are nested\"\n\n### Further reading\n* [Cognitive Complexity docs](https://docs.codeclimate.com/v1.0/docs/cognitive-complexity)\n* [Cognitive Complexity: A new way of measuring understandability](https://www.sonarsource.com/docs/CognitiveComplexity.pdf)\n"},"description":"Function `installServices` has a Cognitive Complexity of 12 (exceeds 5 allowed). Consider refactoring.","fingerprint":"6251cd2aac701eb08e79e77eace21b03","location":{"path":"lib/services.js","lines":{"begin":6,"end":29}},"other_locations":[],"remediation_points":850000,"severity":"minor","type":"issue","engine_name":"structure"}] |
const Session = require('./session'); | ||
const { installServices } = require('./services'); | ||
const Proxy = require('./proxy'); | ||
const { installServices, routeRequest } = require('./services'); | ||
module.exports = function(methods){ | ||
module.exports = function({ post, all }){ | ||
this.cookieSecret = this.conf.signatureSecret; | ||
if(this.conf.services) | ||
installServices(methods, this.conf.services); | ||
installServices(this); | ||
let { post } = methods; | ||
post('/login', Session.create); | ||
post('/logout', Session.match, Session.destroy); | ||
all(routeRequest, Session.match, Proxy.pass); | ||
} |
@@ -0,39 +1,82 @@ | ||
const redis = require('async-redis'); | ||
const acme = require('acme-client'); | ||
const Nodecaf = require('nodecaf'); | ||
const periodo = require('periodo'); | ||
const redis = require('async-redis'); | ||
const { AppServer } = require('nodecaf'); | ||
const https = require('https'); | ||
const http = require('http'); | ||
const { onBackendEvent, buildWSServer } = require('./events'); | ||
const api = require('./api'); | ||
const { SNICallback, replyChallenge } = require('./acme'); | ||
let subber; | ||
module.exports = function init(){ | ||
let app = new AppServer(__dirname + '/default.toml'); | ||
app.name = 'API Joe'; | ||
app.version = '0.1.3'; | ||
app.shouldParseBody = false; | ||
app.alwaysRebuildAPI = true; | ||
async function getRedisClient(conf){ | ||
let client = redis.createClient(conf); | ||
await new Promise( (done, fail) => { | ||
client.on('connect', done); | ||
client.on('error', fail); | ||
client.on('end', () => fail(new Error('Redis connection aborted'))); | ||
}); | ||
return client; | ||
} | ||
let shared = {}; | ||
module.exports = () => new Nodecaf({ | ||
api, | ||
conf: __dirname + '/default.toml', | ||
shouldParseBody: false, | ||
alwaysRebuildAPI: true, | ||
app.beforeStart = function({ conf }){ | ||
server(app){ | ||
if(app.conf.acme) | ||
return https.createServer({ SNICallback: SNICallback.bind(app) }); | ||
return http.createServer(); | ||
}, | ||
conf.cookie.maxAge = periodo(conf.cookie.maxAge).time; | ||
conf.cookie.signed = true; | ||
async startup(app){ | ||
let envIsProd = process.env.NODE_ENV == 'production'; | ||
let { conf, global } = app; | ||
conf.session.timeout = Math.floor(periodo(conf.session.timeout).time / 1000); | ||
global.cookieOpts = { | ||
maxAge: periodo(conf.session.timeout).time, | ||
secure: envIsProd, | ||
httpOnly: true, | ||
sameSite: conf.cookie.sameSite, | ||
signed: true | ||
}; | ||
var rclient = redis.createClient(conf.redis); | ||
shared.redis = rclient; | ||
conf.session.timeout = Math.floor(global.cookieOpts.maxAge / 1000); | ||
return new Promise( (done, fail) => { | ||
rclient.on('connect', done); | ||
rclient.on('error', fail); | ||
}); | ||
}; | ||
conf.redis['retry_strategy'] = () => new Error('Redis connection failed'); | ||
global.redis = await getRedisClient(conf.redis); | ||
app.afterStop = function(){ | ||
shared.redis.quit(); | ||
}; | ||
subber = await getRedisClient(conf.redis); | ||
subber.on('message', onBackendEvent.bind(global.redis, this)); | ||
subber.subscribe('joe:ws:event'); | ||
app.expose(shared); | ||
app.api(api); | ||
if(conf.acme){ | ||
let accountKey = await global.redis.get('joe:privateKey'); | ||
if(!accountKey){ | ||
accountKey = await acme.forge.createPrivateKey(); | ||
global.redis.set('joe:privateKey', accountKey); | ||
} | ||
return app; | ||
} | ||
global.contexts = {}; | ||
let directoryUrl = conf.acme.api || /* istanbul ignore next */(envIsProd | ||
? acme.directory.letsencrypt.production | ||
: acme.directory.letsencrypt.staging); | ||
global.acmeClient = new acme.Client({ directoryUrl, accountKey }); | ||
global.chlngServer = http.createServer(replyChallenge.bind(global)); | ||
global.chlngServer.listen(envIsProd ? 80 : 5002); | ||
} | ||
app.running.then(() => global.wss = buildWSServer(app)); | ||
}, | ||
async shutdown({ global }){ | ||
await global.wss.doClose(); | ||
await new Promise(done => global.redis.quit(done)); | ||
await new Promise(done => subber.quit(done)); | ||
global.chlngServer && global.chlngServer.close(); | ||
} | ||
}); |
@@ -5,8 +5,8 @@ const httpProxy = require('http-proxy'); | ||
pass({ req, res, log, flash, conf, headers }){ | ||
pass({ req, res, log, flash, conf }){ | ||
let target = flash.service.url + (flash.removePrefix | ||
? req.url.substr(flash.service.name.length + 1) | ||
: req.url); | ||
let preffixSize = this.service.name.length + 1; | ||
let target = this.service.url + req.url.substr(preffixSize); | ||
let proxy = httpProxy.createProxyServer({ | ||
@@ -17,5 +17,2 @@ ignorePath: true, target, proxyTimeout: 3000, | ||
//let c1 = res.getHeader('Access-Control-Allow-Origin'); | ||
//let c2 = res.getHeader('Access-Control-Allow-Credentials'); | ||
proxy.on('proxyReq', prq => { | ||
@@ -34,5 +31,4 @@ prq.removeHeader(conf.proxy.claimHeader); | ||
proxy.on('proxyRes', (prs, req, res) => { | ||
//res.setHeaders(prs.getHeaders()); | ||
//res.setHeader('Access-Control-Allow-Origin', c1); | ||
//res.setHeader('Access-Control-Allow-Credentials', c2); | ||
/* istanbul ignore next */ | ||
if(prs.headers['content-type']) | ||
@@ -45,13 +41,2 @@ res.setHeader('Content-Type', prs.headers['content-type']); | ||
if(headers.upgrade == 'websocket'){ | ||
delete req.headers[conf.proxy.claimHeader]; | ||
if(flash.claim) | ||
req.headers[conf.proxy.claimHeader] = encodeURIComponent(flash.claim); | ||
proxy.ws(req, req.socket, undefined); | ||
log.debug({ target, class: 'ws' }, 'Proxyed websocket upgrade to %s', target); | ||
return; | ||
} | ||
proxy.web(req, res, err => { | ||
@@ -58,0 +43,0 @@ res.status(503).end(); |
const Session = require('./session'); | ||
const Proxy = require('./proxy'); | ||
const SERVICE_KEYS = { url: 1, endpoints: 1, domain: 1, secure: 1, exposed: 1 }; | ||
function installEndpoint(methodFn, path, service){ | ||
module.exports = { | ||
if(typeof methodFn !== 'function') | ||
throw Error('Invalid method name in services definition'); | ||
installServices(app){ | ||
app.global.services = {}; | ||
app.global.domains = {}; | ||
methodFn(path, | ||
Session.match, | ||
Proxy.pass.bind({ service }) | ||
); | ||
for(let name in app.conf.services){ | ||
let service = app.conf.services[name]; | ||
} | ||
for(let key in service) | ||
if(!(key in SERVICE_KEYS)) | ||
throw new Error(`Unsupported key '${key}' in services definition`); | ||
const SERVICE_KEYS = { url: 1, endpoints: 1 }; | ||
let def = { ...service, name }; | ||
function installService(methods, name, service){ | ||
service.endpoints = Array.isArray(service.endpoints) ? service.endpoints : []; | ||
def.endpoints = {}; | ||
for(spec of service.endpoints) | ||
def.endpoints[spec] = 1; | ||
let key = ''; | ||
let badKey = Object.keys(service).some( k => { | ||
key = k; | ||
return !(k in SERVICE_KEYS); | ||
}); | ||
app.global.services[name] = def; | ||
if(badKey) | ||
throw new Error(`Unsupported key '${key}' in services definition`); | ||
if(service.domain) | ||
app.global.services[service.domain] = def; | ||
} | ||
}, | ||
service = { ...service, name }; | ||
routeRequest({ flash, params, headers, services, res, next, req }){ | ||
let [ host ] = headers.host.split(':'); | ||
let [ prefix, ...path ] = params.path.split('/'); | ||
host = host.toLowerCase(); | ||
flash.service = services[host] || services[prefix]; | ||
flash.removePrefix = !services[host]; | ||
if(!Array.isArray(service.endpoints) || service.endpoints.length === 0) | ||
throw new Error(`Service '${name}' must have at least 1 endpoint`); | ||
res.notFound(!flash.service); | ||
for(let spec of service.endpoints){ | ||
let [ method, path ] = spec.split(' '); | ||
method = methods[method.toLowerCase()]; | ||
installEndpoint(method, '/' + name + path, service); | ||
if(!flash.service.exposed){ | ||
let p = flash.removePrefix ? path : [ prefix, ...path ]; | ||
res.notFound(!(req.method + ' /' + p.join('/') in flash.service.endpoints)); | ||
} | ||
next(); | ||
} | ||
} | ||
function installServices(methods, services){ | ||
for(let name in services) | ||
installService(methods, name, services[name]); | ||
} | ||
module.exports = { installService, installServices, installEndpoint }; |
const { request, post } = require('muhb'); | ||
const { authn } = require('nodecaf').assertions; | ||
const { v4: uuid } = require('uuid'); | ||
function eraseCookie(conf, res){ | ||
let opts = { ...conf.cookie }; | ||
delete opts.maxAge; | ||
res.clearCookie(conf.cookie.name, opts); | ||
} | ||
module.exports = { | ||
async match({ redis, req, flash, next, conf, res }){ | ||
let sessid = req.signedCookies[conf.cookie.name]; | ||
async match({ signedCookies, cookieOpts, redis, flash, next, conf, res }){ | ||
let sessid = signedCookies[conf.cookie.name]; | ||
if(typeof sessid !== 'string') | ||
return next(); | ||
let claim = await redis.get(sessid); | ||
if(!claim){ | ||
eraseCookie(conf, res); | ||
if(! (flash.claim = await redis.get(sessid)) ){ | ||
res.clearCookie(conf.cookie.name, cookieOpts); | ||
return next(); | ||
} | ||
flash.claim = claim; | ||
flash.sessid = sessid; | ||
@@ -30,4 +22,5 @@ redis.expire(sessid, conf.session.timeout); | ||
async create({ req, conf, res, redis }){ | ||
async create({ cookieOpts, req, conf, res, redis }){ | ||
// Needed because Body parsing is disabled (due to proxy) | ||
let body = ''; | ||
@@ -44,11 +37,10 @@ await new Promise(done => { | ||
url: conf.auth.url, | ||
headers: { 'Content-Type': 'application/json' } | ||
headers: conf.auth.headers | ||
}); | ||
authn(status === 200, authData); | ||
res.badRequest(status !== 200, authData); | ||
let sessid = uuid(); | ||
res.cookie(conf.cookie.name, sessid, conf.cookie); | ||
res.end(); | ||
res.cookie(conf.cookie.name, sessid, cookieOpts).end(); | ||
@@ -62,4 +54,4 @@ await redis.set(sessid, authData); | ||
destroy({ flash, res, redis, conf }){ | ||
eraseCookie(conf, res); | ||
destroy({ cookieOpts, flash, res, redis, conf }){ | ||
res.clearCookie(conf.cookie.name, cookieOpts); | ||
res.end(); | ||
@@ -66,0 +58,0 @@ redis.del(flash.sessid); |
{ | ||
"name": "api-joe", | ||
"version": "0.2.1", | ||
"version": "0.3.0", | ||
"description": "An API Gateway to easily expose your services to web clients", | ||
@@ -37,13 +37,15 @@ "main": "lib/main.js", | ||
"dependencies": { | ||
"acme-client": "^4.1.2", | ||
"async-redis": "^1.1.7", | ||
"http-proxy": "^1.18.0", | ||
"muhb": "^3.0.1", | ||
"nodecaf": "^0.9.1", | ||
"nodecaf-run": "0.0.2", | ||
"cookie": "^0.4.1", | ||
"cookie-signature": "^1.1.0", | ||
"http-proxy": "^1.18.1", | ||
"muhb": "^3.0.4", | ||
"nodecaf": "^0.11.0", | ||
"nodecaf-run": "^0.1.1", | ||
"periodo": "^0.1.1", | ||
"uuid": "^7.0.2" | ||
}, | ||
"devDependencies": { | ||
"ws": "^7.2.3" | ||
"toml": "^3.0.0", | ||
"uuid": "^8.3.1", | ||
"ws": "^7.4.0" | ||
} | ||
} |
416
test/spec.js
const assert = require('assert'); | ||
const { v4: uuid } = require('uuid'); | ||
const { context } = require('muhb'); | ||
const Nodecaf = require('nodecaf'); | ||
@@ -8,22 +10,49 @@ process.env.NODE_ENV = 'testing'; | ||
const HTTPBIN_URL = process.env.HTTPBIN_URL || 'http://localhost:8066'; | ||
const ACME_GATEWAY = process.env.ACME_GATEWAY || 'host.docker.internal'; | ||
const REDIS_HOST = process.env.REDIS_HOST || 'localhost'; | ||
const ACME_API_URL = process.env.ACME_API_URL || 'https://localhost:14000/dir'; | ||
const SERVICES = { | ||
ws: { url: 'ws://localhost:1234', endpoints: [ 'GET /ws' ] }, | ||
unresponsive: { url: 'http://anything', endpoints: [ 'GET /foo' ] }, | ||
httpbin: { url: HTTPBIN_URL, endpoints: [ 'GET /get', 'GET /anything' ] } | ||
backend: { url: 'http://localhost:8060', endpoints: [ 'GET /get', 'GET /headers' ] }, | ||
public: { url: 'http://localhost:8060', exposed: true }, | ||
hostly: { domain: 'foobar', url: 'http://localhost:8060', exposed: true }, | ||
httpsonly: { domain: ACME_GATEWAY, url: 'http://localhost:8060', secure: true, endpoints: [ 'GET /headers' ] } | ||
}; | ||
const ACME_CONF = { api: ACME_API_URL, email: 'api-joe@gmail.com' }; | ||
let base = context('http://localhost:8232'); | ||
let app, base = context('http://127.0.0.1:8236'), secBase = context('https://localhost:8236'); | ||
let app; | ||
const authProvider = new Nodecaf({ | ||
conf: { port: 8060, log: false }, | ||
api({ post, get }){ | ||
post('/auth', ({ res, body }) => { | ||
if(body) | ||
return res.status(400).end(); | ||
res.end('ID: ' + uuid()); | ||
}); | ||
get('/headers', ({ headers, res }) => res.json(headers)); | ||
get('/public-only', ({ res }) => res.end('public-only')); | ||
} | ||
}); | ||
before(async function(){ | ||
await authProvider.start(); | ||
}); | ||
after(async function(){ | ||
await authProvider.stop(); | ||
}); | ||
beforeEach(async function(){ | ||
this.timeout(4000); | ||
app = init(); | ||
app.setup({ | ||
port: 8232, | ||
port: 8236, | ||
redis: { host: REDIS_HOST }, | ||
cookie: { name: 'foobaz' }, | ||
cookie: { name: 'foobaz', secret: 'FOOBAR' }, | ||
services: SERVICES, | ||
auth: { url: HTTPBIN_URL + '/post' } | ||
auth: { url: 'http://localhost:8060/auth' } | ||
}); | ||
@@ -34,2 +63,3 @@ await app.start(); | ||
afterEach(async function(){ | ||
await new Promise(done => setTimeout(done, 400)); | ||
await app.stop(); | ||
@@ -51,131 +81,323 @@ }); | ||
it('Should fail when cannot connect to redis', function(){ | ||
app.setup({ redis: { port: 8676 } }); | ||
assert.rejects(() => app.restart(), /Redis connection/); | ||
it('Should fail when cannot connect to redis', async function(){ | ||
this.timeout(3e3); | ||
await assert.rejects(app.restart({ redis: { port: 8676 } })); | ||
app.setup({ redis: { port: 6379 } }); | ||
await app.start(); | ||
}); | ||
it('Should fail if serivce config has unsupported keys', async function(){ | ||
let p = app.restart({ services: { foo: { bar: 'baz' } } }); | ||
await assert.rejects(p, /key 'bar'/ ); | ||
}); | ||
describe('Services', function(){ | ||
}); | ||
it('Should fail when endpoint has invalid method name', function(){ | ||
let obj = { services: { foo: { url: 'test', endpoints: [ 'FOO /bar' ] } } }; | ||
assert.throws( () => app.setup(obj), /method name/ ); | ||
}); | ||
describe('Proxy', function(){ | ||
it('Should fail if serivce has unsupported keys', function(){ | ||
let obj = { services: { foo: { bar: 'baz' } } }; | ||
assert.throws( () => app.setup(obj), /key 'bar'/ ); | ||
}); | ||
it('Should fail when service doesn\'t exist', async function(){ | ||
let { assert } = await base.get('nothing'); | ||
assert.status.is(404); | ||
}); | ||
it('Should fail if serivce has no endpoints', function(){ | ||
let obj = { services: { foo: { } } }; | ||
assert.throws( () => app.setup(obj), /least 1 endpoint/ ); | ||
}); | ||
it('Should fail when service is unresponsive', async function(){ | ||
this.timeout(2700); | ||
let { assert } = await base.get('unresponsive/foo'); | ||
assert.status.is(503); | ||
}); | ||
it('Should fail when endpoint doesn\'t exist', async function(){ | ||
let { assert } = await base.get('backend/public-only'); | ||
assert.status.is(404); | ||
}); | ||
it('Should reach an exposed service endpoints', async function(){ | ||
let { assert } = await base.get('backend/headers'); | ||
assert.status.is(200); | ||
assert.body.contains('date'); | ||
}); | ||
it('Should reach any endpoint of exposed service [*service.exposed]', async function(){ | ||
let { assert } = await base.get('public/public-only'); | ||
assert.status.is(200); | ||
assert.body.contains('public-only'); | ||
let { assert: a } = await base.get('public/nothing'); | ||
a.status.is(404); | ||
}); | ||
it('Should preserve cookies from outside when setup [proxy.preserveCookies]', async function(){ | ||
await app.restart({ proxy: { preserveCookies: true } }); | ||
let { assert } = await base.get('backend/headers', { cookies: { foo: 'bar' } }); | ||
assert.body.contains('"cookie":"foo=bar'); | ||
}); | ||
it('Should find service according to HOST header [*service.domain]', async function(){ | ||
let { assert } = await base.get('headers', { 'host': 'hostly' }); | ||
assert.status.is(200); | ||
assert.body.contains('{'); | ||
}); | ||
}); | ||
describe('API', function(){ | ||
describe('Session', function(){ | ||
describe('Endpoints', function(){ | ||
describe('POST /login', function(){ | ||
it('Should fail when service doesn\'t exist', async function(){ | ||
let { assert } = await base.get('nothing'); | ||
assert.status.is(404); | ||
}); | ||
it('Should fail when service is unresponsive', async function(){ | ||
it('Should fail when can\'t reach auth server', async function(){ | ||
this.timeout(2700); | ||
let { assert } = await base.get('unresponsive/foo'); | ||
assert.status.is(503); | ||
await app.restart({ auth: { url: 'http://nothing' } }); | ||
let { assert } = await base.post('login'); | ||
assert.status.is(500); | ||
}); | ||
it('Should fail when endpoint doesn\'t exist', async function(){ | ||
let { assert } = await base.get('unresponsive/foo2'); | ||
assert.status.is(404); | ||
it('Should return authentication failure when not 200', async function(){ | ||
let { assert } = await base.post('login', { auto: true }, 'must fail'); | ||
assert.status.is(400); | ||
}); | ||
it('Should reach an exposed service endpoints', async function(){ | ||
let { assert } = await base.get('httpbin/get'); | ||
it('Should return ok when auth succeeds', async function(){ | ||
let { assert } = await base.post('login'); | ||
assert.status.is(200); | ||
assert.body.contains('/get'); | ||
}); | ||
it('Should preserve cookies from outside when setup [proxy.preserveCookies]', async function(){ | ||
await app.restart({ proxy: { preserveCookies: true } }); | ||
let { assert } = await base.get('httpbin/get', { 'cookies': { foo: 'bar' } }); | ||
assert.body.contains('"Cookie": "foo=bar'); | ||
it('Should include claim header when proxying for logged in client', async function(){ | ||
let { assert, cookies } = await base.post('login'); | ||
assert.status.is(200); | ||
let { assert: a } = await base.get('backend/headers', { cookies }); | ||
a.status.is(200); | ||
a.body.contains('"x-claim":"ID'); | ||
}); | ||
it('Should support WebSocket connection', function(done){ | ||
let count = 0; | ||
}); | ||
const WebSocket = require('ws'); | ||
let wss = new WebSocket.Server({ port: 1234 }); | ||
wss.on('connection', client => { | ||
describe('POST /logout', function(){ | ||
client.on('message', message => { | ||
count++; | ||
assert.strictEqual(message, 'foobar'); | ||
client.send('bahbaz'); | ||
}); | ||
it('Should force expire an active session', async function(){ | ||
await app.restart({ session: { timeout: '1d' } }); | ||
let { cookies } = await base.post('login'); | ||
let { headers } = await base.post('logout', { cookies }); | ||
assert(headers['set-cookie'][0].indexOf('01 Jan 1970') > 0); | ||
}); | ||
client.on('close', () => { | ||
assert.strictEqual(count, 4); | ||
wss.close(); | ||
done(); | ||
}); | ||
}); | ||
count++; | ||
}); | ||
}); | ||
let wsc = new WebSocket('http://localhost:8232/ws/ws'); | ||
wsc.on('open', () => { | ||
count++; | ||
wsc.send('foobar'); | ||
}); | ||
wsc.on('message', m => { | ||
count++; | ||
assert.strictEqual(m, 'bahbaz'); | ||
wsc.close(); | ||
}); | ||
describe('Events', function(){ | ||
const WebSocket = require('ws'); | ||
const redis = require('async-redis'); | ||
let backend, logWS, pubWS, logWS2, logClaim; | ||
before(async function(){ | ||
backend = redis.createClient({ host: REDIS_HOST }); | ||
await new Promise( (done, fail) => { | ||
backend.on('connect', done); | ||
backend.on('error', fail); | ||
backend.on('end', fail); | ||
}); | ||
}); | ||
beforeEach(async function(){ | ||
let { cookies } = await base.post('login'); | ||
let { cookies: c2 } = await base.post('login'); | ||
logClaim = await backend.get(cookies.foobaz.substr(2, 36)); | ||
await backend.sadd(logClaim, logClaim); | ||
pubWS = new WebSocket('ws://localhost:8236/events'); | ||
logWS = new WebSocket('ws://localhost:8236/events', { | ||
headers: { 'Cookie': 'foobaz=' + cookies.foobaz } | ||
}); | ||
logWS2 = new WebSocket('ws://localhost:8236/events', { | ||
headers: { 'Cookie': 'foobaz=' + c2.foobaz } | ||
}); | ||
}); | ||
describe('POST /login', function(){ | ||
afterEach(function(){ | ||
pubWS.close(); | ||
logWS.close(); | ||
logWS2.close(); | ||
}); | ||
it('Should fail when can\'t reach auth server', async function(){ | ||
this.timeout(2700); | ||
await app.restart({ auth: { url: 'http://nothing' } }); | ||
let { assert } = await base.post('login'); | ||
assert.status.is(500); | ||
}); | ||
after(function(){ | ||
backend.quit(); | ||
}); | ||
it('Should return authentication failure when not 200', async function(){ | ||
await app.restart({ auth: { url: HTTPBIN_URL + '/get' } }); | ||
let { assert } = await base.post('login'); | ||
assert.status.is(400); | ||
it('Should drop connections to paths other than /events', function(done){ | ||
let ws = new WebSocket('ws://localhost:8236/foobar'); | ||
ws.on('error', () => done()); | ||
}); | ||
it('Should accept Authenticated WS clients', function(done){ | ||
logWS.on('open', () => { | ||
setTimeout(() => { | ||
app.global.wss.clients.forEach(ws => assert(ws.claim || ws.public)); | ||
done(); | ||
}, 400); | ||
}); | ||
}); | ||
it('Should return ok when auth succeeds', async function(){ | ||
let { assert } = await base.post('login', 'some raw data'); | ||
assert.status.is(200); | ||
it('Should kick clients when stopping', function(done){ | ||
this.timeout(3e3); | ||
pubWS.on('open', () => { | ||
setTimeout(() => app.stop(), 2e3); | ||
}); | ||
pubWS.on('close', async () => { | ||
await app.start(); | ||
done(); | ||
}); | ||
}); | ||
it('Should notify all clients', function(done){ | ||
let c = 0, r = 0; | ||
let cfn = () => { | ||
c == 1 && | ||
backend.publish('joe:ws:event', '{"broadcast":true,"data":"foobar"}'); | ||
c++; | ||
}; | ||
let rfn = msg => { | ||
assert.strictEqual(msg, '"foobar"'); | ||
r == 1 && done(); | ||
r++; | ||
} | ||
pubWS.on('open', cfn); | ||
logWS.on('open', cfn); | ||
pubWS.on('message', rfn); | ||
logWS.on('message', rfn); | ||
}); | ||
describe('POST /logout', function(){ | ||
it('Should notify only public clients', function(done){ | ||
let c = 0; | ||
let cfn = () => { | ||
c == 1 && | ||
backend.publish('joe:ws:event', '{"public":true,"data":"foobar"}'); | ||
c++; | ||
}; | ||
pubWS.on('open', cfn); | ||
logWS.on('open', cfn); | ||
pubWS.on('message', () => done()); | ||
logWS.on('message', () => done(new Error('Logged client received public message'))); | ||
}); | ||
it('Should force expire an active session', async function(){ | ||
await app.restart({ session: { timeout: '1d' }, cookie: { secure: false } }); | ||
let { cookies } = await base.post('login', 'some raw data'); | ||
let { headers } = await base.post('logout', { cookies }); | ||
assert(headers['set-cookie'][0].indexOf('01 Jan 1970') > 0); | ||
it('Should notify only targeted clients', function(done){ | ||
let c = 0; | ||
let cfn = () => { | ||
c == 2 && | ||
backend.publish('joe:ws:event', '{"target":"' + logClaim + '","data":"foobar"}'); | ||
c++; | ||
}; | ||
pubWS.on('open', cfn); | ||
logWS.on('open', cfn); | ||
logWS2.on('open', cfn); | ||
logWS.on('message', () => setTimeout(done, 400)); | ||
pubWS.on('message', () => done(new Error('Logged client received public message'))); | ||
logWS2.on('message', () => done(new Error('Logged client received public message'))); | ||
}); | ||
it('Should notify only targeted group', function(done){ | ||
let c = 0; | ||
let cfn = () => { | ||
c == 2 && | ||
backend.publish('joe:ws:event', '{"members":"' + logClaim + '","data":"foobar"}'); | ||
c++; | ||
}; | ||
pubWS.on('open', cfn); | ||
logWS.on('open', cfn); | ||
logWS2.on('open', cfn); | ||
logWS.on('message', () => done()); | ||
pubWS.on('message', () => done(new Error('Logged client received public message'))); | ||
logWS2.on('message', () => done(new Error('Logged client received public message'))); | ||
}); | ||
it('Should notify only targeted group with exception', function(done){ | ||
let c = 0; | ||
let cfn = () => { | ||
c == 2 && | ||
backend.publish('joe:ws:event', `{"except":["${logClaim}"], "members":"${logClaim}","data":"foobar"}`); | ||
c++; | ||
}; | ||
pubWS.on('open', cfn); | ||
logWS.on('open', cfn); | ||
logWS2.on('open', cfn); | ||
logWS.on('message', () => done(new Error('Logged client received public message'))); | ||
pubWS.on('message', () => done(new Error('Logged client received public message'))); | ||
logWS2.on('message', () => done(new Error('Logged client received public message'))); | ||
setTimeout(done, 400); | ||
}); | ||
it('Should notify only targeted clients except within group', function(done){ | ||
let c = 0; | ||
let cfn = () => { | ||
c == 2 && | ||
backend.publish('joe:ws:event', `{"target":["${logClaim}"], "notMembers":"${logClaim}","data":"foobar"}`); | ||
c++; | ||
}; | ||
pubWS.on('open', cfn); | ||
logWS.on('open', cfn); | ||
logWS2.on('open', cfn); | ||
logWS.on('message', () => done(new Error('Logged client received public message'))); | ||
pubWS.on('message', () => done(new Error('Logged client received public message'))); | ||
logWS2.on('message', () => done(new Error('Logged client received public message'))); | ||
setTimeout(done, 400); | ||
}); | ||
}); | ||
describe('ACME', function(){ | ||
const redis = require('async-redis'); | ||
let redisClient; | ||
process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = 0; | ||
before(async function(){ | ||
redisClient = redis.createClient({ host: REDIS_HOST }); | ||
await new Promise( (done, fail) => { | ||
redisClient.on('connect', done); | ||
redisClient.on('error', fail); | ||
redisClient.on('end', fail); | ||
}); | ||
}); | ||
after(function(){ | ||
redisClient.quit(); | ||
}); | ||
it('Should handle SSL when ACME is setup', async function(){ | ||
this.timeout(6e3); | ||
await redisClient.del('joe:privateKey'); | ||
await redisClient.del('joe:domains:' + ACME_GATEWAY); | ||
await app.restart({ acme: ACME_CONF }); | ||
let { assert } = await secBase.get('headers', { 'host': ACME_GATEWAY }); | ||
assert.status.is(200); | ||
// TODO actually validate if cert is OK? | ||
}); | ||
it('Should reuse SSl cert while still valid', async function(){ | ||
this.timeout(6e3); | ||
await redisClient.del('joe:domains:' + ACME_GATEWAY); | ||
await app.restart({ acme: ACME_CONF }); | ||
await secBase.get('headers', { 'host': ACME_GATEWAY }); | ||
let { assert } = await secBase.get('headers', { 'host': ACME_GATEWAY }); | ||
assert.status.is(200); | ||
// TODO actually validate if cert is OK? | ||
}); | ||
it('Should default to Self Signed cert in case of ACME error', async function(){ | ||
this.timeout(6e3); | ||
await redisClient.del('joe:privateKey'); | ||
await redisClient.del('joe:domains:' + ACME_GATEWAY); | ||
await app.restart({ acme: ACME_CONF }); | ||
// Passing HOST that is unreachable to Pebble | ||
let { assert } = await secBase.get('headers', { 'host': 'httpsonly' }); | ||
assert.status.is(200); | ||
// TODO actually validate if cert is OK? | ||
}); | ||
}); | ||
@@ -192,14 +414,14 @@ | ||
it('Should add setup header when proxying after auth [cookie.*][proxy.claimHeader]', async function(){ | ||
await app.restart({ proxy: { claimHeader: 'X-Foo' }, cookie: { secure: false } }); | ||
await app.restart({ proxy: { claimHeader: 'X-Foo' } }); | ||
let { cookies } = await base.post('login'); | ||
let { assert } = await base.get('httpbin/anything', { cookies }); | ||
assert.body.contains('"X-Foo":'); | ||
let { assert } = await base.get('backend/headers', { cookies }); | ||
assert.body.contains('"x-foo":'); | ||
}); | ||
it('Should expire auth data after the setup timeout [session.timeout]', async function(){ | ||
await app.restart({ session: { timeout: '1s' }, cookie: { secure: false } }); | ||
let { cookies } = await base.post('login', 'some raw data'); | ||
await app.restart({ session: { timeout: '1s' } }); | ||
let { cookies } = await base.post('login'); | ||
await new Promise(done => setTimeout(done, 1500)); | ||
let { body } = await base.get('httpbin/anything', { cookies }); | ||
assert(body.indexOf('X-Claim') < 0); | ||
let { body } = await base.get('backend/headers', { cookies }); | ||
assert(body.indexOf('x-claim') < 0); | ||
}); | ||
@@ -206,0 +428,0 @@ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 3 instances in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
45026
0
22
752
12
6
6
+ Addedacme-client@^4.1.2
+ Addedcookie@^0.4.1
+ Addedcookie-signature@^1.1.0
+ Addedtoml@^3.0.0
+ Addedws@^7.4.0
+ Addedacme-client@4.2.5(transitive)
+ Addedaxios@0.26.1(transitive)
+ Addedbacko2@1.0.2(transitive)
+ Addedbluebird@3.7.2(transitive)
+ Addedbusboy@0.3.1(transitive)
+ Addedconfort@0.2.1(transitive)
+ Addedcookie@0.4.2(transitive)
+ Addedcookie-signature@1.2.2(transitive)
+ Addeddebug@4.4.0(transitive)
+ Addeddicer@0.3.0(transitive)
+ Addedgolog@0.4.1(transitive)
+ Addednode-forge@1.3.1(transitive)
+ Addednodecaf@0.11.14(transitive)
+ Addednodecaf-run@0.1.2(transitive)
+ Addedstreamsearch@0.1.2(transitive)
+ Addedtoml@3.0.0(transitive)
+ Addeduuid@8.3.2(transitive)
- Removedaccepts@1.3.8(transitive)
- Removedarray-flatten@1.1.1(transitive)
- Removedbody-parser@1.20.3(transitive)
- Removedbusboy@1.6.0(transitive)
- Removedbytes@3.1.2(transitive)
- Removedcall-bind-apply-helpers@1.0.1(transitive)
- Removedcall-bound@1.0.3(transitive)
- Removedcompressible@2.0.18(transitive)
- Removedcompression@1.7.5(transitive)
- Removedconfort@0.1.4(transitive)
- Removedcontent-disposition@0.5.4(transitive)
- Removedcookie@0.7.10.7.2(transitive)
- Removedcookie-parser@1.4.7(transitive)
- Removedcookie-signature@1.0.6(transitive)
- Removedcore-util-is@1.0.3(transitive)
- Removeddebug@2.6.9(transitive)
- Removeddeepmerge@4.3.1(transitive)
- Removeddepd@1.1.22.0.0(transitive)
- Removeddestroy@1.2.0(transitive)
- Removeddunder-proto@1.0.1(transitive)
- Removedee-first@1.1.1(transitive)
- Removedencodeurl@1.0.22.0.0(transitive)
- Removedes-define-property@1.0.1(transitive)
- Removedes-errors@1.3.0(transitive)
- Removedes-object-atoms@1.0.0(transitive)
- Removedescape-html@1.0.3(transitive)
- Removedetag@1.8.1(transitive)
- Removedexpress@4.21.2(transitive)
- Removedexpress-fileupload@1.5.1(transitive)
- Removedfinalhandler@1.3.1(transitive)
- Removedforwarded@0.2.0(transitive)
- Removedfresh@0.5.2(transitive)
- Removedfunction-bind@1.1.2(transitive)
- Removedget-intrinsic@1.2.6(transitive)
- Removedgopd@1.2.0(transitive)
- Removedhas-symbols@1.1.0(transitive)
- Removedhasown@2.0.2(transitive)
- Removedhttp-errors@1.8.12.0.0(transitive)
- Removediconv-lite@0.4.24(transitive)
- Removedinherits@2.0.4(transitive)
- Removedipaddr.js@1.9.1(transitive)
- Removedisarray@1.0.0(transitive)
- Removedmath-intrinsics@1.1.0(transitive)
- Removedmedia-typer@0.3.0(transitive)
- Removedmerge-descriptors@1.0.3(transitive)
- Removedmethods@1.1.2(transitive)
- Removedmime@1.6.02.6.0(transitive)
- Removedmime-db@1.52.01.53.0(transitive)
- Removedmime-types@2.1.35(transitive)
- Removedms@2.0.0(transitive)
- Removednegotiator@0.6.30.6.4(transitive)
- Removednodecaf@0.9.5(transitive)
- Removednodecaf-run@0.0.2(transitive)
- Removedobject-inspect@1.13.3(transitive)
- Removedon-finished@2.4.1(transitive)
- Removedon-headers@1.0.2(transitive)
- Removedparseurl@1.3.3(transitive)
- Removedpath-to-regexp@0.1.12(transitive)
- Removedprocess-nextick-args@2.0.1(transitive)
- Removedproxy-addr@2.0.7(transitive)
- Removedqs@6.13.0(transitive)
- Removedrange-parser@1.2.1(transitive)
- Removedraw-body@2.5.2(transitive)
- Removedreadable-stream@2.3.8(transitive)
- Removedsafe-buffer@5.1.25.2.1(transitive)
- Removedsafer-buffer@2.1.2(transitive)
- Removedsend@0.19.0(transitive)
- Removedserve-static@1.16.2(transitive)
- Removedsetprototypeof@1.2.0(transitive)
- Removedside-channel@1.1.0(transitive)
- Removedside-channel-list@1.0.0(transitive)
- Removedside-channel-map@1.0.1(transitive)
- Removedside-channel-weakmap@1.0.2(transitive)
- Removedstatuses@1.5.02.0.1(transitive)
- Removedstdout-stream@1.4.1(transitive)
- Removedstreamsearch@1.1.0(transitive)
- Removedstring_decoder@1.1.1(transitive)
- Removedtoidentifier@1.0.1(transitive)
- Removedtype-is@1.6.18(transitive)
- Removedunpipe@1.0.0(transitive)
- Removedutil-deprecate@1.0.2(transitive)
- Removedutils-merge@1.0.1(transitive)
- Removeduuid@7.0.3(transitive)
Updatedhttp-proxy@^1.18.1
Updatedmuhb@^3.0.4
Updatednodecaf@^0.11.0
Updatednodecaf-run@^0.1.1
Updateduuid@^8.3.1