Merge pull request #20034 from overleaf/tm-collab-limit-edit-invites

Enforce collaborator limit when accepting project invites

GitOrigin-RevId: 94f281113fe7c7b6d0a5ef43e11ab579400d9e56
This commit is contained in:
Thomas 2024-08-22 12:48:53 +02:00 committed by Copybot
parent 5e2662adc4
commit 98a914bb94
12 changed files with 575 additions and 266 deletions

View file

@ -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}`)
}

View file

@ -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
)

View file

@ -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,
},
}

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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 () {

View file

@ -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,

View file

@ -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])
})
})
})
})

View file

@ -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(

View file

@ -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,

View file

@ -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())