From b76869e2d237a9db5e821ede2a26a8a87fb7f053 Mon Sep 17 00:00:00 2001 From: Calvin Montgomery Date: Fri, 1 Sep 2017 21:20:07 -0700 Subject: [PATCH] Add some basic tests for implemented /account/data handlers --- src/web/routes/account/data.js | 36 +++- src/web/webserver.js | 5 +- test/web/routes/account/data.js | 287 ++++++++++++++++++++++++++++++++ 3 files changed, 316 insertions(+), 12 deletions(-) create mode 100644 test/web/routes/account/data.js diff --git a/src/web/routes/account/data.js b/src/web/routes/account/data.js index 384b95a6..924778e9 100644 --- a/src/web/routes/account/data.js +++ b/src/web/routes/account/data.js @@ -1,6 +1,6 @@ import { GET, POST, PATCH, DELETE } from '@calzoneman/express-babel-decorators'; import { CSRFError, InvalidRequestError } from '../../../errors'; -import { verify as csrfVerify } from '../../csrf'; +import Promise from 'bluebird'; const LOGGER = require('@calzoneman/jsli')('AccountDataRoute'); @@ -14,7 +14,7 @@ function checkAcceptsJSON(req, res) { return true; } -async function authorize(req, res) { +async function authorize(req, res, csrfVerify, verifySessionAsync) { if (!req.signedCookies || !req.signedCookies.auth) { res.status(401).json({ error: 'Authorization required' @@ -38,7 +38,23 @@ async function authorize(req, res) { return false; } - // TODO: verify session + try { + const user = await verifySessionAsync(req.signedCookies.auth); + + if (user.name !== req.params.user) { + res.status(403).json({ + error: 'Session username does not match' + }); + + return false; + } + } catch (error) { + res.status(403).json({ + error: error.message + }); + + return false; + } return true; } @@ -59,15 +75,17 @@ function reportError(req, res, error) { } class AccountDataRoute { - constructor(accountDB, channelDB) { + constructor(accountDB, channelDB, csrfVerify, verifySessionAsync) { this.accountDB = accountDB; this.channelDB = channelDB; + this.csrfVerify = csrfVerify; + this.verifySessionAsync = verifySessionAsync; } @GET('/account/data/:user') async getAccount(req, res) { if (!checkAcceptsJSON(req, res)) return; - if (!await authorize(req, res)) return; + if (!await authorize(req, res, this.csrfVerify, this.verifySessionAsync)) return; try { const user = await this.accountDB.getByName(req.params.user); @@ -94,7 +112,7 @@ class AccountDataRoute { @PATCH('/account/data/:user') async updateAccount(req, res) { if (!checkAcceptsJSON(req, res)) return; - if (!await authorize(req, res)) return; + if (!await authorize(req, res, this.csrfVerify, this.verifySessionAsync)) return; res.status(501).json({ error: 'Not implemented' }); } @@ -102,7 +120,7 @@ class AccountDataRoute { @GET('/account/data/:user/channels') async listChannels(req, res) { if (!checkAcceptsJSON(req, res)) return; - if (!await authorize(req, res)) return; + if (!await authorize(req, res, this.csrfVerify, this.verifySessionAsync)) return; try { const channels = await this.channelDB.listByOwner(req.params.user).map( @@ -124,7 +142,7 @@ class AccountDataRoute { @POST('/account/data/:user/channels/:name') async createChannel(req, res) { if (!checkAcceptsJSON(req, res)) return; - if (!await authorize(req, res)) return; + if (!await authorize(req, res, this.csrfVerify, this.verifySessionAsync)) return; res.status(501).json({ error: 'Not implemented' }); } @@ -132,7 +150,7 @@ class AccountDataRoute { @DELETE('/account/data/:user/channels/:name') async deleteChannel(req, res) { if (!checkAcceptsJSON(req, res)) return; - if (!await authorize(req, res)) return; + if (!await authorize(req, res, this.csrfVerify, this.verifySessionAsync)) return; res.status(501).json({ error: 'Not implemented' }); } diff --git a/src/web/webserver.js b/src/web/webserver.js index 90a20688..db3563f7 100644 --- a/src/web/webserver.js +++ b/src/web/webserver.js @@ -13,6 +13,7 @@ import { CSRFError, HTTPError } from '../errors'; import counters from '../counters'; import { Summary, Counter } from 'prom-client'; import session from '../session'; +import { verify as csrfVerify } from './csrf'; const verifySessionAsync = require('bluebird').promisify(session.verifySession); const LOGGER = require('@calzoneman/jsli')('webserver'); @@ -256,13 +257,11 @@ module.exports = { require('./routes/google_drive_userscript')(app); require('./routes/ustream_bypass')(app); - /* const { AccountDataRoute } = require('./routes/account/data'); require('@calzoneman/express-babel-decorators').bind( app, - new AccountDataRoute(accountDB, channelDB) + new AccountDataRoute(accountDB, channelDB, csrfVerify, verifySessionAsync) ); - */ app.use(serveStatic(path.join(__dirname, '..', '..', 'www'), { maxAge: webConfig.getCacheTTL() diff --git a/test/web/routes/account/data.js b/test/web/routes/account/data.js new file mode 100644 index 00000000..b6dbcf73 --- /dev/null +++ b/test/web/routes/account/data.js @@ -0,0 +1,287 @@ +const assert = require('assert'); +const sinon = require('sinon'); +const express = require('express'); +const { AccountDB } = require('../../../../lib/db/account'); +const { ChannelDB } = require('../../../../lib/db/channel'); +const { AccountDataRoute } = require('../../../../lib/web/routes/account/data'); +const http = require('http'); +const expressBabelDecorators = require('@calzoneman/express-babel-decorators'); +const nodeurl = require('url'); +const Promise = require('bluebird'); +const bodyParser = require('body-parser'); +const { CSRFError } = require('../../../../lib/errors'); + +const TEST_PORT = 10111; +const URL_BASE = `http://localhost:${TEST_PORT}`; + +function request(method, url, additionalOptions) { + if (!additionalOptions) additionalOptions = {}; + + return new Promise((resolve, reject) => { + const options = { + headers: { + 'Accept': 'application/json' + }, + method + }; + + Object.assign(options, nodeurl.parse(url), additionalOptions); + + const req = http.request(options); + + req.on('error', error => { + reject(error); + }); + + req.on('response', res => { + let buffer = ''; + res.setEncoding('utf8'); + + res.on('data', data => { + buffer += data; + + if (buffer.length > 100 * 1024) { + req.abort(); + reject(new Error('Response size exceeds 100KB')); + } + }); + + res.on('end', () => { + res.body = buffer; + resolve(res); + }); + }); + + req.end(); + }); +} + +describe('AccountDataRoute', () => { + let accountDB; + let channelDB; + let csrfVerify; + let verifySessionAsync; + let server; + let app; + let signedCookies; + let accountDataRoute; + + beforeEach(() => { + let realAccountDB = new AccountDB(); + let realChannelDB = new ChannelDB(); + accountDB = sinon.mock(realAccountDB); + channelDB = sinon.mock(realChannelDB); + csrfVerify = sinon.stub(); + verifySessionAsync = sinon.stub(); + verifySessionAsync.withArgs('test_auth_cookie').resolves({ name: 'test' }); + + signedCookies = { + auth: 'test_auth_cookie' + }; + app = express(); + app.use((req, res, next) => { + req.signedCookies = signedCookies; + next(); + }); + app.use(bodyParser.urlencoded({ + extended: false, + limit: '1kb' + })); + + accountDataRoute = new AccountDataRoute( + realAccountDB, + realChannelDB, + csrfVerify, + verifySessionAsync + ); + + expressBabelDecorators.bind(app, accountDataRoute); + + server = http.createServer(app); + server.listen(TEST_PORT); + }); + + afterEach(() => { + server.close(); + }); + + function checkDefaults(route, method) { + it('rejects requests that don\'t accept JSON', () => { + return request(method, `${URL_BASE}${route}`, { + headers: { 'Accept': 'text/plain' } + }).then(res => { + assert.strictEqual(res.statusCode, 406); + + assert.deepStrictEqual( + res.body, + 'Not Acceptable' + ); + }); + }); + + it('rejects requests with no auth cookie', () => { + signedCookies.auth = null; + + return request(method, `${URL_BASE}${route}`).then(res => { + assert.strictEqual(res.statusCode, 401); + + const response = JSON.parse(res.body); + + assert.deepStrictEqual( + response, + { error: 'Authorization required' } + ); + }); + }); + + it('rejects requests with invalid auth cookie', () => { + signedCookies.auth = 'invalid'; + verifySessionAsync.withArgs('invalid').rejects(new Error('Invalid')); + + return request(method, `${URL_BASE}${route}`).then(res => { + assert.strictEqual(res.statusCode, 403); + + const response = JSON.parse(res.body); + + assert.deepStrictEqual( + response, + { error: 'Invalid' } + ); + assert(verifySessionAsync.calledWith('invalid')); + }); + }); + + it('rejects requests with mismatched auth cookie', () => { + signedCookies.auth = 'mismatch'; + verifySessionAsync.withArgs('mismatch').resolves({ name: 'not_test' }); + + return request(method, `${URL_BASE}${route}`).then(res => { + assert.strictEqual(res.statusCode, 403); + + const response = JSON.parse(res.body); + + assert.deepStrictEqual( + response, + { error: 'Session username does not match' } + ); + assert(verifySessionAsync.calledWith('mismatch')); + }); + }); + + it('rejects requests with invalid CSRF tokens', () => { + csrfVerify.throws(new CSRFError('CSRF')); + + return request(method, `${URL_BASE}${route}`).then(res => { + assert.strictEqual(res.statusCode, 403); + + const response = JSON.parse(res.body); + + assert.deepStrictEqual( + response, + { error: 'Invalid CSRF token' } + ); + assert(csrfVerify.called); + }); + }); + + it('rejects requests with an internal CSRF handling error', () => { + csrfVerify.throws(new Error('broken')); + + return request(method, `${URL_BASE}${route}`).then(res => { + assert.strictEqual(res.statusCode, 503); + + const response = JSON.parse(res.body); + + assert.deepStrictEqual( + response, + { error: 'Internal error' } + ); + assert(csrfVerify.called); + }); + }); + } + + describe('#getAccount', () => { + it('serves a valid request', () => { + accountDB.expects('getByName').withArgs('test').returns({ + name: 'test', + email: 'test@example.com', + profile: { text: 'blah', image: 'image.jpeg' }, + time: new Date('2017-09-01T00:00:00.000Z'), + extraData: 'foo' + }); + + return request('GET', `${URL_BASE}/account/data/test`) + .then(res => { + assert.strictEqual(res.statusCode, 200); + + const response = JSON.parse(res.body); + + assert.deepStrictEqual( + response, + { + result: { + name: 'test', + email: 'test@example.com', + profile: { text: 'blah', image: 'image.jpeg' }, + time: '2017-09-01T00:00:00.000Z' + } + } + ); + assert(verifySessionAsync.calledWith(signedCookies.auth)); + assert(csrfVerify.called); + }); + }); + + checkDefaults('/account/data/test', 'GET'); + }); + + describe('#updateAccount', () => { + checkDefaults('/account/data/test', 'PATCH'); + }); + + describe('#createChannel', () => { + checkDefaults('/account/data/test/channels/test_channel', 'POST'); + }); + + describe('#deleteChannel', () => { + checkDefaults('/account/data/test/channels/test_channel', 'DELETE'); + }); + + describe('#listChannels', () => { + it('serves a valid request', () => { + channelDB.expects('listByOwner').withArgs('test').returns([{ + name: 'test_channel', + owner: 'test', + time: new Date('2017-09-01T00:00:00.000Z'), + last_loaded: new Date('2017-09-01T01:00:00.000Z'), + owner_last_seen: new Date('2017-09-01T02:00:00.000Z'), + extraData: 'foo' + }]); + + return request('GET', `${URL_BASE}/account/data/test/channels`) + .then(res => { + assert.strictEqual(res.statusCode, 200); + + const response = JSON.parse(res.body); + + assert.deepStrictEqual( + response, + { + result: [{ + name: 'test_channel', + owner: 'test', + time: '2017-09-01T00:00:00.000Z', + last_loaded: '2017-09-01T01:00:00.000Z', + owner_last_seen: '2017-09-01T02:00:00.000Z', + }] + } + ); + assert(verifySessionAsync.calledWith(signedCookies.auth)); + assert(csrfVerify.called); + }); + }); + + checkDefaults('/account/data/test/channels', 'GET'); + }); +});