Huge update and cleanup on dependencies and test environment among other stuff

This commit is contained in:
Jonatan Nilsson 2021-07-03 13:56:53 +00:00
parent 8f70c1de67
commit dc462a3d53
32 changed files with 984 additions and 383 deletions

View file

@ -1,5 +0,0 @@
{
"plugins": [
"transform-es2015-modules-commonjs"
]
}

View file

@ -2,31 +2,26 @@ version: 2
jobs: jobs:
build: build:
docker: docker:
- image: docker:latest - image: circleci/node:latest
environment: environment:
- di: "nfpis/storage-upload" - di: "nfpis/storage-upload"
- dtag: "latest" - dtag: "latest"
working_directory: ~/storage-upload working_directory: ~/storage-upload
steps: steps:
- run:
name: Update and install SSH & Git
command: apk update && apk upgrade && apk add --no-cache bash git openssh
- checkout - checkout
- setup_remote_docker
- run: - run:
name: Build docker image name: Install dependencies
command: | command: |
docker build -t nfp-test . npm install
docker build --build-arg NODE=production -t ${di}:build_${CIRCLE_BUILD_NUM} -t ${di}:${CIRCLE_SHA1} -t ${di}:${dtag} .
- run: - run:
name: Run tests name: Run tests
command: | command: |
docker run nfp-test npm run test npm test
- deploy: # - deploy:
name: Push to docker # name: Push to docker
command: | # command: |
docker login -u $DOCKER_USER -p $DOCKER_PASS # docker login -u $DOCKER_USER -p $DOCKER_PASS
docker push ${di} # docker push ${di}
workflows: workflows:
version: 2 version: 2

View file

