From 98a914bb9431c8ad357e8c4c020381a1d534c9df Mon Sep 17 00:00:00 2001 From: Thomas Date: Thu, 22 Aug 2024 12:48:53 +0200 Subject: [PATCH] Merge pull request #20034 from overleaf/tm-collab-limit-edit-invites Enforce collaborator limit when accepting project invites GitOrigin-RevId: 94f281113fe7c7b6d0a5ef43e11ab579400d9e56 --- .../Collaborators/CollaboratorsHandler.js | 11 +- .../CollaboratorsInviteController.js | 7 +- .../CollaboratorsInviteGetter.js | 55 ++++ .../CollaboratorsInviteHandler.js | 79 +++--- .../Features/Editor/EditorHttpController.js | 4 +- .../Subscription/LimitationsManager.js | 19 +- .../CollaboratorsHandlerTests.js | 26 ++ .../CollaboratorsInviteControllerTests.js | 46 ++-- .../CollaboratorsInviteGetterTests.js | 232 ++++++++++++++++ .../CollaboratorsInviteHandlerTests.js | 251 ++++++------------ .../src/Editor/EditorHttpControllerTests.js | 6 +- .../Subscription/LimitationsManagerTests.js | 105 ++++++-- 12 files changed, 575 insertions(+), 266 deletions(-) create mode 100644 services/web/app/src/Features/Collaborators/CollaboratorsInviteGetter.js create mode 100644 services/web/test/unit/src/Collaborators/CollaboratorsInviteGetterTests.js diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js b/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js index b23f981414..e4f795ac6a 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsHandler.js @@ -101,7 +101,8 @@ async function addUserIdToProject( projectId, addingUserId, userId, - privilegeLevel + privilegeLevel, + { pendingEditor } = {} ) { const project = await ProjectGetter.promises.getProject(projectId, { owner_ref: 1, @@ -124,7 +125,13 @@ async function addUserIdToProject( ) } else if (privilegeLevel === PrivilegeLevels.READ_ONLY) { level = { readOnly_refs: userId } - logger.debug({ privileges: 'readOnly', userId, projectId }, 'adding user') + if (pendingEditor) { + level.pendingEditor_refs = userId + } + logger.debug( + { privileges: 'readOnly', userId, projectId, pendingEditor }, + 'adding user' + ) } else { throw new Error(`unknown privilegeLevel: ${privilegeLevel}`) } diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.js b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.js index 224200ac12..861ba74496 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteController.js @@ -4,6 +4,7 @@ const LimitationsManager = require('../Subscription/LimitationsManager') const UserGetter = require('../User/UserGetter') const CollaboratorsGetter = require('./CollaboratorsGetter') const CollaboratorsInviteHandler = require('./CollaboratorsInviteHandler') +const CollaboratorsInviteGetter = require('./CollaboratorsInviteGetter') const logger = require('@overleaf/logger') const Settings = require('@overleaf/settings') const EmailHelper = require('../Helpers/EmailHelper') @@ -38,7 +39,7 @@ const CollaboratorsInviteController = { const projectId = req.params.Project_id logger.debug({ projectId }, 'getting all active invites for project') const invites = - await CollaboratorsInviteHandler.promises.getAllInvites(projectId) + await CollaboratorsInviteGetter.promises.getAllInvites(projectId) res.json({ invites }) }, @@ -291,7 +292,7 @@ const CollaboratorsInviteController = { } // get the invite - const invite = await CollaboratorsInviteHandler.promises.getInviteByToken( + const invite = await CollaboratorsInviteGetter.promises.getInviteByToken( projectId, token ) @@ -351,7 +352,7 @@ const CollaboratorsInviteController = { 'got request to accept invite' ) - const invite = await CollaboratorsInviteHandler.promises.getInviteByToken( + const invite = await CollaboratorsInviteGetter.promises.getInviteByToken( projectId, token ) diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteGetter.js b/services/web/app/src/Features/Collaborators/CollaboratorsInviteGetter.js new file mode 100644 index 0000000000..4aecfe71ab --- /dev/null +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteGetter.js @@ -0,0 +1,55 @@ +const logger = require('@overleaf/logger') +const { ProjectInvite } = require('../../models/ProjectInvite') +const PrivilegeLevels = require('../Authorization/PrivilegeLevels') +const CollaboratorsInviteHelper = require('./CollaboratorsInviteHelper') + +async function getAllInvites(projectId) { + logger.debug({ projectId }, 'fetching invites for project') + const invites = await ProjectInvite.find({ projectId }) + .select('_id email privileges') + .exec() + logger.debug( + { projectId, count: invites.length }, + 'found invites for project' + ) + return invites +} + +async function getInviteCount(projectId) { + logger.debug({ projectId }, 'counting invites for project') + const count = await ProjectInvite.countDocuments({ projectId }).exec() + return count +} + +async function getEditInviteCount(projectId) { + logger.debug({ projectId }, 'counting edit invites for project') + const count = await ProjectInvite.countDocuments({ + projectId, + privileges: { $ne: PrivilegeLevels.READ_ONLY }, + }).exec() + return count +} + +async function getInviteByToken(projectId, tokenString) { + logger.debug({ projectId }, 'fetching invite by token') + const invite = await ProjectInvite.findOne({ + projectId, + tokenHmac: CollaboratorsInviteHelper.hashInviteToken(tokenString), + }).exec() + + if (invite == null) { + logger.err({ projectId }, 'no invite found') + return null + } + + return invite +} + +module.exports = { + promises: { + getAllInvites, + getInviteCount, + getEditInviteCount, + getInviteByToken, + }, +} diff --git a/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.js b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.js index 4635f2182e..8a1baa7a37 100644 --- a/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.js +++ b/services/web/app/src/Features/Collaborators/CollaboratorsInviteHandler.js @@ -8,36 +8,12 @@ const UserGetter = require('../User/UserGetter') const ProjectGetter = require('../Project/ProjectGetter') const NotificationsBuilder = require('../Notifications/NotificationsBuilder') const PrivilegeLevels = require('../Authorization/PrivilegeLevels') +const SplitTestHandler = require('../SplitTests/SplitTestHandler') +const LimitationsManager = require('../Subscription/LimitationsManager') +const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler') const _ = require('lodash') const CollaboratorsInviteHandler = { - async getAllInvites(projectId) { - logger.debug({ projectId }, 'fetching invites for project') - const invites = await ProjectInvite.find({ projectId }) - .select('_id email privileges') - .exec() - logger.debug( - { projectId, count: invites.length }, - 'found invites for project' - ) - return invites - }, - - async getInviteCount(projectId) { - logger.debug({ projectId }, 'counting invites for project') - const count = await ProjectInvite.countDocuments({ projectId }).exec() - return count - }, - - async getEditInviteCount(projectId) { - logger.debug({ projectId }, 'counting edit invites for project') - const count = await ProjectInvite.countDocuments({ - projectId, - privileges: { $ne: PrivilegeLevels.READ_ONLY }, - }).exec() - return count - }, - async _trySendInviteNotification(projectId, sendingUser, invite) { const { email } = invite const existingUser = await UserGetter.promises.getUserByAnyEmail(email, { @@ -152,27 +128,43 @@ const CollaboratorsInviteHandler = { ) }, - async getInviteByToken(projectId, tokenString) { - logger.debug({ projectId }, 'fetching invite by token') - const invite = await ProjectInvite.findOne({ - projectId, - tokenHmac: CollaboratorsInviteHelper.hashInviteToken(tokenString), - }).exec() - - if (invite == null) { - logger.err({ projectId }, 'no invite found') - return null + async acceptInvite(invite, projectId, user) { + const project = await ProjectGetter.promises.getProject(projectId, { + owner_ref: 1, + }) + const linkSharingEnforcement = + await SplitTestHandler.promises.getAssignmentForUser( + project.owner_ref, + 'link-sharing-enforcement' + ) + const pendingEditor = + invite.privileges === PrivilegeLevels.READ_AND_WRITE && + linkSharingEnforcement?.variant === 'active' && + !(await LimitationsManager.promises.canAcceptEditCollaboratorInvite( + project._id + )) + if (pendingEditor) { + logger.debug( + { projectId, userId: user._id }, + 'no collaborator slots available, user added as read only (pending editor)' + ) + await ProjectAuditLogHandler.promises.addEntry( + projectId, + 'editor-moved-to-pending', // controller already logged accept-invite + null, + null, + { + userId: user._id.toString(), + } + ) } - return invite - }, - - async acceptInvite(invite, projectId, user) { await CollaboratorsHandler.promises.addUserIdToProject( projectId, invite.sendingUserId, user._id, - invite.privileges + pendingEditor ? PrivilegeLevels.READ_ONLY : invite.privileges, + { pendingEditor } ) // Remove invite @@ -192,12 +184,9 @@ const CollaboratorsInviteHandler = { module.exports = { promises: CollaboratorsInviteHandler, - getAllInvites: callbackify(CollaboratorsInviteHandler.getAllInvites), - getInviteCount: callbackify(CollaboratorsInviteHandler.getInviteCount), inviteToProject: callbackify(CollaboratorsInviteHandler.inviteToProject), revokeInvite: callbackify(CollaboratorsInviteHandler.revokeInvite), generateNewInvite: callbackify(CollaboratorsInviteHandler.generateNewInvite), - getInviteByToken: callbackify(CollaboratorsInviteHandler.getInviteByToken), acceptInvite: callbackify(CollaboratorsInviteHandler.acceptInvite), _trySendInviteNotification: callbackify( CollaboratorsInviteHandler._trySendInviteNotification diff --git a/services/web/app/src/Features/Editor/EditorHttpController.js b/services/web/app/src/Features/Editor/EditorHttpController.js index 7f2d54167c..35e17fdfe9 100644 --- a/services/web/app/src/Features/Editor/EditorHttpController.js +++ b/services/web/app/src/Features/Editor/EditorHttpController.js @@ -5,7 +5,7 @@ const AuthorizationManager = require('../Authorization/AuthorizationManager') const ProjectEditorHandler = require('../Project/ProjectEditorHandler') const Metrics = require('@overleaf/metrics') const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter') -const CollaboratorsInviteHandler = require('../Collaborators/CollaboratorsInviteHandler') +const CollaboratorsInviteGetter = require('../Collaborators/CollaboratorsInviteGetter') const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler') const PrivilegeLevels = require('../Authorization/PrivilegeLevels') const SessionManager = require('../Authentication/SessionManager') @@ -128,7 +128,7 @@ async function _buildJoinProjectView(req, projectId, userId) { return { project: null, privilegeLevel: null, isRestrictedUser: false } } const invites = - await CollaboratorsInviteHandler.promises.getAllInvites(projectId) + await CollaboratorsInviteGetter.promises.getAllInvites(projectId) const isTokenMember = await CollaboratorsHandler.promises.userIsTokenMember( userId, projectId diff --git a/services/web/app/src/Features/Subscription/LimitationsManager.js b/services/web/app/src/Features/Subscription/LimitationsManager.js index cc7e1a5a57..a2dba7c02d 100644 --- a/services/web/app/src/Features/Subscription/LimitationsManager.js +++ b/services/web/app/src/Features/Subscription/LimitationsManager.js @@ -4,7 +4,7 @@ const UserGetter = require('../User/UserGetter') const SubscriptionLocator = require('./SubscriptionLocator') const Settings = require('@overleaf/settings') const CollaboratorsGetter = require('../Collaborators/CollaboratorsGetter') -const CollaboratorsInvitesHandler = require('../Collaborators/CollaboratorsInviteHandler') +const CollaboratorsInvitesGetter = require('../Collaborators/CollaboratorsInviteGetter') const V1SubscriptionManager = require('./V1SubscriptionManager') const { V1ConnectionError } = require('../Errors/Errors') const { @@ -28,6 +28,18 @@ async function allowedNumberOfCollaboratorsForUser(userId) { } } +async function canAcceptEditCollaboratorInvite(projectId) { + const allowedNumber = await allowedNumberOfCollaboratorsInProject(projectId) + if (allowedNumber < 0) { + return true // -1 means unlimited + } + const currentEditors = + await CollaboratorsGetter.promises.getInvitedEditCollaboratorCount( + projectId + ) + return currentEditors + 1 <= allowedNumber +} + async function canAddXCollaborators(projectId, numberOfNewCollaborators) { const allowedNumber = await allowedNumberOfCollaboratorsInProject(projectId) if (allowedNumber < 0) { @@ -36,7 +48,7 @@ async function canAddXCollaborators(projectId, numberOfNewCollaborators) { const currentNumber = await CollaboratorsGetter.promises.getInvitedCollaboratorCount(projectId) const inviteCount = - await CollaboratorsInvitesHandler.promises.getInviteCount(projectId) + await CollaboratorsInvitesGetter.promises.getInviteCount(projectId) return currentNumber + inviteCount + numberOfNewCollaborators <= allowedNumber } @@ -53,7 +65,7 @@ async function canAddXEditCollaborators( projectId ) const editInviteCount = - await CollaboratorsInvitesHandler.promises.getEditInviteCount(projectId) + await CollaboratorsInvitesGetter.promises.getEditInviteCount(projectId) return ( currentEditors + editInviteCount + numberOfNewEditCollaborators <= allowedNumber @@ -179,6 +191,7 @@ const LimitationsManager = { promises: { allowedNumberOfCollaboratorsInProject, allowedNumberOfCollaboratorsForUser, + canAcceptEditCollaboratorInvite, canAddXCollaborators, canAddXEditCollaborators, hasPaidSubscription, diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js index 7070595268..a333a75a46 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js +++ b/services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js @@ -246,6 +246,32 @@ describe('CollaboratorsHandler', function () { this.userId ) }) + + describe('and with pendingEditor flag', function () { + it('should add them to the pending editor refs', async function () { + this.ProjectMock.expects('updateOne') + .withArgs( + { + _id: this.project._id, + }, + { + $addToSet: { + readOnly_refs: this.userId, + pendingEditor_refs: this.userId, + }, + } + ) + .chain('exec') + .resolves() + await this.CollaboratorsHandler.promises.addUserIdToProject( + this.project._id, + this.addingUserId, + this.userId, + 'readOnly', + { pendingEditor: true } + ) + }) + }) }) describe('as readAndWrite', function () { diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.js index 87ec84929c..0ebfe73456 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.js +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteControllerTests.js @@ -83,15 +83,20 @@ describe('CollaboratorsInviteController', function () { this.CollaboratorsInviteHandler = { promises: { - getAllInvites: sinon.stub(), inviteToProject: sinon.stub().resolves(this.inviteReducedData), - getInviteByToken: sinon.stub().resolves(this.invite), generateNewInvite: sinon.stub().resolves(this.invite), revokeInvite: sinon.stub().resolves(this.invite), acceptInvite: sinon.stub(), }, } + this.CollaboratorsInviteGetter = { + promises: { + getAllInvites: sinon.stub(), + getInviteByToken: sinon.stub().resolves(this.invite), + }, + } + this.EditorRealTimeController = { emitToRoom: sinon.stub(), } @@ -123,6 +128,7 @@ describe('CollaboratorsInviteController', function () { '../User/UserGetter': this.UserGetter, './CollaboratorsGetter': this.CollaboratorsGetter, './CollaboratorsInviteHandler': this.CollaboratorsInviteHandler, + './CollaboratorsInviteGetter': this.CollaboratorsInviteGetter, '../Editor/EditorRealTimeController': this.EditorRealTimeController, '../Analytics/AnalyticsManager': this.AnalyticsManger, '../Authentication/SessionManager': this.SessionManager, @@ -150,7 +156,7 @@ describe('CollaboratorsInviteController', function () { describe('when all goes well', function () { beforeEach(function (done) { - this.CollaboratorsInviteHandler.promises.getAllInvites.resolves( + this.CollaboratorsInviteGetter.promises.getAllInvites.resolves( this.fakeInvites ) this.res.callback = () => done() @@ -173,10 +179,10 @@ describe('CollaboratorsInviteController', function () { }) it('should have called CollaboratorsInviteHandler.getAllInvites', function () { - this.CollaboratorsInviteHandler.promises.getAllInvites.callCount.should.equal( + this.CollaboratorsInviteGetter.promises.getAllInvites.callCount.should.equal( 1 ) - this.CollaboratorsInviteHandler.promises.getAllInvites + this.CollaboratorsInviteGetter.promises.getAllInvites .calledWith(this.projectId) .should.equal(true) }) @@ -184,7 +190,7 @@ describe('CollaboratorsInviteController', function () { describe('when CollaboratorsInviteHandler.getAllInvites produces an error', function () { beforeEach(function (done) { - this.CollaboratorsInviteHandler.promises.getAllInvites.rejects( + this.CollaboratorsInviteGetter.promises.getAllInvites.rejects( new Error('woops') ) this.next.callsFake(() => done()) @@ -802,7 +808,7 @@ describe('CollaboratorsInviteController', function () { this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject.resolves( false ) - this.CollaboratorsInviteHandler.promises.getInviteByToken.resolves( + this.CollaboratorsInviteGetter.promises.getInviteByToken.resolves( this.invite ) this.ProjectGetter.promises.getProject.resolves(this.fakeProject) @@ -838,10 +844,10 @@ describe('CollaboratorsInviteController', function () { }) it('should call getInviteByToken', function () { - this.CollaboratorsInviteHandler.promises.getInviteByToken.callCount.should.equal( + this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) - this.CollaboratorsInviteHandler.promises.getInviteByToken + this.CollaboratorsInviteGetter.promises.getInviteByToken .calledWith(this.fakeProject._id, this.invite.token) .should.equal(true) }) @@ -924,7 +930,7 @@ describe('CollaboratorsInviteController', function () { }) it('should not call getInviteByToken', function () { - this.CollaboratorsInviteHandler.promises.getInviteByToken.callCount.should.equal( + this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 0 ) }) @@ -966,7 +972,7 @@ describe('CollaboratorsInviteController', function () { }) it('should not call getInviteByToken', function () { - this.CollaboratorsInviteHandler.promises.getInviteByToken.callCount.should.equal( + this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 0 ) }) @@ -982,7 +988,7 @@ describe('CollaboratorsInviteController', function () { describe('when the getInviteByToken produces an error', function () { beforeEach(function (done) { - this.CollaboratorsInviteHandler.promises.getInviteByToken.rejects( + this.CollaboratorsInviteGetter.promises.getInviteByToken.rejects( new Error('woops') ) this.next.callsFake(() => done()) @@ -1008,7 +1014,7 @@ describe('CollaboratorsInviteController', function () { }) it('should call getInviteByToken', function () { - this.CollaboratorsInviteHandler.promises.getInviteByToken.callCount.should.equal( + this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject @@ -1027,7 +1033,7 @@ describe('CollaboratorsInviteController', function () { describe('when the getInviteByToken does not produce an invite', function () { beforeEach(function (done) { - this.CollaboratorsInviteHandler.promises.getInviteByToken.resolves(null) + this.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null) this.res.callback = () => done() this.CollaboratorsInviteController.viewInvite( this.req, @@ -1057,7 +1063,7 @@ describe('CollaboratorsInviteController', function () { }) it('should call getInviteByToken', function () { - this.CollaboratorsInviteHandler.promises.getInviteByToken.callCount.should.equal( + this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) this.CollaboratorsGetter.promises.isUserInvitedMemberOfProject @@ -1100,7 +1106,7 @@ describe('CollaboratorsInviteController', function () { }) it('should call getInviteByToken', function () { - this.CollaboratorsInviteHandler.promises.getInviteByToken.callCount.should.equal( + this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) }) @@ -1149,7 +1155,7 @@ describe('CollaboratorsInviteController', function () { }) it('should call getInviteByToken', function () { - this.CollaboratorsInviteHandler.promises.getInviteByToken.callCount.should.equal( + this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) }) @@ -1192,7 +1198,7 @@ describe('CollaboratorsInviteController', function () { }) it('should call getInviteByToken', function () { - this.CollaboratorsInviteHandler.promises.getInviteByToken.callCount.should.equal( + this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) }) @@ -1241,7 +1247,7 @@ describe('CollaboratorsInviteController', function () { }) it('should call getInviteByToken', function () { - this.CollaboratorsInviteHandler.promises.getInviteByToken.callCount.should.equal( + this.CollaboratorsInviteGetter.promises.getInviteByToken.callCount.should.equal( 1 ) }) @@ -1513,7 +1519,7 @@ describe('CollaboratorsInviteController', function () { describe('when the invite is not found', function () { beforeEach(function (done) { - this.CollaboratorsInviteHandler.promises.getInviteByToken.resolves(null) + this.CollaboratorsInviteGetter.promises.getInviteByToken.resolves(null) this.next.callsFake(() => done()) this.CollaboratorsInviteController.acceptInvite( this.req, diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteGetterTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsInviteGetterTests.js new file mode 100644 index 0000000000..ee2255faa7 --- /dev/null +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteGetterTests.js @@ -0,0 +1,232 @@ +const sinon = require('sinon') +const { expect } = require('chai') +const SandboxedModule = require('sandboxed-module') +const { ObjectId } = require('mongodb') +const Crypto = require('crypto') + +const MODULE_PATH = + '../../../../app/src/Features/Collaborators/CollaboratorsInviteGetter.js' + +describe('CollaboratorsInviteGetter', function () { + beforeEach(function () { + this.ProjectInvite = class ProjectInvite { + constructor(options) { + if (options == null) { + options = {} + } + this._id = new ObjectId() + for (const k in options) { + const v = options[k] + this[k] = v + } + } + } + this.ProjectInvite.prototype.save = sinon.stub() + this.ProjectInvite.findOne = sinon.stub() + this.ProjectInvite.find = sinon.stub() + this.ProjectInvite.deleteOne = sinon.stub() + this.ProjectInvite.findOneAndDelete = sinon.stub() + this.ProjectInvite.countDocuments = sinon.stub() + + this.Crypto = { + randomBytes: sinon.stub().callsFake(Crypto.randomBytes), + } + + this.CollaboratorsInviteHelper = { + generateToken: sinon.stub().returns(this.Crypto.randomBytes(24)), + hashInviteToken: sinon.stub().returns(this.tokenHmac), + } + + this.CollaboratorsInviteGetter = SandboxedModule.require(MODULE_PATH, { + requires: { + '../../models/ProjectInvite': { ProjectInvite: this.ProjectInvite }, + './CollaboratorsInviteHelper': this.CollaboratorsInviteHelper, + }, + }) + + this.projectId = new ObjectId() + this.sendingUserId = new ObjectId() + this.email = 'user@example.com' + this.userId = new ObjectId() + this.inviteId = new ObjectId() + this.token = 'hnhteaosuhtaeosuahs' + this.privileges = 'readAndWrite' + this.fakeInvite = { + _id: this.inviteId, + email: this.email, + token: this.token, + tokenHmac: this.tokenHmac, + sendingUserId: this.sendingUserId, + projectId: this.projectId, + privileges: this.privileges, + createdAt: new Date(), + } + }) + + describe('getInviteCount', function () { + beforeEach(function () { + this.ProjectInvite.countDocuments.returns({ + exec: sinon.stub().resolves(2), + }) + this.call = async () => { + return await this.CollaboratorsInviteGetter.promises.getInviteCount( + this.projectId + ) + } + }) + + it('should produce the count of documents', async function () { + const count = await this.call() + expect(count).to.equal(2) + }) + + describe('when model.countDocuments produces an error', function () { + beforeEach(function () { + this.ProjectInvite.countDocuments.returns({ + exec: sinon.stub().rejects(new Error('woops')), + }) + }) + + it('should produce an error', async function () { + await expect(this.call()).to.be.rejectedWith(Error) + }) + }) + }) + + describe('getEditInviteCount', function () { + beforeEach(function () { + this.ProjectInvite.countDocuments.returns({ + exec: sinon.stub().resolves(2), + }) + this.call = async () => { + return await this.CollaboratorsInviteGetter.promises.getEditInviteCount( + this.projectId + ) + } + }) + + it('should produce the count of documents', async function () { + const count = await this.call() + expect(this.ProjectInvite.countDocuments).to.be.calledWith({ + projectId: this.projectId, + privileges: { $ne: 'readOnly' }, + }) + expect(count).to.equal(2) + }) + + describe('when model.countDocuments produces an error', function () { + beforeEach(function () { + this.ProjectInvite.countDocuments.returns({ + exec: sinon.stub().rejects(new Error('woops')), + }) + }) + + it('should produce an error', async function () { + await expect(this.call()).to.be.rejectedWith(Error) + }) + }) + }) + + describe('getAllInvites', function () { + beforeEach(function () { + this.fakeInvites = [ + { _id: new ObjectId(), one: 1 }, + { _id: new ObjectId(), two: 2 }, + ] + this.ProjectInvite.find.returns({ + select: sinon.stub().returnsThis(), + exec: sinon.stub().resolves(this.fakeInvites), + }) + this.call = async () => { + return await this.CollaboratorsInviteGetter.promises.getAllInvites( + this.projectId + ) + } + }) + + describe('when all goes well', function () { + beforeEach(function () {}) + + it('should produce a list of invite objects', async function () { + const invites = await this.call() + expect(invites).to.not.be.oneOf([null, undefined]) + expect(invites).to.deep.equal(this.fakeInvites) + }) + + it('should have called ProjectInvite.find', async function () { + await this.call() + this.ProjectInvite.find.callCount.should.equal(1) + this.ProjectInvite.find + .calledWith({ projectId: this.projectId }) + .should.equal(true) + }) + }) + + describe('when ProjectInvite.find produces an error', function () { + beforeEach(function () { + this.ProjectInvite.find.returns({ + select: sinon.stub().returnsThis(), + exec: sinon.stub().rejects(new Error('woops')), + }) + }) + + it('should produce an error', async function () { + await expect(this.call()).to.be.rejectedWith(Error) + }) + }) + }) + + describe('getInviteByToken', function () { + beforeEach(function () { + this.ProjectInvite.findOne.returns({ + exec: sinon.stub().resolves(this.fakeInvite), + }) + this.call = async () => { + return await this.CollaboratorsInviteGetter.promises.getInviteByToken( + this.projectId, + this.token + ) + } + }) + + describe('when all goes well', function () { + it('should produce the invite object', async function () { + const invite = await this.call() + expect(invite).to.deep.equal(this.fakeInvite) + }) + + it('should call ProjectInvite.findOne', async function () { + await this.call() + this.ProjectInvite.findOne.callCount.should.equal(1) + this.ProjectInvite.findOne + .calledWith({ projectId: this.projectId, tokenHmac: this.tokenHmac }) + .should.equal(true) + }) + }) + + describe('when findOne produces an error', function () { + beforeEach(function () { + this.ProjectInvite.findOne.returns({ + exec: sinon.stub().rejects(new Error('woops')), + }) + }) + + it('should produce an error', async function () { + await expect(this.call()).to.be.rejectedWith(Error) + }) + }) + + describe('when findOne does not find an invite', function () { + beforeEach(function () { + this.ProjectInvite.findOne.returns({ + exec: sinon.stub().resolves(null), + }) + }) + + it('should not produce an invite object', async function () { + const invite = await this.call() + expect(invite).to.be.oneOf([null, undefined]) + }) + }) + }) +}) diff --git a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandlerTests.js b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandlerTests.js index 5c3ac49503..50eaebd5ba 100644 --- a/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandlerTests.js +++ b/services/web/test/unit/src/Collaborators/CollaboratorsInviteHandlerTests.js @@ -39,7 +39,7 @@ describe('CollaboratorsInviteHandler', function () { }, } this.UserGetter = { promises: { getUser: sinon.stub() } } - this.ProjectGetter = { promises: {} } + this.ProjectGetter = { promises: { getProject: sinon.stub().resolves() } } this.NotificationsBuilder = { promises: {} } this.tokenHmac = 'jkhajkefhaekjfhkfg' this.CollaboratorsInviteHelper = { @@ -47,6 +47,25 @@ describe('CollaboratorsInviteHandler', function () { hashInviteToken: sinon.stub().returns(this.tokenHmac), } + this.SplitTestHandler = { + promises: { + getAssignmentForUser: sinon.stub().resolves(), + }, + } + + this.LimitationsManager = { + promises: { + canAcceptEditCollaboratorInvite: sinon.stub().resolves(), + }, + } + + this.ProjectAuditLogHandler = { + promises: { + addEntry: sinon.stub().resolves(), + }, + addEntryInBackground: sinon.stub(), + } + this.CollaboratorsInviteHandler = SandboxedModule.require(MODULE_PATH, { requires: { '@overleaf/settings': this.settings, @@ -57,7 +76,10 @@ describe('CollaboratorsInviteHandler', function () { '../Project/ProjectGetter': this.ProjectGetter, '../Notifications/NotificationsBuilder': this.NotificationsBuilder, './CollaboratorsInviteHelper': this.CollaboratorsInviteHelper, - crypto: this.Crypto, + '../SplitTests/SplitTestHandler': this.SplitTestHandler, + '../Subscription/LimitationsManager': this.LimitationsManager, + '../Project/ProjectAuditLogHandler': this.ProjectAuditLogHandler, + crypto: this.CryptogetAssignmentForUser, }, }) @@ -88,119 +110,6 @@ describe('CollaboratorsInviteHandler', function () { } }) - describe('getInviteCount', function () { - beforeEach(function () { - this.ProjectInvite.countDocuments.returns({ - exec: sinon.stub().resolves(2), - }) - this.call = async () => { - return await this.CollaboratorsInviteHandler.promises.getInviteCount( - this.projectId - ) - } - }) - - it('should produce the count of documents', async function () { - const count = await this.call() - expect(count).to.equal(2) - }) - - describe('when model.countDocuments produces an error', function () { - beforeEach(function () { - this.ProjectInvite.countDocuments.returns({ - exec: sinon.stub().rejects(new Error('woops')), - }) - }) - - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) - }) - }) - }) - - describe('getEditInviteCount', function () { - beforeEach(function () { - this.ProjectInvite.countDocuments.returns({ - exec: sinon.stub().resolves(2), - }) - this.call = async () => { - return await this.CollaboratorsInviteHandler.promises.getEditInviteCount( - this.projectId - ) - } - }) - - it('should produce the count of documents', async function () { - const count = await this.call() - expect(this.ProjectInvite.countDocuments).to.be.calledWith({ - projectId: this.projectId, - privileges: { $ne: 'readOnly' }, - }) - expect(count).to.equal(2) - }) - - describe('when model.countDocuments produces an error', function () { - beforeEach(function () { - this.ProjectInvite.countDocuments.returns({ - exec: sinon.stub().rejects(new Error('woops')), - }) - }) - - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) - }) - }) - }) - - describe('getAllInvites', function () { - beforeEach(function () { - this.fakeInvites = [ - { _id: new ObjectId(), one: 1 }, - { _id: new ObjectId(), two: 2 }, - ] - this.ProjectInvite.find.returns({ - select: sinon.stub().returnsThis(), - exec: sinon.stub().resolves(this.fakeInvites), - }) - this.call = async () => { - return await this.CollaboratorsInviteHandler.promises.getAllInvites( - this.projectId - ) - } - }) - - describe('when all goes well', function () { - beforeEach(function () {}) - - it('should produce a list of invite objects', async function () { - const invites = await this.call() - expect(invites).to.not.be.oneOf([null, undefined]) - expect(invites).to.deep.equal(this.fakeInvites) - }) - - it('should have called ProjectInvite.find', async function () { - await this.call() - this.ProjectInvite.find.callCount.should.equal(1) - this.ProjectInvite.find - .calledWith({ projectId: this.projectId }) - .should.equal(true) - }) - }) - - describe('when ProjectInvite.find produces an error', function () { - beforeEach(function () { - this.ProjectInvite.find.returns({ - select: sinon.stub().returnsThis(), - exec: sinon.stub().rejects(new Error('woops')), - }) - }) - - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) - }) - }) - }) - describe('inviteToProject', function () { beforeEach(function () { this.ProjectInvite.prototype.save.callsFake(async function () { @@ -484,67 +393,15 @@ describe('CollaboratorsInviteHandler', function () { }) }) - describe('getInviteByToken', function () { - beforeEach(function () { - this.ProjectInvite.findOne.returns({ - exec: sinon.stub().resolves(this.fakeInvite), - }) - this.call = async () => { - return await this.CollaboratorsInviteHandler.promises.getInviteByToken( - this.projectId, - this.token - ) - } - }) - - describe('when all goes well', function () { - it('should produce the invite object', async function () { - const invite = await this.call() - expect(invite).to.deep.equal(this.fakeInvite) - }) - - it('should call ProjectInvite.findOne', async function () { - await this.call() - this.ProjectInvite.findOne.callCount.should.equal(1) - this.ProjectInvite.findOne - .calledWith({ projectId: this.projectId, tokenHmac: this.tokenHmac }) - .should.equal(true) - }) - }) - - describe('when findOne produces an error', function () { - beforeEach(function () { - this.ProjectInvite.findOne.returns({ - exec: sinon.stub().rejects(new Error('woops')), - }) - }) - - it('should produce an error', async function () { - await expect(this.call()).to.be.rejectedWith(Error) - }) - }) - - describe('when findOne does not find an invite', function () { - beforeEach(function () { - this.ProjectInvite.findOne.returns({ - exec: sinon.stub().resolves(null), - }) - }) - - it('should not produce an invite object', async function () { - const invite = await this.call() - expect(invite).to.be.oneOf([null, undefined]) - }) - }) - }) - describe('acceptInvite', function () { beforeEach(function () { this.fakeProject = { _id: this.projectId, - collaberator_refs: [], - readOnly_refs: [], + owner_ref: this.sendingUserId, } + this.ProjectGetter.promises.getProject = sinon + .stub() + .resolves(this.fakeProject) this.CollaboratorsHandler.promises.addUserIdToProject.resolves() this.CollaboratorsInviteHandler.promises._tryCancelInviteNotification = sinon.stub().resolves() @@ -602,6 +459,58 @@ describe('CollaboratorsInviteHandler', function () { }) }) + describe('when link-sharing-enforcement is active', function () { + beforeEach(function () { + this.SplitTestHandler.promises.getAssignmentForUser.resolves({ + variant: 'active', + }) + }) + + describe('when the project has no more edit collaborator slots', function () { + beforeEach(function () { + this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( + false + ) + }) + + it('should add readAndWrite invitees to the project as readOnly (pendingEditor) users', async function () { + await this.call() + this.ProjectAuditLogHandler.promises.addEntry.should.have.been.calledWith( + this.projectId, + 'editor-moved-to-pending', + null, + null, + { userId: this.userId.toString() } + ) + this.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith( + this.projectId, + this.sendingUserId, + this.userId, + 'readOnly', + { pendingEditor: true } + ) + }) + }) + + describe('when the project has available edit collaborator slots', function () { + beforeEach(function () { + this.LimitationsManager.promises.canAcceptEditCollaboratorInvite.resolves( + true + ) + }) + + it('should add readAndWrite invitees to the project as normal', async function () { + await this.call() + this.CollaboratorsHandler.promises.addUserIdToProject.should.have.been.calledWith( + this.projectId, + this.sendingUserId, + this.userId, + this.fakeInvite.privileges + ) + }) + }) + }) + describe('when addUserIdToProject produces an error', function () { beforeEach(function () { this.CollaboratorsHandler.promises.addUserIdToProject.callsArgWith( diff --git a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js index f07a0bd48b..e87cc8eb30 100644 --- a/services/web/test/unit/src/Editor/EditorHttpControllerTests.js +++ b/services/web/test/unit/src/Editor/EditorHttpControllerTests.js @@ -67,7 +67,7 @@ describe('EditorHttpController', function () { userIsTokenMember: sinon.stub().resolves(false), }, } - this.CollaboratorsInviteHandler = { + this.CollaboratorsInviteGetter = { promises: { getAllInvites: sinon.stub().resolves([ { @@ -147,8 +147,8 @@ describe('EditorHttpController', function () { '@overleaf/metrics': this.Metrics, '../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter, '../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler, - '../Collaborators/CollaboratorsInviteHandler': - this.CollaboratorsInviteHandler, + '../Collaborators/CollaboratorsInviteGetter': + this.CollaboratorsInviteGetter, '../TokenAccess/TokenAccessHandler': this.TokenAccessHandler, '../Authentication/SessionManager': this.SessionManager, '../../infrastructure/FileWriter': this.FileWriter, diff --git a/services/web/test/unit/src/Subscription/LimitationsManagerTests.js b/services/web/test/unit/src/Subscription/LimitationsManagerTests.js index e438e37a8a..b4d39756c2 100644 --- a/services/web/test/unit/src/Subscription/LimitationsManagerTests.js +++ b/services/web/test/unit/src/Subscription/LimitationsManagerTests.js @@ -1,6 +1,7 @@ const SandboxedModule = require('sandboxed-module') const assert = require('assert') const sinon = require('sinon') +const { expect } = require('chai') const modulePath = require('path').join( __dirname, '../../../../app/src/Features/Subscription/LimitationsManager' @@ -60,7 +61,7 @@ describe('LimitationsManager', function () { }, } - this.CollaboratorsInviteHandler = { + this.CollaboratorsInviteGetter = { promises: { getInviteCount: sinon.stub().resolves(), getEditInviteCount: sinon.stub().resolves(), @@ -74,8 +75,8 @@ describe('LimitationsManager', function () { './SubscriptionLocator': this.SubscriptionLocator, '@overleaf/settings': (this.Settings = {}), '../Collaborators/CollaboratorsGetter': this.CollaboratorsGetter, - '../Collaborators/CollaboratorsInviteHandler': - this.CollaboratorsInviteHandler, + '../Collaborators/CollaboratorsInviteGetter': + this.CollaboratorsInviteGetter, './V1SubscriptionManager': this.V1SubscriptionManager, }, }) @@ -157,6 +158,76 @@ describe('LimitationsManager', function () { }) }) + describe('canAcceptEditCollaboratorInvite', function () { + describe('when the project has fewer collaborators than allowed', function () { + beforeEach(function () { + this.current_number = 1 + this.user.features.collaborators = 2 + this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount = + sinon.stub().resolves(this.current_number) + }) + + it('should return true', async function () { + const result = + await this.LimitationsManager.promises.canAcceptEditCollaboratorInvite( + this.projectId + ) + expect(result).to.be.true + }) + }) + + describe('when accepting the invite would exceed the collaborator limit', function () { + beforeEach(function () { + this.current_number = 2 + this.user.features.collaborators = 2 + this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount = + sinon.stub().resolves(this.current_number) + }) + + it('should return false', async function () { + const result = + await this.LimitationsManager.promises.canAcceptEditCollaboratorInvite( + this.projectId + ) + expect(result).to.be.false + }) + }) + + describe('when the project has more collaborators than allowed', function () { + beforeEach(function () { + this.current_number = 3 + this.user.features.collaborators = 2 + this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount = + sinon.stub().resolves(this.current_number) + }) + + it('should return false', async function () { + const result = + await this.LimitationsManager.promises.canAcceptEditCollaboratorInvite( + this.projectId + ) + expect(result).to.be.false + }) + }) + + describe('when the project has infinite collaborators', function () { + beforeEach(function () { + this.current_number = 100 + this.user.features.collaborators = -1 + this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount = + sinon.stub().resolves(this.current_number) + }) + + it('should return true', async function () { + const result = + await this.LimitationsManager.promises.canAcceptEditCollaboratorInvite( + this.projectId + ) + expect(result).to.be.true + }) + }) + }) + describe('canAddXCollaborators', function () { describe('when the project has fewer collaborators than allowed', function () { beforeEach(function (done) { @@ -166,7 +237,7 @@ describe('LimitationsManager', function () { this.CollaboratorsGetter.promises.getInvitedCollaboratorCount = sinon .stub() .resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -190,7 +261,7 @@ describe('LimitationsManager', function () { this.CollaboratorsGetter.promises.getInvitedCollaboratorCount = sinon .stub() .resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -214,7 +285,7 @@ describe('LimitationsManager', function () { this.CollaboratorsGetter.promises.getInvitedCollaboratorCount = sinon .stub() .resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -238,7 +309,7 @@ describe('LimitationsManager', function () { this.CollaboratorsGetter.promises.getInvitedCollaboratorCount = sinon .stub() .resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -262,7 +333,7 @@ describe('LimitationsManager', function () { this.CollaboratorsGetter.promises.getInvitedCollaboratorCount = sinon .stub() .resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -286,7 +357,7 @@ describe('LimitationsManager', function () { this.CollaboratorsGetter.promises.getInvitedCollaboratorCount = sinon .stub() .resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -310,7 +381,7 @@ describe('LimitationsManager', function () { this.CollaboratorsGetter.promises.getInvitedCollaboratorCount = sinon .stub() .resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -334,7 +405,7 @@ describe('LimitationsManager', function () { this.invite_count = 0 this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount = sinon.stub().resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getEditInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -357,7 +428,7 @@ describe('LimitationsManager', function () { this.invite_count = 1 this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount = sinon.stub().resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getEditInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -380,7 +451,7 @@ describe('LimitationsManager', function () { this.invite_count = 0 this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount = sinon.stub().resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getEditInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -403,7 +474,7 @@ describe('LimitationsManager', function () { this.invite_count = 0 this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount = sinon.stub().resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getEditInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -426,7 +497,7 @@ describe('LimitationsManager', function () { this.invite_count = 0 this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount = sinon.stub().resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getEditInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -449,7 +520,7 @@ describe('LimitationsManager', function () { this.invite_count = 2 this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount = sinon.stub().resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getEditInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done()) @@ -472,7 +543,7 @@ describe('LimitationsManager', function () { this.invite_count = 1 this.CollaboratorsGetter.promises.getInvitedEditCollaboratorCount = sinon.stub().resolves(this.current_number) - this.CollaboratorsInviteHandler.promises.getEditInviteCount = sinon + this.CollaboratorsInviteGetter.promises.getEditInviteCount = sinon .stub() .resolves(this.invite_count) this.callback = sinon.stub().callsFake(() => done())