From 647ea9b686d207ea2805eb6323dca3656546ddce Mon Sep 17 00:00:00 2001 From: scottinet Date: Thu, 14 Feb 2019 14:11:41 +0100 Subject: [PATCH 1/2] [realtime] properly handle tokenExpired events --- src/controllers/realtime/index.js | 26 ++++- src/controllers/realtime/room.js | 19 +++- test/controllers/realtime.test.js | 73 +++++++++---- test/controllers/realtime/room.test.js | 144 +++++++++++++++++-------- 4 files changed, 193 insertions(+), 69 deletions(-) diff --git a/src/controllers/realtime/index.js b/src/controllers/realtime/index.js index b14743450..fa58c6a03 100644 --- a/src/controllers/realtime/index.js +++ b/src/controllers/realtime/index.js @@ -1,16 +1,16 @@ const Room = require('./room'); -class RealTimeController { +const expirationThrottleDelay = 1000; +class RealTimeController { /** * @param {Kuzzle} kuzzle */ constructor (kuzzle) { this._kuzzle = kuzzle; + this.lastExpirationTimestamp = 0; this.subscriptions = { - filters: {}, - channels: {} }; } @@ -68,7 +68,7 @@ class RealTimeController { throw new Error('Kuzzle.realtime.subscribe: a callback function is required'); } - const room = new Room(this.kuzzle, index, collection, filters, callback, options); + const room = new Room(this, index, collection, filters, callback, options); return room.subscribe() .then(() => { @@ -105,6 +105,24 @@ class RealTimeController { return response.result; }); } + + /** + * Removes all subscriptions, and emit a "tokenExpired" event + * (tries to prevent event duplication by throttling it) + */ + tokenExpired() { + for (const roomId of Object.keys(this.subscriptions)) { + this.subscriptions[roomId].forEach(room => room.removeListeners()); + } + + this.subscriptions = {}; + + const now = Date.now(); + if ((now - this.lastExpirationTimestamp) > expirationThrottleDelay) { + this.lastExpirationTimestamp = now; + this.kuzzle.emit('tokenExpired'); + } + } } module.exports = RealTimeController; diff --git a/src/controllers/realtime/room.js b/src/controllers/realtime/room.js index 1bbc5c633..51245b764 100644 --- a/src/controllers/realtime/room.js +++ b/src/controllers/realtime/room.js @@ -1,15 +1,16 @@ class Room { /** - * @param {Kuzzle} kuzzle + * @param {RealTimeController} controller * @param {string} index * @param {string} collection * @param {object} body * @param {function} callback * @param {object} options */ - constructor (kuzzle, index, collection, body, callback, options = {}) { - this.kuzzle = kuzzle; + constructor (controller, index, collection, body, callback, options = {}) { + this.controller = controller; + this.kuzzle = controller.kuzzle; this.index = index; this.collection = collection; this.callback = callback; @@ -33,7 +34,7 @@ class Room { this.autoResubscribe = typeof options.autoResubscribe === 'boolean' ? options.autoResubscribe - : kuzzle.autoResubscribe; + : this.kuzzle.autoResubscribe; this.subscribeToSelf = typeof options.subscribeToSelf === 'boolean' ? options.subscribeToSelf : true; @@ -71,7 +72,15 @@ class Room { } _channelListener (data) { - const fromSelf = data.volatile && data.volatile.sdkInstanceId === this.kuzzle.protocol.id; + // intercept token expiration messages and relay them to the parent + // controller + if (data.type === 'TokenExpired') { + return this.controller.tokenExpired(); + } + + const fromSelf = + data.volatile && data.volatile.sdkInstanceId === this.kuzzle.protocol.id; + if (this.subscribeToSelf || !fromSelf) { this.callback(data); } diff --git a/test/controllers/realtime.test.js b/test/controllers/realtime.test.js index 6dc3e27ba..0b44929b1 100644 --- a/test/controllers/realtime.test.js +++ b/test/controllers/realtime.test.js @@ -11,7 +11,8 @@ describe('Realtime Controller', () => { beforeEach(() => { kuzzle = { - query: sinon.stub().resolves() + query: sinon.stub().resolves(), + emit: sinon.stub() }; kuzzle.realtime = new RealtimeController(kuzzle); }); @@ -96,22 +97,27 @@ describe('Realtime Controller', () => { beforeEach(() => { room = null; - mockrequire('../../src/controllers/realtime/room', function (kuz, index, collection, body, callback, opts = {}) { - room = { - kuzzle: kuz, - index, - collection, - body, - callback, - options: opts, - id: roomId, - subscribe: sinon.stub().resolves(subscribeResponse) - }; - - return room; - }); - - kuzzle.realtime = new (mockrequire.reRequire('../../src/controllers/realtime/index'))(kuzzle); + mockrequire( + '../../src/controllers/realtime/room', + function (controller, index, collection, body, callback, opts = {}) { + room = { + controller, + index, + collection, + body, + callback, + kuzzle: controller.kuzzle, + options: opts, + id: roomId, + subscribe: sinon.stub().resolves(subscribeResponse) + }; + + return room; + }); + + const MockRealtimeController = + mockrequire.reRequire('../../src/controllers/realtime/index'); + kuzzle.realtime = new MockRealtimeController(kuzzle); }); it('should throw an error if the "index" argument is not provided', () => { @@ -245,7 +251,7 @@ describe('Realtime Controller', () => { }); }); - it('should call realtime/unsubiscribe query with the roomId and return a Promise which resolves the roomId', () => { + it('should call realtime/unsubscribe query with the roomId and return a Promise which resolves the roomId', () => { return kuzzle.realtime.unsubscribe(roomId, options) .then(res => { should(kuzzle.query) @@ -260,4 +266,35 @@ describe('Realtime Controller', () => { }); }); }); + + describe('#tokenExpired', () => { + it('should clear all subscriptions and emit a "tokenExpired" event', () => { + const stub = sinon.stub(); + + for (let i = 0; i < 10; i++) { + kuzzle.realtime.subscriptions[uuidv4()] = [{removeListeners: stub}]; + } + + kuzzle.realtime.tokenExpired(); + + should(kuzzle.realtime.subscriptions).be.empty(); + should(stub.callCount).be.eql(10); + should(kuzzle.emit).calledOnce().calledWith('tokenExpired'); + }); + + it('should throttle to prevent emitting duplicate occurrences of the same event', () => { + const stub = sinon.stub(); + + for (let i = 0; i < 10; i++) { + kuzzle.realtime.subscriptions[uuidv4()] = [{removeListeners: stub}]; + + kuzzle.realtime.tokenExpired(); + + should(kuzzle.realtime.subscriptions).be.empty(); + should(stub.callCount).be.eql(i+1); + } + + should(kuzzle.emit).calledOnce().calledWith('tokenExpired'); + }); + }); }); diff --git a/test/controllers/realtime/room.test.js b/test/controllers/realtime/room.test.js index 0681c9f09..f176aa5b9 100644 --- a/test/controllers/realtime/room.test.js +++ b/test/controllers/realtime/room.test.js @@ -9,21 +9,24 @@ describe('Room', () => { eventEmitter = new KuzzleEventEmitter(), options = {opt: 'in'}; - let kuzzle; + let controller; beforeEach(() => { - kuzzle = { - query: sinon.stub().resolves(), - addListener: (evt, listener) => { - eventEmitter.addListener(evt, listener); - }, - removeListener: (evt, listener) => { - eventEmitter.removeListener(evt, listener); + controller = { + kuzzle: { + query: sinon.stub().resolves(), + addListener: (evt, listener) => { + eventEmitter.addListener(evt, listener); + }, + removeListener: (evt, listener) => { + eventEmitter.removeListener(evt, listener); + }, + protocol: new KuzzleEventEmitter() }, - protocol: new KuzzleEventEmitter() + tokenExpired: sinon.stub() }; - kuzzle.protocol.id = 'kuz-protocol-id'; + controller.kuzzle.protocol.id = 'kuz-protocol-id'; }); describe('constructor', () => { @@ -32,11 +35,13 @@ describe('Room', () => { body = {foo: 'bar'}, cb = sinon.stub(); - kuzzle.autoResubscribe = 'default'; + controller.kuzzle.autoResubscribe = 'default'; - const room = new Room(kuzzle, 'index', 'collection', body, cb, options); + const room = new Room( + controller, 'index', 'collection', body, cb, options); - should(room.kuzzle).be.equal(kuzzle); + should(room.controller).be.equal(controller); + should(room.kuzzle).be.equal(controller.kuzzle); should(room.index).be.equal('index'); should(room.collection).be.equal('collection'); @@ -71,7 +76,7 @@ describe('Room', () => { body = {foo: 'bar'}, cb = sinon.stub(); - const room = new Room(kuzzle, 'index', 'collection', body, cb, opts); + const room = new Room(controller, 'index', 'collection', body, cb, opts); should(room.options).be.empty(); @@ -86,12 +91,17 @@ describe('Room', () => { body = {foo: 'bar'}, cb = sinon.stub(); - kuzzle.autoResubscribe = 'default'; + controller.kuzzle.autoResubscribe = 'default'; const - room1 = new Room(kuzzle, 'index', 'collection', body, cb, {autoResubscribe: true}), - room2 = new Room(kuzzle, 'index', 'collection', body, cb, {autoResubscribe: false}), - room3 = new Room(kuzzle, 'index', 'collection', body, cb, {autoResubscribe: 'foobar'}); + room1 = new Room( + controller, 'index', 'collection', body, cb, {autoResubscribe: true}), + room2 = new Room( + controller, 'index', 'collection', body, cb, + {autoResubscribe: false}), + room3 = new Room( + controller, 'index', 'collection', body, cb, + {autoResubscribe: 'foobar'}); should(room1.options).be.empty(); should(room2.options).be.empty(); @@ -108,9 +118,14 @@ describe('Room', () => { cb = sinon.stub(); const - room1 = new Room(kuzzle, 'index', 'collection', body, cb, {subscribeToSelf: true}), - room2 = new Room(kuzzle, 'index', 'collection', body, cb, {subscribeToSelf: false}), - room3 = new Room(kuzzle, 'index', 'collection', body, cb, {subscribeToSelf: 'foobar'}); + room1 = new Room( + controller, 'index', 'collection', body, cb, {subscribeToSelf: true}), + room2 = new Room( + controller, 'index', 'collection', body, cb, + {subscribeToSelf: false}), + room3 = new Room( + controller, 'index', 'collection', body, cb, + {subscribeToSelf: 'foobar'}); should(room1.options).be.empty(); should(room2.options).be.empty(); @@ -123,22 +138,33 @@ describe('Room', () => { }); describe('subscribe', () => { - const response = {result: {roomId: 'my-room-id', channel: 'subscription-channel'}}; + const response = { + result: { + roomId: 'my-room-id', + channel: 'subscription-channel' + } + }; beforeEach(() => { - kuzzle.query.resolves(response); + controller.kuzzle.query.resolves(response); }); it('should call realtime/subscribe action with subscribe filters and return a promise that resolve the roomId and channel', () => { const - opts = {opt: 'in', scope: 'in', state: 'done', users: 'all', volatile: {bar: 'foo'}}, + opts = { + opt: 'in', + scope: 'in', + state: 'done', + users: 'all', + volatile: {bar: 'foo'} + }, body = {foo: 'bar'}, cb = sinon.stub(), - room = new Room(kuzzle, 'index', 'collection', body, cb, opts); + room = new Room(controller, 'index', 'collection', body, cb, opts); return room.subscribe() .then(res => { - should(kuzzle.query) + should(controller.kuzzle.query) .be.calledOnce() .be.calledWith({ controller: 'realtime', @@ -158,10 +184,16 @@ describe('Room', () => { it('should set "id" and "channel" properties', () => { const - opts = {opt: 'in', scope: 'in', state: 'done', users: 'all', volatile: {bar: 'foo'}}, + opts = { + opt: 'in', + scope: 'in', + state: 'done', + users: 'all', + volatile: {bar: 'foo'} + }, body = {foo: 'bar'}, cb = sinon.stub(), - room = new Room(kuzzle, 'index', 'collection', body, cb, opts); + room = new Room(controller, 'index', 'collection', body, cb, opts); return room.subscribe() .then(() => { @@ -172,18 +204,24 @@ describe('Room', () => { it('should call _channelListener while receiving data on the current channel', () => { const - opts = {opt: 'in', scope: 'in', state: 'done', users: 'all', volatile: {bar: 'foo'}}, + opts = { + opt: 'in', + scope: 'in', + state: 'done', + users: 'all', + volatile: {bar: 'foo'} + }, body = {foo: 'bar'}, cb = sinon.stub(), - room = new Room(kuzzle, 'index', 'collection', body, cb, opts); + room = new Room(controller, 'index', 'collection', body, cb, opts); room._channelListener = sinon.stub(); return room.subscribe() .then(() => { - kuzzle.protocol.emit('my-room-id', 'message 1'); - kuzzle.protocol.emit('subscription-channel', 'message 2'); - kuzzle.protocol.emit('subscription-channel', 'message 3'); + controller.kuzzle.protocol.emit('my-room-id', 'message 1'); + controller.kuzzle.protocol.emit('subscription-channel', 'message 2'); + controller.kuzzle.protocol.emit('subscription-channel', 'message 3'); should(room._channelListener).be.calledTwice(); should(room._channelListener.firstCall).be.calledWith('message 2'); should(room._channelListener.secondCall).be.calledWith('message 3'); @@ -192,10 +230,16 @@ describe('Room', () => { it('should call _reSubscribeListener once reconnected', () => { const - opts = {opt: 'in', scope: 'in', state: 'done', users: 'all', volatile: {bar: 'foo'}}, + opts = { + opt: 'in', + scope: 'in', + state: 'done', + users: 'all', + volatile: {bar: 'foo'} + }, body = {foo: 'bar'}, cb = sinon.stub(), - room = new Room(kuzzle, 'index', 'collection', body, cb, opts); + room = new Room(controller, 'index', 'collection', body, cb, opts); room._reSubscribeListener = sinon.stub(); @@ -212,28 +256,30 @@ describe('Room', () => { let room; beforeEach(() => { - room = new Room(kuzzle, 'index', 'collection', {foo: 'bar'}, sinon.stub(), options); + room = new Room( + controller, 'index', 'collection', {foo: 'bar'}, sinon.stub(), options); room.id = 'my-room-id'; room.channel = 'subscription-channel'; }); it('should not listen to channel messages anymore', () => { room._channelListener = sinon.stub(); - kuzzle.protocol.on('subscription-channel', room._channelListener); + controller.kuzzle.protocol.on( + 'subscription-channel', room._channelListener); should(room._channelListener).not.be.called(); - kuzzle.protocol.emit('subscription-channel', 'message'); + controller.kuzzle.protocol.emit('subscription-channel', 'message'); should(room._channelListener).be.calledOnce(); room._channelListener.reset(); room.removeListeners(); - kuzzle.protocol.emit('subscription-channel', 'message'); + controller.kuzzle.protocol.emit('subscription-channel', 'message'); should(room._channelListener).not.be.called(); }); it('should not listen to "reconnected" event anymore', () => { room._reSubscribeListener = sinon.stub(); - kuzzle.addListener('reconnected', room._reSubscribeListener); + controller.kuzzle.addListener('reconnected', room._reSubscribeListener); should(room._reSubscribeListener).not.be.called(); eventEmitter.emit('reconnected'); @@ -253,7 +299,8 @@ describe('Room', () => { beforeEach(() => { cb = sinon.stub(); - room = new Room(kuzzle, 'index', 'collection', {foo: 'bar'}, cb, options); + room = new Room( + controller, 'index', 'collection', {foo: 'bar'}, cb, options); room.id = 'my-room-id'; room.channel = 'subscription-channel'; }); @@ -319,6 +366,18 @@ describe('Room', () => { room._channelListener(data); should(cb).not.be.called(); }); + + it('should redirect a tokenExpired notification to the parent controller', () => { + const data = { + type: 'TokenExpired', + foo: 'bar' + }; + + room._channelListener(data); + + should(cb).not.be.called(); + should(controller.tokenExpired).be.called(); + }); }); describe('_reSubscribeListener', () => { @@ -326,7 +385,8 @@ describe('Room', () => { room; beforeEach(() => { - room = new Room(kuzzle, 'index', 'collection', {foo: 'bar'}, sinon.stub(), options); + room = new Room( + controller, 'index', 'collection', {foo: 'bar'}, sinon.stub(), options); room.subscribe = sinon.stub(); }); From 2347e893c3ce82de415f7a6f8ce74de1f69e7ba3 Mon Sep 17 00:00:00 2001 From: scottinet Date: Thu, 14 Feb 2019 14:15:18 +0100 Subject: [PATCH 2/2] [tokenExpired] clear the JWT upon receiving a TokenExpired notification --- src/controllers/realtime/index.js | 1 + test/controllers/realtime.test.js | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/controllers/realtime/index.js b/src/controllers/realtime/index.js index fa58c6a03..42a23af87 100644 --- a/src/controllers/realtime/index.js +++ b/src/controllers/realtime/index.js @@ -116,6 +116,7 @@ class RealTimeController { } this.subscriptions = {}; + this.kuzzle.jwt = undefined; const now = Date.now(); if ((now - this.lastExpirationTimestamp) > expirationThrottleDelay) { diff --git a/test/controllers/realtime.test.js b/test/controllers/realtime.test.js index 0b44929b1..6b8a4aaaa 100644 --- a/test/controllers/realtime.test.js +++ b/test/controllers/realtime.test.js @@ -271,6 +271,8 @@ describe('Realtime Controller', () => { it('should clear all subscriptions and emit a "tokenExpired" event', () => { const stub = sinon.stub(); + kuzzle.jwt = 'foobar'; + for (let i = 0; i < 10; i++) { kuzzle.realtime.subscriptions[uuidv4()] = [{removeListeners: stub}]; } @@ -280,6 +282,7 @@ describe('Realtime Controller', () => { should(kuzzle.realtime.subscriptions).be.empty(); should(stub.callCount).be.eql(10); should(kuzzle.emit).calledOnce().calledWith('tokenExpired'); + should(kuzzle.jwt).be.undefined(); }); it('should throttle to prevent emitting duplicate occurrences of the same event', () => {