@ -1,6 +1,12 @@
'use strict' import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import Nconf from 'nconf-lite'
const nconf = new Nconf()
const __dirname = path.dirname(fileURLToPath(import.meta.url))
let pckg = JSON.parse(fs.readFileSync(path.resolve(path.join(__dirname, '../package.json'))))
const nconf = require('nconf')
// Helper method for global usage. // Helper method for global usage.
nconf.inTest = () => nconf.get('NODE_ENV') === 'test' nconf.inTest = () => nconf.get('NODE_ENV') === 'test'
@ -12,9 +18,6 @@ nconf.inTest = () => nconf.get('NODE_ENV') === 'test'
// 4. config/config.default.json // 4. config/config.default.json
// Load package.json for name and such
let pckg = require('./package.json')
pckg = { pckg = {
name: pckg.name, name: pckg.name,
version: pckg.version, version: pckg.version,
@ -34,22 +37,22 @@ nconf.env()
// Load any overrides from the appropriate config file // Load any overrides from the appropriate config file
let configFile = 'config/config.json' let configFile = '../config/config.json'
/* istanbul ignore else */ /* istanbul ignore else */
if (nconf.get('NODE_ENV') === 'test') { if (nconf.get('NODE_ENV') === 'test') {
configFile = 'config/config.test.json' configFile = '../config/config.test.json'
} }
/* istanbul ignore if */ /* istanbul ignore if */
if (nconf.get('NODE_ENV') === 'production') { if (nconf.get('NODE_ENV') === 'production') {
configFile = 'config/config.production.json' configFile = '../config/config.production.json'
} }
nconf.file('main', configFile) nconf.file('main', path.resolve(path.join(__dirname, configFile)))
// Load defaults // Load defaults
nconf.file('default', 'config/config.default.json') nconf.file('default', path.resolve(path.join(__dirname, '../config/config.default.json')))
// Final sanity checks // Final sanity checks
@ -61,4 +64,4 @@ if (typeof global.it === 'function' & !nconf.inTest()) {
} }
module.exports = nconf export default nconf

View file

@ -1,20 +0,0 @@
function defaults(options, def) {
options = options || {}
Object.keys(def).forEach(function(key) {
if (typeof options[key] === 'undefined') {
// No need to do clone since we mostly deal with
// flat objects
options[key] = def[key]
}
else if (typeof options[key] === 'object' &&
typeof def[key] === 'object') {
options[key] = defaults(options[key], def[key])
}
})
return options
}
export default defaults

34
api/defaults.mjs Normal file
View file

@ -0,0 +1,34 @@
// taken from isobject npm library
function isObject(val) {
return val != null && typeof val === 'object' && Array.isArray(val) === false
}
export default function defaults(options, def) {
let out = { }
if (options) {
Object.keys(options || {}).forEach(key => {
out[key] = options[key]
if (Array.isArray(out[key])) {
out[key] = out[key].map(item => {
if (isObject(item)) return defaults(item)
return item
})
} else if (out[key] && typeof out[key] === 'object') {
out[key] = defaults(options[key], def && def[key])
}
})
}
if (def) {
Object.keys(def).forEach(function(key) {
if (typeof out[key] === 'undefined') {
out[key] = def[key]
}
})
}
return out
}

View file

@ -1,19 +0,0 @@
import jwt from 'jsonwebtoken'
export function sign(value, secret) {
return jwt.sign(value, secret)
}
export function verify(token, secret) {
return new Promise((resolve, reject) =>
jwt.verify(token, secret, (err, res) => {
if (err) return reject(err)
resolve(res)
})
)
}
export function decode(token) {
return jwt.decode(token)
}

21
api/jwt/base64urlsafe.mjs Normal file
View file

@ -0,0 +1,21 @@
export function encode(buffer) {
return buffer
.toString('base64')
.replace(/\+/g, '-') // Convert '+' to '-'
.replace(/\//g, '_') // Convert '/' to '_'
.replace(/=+$/, '') // Remove ending '='
}
export function decode(base64StringUrlSafe) {
let base64String = base64StringUrlSafe.replace(/-/g, '+').replace(/_/g, '/')
switch (base64String.length % 4) {
case 2:
base64String += '=='
break
case 3:
base64String += '='
break
}
return Buffer.from(base64String, 'base64')
}

169
api/jwt/decode.mjs Normal file
View file

@ -0,0 +1,169 @@
import crypto from 'crypto'
import defaults from '../defaults.mjs'
import * as base64UrlSafe from './base64urlsafe.mjs'
const defaultOptions = {
expiresSkew: 0,
expiresMax: 0,
nbfIatSkew: 300,
fixup: null
}
/**
*
* @param {string} jwt
* @param {Object} publicKeys
* @param {Array<string>} audiences
* @param {Object} [options]
* @param {Object} [options.expiresSkew=0]
* @param {Object} [options.expiresMax=0]
* @param {Object} [options.nbfIatSkew=300]
* @param {Function<header,body,void>} [options.fixup]
*/
export default function decode(jwt, publicKeys, audiences, options = defaultOptions) {
if (typeof jwt !== 'string') {
throw new Error('jwt needs to a string')
}
if (typeof publicKeys !== 'object' || Array.isArray(publicKeys)) {
throw new Error(
'publicKeys needs to be a map of { issuer: { keyid: "PEM encoded key" }'
)
}
if (!Array.isArray(audiences)) {
throw new Error('audiences needs to be an array of allowed audiences')
}
if (typeof options !== 'object' || Array.isArray(publicKeys)) {
throw new Error('options needs to a map of { nbfIatSkew: 300, ... }')
}
let parts = jwt.split(/\./)
if (parts.length !== 3) {
throw new Error('JWT does not contain 3 dots')
}
let header = JSON.parse(base64UrlSafe.decode(parts[0]).toString('utf8'))
let body = JSON.parse(base64UrlSafe.decode(parts[1]).toString('utf8'))
if (typeof options.fixup === 'function') {
options.fixup(header, body)
}
let hmacAlgo = null
switch (header.alg) {
case 'HS256':
hmacAlgo = 'sha256'
break
case 'HS384':
hmacAlgo = 'sha384'
break
case 'HS512':
hmacAlgo = 'sha512'
break
default:
throw new Error(
'Only alg HS256, HS384 and HS512 are supported'
)
}
if (!body.iss) {
throw new Error('No issuer set')
}
let issuer = publicKeys[body.iss]
if (!issuer) {
throw new Error(`Unknown issuer '${body.iss}'`)
}
// Find public key
let pubkeyOrSharedKey =
typeof header.kid === 'string'
? issuer[`${header.kid}@${header.alg}`]
: issuer[`default@${header.alg}`]
let overrideOptions = {}
if (!pubkeyOrSharedKey) {
throw new Error(
`Unknown pubkey id '${header.kid}' for this issuer`
)
} else if ((typeof(pubkeyOrSharedKey) !== 'object' || Array.isArray(pubkeyOrSharedKey)) && typeof(pubkeyOrSharedKey) !== 'string') {
throw new Error(
`Pubkey of '${header.kid || 'default'}' for '${header.alg}' for this issuer is misconfigured`
)
}
if (typeof(pubkeyOrSharedKey) === 'object') {
if (typeof(pubkeyOrSharedKey.key) !== 'string') {
throw new Error(
`Pubkey of '${header.kid || 'default'}' for '${header.alg}' for this issuer is misconfigured`
)
}
overrideOptions = pubkeyOrSharedKey
pubkeyOrSharedKey = pubkeyOrSharedKey.key
}
let signatureOrHash = base64UrlSafe.decode(parts[2])
const hmac = crypto.createHmac(hmacAlgo, pubkeyOrSharedKey)
hmac.update(`${parts[0]}.${parts[1]}`, 'utf8')
let signatureBuffer = hmac.digest()
if (signatureOrHash.length !== signatureBuffer.length || !crypto.timingSafeEqual(signatureOrHash, signatureBuffer)) {
throw new Error(`Verification failed with alg '${header.alg}'`)
}
let unixNow = Math.floor(Date.now() / 1000)
let validators = defaults(options.validators, {
aud: validateAudience,
exp: validateExpires,
iat: validateIssuedAt,
nbf: validateNotBefore
})
let validationOptions = defaults(overrideOptions, options)
validators.aud(body, audiences, validationOptions)
validators.iat(body, unixNow, validationOptions)
validators.nbf(body, unixNow, validationOptions)
validators.exp(body, unixNow, validationOptions)
return body
}
function validateNotBefore(body, unixNow, options) {
if (body.nbf && body.nbf > unixNow + options.nbfIatSkew) {
throw new Error(
`Not before in the future by more than ${options.nbfIatSkew} seconds`
)
}
}
function validateIssuedAt(body, unixNow, options) {
if (body.iat && body.iat > unixNow + options.nbfIatSkew) {
throw new Error(
`Issued at in the future by more than ${options.nbfIatSkew} seconds`
)
}
}
function validateAudience(body, audiences, options) {
let auds = Array.isArray(body.aud) ? body.aud : [body.aud]
if (!auds.some(aud => audiences.includes(aud))) {
throw new Error(`Unknown audience '${auds.join(',')}'`)
}
}
function validateExpires(body, unixNow, options) {
if (!body.exp) {
throw new Error(`No expires set on token`)
}
let notBefore = body.iat || body.nbf || unixNow
if (options.expiresMax && body.exp > notBefore + options.expiresMax) {
throw new Error(
`Expires in the future by more than ${options.expiresMax} seconds`
)
}
if (body.exp + (options.expiresSkew || 0) <= unixNow) {
throw new Error('Token has expired')
}
}

43
api/jwt/encode.mjs Normal file
View file

@ -0,0 +1,43 @@
import crypto from 'crypto'
import * as base64UrlSafe from './base64urlsafe.mjs'
export default function encode(header, body, privateKeyPassword = null) {
if (
typeof header !== 'object' ||
Array.isArray(header) ||
typeof body !== 'object' ||
Array.isArray(body)
) {
throw new Error('both header and body should be of type object')
}
let hmacAlgo = null
switch (header.alg) {
case 'HS256':
hmacAlgo = 'sha256'
break
case 'HS384':
hmacAlgo = 'sha384'
break
case 'HS512':
hmacAlgo = 'sha512'
break
default:
throw new Error(
'Only alg HS256, HS384 and HS512 are supported'
)
}
// Base64 encode header and body
let headerBase64 = base64UrlSafe.encode(Buffer.from(JSON.stringify(header)))
let bodyBase64 = base64UrlSafe.encode(Buffer.from(JSON.stringify(body)))
let headerBodyBase64 = headerBase64 + '.' + bodyBase64
const hmac = crypto.createHmac(hmacAlgo, privateKeyPassword)
hmac.update(headerBodyBase64)
let signatureBuffer = hmac.digest()
// Construct final JWT
let signatureBase64 = base64UrlSafe.encode(signatureBuffer)
return headerBodyBase64 + '.' + signatureBase64
}

29
api/log.mjs Normal file
View file

@ -0,0 +1,29 @@
import bunyan from 'bunyan-lite'
import config from './config.mjs'
import * as defaults from './defaults.mjs'
// Clone the settings as we will be touching
// on them slightly.
let settings = defaults.default(config.get('bunyan'))
// Replace any instance of 'process.stdout' with the
// actual reference to the process.stdout.
for (let i = 0; i < settings.streams.length; i++) {
/* istanbul ignore else */
if (settings.streams[i].stream === 'process.stdout') {
settings.streams[i].stream = process.stdout
}
}
// Create our logger.
const log = bunyan.createLogger(settings)
export default log
log.logMiddleware = () =>
(ctx, next) => {
ctx.log = log
return next()
}

View file

@ -1,7 +1,6 @@
const fs = require('fs')
import multer from 'multer' import multer from 'multer'
const storage = multer.diskStorage({ /*const storage = multer.diskStorage({
destination: function (req, file, cb) { destination: function (req, file, cb) {
cb(null, '/tmp/my-uploads') cb(null, '/tmp/my-uploads')
}, },
@ -9,7 +8,7 @@ const storage = multer.diskStorage({
console.log(file) console.log(file)
cb(null, file.fieldname + '-' + Date.now()) cb(null, file.fieldname + '-' + Date.now())
} }
}) })*/
export function uploadFile(ctx, siteName) { export function uploadFile(ctx, siteName) {
return new Promise((res, rej) => { return new Promise((res, rej) => {

View file

@ -1,6 +1,5 @@
import config from '../../config' import { verifyToken } from './security.mjs'
import { verifyToken } from './security' import { uploadFile } from './multer.mjs'
import { uploadFile, rename } from './multer'
export async function upload(ctx) { export async function upload(ctx) {
let site = await verifyToken(ctx) let site = await verifyToken(ctx)

View file

@ -1,12 +1,12 @@
import * as jwt from '../jwt' // import * as jwt from '../jwt.mjs'
import config from '../../config' import config from '../config.mjs'
export async function verifyToken(ctx) { export async function verifyToken(ctx) {
if (!ctx.query.token) { if (!ctx.query.token) {
throw new Error('Token is missing in query') throw new Error('Token is missing in query')
} }
let decoded = jwt.decode(ctx.query.token) /*let decoded = jwt.decode(ctx.query.token)
if (!decoded || !decoded.site) { if (!decoded || !decoded.site) {
throw new Error('Token is invalid') throw new Error('Token is invalid')
@ -15,7 +15,7 @@ export async function verifyToken(ctx) {
let output = await jwt.verify( let output = await jwt.verify(
ctx.query.token, ctx.query.token,
config.get(`sites:${decoded.site}`) config.get(`sites:${decoded.site}`)
) )*/
return output.site return output.site
} }

View file

@ -1,11 +1,12 @@
import Router from 'koa-router' import Router from 'koa-router'
import * as test from './test/routes' import * as test from './test/routes.mjs'
import * as media from './media/routes' import * as media from './media/routes.mjs'
const router = new Router() const router = new Router()
router.get('/', test.testStatic) router.get('/', test.testStatic)
router.get('/error', test.testError)
router.post('/media', media.upload) router.post('/media', media.upload)
export default router export default router

18
api/server.mjs Normal file
View file

@ -0,0 +1,18 @@
import Koa from 'koa-lite'
import config from './config.mjs'
import log from './log.mjs'
import router from './router.mjs'
import { errorMiddleware } from './error.mjs'
const app = new Koa()
app.use(errorMiddleware)
app.use(router.routes())
app.use(router.allowedMethods())
const server = app.listen(config.get('server:port'), function(a,b) {
log.info(`Server listening at ${config.get('server:port')}`)
})
export default server

View file

@ -1,4 +1,4 @@
import config from '../../config' import config from '../config.mjs'
export async function testStatic(ctx) { export async function testStatic(ctx) {
ctx.body = { ctx.body = {
@ -7,3 +7,7 @@ export async function testStatic(ctx) {
environment: config.get('NODE_ENV'), environment: config.get('NODE_ENV'),
} }
} }
export async function testError(ctx) {
throw new Error('This is a test')
}

View file

@ -1,3 +0,0 @@
require('babel-register')
require('./server')

View file

@ -5,13 +5,8 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "nodemon index.js", "dev": "nodemon index.js",
"start": "node index.js", "start": "node --experimental-modules api/server.mjs",
"test": "env NODE_ENV=test mocha --require babel-register --recursive --reporter dot", "test": "eltro test/**/*.test.mjs -r dot"
"docker": "docker run -it --rm --name my-running-script -v \"$PWD\":/usr/src/app -w /usr/src/app node:alpine",
"install:test": "npm install --no-optional && npm run test",
"install:dev": "npm install --no-optional && npm run dev",
"docker:test": "npm run docker -- npm run install:test",
"docker:dev": "npm run docker -- npm run install:dev"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -24,20 +19,13 @@
}, },
"homepage": "https://github.com/nfp-projects/storage-upload#readme", "homepage": "https://github.com/nfp-projects/storage-upload#readme",
"dependencies": { "dependencies": {
"babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", "bunyan-lite": "^1.1.1",
"babel-register": "^6.26.0", "koa-lite": "^2.10.1",
"koa": "^2.3.0",
"multer": "^1.3.0",
"koa-router": "^7.2.1", "koa-router": "^7.2.1",
"jsonwebtoken": "^8.1.0", "multer": "^1.3.0",
"nconf": "^0.8.5" "nconf-lite": "^2.0.0"
}, },
"devDependencies": { "devDependencies": {
"app-root-path": "^2.0.1", "eltro": "^1.1.0"
"assert-extended": "^1.0.1",
"mocha": "^4.0.1",
"nodemon": "^1.12.1",
"request-json": "^0.6.2",
"sinon": "^4.1.3"
} }
} }

View file

@ -1,15 +0,0 @@
import Koa from 'koa'
import config from './config'
import router from './api/router'
import { errorMiddleware } from './api/error'
const app = new Koa()
app.use(errorMiddleware)
app.use(router.routes())
app.use(router.allowedMethods())
const server = app.listen(config.get('server:port'))
export default server

View file

@ -1,32 +0,0 @@
import assert from 'assert-extended'
import sinon from 'sinon'
describe('defaults', () => {
const defaults = require('../api/defaults').default
describe('#defaults()', () => {
it('should apply defaults to flat objects', () => {
let assertOutput = { a: 1 }
let output = defaults(null, { a: 1 })
assert.deepEqual(output, assertOutput)
})
it('should allow overriding defult properties', () => {
let assertOutput = { a: 2 }
let output = defaults(assertOutput, { a: 1 })
assert.deepEqual(output, assertOutput)
})
it('should allow nesting through objects', () => {
let def = { a: { b: 2 } }
let output = defaults({ a: { c: 3} }, def)
assert.deepEqual(output.a, {
b: 2,
c: 3,
})
})
})
})

31
test/defaults.test.mjs Normal file
View file

@ -0,0 +1,31 @@
import { Eltro as t, assert} from 'eltro'
import defaults from '../api/defaults.mjs'
t.describe('#defaults()', () => {
t.test('should apply defaults to flat objects', () => {
let assertOutput = { a: 1 }
let output = defaults(null, { a: 1 })
assert.deepStrictEqual(output, assertOutput)
output = defaults({ a: 1 })
assert.deepStrictEqual(output, assertOutput)
})
t.test('should allow overriding defult properties', () => {
let assertOutput = { a: 2 }
let output = defaults(assertOutput, { a: 1 })
assert.deepStrictEqual(output, assertOutput)
})
t.test('should allow nesting through objects', () => {
let def = { a: { b: 2 } }
let output = defaults({ a: { c: 3} }, def)
assert.deepStrictEqual(output.a, {
b: 2,
c: 3,
})
})
})

View file

@ -1,54 +0,0 @@
import assert from 'assert-extended'
import sinon from 'sinon'
import { createContext } from './helper.server'
describe('Error (Middleware)', () => {
const error = require('../api/error')
let ctx
beforeEach(() => {
ctx = createContext({ })
})
describe('#errorMiddleware()', () => {
let stub
beforeEach(() => {
stub = sinon.stub()
})
it('should call next and not do anything if success', async () => {
await error.errorMiddleware(ctx, stub)
assert.ok(stub.called)
assert.strictEqual(ctx.body, undefined)
assert.strictEqual(ctx.status, undefined)
})
it('should support stub throwing', async () => {
let assertError = new Error('testetytest')
stub.throws(assertError)
await error.errorMiddleware(ctx, stub)
assert.ok(ctx.body)
assert.strictEqual(ctx.status, 422)
assert.strictEqual(ctx.body.status, 422)
assert.strictEqual(ctx.body.message, assertError.message)
})
it('should support stub resolving false', async () => {
let assertError = new Error('herpaderpderp')
stub.rejects(assertError)
await error.errorMiddleware(ctx, stub)
assert.ok(ctx.body)
assert.strictEqual(ctx.status, 422)
assert.strictEqual(ctx.body.status, 422)
assert.strictEqual(ctx.body.message, assertError.message)
})
})
})

View file

@ -1,89 +0,0 @@
import request from 'request-json'
import defaults from '../api/defaults'
import config from '../config'
function parseBody(body, reject) {
try {
return JSON.parse(body)
} catch (error) {
// eslint-disable-next-line no-console
console.log(body)
return reject(error)
}
}
function callback(resolve, reject) {
return (err, res, rawBody) => {
let body = rawBody
if (err) {
return reject(err)
}
if (typeof body === 'string' && body) {
body = parseBody(body, reject)
}
if (res.statusCode >= 300 ||
res.statusCode < 200) {
return reject(body)
}
resolve(body)
}
}
export default function createClient(host = config.get('server:port'), opts) {
let options = defaults(opts, {})
let client = request.createClient('', options)
let prefix
prefix = `http://localhost:${host}`
client.headers['x-request-id'] = 'asdf'
client.auth = (user) => {
// let m = helperDB.model('user', {
// id: user.id,
// level: (user.get && user.get('level')) || 1,
// institute_id: (user.get && user.get('institute_id')) || null,
// password: (user.get && user.get('password')) || null,
// })
// let token = jwt.createUserToken(m)
// client.headers.authorization = `Bearer ${token}`
}
// Simple wrappers to wrap into promises
client.getAsync = (path) =>
new Promise((resolve, reject) => {
if (path.slice(0, 4) === 'http') {
return client.get(path, callback(resolve, reject))
}
client.get(prefix + path, callback(resolve, reject))
})
// Simple wrappers to wrap into promises
client.saveFileAsync = (path, destination) =>
new Promise((resolve, reject) => {
client.saveFile(prefix + path, destination, callback(resolve, reject, true))
})
client.postAsync = (path, data) =>
new Promise((resolve, reject) => {
client.post(prefix + path, data, callback(resolve, reject))
})
client.putAsync = (path, data) =>
new Promise((resolve, reject) => {
client.put(prefix + path, data, callback(resolve, reject))
})
client.deleteAsync = (path) =>
new Promise((resolve, reject) => {
client.del(prefix + path, callback(resolve, reject))
})
client.sendFileAsync = (path, files, data) =>
new Promise((resolve, reject) => {
client.sendFile(prefix + path, files, data || {}, callback(resolve, reject))
})
return client
}

123
test/helper.client.mjs Normal file
View file

@ -0,0 +1,123 @@
import http from 'http'
import { URL } from 'url'
import defaults from '../api/defaults.mjs'
import config from '../api/config.mjs'
export default function Client(port = config.get('server:port'), opts) {
this.options = defaults(opts, {})
this.prefix = `http://localhost:${port}`
}
Client.prototype.customRequest = function(method = 'GET', path, body, options) {
if (path.slice(0, 4) !== 'http') {
path = this.prefix + path
}
let urlObj = new URL(path)
return new Promise((resolve, reject) => {
const opts = defaults(defaults(options, {
method: method,
timeout: 500,
protocol: urlObj.protocol,
username: urlObj.username,
password: urlObj.password,
host: urlObj.hostname,
port: Number(urlObj.port),
path: urlObj.pathname + urlObj.search,
}))
const req = http.request(opts)
if (body) {
req.write(body)
}
req.on('error', reject)
req.on('timeout', function() { reject(new Error(`Request ${method} ${path} timed out`)) })
req.on('response', res => {
res.setEncoding('utf8')
let output = ''
res.on('data', function (chunk) {
output += chunk.toString()
})
res.on('end', function () {
try {
output = JSON.parse(output)
} catch (e) {
return reject(new Error(`${e.message} while decoding: ${output}`))
}
if (output.status) {
let err = new Error(`Request failed [${output.status}]: ${output.message}`)
err.body = output
return reject(err)
}
resolve(output)
})
})
req.end()
})
}
Client.prototype.get = function(path = '/') {
return this.customRequest('GET', path, null)
}
/*
export function createClient(host = config.get('server:port'), opts) {
let options = defaults(opts, {})
let prefix = `http://localhost:${host}`
options.headers['x-request-id'] = 'asdf'
client.auth = (user) => {
// let m = helperDB.model('user', {
// id: user.id,
// level: (user.get && user.get('level')) || 1,
// institute_id: (user.get && user.get('institute_id')) || null,
// password: (user.get && user.get('password')) || null,
// })
// let token = jwt.createUserToken(m)
// client.headers.authorization = `Bearer ${token}`
}
// Simple wrappers to wrap into promises
client.getAsync = (path) =>
new Promise((resolve, reject) => {
if (path.slice(0, 4) === 'http') {
return client.get(path, callback(resolve, reject))
}
client.get(prefix + path, callback(resolve, reject))
})
// Simple wrappers to wrap into promises
client.saveFileAsync = (path, destination) =>
new Promise((resolve, reject) => {
client.saveFile(prefix + path, destination, callback(resolve, reject, true))
})
client.postAsync = (path, data) =>
new Promise((resolve, reject) => {
client.post(prefix + path, data, callback(resolve, reject))
})
client.putAsync = (path, data) =>
new Promise((resolve, reject) => {
client.put(prefix + path, data, callback(resolve, reject))
})
client.deleteAsync = (path) =>
new Promise((resolve, reject) => {
client.del(prefix + path, callback(resolve, reject))
})
client.sendFileAsync = (path, files, data) =>
new Promise((resolve, reject) => {
client.sendFile(prefix + path, files, data || {}, callback(resolve, reject))
})
return client
}
*/

View file

@ -1,12 +1,12 @@
// import _ from 'lodash' // import _ from 'lodash'
// import sinon from 'sinon' // import sinon from 'sinon'
import server from '../server' import Client from './helper.client.mjs'
import client from './helper.client' import defaults from '../api/defaults.mjs'
import defaults from '../api/defaults' import '../api/server.mjs'
after(() => server.close()) export function createClient() {
return new Client()
export const createClient = client }
export function createContext(opts) { export function createContext(opts) {
return defaults(opts, { return defaults(opts, {

View file

@ -1,61 +0,0 @@
import assert from 'assert-extended'
import sinon from 'sinon'
describe('jwt', () => {
const jsonwebtoken = require('jsonwebtoken')
const jwt = require('../api/jwt')
describe('#sign', () => {
it('should call security correctly', () => {
let token = jwt.sign({ a: 1 }, 'asdf')
assert.ok(token)
let decoded = jsonwebtoken.decode(token)
assert.strictEqual(decoded.a, 1)
})
it('should support custom secret', done => {
const assertSecret = 'sdfagsda'
let token = jwt.sign({ a: 1 }, assertSecret)
jsonwebtoken.verify(token, assertSecret, done)
})
})
describe('#decode()', () => {
it('should decode correctly', () => {
let data = { a: 1, b: 2 }
let token = jwt.sign(data, 'asdf')
let decoded = jwt.decode(token)
assert.strictEqual(decoded.a, data.a)
assert.strictEqual(decoded.b, data.b)
})
})
describe('#verify', () => {
it('should verify correctly', () => {
const assertSecret = 'asdfasdf'
const assertResult = 23532
let token = jwt.sign({ a: assertResult }, assertSecret)
return assert.isFulfilled(jwt.verify(token, assertSecret))
.then(data => assert.strictEqual(data.a, assertResult))
})
it('should fail if secret does not match', () => {
const assertSecret = 'asdfasdf'
let token = jwt.sign({ a: 1 }, assertSecret)
return assert.isRejected(jwt.verify(token, assertSecret + 'a'))
.then(err => assert.match(err.message, /[Ss]ignature/))
})
it('should fail token has been mishandled', () => {
let token = jwt.sign({ a: 1 }, 'asdf')
return assert.isRejected(jwt.verify(token + 'a', 'asdf'))
.then(err => assert.match(err.message, /[Ss]ignature/))
})
})
})

72
test/jwt/decode.test.mjs Normal file
View file

@ -0,0 +1,72 @@
import { Eltro as t, assert} from 'eltro'
import decode from '../../api/jwt/decode.mjs'
const pubKeys = {}
const audiences = []
const testJwt =
'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjEifQ.eyJhdWQiOiJodHRwczovL2hvc3Qvb2F1dGgvdG9rZW4iLCJpc3MiOiJ0ZXN0QHRlc3QuY29tIiwiaWF0IjoxNTAzMzM1MTY5LCJleHAiOjE1MDMzMzU3NjksInNjb3BlIjpbImh0dHA6Ly9zdHVmZiIsImh0dHA6Ly9zdHVmZjIiXX0.zO278VV6NzwsvBrAIc15mOfwza-FkmLCV28NRXnrI550xw1S1145cS1UsZP5zXxcrk5f4oEgB91Jt6ble76yK5nU68fALUXtfe7xPUkhcOUIw92q_x_Iaaw4z6a71NtyishCfJlbmwkXXEq5YCVAvX3KNDtyPf_fQrAqjzsbgQc'
const testJwtWrongAlg =
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzEyOCJ9.eyJhdWQiOiJodHRwczovL2hvc3Qvb2F1dGgvdG9rZW4iLCJpc3MiOiJ0ZXN0QHRlc3QuY29tIiwiaWF0IjoxNTAzMzM2NzU5LCJleHAiOjE1MDMzMzczNTksInNjb3BlIjpbImh0dHA6Ly9zdHVmZiIsImh0dHA6Ly9zdHVmZjIiXX0.12co2gXwBxmZ2uLJecd26bfteCLBx7jgu_9rp2hhKAHWA4qFKm1HcQOZXqDvHkjflQDtNAQ1ZUUf3U8kntUUAmMOjhHx0BspC-xuaTFylZWqj--A2_w9e7JSk46TF_x3e_hZLB3rtyuSEAPMh_nOCsmM-4A2fnQx0Y5p-Bwbt0I'
t.describe('jwtUtils', function() {
t.describe('decode', function() {
t.test('invalid jwt input', function() {
assert.throws(
function() {
decode({}, pubKeys, audiences)
},
/jwt needs to a string/
)
})
t.test('invalid pubKeys input', function() {
assert.throws(
function() {
decode(testJwt, [], audiences)
},
/publicKeys needs to be a map of { issuer: { keyid: "PEM encoded key" }/
)
})
t.test('invalid audiences input', function() {
assert.throws(
function() {
decode(testJwt, pubKeys, '')
},
/audiences needs to be an array of allowed audiences/
)
})
t.test('invalid options input', function() {
assert.throws(
function() {
decode(testJwt, pubKeys, audiences, '')
},
/options needs to a map of { nbfIatSkew: 300, ... }/
)
})
t.test('too few spaces', function() {
assert.throws(
function() {
decode('hello.test', pubKeys, audiences)
},
/JWT does not contain 3 dots/
)
})
t.test('invalid json', function() {
assert.throws(
function() {
decode(testJwt.substr(10), pubKeys, audiences)
},
/Unexpected token \$ in JSON at position 0/
)
})
t.test('wrong alg', function() {
assert.throws(
function() {
decode(testJwtWrongAlg, pubKeys, audiences)
},
/Only alg HS256, HS384 and HS512 are supported/
)
})
})
})

21
test/jwt/encode.test.mjs Normal file
View file

@ -0,0 +1,21 @@
import { Eltro as t, assert} from 'eltro'
import encode from '../../api/jwt/encode.mjs'
t.describe('encode', function() {
t.test('should faile with invalid header and body', function() {
assert.throws(
function() {
encode('', '')
},
/both header and body should be of type object/
)
})
t.test('should faile with empty header and body', function() {
assert.throws(
function() {
encode({}, {})
},
/Only alg HS256, HS384 and HS512 are supported/
)
})
})

343
test/jwt/jwt.test.mjs Normal file
View file

@ -0,0 +1,343 @@
import { Eltro as t, assert} from 'eltro'
import encode from '../../api/jwt/encode.mjs'
import decode from '../../api/jwt/decode.mjs'
import defaults from '../../api/defaults.mjs'
const unixNow = Math.floor(Date.now() / 1000)
const jwtHeader = {
typ: 'JWT',
alg: 'HS256',
kid: '2'
}
const jwtBody = {
aud: 'https://host/oauth/token',
iss: 'test@test.com',
iat: unixNow,
exp: unixNow + 600,
scope: ['http://stuff', 'http://stuff2']
}
const pubKeys = {
'test@test.com': {
'default@HS256': 'sharedkey',
'2@HS256': 'sharedkey',
'2@HS384': 'sharedkey',
'2@HS512': 'sharedkey',
'5@HS256': 'wrongkey'
},
'test@custom.com': {
'2@HS256': {
key: 'sharedkey',
expiresSkew: 600,
expiresMax: 86400
},
'3@HS256': {
},
'4@HS256': [],
'5@HS256': { key: 'sharedkey' },
},
}
t.describe('encode/decode', function() {
['HS256', 'HS384', 'HS512'].forEach(function(algo) {
t.test('success with ' + algo, function() {
let customJwtHeader = defaults(jwtHeader)
customJwtHeader.kid = '2'
customJwtHeader.alg = algo
let jwt = encode(customJwtHeader, jwtBody, 'sharedkey')
let decodedJwtBody = decode(jwt, pubKeys, [
'https://host/oauth/token'
])
assert.deepStrictEqual(jwtBody, decodedJwtBody)
})
})
t.test('success without kid', function() {
let customJwtHeader = defaults(jwtHeader)
delete customJwtHeader.kid
let jwt = encode(customJwtHeader, jwtBody, 'sharedkey')
let decodedJwtBody = decode(jwt, pubKeys, [
'https://host/oauth/token'
])
assert.deepStrictEqual(jwtBody, decodedJwtBody)
})
t.test('success with object key', function() {
let customJwtBody = defaults(jwtBody)
customJwtBody.kid = '5'
customJwtBody.iss = 'test@custom.com'
let jwt = encode(jwtHeader, jwtBody, 'sharedkey')
let decodedJwtBody = decode(jwt, pubKeys, [
'https://host/oauth/token'
])
assert.deepStrictEqual(jwtBody, decodedJwtBody)
})
t.test('success with array aud', function() {
let customJwtBody = defaults(jwtBody)
customJwtBody.aud = [
'https://myhost/oauth/token',
'https://host/oauth/token'
]
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
let decodedJwtBody = decode(jwt, pubKeys, [
'https://host/oauth/token'
])
assert.deepStrictEqual(customJwtBody, decodedJwtBody)
})
t.test('success with expired token', function() {
let customJwtBody = defaults(jwtBody)
customJwtBody.iss = 'test@custom.com'
customJwtBody.exp -= 600
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
let decodedJwtBody = decode(jwt, pubKeys, [
'https://host/oauth/token'
])
assert.deepStrictEqual(customJwtBody, decodedJwtBody)
})
t.test('token outside maximum expires', function() {
let customJwtBody = defaults(jwtBody)
customJwtBody.iss = 'test@custom.com'
customJwtBody.exp += 172800
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/Expires in the future by more than 86400 seconds/
)
})
t.test('token outside maximum expires using decode options', function() {
let customJwtBody = defaults(jwtBody)
customJwtBody.exp += 172800
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'], {
expiresMax: 600
})
},
/Expires in the future by more than 600 seconds/
)
})
t.test('token outside maximum expires using nbf', function() {
let customJwtBody = defaults(jwtBody)
customJwtBody.exp += 172800
customJwtBody.nbf = customJwtBody.iat
delete customJwtBody.iat
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'], {
expiresMax: 600
})
},
/Expires in the future by more than 600 seconds/
)
})
t.test('token outside maximum expires using unixNow', function() {
let customJwtBody = defaults(jwtBody)
customJwtBody.exp += 172800
delete customJwtBody.iat
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'], {
expiresMax: 600
})
},
/Expires in the future by more than 600 seconds/
)
})
t.test('unknown aud', function() {
let jwt = encode(jwtHeader, jwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://myhost/oauth/token'])
},
/Unknown audience 'https:\/\/host\/oauth\/token'/
)
})
t.test('expired', function() {
let customJwtBody = defaults(jwtBody)
customJwtBody.iat -= 1200
customJwtBody.exp -= 800
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/Token has expired/
)
})
t.test('missing exp', function() {
let customJwtBody = defaults(jwtBody)
delete customJwtBody.exp
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/No expires set on token/
)
})
t.test('missing iss', function() {
let customJwtBody = defaults(jwtBody)
delete customJwtBody.iss
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/No issuer set/
)
})
t.test('iat invalid', function() {
let customJwtBody = defaults(jwtBody)
customJwtBody.iat += 1200
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/Issued at in the future by more than 300 seconds/
)
})
t.test('nbf invalid', function() {
let customJwtBody = defaults(jwtBody)
customJwtBody.nbf = customJwtBody.iat + 1200
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/Not before in the future by more than 300 seconds/
)
})
t.test('unknown issuer', function() {
let customJwtBody = defaults(jwtBody)
customJwtBody.iss = 'unknown@test.com'
let jwt = encode(jwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/Unknown issuer 'unknown@test.com'/
)
})
t.test('wrong alg', function() {
let customJwtHeader = defaults(jwtHeader)
customJwtHeader.alg = 'HS128'
assert.throws(
function() {
encode(customJwtHeader, jwtBody, 'sharedkey')
},
/Only alg HS256, HS384 and HS512 are supported/
)
})
t.test('unknown kid', function() {
let customJwtHeader = defaults(jwtHeader)
customJwtHeader.kid = '3'
let jwt = encode(customJwtHeader, jwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/Unknown pubkey id '3' for this issuer/
)
})
t.test('invalid signature', function() {
let customJwtHeader = defaults(jwtHeader)
customJwtHeader.kid = '2'
let jwt = encode(customJwtHeader, jwtBody, 'sharedkey')
let backup = jwt
if (jwt[jwt.length - 2] === 'A') {
jwt = jwt.slice(0, -2) + 'BB'
} else {
jwt = jwt.slice(0, -2) + 'AA'
}
try {
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/Verification failed with alg 'HS256'/
)
} catch (err) {
console.log('-----')
console.log(backup)
console.log(jwt)
throw err
}
jwt = encode(customJwtHeader, jwtBody, 'sharedkey')
jwt += 'a'
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/Verification failed with alg 'HS256'/
)
})
t.test('invalid shared key', function() {
let customJwtHeader = defaults(jwtHeader)
customJwtHeader.kid = '5'
customJwtHeader.alg = 'HS256'
let jwt = encode(customJwtHeader, jwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/Verification failed with alg 'HS256'/
)
})
t.test('invalid pubkey', function() {
let customJwtHeader = defaults(jwtHeader)
customJwtHeader.kid = '4'
let jwt = encode(customJwtHeader, jwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
}
)
let customJwtBody = defaults(jwtBody)
customJwtBody.iss = 'test@custom.com'
customJwtHeader.kid = '3'
jwt = encode(customJwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/'3'.+misconfigured/
)
customJwtHeader.kid = '4'
jwt = encode(customJwtHeader, customJwtBody, 'sharedkey')
assert.throws(
function() {
decode(jwt, pubKeys, ['https://host/oauth/token'])
},
/'4'.+misconfigured/
)
})
t.test('success with broken token', function() {
let expectedJwtBody = {
id: 1,
exp: 1519802991,
iat: 1519802691,
iss: 'test@test.com',
aud: 'https://host/oauth/token'
}
let decodedJwtBody = decode(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwiaWF0IjoxNTE5ODAyNjkxfQ.p6t378Ri2JpOCm9WtC36ttyH8ILzG9-OWT_kgMrrRfo',
pubKeys,
['https://host/oauth/token'],
{
fixup: (header, body) => {
header.kid = '2'
body.iss = 'test@test.com'
body.aud = 'https://host/oauth/token'
body.exp = body.iat + 300
},
expiresSkew: 307584000
}
)
assert.deepStrictEqual(decodedJwtBody, expectedJwtBody)
})
})

View file

@ -1,22 +0,0 @@
import assert from 'assert-extended'
import * as server from './helper.server'
describe('Server', () => {
let client
beforeEach(() => {
client = server.createClient()
})
it('should run', () =>
assert.isFulfilled(
client.getAsync('/')
)
.then(data => {
assert.ok(data)
assert.ok(data.name)
assert.ok(data.version)
})
)
})

28
test/server.test.mjs Normal file
View file

@ -0,0 +1,28 @@
import { Eltro as t, assert} from 'eltro'
import * as server from './helper.server.mjs'
t.describe('Server', function() {
let client
t.before(function() {
client = server.createClient()
})
t.test('should run', async function() {
let data = await client.get('/')
assert.ok(data)
assert.ok(data.name)
assert.ok(data.version)
})
t.test('should handle errors fine', async function() {
let data = await assert.isRejected(client.get('/error'))
assert.ok(data)
assert.ok(data.body)
assert.strictEqual(data.body.status, 422)
assert.match(data.body.message, /test/)
})
})