[web] Enforce collaborator limit (#19619)

* Enables collaborator limit enforcement

* Add pendingEditor_refs for editors downgraded during limit enforcement

* Add isPendingEditor to useEditorContext

---------

Co-authored-by: Thomas Mees <thomas.mees@overleaf.com>
GitOrigin-RevId: b622213f6282ccd8ee85a23ceb29b8c6f8ff6a96
This commit is contained in:
roo hutton 2024-08-22 11:41:05 +01:00 committed by Copybot
parent 18ee2bc87a
commit 5e2662adc4
13 changed files with 227 additions and 28 deletions

View file

@ -40,6 +40,7 @@ module.exports = {
getMemberIdPrivilegeLevel, getMemberIdPrivilegeLevel,
getInvitedCollaboratorCount, getInvitedCollaboratorCount,
getInvitedEditCollaboratorCount, getInvitedEditCollaboratorCount,
getInvitedPendingEditorCount,
getProjectsUserIsMemberOf, getProjectsUserIsMemberOf,
dangerouslyGetAllProjectsUserIsMemberOf, dangerouslyGetAllProjectsUserIsMemberOf,
isUserInvitedMemberOfProject, isUserInvitedMemberOfProject,
@ -59,6 +60,7 @@ async function getMemberIdsWithPrivilegeLevels(projectId) {
tokenAccessReadOnly_refs: 1, tokenAccessReadOnly_refs: 1,
tokenAccessReadAndWrite_refs: 1, tokenAccessReadAndWrite_refs: 1,
publicAccesLevel: 1, publicAccesLevel: 1,
pendingEditor_refs: 1,
}) })
if (!project) { if (!project) {
throw new Errors.NotFoundError(`no project found with id ${projectId}`) throw new Errors.NotFoundError(`no project found with id ${projectId}`)
@ -69,7 +71,8 @@ async function getMemberIdsWithPrivilegeLevels(projectId) {
project.readOnly_refs, project.readOnly_refs,
project.tokenAccessReadAndWrite_refs, project.tokenAccessReadAndWrite_refs,
project.tokenAccessReadOnly_refs, project.tokenAccessReadOnly_refs,
project.publicAccesLevel project.publicAccesLevel,
project.pendingEditor_refs
) )
return memberIds return memberIds
} }
@ -136,6 +139,17 @@ async function getInvitedEditCollaboratorCount(projectId) {
).length ).length
} }
async function getInvitedPendingEditorCount(projectId) {
// Only counts invited members that are readonly pending editors
const members = await getMemberIdsWithPrivilegeLevels(projectId)
return members.filter(
m =>
m.source === Sources.INVITE &&
m.privilegeLevel === PrivilegeLevels.READ_ONLY &&
m.pendingEditor === true
).length
}
async function isUserInvitedMemberOfProject(userId, projectId) { async function isUserInvitedMemberOfProject(userId, projectId) {
if (!userId) { if (!userId) {
return false return false
@ -309,7 +323,8 @@ function _getMemberIdsWithPrivilegeLevelsFromFields(
readOnlyIds, readOnlyIds,
tokenAccessIds, tokenAccessIds,
tokenAccessReadOnlyIds, tokenAccessReadOnlyIds,
publicAccessLevel publicAccessLevel,
pendingEditorIds
) { ) {
const members = [] const members = []
members.push({ members.push({
@ -329,6 +344,9 @@ function _getMemberIdsWithPrivilegeLevelsFromFields(
id: memberId.toString(), id: memberId.toString(),
privilegeLevel: PrivilegeLevels.READ_ONLY, privilegeLevel: PrivilegeLevels.READ_ONLY,
source: Sources.INVITE, source: Sources.INVITE,
...(pendingEditorIds?.some(pe => memberId.equals(pe)) && {
pendingEditor: true,
}),
}) })
} }
if (publicAccessLevel === PublicAccessLevels.TOKEN_BASED) { if (publicAccessLevel === PublicAccessLevels.TOKEN_BASED) {
@ -364,7 +382,11 @@ async function _loadMembers(members) {
signUpDate: 1, signUpDate: 1,
}) })
if (user != null) { if (user != null) {
return { user, privilegeLevel: member.privilegeLevel } return {
user,
privilegeLevel: member.privilegeLevel,
...(member.pendingEditor && { pendingEditor: true }),
}
} else { } else {
return null return null
} }

View file

@ -49,6 +49,7 @@ async function removeUserFromProject(projectId, userId) {
$pull: { $pull: {
collaberator_refs: userId, collaberator_refs: userId,
readOnly_refs: userId, readOnly_refs: userId,
pendingEditor_refs: userId,
tokenAccessReadOnly_refs: userId, tokenAccessReadOnly_refs: userId,
tokenAccessReadAndWrite_refs: userId, tokenAccessReadAndWrite_refs: userId,
trashed: userId, trashed: userId,
@ -62,6 +63,7 @@ async function removeUserFromProject(projectId, userId) {
$pull: { $pull: {
collaberator_refs: userId, collaberator_refs: userId,
readOnly_refs: userId, readOnly_refs: userId,
pendingEditor_refs: userId,
tokenAccessReadOnly_refs: userId, tokenAccessReadOnly_refs: userId,
tokenAccessReadAndWrite_refs: userId, tokenAccessReadAndWrite_refs: userId,
archived: userId, archived: userId,
@ -196,6 +198,19 @@ async function transferProjects(fromUserId, toUserId) {
} }
).exec() ).exec()
await Project.updateMany(
{ pendingEditor_refs: fromUserId },
{
$addToSet: { pendingEditor_refs: toUserId },
}
).exec()
await Project.updateMany(
{ pendingEditor_refs: fromUserId },
{
$pull: { pendingEditor_refs: fromUserId },
}
).exec()
// Flush in background, no need to block on this // Flush in background, no need to block on this
_flushProjects(projectIds).catch(err => { _flushProjects(projectIds).catch(err => {
logger.err( logger.err(
@ -220,14 +235,14 @@ async function setCollaboratorPrivilegeLevel(
switch (privilegeLevel) { switch (privilegeLevel) {
case PrivilegeLevels.READ_AND_WRITE: { case PrivilegeLevels.READ_AND_WRITE: {
update = { update = {
$pull: { readOnly_refs: userId }, $pull: { readOnly_refs: userId, pendingEditor_refs: userId },
$addToSet: { collaberator_refs: userId }, $addToSet: { collaberator_refs: userId },
} }
break break
} }
case PrivilegeLevels.READ_ONLY: { case PrivilegeLevels.READ_ONLY: {
update = { update = {
$pull: { collaberator_refs: userId }, $pull: { collaberator_refs: userId, pendingEditor_refs: userId },
$addToSet: { readOnly_refs: userId }, $addToSet: { readOnly_refs: userId },
} }
break break

View file

@ -415,6 +415,7 @@ const _ProjectController = {
tokens: 1, tokens: 1,
tokenAccessReadAndWrite_refs: 1, // used for link sharing analytics tokenAccessReadAndWrite_refs: 1, // used for link sharing analytics
collaberator_refs: 1, // used for link sharing analytics collaberator_refs: 1, // used for link sharing analytics
pendingEditor_refs: 1, // used for link sharing analytics
}), }),
userIsMemberOfGroupSubscription: sessionUser userIsMemberOfGroupSubscription: sessionUser
? (async () => ? (async () =>
@ -488,12 +489,24 @@ const _ProjectController = {
anonRequestToken anonRequestToken
) )
const linkSharingChanges = const [linkSharingChanges, linkSharingEnforcement] = await Promise.all([
await SplitTestHandler.promises.getAssignmentForUser( SplitTestHandler.promises.getAssignmentForUser(
project.owner_ref, project.owner_ref,
'link-sharing-warning' 'link-sharing-warning'
) ),
SplitTestHandler.promises.getAssignmentForUser(
project.owner_ref,
'link-sharing-enforcement'
),
])
if (linkSharingChanges?.variant === 'active') { if (linkSharingChanges?.variant === 'active') {
if (linkSharingEnforcement?.variant === 'active') {
await Modules.promises.hooks.fire(
'enforceCollaboratorLimit',
projectId
)
}
if (isTokenMember) { if (isTokenMember) {
// Check explicitly that the user is in read write token refs, while this could be inferred // Check explicitly that the user is in read write token refs, while this could be inferred
// from the privilege level, the privilege level of token members might later be restricted // from the privilege level, the privilege level of token members might later be restricted
@ -570,12 +583,14 @@ const _ProjectController = {
) )
const planLimit = ownerFeatures?.collaborators || 0 const planLimit = ownerFeatures?.collaborators || 0
const namedEditors = project.collaberator_refs?.length || 0 const namedEditors = project.collaberator_refs?.length || 0
const pendingEditors = project.pendingEditor_refs?.length || 0
const exceedAtLimit = planLimit > -1 && namedEditors >= planLimit const exceedAtLimit = planLimit > -1 && namedEditors >= planLimit
const projectOpenedSegmentation = { const projectOpenedSegmentation = {
projectId: project._id, projectId: project._id,
// temporary link sharing segmentation: // temporary link sharing segmentation:
linkSharingWarning: linkSharingChanges?.variant, linkSharingWarning: linkSharingChanges?.variant,
namedEditors, namedEditors,
pendingEditors,
tokenEditors: project.tokenAccessReadAndWrite_refs?.length || 0, tokenEditors: project.tokenAccessReadAndWrite_refs?.length || 0,
planLimit, planLimit,
exceedAtLimit, exceedAtLimit,
@ -1034,7 +1049,6 @@ const ProjectController = {
), ),
updateProjectSettings: expressify(_ProjectController.updateProjectSettings), updateProjectSettings: expressify(_ProjectController.updateProjectSettings),
userProjectsJson: expressify(_ProjectController.userProjectsJson), userProjectsJson: expressify(_ProjectController.userProjectsJson),
_buildProjectList: _ProjectController._buildProjectList, _buildProjectList: _ProjectController._buildProjectList,
_buildProjectViewModel: _ProjectController._buildProjectViewModel, _buildProjectViewModel: _ProjectController._buildProjectViewModel,
_injectProjectUsers: _ProjectController._injectProjectUsers, _injectProjectUsers: _ProjectController._injectProjectUsers,

View file

@ -83,11 +83,9 @@ module.exports = ProjectEditorHandler = {
for (const member of members || []) { for (const member of members || []) {
if (member.privilegeLevel === 'owner') { if (member.privilegeLevel === 'owner') {
ownerFeatures = member.user.features ownerFeatures = member.user.features
owner = this.buildUserModelView(member.user, 'owner') owner = this.buildUserModelView(member)
} else { } else {
filteredMembers.push( filteredMembers.push(this.buildUserModelView(member))
this.buildUserModelView(member.user, member.privilegeLevel)
)
} }
} }
return { return {
@ -97,14 +95,16 @@ module.exports = ProjectEditorHandler = {
} }
}, },
buildUserModelView(user, privileges) { buildUserModelView(member) {
const user = member.user
return { return {
_id: user._id, _id: user._id,
first_name: user.first_name, first_name: user.first_name,
last_name: user.last_name, last_name: user.last_name,
email: user.email, email: user.email,
privileges, privileges: member.privilegeLevel,
signUpDate: user.signUpDate, signUpDate: user.signUpDate,
pendingEditor: member.pendingEditor,
} }
}, },

View file

@ -30,14 +30,14 @@ async function allowedNumberOfCollaboratorsForUser(userId) {
async function canAddXCollaborators(projectId, numberOfNewCollaborators) { async function canAddXCollaborators(projectId, numberOfNewCollaborators) {
const allowedNumber = await allowedNumberOfCollaboratorsInProject(projectId) const allowedNumber = await allowedNumberOfCollaboratorsInProject(projectId)
if (allowedNumber < 0) {
return true // -1 means unlimited
}
const currentNumber = const currentNumber =
await CollaboratorsGetter.promises.getInvitedCollaboratorCount(projectId) await CollaboratorsGetter.promises.getInvitedCollaboratorCount(projectId)
const inviteCount = const inviteCount =
await CollaboratorsInvitesHandler.promises.getInviteCount(projectId) await CollaboratorsInvitesHandler.promises.getInviteCount(projectId)
return ( return currentNumber + inviteCount + numberOfNewCollaborators <= allowedNumber
currentNumber + inviteCount + numberOfNewCollaborators <= allowedNumber ||
allowedNumber < 0 // -1 means unlimited
)
} }
async function canAddXEditCollaborators( async function canAddXEditCollaborators(
@ -45,6 +45,9 @@ async function canAddXEditCollaborators(
numberOfNewEditCollaborators numberOfNewEditCollaborators
) { ) {
const allowedNumber = await allowedNumberOfCollaboratorsInProject(projectId) const allowedNumber = await allowedNumberOfCollaboratorsInProject(projectId)
if (allowedNumber < 0) {
return true // -1 means unlimited
}
const currentEditors = const currentEditors =
await CollaboratorsGetter.promises.getInvitedEditCollaboratorCount( await CollaboratorsGetter.promises.getInvitedEditCollaboratorCount(
projectId projectId
@ -53,7 +56,7 @@ async function canAddXEditCollaborators(
await CollaboratorsInvitesHandler.promises.getEditInviteCount(projectId) await CollaboratorsInvitesHandler.promises.getEditInviteCount(projectId)
return ( return (
currentEditors + editInviteCount + numberOfNewEditCollaborators <= currentEditors + editInviteCount + numberOfNewEditCollaborators <=
allowedNumber || allowedNumber < 0 // -1 means unlimited allowedNumber
) )
} }

View file

@ -39,6 +39,7 @@ const ProjectSchema = new Schema(
owner_ref: { type: ObjectId, ref: 'User' }, owner_ref: { type: ObjectId, ref: 'User' },
collaberator_refs: [{ type: ObjectId, ref: 'User' }], collaberator_refs: [{ type: ObjectId, ref: 'User' }],
readOnly_refs: [{ type: ObjectId, ref: 'User' }], readOnly_refs: [{ type: ObjectId, ref: 'User' }],
pendingEditor_refs: [{ type: ObjectId, ref: 'User' }],
rootDoc_id: { type: ObjectId }, rootDoc_id: { type: ObjectId },
rootFolder: [FolderSchema], rootFolder: [FolderSchema],
version: { type: Number }, // incremented for every change in the project structure (folders and filenames) version: { type: Number }, // incremented for every change in the project structure (folders and filenames)

View file

@ -124,9 +124,17 @@ export default function EditMember({
<FormGroup className="project-member"> <FormGroup className="project-member">
<Col xs={7}> <Col xs={7}>
<div className="project-member-email-icon"> <div className="project-member-email-icon">
<Icon type={shouldWarnMember() ? 'warning' : 'user'} fw /> <Icon
type={
shouldWarnMember() || member.pendingEditor ? 'warning' : 'user'
}
fw
/>
<div className="email-warning"> <div className="email-warning">
{member.email} {member.email}
{member.pendingEditor && (
<div className="subtitle">Pending editor</div>
)}
{shouldWarnMember() && ( {shouldWarnMember() && (
<div className="subtitle"> <div className="subtitle">
{t('will_lose_edit_access_on_date', { {t('will_lose_edit_access_on_date', {

View file

@ -44,6 +44,7 @@ export const EditorContext = createContext<
insertSymbol?: (symbol: string) => void insertSymbol?: (symbol: string) => void
isProjectOwner: boolean isProjectOwner: boolean
isRestrictedTokenMember?: boolean isRestrictedTokenMember?: boolean
isPendingEditor: boolean
permissionsLevel: 'readOnly' | 'readAndWrite' | 'owner' permissionsLevel: 'readOnly' | 'readAndWrite' | 'owner'
deactivateTutorial: (tutorial: string) => void deactivateTutorial: (tutorial: string) => void
inactiveTutorials: string[] inactiveTutorials: string[]
@ -62,7 +63,7 @@ export const EditorProvider: FC = ({ children }) => {
const { role } = useDetachContext() const { role } = useDetachContext()
const { showGenericMessageModal } = useModalsContext() const { showGenericMessageModal } = useModalsContext()
const { owner, features, _id: projectId } = useProjectContext() const { owner, features, _id: projectId, members } = useProjectContext()
const cobranding = useMemo(() => { const cobranding = useMemo(() => {
const brandVariation = getMeta('ol-brandVariation') const brandVariation = getMeta('ol-brandVariation')
@ -98,6 +99,17 @@ export const EditorProvider: FC = ({ children }) => {
const [currentPopup, setCurrentPopup] = useState<string | null>(null) const [currentPopup, setCurrentPopup] = useState<string | null>(null)
const isPendingEditor = useMemo(
() =>
members?.some(
member =>
member._id === userId &&
member.pendingEditor &&
member.privileges === 'readAndWrite'
),
[members, userId]
)
const deactivateTutorial = useCallback( const deactivateTutorial = useCallback(
tutorialKey => { tutorialKey => {
setInactiveTutorials([...inactiveTutorials, tutorialKey]) setInactiveTutorials([...inactiveTutorials, tutorialKey])
@ -174,6 +186,7 @@ export const EditorProvider: FC = ({ children }) => {
setPermissionsLevel, setPermissionsLevel,
isProjectOwner: owner?._id === userId, isProjectOwner: owner?._id === userId,
isRestrictedTokenMember: getMeta('ol-isRestrictedTokenMember'), isRestrictedTokenMember: getMeta('ol-isRestrictedTokenMember'),
isPendingEditor,
showSymbolPalette, showSymbolPalette,
toggleSymbolPalette, toggleSymbolPalette,
insertSymbol, insertSymbol,
@ -194,6 +207,7 @@ export const EditorProvider: FC = ({ children }) => {
renameProject, renameProject,
permissionsLevel, permissionsLevel,
setPermissionsLevel, setPermissionsLevel,
isPendingEditor,
showSymbolPalette, showSymbolPalette,
toggleSymbolPalette, toggleSymbolPalette,
insertSymbol, insertSymbol,

View file

@ -7,6 +7,7 @@ export type ProjectContextMember = {
email: string email: string
first_name: string first_name: string
last_name: string last_name: string
pendingEditor?: boolean
} }
export type ProjectContextValue = { export type ProjectContextValue = {

View file

@ -946,6 +946,10 @@ class User {
updateOp = { $addToSet: { collaberator_refs: user._id } } updateOp = { $addToSet: { collaberator_refs: user._id } }
} else if (privileges === 'readOnly') { } else if (privileges === 'readOnly') {
updateOp = { $addToSet: { readOnly_refs: user._id } } updateOp = { $addToSet: { readOnly_refs: user._id } }
} else if (privileges === 'pendingEditor') {
updateOp = {
$addToSet: { readOnly_refs: user._id, pendingEditor_refs: user._id },
}
} }
db.projects.updateOne({ _id: new ObjectId(projectId) }, updateOp, callback) db.projects.updateOne({ _id: new ObjectId(projectId) }, updateOp, callback)
} }

View file

@ -17,6 +17,7 @@ describe('CollaboratorsGetter', function () {
this.ownerRef = new ObjectId() this.ownerRef = new ObjectId()
this.readOnlyRef1 = new ObjectId() this.readOnlyRef1 = new ObjectId()
this.readOnlyRef2 = new ObjectId() this.readOnlyRef2 = new ObjectId()
this.pendingEditorRef = new ObjectId()
this.readWriteRef1 = new ObjectId() this.readWriteRef1 = new ObjectId()
this.readWriteRef2 = new ObjectId() this.readWriteRef2 = new ObjectId()
this.readOnlyTokenRef = new ObjectId() this.readOnlyTokenRef = new ObjectId()
@ -25,7 +26,12 @@ describe('CollaboratorsGetter', function () {
this.project = { this.project = {
_id: new ObjectId(), _id: new ObjectId(),
owner_ref: [this.ownerRef], owner_ref: [this.ownerRef],
readOnly_refs: [this.readOnlyRef1, this.readOnlyRef2], readOnly_refs: [
this.readOnlyRef1,
this.readOnlyRef2,
this.pendingEditorRef,
],
pendingEditor_refs: [this.pendingEditorRef],
collaberator_refs: [this.readWriteRef1, this.readWriteRef2], collaberator_refs: [this.readWriteRef1, this.readWriteRef2],
tokenAccessReadAndWrite_refs: [this.readWriteTokenRef], tokenAccessReadAndWrite_refs: [this.readWriteTokenRef],
tokenAccessReadOnly_refs: [this.readOnlyTokenRef], tokenAccessReadOnly_refs: [this.readOnlyTokenRef],
@ -99,6 +105,12 @@ describe('CollaboratorsGetter', function () {
privilegeLevel: 'readOnly', privilegeLevel: 'readOnly',
source: 'invite', source: 'invite',
}, },
{
id: this.pendingEditorRef.toString(),
privilegeLevel: 'readOnly',
source: 'invite',
pendingEditor: true,
},
{ {
id: this.readOnlyTokenRef.toString(), id: this.readOnlyTokenRef.toString(),
privilegeLevel: 'readOnly', privilegeLevel: 'readOnly',
@ -139,6 +151,7 @@ describe('CollaboratorsGetter', function () {
this.readOnlyRef2.toString(), this.readOnlyRef2.toString(),
this.readWriteRef1.toString(), this.readWriteRef1.toString(),
this.readWriteRef2.toString(), this.readWriteRef2.toString(),
this.pendingEditorRef.toString(),
this.readWriteTokenRef.toString(), this.readWriteTokenRef.toString(),
this.readOnlyTokenRef.toString(), this.readOnlyTokenRef.toString(),
]) ])
@ -157,6 +170,7 @@ describe('CollaboratorsGetter', function () {
this.readOnlyRef2.toString(), this.readOnlyRef2.toString(),
this.readWriteRef1.toString(), this.readWriteRef1.toString(),
this.readWriteRef2.toString(), this.readWriteRef2.toString(),
this.pendingEditorRef.toString(),
]) ])
}) })
}) })
@ -484,4 +498,14 @@ describe('CollaboratorsGetter', function () {
expect(count).to.equal(2) expect(count).to.equal(2)
}) })
}) })
describe('getInvitedPendingEditorCount', function () {
it('should return the count of pending editors', async function () {
const count =
await this.CollaboratorsGetter.promises.getInvitedPendingEditorCount(
this.project._id
)
expect(count).to.equal(1)
})
})
}) })

View file

@ -106,6 +106,7 @@ describe('CollaboratorsHandler', function () {
$pull: { $pull: {
collaberator_refs: this.userId, collaberator_refs: this.userId,
readOnly_refs: this.userId, readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
tokenAccessReadOnly_refs: this.userId, tokenAccessReadOnly_refs: this.userId,
tokenAccessReadAndWrite_refs: this.userId, tokenAccessReadAndWrite_refs: this.userId,
archived: this.userId, archived: this.userId,
@ -148,6 +149,7 @@ describe('CollaboratorsHandler', function () {
$pull: { $pull: {
collaberator_refs: this.userId, collaberator_refs: this.userId,
readOnly_refs: this.userId, readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
tokenAccessReadOnly_refs: this.userId, tokenAccessReadOnly_refs: this.userId,
tokenAccessReadAndWrite_refs: this.userId, tokenAccessReadAndWrite_refs: this.userId,
trashed: this.userId, trashed: this.userId,
@ -182,6 +184,7 @@ describe('CollaboratorsHandler', function () {
$pull: { $pull: {
collaberator_refs: this.userId, collaberator_refs: this.userId,
readOnly_refs: this.userId, readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
tokenAccessReadOnly_refs: this.userId, tokenAccessReadOnly_refs: this.userId,
tokenAccessReadAndWrite_refs: this.userId, tokenAccessReadAndWrite_refs: this.userId,
archived: this.userId, archived: this.userId,
@ -377,6 +380,7 @@ describe('CollaboratorsHandler', function () {
$pull: { $pull: {
collaberator_refs: this.userId, collaberator_refs: this.userId,
readOnly_refs: this.userId, readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
tokenAccessReadOnly_refs: this.userId, tokenAccessReadOnly_refs: this.userId,
tokenAccessReadAndWrite_refs: this.userId, tokenAccessReadAndWrite_refs: this.userId,
archived: this.userId, archived: this.userId,
@ -457,6 +461,24 @@ describe('CollaboratorsHandler', function () {
) )
.chain('exec') .chain('exec')
.resolves() .resolves()
this.ProjectMock.expects('updateMany')
.withArgs(
{ pendingEditor_refs: this.fromUserId },
{
$addToSet: { pendingEditor_refs: this.toUserId },
}
)
.chain('exec')
.resolves()
this.ProjectMock.expects('updateMany')
.withArgs(
{ pendingEditor_refs: this.fromUserId },
{
$pull: { pendingEditor_refs: this.fromUserId },
}
)
.chain('exec')
.resolves()
}) })
describe('successfully', function () { describe('successfully', function () {
@ -501,7 +523,10 @@ describe('CollaboratorsHandler', function () {
], ],
}, },
{ {
$pull: { collaberator_refs: this.userId }, $pull: {
collaberator_refs: this.userId,
pendingEditor_refs: this.userId,
},
$addToSet: { readOnly_refs: this.userId }, $addToSet: { readOnly_refs: this.userId },
} }
) )
@ -526,7 +551,10 @@ describe('CollaboratorsHandler', function () {
}, },
{ {
$addToSet: { collaberator_refs: this.userId }, $addToSet: { collaberator_refs: this.userId },
$pull: { readOnly_refs: this.userId }, $pull: {
readOnly_refs: this.userId,
pendingEditor_refs: this.userId,
},
} }
) )
.chain('exec') .chain('exec')

View file

@ -131,6 +131,11 @@ describe('ProjectController', function () {
isUserInvitedReadWriteMemberOfProject: sinon.stub().resolves(true), isUserInvitedReadWriteMemberOfProject: sinon.stub().resolves(true),
}, },
} }
this.CollaboratorsHandler = {
promises: {
setCollaboratorPrivilegeLevel: sinon.stub().resolves(),
},
}
this.ProjectEntityHandler = {} this.ProjectEntityHandler = {}
this.UserGetter = { this.UserGetter = {
getUserFullEmails: sinon.stub().yields(null, []), getUserFullEmails: sinon.stub().yields(null, []),
@ -200,12 +205,16 @@ describe('ProjectController', function () {
this.OnboardingDataCollectionManager = { this.OnboardingDataCollectionManager = {
getOnboardingDataValue: sinon.stub().resolves(null), getOnboardingDataValue: sinon.stub().resolves(null),
} }
this.Modules = {
promises: { hooks: { fire: sinon.stub().resolves() } },
}
this.ProjectController = SandboxedModule.require(MODULE_PATH, { this.ProjectController = SandboxedModule.require(MODULE_PATH, {
requires: { requires: {
'mongodb-legacy': { ObjectId }, 'mongodb-legacy': { ObjectId },
'@overleaf/settings': this.settings, '@overleaf/settings': this.settings,
'@overleaf/metrics': this.Metrics, '@overleaf/metrics': this.Metrics,
'../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler,
'../SplitTests/SplitTestHandler': this.SplitTestHandler, '../SplitTests/SplitTestHandler': this.SplitTestHandler,
'../SplitTests/SplitTestSessionHandler': this.SplitTestSessionHandler, '../SplitTests/SplitTestSessionHandler': this.SplitTestSessionHandler,
'./ProjectDeleter': this.ProjectDeleter, './ProjectDeleter': this.ProjectDeleter,
@ -255,6 +264,7 @@ describe('ProjectController', function () {
updateUser: sinon.stub().resolves(), updateUser: sinon.stub().resolves(),
}, },
}, },
'../../infrastructure/Modules': this.Modules,
}, },
}) })
@ -1015,9 +1025,13 @@ describe('ProjectController', function () {
describe('link sharing changes active', function () { describe('link sharing changes active', function () {
beforeEach(function () { beforeEach(function () {
this.SplitTestHandler.promises.getAssignmentForUser.resolves({ this.SplitTestHandler.promises.getAssignmentForUser.callsFake(
variant: 'active', async (userId, test) => {
}) if (test === 'link-sharing-warning') {
return { variant: 'active' }
}
}
)
}) })
describe('when user is a read write token member (and not already a named editor)', function () { describe('when user is a read write token member (and not already a named editor)', function () {
@ -1059,6 +1073,57 @@ describe('ProjectController', function () {
}) })
}) })
}) })
describe('link sharing enforcement', function () {
describe('when not active (default)', function () {
beforeEach(function () {
this.SplitTestHandler.promises.getAssignmentForUser.callsFake(
async (userId, test) => {
if (test === 'link-sharing-warning') {
return { variant: 'active' }
} else if (test === 'link-sharing-enforcement') {
return { variant: 'default' }
}
}
)
})
it('should not call the collaborator limit enforcement check', function (done) {
this.res.render = (pageName, opts) => {
this.Modules.promises.hooks.fire.should.not.have.been.calledWith(
'enforceCollaboratorLimit'
)
done()
}
this.ProjectController.loadEditor(this.req, this.res)
})
})
describe('when active', function () {
beforeEach(function () {
this.SplitTestHandler.promises.getAssignmentForUser.callsFake(
async (userId, test) => {
if (test === 'link-sharing-warning') {
return { variant: 'active' }
} else if (test === 'link-sharing-enforcement') {
return { variant: 'active' }
}
}
)
})
it('should call the collaborator limit enforcement check', function (done) {
this.res.render = (pageName, opts) => {
this.Modules.promises.hooks.fire.should.have.been.calledWith(
'enforceCollaboratorLimit',
this.project_id
)
done()
}
this.ProjectController.loadEditor(this.req, this.res)
})
})
})
}) })
describe('userProjectsJson', function () { describe('userProjectsJson', function () {