From 0f06c44168dd66e8456faf2d743be1d9fc0886f1 Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Wed, 4 Dec 2019 21:43:27 -0500 Subject: [PATCH] major updates and test suite --- .travis.yml | 8 + app.js | 5 +- config/test.json | 12 + db/create-admin-token.sql | 2 + db/jambones-sql.sql | 39 +- db/jambones.sqs | 91 +--- lib/auth/index.js | 20 +- lib/db/mysql.js | 1 - lib/models/account.js | 36 ++ lib/models/application.js | 77 +++ lib/models/model.js | 4 +- lib/models/phone-number.js | 36 ++ lib/models/voip-carrier.js | 27 + lib/routes/api/accounts.js | 29 ++ lib/routes/api/api-keys.js | 31 +- lib/routes/api/applications.js | 54 ++ lib/routes/api/decorate.js | 12 +- lib/routes/api/error.js | 6 +- lib/routes/api/index.js | 7 +- lib/routes/api/phone-numbers.js | 53 ++ lib/routes/api/voip-carriers.js | 17 + lib/swagger/swagger.yaml | 832 ++++++++++++++++++------------- lib/utils/phone-number-syntax.js | 8 + lib/utils/scopes.js | 17 - package.json | 14 +- test/accounts.js | 99 ++++ test/applications.js | 109 ++++ test/docker-compose-testbed.yaml | 25 + test/docker-start.js | 62 +++ test/docker-stop.js | 11 + test/index.js | 7 + test/phone-numbers.js | 121 +++++ test/service-providers.js | 79 +++ test/utils.js | 64 +++ test/voip-carriers.js | 109 ++++ 35 files changed, 1624 insertions(+), 500 deletions(-) create mode 100644 .travis.yml create mode 100644 config/test.json create mode 100644 db/create-admin-token.sql create mode 100644 lib/models/account.js create mode 100644 lib/models/application.js create mode 100644 lib/models/phone-number.js create mode 100644 lib/models/voip-carrier.js create mode 100644 lib/routes/api/accounts.js create mode 100644 lib/routes/api/applications.js create mode 100644 lib/routes/api/phone-numbers.js create mode 100644 lib/routes/api/voip-carriers.js create mode 100644 lib/utils/phone-number-syntax.js delete mode 100644 lib/utils/scopes.js create mode 100644 test/accounts.js create mode 100644 test/applications.js create mode 100644 test/docker-compose-testbed.yaml create mode 100644 test/docker-start.js create mode 100644 test/docker-stop.js create mode 100644 test/index.js create mode 100644 test/phone-numbers.js create mode 100644 test/service-providers.js create mode 100644 test/utils.js create mode 100644 test/voip-carriers.js diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..956789d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +sudo: required +language: node_js +node_js: + - "lts/*" +services: + - docker +script: + - npm test diff --git a/app.js b/app.js index f002fb5..d1a76a9 100644 --- a/app.js +++ b/app.js @@ -20,6 +20,9 @@ app.use(bodyParser.json()); app.use('/v1', passport.authenticate('bearer', { session: false })); app.use('/', routes); app.use((err, req, res, next) => { - req.status(500).json({msg: err.message}); + logger.error(err, 'burped error'); + res.status(err.status || 500).json({msg: err.message}); }); app.listen(PORT); + +module.exports = app; diff --git a/config/test.json b/config/test.json new file mode 100644 index 0000000..8ff1812 --- /dev/null +++ b/config/test.json @@ -0,0 +1,12 @@ +{ + "logging": { + "level": "error" + }, + "mysql": { + "host": "localhost", + "user": "jambones", + "password": "jambones", + "database": "jambones", + "port": 3406 + } +} \ No newline at end of file diff --git a/db/create-admin-token.sql b/db/create-admin-token.sql new file mode 100644 index 0000000..32c80d4 --- /dev/null +++ b/db/create-admin-token.sql @@ -0,0 +1,2 @@ +insert into api_keys (api_key_sid, token) +values ('3f35518f-5a0d-4c2e-90a5-2407bb3b36f0', '38700987-c7a4-4685-a5bb-af378f9734de'); \ No newline at end of file diff --git a/db/jambones-sql.sql b/db/jambones-sql.sql index 971e019..318935b 100644 --- a/db/jambones-sql.sql +++ b/db/jambones-sql.sql @@ -27,8 +27,6 @@ DROP TABLE IF EXISTS `accounts`; DROP TABLE IF EXISTS `service_providers`; -DROP TABLE IF EXISTS `phone_number_inventory`; - DROP TABLE IF EXISTS `voip_carriers`; CREATE TABLE IF NOT EXISTS `applications` @@ -66,16 +64,6 @@ CREATE TABLE IF NOT EXISTS `conference_participants` PRIMARY KEY (`conference_participant_sid`) ) ENGINE=InnoDB COMMENT='A relationship between a call and a conference that it is co'; -CREATE TABLE IF NOT EXISTS `phone_numbers` -( -`phone_number_sid` CHAR(36) NOT NULL UNIQUE , -`number` VARCHAR(255) NOT NULL UNIQUE , -`account_sid` CHAR(36) NOT NULL, -`application_sid` CHAR(36), -`phone_number_inventory_id` INTEGER(10) UNSIGNED NOT NULL, -PRIMARY KEY (`phone_number_sid`) -) ENGINE=InnoDB COMMENT='A phone number that has been assigned to an account'; - CREATE TABLE IF NOT EXISTS `queues` ( `id` INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE , @@ -182,13 +170,15 @@ CREATE TABLE IF NOT EXISTS `voip_carriers` PRIMARY KEY (`voip_carrier_sid`) ) ENGINE=InnoDB COMMENT='An external organization that can provide sip trunking and D'; -CREATE TABLE IF NOT EXISTS `phone_number_inventory` +CREATE TABLE IF NOT EXISTS `phone_numbers` ( -`phone_number_inventory_sid` CHAR(36) NOT NULL UNIQUE , +`phone_number_sid` CHAR(36) NOT NULL UNIQUE , `number` VARCHAR(255) NOT NULL UNIQUE , `voip_carrier_sid` CHAR(36) NOT NULL, -PRIMARY KEY (`phone_number_inventory_sid`) -) ENGINE=InnoDB COMMENT='Telephone numbers (DIDs) that have been procured from a voip'; +`account_sid` CHAR(36), +`application_sid` CHAR(36), +PRIMARY KEY (`phone_number_sid`) +) ENGINE=InnoDB COMMENT='A phone number that has been assigned to an account'; CREATE INDEX `applications_application_sid_idx` ON `applications` (`application_sid`); CREATE INDEX `applications_name_idx` ON `applications` (`name`); @@ -205,11 +195,6 @@ ALTER TABLE `conference_participants` ADD FOREIGN KEY call_sid_idxfk (`call_sid` ALTER TABLE `conference_participants` ADD FOREIGN KEY conference_sid_idxfk (`conference_sid`) REFERENCES `conferences` (`conference_sid`); -CREATE INDEX `phone_numbers_phone_number_sid_idx` ON `phone_numbers` (`phone_number_sid`); -ALTER TABLE `phone_numbers` ADD FOREIGN KEY account_sid_idxfk_2 (`account_sid`) REFERENCES `accounts` (`account_sid`); - -ALTER TABLE `phone_numbers` ADD FOREIGN KEY application_sid_idxfk_1 (`application_sid`) REFERENCES `applications` (`application_sid`); - CREATE INDEX `queues_queue_sid_idx` ON `queues` (`queue_sid`); CREATE INDEX `registrations_registration_sid_idx` ON `registrations` (`registration_sid`); CREATE INDEX `queue_members_queue_member_sid_idx` ON `queue_members` (`queue_member_sid`); @@ -220,7 +205,7 @@ ALTER TABLE `queue_members` ADD FOREIGN KEY queue_sid_idxfk (`queue_sid`) REFERE CREATE INDEX `calls_call_sid_idx` ON `calls` (`call_sid`); ALTER TABLE `calls` ADD FOREIGN KEY parent_call_sid_idxfk (`parent_call_sid`) REFERENCES `calls` (`call_sid`); -ALTER TABLE `calls` ADD FOREIGN KEY application_sid_idxfk_2 (`application_sid`) REFERENCES `applications` (`application_sid`); +ALTER TABLE `calls` ADD FOREIGN KEY application_sid_idxfk_1 (`application_sid`) REFERENCES `applications` (`application_sid`); ALTER TABLE `calls` ADD FOREIGN KEY phone_number_sd_idxfk (`phone_number_sd`) REFERENCES `phone_numbers` (`phone_number_sid`); @@ -231,7 +216,7 @@ ALTER TABLE `calls` ADD FOREIGN KEY outbound_user_sid_idxfk (`outbound_user_sid` CREATE INDEX `service_providers_service_provider_sid_idx` ON `service_providers` (`service_provider_sid`); CREATE INDEX `service_providers_name_idx` ON `service_providers` (`name`); CREATE INDEX `api_keys_api_key_sid_idx` ON `api_keys` (`api_key_sid`); -ALTER TABLE `api_keys` ADD FOREIGN KEY account_sid_idxfk_3 (`account_sid`) REFERENCES `accounts` (`account_sid`); +ALTER TABLE `api_keys` ADD FOREIGN KEY account_sid_idxfk_2 (`account_sid`) REFERENCES `accounts` (`account_sid`); ALTER TABLE `api_keys` ADD FOREIGN KEY service_provider_sid_idxfk (`service_provider_sid`) REFERENCES `service_providers` (`service_provider_sid`); @@ -242,5 +227,9 @@ CREATE INDEX `accounts_name_idx` ON `accounts` (`name`); ALTER TABLE `accounts` ADD FOREIGN KEY service_provider_sid_idxfk_1 (`service_provider_sid`) REFERENCES `service_providers` (`service_provider_sid`); CREATE INDEX `voip_carriers_voip_carrier_sid_idx` ON `voip_carriers` (`voip_carrier_sid`); -CREATE INDEX `phone_number_inventory_sid_idx` ON `phone_number_inventory` (`phone_number_inventory_sid`); -ALTER TABLE `phone_number_inventory` ADD FOREIGN KEY voip_carrier_sid_idxfk (`voip_carrier_sid`) REFERENCES `voip_carriers` (`voip_carrier_sid`); +CREATE INDEX `phone_numbers_phone_number_sid_idx` ON `phone_numbers` (`phone_number_sid`); +ALTER TABLE `phone_numbers` ADD FOREIGN KEY voip_carrier_sid_idxfk (`voip_carrier_sid`) REFERENCES `voip_carriers` (`voip_carrier_sid`); + +ALTER TABLE `phone_numbers` ADD FOREIGN KEY account_sid_idxfk_3 (`account_sid`) REFERENCES `accounts` (`account_sid`); + +ALTER TABLE `phone_numbers` ADD FOREIGN KEY application_sid_idxfk_2 (`application_sid`) REFERENCES `applications` (`application_sid`); diff --git a/db/jambones.sqs b/db/jambones.sqs index 3b00699..ac5aa84 100644 --- a/db/jambones.sqs +++ b/db/jambones.sqs @@ -111,7 +111,7 @@ 266.00 80.00 - 14 + 13 @@ -204,58 +204,6 @@ - - - - - - - 78.00 - 178.00 - - - 344.00 - 80.00 - - 13 - - - - 1 - - - - - - - - - - - - - - - - - - voip_carrier_sid - voip_carriers - - - 4 - 1 - - - - - - - - - - - @@ -746,7 +694,7 @@ 83.00 - 322.00 + 191.00 331.00 @@ -771,6 +719,20 @@ + + + + voip_carrier_sid + voip_carriers + + + 4 + 1 + + + + + @@ -779,10 +741,10 @@ 4 - 1 + 2 - + @@ -801,13 +763,6 @@ - - - - - - - @@ -1036,17 +991,17 @@ - + - - - + + + - + diff --git a/lib/auth/index.js b/lib/auth/index.js index fcdae12..15139d3 100644 --- a/lib/auth/index.js +++ b/lib/auth/index.js @@ -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}); }); diff --git a/lib/db/mysql.js b/lib/db/mysql.js index 88c1ef7..9768060 100644 --- a/lib/db/mysql.js +++ b/lib/db/mysql.js @@ -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'); }); }); diff --git a/lib/models/account.js b/lib/models/account.js new file mode 100644 index 0000000..6ed0105 --- /dev/null +++ b/lib/models/account.js @@ -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; diff --git a/lib/models/application.js b/lib/models/application.js new file mode 100644 index 0000000..f46fd2a --- /dev/null +++ b/lib/models/application.js @@ -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; diff --git a/lib/models/model.js b/lib/models/model.js index bc72ee2..782b3ef 100644 --- a/lib/models/model.js +++ b/lib/models/model.js @@ -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) => { diff --git a/lib/models/phone-number.js b/lib/models/phone-number.js new file mode 100644 index 0000000..4e6295a --- /dev/null +++ b/lib/models/phone-number.js @@ -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; diff --git a/lib/models/voip-carrier.js b/lib/models/voip-carrier.js new file mode 100644 index 0000000..bc505ba --- /dev/null +++ b/lib/models/voip-carrier.js @@ -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; diff --git a/lib/routes/api/accounts.js b/lib/routes/api/accounts.js new file mode 100644 index 0000000..ae6a972 --- /dev/null +++ b/lib/routes/api/accounts.js @@ -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; diff --git a/lib/routes/api/api-keys.js b/lib/routes/api/api-keys.js index a2113a5..b9b8aed 100644 --- a/lib/routes/api/api-keys.js +++ b/lib/routes/api/api-keys.js @@ -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'); } diff --git a/lib/routes/api/applications.js b/lib/routes/api/applications.js new file mode 100644 index 0000000..973a1d3 --- /dev/null +++ b/lib/routes/api/applications.js @@ -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; diff --git a/lib/routes/api/decorate.js b/lib/routes/api/decorate.js index d3fd114..0181901 100644 --- a/lib/routes/api/decorate.js +++ b/lib/routes/api/decorate.js @@ -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(); diff --git a/lib/routes/api/error.js b/lib/routes/api/error.js index 0e83292..57e5cf6 100644 --- a/lib/routes/api/error.js +++ b/lib/routes/api/error.js @@ -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'); diff --git a/lib/routes/api/index.js b/lib/routes/api/index.js index 21c7b70..83d2e7e 100644 --- a/lib/routes/api/index.js +++ b/lib/routes/api/index.js @@ -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; diff --git a/lib/routes/api/phone-numbers.js b/lib/routes/api/phone-numbers.js new file mode 100644 index 0000000..7d7a6ba --- /dev/null +++ b/lib/routes/api/phone-numbers.js @@ -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; diff --git a/lib/routes/api/voip-carriers.js b/lib/routes/api/voip-carriers.js new file mode 100644 index 0000000..e1e978c --- /dev/null +++ b/lib/routes/api/voip-carriers.js @@ -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; diff --git a/lib/swagger/swagger.yaml b/lib/swagger/swagger.yaml index 0535c10..f9a6f67 100644 --- a/lib/swagger/swagger.yaml +++ b/lib/swagger/swagger.yaml @@ -64,6 +64,300 @@ paths: 404: description: api key or account not found + /VoipCarriers: + post: + summary: create voip carrier + operationId: createVoipCarrier + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: voip carrier name + description: + type: string + required: + - name + responses: + 201: + description: voip carrier successfully created + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessfulAdd' + 400: + description: bad request + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + 422: + description: unprocessable entity + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + get: + summary: list voip carriers + operationId: listVoipCarriers + responses: + 200: + description: list of voip carriers + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/VoipCarrier' + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + /VoipCarriers/{VoipCarrierSid}: + parameters: + - name: VoipCarrierSid + in: path + required: true + style: simple + explode: false + schema: + type: string + delete: + summary: delete a voip carrier + operationId: deleteVoipCarrier + responses: + 204: + description: voip carrier successfully deleted + 404: + description: voip carrier not found + 422: + description: unprocessable entity + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + example: + msg: a service provider with active accounts can not be deleted + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + get: + summary: retrieve voip carrier + operationId: getVoipCarrier + responses: + 200: + description: voip carrier found + content: + application/json: + schema: + $ref: '#/components/schemas/VoipCarrier' + 404: + description: voip carrier not found + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + put: + summary: update voip carrier + operationId: updateVoipCarrier + parameters: + - name: VoipCarrierSid + in: path + required: true + style: simple + explode: false + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/VoipCarrier' + responses: + 204: + description: voip carrier updated + content: + application/json: + schema: + $ref: '#/components/schemas/VoipCarrier' + 400: + description: bad request + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + 404: + description: voip carrier not found + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + + /PhoneNumbers: + post: + summary: provision a phone number into inventory from a Voip Carrier + operationId: provisionPhoneNumber + requestBody: + content: + application/json: + schema: + type: object + properties: + number: + type: string + description: telephone number + voip_carrier_sid: + type: string + format: uuid + required: + - number + - voip_carrier_sid + responses: + 201: + description: phone number successfully provisioned + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessfulAdd' + 400: + description: bad request + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + example: + msg: invalid telephone number format + 404: + description: voip carrier not found + 422: + description: unprocessable entity + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + example: + msg: the phone number provided already exists in inventory + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + get: + summary: list phone numbers + operationId: listProvisionedPhoneNumbers + responses: + 200: + description: list of phone numbers + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PhoneNumber' + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + /PhoneNumbers/{PhoneNumberSid}: + parameters: + - name: PhoneNumberSid + in: path + required: true + style: simple + explode: false + schema: + type: string + delete: + summary: delete a phone number + operationId: deletePhoneNumber + responses: + 204: + description: phone number successfully deleted + 404: + description: phone number not found + 422: + description: unprocessable entity + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + example: + msg: phone number that is assigned to an account may not be deleted + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + get: + summary: retrieve phone number + operationId: getPhoneNumber + responses: + 200: + description: phone number found + content: + application/json: + schema: + $ref: '#/components/schemas/PhoneNumber' + 404: + description: phone number not found + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + put: + summary: update phone number + operationId: updatePhoneNumber + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PhoneNumber' + responses: + 204: + description: phone number updated + content: + application/json: + schema: + $ref: '#/components/schemas/VoipCarrier' + 400: + description: bad request + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + 404: + description: phone number not found + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + /ServiceProviders: post: summary: create service provider @@ -107,6 +401,13 @@ paths: type: array items: $ref: '#/components/schemas/ServiceProvider' + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + /ServiceProviders/{ServiceProviderSid}: parameters: - name: ServiceProviderSid @@ -130,6 +431,13 @@ paths: application/json: schema: $ref: '#/components/schemas/GeneralError' + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + get: summary: retrieve service provider operationId: getServiceProvider @@ -142,6 +450,13 @@ paths: $ref: '#/components/schemas/ServiceProvider' 404: description: service provider not found + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + put: summary: update service provider operationId: updateServiceProvider @@ -161,14 +476,14 @@ paths: application/json: schema: $ref: '#/components/schemas/GeneralError' - /ServiceProviders/{ServiceProviderSid}/Accounts: - parameters: - - name: ServiceProviderSid - in: path - required: true - schema: - type: string - format: uuid + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + + /Accounts: post: summary: create an account operationId: createAccount @@ -182,34 +497,48 @@ paths: type: string description: account name example: foobar - sipRealm: + sip_realm: type: string description: sip realm for registration example: sip.mycompany.com - registrationUrl: + registration_hook: type: string format: url description: authentication webhook for registration example: https://mycompany.com + service_provider_sid: + type: string + format: uuid + example: 85f9c036-ba61-4f28-b2f5-617c23fa68ff required: - name + - service_provider_sid responses: 201: description: account successfully created content: application/json: schema: - required: - - accountSid - properties: - accountSid: - type: string - format: uuid - example: 2531329f-fb09-4ef7-887e-84e648214436 + $ref: '#/components/schemas/SuccessfulAdd' 400: description: bad request - 409: - description: account with this name already exists + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + 422: + description: unprocessable entity + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + callbacks: onRegistrationAttempt: '{$request.body#/registrationUrl}/auth': @@ -314,7 +643,7 @@ paths: address will be blacklisted by the platform. get: - summary: list accounts for a service provider + summary: list accounts operationId: listAccounts responses: 200: @@ -325,14 +654,15 @@ paths: type: array items: $ref: '#/components/schemas/Account' - /ServiceProviders/{ServiceProviderSid}/Accounts/{AccountSid}: + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + + /Accounts/{AccountSid}: parameters: - - name: ServiceProviderSid - in: path - required: true - schema: - type: string - format: uuid - name: AccountSid in: path required: true @@ -343,181 +673,71 @@ paths: summary: delete an account operationId: deleteAccount responses: - 200: + 204: description: account successfully deleted 404: description: account not found - 409: - description: account with applications or phone numbers can not be deleted + 422: + description: unprocessable entity + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + get: + summary: retrieve account + operationId: getAccount + responses: + 200: + description: account found + content: + application/json: + schema: + $ref: '#/components/schemas/Account' + 404: + description: account not found + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' - /VoipCarriers: - post: - summary: create voip carrier - operationId: createVoipCarrier - requestBody: - content: - application/json: - schema: - type: object - properties: - name: - type: string - description: voip carrier name - description: - type: string - required: - - name - responses: - 201: - description: voip carrier successfully created - content: - application/json: - schema: - required: - - voipCarrierSid - properties: - accountSid: - type: string - format: uuid - example: 2531329f-fb09-4ef7-887e-84e648214436 - 400: - description: bad request - 409: - description: an existing voip carrier already exists with this name - get: - summary: list voip carriers - operationId: listVoipCarriers - responses: - 200: - description: list of voip carriers - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/VoipCarrier' - /VoipCarriers/{VoipCarrierSid}: - parameters: - - name: VoipCarrierSid - in: path - required: true - style: simple - explode: false - schema: - type: string - delete: - summary: delete a voip carrier - operationId: deleteVoipCarrier - responses: - 200: - description: voip carrier successfully deleted - 404: - description: voip carrier not found - 409: - description: voip carrier with active phone numbers can not be deleted - get: - summary: retrieve voip carrier - operationId: getVoipCarrier - responses: - 200: - description: voip carrier found - content: - application/json: - schema: - $ref: '#/components/schemas/VoipCarrier' - 404: - description: voip carrier not found put: - summary: update voip carrier - operationId: updateVoipCarrier - parameters: - - name: VoipCarrierSid - in: path - required: true - style: simple - explode: false - schema: - type: string + summary: update account + operationId: updateAccount requestBody: content: application/json: schema: - $ref: '#/components/schemas/VoipCarrier' + $ref: '#/components/schemas/Account' responses: - 200: - description: voip carrier updated - content: - application/json: - schema: - $ref: '#/components/schemas/VoipCarrier' + 204: + description: account updated 404: - description: voip carrier not found - /VoipCarriers/{VoipCarrierSid}/PhoneNumbers: - parameters: - - name: VoipCarrierSid - in: path - required: true - schema: - type: string - format: uuid - post: - summary: provision a phone number into inventory from a Voip Carrier - operationId: provisionPhoneNumber - requestBody: - content: - application/json: - schema: - type: object - properties: - number: - type: string - description: telephone number - description: - type: string - required: - - number - responses: - 201: - description: phone number successfully provisioned into inventory + description: account not found + 422: + description: unprocessable entity content: application/json: schema: - required: - - phoneNumberSid - properties: - phoneNumberSid: - type: string - format: uuid - example: 2531329f-fb09-4ef7-887e-84e648214436 - 400: - description: bad request - 409: - description: the specified phone number already exists in inventory - get: - summary: list phone numbers for a carrier - operationId: listProvisionedPhoneNumbers - responses: - 200: - description: list of phone numbers + $ref: '#/components/schemas/GeneralError' + 500: + description: system error content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/PhoneNumber' + $ref: '#/components/schemas/GeneralError' - /Accounts/{AccountSid}/Applications: + /Applications: post: summary: create application operationId: createApplication - parameters: - - name: AccountSid - in: path - required: true - style: simple - explode: false - schema: - type: string requestBody: content: application/json: @@ -527,70 +747,68 @@ paths: name: type: string description: application name - description: + account_sid: type: string - statusUrl: + format: uuid + call_hook: + type: string + format: url + description: webhook to invoke when call is received + call_status_hook: type: string format: url description: webhook to pass call status updates to - voiceUrl: - type: string - format: url - description: webhook to call when call is received - voiceFallbackUrl: - type: string - format: url - description: fallback webook url required: - name - - statusUrl - - voiceUrl + - account_sid + - call_hook + - call_status_hook responses: 201: description: application successfully created content: application/json: schema: - required: - - ApplicationSid - properties: - accountSid: - type: string - format: uuid - example: 2531329f-fb09-4ef7-887e-84e648214436 + $ref: '#/components/schemas/SuccessfulAdd' 400: description: bad request - 409: - description: an existing application already exists with this name + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + 422: + description: unprocessable entity + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' get: - summary: list applications for an account + summary: list applications operationId: listApplications - parameters: - - name: AccountSid - in: path - required: true - style: simple - explode: false - schema: - type: string responses: 200: - description: retrieve applications for the account + description: list of applications content: application/json: schema: type: array items: $ref: '#/components/schemas/Application' - /Accounts/{AccountSid}/Applications/{ApplicationSid}: + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + + /Applications/{ApplicationSid}: parameters: - - name: AccountSid - in: path - required: true - style: simple - explode: false - schema: - type: string - name: ApplicationSid in: path required: true @@ -602,13 +820,24 @@ paths: summary: delete an application operationId: deleteApplication responses: - 200: + 204: description: application successfully deleted 404: description: application not found + 422: + description: unprocessable entity + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' get: - summary: retrieve application - operationId: getApplication + summary: retrieve an application responses: 200: description: application found @@ -618,137 +847,38 @@ paths: $ref: '#/components/schemas/Application' 404: description: application not found + 500: + description: system error + content: + application/json: + schema: + $ref: '#/components/schemas/GeneralError' put: summary: update application operationId: updateApplication - parameters: - - name: AccountSid - in: path - required: true - style: simple - explode: false - schema: - type: string - - name: ApplicationSid - in: path - required: true - style: simple - explode: false - schema: - type: string requestBody: content: application/json: schema: $ref: '#/components/schemas/Application' responses: - 200: + 204: description: application updated - content: - application/json: - schema: - $ref: '#/components/schemas/Application' 404: description: application not found - /Accounts/{AccountSid}/Applications/{ApplicationSid}/PhoneNumbers/{PhoneNumberSid}: - parameters: - - name: AccountSid - in: path - required: true - schema: - type: string - format: uuid - - name: ApplicationSid - in: path - required: true - schema: - type: string - format: uuid - - name: PhoneNumberSid - in: path - required: true - schema: - type: string - format: uuid - delete: - summary: remove a phone number from an application - operationId: removePhoneNumberFromApplication - responses: - 200: - description: phone number removed from this application - 404: - description: phone number not found or was not assigned to this application - put: - summary: provision a phone number for an Application - operationId: assignPhoneNumberToApplication - responses: - 200: - description: phone number successfully assigned to application + 422: + description: unprocessable entity content: application/json: schema: - $ref: '#/components/schemas/PhoneNumber' - 400: - description: bad request - 409: - description: the specified phone number is already assigned to another application or account - - /Accounts/{AccountSid}/Apikeys: - post: - summary: create an account level api key - operationId: createAccountApikey - parameters: - - name: AccountSid - in: path - required: true - style: simple - explode: false - schema: - type: string - responses: - 201: - description: api key successfully created + $ref: '#/components/schemas/GeneralError' + 500: + description: system error content: application/json: schema: - required: - - apiKey - - apiKeySid - properties: - api_key_sid: - type: string - description: system identifier for api key that was created - format: uuid - example: 7531328e-eb08-4eff-887e-84e648214872 - token: - type: string - description: api key authorization token - format: uuid - example: 2531329f-fb09-4ef7-887e-84e648214436 - 404: - description: Account not found - /Accounts/{AccountSid}/Apikeys/{ApiKeySid}: - delete: - summary: delete account-level api key - operationId: deleteAccountApiKey - parameters: - - name: AccountSid - in: path - required: true - style: simple - explode: false - schema: - type: string - - name: ApiKeySid - in: path - required: true - schema: - type: string - responses: - 200: - description: api key deleted - 404: - description: api key or account not found + $ref: '#/components/schemas/GeneralError' + /Accounts/{AccountSid}/Applications/{ApplicationSid}/Calls: post: @@ -900,8 +1030,9 @@ components: registration_hook: type: string format: url - service_provider: - $ref: '#/components/schemas/ServiceProvider' + service_provider_sid: + type: string + format: uuid required: - account_sid - name @@ -914,8 +1045,9 @@ components: format: uuid name: type: string - account: - $ref: '#/components/schemas/Account' + account_sid: + type: string + format: uuid call_hook: type: string format: url @@ -925,7 +1057,7 @@ components: required: - application_sid - name - - account + - account_sid - call_hook - call_status_hook PhoneNumber: @@ -936,17 +1068,19 @@ components: format: uuid number: type: string - voip_carrier: - $ref: '#/components/schemas/VoipCarrier' - account: - $ref: '#/components/schemas/Account' - application: - $ref: '#/components/schemas/Application' + voip_carrier_sid: + type: string + format: uuid + account_sid: + type: string + format: uuid + application_sid: + type: string + format: uuid required: - phone_number_sid - number - - voip_carrier - - account + - voip_carrier_sid Registration: type: object properties: diff --git a/lib/utils/phone-number-syntax.js b/lib/utils/phone-number-syntax.js new file mode 100644 index 0000000..9749632 --- /dev/null +++ b/lib/utils/phone-number-syntax.js @@ -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; diff --git a/lib/utils/scopes.js b/lib/utils/scopes.js deleted file mode 100644 index e73ec28..0000000 --- a/lib/utils/scopes.js +++ /dev/null @@ -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 -}; diff --git a/package.json b/package.json index 028f2d3..e0b74ad 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,9 @@ "description": "", "main": "app.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "NODE_ENV=test node test/ | ./node_modules/.bin/tap-spec", + "coverage": "./node_modules/.bin/nyc --reporter html --report-dir ./coverage npm run test", + "jslint": "eslint app.js lib" }, "author": "", "license": "ISC", @@ -19,7 +21,17 @@ "request": "^2.88.0", "request-debug": "^0.2.0", "swagger-ui-express": "^4.1.2", + "tape": "^4.11.0", "uuid": "^3.3.3", "yamljs": "^0.3.0" + }, + "devDependencies": { + "eslint": "^6.7.2", + "eslint-plugin-promise": "^4.2.1", + "nyc": "^14.1.1", + "request-promise-native": "^1.0.8", + "tap": "^14.10.2", + "tap-dot": "^2.0.0", + "tap-spec": "^5.0.0" } } diff --git a/test/accounts.js b/test/accounts.js new file mode 100644 index 0000000..100f27c --- /dev/null +++ b/test/accounts.js @@ -0,0 +1,99 @@ +const test = require('tape').test ; +const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de'; +const authAdmin = {bearer: ADMIN_TOKEN}; +const request = require('request-promise-native').defaults({ + baseUrl: 'http://127.0.0.1:3000/v1' +}); +const {createVoipCarrier, createServiceProvider, createPhoneNumber, deleteObjectBySid} = require('./utils'); + +process.on('unhandledRejection', (reason, p) => { + console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); +}); + +test('account tests', async(t) => { + const app = require('../app'); + let sid; + try { + let result; + + /* add service provider, phone number, and voip carrier */ + const voip_carrier_sid = await createVoipCarrier(request); + const service_provider_sid = await createServiceProvider(request); + const phone_number_sid = await createPhoneNumber(request, voip_carrier_sid); + + /* add an account */ + result = await request.post('/Accounts', { + resolveWithFullResponse: true, + auth: authAdmin, + json: true, + body: { + name: 'daveh', + service_provider_sid + } + }); + t.ok(result.statusCode === 201, 'successfully created account'); + const sid = result.body.sid; + + /* query all accounts */ + result = await request.get('/Accounts', { + auth: authAdmin, + json: true, + }); + t.ok(result.length === 1 , 'successfully queried all accounts'); + + /* query one accounts */ + result = await request.get(`/Accounts/${sid}`, { + auth: authAdmin, + json: true, + }); + t.ok(result.name === 'daveh' , 'successfully retrieved account by sid'); + + /* update accounts */ + result = await request.put(`/Accounts/${sid}`, { + auth: authAdmin, + json: true, + resolveWithFullResponse: true, + body: { + name: 'robb' + } + }); + t.ok(result.statusCode === 204, 'successfully updated account'); + + /* assign phone number to account */ + result = await request.put(`/PhoneNumbers/${phone_number_sid}`, { + auth: authAdmin, + json: true, + resolveWithFullResponse: true, + body: { + account_sid: sid + } + }); + t.ok(result.statusCode === 204, 'successfully assigned phone number to account'); + + /* cannot delete account that has phone numbers assigned */ + result = await request.delete(`/Accounts/${sid}`, { + auth: authAdmin, + resolveWithFullResponse: true, + simple: false, + json: true + }); + t.ok(result.statusCode === 422 && result.body.msg === 'cannot delete account with phone numbers', 'cannot delete account with phone numbers'); + + /* delete account */ + await request.delete(`/PhoneNumbers/${phone_number_sid}`, {auth: authAdmin}); + result = await request.delete(`/Accounts/${sid}`, { + auth: authAdmin, + resolveWithFullResponse: true, + }); + t.ok(result.statusCode === 204, 'successfully deleted account after removing phone number'); + + await deleteObjectBySid(request, '/VoipCarriers', voip_carrier_sid); + await deleteObjectBySid(request, '/ServiceProviders', service_provider_sid); + t.end(); + } + catch (err) { + //console.error(err); + t.end(err); + } +}); + diff --git a/test/applications.js b/test/applications.js new file mode 100644 index 0000000..6943e07 --- /dev/null +++ b/test/applications.js @@ -0,0 +1,109 @@ +const test = require('tape').test ; +const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de'; +const authAdmin = {bearer: ADMIN_TOKEN}; +const request = require('request-promise-native').defaults({ + baseUrl: 'http://127.0.0.1:3000/v1' +}); +const {createVoipCarrier, createServiceProvider, + createPhoneNumber, createAccount, deleteObjectBySid +} = require('./utils'); + +process.on('unhandledRejection', (reason, p) => { + console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); +}); + +test('application tests', async(t) => { + const app = require('../app'); + let sid; + try { + let result; + + /* add service provider, phone number, and voip carrier */ + const voip_carrier_sid = await createVoipCarrier(request); + const service_provider_sid = await createServiceProvider(request); + const phone_number_sid = await createPhoneNumber(request, voip_carrier_sid); + const account_sid = await createAccount(request, service_provider_sid); + + /* add an application */ + result = await request.post('/Applications', { + resolveWithFullResponse: true, + auth: authAdmin, + json: true, + body: { + name: 'daveh', + account_sid, + call_hook: 'http://example.com', + call_status_hook: 'http://example.com' + } + }); + t.ok(result.statusCode === 201, 'successfully created application'); + const sid = result.body.sid; + + /* query all applications */ + result = await request.get('/Applications', { + auth: authAdmin, + json: true, + }); + t.ok(result.length === 1 , 'successfully queried all applications'); + + /* query one applications */ + result = await request.get(`/Applications/${sid}`, { + auth: authAdmin, + json: true, + }); + t.ok(result.name === 'daveh' , 'successfully retrieved application by sid'); + + /* update applications */ + result = await request.put(`/Applications/${sid}`, { + auth: authAdmin, + json: true, + resolveWithFullResponse: true, + body: { + call_hook: 'http://example2.com' + } + }); + t.ok(result.statusCode === 204, 'successfully updated application'); + + /* assign phone number to application */ + result = await request.put(`/PhoneNumbers/${phone_number_sid}`, { + auth: authAdmin, + json: true, + resolveWithFullResponse: true, + body: { + application_sid: sid, + account_sid + } + }); + t.ok(result.statusCode === 204, 'successfully assigned phone number to application'); + + /* delete application */ + result = await request.delete(`/Applications/${sid}`, { + auth: authAdmin, + resolveWithFullResponse: true, + simple: false, + json: true + }); + //console.log(results); + t.ok(result.statusCode === 422, 'cannot delete application with phone numbers'); + + /* delete application */ + await request.delete(`/PhoneNumbers/${phone_number_sid}`, {auth: authAdmin}); + result = await request.delete(`/Applications/${sid}`, { + auth: authAdmin, + resolveWithFullResponse: true, + }); + //console.log(result); + t.ok(result.statusCode === 204, 'successfully deleted application after removing phone number'); + + await deleteObjectBySid(request, '/Accounts', account_sid); + await deleteObjectBySid(request, '/VoipCarriers', voip_carrier_sid); + await deleteObjectBySid(request, '/ServiceProviders', service_provider_sid); + + t.end(); + } + catch (err) { + //console.error(err); + t.end(err); + } +}); + diff --git a/test/docker-compose-testbed.yaml b/test/docker-compose-testbed.yaml new file mode 100644 index 0000000..8b25144 --- /dev/null +++ b/test/docker-compose-testbed.yaml @@ -0,0 +1,25 @@ +version: '3' + +networks: + jambones-api-server: + driver: bridge + ipam: + config: + - subnet: 172.22.0.0/16 + +services: + mysql: + image: mysql:8 + environment: + MYSQL_RANDOM_ROOT_PASSWORD: "yes" + MYSQL_DATABASE: jambones + MYSQL_USER: jambones + MYSQL_PASSWORD: jambones + command: --default-authentication-plugin=mysql_native_password + ports: + - "3406:3306/tcp" + volumes: + - ../db:/tmp + networks: + jambones-api-server: + ipv4_address: 172.22.0.10 diff --git a/test/docker-start.js b/test/docker-start.js new file mode 100644 index 0000000..eea941d --- /dev/null +++ b/test/docker-start.js @@ -0,0 +1,62 @@ +const test = require('tape').test ; +const exec = require('child_process').exec ; + +test('starting docker network..', (t) => { + exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml up -d`, (err, stdout, stderr) => { + if (err) t.end(err); + + console.log('docker network started, giving extra time to create test mysql database...'); + testMysql(60000, (err) => { + if (err) { + exec(`docker logs test_mysql_1`, (err, stdout, stderr) => { + console.log(stdout); + console.log(stderr); + }); + } + else t.pass('successfully connected to mysql'); + setTimeout(() => t.end(), 2000); + }); + }); +}); + +test('creating schema', (t) => { + exec('docker exec test_mysql_1 mysql -h localhost -u jambones -D jambones -pjambones -e "source /tmp/jambones-sql.sql"', (err, stdout, stderr) => { + if (!err) t.pass('successfully created schema'); + else { + console.log(stderr); + console.log(stdout); + } + t.end(err); + }); +}); + +test('creating initial auth token', (t) => { + exec('docker exec test_mysql_1 mysql -h localhost -u jambones -D jambones -pjambones -e "source /tmp/create-admin-token.sql"', (err, stdout, stderr) => { + if (!err) t.pass('successfully created auth token'); + else { + console.log(stderr); + console.log(stdout); + } + t.end(err); + }); +}); + +function testMysql(timeout, callback) { + const retryTimer = setInterval(() => { + exec('docker exec test_mysql_1 mysql -h localhost -u jambones -D jambones -pjambones -e "SELECT 1"', (err, stdout, stderr) => { + if (!err) { + clearTimeout(timeoutTimer); + clearInterval(retryTimer); + return callback(null); + } + //console.log(`failed connecting (err): ${stderr}`); + //console.log(`failed connecting (out): ${stdout}`); + }); + }, 4000); + + const timeoutTimer = setTimeout(() => { + clearInterval(retryTimer); + callback('timeout connecting to mysql'); + }, timeout); +} + diff --git a/test/docker-stop.js b/test/docker-stop.js new file mode 100644 index 0000000..c985de4 --- /dev/null +++ b/test/docker-stop.js @@ -0,0 +1,11 @@ +const test = require('tape').test ; +const exec = require('child_process').exec ; + +test('stopping docker network..', (t) => { + t.timeoutAfter(10000); + exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml down`, (err, stdout, stderr) => { + //console.log(`stderr: ${stderr}`); + process.exit(0); + }); + t.end() ; +}); diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..e2107c3 --- /dev/null +++ b/test/index.js @@ -0,0 +1,7 @@ +require('./docker-start'); +require('./service-providers'); +require('./voip-carriers'); +require('./accounts'); +require('./phone-numbers'); +require('./applications'); +require('./docker-stop'); diff --git a/test/phone-numbers.js b/test/phone-numbers.js new file mode 100644 index 0000000..3b8dfc7 --- /dev/null +++ b/test/phone-numbers.js @@ -0,0 +1,121 @@ +const test = require('tape').test ; +const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de'; +const authAdmin = {bearer: ADMIN_TOKEN}; +const request = require('request-promise-native').defaults({ + baseUrl: 'http://127.0.0.1:3000/v1' +}); +const {createVoipCarrier, deleteObjectBySid} = require('./utils'); + +process.on('unhandledRejection', (reason, p) => { + console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); +}); + +test('phone number tests', async(t) => { + const app = require('../app'); + let sid; + try { + let result; + + /* add service provider, phone number, and voip carrier */ + const voip_carrier_sid = await createVoipCarrier(request); + + /* provision phone number - failure case: voip_carrier_sid is required */ + result = await request.post('/PhoneNumbers', { + resolveWithFullResponse: true, + simple: false, + auth: authAdmin, + json: true, + body: { + number: '15083084809' + } + }); + t.ok(result.statusCode === 400 && result.body.msg === 'voip_carrier_sid is required', + 'voip_carrier_sid is required when provisioning a phone number'); + + /* provision phone number - failure case: digits only */ + result = await request.post('/PhoneNumbers', { + resolveWithFullResponse: true, + simple: false, + auth: authAdmin, + json: true, + body: { + number: '+15083084809', + voip_carrier_sid + } + }); + t.ok(result.statusCode === 400 && result.body.msg === 'phone number must only include digits', + 'service_provider_sid is required when provisioning a phone number'); + + /* provision phone number - failure case: insufficient digits */ + result = await request.post('/PhoneNumbers', { + resolveWithFullResponse: true, + simple: false, + auth: authAdmin, + json: true, + body: { + number: '1508308', + voip_carrier_sid + } + }); + //console.log(`result: ${JSON.stringify(result)}`); + t.ok(result.statusCode === 400 && result.body.msg === 'invalid phone number: insufficient digits', + 'invalid phone number: insufficient digits'); + + /* provision phone number - failure case: invalid US number */ + result = await request.post('/PhoneNumbers', { + resolveWithFullResponse: true, + simple: false, + auth: authAdmin, + json: true, + body: { + number: '150830848091', + voip_carrier_sid + } + }); + t.ok(result.statusCode === 400 && result.body.msg === 'invalid US phone number', + 'invalid US phone number'); + + /* add a phone number */ + result = await request.post('/PhoneNumbers', { + resolveWithFullResponse: true, + auth: authAdmin, + json: true, + body: { + number: '16173333456', + voip_carrier_sid + } + }); + t.ok(result.statusCode === 201, 'successfully created phone number'); + const sid = result.body.sid; + + /* query all phone numbers */ + result = await request.get('/PhoneNumbers', { + auth: authAdmin, + json: true, + }); + t.ok(result.length === 1 , 'successfully queried all phone numbers'); + + /* query one phone numbers */ + result = await request.get(`/PhoneNumbers/${sid}`, { + auth: authAdmin, + json: true, + }); + t.ok(result.number === '16173333456' , 'successfully retrieved phone number by sid'); + + /* delete phone number */ + result = await request.delete(`/PhoneNumbers/${sid}`, { + auth: authAdmin, + resolveWithFullResponse: true, + }); + t.ok(result.statusCode === 204, 'successfully deleted phone number'); + + await deleteObjectBySid(request, '/VoipCarriers', voip_carrier_sid); + + t.end(); + } + catch (err) { + console.error(err); + t.end(err); + } +}); + diff --git a/test/service-providers.js b/test/service-providers.js new file mode 100644 index 0000000..2c6f0f6 --- /dev/null +++ b/test/service-providers.js @@ -0,0 +1,79 @@ +const test = require('tape').test ; +const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de'; +const authAdmin = {bearer: ADMIN_TOKEN}; +const request = require('request-promise-native').defaults({ + baseUrl: 'http://127.0.0.1:3000/v1' +}); + +process.on('unhandledRejection', (reason, p) => { + console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); +}); + +test('service provider tests', async(t) => { + const app = require('../app'); + let sid; + try { + let result; + result = await request.post('/ServiceProviders', { + simple: false, + resolveWithFullResponse: true, + //auth: authAdmin, + json: true, + body: { + name: 'daveh' + } + }); + t.ok(result.statusCode === 401, 'fails with 401 if Bearer token not supplied'); + + /* add a service provider */ + result = await request.post('/ServiceProviders', { + resolveWithFullResponse: true, + auth: authAdmin, + json: true, + body: { + name: 'daveh' + } + }); + t.ok(result.statusCode === 201, 'successfully created service provider'); + const sid = result.body.sid; + + /* query all service providers */ + result = await request.get('/ServiceProviders', { + auth: authAdmin, + json: true, + }); + t.ok(result.length === 1 , 'successfully queried all service providers'); + + /* query one service providers */ + result = await request.get(`/ServiceProviders/${sid}`, { + auth: authAdmin, + json: true, + }); + t.ok(result.name === 'daveh' , 'successfully retrieved service provider by sid'); + + + /* update service providers */ + result = await request.put(`/ServiceProviders/${sid}`, { + auth: authAdmin, + json: true, + resolveWithFullResponse: true, + body: { + name: 'robb' + } + }); + t.ok(result.statusCode === 204, 'successfully updated service provider'); + + /* delete service providers */ + result = await request.delete(`/ServiceProviders/${sid}`, { + auth: authAdmin, + resolveWithFullResponse: true, + }); + t.ok(result.statusCode === 204, 'successfully deleted service provider'); + t.end(); + } + catch (err) { + console.error(err); + t.end(err); + } +}); + diff --git a/test/utils.js b/test/utils.js new file mode 100644 index 0000000..6bcb621 --- /dev/null +++ b/test/utils.js @@ -0,0 +1,64 @@ + +const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de'; +const authAdmin = {bearer: ADMIN_TOKEN}; + +async function createServiceProvider(request) { + const result = await request.post('/ServiceProviders', { + auth: authAdmin, + json: true, + body: { + name: 'daveh' + } + }); + return result.sid; +} + +async function createVoipCarrier(request) { + const result = await request.post('/VoipCarriers', { + auth: authAdmin, + json: true, + body: { + name: 'daveh' + } + }); + return result.sid; +} + +async function createPhoneNumber(request, voip_carrier_sid) { + const result = await request.post('/PhoneNumbers', { + auth: authAdmin, + json: true, + body: { + number: '15083084809', + voip_carrier_sid + } + }); + return result.sid; +} + +async function createAccount(request, service_provider_sid) { + const result = await request.post('/Accounts', { + auth: authAdmin, + json: true, + body: { + name: 'daveh', + service_provider_sid + } + }); + return result.sid; +} + +async function deleteObjectBySid(request, path, sid) { + const result = await request.delete(`${path}/${sid}`, { + auth: authAdmin, + }); + return result; +} + +module.exports = { + createServiceProvider, + createVoipCarrier, + createPhoneNumber, + createAccount, + deleteObjectBySid +}; diff --git a/test/voip-carriers.js b/test/voip-carriers.js new file mode 100644 index 0000000..25c75a0 --- /dev/null +++ b/test/voip-carriers.js @@ -0,0 +1,109 @@ +const test = require('tape').test ; +const ADMIN_TOKEN = '38700987-c7a4-4685-a5bb-af378f9734de'; +const authAdmin = {bearer: ADMIN_TOKEN}; +const request = require('request-promise-native').defaults({ + baseUrl: 'http://127.0.0.1:3000/v1' +}); + +process.on('unhandledRejection', (reason, p) => { + console.log('Unhandled Rejection at: Promise', p, 'reason:', reason); +}); + +test('voip carrier tests', async(t) => { + const app = require('../app'); + let sid; + try { + let result; + + /* add a voip carrier */ + result = await request.post('/VoipCarriers', { + resolveWithFullResponse: true, + auth: authAdmin, + json: true, + body: { + name: 'daveh' + } + }); + t.ok(result.statusCode === 201, 'successfully created voip carrier'); + const sid = result.body.sid; + + /* query all voip carriers */ + result = await request.get('/VoipCarriers', { + auth: authAdmin, + json: true, + }); + t.ok(result.length === 1 , 'successfully queried all voip carriers'); + + /* query one voip carriers */ + result = await request.get(`/VoipCarriers/${sid}`, { + auth: authAdmin, + json: true, + }); + t.ok(result.name === 'daveh' , 'successfully retrieved voip carrier by sid'); + + + /* update voip carriers */ + result = await request.put(`/VoipCarriers/${sid}`, { + auth: authAdmin, + json: true, + resolveWithFullResponse: true, + body: { + name: 'robb' + } + }); + t.ok(result.statusCode === 204, 'successfully updated voip carrier'); + + /* provision a phone number for the voip carrier */ + result = await request.post('/PhoneNumbers', { + resolveWithFullResponse: true, + simple: false, + auth: authAdmin, + json: true, + body: { + number: '15083084809', + voip_carrier_sid: sid + } + }); + t.ok(result.statusCode === 201, 'successfully provisioned a phone number from voip carrier'); + const phone_number_sid = result.body.sid; + + + /* can't delete a voip carrier that has phone numbers assigned */ + result = await request.delete(`/VoipCarriers/${sid}`, { + resolveWithFullResponse: true, + simple: false, + json: true, + auth: authAdmin + }); + //console.log(`result: ${JSON.stringify(result)}`); + t.ok(result.statusCode === 422 && result.body.msg === 'cannot delete voip carrier with active phone numbers', + 'cannot delete voip carrier with active phone numbers'); + + /* delete phone number */ + result = await request.delete(`/PhoneNumbers/${phone_number_sid}`, { + resolveWithFullResponse: true, + simple: false, + json: true, + auth: authAdmin + }); + //console.log(`result: ${JSON.stringify(result)}`); + t.ok(result.statusCode === 204, 'successfully deleted phone number'); + + /* delete voip carrier */ + result = await request.delete(`/VoipCarriers/${sid}`, { + resolveWithFullResponse: true, + simple: false, + json: true, + auth: authAdmin + }); + //console.log(`result: ${JSON.stringify(result)}`); + t.ok(result.statusCode === 204, 'successfully deleted voip carrier'); + + t.end(); + } + catch (err) { + console.error(err); + t.end(err); + } +}); +