diff --git a/app/routes/invite.js b/app/routes/invite.js new file mode 100644 index 0000000..a03dbec --- /dev/null +++ b/app/routes/invite.js @@ -0,0 +1,84 @@ +const express = require('express'); +const router = express.Router(); +const crypto = require('crypto'); + +const ModelPath = '../models/'; +const Invite = require(ModelPath + 'Invite.js'); +const User = require(ModelPath + 'User.js'); + +const wrap = require('../util/wrap.js'); +const requireAuth = require('../util/requireAuth'); +const verifyScope = require('../util/verifyScope'); +const verifyBody = require('../util/verifyBody'); + + +const updateInviteCount = async (req, next) => + User.updateOne({username: req.username}, {$inc: {inviteCount: 1}}).catch(next); + +const verifyUserHasScope = userScope => + scope => verifyScope(userScope, scope); + +const createParams = [{name: 'scope', instance: Array}]; +router.post('/create', requireAuth('invite.create'), verifyBody(createParams), wrap(async (req, res, next) => { + const scope = req.body.scope; + const hasPermission = scope.every(verifyUserHasScope(req.scope)); + if (!hasPermission) + return res.status(403).json({message: 'Requested scope exceeds own scope.'}); + + const invite = { + code: crypto.randomBytes(12).toString('hex'), + scope: scope, + issuer: req.username, + issued: Date.now(), + expires: req.body.expires + }; + + await Promise.all([ + Invite.create(invite).catch(next), + updateInviteCount(req, next) + ]); + + res.status(200).json({ + message: 'Invite created.', + code: invite.code + }); +})); + +const deleteParams = [{name: 'code', type: 'string'}]; +router.post('/delete', requireAuth('invite.delete'), verifyBody(deleteParams), wrap(async (req, res, next) => { + let query = {code: req.body.code}; + + // Users need a permission to delete invites other than their own + if (!verifyScope(req.scope, 'invite.delete.others')) + query.issuer = req.username; + + // Find the invite + const invite = await Invite.findOne(query).catch(next); + if (!invite) + return res.status(404).json({message: 'Invite not found.'}); + + // Users need a permission to delete invites that have been used + if (!verifyScope(req.scope, 'invite.delete.used') && invite.used != null && invite.recipient != null) + return res.status(403).json({message: 'Forbidden to delete used invites.'}); + + await Invite.deleteOne({_id: invite._id}).catch(next); + res.status(200).json({message: 'Invite deleted.'}); +})); + +const getParams = [{name: 'code', type: 'string', optional: true}]; +router.get('/get', requireAuth('invite.get'), verifyBody(getParams), wrap(async (req, res, next) => { + let query = {}; + + // Users need a permission to list invites other than their own + if (!verifyScope(req.scope, 'invite.get.others')) + query.issuer = req.username; + + // Narrow down the query by code if specified + if (req.body.code) + query.code = req.body.code; + + const invites = await Invite.find(query).catch(next); + res.status(200).json(invites); +})); + +module.exports = router; \ No newline at end of file diff --git a/app/routes/invites.js b/app/routes/invites.js deleted file mode 100644 index 55b1583..0000000 --- a/app/routes/invites.js +++ /dev/null @@ -1,78 +0,0 @@ -var express = require('express'); -var router = express.Router(); - -var Invite = require('../models/Invite.js'); - -var requireScope = function (perm) { - return function(req, res, next) { - User.findOne({username: req.session.passport.user}, function(err, user) { - if (err) throw err; - if (user.scope.indexOf(perm) === -1) - res.status(400).json({'message': 'No permission.'}); - else - next(); - }); - } -}; - -router.post('/create', function (req, res) { - if (!req.body.scope) { - res.status(400).json({'message': 'Bad request.'}); - return; - } - - var scope; - try { - scope = JSON.parse(req.body.scope); - } catch (e) { - res.status(500).json({'message': e.name + ': ' + e.message}); - return; - } - - var expiry = req.body.exp; - if (!expiry || expiry < Date.now()) - expiry = 0; - - var entry = { - code: crypto.randomBytes(12).toString('hex'), - scope: scope, - issuer: req.session.passport.user, - issued: Date.now(), - exp: expiry - }; - - Invite.create(entry, function (err) { - if (err) { - throw err; - } else { - res.status(200).json({ - code: entry.code, - scope: entry.scope - }); - } - }) -}); - -router.get('/get', function (req, res, next) { - var query = {issuer: req.session.passport.user}; - - if (req.body.code) - query.code = req.body.code; - - Invite.find(query, function (err, invites) { - if (err) { - next(err); - } else { - res.status(200).json(invites); - } - }) -}); - -router.post('/delete', function (req, res, next) { - Invite.deleteOne({code: req.body.code}, function (err) { - if (err) next(err); - else res.status(200).json({'message': 'Successfully deleted.'}); - }); -}); - -module.exports = router; \ No newline at end of file diff --git a/test/api.js b/test/api.js index 4c1e2b1..865de69 100644 --- a/test/api.js +++ b/test/api.js @@ -65,7 +65,7 @@ describe('Authentication', function() { async function verifyRejectedInvite(invite, message) { const user = {displayname: 'user', password: 'pass', invite: 'code'}; if (invite) { - await util.createInvite(invite, agent); + await util.insertInvite(invite, agent); user.invite = invite.code; } @@ -196,15 +196,12 @@ describe('Uploading', () => { beforeEach(async () => util.clearDatabase()); describe('/POST upload', () => { - async function verifySuccessfulUpload(file, username) { + async function verifySuccessfulUpload(file, key) { // Get file stats beforehand const [fileHash, fileSize] = await Promise.all([util.fileHash(file), util.fileSize(file)]); - // Get the user stats beforehand - const userBefore = await User.findOne({username: username}, {_id: 0, uploadCount: 1, uploadSize: 1}); - // Submit the upload and verify the result - const res = await util.upload(file, agent); + const res = await util.upload(file, agent, key); res.should.have.status(200); res.body.should.be.a('object'); res.body.should.have.property('url'); @@ -221,21 +218,38 @@ describe('Uploading', () => { uploadHash.should.equal(fileHash); uploadSize.should.equal(fileSize); + return fileSize; + } + + async function verifySuccessfulUserUpload(file, username) { + // Get the user's stats beforehand + const userBefore = await User.findOne({username: username}, {_id: 0, uploadCount: 1, uploadSize: 1}); + + const fileSize = await verifySuccessfulUpload(file); + // Verify the user's stats have been updated correctly const userAfter = await User.findOne({username: username}, {_id: 0, uploadCount: 1, uploadSize: 1}); userAfter.uploadCount.should.equal(userBefore.uploadCount + 1); userAfter.uploadSize.should.equal(userBefore.uploadSize + fileSize); } - async function verifySuccessfulKeyUpload(key, file) { + async function verifySuccessfulKeyUpload(file, key) { + // Get the key's stats beforehand + const keyBefore = await Key.findOne({key: key}, {_id: 0, uploadCount: 1, uploadSize: 1}); + const fileSize = await verifySuccessfulUpload(file, key); + + // Verify the key's stats have been updated correctly + const keyAfter = await Key.findOne({key: key}, {_id: 0, uploadCount: 1, uploadSize: 1}); + keyAfter.uploadCount.should.equal(keyBefore.uploadCount + 1); + keyAfter.uploadSize.should.equal(keyBefore.uploadSize + fileSize); } - async function verifyFailedUpload(file, status, message) { + async function verifyFailedUpload(file, status, message, key) { const fileCountBefore = await util.directoryFileCount(config.get('Upload.path')); const uploadCountBefore = await Upload.countDocuments({}); - const res = await util.upload(file, agent); + const res = await util.upload(file, agent, key); res.should.have.status(status); res.body.should.be.a('object'); res.body.should.have.property('message').equal(message); @@ -248,28 +262,43 @@ describe('Uploading', () => { } describe('0 Valid Request', () => { - it('SHOULD accept logged in valid upload', async () => { + it('SHOULD accept an upload from a valid session', async () => { await Promise.all([ util.createTestSession(agent), util.createTestFile(2048, 'test.bin') ]); - await verifySuccessfulUpload('test.bin', 'user'); + await verifySuccessfulUserUpload('test.bin', 'user'); return Promise.all([ util.logout(agent), util.deleteFile('test.bin') ]); }); + + it('SHOULD accept an upload from a valid api key', async () => { + await Promise.all([ + util.createTestKey(['file.upload']), + util.createTestFile(2048, 'test.bin') + ]); + + await verifySuccessfulKeyUpload('test.bin', 'key'); + + return util.deleteFile('test.bin'); + }) }); describe('1 Invalid Authentication', () => { - it('SHOULD NOT accept an unauthenticated request', async () => - verifyFailedUpload(null, 401, 'Unauthorized.') - ); + it('SHOULD NOT accept an unauthenticated request', async () => { + await util.createTestFile(2048, 'test.bin'); - it('SHOULD NOT accept a request without file.upload scope', async () => { - await util.createInvite({code: 'code', scope: [], issuer: 'Mocha'}); + await verifyFailedUpload(null, 401, 'Unauthorized.'); + + return util.deleteFile('test.bin'); + }); + + it('SHOULD NOT accept a session request without file.upload scope', async () => { + await util.insertInvite({code: 'code', scope: [], issuer: 'Mocha'}); await util.registerUser({displayname: 'user', password: 'pass', invite: 'code'}, agent); await util.login({displayname: 'user', password: 'pass'}, agent); @@ -282,6 +311,17 @@ describe('Uploading', () => { util.deleteFile('test.bin') ]); }); + + it('SHOULD NOT accept a key request without file.upload scope', async () => { + await Promise.all([ + util.createTestKey([]), + util.createTestFile(2048, 'test.bin') + ]); + + await verifyFailedUpload('test.bin', 403, 'Forbidden.', 'key'); + + return util.deleteFile('test.bin'); + }) }); describe('3 Invalid File', () => { @@ -306,9 +346,222 @@ describe('Uploading', () => { await verifyFailedUpload(null, 400, 'No file specified.'); return util.logout(agent); - }) + }); }) }); }); +describe('Invites', () => { + beforeEach(async () => util.clearDatabase()); + + async function verifyCreatedInvite(invite) { + const res = await util.createInvite(invite, agent); + util.verifyResponse(res, 200, 'Invite created.'); + res.body.should.have.property('code').match(/^[A-Fa-f0-9]+$/); + + const dbInvite = await Invite.findOne({code: res.body.code}); + dbInvite.should.not.equal(null); + dbInvite.scope.should.deep.equal(invite.scope); + dbInvite.issuer.should.equal('user'); + } + + async function verifyDeletedInvite(code) { + const res = await util.deleteInvite(code, agent); + util.verifyResponse(res, 200, 'Invite deleted.'); + + const inviteCount = await Invite.countDocuments({code: code}); + inviteCount.should.equal(0, 'The invite should have been removed from the database'); + } + + async function verifyInviteSearch(codes) { + const res = await util.getInvites({}, agent); + res.should.have.status(200); + res.body.should.be.a('Array'); + + codes.sort(); + const resCodes = res.body.map(invite => invite.code).sort(); + + resCodes.should.deep.equal(codes, 'All invites should be present in result'); + } + + async function verifySingleSearch(code) { + const res = await util.getInvites({code: code}, agent); + res.should.have.status(200); + res.body.should.be.a('Array'); + res.body.should.have.length(1); + res.body[0].code.should.equal(code); + } + + describe('/POST create', () => { + describe('0 Valid Request', () => { + it('SHOULD create an invite with valid scope from a valid session', async () => { + await util.createSession(agent, ['invite.create', 'file.upload']); + return verifyCreatedInvite({scope: ['file.upload']}); + }); + }); + + describe('1 Invalid Scope', () => { + it('SHOULD NOT create in invite without invite.create scope', async () => { + await util.createSession(agent, ['file.upload']); + const res = await util.createInvite({scope: ['file.upload']}, agent); + util.verifyResponse(res, 403, 'Forbidden.'); + }); + + it('SHOULD NOT create an invite with a scope exceeding the requesters', async () => { + await util.createSession(agent, ['invite.create', 'file.upload']); + const res = await util.createInvite({scope: ['user.ban']}, agent); + util.verifyResponse(res, 403, 'Requested scope exceeds own scope.'); + }); + }); + + describe('2 Malformed Request', () => { + it('SHOULD return an error when scope is not specified.', async () => { + await util.createSession(agent, ['invite.create']); + const res = await util.createInvite(null, agent); + util.verifyResponse(res, 400, 'scope not specified.'); + }); + + it('SHOULD return an error when scope is not an array', async () => { + await util.createSession(agent, ['invite.create']); + const res = await util.createInvite({scope: {broken: 'object'}}, agent); + util.verifyResponse(res, 400, 'scope malformed.'); + }); + }) + }); + + describe('/POST delete', () => { + describe('0 Valid Request', () => { + it('SHOULD delete an invite with valid permission from a valid session', async () => { + await util.createSession(agent, ['invite.create', 'invite.delete', 'file.upload']); + const res = await util.createInvite({scope: ['file.upload']}, agent); + return verifyDeletedInvite(res.body.code); + }); + + it('SHOULD delete another users invite with invite.delete.others scope', async () => { + await util.createSession(agent, ['invite.create', 'file.upload'], 'alice'); + const invite = await util.createInvite({scope: ['file.upload']}, agent); + await util.logout(agent); + + await util.createSession(agent, ['invite.create', 'invite.delete', 'invite.delete.others'], 'eve'); + return verifyDeletedInvite(invite.body.code); + }); + + it('SHOULD delete a usedinvite with invite.delete.used scope', async () => { + await util.createSession(agent, ['invite.create', 'invite.delete', 'invite.delete.used', 'file.upload'], 'alice'); + const invite = await util.createInvite({scope: ['file.upload']}, agent); + await util.registerUser({displayname: 'bob', password: 'hunter2', invite: invite.body.code}, agent); + + return verifyDeletedInvite(invite.body.code); + }); + }); + + describe('1 Invalid Scope', () => { + it('SHOULD NOT delete an invite without invite.delete scope', async () => { + await util.createSession(agent, ['invite.create', 'file.upload']); + const invite = await util.createInvite({scope: ['file.upload']}, agent); + const res = await util.deleteInvite(invite.body.code, agent); + util.verifyResponse(res, 403, 'Forbidden.'); + }); + + it('SHOULD NOT delete another users invite without invite.delete.others scope', async () => { + await util.createSession(agent, ['invite.create', 'file.upload'], 'alice'); + const invite = await util.createInvite({scope: ['file.upload']}, agent); + await util.logout(agent); + + await util.createSession(agent, ['invite.create', 'invite.delete'], 'eve'); + const res = await util.deleteInvite(invite.body.code, agent); + util.verifyResponse(res, 404, 'Invite not found.'); + }); + + it('SHOULD NOT delete a used invite without invite.delete.used scope', async () => { + await util.createSession(agent, ['invite.create', 'invite.delete', 'file.upload'], 'alice'); + const invite = await util.createInvite({scope: ['file.upload']}, agent); + + await util.registerUser({displayname: 'bob', password: 'hunter2', invite: invite.body.code}, agent); + + const res = await util.deleteInvite(invite.body.code, agent); + util.verifyResponse(res, 403, 'Forbidden to delete used invites.'); + }); + }); + + describe('2 Invalid Code', () => { + it('SHOULD return an error when the invite is not found', async () => { + await util.createSession(agent, ['invite.delete']); + const res = await util.deleteInvite('bogus', agent); + util.verifyResponse(res, 404, 'Invite not found.'); + }); + }); + + describe('3 Malformed Request', () => { + it('SHOULD return an error when no code was specified', async () => { + await util.createSession(agent, ['invite.delete']); + const res = await util.deleteInvite(null, agent); + util.verifyResponse(res, 400, 'code not specified.'); + }); + + it('SHOULD return an error when the code is not a string', async () => { + await util.createSession(agent, ['invite.delete']); + const res = await util.deleteInvite({break: 'everything'}, agent); + util.verifyResponse(res, 400, 'code malformed.'); + }); + }); + }); + + describe('/POST get', () => { + describe('0 Valid Request', () => { + it('SHOULD get multiple invites from a valid session', async () => { + await util.createSession(agent, ['invite.create', 'invite.get', 'file.upload']); + const inv1 = await util.createInvite({scope: ['file.upload']}, agent); + const inv2 = await util.createInvite({scope: ['invite.create']}, agent); + + return verifyInviteSearch([inv1.body.code, inv2.body.code]); + }); + + it('SHOULD get a single invite from a valid session', async () => { + await util.createSession(agent, ['invite.create', 'invite.get', 'file.upload']); + const inv = await util.createInvite({scope: ['file.upload']}, agent); + + return verifySingleSearch(inv.body.code); + }); + + it('SHOULD get another users invite with invite.get.others scope', async () => { + await util.createSession(agent, ['invite.create', 'file.upload'], 'alice'); + const inv = await util.createInvite({scope: ['file.upload']}, agent); + await util.logout(agent); + + await util.createSession(agent, ['invite.get', 'invite.get.others'], 'eve'); + return verifySingleSearch(inv.body.code); + }); + }); + + describe('1 Invalid Scope', () => { + it('SHOULD NOT get invites without invite.get scope', async () => { + await util.createSession(agent, ['invite.create', 'file.upload']); + const res = await util.getInvites({code: 'bogus'}, agent); + util.verifyResponse(res, 403, 'Forbidden.'); + }); + + it('SHOULD NOT get another users invite without invite.get.others scope', async () => { + await util.createSession(agent, ['invite.create', 'file.upload'], 'alice'); + const invite = await util.createInvite({scope: ['file.upload']}, agent); + await util.logout(agent); + + await util.createSession(agent, ['invite.get'], 'eve'); + const res = await util.getInvites({code: invite.body.code}, agent); + res.should.have.status(200); + res.body.should.be.a('Array'); + res.body.should.have.length(0); + }); + }); + + describe('2 Malformed Request', () => { + it('SHOULD return an error when code is not a string', async () => { + await util.createSession(agent, ['invite.get']); + const res = await util.getInvites({code: {what: 'even'}}, agent); + util.verifyResponse(res, 400, 'code malformed.'); + }); + }); + }); +}); + after(() => server.close(() => process.exit(0))); diff --git a/test/testUtil.js b/test/testUtil.js index 7f10c19..42d8d4e 100644 --- a/test/testUtil.js +++ b/test/testUtil.js @@ -14,6 +14,19 @@ const crypto = require('crypto'); const fs = require('fs'); const fsPromises = fs.promises; +//---------------- RESPONSE VERIFICATION ----------------// + +exports.verifyResponse = (res, status, message) => { + res.should.have.status(status); + res.body.should.be.a('object'); + res.body.should.have.property('message').equal(message); +}; + +exports.verifyResponseObj = (res, status, obj) => { + res.should.have.status(status); + res.body.should.deep.equal(obj); +}; + //---------------- DATABASE UTIL ----------------// exports.clearDatabase = () => @@ -24,44 +37,44 @@ exports.clearDatabase = () => Upload.remove({}) ]); +exports.insertInvite = invite => + Invite.create(invite); + +exports.insertKey = key => + Key.create(key); + //---------------- API ROUTES ----------------// exports.login = (credentials, agent) => - agent - .post('/api/auth/login') + agent.post('/api/auth/login') .send(credentials); exports.logout = agent => - agent - .post('/api/auth/logout'); - -exports.createInvite = (invite) => - Invite.create(invite); + agent.post('/api/auth/logout'); exports.registerUser = (user, agent) => - agent - .post('/api/auth/register') + agent.post('/api/auth/register') .send(user); exports.whoami = (agent) => - agent - .get('/api/auth/whoami') + agent.get('/api/auth/whoami') .send(); //---------------- TEST ENTRY CREATION ----------------// exports.createTestInvite = () => - exports.createInvite({code: 'code', scope: ['file.upload'], issuer: 'Mocha'}); + exports.insertInvite({code: 'code', scope: ['file.upload'], issuer: 'Mocha'}); exports.createTestInvites = (n) => Promise.all( Array.from(new Array(n), (val, index) => 'code' + index) - .map(code => exports.createInvite({code: code, scope: ['file.upload'], issuer: 'Mocha'})) + .map(code => exports.insertInvite({code: code, scope: ['file.upload'], issuer: 'Mocha'})) ); exports.createTestUser = async agent => { await exports.createTestInvite(); - return exports.registerUser({displayname: 'user', password: 'pass', invite: 'code'}, agent); + exports.registerUser({displayname: 'user', password: 'pass', invite: 'code'}, agent); + await Invite.deleteOne({code: 'code'}); }; exports.createTestSession = async agent => { @@ -69,9 +82,19 @@ exports.createTestSession = async agent => { return exports.login({displayname: 'user', password: 'pass'}, agent); }; +exports.createSession = async (agent, scope, displayname) => { + await exports.insertInvite({code: 'code', scope: scope, issuer: 'Mocha'}); + await exports.registerUser({displayname: displayname ? displayname : 'user', password: 'pass', invite: 'code'}, agent); + await exports.login({displayname: displayname ? displayname : 'user', password: 'pass'}, agent); + await Invite.deleteOne({code: 'code'}); +}; + exports.createTestFile = (size, name) => fsPromises.writeFile(name, Buffer.allocUnsafe(size)); +exports.createTestKey = scope => + exports.insertKey({key: 'key', identifier: 'test', scope: scope, issuer: 'Mocha'}); + //---------------- FILESYSTEM ----------------// exports.deleteFile = file => @@ -97,7 +120,25 @@ exports.directoryFileCount = async dir => //---------------- UPLOADS ----------------// -exports.upload = (file, agent) => - agent - .post('/api/upload') - .attach('file', file); +exports.upload = (file, agent, key) => { + const request = agent.post('/api/upload'); + + if (key) + request.field('key', key); + + return request.attach('file', file); +}; + +//---------------- Invites ----------------// + +exports.createInvite = (invite, agent) => + agent.post('/api/invites/create') + .send(invite); + +exports.deleteInvite = (code, agent) => + agent.post('/api/invites/delete') + .send({code: code}); + +exports.getInvites = (query, agent) => + agent.get('/api/invites/get') + .send(query);