Files
overleaf-cep/services/web/test/unit/src/Collaborators/OwnershipTransferHandler.test.mjs
Malik Glossop 016778295a Merge pull request #29733 from overleaf/mg-transfer-ownership
Fix transfer ownership permissions

GitOrigin-RevId: b6d09704361507085e3eae8dc9240a36ae47c70e
2025-11-24 09:06:50 +00:00

682 lines
21 KiB
JavaScript

import { vi, expect } from 'vitest'
import sinon from 'sinon'
import PrivilegeLevels from '../../../../app/src/Features/Authorization/PrivilegeLevels.mjs'
import Errors from '../../../../app/src/Features/Errors/Errors.js'
import mongodb from 'mongodb-legacy'
const { ObjectId } = mongodb
const MODULE_PATH =
'../../../../app/src/Features/Collaborators/OwnershipTransferHandler.mjs'
describe('OwnershipTransferHandler', function () {
beforeEach(async function (ctx) {
ctx.user = { _id: new ObjectId(), email: 'owner@example.com' }
ctx.collaborator = {
_id: new ObjectId(),
email: 'collaborator@example.com',
}
ctx.readOnlyCollaborator = {
_id: new ObjectId(),
email: 'readonly@example.com',
}
ctx.reviewer = {
_id: new ObjectId(),
email: 'reviewer@example.com',
}
ctx.project = {
_id: new ObjectId(),
name: 'project',
owner_ref: ctx.user._id,
collaberator_refs: [ctx.collaborator._id],
readOnly_refs: [ctx.readOnlyCollaborator._id],
reviewer_refs: [ctx.reviewer._id],
}
ctx.ProjectGetter = {
promises: {
getProject: sinon.stub().resolves(ctx.project),
},
}
ctx.ProjectModel = {
find: sinon.stub().resolves([]),
updateOne: sinon.stub().returns({
exec: sinon.stub().resolves(),
}),
}
ctx.UserGetter = {
promises: {
getUser: sinon.stub().resolves(),
},
}
ctx.TpdsUpdateSender = {
promises: {
moveEntity: sinon.stub().resolves(),
},
}
ctx.TpdsProjectFlusher = {
promises: {
flushProjectToTpds: sinon.stub().resolves(),
},
}
ctx.CollaboratorsHandler = {
promises: {
removeUserFromProject: sinon.stub().resolves(),
addUserIdToProject: sinon.stub().resolves(),
},
}
ctx.EmailHandler = {
promises: {
sendEmail: sinon.stub().resolves(),
},
}
ctx.ProjectAuditLogHandler = {
promises: {
addEntry: sinon.stub().resolves(),
},
}
ctx.TagsHandler = {
promises: {
createTag: sinon.stub().resolves(),
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')
)
vi.doMock('../../../../app/src/Features/Project/ProjectGetter.mjs', () => ({
default: ctx.ProjectGetter,
}))
vi.doMock('../../../../app/src/models/Project.mjs', () => ({
Project: ctx.ProjectModel,
}))
vi.doMock('../../../../app/src/Features/Tags/TagsHandler.mjs', () => ({
default: ctx.TagsHandler,
}))
vi.doMock('../../../../app/src/Features/User/UserGetter.mjs', () => ({
default: ctx.UserGetter,
}))
vi.doMock(
'../../../../app/src/Features/ThirdPartyDataStore/TpdsProjectFlusher.mjs',
() => ({
default: ctx.TpdsProjectFlusher,
})
)
vi.doMock(
'../../../../app/src/Features/Project/ProjectAuditLogHandler.mjs',
() => ({
default: ctx.ProjectAuditLogHandler,
})
)
vi.doMock('../../../../app/src/Features/Email/EmailHandler.mjs', () => ({
default: ctx.EmailHandler,
}))
vi.doMock(
'../../../../app/src/Features/Collaborators/CollaboratorsHandler.mjs',
() => ({
default: ctx.CollaboratorsHandler,
})
)
vi.doMock(
'../../../../app/src/Features/Analytics/AnalyticsManager',
() => ({
default: {
recordEventForUserInBackground: (ctx.recordEventForUserInBackground =
sinon.stub()),
},
})
)
vi.doMock(
'../../../../app/src/Features/Subscription/LimitationsManager.mjs',
() => ({
default: ctx.LimitationsManager,
})
)
ctx.handler = (await import(MODULE_PATH)).default
})
describe('transferOwnership', function () {
beforeEach(function (ctx) {
ctx.UserGetter.promises.getUser.withArgs(ctx.user._id).resolves(ctx.user)
ctx.UserGetter.promises.getUser
.withArgs(ctx.collaborator._id)
.resolves(ctx.collaborator)
ctx.UserGetter.promises.getUser
.withArgs(ctx.readOnlyCollaborator._id)
.resolves(ctx.readOnlyCollaborator)
ctx.UserGetter.promises.getUser
.withArgs(ctx.reviewer._id)
.resolves(ctx.reviewer)
})
it("should return a not found error if the project can't be found", async function (ctx) {
ctx.ProjectGetter.promises.getProject.resolves(null)
await expect(
ctx.handler.promises.transferOwnership('abc', ctx.collaborator._id)
).to.be.rejectedWith(Errors.ProjectNotFoundError)
})
it("should return a not found error if the user can't be found", async function (ctx) {
ctx.UserGetter.promises.getUser
.withArgs(ctx.collaborator._id)
.resolves(null)
await expect(
ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id
)
).to.be.rejectedWith(Errors.UserNotFoundError)
})
it('should return an error if user cannot be removed as collaborator ', async function (ctx) {
ctx.CollaboratorsHandler.promises.removeUserFromProject.rejects(
new Error('user-cannot-be-removed')
)
await expect(
ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id
)
).to.be.rejected
})
it('should transfer ownership of the project', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id
)
expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: ctx.project._id },
sinon.match({ $set: { owner_ref: ctx.collaborator._id } })
)
})
it('should check collaborator limits after ownership transfer', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._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
)
})
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) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.user._id
)
expect(ctx.ProjectModel.updateOne).not.to.have.been.called
})
it("should remove the user from the project's collaborators", async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id
)
expect(
ctx.CollaboratorsHandler.promises.removeUserFromProject
).to.have.been.calledWith(ctx.project._id, ctx.collaborator._id)
})
it('should transfer ownership of the project to a reviewer', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.reviewer._id
)
expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: ctx.project._id },
sinon.match({ $set: { owner_ref: ctx.reviewer._id } })
)
})
it('should flush the project to tpds', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id
)
expect(
ctx.TpdsProjectFlusher.promises.flushProjectToTpds
).to.have.been.calledWith(ctx.project._id)
})
it('should send an email notification', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id
)
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'ownershipTransferConfirmationPreviousOwner',
{
to: ctx.user.email,
project: ctx.project,
newOwner: ctx.collaborator,
}
)
expect(ctx.EmailHandler.promises.sendEmail).to.have.been.calledWith(
'ownershipTransferConfirmationNewOwner',
{
to: ctx.collaborator.email,
project: ctx.project,
previousOwner: ctx.user,
}
)
})
it('should not send an email notification with the skipEmails option', async function (ctx) {
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id,
{ skipEmails: true }
)
expect(ctx.EmailHandler.promises.sendEmail).not.to.have.been.called
})
it('should track the change in BigQuery', async function (ctx) {
const sessionUserId = new ObjectId()
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id,
{ sessionUserId }
)
expect(ctx.recordEventForUserInBackground).to.have.been.calledWith(
ctx.user._id,
'project-ownership-transfer',
{
projectId: ctx.project._id,
newOwnerId: ctx.collaborator._id,
}
)
})
it('should write an entry in the audit log', async function (ctx) {
const sessionUserId = new ObjectId()
const ipAddress = '1.2.3.4'
await ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id,
{ sessionUserId, ipAddress }
)
expect(
ctx.ProjectAuditLogHandler.promises.addEntry
).to.have.been.calledWith(
ctx.project._id,
'transfer-ownership',
sessionUserId,
ipAddress,
{
previousOwnerId: ctx.user._id,
newOwnerId: ctx.collaborator._id,
}
)
})
it('should decline to transfer ownership to a non-collaborator', async function (ctx) {
ctx.project.collaberator_refs = []
ctx.project.readOnly_refs = []
await expect(
ctx.handler.promises.transferOwnership(
ctx.project._id,
ctx.collaborator._id
)
).to.be.rejectedWith(Errors.UserNotCollaboratorError)
})
})
describe('transferAllProjectsToUser', function () {
const fromUserEmail = 'user.one@example.com'
const ipAddress = '1.2.3.4'
let fromUserId, toUserId
beforeEach(function () {
fromUserId = new ObjectId().toString()
toUserId = new ObjectId().toString()
})
describe('with missing user', function () {
it('should throw an error', async function (ctx) {
ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves(null)
ctx.UserGetter.promises.getUser
.withArgs(toUserId)
.resolves({ _id: new ObjectId(toUserId) })
await expect(
ctx.handler.promises.transferAllProjectsToUser({
toUserId,
fromUserId,
ipAddress,
})
).to.be.rejectedWith(/missing source user/)
ctx.UserGetter.promises.getUser
.withArgs(fromUserId)
.resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves(null)
await expect(
ctx.handler.promises.transferAllProjectsToUser({
fromUserId,
toUserId,
ipAddress,
})
).to.be.rejectedWith(/missing destination user/)
})
})
describe('with the same id', function () {
it('should throw an error', async function (ctx) {
ctx.UserGetter.promises.getUser
.withArgs(fromUserId)
.resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
await expect(
ctx.handler.promises.transferAllProjectsToUser({
fromUserId,
toUserId: fromUserId,
ipAddress,
})
).to.be.rejectedWith(/rejecting transfer between identical users/)
})
})
describe('happy path', function () {
let tag, fromUserEmail, projects
beforeEach(function (ctx) {
tag = {
_id: new ObjectId(),
name: 'some-tag-name',
}
projects = [
{ _id: 'project-1' },
{ _id: 'project-2' },
{ _id: 'project-3' },
]
ctx.UserGetter.promises.getUser.withArgs(fromUserId).resolves({
_id: new ObjectId(fromUserId),
email: fromUserEmail,
})
ctx.UserGetter.promises.getUser.withArgs(toUserId).resolves({
_id: new ObjectId(toUserId),
})
ctx.ProjectModel.find.resolves(projects)
ctx.TagsHandler.promises.createTag.resolves({
_id: tag._id,
name: 'some-tag-name',
})
ctx.TagsHandler.promises.addProjectsToTag.resolves()
})
it('creates a tag', async function (ctx) {
await ctx.handler.promises.transferAllProjectsToUser({
fromUserId,
toUserId,
ipAddress,
})
expect(ctx.TagsHandler.promises.createTag).to.have.been.calledWith(
toUserId,
`transferred-from-${fromUserEmail}`,
'#434AF0',
{ truncate: true }
)
})
it('returns a projectCount, and tag name', async function (ctx) {
const result = await ctx.handler.promises.transferAllProjectsToUser({
fromUserId,
toUserId,
ipAddress,
})
expect(result.projectCount).to.equal(projects.length)
expect(result.newTagName).to.equal('some-tag-name')
})
it('gets the user records', async function (ctx) {
await ctx.handler.promises.transferAllProjectsToUser({
fromUserId,
toUserId,
ipAddress,
})
expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
fromUserId
)
expect(ctx.UserGetter.promises.getUser).to.have.been.calledWith(
toUserId
)
})
it('gets the list of affected projects', async function (ctx) {
await ctx.handler.promises.transferAllProjectsToUser({
fromUserId,
toUserId,
ipAddress,
})
expect(ctx.ProjectModel.find).to.have.been.calledWith({
owner_ref: fromUserId,
})
})
it('transfers all of the projects', async function (ctx) {
await ctx.handler.promises.transferAllProjectsToUser({
fromUserId,
toUserId,
ipAddress,
})
expect(ctx.ProjectModel.updateOne.callCount).to.equal(3)
expect(ctx.TagsHandler.promises.addProjectsToTag.callCount).to.equal(1)
for (const project of projects) {
expect(ctx.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: project._id },
sinon.match({ $set: { owner_ref: toUserId } })
)
}
expect(
ctx.TagsHandler.promises.addProjectsToTag
).to.have.been.calledWith(
toUserId,
tag._id,
projects.map(p => p._id)
)
})
})
})
})