Finished unit test, refactored to use flaska instead of koa

This commit is contained in:
Jonatan Nilsson 2021-10-11 00:21:57 +00:00
parent 2b53b48a5d
commit 11c1133696
19 changed files with 422 additions and 211 deletions

View file

@ -1,11 +1,18 @@
export class HttpError extends Error {
constructor(message, status = 500) {
super(message)
this.status = status
}
}
export async function errorMiddleware(ctx, next) {
try {
await next()
} catch (e) {
ctx.status = 422
ctx.status = e.status || 500
ctx.body = {
status: 422,
status: ctx.status,
message: e.message,
}
}

View file

@ -2,17 +2,24 @@ import crypto from 'crypto'
import * as base64UrlSafe from './base64urlsafe.mjs'
import defaults from '../defaults.mjs'
export default function encode(header, orgBody, privateKeyPassword = null, disableAutofill = false) {
let body = defaults(orgBody)
export default function encode(orgHeader, orgBody, privateKeyPassword = null, disableAutofill = false) {
if (
typeof header !== 'object' ||
Array.isArray(header) ||
typeof body !== 'object' ||
Array.isArray(body)
typeof orgHeader !== 'object' ||
typeof orgBody !== 'object'
) {
throw new Error('both header and body should be of type object')
}
let header = defaults(orgHeader)
let body = defaults(orgBody)
if (!header.alg) {
header.alg = 'HS256'
}
if (!header.typ) {
header.typ = 'JWT'
}
let hmacAlgo = null
switch (header.alg) {
case 'HS256':

View file

@ -1,13 +1,22 @@
import { verifyToken } from './security.mjs'
import { uploadFile } from './multer.mjs'
import * as security from './security.mjs'
import * as multer from './multer.mjs'
export async function upload(ctx) {
let site = await verifyToken(ctx)
export default class MediaRoutes {
constructor(opts = {}) {
Object.assign(this, {
security: opts.security || security,
multer: opts.multer || multer,
})
}
let result = await uploadFile(ctx, site)
async upload(ctx) {
let site = await this.security.verifyToken(ctx)
ctx.body = {
filename: result.filename,
path: `/${site}/${result.filename}`
let result = await this.multer.uploadFile(ctx, site)
ctx.body = {
filename: result.filename,
path: `/${site}/${result.filename}`
}
}
}

View file

@ -1,13 +1,18 @@
// import * as jwt from '../jwt.mjs'
import { HttpError } from '../error.mjs'
import decode from '../jwt/decode.mjs'
import config from '../config.mjs'
export function verifyToken(ctx) {
if (!ctx.query.token) {
throw new Error('Token is missing in query')
let token = ctx.query.get('token')
if (!token) {
throw new HttpError('Token is missing in query', 422)
}
let decoded = decode(ctx.query.token, config.get('sites'), [])
return decoded.iss
try {
let decoded = decode(token, config.get('sites'), [])
return decoded.iss
} catch (err) {
ctx.log.error(err, 'Error decoding token: ' + token)
throw new HttpError('Token was invalid', 422)
}
}

View file

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

View file

@ -1,7 +1,14 @@
import { performance } from 'perf_hooks'
import Koa from 'koa-lite'
import { Flaska, QueryHandler } from 'flaska'
import TestRoutes from './test/routes.mjs'
import MediaRoutes from './media/routes.mjs'
import config from './config.mjs'
import log from './log.mjs'
/*
import router from './router.mjs'
import { errorMiddleware } from './error.mjs'
@ -15,4 +22,51 @@ const server = app.listen(config.get('server:port'), function(a,b) {
log.info(`Server listening at ${config.get('server:port')}`)
})
export default server
export default server */
const app = new Flaska({
log: log,
})
app.before(function(ctx) {
ctx.__started = performance.now()
})
app.after(function(ctx) {
let ended = performance.now() - ctx.__started
let logger = ctx.log.info
if (ctx.status >= 400) {
logger = ctx.log.warn
}
logger({
path: ctx.url,
status: ctx.status,
ms: Math.round(ended),
}, 'Request finished')
})
app.onerror(function(err, ctx) {
if (err.status && err.status >= 400 && err.status < 500) {
ctx.log.warn(err.message)
} else {
ctx.log.error(err)
}
ctx.status = err.status || 500
ctx.body = {
status: ctx.status,
message: err.message,
}
})
const test = new TestRoutes()
app.get('/', test.static.bind(test))
app.get('/error', test.error.bind(test))
const media = new MediaRoutes()
app.post('/media', [QueryHandler()], media.upload.bind(media))
app.listen(config.get('server:port'), function(a,b) {
log.info(`Server listening at ${config.get('server:port')}`)
})
export default app

View file

@ -1,13 +1,19 @@
import config from '../config.mjs'
export async function testStatic(ctx) {
ctx.body = {
name: config.get('name'),
version: config.get('version'),
environment: config.get('NODE_ENV'),
export default class TestRoutes {
constructor(opts = {}) {
Object.assign(this, { })
}
static(ctx) {
ctx.body = {
name: config.get('name'),
version: config.get('version'),
environment: config.get('NODE_ENV'),
}
}
error(ctx) {
throw new Error('This is a test')
}
}
export async function testError(ctx) {
throw new Error('This is a test')
}

15
config/config.test.json Normal file
View file

@ -0,0 +1,15 @@
{
"bunyan": {
"name": "storage-upload-test",
"streams": [{
"stream": "process.stdout",
"level": "error"
}
]
},
"sites": {
"development": {
"default@HS256": "asdf1234"
}
}
}

View file

@ -6,7 +6,8 @@
"scripts": {
"dev": "nodemon index.js",
"start": "node --experimental-modules api/server.mjs",
"test": "eltro test/**/*.test.mjs -r dot"
"test": "set NODE_ENV=test&& eltro test/**/*.test.mjs -r dot",
"test:linux": "NODE_ENV=test eltro test/**/*.test.mjs -r dot"
},
"repository": {
"type": "git",
@ -20,12 +21,11 @@
"homepage": "https://github.com/nfp-projects/storage-upload#readme",
"dependencies": {
"bunyan-lite": "^1.1.1",
"koa-lite": "^2.10.1",
"koa-router": "^7.2.1",
"flaska": "^0.9.5",
"multer": "^1.3.0",
"nconf-lite": "^2.0.0"
},
"devDependencies": {
"eltro": "^1.1.0"
"eltro": "^1.2.2"
}
}

1
test.json Normal file
View file

@ -0,0 +1 @@
{"name":"storage-upload","hostname":"JonatanPC","pid":26596,"level":30,"path":"/","status":200,"ms":4,"msg":"Request finished","time":"2021-10-10T23:55:12.390Z","v":0}

View file

@ -1,4 +1,6 @@
import http from 'http'
import fs from 'fs/promises'
import path from 'path'
import { URL } from 'url'
import defaults from '../api/defaults.mjs'
import config from '../api/config.mjs'
@ -50,6 +52,7 @@ Client.prototype.customRequest = function(method = 'GET', path, body, options) {
if (output.status) {
let err = new Error(`Request failed [${output.status}]: ${output.message}`)
err.body = output
err.status = output.status
return reject(err)
}
resolve(output)
@ -59,8 +62,37 @@ Client.prototype.customRequest = function(method = 'GET', path, body, options) {
})
}
Client.prototype.get = function(path = '/') {
return this.customRequest('GET', path, null)
Client.prototype.get = function(url = '/') {
return this.customRequest('GET', url, null)
}
Client.prototype.upload = function(url, file, method = 'POST') {
return fs.readFile(file).then(data => {
const crlf = '\r\n'
const filename = path.basename(file)
const boundary = `---------${Math.random().toString(16)}`
const headers = [
`Content-Disposition: form-data; name="file"; filename="${filename}"` + crlf,
]
const multipartBody = Buffer.concat([
Buffer.from(
`${crlf}--${boundary}${crlf}` +
headers.join('') + crlf
),
data,
Buffer.from(
`${crlf}--${boundary}--`
),
])
return this.customRequest(method, url, multipartBody, {
timeout: 5000,
headers: {
'Content-Type': 'multipart/form-data; boundary=' + boundary,
'Content-Length': multipartBody.length,
},
})
})
}
/*

View file

@ -1,17 +1,38 @@
// import _ from 'lodash'
// import sinon from 'sinon'
import { stub } from 'eltro'
import Client from './helper.client.mjs'
import defaults from '../api/defaults.mjs'
import '../api/server.mjs'
import serv from '../api/server.mjs'
serv.log = {
log: stub(),
warn: stub(),
info: stub(),
error: stub(),
}
export const server = serv
export function createClient() {
return new Client()
}
export function resetLog() {
serv.log.log.reset()
serv.log.info.reset()
serv.log.warn.reset()
serv.log.error.reset()
}
export function createContext(opts) {
return defaults(opts, {
query: { },
query: new Map(),
req: { },
res: { },
log: {
log: stub(),
warn: stub(),
info: stub(),
error: stub(),
},
})
}

View file

@ -1,5 +1,6 @@
import { Eltro as t, assert} from 'eltro'
import encode from '../../api/jwt/encode.mjs'
import decode from '../../api/jwt/decode.mjs'
t.describe('encode', function() {
t.test('should faile with invalid header and body', function() {
@ -10,12 +11,18 @@ t.describe('encode', function() {
/both header and body should be of type object/
)
})
t.test('should faile with empty header and body', function() {
t.test('should faile with invalid alg type', function() {
assert.throws(
function() {
encode({}, {})
encode({ alg: 'asdf' }, {})
},
/Only alg HS256, HS384 and HS512 are supported/
)
})
t.test('should have default header options', function() {
const assertTest = '1234'
let token = encode(null, { iss: 'test', test: assertTest }, 'bla')
let decoded = decode(token, { 'test': { 'default@HS256': 'bla' } }, [])
assert.strictEqual(decoded.test, assertTest)
})
})

View file

@ -1,61 +0,0 @@
import { Eltro as t, assert} from 'eltro'
import fs from 'fs'
import { fileURLToPath } from 'url'
import path from 'path'
import createClient from '../helper.client.mjs'
import config from '../../api/config.mjs'
import jwt from '../../api/jwt.mjs'
let __dirname = path.dirname(fileURLToPath(import.meta.url))
console.log(__dirname)
t.describe('Media (API)', () => {
let testFile
let client = createClient()
t.before(() => {
config.set('sites', {
development: 'hello-world'
})
})
t.after(done => {
if (testFile) {
// path.resolve(path.join(__dirname, 'fixtures', file))
return fs.unlink( appRoot.resolve(`/public${testFile}`), done)
}
done()
})
t.describe('POST /media', function temp() {
this.timeout(10000)
t.test('should require authentication', async () => {
let err = await assert.isRejected(
client.sendFileAsync('/media',
appRoot.resolve('/test/media/test.png')))
assert.strictEqual(err.status, 422)
assert.match(err.message, /[Tt]oken/)
})
t.test('should upload file and create file', async () => {
let token = jwt.sign({ site: 'development' }, 'hello-world')
let data = await assert.isFulfilled(
client.sendFileAsync(
`/media?token=${token}`,
appRoot.resolve('/test/media/test.png')
)
)
assert.ok(data)
assert.ok(data.filename)
assert.ok(data.path)
testFile = data.path
})
})
})

94
test/media/api.test.mjs Normal file
View file

@ -0,0 +1,94 @@
import { Eltro as t, assert} from 'eltro'
import fs from 'fs'
import { fileURLToPath } from 'url'
import path from 'path'
import { server, resetLog } from '../helper.server.mjs'
import Client from '../helper.client.mjs'
import config from '../../api/config.mjs'
import encode from '../../api/jwt/encode.mjs'
let __dirname = path.dirname(fileURLToPath(import.meta.url))
function resolve(file) {
return path.resolve(path.join(__dirname, file))
}
t.describe('Media (API)', () => {
const client = new Client()
const secret = 'asdf1234'
let testFile
t.after(function(done) {
if (testFile) {
return fs.unlink(resolve(`../../public/${testFile}`), done)
}
done()
})
t.timeout(10000).describe('POST /media', function temp() {
t.test('should require authentication', async () => {
resetLog()
assert.strictEqual(server.log.error.callCount, 0)
assert.strictEqual(server.log.warn.callCount, 0)
let err = await assert.isRejected(
client.upload('/media',
resolve('test.png')
)
)
assert.strictEqual(err.status, 422)
assert.match(err.message, /[Tt]oken/)
assert.match(err.message, /[Mm]issing/)
assert.strictEqual(server.log.error.callCount, 0)
assert.strictEqual(server.log.warn.callCount, 2)
assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string')
assert.match(server.log.warn.firstCall[0], /[Tt]oken/)
assert.match(server.log.warn.firstCall[0], /[Mm]issing/)
})
t.test('should verify token correctly', async () => {
const assertToken = 'asdf.asdf.asdf'
resetLog()
assert.strictEqual(server.log.error.callCount, 0)
assert.strictEqual(server.log.warn.callCount, 0)
assert.strictEqual(server.log.info.callCount, 0)
let err = await assert.isRejected(
client.upload('/media?token=' + assertToken,
resolve('test.png')
)
)
assert.strictEqual(err.status, 422)
assert.match(err.message, /[Tt]oken/)
assert.match(err.message, /[Ii]nvalid/)
assert.strictEqual(server.log.error.callCount, 1)
assert.strictEqual(server.log.warn.callCount, 2)
assert.strictEqual(typeof(server.log.warn.firstCall[0]), 'string')
assert.match(server.log.warn.firstCall[0], /[Tt]oken/)
assert.match(server.log.warn.firstCall[0], /[Ii]nvalid/)
assert.ok(server.log.error.lastCall[0] instanceof Error)
assert.match(server.log.error.lastCall[1], new RegExp(assertToken))
})
t.test('should upload file and create file', async () => {
let token = encode(null, { iss: 'development' }, secret)
let data = await assert.isFulfilled(
client.upload(
`/media?token=${token}`,
resolve('test.png')
)
)
assert.ok(data)
assert.ok(data.filename)
assert.ok(data.path)
testFile = data.path
})
})
})

View file

@ -1,73 +0,0 @@
import assert from 'assert-extended'
import sinon from 'sinon'
import { createContext } from '../helper.server'
describe('Media (Routes)', () => {
const multer = require('../../api/media/multer')
const routes = require('../../api/media/routes')
const security = require('../../api/media/security')
const config = require('../../config')
let sandbox
let ctx
beforeEach(() => {
sandbox = sinon.sandbox.create()
ctx = createContext({
req: {
file: { },
},
})
})
afterEach(() => {
sandbox.restore()
})
describe('#upload', () => {
let stubVerifyToken
let stubUpload
beforeEach(() => {
stubVerifyToken = sandbox.stub(security, 'verifyToken')
stubUpload = sandbox.stub(multer, 'uploadFile').resolves({})
})
it('should call security correctly', async () => {
const assertError = new Error('temp')
stubVerifyToken.rejects(assertError)
let err = await assert.isRejected(routes.upload(ctx))
assert.ok(stubVerifyToken.called)
assert.strictEqual(err, assertError)
assert.strictEqual(stubVerifyToken.firstCall.args[0], ctx)
})
it('should call upload correctly', async () => {
const assertSiteName = 'benshapiro'
const assertError = new Error('hello')
stubVerifyToken.resolves(assertSiteName)
stubUpload.rejects(assertError)
let err = await assert.isRejected(routes.upload(ctx))
assert.ok(stubUpload.called)
assert.strictEqual(err, assertError)
assert.strictEqual(stubUpload.firstCall.args[0], ctx)
assert.strictEqual(stubUpload.firstCall.args[1], assertSiteName)
})
it('should otherwise set context status to 204 and file in result', async () => {
const assertFilename = 'asdfsafd'
const assertSite = 'mario'
stubVerifyToken.resolves(assertSite)
stubUpload.resolves({ filename: assertFilename })
await routes.upload(ctx)
assert.strictEqual(ctx.body.filename, assertFilename)
assert.strictEqual(ctx.body.path, `/${assertSite}/${assertFilename}`)
})
})
})

View file

@ -0,0 +1,64 @@
import { Eltro as t, assert, stub } from 'eltro'
import { createContext } from '../helper.server.mjs'
import MediaRoutes from '../../api/media/routes.mjs'
t.describe('#upload', () => {
const stubVerify = stub()
const stubUpload = stub()
const routes = new MediaRoutes({
security: { verifyToken: stubVerify },
multer: { uploadFile: stubUpload },
})
function reset() {
stubVerify.reset()
stubUpload.reset()
}
t.test('should call security correctly', async () => {
reset()
let ctx = createContext()
const assertError = new Error('temp')
stubVerify.rejects(assertError)
let err = await assert.isRejected(routes.upload(ctx))
assert.ok(stubVerify.called)
assert.strictEqual(err, assertError)
assert.strictEqual(stubVerify.firstCall[0], ctx)
})
t.test('should call upload correctly', async () => {
reset()
let ctx = createContext()
const assertSiteName = 'benshapiro'
const assertError = new Error('hello')
stubVerify.resolves(assertSiteName)
stubUpload.rejects(assertError)
let err = await assert.isRejected(routes.upload(ctx))
assert.ok(stubUpload.called)
assert.strictEqual(err, assertError)
assert.strictEqual(stubUpload.firstCall[0], ctx)
assert.strictEqual(stubUpload.firstCall[1], assertSiteName)
})
t.test('should otherwise set context status to 204 and file in result', async () => {
reset()
let ctx = createContext()
const assertFilename = 'asdfsafd'
const assertSite = 'mario'
stubVerify.resolves(assertSite)
stubUpload.resolves({ filename: assertFilename })
await routes.upload(ctx)
assert.strictEqual(ctx.body.filename, assertFilename)
assert.strictEqual(ctx.body.path, `/${assertSite}/${assertFilename}`)
})
})

View file

@ -2,6 +2,7 @@ import { Eltro as t, assert} from 'eltro'
import { createContext } from '../helper.server.mjs'
import { verifyToken } from '../../api/media/security.mjs'
import { HttpError } from '../../api/error.mjs'
import encode from '../../api/jwt/encode.mjs'
import config from '../../api/config.mjs'
@ -16,51 +17,73 @@ t.describe('#verifyToken()', function() {
t.test('should fail if query token is missing', function() {
let ctx = createContext({ })
delete ctx.query.token
ctx.query.delete('token')
assert.throws(function() { verifyToken(ctx) }, /[Mm]issing/)
assert.throws(function() { verifyToken(ctx) }, function(err) {
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 422)
assert.match(err.message, /[Qq]uery/)
assert.match(err.message, /[Tt]oken/)
return true
})
})
function assertInvalidToken(err) {
assert.ok(err instanceof HttpError)
assert.ok(err instanceof Error)
assert.strictEqual(err.status, 422)
assert.match(err.message, /[Ii]nvalid/)
assert.match(err.message, /[Tt]oken/)
return true
}
t.test('should fail if token is invalid', function() {
let ctx = createContext({ })
ctx.query.token = 'asdfasdgassdga'
ctx.query.set('token', 'asdfasdgassdga')
assert.throws(function() { verifyToken(ctx) })
assert.throws(function() { verifyToken(ctx) }, assertInvalidToken)
assert.ok(ctx.log.error.lastCall)
assert.match(ctx.log.error.lastCall[0].message, /3 dots/)
ctx.query.token = 'asdfasdgassdga.asdfasdg.sadfsadfas'
ctx.query.set('token', 'asdfasdgassdga.asdfasdg.sadfsadfas')
assert.throws(function() { verifyToken(ctx) }, /invalid/)
assert.throws(function() { verifyToken(ctx) }, assertInvalidToken)
assert.match(ctx.log.error.lastCall[0].message, /[Ii]nvalid/)
ctx.query.token = encode(
ctx.query.set('token', encode(
{ typ: 'JWT', alg: 'HS256' },
{ iss: 'justatest' },
'mysharedkey'
)
assert.throws(function() { verifyToken(ctx) }, /pubkey/)
))
assert.throws(function() { verifyToken(ctx) }, assertInvalidToken)
assert.match(ctx.log.error.lastCall[0].message, /pubkey/)
ctx.query.token = encode(
ctx.query.set('token', encode(
{ typ: 'JWT', alg: 'HS512' },
{ iss: 'notexist' },
'mysharedkey'
)
assert.throws(function() { verifyToken(ctx) }, /notexist/)
))
assert.throws(function() { verifyToken(ctx) }, assertInvalidToken)
assert.match(ctx.log.error.lastCall[0].message, /notexist/)
ctx.query.token = encode(
ctx.query.set('token', encode(
{ typ: 'JWT', alg: 'HS512' },
{ iss: 'justatest' },
'mysharedkey2'
)
assert.throws(function() { verifyToken(ctx) }, /HS512/)
assert.throws(function() { verifyToken(ctx) }, /[vV]erification/)
))
assert.throws(function() { verifyToken(ctx) }, assertInvalidToken)
assert.match(ctx.log.error.lastCall[0].message, /HS512/)
assert.match(ctx.log.error.lastCall[0].message, /[vV]erification/)
})
t.test('should otherwise return the issuer', function() {
let ctx = createContext({ })
ctx.query.token = encode(
ctx.query.set('token', encode(
{ typ: 'JWT', alg: 'HS512' },
{ iss: 'justatest' },
'mysharedkey'
)
))
let site = verifyToken(ctx)
assert.strictEqual(site, 'justatest')
})

View file

@ -1,28 +1,40 @@
import { Eltro as t, assert} from 'eltro'
import * as server from './helper.server.mjs'
import { createClient, server, resetLog } from './helper.server.mjs'
t.describe('Server', function() {
let client
t.before(function() {
client = server.createClient()
client = createClient()
})
t.test('should run', async function() {
resetLog()
assert.strictEqual(server.log.info.callCount, 0)
let data = await client.get('/')
assert.ok(data)
assert.ok(data.name)
assert.ok(data.version)
assert.strictEqual(server.log.info.callCount, 1)
assert.strictEqual(server.log.info.lastCall[0].status, 200)
assert.strictEqual(server.log.info.lastCall[0].path, '/')
assert.strictEqual(typeof(server.log.info.lastCall[0].ms), 'number')
})
t.test('should handle errors fine', async function() {
resetLog()
assert.strictEqual(server.log.warn.callCount, 0)
let data = await assert.isRejected(client.get('/error'))
assert.ok(data)
assert.ok(data.body)
assert.strictEqual(data.body.status, 422)
assert.strictEqual(data.body.status, 500)
assert.match(data.body.message, /test/)
assert.strictEqual(server.log.warn.callCount, 1)
assert.strictEqual(server.log.warn.lastCall[0].status, 500)
assert.strictEqual(server.log.warn.lastCall[0].path, '/error')
assert.strictEqual(typeof(server.log.warn.lastCall[0].ms), 'number')
})
})