major updates and test suite

This commit is contained in:
Dave Horton
2019-12-04 21:43:27 -05:00
parent 47bb642854
commit 0f06c44168
35 changed files with 1624 additions and 500 deletions
+12 -8
View File
@@ -27,18 +27,22 @@ function makeStrategy(logger) {
}
// found api key
logger.info(results, 'api key lookup');
const scope = [];
if (results[0].account_sid === null && results[0].service_provider_sid === null) {
scope.push.apply(scope, ['admin', 'service_provider', 'account']);
}
else if (results[0].service_provider_sid) {
scope.push.apply(scope, ['service_provider', 'account']);
}
else {
scope.push('account');
}
const user = {
account_sid: results[0].account_sid,
service_provider_sid: results[0].service_provider_sid,
isAdmin: results[0].account_sid === null && results[0].service_provider_sid === null,
isServiceProvider: results[0].service_provider_sid !== null,
isUser: results[0].account_sid != null
hasScope: (s) => scope.includes(s)
};
const scope = [];
if (user.isAdmin) scope.push('admin');
else if (user.isServiceProvider) scope.push('service_provider');
else scope.push('user');
logger.info(user, `successfully validated with scope ${scope}`);
return done(null, user, {scope});
});
-1
View File
@@ -6,7 +6,6 @@ pool.getConnection((err, conn) => {
if (err) return console.error(err, 'Error testing pool');
conn.ping((err) => {
if (err) return console.error(err, `Error pinging mysql at ${JSON.stringify(config.get('mysql'))}`);
console.log('successfully pinged mysql database');
});
});
+36
View File
@@ -0,0 +1,36 @@
const Model = require('./model');
class Account extends Model {
constructor() {
super();
}
}
Account.table = 'accounts';
Account.fields = [
{
name: 'account_sid',
type: 'string',
primaryKey: true
},
{
name: 'name',
type: 'string',
required: true
},
{
name: 'service_provider_sid',
type: 'string',
required: true
},
{
name: 'sip_realm',
type: 'string',
},
{
name: 'registration_hook',
type: 'string',
}
];
module.exports = Account;
+77
View File
@@ -0,0 +1,77 @@
const Model = require('./model');
const {getMysqlConnection} = require('../db');
const serviceProviderSql = `
SELECT * from ${this.table}
WHERE account_sid in (
SELECT account_sid from accounts
WHERE service_provider_sid = ?
)`;
class Application extends Model {
constructor() {
super();
}
/**
* retrieve all applications for an account
*/
static retrieveAllForAccount(account_sid) {
return new Promise((resolve, reject) => {
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(`SELECT * from ${this.table} WHERE account_sid = ?`, [account_sid], (err, results, fields) => {
conn.release();
if (err) return reject(err);
resolve(results);
});
});
});
}
/**
* retrieve all applications for a service provider
*/
static retrieveAllForServiceProvider(service_provider_sid) {
return new Promise((resolve, reject) => {
getMysqlConnection((err, conn) => {
if (err) return reject(err);
conn.query(serviceProviderSql, [service_provider_sid], (err, results, fields) => {
conn.release();
if (err) return reject(err);
resolve(results);
});
});
});
}
}
Application.table = 'applications';
Application.fields = [
{
name: 'application_sid',
type: 'string',
primaryKey: true
},
{
name: 'name',
type: 'string',
required: true
},
{
name: 'account_sid',
type: 'string',
required: true
},
{
name: 'call_hook',
type: 'string',
required: true
},
{
name: 'call_status_hook',
type: 'string',
required: true
}
];
module.exports = Application;
+2 -2
View File
@@ -30,11 +30,11 @@ class Model extends Emitter {
static make(obj) {
return new Promise((resolve, reject) => {
const pk = this.getPrimaryKey();
const uuid = uuidv4();
obj[pk.name] = uuid;
this.checkIsInsertable(obj);
getMysqlConnection((err, conn) => {
if (err) return reject(err);
const uuid = uuidv4();
obj[pk.name] = uuid;
conn.query(`INSERT into ${this.table} SET ?`,
obj,
(err, results, fields) => {
+36
View File
@@ -0,0 +1,36 @@
const Model = require('./model');
class PhoneNumber extends Model {
constructor() {
super();
}
}
PhoneNumber.table = 'phone_numbers';
PhoneNumber.fields = [
{
name: 'phone_number_sid',
type: 'string',
primaryKey: true
},
{
name: 'number',
type: 'string',
required: true
},
{
name: 'voip_carrier_sid',
type: 'string',
required: true
},
{
name: 'account_sid',
type: 'string',
},
{
name: 'application_sid',
type: 'string',
}
];
module.exports = PhoneNumber;
+27
View File
@@ -0,0 +1,27 @@
const Model = require('./model');
class VoipCarrier extends Model {
constructor() {
super();
}
}
VoipCarrier.table = 'voip_carriers';
VoipCarrier.fields = [
{
name: 'voip_carrier_sid',
type: 'string',
primaryKey: true
},
{
name: 'name',
type: 'string',
required: true
},
{
name: 'description',
type: 'string'
}
];
module.exports = VoipCarrier;
+29
View File
@@ -0,0 +1,29 @@
const router = require('express').Router();
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
const Account = require('../../models/account');
const ServiceProvider = require('../../models/service-provider');
const decorate = require('./decorate');
const preconditions = {
'add': validateAdd,
'update': validateUpdate,
'delete': validateDelete
};
async function validateAdd(req) {
/* check that service provider exists */
const result = await ServiceProvider.retrieve(req.body.service_provider_sid);
if (!result || result.length === 0) {
throw new DbErrorBadRequest(`service_provider not found for sid ${req.body.service_provider_sid}`);
}
}
async function validateUpdate(req, sid) {
if (req.body.service_provider_sid) throw new DbErrorBadRequest('service_provider_sid may not be modified')
}
async function validateDelete(req, sid) {
const assignedPhoneNumbers = await Account.getForeignKeyReferences('phone_numbers.account_sid', sid);
if (assignedPhoneNumbers > 0) throw new DbErrorUnprocessableRequest('cannot delete account with phone numbers');
}
decorate(router, Account, ['*'], preconditions);
module.exports = router;
+10 -21
View File
@@ -1,7 +1,6 @@
const router = require('express').Router();
const {DbErrorBadRequest} = require('../../utils/errors');
const ApiKey = require('../../models/api-key');
const {isAdmin, isServiceProvider, isUser} = require('../../utils/scopes');
const decorate = require('./decorate');
const uuidv4 = require('uuid/v4');
const assert = require('assert');
@@ -11,38 +10,28 @@ const preconditions = {
'delete': validateDeleteToken
};
/**
* if user scope, add to the associated account
* if admin scope, only admin-level tokens may be created
*/
function validateAddToken(req) {
if (isAdmin(req) && ('account_sid' in req.body)) {
throw new DbErrorBadRequest('admin users may not create account-level tokens');
if (req.user.hasScope('admin') && ('account_sid' in req.body)) {
// ok
}
else if (isServiceProvider(req) && (!('account_sid' in req.body) && !('service_provider_sid' in req.body))) {
req.body['service_provider_sid'] = req.user.service_provider_sid
else if (req.user.hasScope('service_provider') &&
(!('account_sid' in req.body) && !('service_provider_sid' in req.body))) {
req.body['service_provider_sid'] = req.user.service_provider_sid;
}
else if (isUser(req)) {
else if (req.user.hasScope('account') && !req.user.hasScope('service_provider')) {
delete req.body['service_provider_sid'];
req.body['account_sid'] = req.user.account_sid;
}
req.body.token = uuidv4();
}
/**
* admin users can only delete admin tokens or service provider tokens
* service_provider users can delete service provider or user tokens
* user-scope may only delete their own tokens
*/
async function validateDeleteToken(req, sid) {
const results = await ApiKey.retrieve(sid);
if (0 == results.length) return;
if (isAdmin(req)) {
if (results[0].account_sid) {
throw new DbErrorBadRequest('an admin user may not delete account level api keys');
}
if (req.user.hasScope('admin')) {
// can do anything
}
else if (isServiceProvider(req)) {
else if (req.user.hasScope('service_provider')) {
if (results[0].service_provider_sid === null && results[0].account_sid === null) {
throw new DbErrorBadRequest('a service provider user may not delete an admin token');
}
@@ -50,7 +39,7 @@ async function validateDeleteToken(req, sid) {
throw new DbErrorBadRequest('a service provider user may not delete api key from another service provider');
}
}
else if (isUser(req)) {
else {
if (results[0].account_sid !== req.user.account_sid) {
throw new DbErrorBadRequest('a user may not delete a token associated with a different account');
}
+54
View File
@@ -0,0 +1,54 @@
const router = require('express').Router();
const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/errors');
const Application = require('../../models/application');
const decorate = require('./decorate');
const sysError = require('./error');
const preconditions = {
'add': validateAdd,
'update': validateUpdate,
'delete': validateDelete
};
/* only user-level tokens can add applications */
async function validateAdd(req) {
if (req.user.account_sid) {
req.body.account_sid = req.user.account_sid;
}
}
async function validateUpdate(req, sid) {
if (req.user.account_sid && sid !== req.user.account_sid) {
throw new DbErrorBadRequest('you may not update or delete an application associated with a different account');
}
}
async function validateDelete(req, sid) {
if (req.user.account_sid && sid !== req.user.account_sid) {
throw new DbErrorBadRequest('you may not update or delete an application associated with a different account');
}
const assignedPhoneNumbers = await Application.getForeignKeyReferences('phone_numbers.application_sid', sid);
if (assignedPhoneNumbers > 0) throw new DbErrorUnprocessableRequest('cannot delete application with phone numbers');
}
decorate(router, Application, ['*'], preconditions);
/**
* if account-level privileges, retrieve only applications for that account
* ditto if service provider
*/
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
try {
let results;
if (req.user.account_sid) results = Application.retrieveAllForAccount(req.user.account_sid);
else if (req.user.service_provider_sid) {
results = Application.retrieveAllForServiceProvider(req.user.service_provider_sid);
}
else results = Application.Application.retrieveAll();
res.status(200).json(results);
} catch (err) {
sysError(logger, res, err);
}
});
module.exports = router;
+10 -2
View File
@@ -19,12 +19,16 @@ function decorate(router, klass, methods, preconditions) {
});
}
function list(router, klass) {
function list(router, klass, preconditions) {
router.get('/', async(req, res) => {
const logger = req.app.locals.logger;
//logger.info(`user: ${JSON.stringify(req.user)}`);
//logger.info(`scope: ${JSON.stringify(req.authInfo.scope)}`);
try {
if ('list' in preconditions) {
assert(typeof preconditions.list === 'function');
await preconditions.list(req);
}
const results = await klass.retrieveAll();
res.status(200).json(results);
} catch (err) {
@@ -63,11 +67,15 @@ function retrieve(router, klass) {
});
}
function update(router, klass) {
function update(router, klass, preconditions) {
router.put('/:sid', async(req, res) => {
const sid = req.params.sid;
const logger = req.app.locals.logger;
try {
if ('update' in preconditions) {
assert(typeof preconditions.update === 'function');
await preconditions.update(req, sid);
}
const rowsAffected = await klass.update(sid, req.body);
if (rowsAffected === 0) {
return res.status(404).end();
+3 -3
View File
@@ -2,15 +2,15 @@ const {DbErrorBadRequest, DbErrorUnprocessableRequest} = require('../../utils/er
function sysError(logger, res, err) {
if (err instanceof DbErrorBadRequest) {
logger.error(err, 'invalid client request');
logger.info(err, 'invalid client request');
return res.status(400).json({msg: err.message});
}
if (err instanceof DbErrorUnprocessableRequest) {
logger.error(err, 'unprocessable request');
logger.info(err, 'unprocessable request');
return res.status(422).json({msg: err.message});
}
if (err.message.includes('ER_DUP_ENTRY')) {
logger.error(err, 'duplicate entry on insert');
logger.info(err, 'duplicate entry on insert');
return res.status(422).json({msg: err.message});
}
logger.error(err, 'Database error');
+5 -2
View File
@@ -1,8 +1,7 @@
const api = require('express').Router();
const {isAdmin} = require('../../utils/scopes');
function isAdminScope(req, res, next) {
if (isAdmin(req)) return next();
if (req.user.hasScope('admin')) return next();
res.status(403).json({
status: 'fail',
message: 'insufficient privileges'
@@ -10,6 +9,10 @@ function isAdminScope(req, res, next) {
}
api.use('/ServiceProviders', isAdminScope, require('./service-providers'));
api.use('/VoipCarriers', isAdminScope, require('./voip-carriers'));
api.use('/PhoneNumbers', isAdminScope, require('./phone-numbers'));
api.use('/ApiKeys', require('./api-keys'));
api.use('/Accounts', require('./accounts'));
api.use('/Applications', require('./applications'));
module.exports = api;
+53
View File
@@ -0,0 +1,53 @@
const router = require('express').Router();
const {DbErrorUnprocessableRequest, DbErrorBadRequest} = require('../../utils/errors');
const PhoneNumber = require('../../models/phone-number');
const VoipCarrier = require('../../models/voip-carrier');
const decorate = require('./decorate');
const validateNumber = require('../../utils/phone-number-syntax');
const preconditions = {
'add': validateAdd,
'delete': checkInUse,
'update': validateUpdate
};
/* check for required fields when adding */
async function validateAdd(req) {
try {
if (!req.body.voip_carrier_sid) throw new DbErrorBadRequest('voip_carrier_sid is required');
if (!req.body.number) throw new DbErrorBadRequest('number is required');
validateNumber(req.body.number);
} catch (err) {
throw new DbErrorBadRequest(err.message);
}
/* check that voip carrier exists */
const result = await VoipCarrier.retrieve(req.body.voip_carrier_sid);
if (!result || result.length === 0) {
throw new DbErrorBadRequest(`voip_carrier not found for sid ${req.body.voip_carrier_sid}`);
}
}
/* can not delete a phone number if it in use */
async function checkInUse(req, sid) {
const phoneNumber = await PhoneNumber.retrieve(sid);
if (phoneNumber.account_sid) {
throw new DbErrorUnprocessableRequest('cannot delete phone number that is assigned to an account');
}
}
/* can not change number or voip carrier */
async function validateUpdate(req, sid) {
const result = await PhoneNumber.retrieve(sid);
if (req.body.voip_carrier_sid) throw new DbErrorBadRequest('voip_carrier_sid may not be modified');
if (req.body.number) throw new DbErrorBadRequest('number may not be modified');
// TODO: if we are assigning to an account, verify it exists
// TODO: if we are assigning to an application, verify it is associated to the same account
// TODO: if we are removing from an account, verify we are also removing from application.
}
decorate(router, PhoneNumber, ['*'], preconditions);
module.exports = router;
+17
View File
@@ -0,0 +1,17 @@
const router = require('express').Router();
const {DbErrorUnprocessableRequest} = require('../../utils/errors');
const VoipCarrier = require('../../models/voip-carrier');
const decorate = require('./decorate');
const preconditions = {
'delete': noActiveAccounts
};
/* can not delete a voip provider if it has any active phone numbers */
async function noActiveAccounts(req, sid) {
const activeAccounts = await VoipCarrier.getForeignKeyReferences('phone_numbers.voip_carrier_sid', sid);
if (activeAccounts > 0) throw new DbErrorUnprocessableRequest('cannot delete voip carrier with active phone numbers');
}
decorate(router, VoipCarrier, ['*'], preconditions);
module.exports = router;
+483 -349
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
function validate(number) {
if (typeof number !== 'string') throw new Error('phone number must be a string');
if (!/^\d+$/.test(number)) throw new Error('phone number must only include digits');
if (number.length < 8) throw new Error('invalid phone number: insufficient digits');
if (number[0] === '1' && number.length !== 11) throw new Error('invalid US phone number');
}
module.exports = validate;
-17
View File
@@ -1,17 +0,0 @@
function isAdmin(req) {
return req.authInfo.scope.includes('admin');
}
function isServiceProvider(req) {
return req.authInfo.scope.includes('service_provider');
}
function isUser(req) {
return req.authInfo.scope.includes('user');
}
module.exports = {
isAdmin,
isServiceProvider,
isUser
};