Merge pull request #29733 from overleaf/mg-transfer-ownership

Fix transfer ownership permissions

GitOrigin-RevId: b6d09704361507085e3eae8dc9240a36ae47c70e
This commit is contained in:
Malik Glossop
2025-11-21 15:56:34 +01:00
committed by Copybot
parent 68c9d1931d
commit 016778295a
3 changed files with 257 additions and 67 deletions

View File

@@ -12,6 +12,7 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
import OError from '@overleaf/o-error'
import TagsHandler from '../Tags/TagsHandler.mjs'
import { promiseMapWithLimit } from '@overleaf/promise-utils'
import LimitationsManager from '../Subscription/LimitationsManager.mjs'
export default {
promises: {
@@ -124,16 +125,20 @@ async function transferOwnership(projectId, newOwnerId, options = {}) {
{ previousOwnerId, newOwnerId }
)
// Determine which permissions to give old owner based on
// new owner's existing permissions
const newPermissions =
_getUserPermissions(newOwner, project) || PrivilegeLevels.READ_ONLY
// Transfer ownership
await _transferOwnership(projectId, previousOwnerId, newOwnerId)
await _transferOwnership(
// Determine which permissions to give old owner (after transfer, so we use new owner's limits)
const { privilegeLevel, pendingPrivilegeLevel } =
await _determinePrivilegeLevelForPreviousOwner(projectId)
// Add the previous owner back to the project with determined permissions
await CollaboratorsHandler.promises.addUserIdToProject(
projectId,
previousOwnerId,
newOwnerId,
newPermissions
previousOwnerId,
privilegeLevel,
pendingPrivilegeLevel
)
// Flush project to TPDS
@@ -189,26 +194,34 @@ function _userIsCollaborator(user, project) {
return Boolean(_getUserPermissions(user, project))
}
async function _transferOwnership(
projectId,
previousOwnerId,
newOwnerId,
newPermissions
) {
async function _determinePrivilegeLevelForPreviousOwner(projectId) {
// Try to give READ_AND_WRITE if space available based on new owner's limits
const canAddEditor =
await LimitationsManager.promises.canAddXEditCollaborators(projectId, 1)
if (canAddEditor) {
return { privilegeLevel: PrivilegeLevels.READ_AND_WRITE }
}
// Collaborator limit is reached for editor and reviewer so fall back to read-only
// Add pending editor status so they are automatically upgraded when possible
return {
privilegeLevel: PrivilegeLevels.READ_ONLY,
pendingPrivilegeLevel: { pendingEditor: true },
}
}
async function _transferOwnership(projectId, previousOwnerId, newOwnerId) {
// Remove new owner from collaborators list
await CollaboratorsHandler.promises.removeUserFromProject(
projectId,
newOwnerId
)
// Update project ownership
await Project.updateOne(
{ _id: projectId },
{ $set: { owner_ref: newOwnerId } }
).exec()
await CollaboratorsHandler.promises.addUserIdToProject(
projectId,
newOwnerId,
previousOwnerId,
newPermissions
)
}
async function _sendEmails(project, previousOwner, newOwner) {

View File

@@ -1,5 +1,6 @@
import { expect } from 'chai'
import UserHelper from './helpers/User.mjs'
import Features from '../../../app/src/infrastructure/Features.mjs'
const User = UserHelper.promises
@@ -53,11 +54,38 @@ describe('Project ownership transfer', function () {
})
it('adds the previous owner as a read/write collaborator', async function () {
// Skip this test in SaaS environments as limited collaborators are enforced
if (Features.hasFeature('saas')) {
this.skip()
}
const project = await this.collaboratorSession.getProject(this.projectId)
expect(project.collaberator_refs.map(x => x.toString())).to.have.members([
this.owner._id.toString(),
this.invitedAdmin._id.toString(),
])
expect(project.owner_ref.toString()).to.equal(
this.collaborator._id.toString()
)
expect(project.readOnly_refs.map(x => x.toString())).to.be.empty
})
it('adds the previous owner as a read only', async function () {
// Skip this test in non-SaaS environments as unlimited collaborators are allowed
if (!Features.hasFeature('saas')) {
this.skip()
}
const project = await this.collaboratorSession.getProject(this.projectId)
expect(project.collaberator_refs.map(x => x.toString())).to.have.members([
this.invitedAdmin._id.toString(),
])
expect(project.owner_ref.toString()).to.equal(
this.collaborator._id.toString()
)
expect(project.readOnly_refs.map(x => x.toString())).to.have.members([
this.owner._id.toString(),
])
})
it('lets the new owner open the project', async function () {

View File

@@ -80,6 +80,11 @@ describe('OwnershipTransferHandler', function () {
addProjectsToTag: sinon.stub().resolves(),
},
}
ctx.LimitationsManager = {
promises: {
canAddXEditCollaborators: sinon.stub().resolves(true),
},
}
vi.mock('../../../../app/src/Features/Errors/Errors.js', () =>
vi.importActual('../../../../app/src/Features/Errors/Errors.js')
@@ -136,6 +141,13 @@ describe('OwnershipTransferHandler', function () {
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/LimitationsManager.mjs',
() => ({
default: ctx.LimitationsManager,
})
)
ctx.handler = (await import(MODULE_PATH)).default
})
@@ -195,30 +207,197 @@ describe('OwnershipTransferHandler', function () {
)
})
it('should transfer ownership of the project to a read-only collaborator', async function (ctx) {
it('should check collaborator limits after ownership transfer', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.readOnlyCollaborator._id
ctx.collaborator._id
)
expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: ctx.project._id },
sinon.match({ $set: { owner_ref: ctx.readOnlyCollaborator._id } })
expect(ctx.ProjectModel.updateOne).to.have.been.calledBefore(
ctx.LimitationsManager.promises.canAddXEditCollaborators
)
expect(
ctx.LimitationsManager.promises.canAddXEditCollaborators
).to.have.been.calledBefore(
ctx.CollaboratorsHandler.promises.addUserIdToProject
)
})
it('gives old owner read-only permissions if new owner was previously a viewer', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.readOnlyCollaborator._id
)
expect(
ctx.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
ctx.project._id,
ctx.readOnlyCollaborator._id,
ctx.user._id,
PrivilegeLevels.READ_ONLY
)
describe('when there are edit collaborator slots available', function () {
beforeEach(function (ctx) {
ctx.LimitationsManager.promises.canAddXEditCollaborators.resolves(true)
})
it('should give old owner read/write permissions when transferring to a read-only collaborator', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.readOnlyCollaborator._id
)
expect(
ctx.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
ctx.project._id,
ctx.readOnlyCollaborator._id,
ctx.user._id,
PrivilegeLevels.READ_AND_WRITE,
undefined
)
expect(
ctx.LimitationsManager.promises.canAddXEditCollaborators
).to.have.been.calledWith(ctx.project._id, 1)
})
it('should give old owner read/write permissions when transferring to an editor', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id
)
expect(
ctx.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
ctx.project._id,
ctx.collaborator._id,
ctx.user._id,
PrivilegeLevels.READ_AND_WRITE,
undefined
)
expect(
ctx.LimitationsManager.promises.canAddXEditCollaborators
).to.have.been.calledWith(ctx.project._id, 1)
})
it('should give old owner read/write permissions when transferring to a reviewer', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.reviewer._id
)
expect(
ctx.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
ctx.project._id,
ctx.reviewer._id,
ctx.user._id,
PrivilegeLevels.READ_AND_WRITE,
undefined
)
expect(
ctx.LimitationsManager.promises.canAddXEditCollaborators
).to.have.been.calledWith(ctx.project._id, 1)
})
it('should give old owner read/write permissions when transferring to a non-collaborator', async function (ctx) {
ctx.project.collaberator_refs = []
ctx.project.readOnly_refs = []
ctx.project.reviewer_refs = []
const newOwner = {
_id: new ObjectId(),
email: 'admin@example.com',
}
ctx.UserGetter.promises.getUser
.withArgs(newOwner._id)
.resolves(newOwner)
await ctx.handler.promises.transferOwnership(
ctx.project._id,
newOwner._id,
{ allowTransferToNonCollaborators: true }
)
expect(
ctx.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
ctx.project._id,
newOwner._id,
ctx.user._id,
PrivilegeLevels.READ_AND_WRITE,
undefined
)
expect(
ctx.LimitationsManager.promises.canAddXEditCollaborators
).to.have.been.calledWith(ctx.project._id, 1)
})
})
describe('when there are no edit collaborator slots available', function () {
beforeEach(function (ctx) {
ctx.LimitationsManager.promises.canAddXEditCollaborators.resolves(false)
})
it('should give old owner read-only with pending editor flag when transferring to an editor', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id
)
expect(
ctx.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
ctx.project._id,
ctx.collaborator._id,
ctx.user._id,
PrivilegeLevels.READ_ONLY,
{ pendingEditor: true }
)
})
it('should give old owner read-only with pending editor flag when transferring to a reviewer', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.reviewer._id
)
expect(
ctx.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
ctx.project._id,
ctx.reviewer._id,
ctx.user._id,
PrivilegeLevels.READ_ONLY,
{ pendingEditor: true }
)
})
it('should give old owner read-only with pending editor flag when transferring to a read-only collaborator', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.readOnlyCollaborator._id
)
expect(
ctx.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
ctx.project._id,
ctx.readOnlyCollaborator._id,
ctx.user._id,
PrivilegeLevels.READ_ONLY,
{ pendingEditor: true }
)
})
it('should give old owner read-only with pending editor flag when transferring to a non-collaborator', async function (ctx) {
ctx.project.collaberator_refs = []
ctx.project.readOnly_refs = []
ctx.project.reviewer_refs = []
const newOwner = {
_id: new ObjectId(),
email: 'newowner@example.com',
}
ctx.UserGetter.promises.getUser
.withArgs(newOwner._id)
.resolves(newOwner)
await ctx.handler.promises.transferOwnership(
ctx.project._id,
newOwner._id,
{ allowTransferToNonCollaborators: true }
)
expect(
ctx.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
ctx.project._id,
newOwner._id,
ctx.user._id,
PrivilegeLevels.READ_ONLY,
{ pendingEditor: true }
)
})
})
it('should do nothing if transferring back to the owner', async function (ctx) {
@@ -239,21 +418,6 @@ describe('OwnershipTransferHandler', function () {
).to.have.been.calledWith(ctx.project._id, ctx.collaborator._id)
})
it('should add the former project owner as a read/write collaborator', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id
)
expect(
ctx.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
ctx.project._id,
ctx.collaborator._id,
ctx.user._id,
PrivilegeLevels.READ_AND_WRITE
)
})
it('should transfer ownership of the project to a reviewer', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
@@ -265,21 +429,6 @@ describe('OwnershipTransferHandler', function () {
)
})
it('gives old owner reviewer permissions if new owner was previously a reviewer', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.reviewer._id
)
expect(
ctx.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
ctx.project._id,
ctx.reviewer._id,
ctx.user._id,
PrivilegeLevels.REVIEW
)
})
it('should flush the project to tpds', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,