From d3215fbf76a85d48793e64c00c7984498a5b9d8c Mon Sep 17 00:00:00 2001 From: Gilles Pirio Date: Wed, 29 Jun 2016 15:44:55 -0700 Subject: [PATCH] Breaking: Full support for websocket-based notifications and eol'ed http notifications --- src/controllers/notifications.js | 67 ---- src/controllers/subscriptions.js | 112 ------- src/lib/app.js | 22 +- src/lib/db.js | 2 - src/lib/notificationBroadcasterWebsocket.js | 58 ++++ src/lib/notificationUtils.js | 28 -- src/lib/notificationWorker.js | 196 ----------- src/lib/subscriptionUtils.js | 18 - src/lib/transferExpiryMonitor.js | 6 +- src/models/accounts.js | 6 +- src/models/subscriptions.js | 88 ----- src/models/transfers.js | 14 +- src/services/app.js | 2 +- src/services/notificationBroadcaster.js | 6 + src/services/notificationWorker.js | 8 - src/services/transferExpiryMonitor.js | 4 +- src/sql/pg/create.sql | 53 --- src/sql/pg/drop.sql | 2 - src/sql/sqlite3/create.sql | 30 -- src/sql/sqlite3/drop.sql | 2 - src/sql/strong-oracle/create.sql | 54 --- src/sql/strong-oracle/drop.sql | 20 -- test/accountSpec.js | 156 --------- test/fulfillmentSpec.js | 164 --------- test/helpers/db.js | 20 -- test/notificationSpec.js | 352 +++++++++++++++++--- test/putTransferSpec.js | 99 ------ test/subscriptionSpec.js | 312 ----------------- 28 files changed, 376 insertions(+), 1525 deletions(-) delete mode 100644 src/controllers/notifications.js delete mode 100644 src/controllers/subscriptions.js create mode 100644 src/lib/notificationBroadcasterWebsocket.js delete mode 100644 src/lib/notificationUtils.js delete mode 100644 src/lib/notificationWorker.js delete mode 100644 src/lib/subscriptionUtils.js delete mode 100644 src/models/subscriptions.js create mode 100644 src/services/notificationBroadcaster.js delete mode 100644 src/services/notificationWorker.js delete mode 100644 test/subscriptionSpec.js diff --git a/src/controllers/notifications.js b/src/controllers/notifications.js deleted file mode 100644 index 371b358..0000000 --- a/src/controllers/notifications.js +++ /dev/null @@ -1,67 +0,0 @@ -'use strict' - -const request = require('five-bells-shared/utils/request') -const model = require('../models/notifications') - -/** - * @api {get} /subscriptions/:subscription_id/notifications/:notification_id Get RESThook notification - * @apiName GetNotification - * @apiGroup Notification - * @apiVersion 1.0.0 - * - * @apiDescription Use this to query about the details of a notification. Only accounts - * that were related to the notification event are authorized. - * - * @apiParam {String} id Subscription - * [UUID](http://en.wikipedia.org/wiki/Universally_unique_identifier). - * - * @apiParam {String} id Notification - * [UUID](http://en.wikipedia.org/wiki/Universally_unique_identifier). - * - * @apiExample {shell} Get notification - * curl -x GET -H "Authorization: Basic QWxhZGRpbjpPcGVuU2VzYW1l" http://usd-ledger.example/USD/subscriptions/f49697a6-d52c-4f46-84c8-9070a31feab7/notifications/89ae630b-959a-47cc-adcf-d7be85e310c0 - * - * @apiSuccessExample {json} 200 Notification Response: - * HTTP/1.1 200 OK - * { - * "id": "http://usd-ledger.example/USD/subscriptions/f49697a6-d52c-4f46-84c8-9070a31feab7/notifications/89ae630b-959a-47cc-adcf-d7be85e310c0", - * "subscription": "http://usd-ledger.example/USD/subscriptions/f49697a6-d52c-4f46-84c8-9070a31feab7", - * "event": "transfer.update", - * "resource": { - * "id": "http://usd-ledger.example/USD/transfers/3a2a1d9e-8640-4d2d-b06c-84f2cd613204", - * "ledger": "http://usd-ledger.example/USD", - * "debits": [{ - * "account": "http://usd-ledger.example/USD/accounts/alice", - * "amount": "50" - * }], - * "credits": [{ - * "account": "http://usd-ledger.example/USD/accounts/bob", - * "amount": "50" - * }], - * "execution_condition": "cc:0:3:8ZdpKBDUV-KX_OnFZTsCWB_5mlCFI3DynX5f5H2dN-Y:2", - * "expires_at": "2015-06-16T00:00:01.000Z", - * "state": "executed" - * }, - * "related_resources": { - * "execution_condition_fulfillment": "cf:0:_v8" - * } - * } - * - * @apiUse NotFoundError - * @apiUse InvalidUriParameterError - * @apiUse UnauthorizedError - * - * @returns {void} - */ -function * getResource () { - const subscriptionId = this.params.subscription_id - request.validateUriParameter('id', subscriptionId, 'Uuid') - - const notificationId = this.params.notification_id - request.validateUriParameter('id', notificationId, 'Uuid') - this.body = yield model.getNotification(subscriptionId.toLowerCase(), notificationId.toLowerCase(), this.req.user) -} - -module.exports = { - getResource -} diff --git a/src/controllers/subscriptions.js b/src/controllers/subscriptions.js deleted file mode 100644 index e5fd003..0000000 --- a/src/controllers/subscriptions.js +++ /dev/null @@ -1,112 +0,0 @@ -'use strict' - -const request = require('five-bells-shared/utils/request') -const model = require('../models/subscriptions') -const uri = require('../services/uriManager') - -/** - * @api {get} /subscriptions/:id Get RESThook subscription - * @apiName GetSubscription - * @apiGroup Subscription - * @apiVersion 1.0.0 - * - * @apiDescription Use this to query about the details or status of a - * subscription. - * - * @apiParam {String} id Subscription - * [UUID](http://en.wikipedia.org/wiki/Universally_unique_identifier). - * - * @apiExample {shell} Get subscription - * curl -x GET -H "Authorization: Basic QWxhZGRpbjpPcGVuU2VzYW1l" http://usd-ledger.example/USD/subscriptions/f49697a6-d52c-4f46-84c8-9070a31feab7 - * - * @apiSuccessExample {json} 200 Notification Response: - * HTTP/1.1 200 OK - * { - * "id": "http://usd-ledger.example/USD/subscriptions/f49697a6-d52c-4f46-84c8-9070a31feab7", - * "owner": "http://usd-ledger.example/USD/accounts/alice", - * "subject": "http://usd-ledger.example/USD/accounts/alice", - * "event": "transfer.update", - * "target": "http://subscriber.example/notifications" - * } - * - * @apiUse NotFoundError - * @apiUse InvalidUriParameterError - * @apiUse UnauthorizedError - * - * @returns {void} - */ -function * getResource () { - const id = this.params.id - request.validateUriParameter('id', id, 'Uuid') - this.body = yield model.getSubscription(id.toLowerCase(), this.req.user) -} - -/** - * @api {put} /subscriptions Subscribe to an event - * @apiName PutSubscription - * @apiGroup Subscription - * @apiVersion 1.0.0 - * - * @apiDescription Note that the format of the notification `POST`ed to the `target` - * is the same as what is returned from `GET /subscriptions/:subscription_id/notifications/:notification_id` - * - * @apiParamExample {json} Request Body Example - * { - * "id": "f49697a6-d52c-4f46-84c8-9070a31feab7", - * "owner": "http://usd-ledger.example/USD/accounts/alice", - * "event": "transfer.create", - * "target": "http://subscriber.example/notifications" - * } - * - * @apiUse InvalidBodyError - * @apiUse UnauthorizedError - * - * @returns {void} - */ -function * putResource () { - const id = this.params.id - request.validateUriParameter('id', id, 'Uuid') - const subscription = this.body - - if (typeof subscription.id !== 'undefined') { - request.assert.strictEqual( - uri.parse(subscription.id, 'subscription').id.toLowerCase(), - id.toLowerCase(), - 'Subscription ID must match the one in the URL') - } - - subscription.id = uri.make('subscription', id.toLowerCase()) - const result = yield model.setSubscription(subscription, this.req.user) - this.body = result.subscription - this.status = result.existed ? 200 : 201 -} - -/** - * @api {delete} /subscriptions/:id Cancel a subscription - * @apiName DeleteSubscription - * @apiGroup Subscription - * @apiVersion 1.0.0 - * - * @apiDescription End a subscription. - * - * @apiParam {String} id Subscription - * [UUID](http://en.wikipedia.org/wiki/Universally_unique_identifier). - * - * @apiUse NotFoundError - * @apiUse InvalidUriParameterError - * @apiUse UnauthorizedError - * - * @returns {void} - */ -function * deleteResource () { - const id = this.params.id - request.validateUriParameter('id', id, 'Uuid') - yield model.deleteSubscription(id.toLowerCase(), this.req.user) - this.status = 204 -} - -module.exports = { - getResource, - putResource, - deleteResource -} diff --git a/src/lib/app.js b/src/lib/app.js index e35a708..3a963c2 100644 --- a/src/lib/app.js +++ b/src/lib/app.js @@ -16,8 +16,6 @@ const metadata = require('../controllers/metadata') const health = require('../controllers/health') const transfers = require('../controllers/transfers') const accounts = require('../controllers/accounts') -const subscriptions = require('../controllers/subscriptions') -const notifications = require('../controllers/notifications') const seedDB = require('./seed-db') const createTables = require('./db').createTables const readLookupTables = require('./db').readLookupTables @@ -31,7 +29,7 @@ class App { this.config = modules.config this.db = modules.db this.timerWorker = modules.timerWorker - this.notificationWorker = modules.notificationWorker + this.notificationBroadcaster = modules.notificationBroadcaster const koaApp = this.koa = websockify(koa()) const router = this._makeRouter() @@ -64,7 +62,6 @@ class App { // Start timerWorker to trigger the transferExpiryMonitor // when transfers are going to expire yield this.timerWorker.start() - this.notificationWorker.start() if (this.config.getIn(['db', 'sync'])) { yield createTables() @@ -143,23 +140,6 @@ class App { }, accounts.putResource) - router.get('/subscriptions/:id', - passport.authenticate(['basic', 'http-signature', 'client-cert'], { session: false }), - subscriptions.getResource) - router.put('/subscriptions/:id', - passport.authenticate(['basic', 'http-signature', 'client-cert'], { session: false }), - function * (next) { - this.body = yield parseBody(this) - yield next - }, - subscriptions.putResource) - router.delete('/subscriptions/:id', - passport.authenticate(['basic', 'http-signature', 'client-cert'], { session: false }), - subscriptions.deleteResource) - - router.get('/subscriptions/:subscription_id/notifications/:notification_id', - passport.authenticate(['basic', 'http-signature', 'client-cert'], { session: false }), - notifications.getResource) return router } diff --git a/src/lib/db.js b/src/lib/db.js index 1a1a363..abe5f8b 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -17,8 +17,6 @@ const TABLE_NAMES = [ 'L_ACCOUNTS', 'L_FULFILLMENTS', 'L_ENTRIES', - 'L_NOTIFICATIONS', - 'L_SUBSCRIPTIONS', 'L_TRANSFERS', 'L_LU_REJECTION_REASON', 'L_LU_TRANSFER_STATUS' diff --git a/src/lib/notificationBroadcasterWebsocket.js b/src/lib/notificationBroadcasterWebsocket.js new file mode 100644 index 0000000..b23dcc0 --- /dev/null +++ b/src/lib/notificationBroadcasterWebsocket.js @@ -0,0 +1,58 @@ +'use strict' + +const _ = require('lodash') +const EventEmitter = require('events').EventEmitter +const transferDictionary = require('five-bells-shared').TransferStateDictionary +const transferStates = transferDictionary.transferStates +const isTransferFinalized = require('./transferUtils').isTransferFinalized +const convertToExternalTransfer = require('../models/converters/transfers') + .convertToExternalTransfer +const getFulfillment = require('../models/db/fulfillments').getFulfillment +const convertToExternalFulfillment = require('../models/converters/fulfillments') + .convertToExternalFulfillment + +class NotificationBroadcaster extends EventEmitter { + constructor (log) { + super() + this.log = log + } + + * sendNotifications (transfer, transaction) { + const affectedAccounts = _([transfer.debits, transfer.credits]) + .flatten().pluck('account').value() + affectedAccounts.push('*') + + // Prepare notification for websocket subscribers + const notificationBody = { + resource: convertToExternalTransfer(transfer) + } + + // If the transfer is finalized, see if it was finalized by a fulfillment + let fulfillment + if (isTransferFinalized(transfer)) { + fulfillment = yield getFulfillment(transfer.id, { transaction }) + + if (fulfillment) { + if (transfer.state === transferStates.TRANSFER_STATE_EXECUTED) { + notificationBody.related_resources = { + execution_condition_fulfillment: + convertToExternalFulfillment(fulfillment) + } + } else if (transfer.state === transferStates.TRANSFER_STATE_REJECTED) { + notificationBody.related_resources = { + cancellation_condition_fulfillment: + convertToExternalFulfillment(fulfillment) + } + } + } + } + + this.log.debug('emitting transfer-{' + affectedAccounts.join(',') + '}') + for (let account of affectedAccounts) { + this.emit('transfer-' + account, notificationBody) + } + } + +} + +module.exports = NotificationBroadcaster diff --git a/src/lib/notificationUtils.js b/src/lib/notificationUtils.js deleted file mode 100644 index 6f76479..0000000 --- a/src/lib/notificationUtils.js +++ /dev/null @@ -1,28 +0,0 @@ -'use strict' - -const request = require('co-request') -const _ = require('lodash') -const url = require('url') - -function isHTTPS (uri) { - return url.parse(uri).protocol.match(/https/) !== null -} - -function tlsOptions (target, config) { - const tls = config.get('tls') - const useTLS = isHTTPS(target) && tls - return useTLS ? _.omit(_.pick(tls, ['cert', 'key', 'ca', 'crl']), _.isUndefined) - : {} -} - -function sendNotification (target, notificationBody, config) { - return request(target, _.assign({ - method: 'post', - json: true, - body: notificationBody - }, tlsOptions(target, config))) -} - -module.exports = { - sendNotification: sendNotification -} diff --git a/src/lib/notificationWorker.js b/src/lib/notificationWorker.js deleted file mode 100644 index 1150b0b..0000000 --- a/src/lib/notificationWorker.js +++ /dev/null @@ -1,196 +0,0 @@ -'use strict' - -const _ = require('lodash') -const co = require('co') -const EventEmitter = require('events').EventEmitter -const utils = require('./notificationUtils') -const NotificationScheduler = require('five-bells-shared').NotificationScheduler -const transferDictionary = require('five-bells-shared').TransferStateDictionary -const transferStates = transferDictionary.transferStates -const uuid4 = require('uuid4') -const JSONSigning = require('five-bells-shared').JSONSigning -const config = require('../services/config') -const getTransfer = require('../models/db/transfers').getTransfer -const isTransferFinalized = require('./transferUtils').isTransferFinalized -const getSubscription = require('../models/db/subscriptions').getSubscription -const convertToExternalTransfer = require('../models/converters/transfers') - .convertToExternalTransfer -const getAffectedSubscriptions = require('../models/db/subscriptions') - .getAffectedSubscriptions -const getMatchingNotification = require('../models/db/notifications') - .getMatchingNotification -const notificationDAO = require('../models/db/notifications') -const getFulfillment = require('../models/db/fulfillments').getFulfillment -const convertToExternalFulfillment = require('../models/converters/fulfillments') - .convertToExternalFulfillment - -const privateKey = config.getIn(['keys', 'notification_sign', 'secret']) - -function * findOrCreate (subscriptionID, transferID, options) { - const result = yield getMatchingNotification( - subscriptionID, transferID, options) - if (result) { - return result - } - const values = _.assign({}, options.defaults || {}, { - subscription_id: subscriptionID, - transfer_id: transferID - }) - if (!values.id) { - values.id = uuid4() - } - yield notificationDAO.insertNotification(values, options) - return yield notificationDAO.getNotification(values.id, options) -} - -class NotificationWorker extends EventEmitter { - constructor (uri, log, config) { - super() - - this.uri = uri - this.log = log - this.config = config - - this.scheduler = new NotificationScheduler({ - notificationDAO, log, - processNotification: this.processNotification.bind(this) - }) - this.signatureCache = {} - } - - start () { this.scheduler.start() } - stop () { this.scheduler.stop() } - processNotificationQueue () { return this.scheduler.processQueue() } - - * queueNotifications (transfer, transaction) { - const affectedAccounts = _([transfer.debits, transfer.credits]) - .flatten().pluck('account').value() - affectedAccounts.push('*') - - // Prepare notification for websocket subscribers - const notificationBody = { - resource: convertToExternalTransfer(transfer) - } - - // If the transfer is finalized, see if it was finalized by a fulfillment - let fulfillment - if (isTransferFinalized(transfer)) { - fulfillment = yield getFulfillment(transfer.id, { transaction }) - - if (fulfillment) { - if (transfer.state === transferStates.TRANSFER_STATE_EXECUTED) { - notificationBody.related_resources = { - execution_condition_fulfillment: - convertToExternalFulfillment(fulfillment) - } - } else if (transfer.state === transferStates.TRANSFER_STATE_REJECTED) { - notificationBody.related_resources = { - cancellation_condition_fulfillment: - convertToExternalFulfillment(fulfillment) - } - } - } - } - - const affectedAccountUris = affectedAccounts.map((account) => - account === '*' ? account : this.uri.make('account', account)) - - let subscriptions = yield getAffectedSubscriptions(affectedAccountUris, - {transaction}) - - if (!subscriptions) { - return - } - - // log.debug('notifying ' + subscription.owner + ' at ' + - // subscription.target) - const self = this - const notifications = yield subscriptions.map(function (subscription) { - return findOrCreate(subscription.id, transfer.id, { transaction }) - }) - - co(function * () { - self.log.debug('emitting transfer-{' + affectedAccounts.join(',') + '}') - for (let account of affectedAccounts) { - self.emit('transfer-' + account, notificationBody) - } - - // We will schedule an immediate attempt to send the notification for - // performance in the good case. - // Don't schedule the immediate attempt if the worker isn't active, though. - if (!self.scheduler.isEnabled()) return - - yield notifications.map(function (notification, i) { - return self.processNotificationWithInstances(notification, transfer, subscriptions[i], fulfillment) - }) - // Schedule any retries. - yield self.scheduler.scheduleProcessing() - }).catch(function (err) { - self.log.warn('immediate notification send failed ' + err.stack) - }) - } - - * processNotification (notification) { - const transfer = yield getTransfer(notification.transfer_id) - const subscription = yield getSubscription(notification.subscription_id) - const fulfillment = yield getFulfillment(transfer.id) - yield this.processNotificationWithInstances(notification, transfer, subscription, fulfillment) - } - - * processNotificationWithInstances (notification, transfer, subscription, fulfillment) { - this.log.debug('sending notification to ' + subscription.target) - const subscriptionURI = this.uri.make('subscription', subscription.id) - const notificationBody = { - id: subscriptionURI + '/notifications/' + notification.id, - subscription: subscriptionURI, - event: 'transfer.update', - resource: convertToExternalTransfer(transfer) - } - if (fulfillment) { - if (transfer.state === transferStates.TRANSFER_STATE_EXECUTED) { - notificationBody.related_resources = { - execution_condition_fulfillment: - convertToExternalFulfillment(fulfillment) - } - } else if (transfer.state === transferStates.TRANSFER_STATE_REJECTED) { - notificationBody.related_resources = { - cancellation_condition_fulfillment: - convertToExternalFulfillment(fulfillment) - } - } - } - // Sign notification - const algorithm = 'CC' // Crypto-condition signatures - let signedNotification - if (this.signatureCache[notification.id]) { - signedNotification = _.extend(notificationBody, { signature: this.signatureCache[notification.id] }) - } else { - signedNotification = JSONSigning.sign(notificationBody, algorithm, privateKey) - this.signatureCache[notification.id] = signedNotification.signature - } - let retry = true - try { - const result = yield utils.sendNotification( - subscription.target, signedNotification, this.config) - // Success! - if (result.statusCode < 400) { - retry = false - } else { - this.log.debug('remote error for notification ' + result.statusCode, - JSON.stringify(result.body)) - this.log.debug(signedNotification) - } - } catch (err) { - this.log.debug('notification send failed ' + err) - } - - if (retry) { - yield this.scheduler.retryNotification(notification) - } else { - delete this.signatureCache[notification.id] - yield notificationDAO.deleteNotification(notification.id) - } - } -} - -module.exports = NotificationWorker diff --git a/src/lib/subscriptionUtils.js b/src/lib/subscriptionUtils.js deleted file mode 100644 index 269af6d..0000000 --- a/src/lib/subscriptionUtils.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict' - -const uri = require('../services/uriManager') - -function isOwnerOrAdmin (requestingUser, subscription) { - const requestOwner = uri.make('account', requestingUser.name) - return requestOwner === subscription.owner || requestingUser.is_admin -} - -function isSubjectOrAdmin (requestingUser, subscription) { - const requestOwner = uri.make('account', requestingUser.name) - return requestOwner === subscription.subject || requestingUser.is_admin -} - -module.exports = { - isOwnerOrAdmin, - isSubjectOrAdmin -} diff --git a/src/lib/transferExpiryMonitor.js b/src/lib/transferExpiryMonitor.js index 3d20ff9..30676e5 100644 --- a/src/lib/transferExpiryMonitor.js +++ b/src/lib/transferExpiryMonitor.js @@ -14,9 +14,9 @@ const isTransferFinalized = require('./transferUtils').isTransferFinalized const transferStates = transferDictionary.transferStates class TransferExpiryMonitor { - constructor (timeQueue, notificationWorker) { + constructor (timeQueue, notificationBroadcaster) { this.queue = timeQueue - this.notificationWorker = notificationWorker + this.notificationBroadcaster = notificationBroadcaster } validateNotExpired (transfer) { @@ -50,7 +50,7 @@ class TransferExpiryMonitor { log.debug('expired transfer: ' + transferId) - yield _this.notificationWorker.queueNotifications(transfer, transaction) + yield _this.notificationBroadcaster.sendNotifications(transfer, transaction) } }) } diff --git a/src/models/accounts.js b/src/models/accounts.js index 7b1fadc..5eb585c 100644 --- a/src/models/accounts.js +++ b/src/models/accounts.js @@ -3,7 +3,7 @@ const _ = require('lodash') const assert = require('assert') const config = require('../services/config') const log = require('../services/log')('accounts') -const notificationWorker = require('../services/notificationWorker') +const notificationBroadcaster = require('../services/notificationBroadcaster') const db = require('./db/accounts') const hashPassword = require('five-bells-shared/utils/hashPassword') const NotFoundError = require('five-bells-shared/errors/not-found-error') @@ -99,9 +99,9 @@ function subscribeTransfers (account, requestingUser, listener) { } log.info('new ws subscriber for ' + account) - notificationWorker.addListener('transfer-' + account, listener) + notificationBroadcaster.addListener('transfer-' + account, listener) - return () => notificationWorker.removeListener('transfer-' + account, listener) + return () => notificationBroadcaster.removeListener('transfer-' + account, listener) } function * insertAccounts (externalAccounts) { diff --git a/src/models/subscriptions.js b/src/models/subscriptions.js deleted file mode 100644 index 324ffba..0000000 --- a/src/models/subscriptions.js +++ /dev/null @@ -1,88 +0,0 @@ -'use strict' - -const db = require('./db/subscriptions') -const log = require('../services/log')('subscriptions') -const NotFoundError = require('five-bells-shared/errors/not-found-error') -const UnauthorizedError = require('five-bells-shared/errors/unauthorized-error') -const UnprocessableEntityError = require('five-bells-shared/errors/unprocessable-entity-error') -const InvalidBodyError = require('five-bells-shared/errors/invalid-body-error') -const subscriptionUtils = require('../lib/subscriptionUtils') -const converters = require('./converters/subscriptions') -const validator = require('../services/validator') - -function * getSubscription (id, requestingUser) { - log.debug('fetching subscription ID ' + id) - const subscription = yield db.getSubscription(id) - if (!subscription) { - throw new NotFoundError('Unknown subscription ID') - } else if (!subscriptionUtils.isOwnerOrAdmin(requestingUser, subscription)) { - throw new UnauthorizedError('You may only view subscriptions you own') - } else { - return converters.convertToExternalSubscription(subscription) - } -} - -function * setSubscription (externalSubscription, requestingUser) { - const validationResult = validator - .create('Subscription')(externalSubscription) - if (validationResult.valid !== true) { - const message = validationResult.schema - ? 'Body did not match schema ' + validationResult.schema - : 'Body did not pass validation' - throw new InvalidBodyError(message, validationResult.errors) - } - const subscription = converters.convertToInternalSubscription( - externalSubscription) - - if (!subscriptionUtils.isOwnerOrAdmin(requestingUser, subscription)) { - throw new UnauthorizedError('You do not own this account') - } else if (!subscriptionUtils.isSubjectOrAdmin(requestingUser, subscription)) { - throw new UnauthorizedError('You are not authorized to listen to this account') - } - - log.debug('updating subscription ID ' + subscription.id) - log.debug('subscribed ' + subscription.owner + ' at ' + subscription.target) - - let existed - yield db.withTransaction(function * (transaction) { - const duplicate = yield db.getMatchingSubscription( - subscription, {transaction}) - if (duplicate) { - throw new UnprocessableEntityError( - 'Subscription with same event, subject, and target already exists') - } - existed = yield db.upsertSubscription(subscription, {transaction}) - }) - - log.debug('update completed') - return { - subscription: converters.convertToExternalSubscription(subscription), - existed: existed - } -} - -function * deleteSubscription (id, requestingUser) { - log.debug('deleting subscription ID ' + id) - yield db.withTransaction(function * (transaction) { - const subscription = yield db.getSubscription(id, {transaction}) - if (!subscription) { - throw new NotFoundError('Unknown subscription ID') - } - if (!subscriptionUtils.isOwnerOrAdmin(requestingUser, subscription)) { - throw new UnauthorizedError('You don\'t have permission to delete this subscription') - } - yield db.deleteSubscription(id, {transaction}) - }) -} - -function * insertSubscriptions (externalSubscriptions) { - yield db.insertSubscriptions(externalSubscriptions.map( - converters.convertToInternalSubscription)) -} - -module.exports = { - getSubscription, - setSubscription, - deleteSubscription, - insertSubscriptions -} diff --git a/src/models/transfers.js b/src/models/transfers.js index 57c6eed..791eb1a 100644 --- a/src/models/transfers.js +++ b/src/models/transfers.js @@ -17,7 +17,7 @@ const validateNoDisabledAccounts = require('../lib/disabledAccounts') const config = require('../services/config') const uri = require('../services/uriManager') const transferExpiryMonitor = require('../services/transferExpiryMonitor') -const notificationWorker = require('../services/notificationWorker') +const notificationBroadcaster = require('../services/notificationBroadcaster') const log = require('../services/log')('transfers') const updateState = require('../lib/updateState') const hashJSON = require('five-bells-shared/utils/hashJson') @@ -360,11 +360,7 @@ function * fulfillTransfer (transferId, fulfillmentUri) { transferExpiryMonitor.unwatch(transfer.id) yield db.updateTransfer(transfer, {transaction}) - // Create persistent notification events. We're doing this within the same - // database transaction in order to maximize the reliability of the - // notification system. If the server crashes while trying to post a - // notification it should retry it when it comes back. - yield notificationWorker.queueNotifications(transfer, transaction) + yield notificationBroadcaster.sendNotifications(transfer, transaction) // Start the expiry countdown if the transfer is not yet finalized // If the expires_at has passed by this time we'll consider @@ -446,11 +442,7 @@ function * setTransfer (externalTransfer, requestingUser) { yield processImmediateExecution(transfer, transaction) yield db.upsertTransfer(transfer, {transaction}) - // Create persistent notification events. We're doing this within the same - // database transaction in order to maximize the reliability of the - // notification system. If the server crashes while trying to post a - // notification it should retry it when it comes back. - yield notificationWorker.queueNotifications(transfer, transaction) + yield notificationBroadcaster.sendNotifications(transfer, transaction) }) // Start the expiry countdown if the transfer is not yet finalized diff --git a/src/services/app.js b/src/services/app.js index 6d99e51..44ccaf2 100644 --- a/src/services/app.js +++ b/src/services/app.js @@ -5,5 +5,5 @@ module.exports = new App({ log: require('./log'), config: require('./config'), timerWorker: require('./timerWorker'), - notificationWorker: require('./notificationWorker') + notificationBroadcaster: require('./notificationBroadcaster') }) diff --git a/src/services/notificationBroadcaster.js b/src/services/notificationBroadcaster.js new file mode 100644 index 0000000..955d40b --- /dev/null +++ b/src/services/notificationBroadcaster.js @@ -0,0 +1,6 @@ +'use strict' + +const NotificationBroadcaster = require('../lib/notificationBroadcasterWebsocket') +const log = require('./log') + +module.exports = new NotificationBroadcaster(log('notificationBroadcaster')) diff --git a/src/services/notificationWorker.js b/src/services/notificationWorker.js deleted file mode 100644 index 11ce4a8..0000000 --- a/src/services/notificationWorker.js +++ /dev/null @@ -1,8 +0,0 @@ -'use strict' - -const NotificationWorker = require('../lib/notificationWorker') -const uri = require('./uriManager') -const log = require('./log') -const config = require('./config') - -module.exports = new NotificationWorker(uri, log('notificationWorker'), config) diff --git a/src/services/transferExpiryMonitor.js b/src/services/transferExpiryMonitor.js index 7eb2659..9d6a874 100644 --- a/src/services/transferExpiryMonitor.js +++ b/src/services/transferExpiryMonitor.js @@ -3,6 +3,6 @@ const TransferExpiryMonitor = require('../lib/transferExpiryMonitor').TransferExpiryMonitor const timeQueue = require('./timeQueue') -const notificationWorker = require('./notificationWorker') +const notificationBroadcaster = require('./notificationBroadcaster') -module.exports = new TransferExpiryMonitor(timeQueue, notificationWorker) +module.exports = new TransferExpiryMonitor(timeQueue, notificationBroadcaster) diff --git a/src/sql/pg/create.sql b/src/sql/pg/create.sql index a4c0baf..50b32dd 100644 --- a/src/sql/pg/create.sql +++ b/src/sql/pg/create.sql @@ -87,7 +87,6 @@ CREATE INDEX "L_XIF_TRANSFERS_STATE" ON "L_TRANSFERS" CREATE INDEX "L_XIF_TRANSFERS_REASON" ON "L_TRANSFERS" ("REJECTION_REASON_ID" ASC); - CREATE TABLE "L_TRANSFER_ADJUSTMENTS" ( "TRANSFER_ADJUSTMENT_ID" SERIAL NOT NULL, @@ -112,58 +111,6 @@ CREATE INDEX "L_XIF_TRANSFER_ADJUSTMENTS_ACC" ON "L_TRANSFER_ADJUSTMENTS" CREATE INDEX "L_XIE_TRANSFER_ADJUSTMENTS" ON "L_TRANSFER_ADJUSTMENTS" ("IS_AUTHORIZED" ASC); - -CREATE TABLE IF NOT EXISTS "L_SUBSCRIPTIONS" ( - "SUBSCRIPTION_ID" CHARACTER VARYING(36) NOT NULL, - "OWNER" CHARACTER VARYING(255) NOT NULL, - "EVENT" CHARACTER VARYING(255) NOT NULL, - "SUBJECT" CHARACTER VARYING(1024) NOT NULL, - "TARGET" CHARACTER VARYING(1024) NOT NULL, - "IS_DELETED" BOOLEAN NOT NULL DEFAULT FALSE -); - -CREATE INDEX "L_XPK_SUBSCRIPTIONS" ON "L_SUBSCRIPTIONS" - ("SUBSCRIPTION_ID" ASC); -ALTER TABLE "L_SUBSCRIPTIONS" ADD CONSTRAINT "L_PK_SUBSCRIPTIONS" PRIMARY KEY - ("SUBSCRIPTION_ID"); --- CREATE UNIQUE INDEX "L_XAK_SUBSCRIPTIONS" ON "L_SUBSCRIPTIONS" --- ("SUBSCRIPTION_UUID" ASC); --- ALTER TABLE "L_SUBSCRIPTIONS" ADD CONSTRAINT "L_XAK_SUBSCRIPTIONS" UNIQUE --- ("SUBSCRIPTION_UUID"); -CREATE INDEX "L_XIF_SUBSCRIPTIONS_OWNER" ON "L_SUBSCRIPTIONS" - ("OWNER" ASC); -CREATE INDEX "L_XIF_SUBSCRIPTIONS_SUBJECT" ON "L_SUBSCRIPTIONS" - ("SUBJECT" ASC); -CREATE INDEX "L_XIE_SUBSCRIPTIONS_DELETED" ON "L_SUBSCRIPTIONS" - ("IS_DELETED" ASC); - - -CREATE TABLE IF NOT EXISTS "L_NOTIFICATIONS" ( - "NOTIFICATION_ID" CHARACTER(36) NOT NULL, - "SUBSCRIPTION_ID" CHARACTER(36) NOT NULL, - "TRANSFER_ID" CHARACTER(36) NOT NULL, - "RETRY_COUNT" INTEGER DEFAULT 0 NOT NULL, - "RETRY_AT" TIMESTAMP WITH TIME ZONE NULL -); - -CREATE INDEX "L_XPK_NOTIFICATIONS" ON "L_NOTIFICATIONS" - ("NOTIFICATION_ID" ASC); -ALTER TABLE "L_NOTIFICATIONS" ADD CONSTRAINT "L_PK_NOTIFICATIONS" PRIMARY KEY - ("NOTIFICATION_ID"); --- CREATE INDEX "L_XAK_NOTIFICATIONS" ON "L_NOTIFICATIONS" --- ("NOTIFICATION_UUID" ASC); --- ALTER TABLE "L_NOTIFICATIONS" ADD CONSTRAINT "L_AK_NOTIFICATIONS" UNIQUE --- ("NOTIFICATION_UUID"); -CREATE INDEX "L_XIE_NOTIFICATIONS_RETRY_AT" ON "L_NOTIFICATIONS" - ("RETRY_AT" ASC); -CREATE INDEX "L_XIF_NOTIFICATIONS_SUB" ON "L_NOTIFICATIONS" - ("SUBSCRIPTION_ID" ASC); -CREATE INDEX "L_XIF_NOTIFICATIONS_TRANSFER" ON "L_NOTIFICATIONS" - ("TRANSFER_ID" ASC); --- CREATE INDEX "L_XIE_NOTIFICATIONS_DELETED" ON "L_NOTIFICATIONS" --- ("IS_DELETED" ASC); - - CREATE TABLE IF NOT EXISTS "L_ENTRIES" ( "ENTRY_ID" SERIAL NOT NULL, "TRANSFER_ID" CHARACTER(36) NOT NULL, diff --git a/src/sql/pg/drop.sql b/src/sql/pg/drop.sql index 29c771a..b689351 100644 --- a/src/sql/pg/drop.sql +++ b/src/sql/pg/drop.sql @@ -2,8 +2,6 @@ DROP TABLE IF EXISTS "L_TRANSFER_ADJUSTMENTS" CASCADE; DROP TABLE IF EXISTS "L_ACCOUNTS" CASCADE; DROP TABLE IF EXISTS "L_LU_REJECTION_REASON" CASCADE; DROP TABLE IF EXISTS "L_LU_TRANSFER_STATUS" CASCADE; -DROP TABLE IF EXISTS "L_SUBSCRIPTIONS" CASCADE; DROP TABLE IF EXISTS "L_ENTRIES" CASCADE; DROP TABLE IF EXISTS "L_FULFILLMENTS" CASCADE; -DROP TABLE IF EXISTS "L_NOTIFICATIONS" CASCADE; DROP TABLE IF EXISTS "L_TRANSFERS" CASCADE; diff --git a/src/sql/sqlite3/create.sql b/src/sql/sqlite3/create.sql index db26db6..6edcf93 100644 --- a/src/sql/sqlite3/create.sql +++ b/src/sql/sqlite3/create.sql @@ -55,7 +55,6 @@ create table if not exists "L_TRANSFERS" ( FOREIGN KEY("STATUS_ID") REFERENCES "L_LU_TRANSFER_STATUS" ("STATUS_ID") ); - create table if not exists "L_TRANSFER_ADJUSTMENTS" ( "TRANSFER_ADJUSTMENT_ID" integer not null primary key, @@ -69,34 +68,6 @@ create table if not exists "L_TRANSFER_ADJUSTMENTS" FOREIGN KEY("ACCOUNT_ID") REFERENCES "L_ACCOUNTS" ("ACCOUNT_ID") ); - -create table if not exists "L_SUBSCRIPTIONS" ( - "SUBSCRIPTION_ID" char(36) not null primary key, - "OWNER" varchar(1024), - "EVENT" varchar(255), - "SUBJECT" varchar(1024), - "TARGET" varchar(1024), - "IS_DELETED" boolean default 0 -); - -create index subscriptions_id_is_deleted_index on "L_SUBSCRIPTIONS" - ("SUBSCRIPTION_ID", "IS_DELETED"); - - -create table if not exists "L_NOTIFICATIONS" ( - "NOTIFICATION_ID" char(36) not null primary key, - "SUBSCRIPTION_ID" char(36), - "TRANSFER_ID" char(36), - "RETRY_COUNT" integer, - "RETRY_AT" datetime -); - -create index notifications_retry_at_index on "L_NOTIFICATIONS" - ("RETRY_AT"); -create index subscription_transfer on "L_NOTIFICATIONS" - ("SUBSCRIPTION_ID", "TRANSFER_ID"); - - create table if not exists "L_ENTRIES" ( "ENTRY_ID" integer not null primary key, "TRANSFER_ID" char(36), @@ -104,7 +75,6 @@ create table if not exists "L_ENTRIES" ( "CREATED_AT" datetime default CURRENT_TIMESTAMP ); - create table if not exists "L_FULFILLMENTS" ( "FULFILLMENT_ID" integer not null primary key, "TRANSFER_ID" char(36), diff --git a/src/sql/sqlite3/drop.sql b/src/sql/sqlite3/drop.sql index 12cc693..0a36699 100644 --- a/src/sql/sqlite3/drop.sql +++ b/src/sql/sqlite3/drop.sql @@ -2,8 +2,6 @@ DROP TABLE IF EXISTS L_TRANSFER_ADJUSTMENTS; DROP TABLE IF EXISTS L_ACCOUNTS; DROP TABLE IF EXISTS L_LU_REJECTION_REASON; DROP TABLE IF EXISTS L_LU_TRANSFER_STATUS; -DROP TABLE IF EXISTS L_SUBSCRIPTIONS; DROP TABLE IF EXISTS L_ENTRIES; DROP TABLE IF EXISTS L_FULFILLMENTS; -DROP TABLE IF EXISTS L_NOTIFICATIONS; DROP TABLE IF EXISTS L_TRANSFERS; diff --git a/src/sql/strong-oracle/create.sql b/src/sql/strong-oracle/create.sql index c99c5b9..6b187db 100644 --- a/src/sql/strong-oracle/create.sql +++ b/src/sql/strong-oracle/create.sql @@ -66,30 +66,6 @@ CREATE INDEX L_XIE_FINGERPRINTS ON "L_ACCOUNTS" ("FINGERPRINT" ASC) / - -CREATE TABLE "L_SUBSCRIPTIONS" -( - "SUBSCRIPTION_ID" VARCHAR2(64) NOT NULL , - "OWNER" VARCHAR2(1024) NULL , - "EVENT" VARCHAR2(255) DEFAULT NULL NULL , - "SUBJECT" VARCHAR2(1024) NULL , - "TARGET" VARCHAR2(1024) DEFAULT NULL NULL , - "IS_DELETED" SMALLINT DEFAULT 0 NOT NULL -) -/ - -CREATE INDEX L_XPK_SUBSCRIPTIONS ON "L_SUBSCRIPTIONS" - ("SUBSCRIPTION_ID" ASC) -/ - -ALTER TABLE "L_SUBSCRIPTIONS" ADD CONSTRAINT L_PK_SUBSCRIPTIONS PRIMARY KEY - ("SUBSCRIPTION_ID") -/ - -CREATE INDEX L_XIE_SUBSCRIPTIONS ON "L_SUBSCRIPTIONS" - ("IS_DELETED" ASC) -/ - CREATE TABLE "L_LU_REJECTION_REASON" ( "REJECTION_REASON_ID" INTEGER NOT NULL, "NAME" CHARACTER VARYING(10) NOT NULL, @@ -260,36 +236,6 @@ CREATE INDEX L_XIF_FULFILLMENTS ON "L_FULFILLMENTS" ("TRANSFER_ID" ASC) / -CREATE TABLE "L_NOTIFICATIONS" -( - "NOTIFICATION_ID" VARCHAR2(36) NOT NULL , - "SUBSCRIPTION_ID" VARCHAR2(36) NULL , - "TRANSFER_ID" VARCHAR2(36) NULL , - "RETRY_COUNT" INTEGER DEFAULT 0 NULL , - "RETRY_AT" TIMESTAMP WITH TIME ZONE -) -/ - -CREATE INDEX XPKL_NOTIFICATIONS ON "L_NOTIFICATIONS" - ("NOTIFICATION_ID" ASC) -/ - -ALTER TABLE "L_NOTIFICATIONS" ADD CONSTRAINT L_PK_NOTIFICATIONS PRIMARY KEY - ("NOTIFICATION_ID") -/ - -CREATE INDEX L_XIE_NOTIFICATIONS_RETRY_AT ON "L_NOTIFICATIONS" - ("RETRY_AT" ASC) -/ - -CREATE INDEX L_XIF_NOTIFICATIONS_SUB ON "L_NOTIFICATIONS" - ("SUBSCRIPTION_ID" ASC) -/ - -CREATE INDEX L_XIF_NOTIFICATIONS_TRANSFER ON "L_NOTIFICATIONS" - ("TRANSFER_ID" ASC) -/ - CREATE OR REPLACE TRIGGER L_TRG_ACCOUNTS_SEQ BEFORE INSERT ON "L_ACCOUNTS" diff --git a/src/sql/strong-oracle/drop.sql b/src/sql/strong-oracle/drop.sql index 65e80da..872f9f0 100644 --- a/src/sql/strong-oracle/drop.sql +++ b/src/sql/strong-oracle/drop.sql @@ -38,16 +38,6 @@ EXCEPTION END; / -BEGIN - EXECUTE IMMEDIATE 'DROP TABLE "L_SUBSCRIPTIONS"'; -EXCEPTION - WHEN OTHERS THEN - IF SQLCODE != -942 THEN - RAISE; - END IF; -END; -/ - BEGIN EXECUTE IMMEDIATE 'DROP TABLE "L_ENTRIES"'; EXCEPTION @@ -78,16 +68,6 @@ EXCEPTION END; / -BEGIN - EXECUTE IMMEDIATE 'DROP TABLE "L_NOTIFICATIONS"'; -EXCEPTION - WHEN OTHERS THEN - IF SQLCODE != -942 THEN - RAISE; - END IF; -END; -/ - BEGIN EXECUTE IMMEDIATE 'DROP SEQUENCE L_SEQ_ACCOUNT_PK'; EXCEPTION diff --git a/test/accountSpec.js b/test/accountSpec.js index 0692a58..3c9c381 100644 --- a/test/accountSpec.js +++ b/test/accountSpec.js @@ -8,15 +8,11 @@ const app = require('../src/services/app') const logger = require('../src/services/log') const dbHelper = require('./helpers/db') const appHelper = require('./helpers/app') -const timingHelper = require('./helpers/timing') const logHelper = require('five-bells-shared/testHelpers/log') const getAccount = require('../src/models/db/accounts').getAccount const convertToExternal = require('../src/models/converters/accounts') .convertToExternalAccount -const transferExpiryMonitor = require('../src/services/transferExpiryMonitor') -const notificationWorker = require('../src/services/notificationWorker') - const validator = require('./helpers/validator') const publicKey = fs.readFileSync('./test/data/public.pem', 'utf8') @@ -640,156 +636,4 @@ describe('Accounts', function () { expect(user.public_key).to.equal(publicKey) }) }) - - describe('GET /accounts/:id/transfers (websocket)', function () { - beforeEach(function * () { - const account = 'http://localhost/accounts/alice' - this.socket = this.ws(account + '/transfers', { - headers: { - Authorization: 'Basic ' + new Buffer('alice:alice', 'utf8').toString('base64') - } - }) - - // Wait until WS connection is established - yield new Promise((resolve) => this.socket.on('open', resolve)) - }) - - afterEach(function * () { - this.socket.terminate() - }) - - it('should send notifications about simple transfers', function * () { - const listener = sinon.spy() - this.socket.on('message', (msg) => listener(JSON.parse(msg))) - - const transfer = this.transfer - - yield this.request() - .put(transfer.id) - .auth('alice', 'alice') - .send(transfer) - .expect(201) - .expect(validator.validateTransfer) - .end() - yield notificationWorker.processNotificationQueue() - - // TODO: Is there a more elegant way? - yield timingHelper.sleep(50) - - sinon.assert.calledOnce(listener) - sinon.assert.calledWithMatch(listener.firstCall, { - resource: _.assign({}, transfer, { - state: 'executed', - timeline: { - proposed_at: '2015-06-16T00:00:00.000Z', - prepared_at: '2015-06-16T00:00:00.000Z', - executed_at: '2015-06-16T00:00:00.000Z' - } - }) - }) - }) - - it('should send notifications about executed transfers', function * () { - const listener = sinon.spy() - this.socket.on('message', (msg) => listener(JSON.parse(msg))) - - const transfer = this.transferWithExpiry - const fulfillment = this.fulfillment - - yield this.request() - .put(transfer.id) - .auth('alice', 'alice') - .send(transfer) - .expect(201) - .expect(validator.validateTransfer) - .end() - yield notificationWorker.processNotificationQueue() - - // TODO: Is there a more elegant way? - yield timingHelper.sleep(50) - - sinon.assert.calledOnce(listener) - sinon.assert.calledWithMatch(listener.firstCall, { - resource: _.assign({}, transfer, { - state: 'prepared', - timeline: { - proposed_at: '2015-06-16T00:00:00.000Z', - prepared_at: '2015-06-16T00:00:00.000Z' - } - }) - }) - this.clock.tick(500) - yield this.request() - .put(transfer.id + '/fulfillment') - .send(fulfillment) - .expect(201) - .end() - - // In production this function should be triggered by the workers started in app.js - yield transferExpiryMonitor.processExpiredTransfers() - - // TODO: Is there a more elegant way? - yield timingHelper.sleep(50) - - sinon.assert.calledTwice(listener) - sinon.assert.calledWithMatch(listener.secondCall, { - resource: _.assign({}, transfer, { - state: 'executed', - timeline: { - proposed_at: '2015-06-16T00:00:00.000Z', - prepared_at: '2015-06-16T00:00:00.000Z', - executed_at: '2015-06-16T00:00:00.500Z' - } - }) - }) - }) - - it('should send notifications about rejected transfers', function * () { - const listener = sinon.spy() - this.socket.on('message', (msg) => listener(JSON.parse(msg))) - - const transfer = this.transferWithExpiry - delete transfer.debits[0].authorized - - yield this.request() - .put(transfer.id) - .auth('alice', 'alice') - .send(transfer) - .expect(201) - .expect(validator.validateTransfer) - .end() - yield notificationWorker.processNotificationQueue() - - // TODO: Is there a more elegant way? - yield timingHelper.sleep(50) - - sinon.assert.calledOnce(listener) - sinon.assert.calledWithMatch(listener.firstCall, { - resource: _.assign({}, transfer, { - state: 'proposed', - timeline: { - proposed_at: '2015-06-16T00:00:00.000Z' - } - }) - }) - this.clock.tick(1000) - - // In production this function should be triggered by the workers started in app.js - yield transferExpiryMonitor.processExpiredTransfers() - - // TODO: Is there a more elegant way? - yield timingHelper.sleep(50) - - sinon.assert.calledTwice(listener) - sinon.assert.calledWithMatch(listener.secondCall, { - resource: _.assign({}, transfer, { - state: 'rejected', - timeline: { - proposed_at: '2015-06-16T00:00:00.000Z', - rejected_at: '2015-06-16T00:00:01.000Z' - } - }) - }) - }) - }) }) diff --git a/test/fulfillmentSpec.js b/test/fulfillmentSpec.js index b69274c..50fad97 100644 --- a/test/fulfillmentSpec.js +++ b/test/fulfillmentSpec.js @@ -6,20 +6,14 @@ nock.enableNetConnect(['localhost', '127.0.0.1']) const expect = require('chai').expect const app = require('../src/services/app') const logger = require('../src/services/log') -const notificationWorker = require('../src/services/notificationWorker') const dbHelper = require('./helpers/db') const appHelper = require('./helpers/app') const logHelper = require('five-bells-shared/testHelpers/log') const sinon = require('sinon') const accounts = require('./data/accounts') -const insertSubscriptions = require('../src/models/subscriptions') - .insertSubscriptions const validator = require('./helpers/validator') -const transferDictionary = require('five-bells-shared').TransferStateDictionary const getAccount = require('../src/models/db/accounts').getAccount -const transferStates = transferDictionary.transferStates - const START_DATE = 1434412800000 // June 16, 2015 00:00:00 GMT describe('GET /fulfillment', function () { @@ -332,162 +326,4 @@ describe('PUT /fulfillment', function () { expect((yield getAccount('alice')).balance).to.equal(100) expect((yield getAccount('bob')).balance).to.equal(0) }) - - it('should trigger subscriptions when notification is executed', function * () { - const subscription = require('./data/subscriptions/alice.json') - yield insertSubscriptions([subscription]) - - const transfer = this.preparedTransfer - const transferPrepared = _.assign({}, transfer, { - timeline: { - prepared_at: '2015-06-16T00:00:00.000Z', - proposed_at: '2015-06-16T00:00:00.000Z' - } - }) - - // Expect notification that transfer was prepared - const notificationPrepared = nock('http://subscriber.example') - .post('/notifications', (body) => { - const idParts = body.id.split('/') - const notificationId = idParts[idParts.length - 1] - expect(_.omit(body, 'signature')).to.deep.equal({ - event: 'transfer.update', - id: subscription.id + '/notifications/' + notificationId, - subscription: subscription.id, - resource: transferPrepared - }) - expect(validator.validateNotification.bind(validator.validateNotification, {body: body})).to.not.throw(Error) - return true - }) - .reply(204) - - yield this.request() - .put(transfer.id) - .auth('alice', 'alice') - .send(transfer) - .expect(201) - .expect(transferPrepared) - .expect(validator.validateTransfer) - .end() - yield notificationWorker.processNotificationQueue() - - notificationPrepared.done() - - const transferExecuted = _.assign({}, transfer, { - state: transferStates.TRANSFER_STATE_EXECUTED, - timeline: { - executed_at: '2015-06-16T00:00:00.000Z', - prepared_at: '2015-06-16T00:00:00.000Z', - proposed_at: '2015-06-16T00:00:00.000Z' - } - }) - // Expect notification that transfer was executed - const notificationExecuted = nock('http://subscriber.example') - .post('/notifications', (body) => { - const idParts = body.id.split('/') - const notificationId = idParts[idParts.length - 1] - expect(_.omit(body, 'signature')).to.deep.equal({ - event: 'transfer.update', - id: subscription.id + '/notifications/' + notificationId, - subscription: subscription.id, - resource: transferExecuted, - related_resources: { - execution_condition_fulfillment: this.executionConditionFulfillment - } - }) - expect(validator.validateNotification.bind(validator.validateNotification, {body: body})).to.not.throw(Error) - return true - }) - .reply(204) - - yield this.request() - .put(transfer.id + '/fulfillment') - .send(this.executionConditionFulfillment) - .expect(201) - .expect(this.executionConditionFulfillment) - .expect(validator.validateFulfillment) - .end() - yield notificationWorker.processNotificationQueue() - - notificationExecuted.done() - }) - - it('should trigger subscriptions when notification is cancelled', function * () { - const subscription = require('./data/subscriptions/alice.json') - yield insertSubscriptions([subscription]) - - const transfer = this.preparedTransfer - const transferPrepared = _.assign({}, transfer, { - timeline: { - prepared_at: '2015-06-16T00:00:00.000Z', - proposed_at: '2015-06-16T00:00:00.000Z' - } - }) - - // Expect notification that transfer was prepared - const notificationPrepared = nock('http://subscriber.example') - .post('/notifications', (body) => { - const idParts = body.id.split('/') - const notificationId = idParts[idParts.length - 1] - expect(_.omit(body, 'signature')).to.deep.equal({ - event: 'transfer.update', - id: subscription.id + '/notifications/' + notificationId, - subscription: subscription.id, - resource: transferPrepared - }) - expect(validator.validateNotification.bind(validator.validateNotification, {body: body})).to.not.throw(Error) - return true - }) - .reply(204) - - yield this.request() - .put(transfer.id) - .auth('alice', 'alice') - .send(transfer) - .expect(201) - .expect(transferPrepared) - .expect(validator.validateTransfer) - .end() - yield notificationWorker.processNotificationQueue() - - notificationPrepared.done() - - const transferCancelled = _.assign({}, transfer, { - state: transferStates.TRANSFER_STATE_REJECTED, - rejection_reason: 'cancelled', - timeline: { - rejected_at: '2015-06-16T00:00:00.000Z', - prepared_at: '2015-06-16T00:00:00.000Z', - proposed_at: '2015-06-16T00:00:00.000Z' - } - }) - // Expect notification that transfer was rejected - const notificationCancelled = nock('http://subscriber.example') - .post('/notifications', (body) => { - const idParts = body.id.split('/') - const notificationId = idParts[idParts.length - 1] - expect(_.omit(body, 'signature')).to.deep.equal({ - event: 'transfer.update', - id: subscription.id + '/notifications/' + notificationId, - subscription: subscription.id, - resource: transferCancelled, - related_resources: { - cancellation_condition_fulfillment: this.cancellationConditionFulfillment - } - }) - expect(validator.validateNotification.bind(validator.validateNotification, {body: body})).to.not.throw(Error) - return true - }) - .reply(204) - - yield this.request() - .put(transfer.id + '/fulfillment') - .send(this.cancellationConditionFulfillment) - .expect(201) - .expect(this.cancellationConditionFulfillment) - .end() - yield notificationWorker.processNotificationQueue() - - notificationCancelled.done() - }) }) diff --git a/test/helpers/db.js b/test/helpers/db.js index 824d438..ab3277e 100644 --- a/test/helpers/db.js +++ b/test/helpers/db.js @@ -4,10 +4,6 @@ const db = require('../../src/lib/db') const insertTransfers = require('../../src/models/transfers').insertTransfers const insertAccounts = require('../../src/models/accounts').insertAccounts const setBalance = require('../../src/models/accounts').setBalance -const insertSubscriptions = require('../../src/models/subscriptions') - .insertSubscriptions -const insertNotification = require('../../src/models/db/notifications') - .insertNotification const insertFulfillments = require('../../src/models/db/fulfillments') .insertFulfillments @@ -46,22 +42,6 @@ exports.addTransfers = function * (transfers) { yield insertTransfers(transfers) } -exports.addSubscriptions = function * (subscriptions) { - if (!Array.isArray(subscriptions)) { - throw new Error('Requires an array of subscriptions, got ' + subscriptions) - } - yield insertSubscriptions(subscriptions) -} - -exports.addNotifications = function * (notifications) { - if (!Array.isArray(notifications)) { - throw new Error('Requires an array of notifications, got ' + notifications) - } - for (let i = 0; i < notifications.length; i++) { - yield insertNotification(notifications[i]) - } -} - exports.addFulfillments = function * (fulfillments) { if (!Array.isArray(fulfillments)) { throw new Error('Requires an array of fulfillments, got ' + fulfillments) diff --git a/test/notificationSpec.js b/test/notificationSpec.js index 7f12c6b..7248840 100644 --- a/test/notificationSpec.js +++ b/test/notificationSpec.js @@ -1,14 +1,18 @@ +/*global describe, it*/ 'use strict' const _ = require('lodash') -const nock = require('nock') -nock.enableNetConnect(['localhost', '127.0.0.1']) const sinon = require('sinon') const app = require('../src/services/app') const logger = require('../src/services/log') -const appHelper = require('./helpers/app') const dbHelper = require('./helpers/db') +const appHelper = require('./helpers/app') +const timingHelper = require('./helpers/timing') const logHelper = require('five-bells-shared/testHelpers/log') +const transferExpiryMonitor = require('../src/services/transferExpiryMonitor') +const transferDictionary = require('five-bells-shared').TransferStateDictionary +const transferStates = transferDictionary.transferStates + const validator = require('./helpers/validator') const START_DATE = 1434412800000 // June 16, 2015 00:00:00 GMT @@ -23,84 +27,326 @@ describe('Notifications', function () { beforeEach(function * () { appHelper.create(this, app) yield dbHelper.clean() - // Define example data - this.exampleTransfer = _.cloneDeep(require('./data/transfers/simple')) - this.existingSubscription = _.cloneDeep(require('./data/subscriptions/alice')) - this.exampleSubscription = _.cloneDeep(require('./data/subscriptions/bob')) - this.deletedSubscription = _.cloneDeep(require('./data/subscriptions/deleted')) - this.transferWithExpiry = _.cloneDeep(require('./data/transfers/withExpiry')) - this.existingNotification = _.cloneDeep(require('./data/notificationDatabaseEntry')) - this.notificationDeletedSubscription = _.cloneDeep(require('./data/notificationDeletedSubscription')) - this.notificationResponse = _.cloneDeep(require('./data/notificationResponse')) - - const idParts = this.exampleTransfer.id.split('/') - this.existingFulfillment = { - transfer_id: idParts[idParts.length - 1], - condition_fulfillment: _.cloneDeep(require('./data/fulfillments/execution')) - } - - // Use fake time + this.clock = sinon.useFakeTimers(START_DATE, 'Date') + // Define example data + this.exampleAccounts = _.cloneDeep(require('./data/accounts')) + this.adminAccount = this.exampleAccounts.admin + this.holdAccount = this.exampleAccounts.hold + this.existingAccount = this.exampleAccounts.alice + this.existingAccount2 = this.exampleAccounts.bob + this.traderAccount = this.exampleAccounts.trader + this.disabledAccount = this.exampleAccounts.disabledAccount + this.infiniteMinBalance = this.exampleAccounts.infiniteMinBalance + this.finiteMinBalance = this.exampleAccounts.finiteMinBalance + this.unspecifiedMinBalance = this.exampleAccounts.unspecifiedMinBalance + this.noBalance = this.exampleAccounts.noBalance + + this.transfer = _.cloneDeep(require('./data/transfers/simple')) + this.preparedTransfer = _.cloneDeep(require('./data/transfers/prepared')) + this.executedTransfer = _.cloneDeep(require('./data/transfers/executed')) + this.transferWithExpiry = _.cloneDeep(require('./data/transfers/simpleWithExpiry')) + this.fulfillment = require('./data/fulfillments/execution') + + this.executionConditionFulfillment = _.cloneDeep(require('./data/fulfillments/execution')) + this.cancellationConditionFulfillment = _.cloneDeep(require('./data/fulfillments/cancellation')) + // Store some example data - yield dbHelper.addAccounts(_.values(require('./data/accounts'))) - yield dbHelper.addTransfers([this.exampleTransfer]) - yield dbHelper.addSubscriptions([_.assign({}, this.existingSubscription, {is_deleted: false}), this.deletedSubscription]) - yield dbHelper.addNotifications([this.existingNotification, this.notificationDeletedSubscription]) - yield dbHelper.addFulfillments([this.existingFulfillment]) + yield dbHelper.addAccounts([ + this.adminAccount, + this.holdAccount, + this.existingAccount, + this.existingAccount2, + this.traderAccount, + this.disabledAccount + ]) }) - describe('GET /subscriptions/:subscription_id/notifications/:notification_id', function () { - it('should return 200', function * () { + describe('GET /accounts/:id/transfers (websocket)', function () { + beforeEach(function * () { + const account = 'http://localhost/accounts/alice' + this.socket = this.ws(account + '/transfers', { + headers: { + Authorization: 'Basic ' + new Buffer('alice:alice', 'utf8').toString('base64') + } + }) + + // Wait until WS connection is established + yield new Promise((resolve) => this.socket.on('open', resolve)) + }) + + afterEach(function * () { + this.socket.terminate() + }) + + it('should send notifications about simple transfers', function * () { + const listener = sinon.spy() + this.socket.on('message', (msg) => listener(JSON.parse(msg))) + + const transfer = this.transfer + yield this.request() - .get(this.existingSubscription.id + '/notifications/' + this.existingNotification.id) + .put(transfer.id) .auth('alice', 'alice') - .expect(200) - .expect(this.notificationResponse) - .expect(validator.validateNotification) + .send(transfer) + .expect(201) + .expect(validator.validateTransfer) .end() + + // TODO: Is there a more elegant way? + yield timingHelper.sleep(50) + + sinon.assert.calledOnce(listener) + sinon.assert.calledWithMatch(listener.firstCall, { + resource: _.assign({}, transfer, { + state: 'executed', + timeline: { + proposed_at: '2015-06-16T00:00:00.000Z', + prepared_at: '2015-06-16T00:00:00.000Z', + executed_at: '2015-06-16T00:00:00.000Z' + } + }) + }) }) - it('should return 404 for a non-existent subscription id', function * () { + it('should not send notifications for wrong id', function * () { + const listener = sinon.spy() + this.socket.on('message', (msg) => listener(JSON.parse(msg))) + + const transfer = this.transfer + yield this.request() - .get(this.exampleSubscription.id + '/notifications/' + this.existingNotification.id) - .auth('bob', 'bob') - .expect(404) + .put(transfer.id) + .auth('alice', 'alice') + .send(transfer) + .expect(201) + .expect(validator.validateTransfer) .end() + + // TODO: Is there a more elegant way? + yield timingHelper.sleep(50) + + const transferId = '6f5ab02c-01d2-4016-8816-df6f22b03d94' // a wrong id + this.socket.send(JSON.stringify({ type: 'request_notification', + id: transferId })) + + yield timingHelper.sleep(50) + + sinon.assert.calledOnce(listener) + sinon.assert.calledWithMatch(listener.firstCall, { + resource: _.assign({}, transfer, { + state: 'executed', + timeline: { + proposed_at: '2015-06-16T00:00:00.000Z', + prepared_at: '2015-06-16T00:00:00.000Z', + executed_at: '2015-06-16T00:00:00.000Z' + } + }) + }) }) - it('should return 404 for a deleted subscription id', function * () { + it('should send notifications about executed transfers', function * () { + const listener = sinon.spy() + this.socket.on('message', (msg) => listener(JSON.parse(msg))) + + const transfer = this.transferWithExpiry + const fulfillment = this.fulfillment + + yield this.request() + .put(transfer.id) + .auth('alice', 'alice') + .send(transfer) + .expect(201) + .expect(validator.validateTransfer) + .end() + + // TODO: Is there a more elegant way? + yield timingHelper.sleep(50) + + sinon.assert.calledOnce(listener) + sinon.assert.calledWithMatch(listener.firstCall, { + resource: _.assign({}, transfer, { + state: 'prepared', + timeline: { + proposed_at: '2015-06-16T00:00:00.000Z', + prepared_at: '2015-06-16T00:00:00.000Z' + } + }) + }) + this.clock.tick(500) yield this.request() - .get(this.deletedSubscription.id + '/notifications/' + this.notificationDeletedSubscription.id) - .auth('admin', 'admin') - .expect(404) + .put(transfer.id + '/fulfillment') + .send(fulfillment) + .expect(201) .end() + + // In production this function should be triggered by the workers started in app.js + yield transferExpiryMonitor.processExpiredTransfers() + + // TODO: Is there a more elegant way? + yield timingHelper.sleep(50) + + sinon.assert.calledTwice(listener) + sinon.assert.calledWithMatch(listener.secondCall, { + resource: _.assign({}, transfer, { + state: 'executed', + timeline: { + proposed_at: '2015-06-16T00:00:00.000Z', + prepared_at: '2015-06-16T00:00:00.000Z', + executed_at: '2015-06-16T00:00:00.500Z' + } + }) + }) }) - it('should return 404 for a non-existent notification id', function * () { + it('should send notifications about rejected transfers', function * () { + const listener = sinon.spy() + this.socket.on('message', (msg) => listener(JSON.parse(msg))) + + const transfer = this.transferWithExpiry + delete transfer.debits[0].authorized + yield this.request() - .get(this.existingSubscription.id + '/notifications/ad78bd3c-68ce-488a-9dba-acd99cbff637') - .auth('bob', 'bob') - .expect(404) + .put(transfer.id) + .auth('alice', 'alice') + .send(transfer) + .expect(201) + .expect(validator.validateTransfer) .end() + + // TODO: Is there a more elegant way? + yield timingHelper.sleep(50) + + sinon.assert.calledOnce(listener) + sinon.assert.calledWithMatch(listener.firstCall, { + resource: _.assign({}, transfer, { + state: 'proposed', + timeline: { + proposed_at: '2015-06-16T00:00:00.000Z' + } + }) + }) + this.clock.tick(1000) + + // In production this function should be triggered by the workers started in app.js + yield transferExpiryMonitor.processExpiredTransfers() + + // TODO: Is there a more elegant way? + yield timingHelper.sleep(50) + + sinon.assert.calledTwice(listener) + sinon.assert.calledWithMatch(listener.secondCall, { + resource: _.assign({}, transfer, { + state: 'rejected', + timeline: { + proposed_at: '2015-06-16T00:00:00.000Z', + rejected_at: '2015-06-16T00:00:01.000Z' + } + }) + }) }) - it('should return 403 for a notification from a subscription the user doesn\'t own', function * () { + it('should check fulfillment condition in notification', function * () { + const listener = sinon.spy() + this.socket.on('message', (msg) => listener(JSON.parse(msg))) + + const transfer = this.preparedTransfer + const transferPrepared = _.assign({}, transfer, { + timeline: { + proposed_at: '2015-06-16T00:00:00.000Z', + prepared_at: '2015-06-16T00:00:00.000Z' + } + }) + + yield this.request() + .put(transfer.id) + .auth('alice', 'alice') + .send(transfer) + .expect(201) + .expect(transferPrepared) + .expect(validator.validateTransfer) + .end() + + // TODO: Is there a more elegant way? + yield timingHelper.sleep(50) + + const transferExecuted = _.assign({}, transfer, { + state: transferStates.TRANSFER_STATE_EXECUTED, + timeline: { + executed_at: '2015-06-16T00:00:00.000Z', + prepared_at: '2015-06-16T00:00:00.000Z', + proposed_at: '2015-06-16T00:00:00.000Z' + } + }) + + yield timingHelper.sleep(50) + yield this.request() - .get(this.existingSubscription.id + '/notifications/' + this.existingNotification.id) - .auth('bob', 'bob') - .expect(403) + .put(transfer.id + '/fulfillment') + .send(this.executionConditionFulfillment) + .expect(201) + .expect(this.executionConditionFulfillment) .end() + + yield timingHelper.sleep(50) + + sinon.assert.calledTwice(listener) + sinon.assert.calledWithMatch(listener.firstCall, { resource: transferPrepared }) + sinon.assert.calledWithMatch(listener.secondCall, { + resource: transferExecuted, + related_resources: { execution_condition_fulfillment: this.executionConditionFulfillment } + }) }) - it('should allow an admin to view any notification', function * () { + it('should check cancellation condition in notification', function * () { + const listener = sinon.spy() + this.socket.on('message', (msg) => listener(JSON.parse(msg))) + + const transfer = this.preparedTransfer + const transferPrepared = _.assign({}, transfer, { + timeline: { + proposed_at: '2015-06-16T00:00:00.000Z', + prepared_at: '2015-06-16T00:00:00.000Z' + } + }) + + yield this.request() + .put(transfer.id) + .auth('alice', 'alice') + .send(transfer) + .expect(201) + .expect(transferPrepared) + .expect(validator.validateTransfer) + .end() + + // TODO: Is there a more elegant way? + yield timingHelper.sleep(50) + + const transferCancelled = _.assign({}, transfer, { + state: transferStates.TRANSFER_STATE_REJECTED, + rejection_reason: 'cancelled', + timeline: { + rejected_at: '2015-06-16T00:00:00.000Z', + prepared_at: '2015-06-16T00:00:00.000Z', + proposed_at: '2015-06-16T00:00:00.000Z' + } + }) + + yield timingHelper.sleep(50) + yield this.request() - .get(this.existingSubscription.id + '/notifications/' + this.existingNotification.id) - .auth('admin', 'admin') - .expect(200) - .expect(this.notificationResponse) - .expect(validator.validateNotification) + .put(transfer.id + '/fulfillment') + .send(this.cancellationConditionFulfillment) + .expect(201) + .expect(this.cancellationConditionFulfillment) .end() + + yield timingHelper.sleep(50) + + sinon.assert.calledTwice(listener) + sinon.assert.calledWithMatch(listener.firstCall, { resource: transferPrepared }) + sinon.assert.calledWithMatch(listener.secondCall, { resource: transferCancelled }) }) }) }) diff --git a/test/putTransferSpec.js b/test/putTransferSpec.js index 9bc980d..4795957 100644 --- a/test/putTransferSpec.js +++ b/test/putTransferSpec.js @@ -17,12 +17,9 @@ const upsertAccount = require('../src/models/db/accounts').upsertAccount const getAccount = require('../src/models/db/accounts').getAccount const logHelper = require('five-bells-shared/testHelpers/log') const sinon = require('sinon') -const notificationWorker = require('../src/services/notificationWorker') const accounts = require('./data/accounts') const validator = require('./helpers/validator') const transferDictionary = require('five-bells-shared').TransferStateDictionary -const insertSubscriptions = require('../src/models/subscriptions') - .insertSubscriptions const transferStates = transferDictionary.transferStates @@ -1020,103 +1017,7 @@ describe('PUT /transfers/:id', function () { .end() }) - /* Subscriptions */ - it('should trigger subscriptions', function * () { - const subscription = require('./data/subscriptions/alice.json') - yield insertSubscriptions([subscription]) - - const transfer = this.exampleTransfer - const transferResult = _.assign({}, transfer, { - state: transferStates.TRANSFER_STATE_EXECUTED, - timeline: { - executed_at: '2015-06-16T00:00:00.000Z', - prepared_at: '2015-06-16T00:00:00.000Z', - proposed_at: '2015-06-16T00:00:00.000Z' - } - }) - - const notification = nock('http://subscriber.example') - .post('/notifications', (body) => { - const idParts = body.id.split('/') - const notificationId = idParts[idParts.length - 1] - expect(_.omit(body, 'signature')).to.deep.equal({ - event: 'transfer.update', - id: subscription.id + '/notifications/' + notificationId, - subscription: subscription.id, - resource: transferResult - }) - expect(validator.validateNotification.bind(validator.validateNotification, {body: body})).to.not.throw(Error) - return true - }) - .reply(204) - - yield this.request() - .put(this.exampleTransfer.id) - .auth('alice', 'alice') - .send(transfer) - .expect(201) - .expect(transferResult) - .expect(validator.validateTransfer) - .end() - - yield notificationWorker.processNotificationQueue() - - notification.done() - }) - - it('should not trigger subscriptions if the subscription is deleted', function * () { - const subscription = require('./data/subscriptions/alice.json') - yield insertSubscriptions([subscription]) - - // Delete subscription - yield this.request() - .delete(subscription.id) - .auth('alice', 'alice') - .expect(204) - .end() - - const transfer = this.exampleTransfer - const transferResult = _.assign({}, transfer, { - state: transferStates.TRANSFER_STATE_EXECUTED, - timeline: { - executed_at: '2015-06-16T00:00:00.000Z', - prepared_at: '2015-06-16T00:00:00.000Z', - proposed_at: '2015-06-16T00:00:00.000Z' - } - }) - - const notification = nock('http://subscriber.example') - .post('/notifications', (body) => { - const idParts = body.id.split('/') - const notificationId = idParts[idParts.length - 1] - expect(_.omit(body, 'signature')).to.deep.equal({ - event: 'transfer.update', - id: subscription.id + '/notifications/' + notificationId, - subscription: subscription.id, - resource: transferResult - }) - expect(validator.validateNotification.bind(validator.validateNotification, {body: body})).to.not.throw(Error) - return true - }) - .reply(204) - - yield this.request() - .put(this.exampleTransfer.id) - .auth('alice', 'alice') - .send(transfer) - .expect(201) - .expect(transferResult) - .expect(validator.validateTransfer) - .end() - - yield notificationWorker.processNotificationQueue() - - // Should not send notification updates - expect(notification.isDone()).to.be.false - }) - /* Multiple credits and/or debits */ - it('should handle transfers with multiple credits', function * () { const transfer = this.multiCreditTransfer diff --git a/test/subscriptionSpec.js b/test/subscriptionSpec.js deleted file mode 100644 index 753caab..0000000 --- a/test/subscriptionSpec.js +++ /dev/null @@ -1,312 +0,0 @@ -'use strict' - -const _ = require('lodash') -const nock = require('nock') -nock.enableNetConnect(['localhost', '127.0.0.1']) -const expect = require('chai').expect -const sinon = require('sinon') -const app = require('../src/services/app') -const logger = require('../src/services/log') -const appHelper = require('./helpers/app') -const dbHelper = require('./helpers/db') -const getSubscription = require('../src/models/db/subscriptions') - .getSubscription -const uri = require('../src/services/uriManager') -const logHelper = require('five-bells-shared/testHelpers/log') -const transferExpiryMonitor = require('../src/services/transferExpiryMonitor') -const notificationWorker = require('../src/services/notificationWorker') -const validator = require('./helpers/validator') -const convertToExternalSubscription = - require('../src/models/converters/subscriptions') - .convertToExternalSubscription - -const START_DATE = 1434412800000 // June 16, 2015 00:00:00 GMT - -describe('Subscriptions', function () { - logHelper(logger) - - before(function * () { - yield dbHelper.init() - }) - - beforeEach(function * () { - appHelper.create(this, app) - yield dbHelper.clean() - // Define example data - this.exampleTransfer = _.cloneDeep(require('./data/transfers/simple')) - this.exampleSubscription = _.cloneDeep(require('./data/subscriptions/alice')) - this.existingSubscription = _.cloneDeep(require('./data/subscriptions/bob')) - this.deletedSubscription = _.cloneDeep(require('./data/subscriptions/deleted')) - this.transferWithExpiry = _.cloneDeep(require('./data/transfers/withExpiry')) - - // Use fake time - this.clock = sinon.useFakeTimers(START_DATE, 'Date') - - // Store some example data - yield dbHelper.addAccounts(_.values(require('./data/accounts'))) - yield dbHelper.addSubscriptions([_.assign({}, this.existingSubscription, {is_deleted: false}), this.deletedSubscription]) - }) - - describe('GET /subscriptions/:uuid', function () { - it('should return 200', function * () { - yield this.request() - .get(this.existingSubscription.id) - .auth('bob', 'bob') - .expect(200) - .expect(this.existingSubscription) - .expect(validator.validateSubscription) - .end() - }) - - it('should return 404 for a non-existent subscription', function * () { - yield this.request() - .get(this.exampleSubscription.id) - .auth('bob', 'bob') - .expect(404) - .end() - }) - - it('should return 404 for a deleted subscription', function * () { - yield this.request() - .get(this.deletedSubscription.id) - .auth('admin', 'admin') - .expect(404) - .end() - }) - - it('should return 403 for a subscription the user doesn\'t own', function * () { - yield this.request() - .get(this.existingSubscription.id) - .auth('alice', 'alice') - .expect(403) - .end() - }) - - it('should allow an admin to view any subscription', function * () { - yield this.request() - .get(this.existingSubscription.id) - .auth('admin', 'admin') - .expect(200) - .expect(this.existingSubscription) - .expect(validator.validateSubscription) - .end() - }) - }) - - describe('PUT /subscriptions', function () { - it('should return 201', function * () { - const id = uri.parse(this.exampleSubscription.id, 'subscription').id - yield this.request() - .put(this.exampleSubscription.id) - .send(this.exampleSubscription) - .auth('alice', 'alice') - .expect(201) - .expect(this.exampleSubscription) - .expect(validator.validateSubscription) - .end() - - // Check that the subscription landed in the database - expect(convertToExternalSubscription(yield getSubscription(id))) - .to.deep.equal(this.exampleSubscription) - }) - - it('should return 200 when updating the target URL', function * () { - this.existingSubscription.target = 'http://subscriber2.example/hooks' - yield this.request() - .put(this.existingSubscription.id) - .send(this.existingSubscription) - .auth('bob', 'bob') - .expect(200) - .expect(this.existingSubscription) - .expect(validator.validateSubscription) - .end() - - // Check that the subscription url is changed in the database - const id = uri.parse(this.existingSubscription.id, 'subscription').id - expect(convertToExternalSubscription(yield getSubscription(id))) - .to.deep.equal(this.existingSubscription) - }) - - it('should return 400 when updating a deleted subscription', function * () { - yield this.request() - .delete(this.existingSubscription.id) - .auth('bob', 'bob') - .expect(204) - .end() - - this.existingSubscription.target = 'http://subscriber2.example/hooks' - yield this.request() - .put(this.existingSubscription.id) - .send(this.existingSubscription) - .auth('bob', 'bob') - .expect(400) - .end() - }) - - it('should return 400 when putting a deleted subscription', function * () { - yield this.request() - .delete(this.existingSubscription.id) - .auth('bob', 'bob') - .expect(204) - .end() - - yield this.request() - .put(this.existingSubscription.id) - .send(this.existingSubscription) - .auth('bob', 'bob') - .expect(400) - .end() - }) - - it('should return a 422 when the event/target/subject matches an existing subscription', function * () { - yield this.request() - .put(this.existingSubscription.id) - .send(this.existingSubscription) - .auth('bob', 'bob') - .expect(422) - .end() - }) - - it('should return 403 for an account the user does not own', function * () { - yield this.request() - .put(this.exampleSubscription.id) - .send(this.exampleSubscription) - .auth('bob', 'bob') - .expect(403) - .end() - }) - - it('should return 403 if the user doesn\'t own the subject account', function * () { - this.exampleSubscription.subject = this.existingSubscription.subject - yield this.request() - .put(this.exampleSubscription.id) - .send(this.exampleSubscription) - .auth('alice', 'alice') - .expect(403) - .end() - }) - - it('should return 201 when an admin subscribes to any subject account', function * () { - this.exampleSubscription.owner = 'http://localhost/accounts/admin' - /* The subject is Alices's account */ - yield this.request() - .put(this.exampleSubscription.id) - .send(this.exampleSubscription) - .auth('admin', 'admin') - .expect(201) - .expect(validator.validateSubscription) - .end() - }) - }) - - describe('DELETE /subscriptions/:uuid', function () { - it('should return 204', function * () { - yield this.request() - .delete(this.existingSubscription.id) - .auth('bob', 'bob') - .expect(204) - .end() - - // Ensure subscription is no longer returned - yield this.request() - .get(this.existingSubscription.id) - .auth('bob', 'bob') - .expect(404) - .end() - }) - - it('should return 403 if the user tries to delete a subscription they don\'t own', function * () { - yield this.request() - .delete(this.existingSubscription.id) - .auth('alice', 'alice') - .expect(403) - .end() - }) - - it('should return 204 when an admin deletes any subscription', function * () { - yield this.request() - .delete(this.existingSubscription.id) - .auth('admin', 'admin') - .expect(204) - .end() - }) - - it('should not insert a db record if the subscription does not exist', function * () { - const nonExistentSubscriptionId = '8c314fbe-8e07-4735-9cc7-3aea8a08193b' - yield this.request() - .delete('http://localhost/subscriptions/' + nonExistentSubscriptionId) - .auth('admin', 'admin') - .expect(404) - .end() - - expect(yield getSubscription(nonExistentSubscriptionId)).to.be.null - }) - }) - - // TODO put all tests related to expiring transfers in one file - describe('Expired Transfer Notification', function () { - it('should notify subscribers for expired transfers', function * () { - const subscriberNock = nock('http://subscriber.example') - .post('/notifications') - .times(2) // once for original submission, once on expiry - .reply(204) - - const transfer = this.transferWithExpiry - delete transfer.debits[0].authorized - delete transfer.debits[1].authorized - - yield this.request() - .put(transfer.id) - .auth('alice', 'alice') - .send(transfer) - .expect(201) - .expect(validator.validateTransfer) - .end() - yield notificationWorker.processNotificationQueue() - this.clock.tick(1000) - - // In production this function should be triggered by the workers started in app.js - yield transferExpiryMonitor.processExpiredTransfers() - yield notificationWorker.processNotificationQueue() - - // Make sure we were notified - subscriberNock.done() - }) - }) - - describe('Retry failed notifications', function () { - it('re-posts the notification until success', function * () { - const subscriberNock1 = nock('http://subscriber.example') - .post('/notifications') - .reply(400) // fail the first time - const subscriberNock2 = nock('http://subscriber.example') - .post('/notifications') - .reply(204) - - const transfer = this.exampleTransfer - delete transfer.debits[0].authorized - yield this.request() - .put(transfer.id) - .auth('alice', 'alice') - .send(transfer) - .expect(201) - .expect(validator.validateTransfer) - .end() - yield notificationWorker.processNotificationQueue() - subscriberNock1.done() - - this.clock.tick(500) - // This doesn't notify because we are still waiting another 1.5 seconds for the retry. - yield notificationWorker.processNotificationQueue() - expect(subscriberNock2.isDone()).to.equal(false) - - // MySQL uses second precision so we add 1000ms to account for - // rounding. - this.clock.tick(2500) - yield notificationWorker.processNotificationQueue() - - // Make sure we were notified - subscriberNock2.done() - }) - }) -})