Merge pull request #27476 from overleaf/jpa-transfer-all

[web] add script for transferring all of a users projects in Server Pro

GitOrigin-RevId: 3aad2b624e1da2af83fec0715c2e5e08eff43695
This commit is contained in:
Jakob Ackermann
2025-07-29 15:21:51 +02:00
committed by Copybot
parent af23ac9ad6
commit 3ae228ff28
5 changed files with 448 additions and 148 deletions

View File

@@ -9,9 +9,75 @@ const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
const TpdsProjectFlusher = require('../ThirdPartyDataStore/TpdsProjectFlusher')
const ProjectAuditLogHandler = require('../Project/ProjectAuditLogHandler')
const AnalyticsManager = require('../Analytics/AnalyticsManager')
const OError = require('@overleaf/o-error')
const TagsHandler = require('../Tags/TagsHandler')
const { promiseMapWithLimit } = require('@overleaf/promise-utils')
module.exports = {
promises: { transferOwnership },
promises: {
transferOwnership,
transferAllProjectsToUser,
},
}
const TAG_COLOR_BLUE = '#434AF0'
/**
* @param {string} fromUserId
* @param {string} toUserId
* @param {string} ipAddress
* @return {Promise<{projectCount: number, newTagName: string}>}
*/
async function transferAllProjectsToUser({ fromUserId, toUserId, ipAddress }) {
// - Verify that both users exist
const fromUser = await UserGetter.promises.getUser(fromUserId, {
_id: 1,
email: 1,
})
const toUser = await UserGetter.promises.getUser(toUserId, { _id: 1 })
if (!fromUser) throw new OError('missing source user', { fromUserId })
if (!toUser) throw new OError('missing destination user', { toUserId })
if (fromUser._id.equals(toUser._id))
throw new OError('rejecting transfer between identical users', {
fromUserId,
toUserId,
})
logger.debug(
{ fromUserId, toUserId },
'started bulk transfer of all projects from one user to another'
)
// - Get all owned projects for fromUserId
const projects = await Project.find({ owner_ref: fromUserId }, { _id: 1 })
// - Create new tag on toUserId
const newTag = await TagsHandler.promises.createTag(
toUserId,
`transferred-from-${fromUser.email}`,
TAG_COLOR_BLUE,
{ truncate: true }
)
// - Add tag to projects (can happen before ownership is transferred)
await TagsHandler.promises.addProjectsToTag(
toUserId,
newTag._id,
projects.map(p => p._id)
)
// - Transfer all projects
await promiseMapWithLimit(5, projects, async project => {
await transferOwnership(project._id, toUserId, {
allowTransferToNonCollaborators: true,
skipEmails: true,
ipAddress,
})
})
logger.debug(
{ fromUserId, toUserId },
'finished bulk transfer of all projects from one user to another'
)
return { projectCount: projects.length, newTagName: newTag.name }
}
async function transferOwnership(projectId, newOwnerId, options = {}) {
@@ -74,8 +140,8 @@ async function transferOwnership(projectId, newOwnerId, options = {}) {
await TpdsProjectFlusher.promises.flushProjectToTpds(projectId)
// Send confirmation emails
const previousOwner = await UserGetter.promises.getUser(previousOwnerId)
if (!skipEmails) {
const previousOwner = await UserGetter.promises.getUser(previousOwnerId)
await _sendEmails(project, previousOwner, newOwner)
}
}

View File

@@ -0,0 +1,46 @@
import { ObjectId } from '../../../app/src/infrastructure/mongodb.js'
import minimist from 'minimist'
import OwnershipTransferHandler from '../../../app/src/Features/Collaborators/OwnershipTransferHandler.js'
import UserGetter from '../../../app/src/Features/User/UserGetter.js'
import EmailHelper from '../../../app/src/Features/Helpers/EmailHelper.js'
const args = minimist(process.argv.slice(2), {
string: ['from-user', 'to-user'],
})
/**
* @param {string} flag
* @return {Promise<string>}
*/
async function resolveUser(flag) {
const raw = args[flag]
if (!raw) throw new Error(`missing parameter --${flag}`)
if (ObjectId.isValid(raw)) return raw
const email = EmailHelper.parseEmail(raw)
if (!email) throw new Error(`invalid email --${flag}=${raw}`)
const user = await UserGetter.promises.getUser({ email }, { _id: 1 })
if (!user)
throw new Error(`user with email --${flag}=${email} does not exist`)
return user._id.toString()
}
async function main() {
const fromUserId = await resolveUser('from-user')
const toUserId = await resolveUser('to-user')
await OwnershipTransferHandler.promises.transferAllProjectsToUser({
fromUserId,
toUserId,
ipAddress: '0.0.0.0',
})
}
main()
.then(() => {
console.error('Done.')
process.exit(0)
})
.catch(err => {
console.error('---')
console.error(err)
process.exit(1)
})

View File

@@ -1,4 +1,4 @@
import { execSync } from 'node:child_process'
import { exec } from 'node:child_process'
import fs from 'node:fs'
import Settings from '@overleaf/settings'
import { expect } from 'chai'
@@ -9,31 +9,43 @@ const { promises: User } = UserHelper
/**
* @param {string} cmd
* @return {string}
* @return {Promise<string>}
*/
function run(cmd) {
async function run(cmd) {
// https://nodejs.org/docs/latest-v12.x/api/child_process.html#child_process_child_process_execsync_command_options
// > stderr by default will be output to the parent process' stderr
// > unless stdio is specified.
// https://nodejs.org/docs/latest-v12.x/api/child_process.html#child_process_options_stdio
// Pipe stdin from /dev/null, store stdout, pipe stderr to /dev/null.
return execSync(cmd, {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
LOG_LEVEL: 'warn',
},
}).toString()
return new Promise((resolve, reject) => {
exec(
cmd,
{
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
LOG_LEVEL: 'warn',
},
},
(error, stdout) => {
if (error) {
reject(error)
} else {
resolve(stdout)
}
}
)
})
}
function runAndExpectError(cmd, errorMessages) {
async function runAndExpectError(cmd, errorMessages, exitCode = 1) {
try {
run(cmd)
await run(cmd)
} catch (error) {
expect(error.status).to.equal(1)
expect(error.code).to.equal(exitCode)
if (errorMessages) {
errorMessages.forEach(errorMessage =>
expect(error.stderr.toString()).to.include(errorMessage)
expect(error.message).to.include(errorMessage)
)
}
return
@@ -47,60 +59,48 @@ async function getUser(email) {
describe('ServerCEScripts', function () {
describe('check-mongodb', function () {
it('should exit with code 0 on success', function () {
run('node modules/server-ce-scripts/scripts/check-mongodb.mjs')
it('should exit with code 0 on success', async function () {
await run('node modules/server-ce-scripts/scripts/check-mongodb.mjs')
})
it('should exit with code 1 on error', function () {
try {
run(
'MONGO_SERVER_SELECTION_TIMEOUT=1' +
'MONGO_CONNECTION_STRING=mongodb://127.0.0.1:4242 ' +
'node modules/server-ce-scripts/scripts/check-mongodb.mjs'
)
} catch (e) {
expect(e.status).to.equal(1)
return
}
expect.fail('command should have failed')
it('should exit with code 1 on error', async function () {
await runAndExpectError(
'MONGO_SERVER_SELECTION_TIMEOUT=1 ' +
'MONGO_CONNECTION_STRING=mongodb://127.0.0.1:4242 ' +
'node modules/server-ce-scripts/scripts/check-mongodb.mjs'
)
})
})
describe('check-redis', function () {
it('should exit with code 0 on success', function () {
run('node modules/server-ce-scripts/scripts/check-redis.mjs')
it('should exit with code 0 on success', async function () {
await run('node modules/server-ce-scripts/scripts/check-redis.mjs')
})
it('should exit with code 1 on error', function () {
try {
run(
'REDIS_PORT=42 node modules/server-ce-scripts/scripts/check-redis.mjs'
)
} catch (e) {
expect(e.status).to.equal(1)
return
}
expect.fail('command should have failed')
it('should exit with code 1 on error', async function () {
await runAndExpectError(
'REDIS_PORT=42 node modules/server-ce-scripts/scripts/check-redis.mjs'
)
})
})
describe('create-user', function () {
it('should exit with code 0 on success', function () {
const out = run(
it('should exit with code 0 on success', async function () {
const out = await run(
'node modules/server-ce-scripts/scripts/create-user.js --email=foo@bar.com'
)
expect(out).to.include('/user/activate?token=')
})
it('should create a regular user by default', async function () {
run(
await run(
'node modules/server-ce-scripts/scripts/create-user.js --email=foo@bar.com'
)
expect(await getUser('foo@bar.com')).to.deep.equal({ isAdmin: false })
})
it('should also work with mjs version', async function () {
const out = run(
const out = await run(
'node modules/server-ce-scripts/scripts/create-user.mjs --email=foo@bar.com'
)
expect(out).to.include('/user/activate?token=')
@@ -108,20 +108,16 @@ describe('ServerCEScripts', function () {
})
it('should create an admin user with --admin flag', async function () {
run(
await run(
'node modules/server-ce-scripts/scripts/create-user.js --admin --email=foo@bar.com'
)
expect(await getUser('foo@bar.com')).to.deep.equal({ isAdmin: true })
})
it('should exit with code 1 on missing email', function () {
try {
run('node modules/server-ce-scripts/scripts/create-user.js')
} catch (e) {
expect(e.status).to.equal(1)
return
}
expect.fail('command should have failed')
it('should exit with code 1 on missing email', async function () {
await runAndExpectError(
'node modules/server-ce-scripts/scripts/create-user.js'
)
})
})
@@ -132,18 +128,18 @@ describe('ServerCEScripts', function () {
await user.login()
})
it('should log missing user', function () {
it('should log missing user', async function () {
const email = 'does-not-exist@example.com'
const out = run(
const out = await run(
'node modules/server-ce-scripts/scripts/delete-user.mjs --email=' +
email
)
expect(out).to.include('not in database, potentially already deleted')
})
it('should exit with code 0 on success', function () {
it('should exit with code 0 on success', async function () {
const email = user.email
run(
await run(
'node modules/server-ce-scripts/scripts/delete-user.mjs --email=' +
email
)
@@ -151,7 +147,7 @@ describe('ServerCEScripts', function () {
it('should have deleted the user on success', async function () {
const email = user.email
run(
await run(
'node modules/server-ce-scripts/scripts/delete-user.mjs --email=' +
email
)
@@ -164,14 +160,10 @@ describe('ServerCEScripts', function () {
expect(softDeletedEntry.deleterData.deleterIpAddress).to.equal('0.0.0.0')
})
it('should exit with code 1 on missing email', function () {
try {
run('node modules/server-ce-scripts/scripts/delete-user.mjs')
} catch (e) {
expect(e.status).to.equal(1)
return
}
expect.fail('command should have failed')
it('should exit with code 1 on missing email', async function () {
await runAndExpectError(
'node modules/server-ce-scripts/scripts/delete-user.mjs'
)
})
})
@@ -221,7 +213,7 @@ describe('ServerCEScripts', function () {
})
it('should do a dry run by default', async function () {
run(
await run(
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs ${csv}`
)
for (const user of usersToMigrate) {
@@ -234,14 +226,14 @@ describe('ServerCEScripts', function () {
}
})
it('should exit with code 0 when successfully migrating user emails', function () {
run(
it('should exit with code 0 when successfully migrating user emails', async function () {
await run(
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csv}`
)
})
it('should migrate the user emails with the --commit option', async function () {
run(
await run(
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csv}`
)
for (const user of usersToMigrate) {
@@ -255,7 +247,7 @@ describe('ServerCEScripts', function () {
})
it('should leave other user emails unchanged', async function () {
run(
await run(
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csv}`
)
for (const user of otherUsers) {
@@ -264,39 +256,27 @@ describe('ServerCEScripts', function () {
}
})
it('should exit with code 1 when there are failures migrating user emails', function () {
try {
run(
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csvfail}`
)
} catch (e) {
expect(e.status).to.equal(1)
return
}
expect.fail('command should have failed')
it('should exit with code 1 when there are failures migrating user emails', async function () {
await runAndExpectError(
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csvfail}`
)
})
it('should migrate other users when there are failures with the --continue option', async function () {
try {
run(
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csvfail}`
)
} catch (e) {
expect(e.status).to.equal(1)
run(
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit --continue ${csvfail}`
)
for (const user of usersToMigrate) {
const dbEntry = await user.get()
expect(dbEntry.email).to.equal(`new-${user.email}`)
expect(dbEntry.emails).to.have.lengthOf(1)
expect(dbEntry.emails[0].email).to.equal(`new-${user.email}`)
expect(dbEntry.emails[0].reversedHostname).to.equal('moc.elpmaxe')
expect(dbEntry.emails[0].createdAt).to.eql(user.emails[0].createdAt)
}
return
await runAndExpectError(
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit ${csvfail}`
)
await run(
`node modules/server-ce-scripts/scripts/migrate-user-emails.mjs --commit --continue ${csvfail}`
)
for (const user of usersToMigrate) {
const dbEntry = await user.get()
expect(dbEntry.email).to.equal(`new-${user.email}`)
expect(dbEntry.emails).to.have.lengthOf(1)
expect(dbEntry.emails[0].email).to.equal(`new-${user.email}`)
expect(dbEntry.emails[0].reversedHostname).to.equal('moc.elpmaxe')
expect(dbEntry.emails[0].createdAt).to.eql(user.emails[0].createdAt)
}
expect.fail('command should have failed')
})
})
@@ -323,7 +303,7 @@ describe('ServerCEScripts', function () {
expect(await getTagNames()).to.deep.equal([oldName])
run(
await run(
`node modules/server-ce-scripts/scripts/rename-tag.mjs --user-id=${user.id} --old-name=${oldName} --new-name=${newName}`
)
@@ -354,9 +334,9 @@ describe('ServerCEScripts', function () {
describe('happy path', function () {
let newUserATimeout
beforeEach('run script on user a', function () {
beforeEach('run script on user a', async function () {
newUserATimeout = userATimeout - 1
run(
await run(
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userA.id} --compile-timeout=${newUserATimeout}`
)
})
@@ -374,27 +354,21 @@ describe('ServerCEScripts', function () {
describe('bad options', function () {
it('should reject zero timeout', async function () {
try {
run(
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userA.id} --compile-timeout=0`
)
expect.fail('should error out')
} catch (err) {
expect(err.stderr.toString()).to.include('positive number of seconds')
}
await runAndExpectError(
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userA.id} --compile-timeout=0`,
['positive number of seconds'],
101
)
expect(await getCompileTimeout(userA)).to.equal(userATimeout)
expect(await getCompileTimeout(userB)).to.equal(userBTimeout)
})
it('should reject a 20min timeout', async function () {
try {
run(
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userA.id} --compile-timeout=1200`
)
expect.fail('should error out')
} catch (err) {
expect(err.stderr.toString()).to.include('below 10 minutes')
}
await runAndExpectError(
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userA.id} --compile-timeout=1200`,
['below 10 minutes'],
101
)
expect(await getCompileTimeout(userA)).to.equal(userATimeout)
expect(await getCompileTimeout(userB)).to.equal(userBTimeout)
})
@@ -432,13 +406,13 @@ describe('ServerCEScripts', function () {
})
beforeEach('downgrade userCustomTimeoutLower', async function () {
run(
await run(
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userCustomTimeoutLower.id} --compile-timeout=42`
)
})
beforeEach('upgrade userCustomTimeoutHigher', async function () {
run(
await run(
`node modules/server-ce-scripts/scripts/change-compile-timeout.mjs --user-id=${userCustomTimeoutHigher.id} --compile-timeout=360`
)
})
@@ -472,8 +446,8 @@ describe('ServerCEScripts', function () {
describe('dry-run', function () {
let output
beforeEach('run script', function () {
output = run(
beforeEach('run script', async function () {
output = await run(
`node modules/server-ce-scripts/scripts/upgrade-user-features.mjs`
)
})
@@ -501,8 +475,8 @@ describe('ServerCEScripts', function () {
describe('live run', function () {
let output
beforeEach('run script', function () {
output = run(
beforeEach('run script', async function () {
output = await run(
`node modules/server-ce-scripts/scripts/upgrade-user-features.mjs --dry-run=false`
)
})
@@ -572,8 +546,10 @@ describe('ServerCEScripts', function () {
})
describe('when running in CE', function () {
beforeEach('run script', function () {
output = run(buildCheckTexLiveCmd({ OVERLEAF_IS_SERVER_PRO: false }))
beforeEach('run script', async function () {
output = await run(
buildCheckTexLiveCmd({ OVERLEAF_IS_SERVER_PRO: false })
)
})
it('should skip checks', function () {
@@ -584,8 +560,8 @@ describe('ServerCEScripts', function () {
})
describe('when sandboxed compiles are disabled', function () {
beforeEach('run script', function () {
output = run(buildCheckTexLiveCmd({ SANDBOXED_COMPILES: false }))
beforeEach('run script', async function () {
output = await run(buildCheckTexLiveCmd({ SANDBOXED_COMPILES: false }))
})
it('should skip checks', function () {
@@ -596,8 +572,8 @@ describe('ServerCEScripts', function () {
})
describe('when texlive configuration is incorrect', function () {
it('should fail when TEX_LIVE_DOCKER_IMAGE is not set', function () {
runAndExpectError(
it('should fail when TEX_LIVE_DOCKER_IMAGE is not set', async function () {
await runAndExpectError(
buildCheckTexLiveCmd({
SANDBOXED_COMPILES: true,
ALL_TEX_LIVE_DOCKER_IMAGES: TEST_TL_IMAGE_LIST,
@@ -608,8 +584,8 @@ describe('ServerCEScripts', function () {
)
})
it('should fail when ALL_TEX_LIVE_DOCKER_IMAGES is not set', function () {
runAndExpectError(
it('should fail when ALL_TEX_LIVE_DOCKER_IMAGES is not set', async function () {
await runAndExpectError(
buildCheckTexLiveCmd({
SANDBOXED_COMPILES: true,
TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE,
@@ -620,8 +596,8 @@ describe('ServerCEScripts', function () {
)
})
it('should fail when TEX_LIVE_DOCKER_IMAGE is not defined in ALL_TEX_LIVE_DOCKER_IMAGES', function () {
runAndExpectError(
it('should fail when TEX_LIVE_DOCKER_IMAGE is not defined in ALL_TEX_LIVE_DOCKER_IMAGES', async function () {
await runAndExpectError(
buildCheckTexLiveCmd({
SANDBOXED_COMPILES: true,
TEX_LIVE_DOCKER_IMAGE: 'tl-1',
@@ -639,8 +615,8 @@ describe('ServerCEScripts', function () {
await db.projects.updateMany({}, { $unset: { imageName: 1 } })
})
it('should fail and suggest running backfilling scripts', function () {
runAndExpectError(
it('should fail and suggest running backfilling scripts', async function () {
await runAndExpectError(
buildCheckTexLiveCmd({
SANDBOXED_COMPILES: true,
TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE,
@@ -659,8 +635,8 @@ describe('ServerCEScripts', function () {
await db.projects.updateMany({}, { $set: { imageName: null } })
})
it('should fail and suggest running backfilling scripts', function () {
runAndExpectError(
it('should fail and suggest running backfilling scripts', async function () {
await runAndExpectError(
buildCheckTexLiveCmd({
SANDBOXED_COMPILES: true,
TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE,
@@ -677,7 +653,7 @@ describe('ServerCEScripts', function () {
describe('when TexLive ALL_TEX_LIVE_DOCKER_IMAGES are upgraded and used images are no longer available', function () {
it('should suggest running a fixing script', async function () {
await db.projects.updateMany({}, { $set: { imageName: TEST_TL_IMAGE } })
runAndExpectError(
await runAndExpectError(
buildCheckTexLiveCmd({
SANDBOXED_COMPILES: true,
TEX_LIVE_DOCKER_IMAGE: 'tl-1',
@@ -695,8 +671,8 @@ describe('ServerCEScripts', function () {
await db.projects.updateMany({}, { $set: { imageName: TEST_TL_IMAGE } })
})
it('should succeed when there are no changes to the TexLive images', function () {
const output = run(
it('should succeed when there are no changes to the TexLive images', async function () {
const output = await run(
buildCheckTexLiveCmd({
SANDBOXED_COMPILES: true,
TEX_LIVE_DOCKER_IMAGE: TEST_TL_IMAGE,
@@ -706,8 +682,8 @@ describe('ServerCEScripts', function () {
expect(output).to.include('Done.')
})
it('should succeed when there are valid changes to the TexLive images', function () {
const output = run(
it('should succeed when there are valid changes to the TexLive images', async function () {
const output = await run(
buildCheckTexLiveCmd({
SANDBOXED_COMPILES: true,
TEX_LIVE_DOCKER_IMAGE: 'new-image',
@@ -718,4 +694,51 @@ describe('ServerCEScripts', function () {
})
})
})
describe('transfer-all-projects-to-user', function () {
let fromUser, projects
beforeEach(async function () {
fromUser = new User()
await fromUser.login()
projects = await Promise.all([
fromUser.createProject('a'),
fromUser.createProject('b'),
fromUser.createProject('c'),
])
})
let toUser
beforeEach(async function () {
toUser = new User()
await toUser.login()
})
it('should log missing user', async function () {
const email = 'does-not-exist@example.com'
await runAndExpectError(
`node modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs --from-user=${email}`,
[`user with email --from-user=${email} does not exist`]
)
})
it('should transfer projects by email', async function () {
await run(
`node modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs --from-user=${fromUser.email} --to-user=${toUser.email}`
)
for (const projectId of projects) {
expect(
(await toUser.getProject(projectId)).owner_ref.toString()
).to.equal(toUser._id.toString())
}
})
it('should transfer projects by id', async function () {
await run(
`node modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs --from-user=${fromUser._id} --to-user=${toUser._id}`
)
for (const projectId of projects) {
expect(
(await toUser.getProject(projectId)).owner_ref.toString()
).to.equal(toUser._id.toString())
}
})
})
})

View File

@@ -140,7 +140,7 @@ class AbstractMockApi {
console.log('Starting mock on port', this.constructor.name, this.port)
}
this.server = this.app
.listen(this.port, err => {
.listen(this.port, '127.0.0.1', err => {
if (err) {
return reject(err)
}

View File

@@ -37,13 +37,14 @@ describe('OwnershipTransferHandler', function () {
},
}
this.ProjectModel = {
find: sinon.stub().resolves([]),
updateOne: sinon.stub().returns({
exec: sinon.stub().resolves(),
}),
}
this.UserGetter = {
promises: {
getUser: sinon.stub().resolves(this.user),
getUser: sinon.stub().resolves(),
},
}
this.TpdsUpdateSender = {
@@ -72,12 +73,19 @@ describe('OwnershipTransferHandler', function () {
addEntry: sinon.stub().resolves(),
},
}
this.TagsHandler = {
promises: {
createTag: sinon.stub().resolves(),
addProjectsToTag: sinon.stub().resolves(),
},
}
this.handler = SandboxedModule.require(MODULE_PATH, {
requires: {
'../Project/ProjectGetter': this.ProjectGetter,
'../../models/Project': {
Project: this.ProjectModel,
},
'../Tags/TagsHandler': this.TagsHandler,
'../User/UserGetter': this.UserGetter,
'../ThirdPartyDataStore/TpdsProjectFlusher': this.TpdsProjectFlusher,
'../Project/ProjectAuditLogHandler': this.ProjectAuditLogHandler,
@@ -326,4 +334,161 @@ describe('OwnershipTransferHandler', function () {
).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 () {
this.UserGetter.promises.getUser.withArgs(fromUserId).resolves(null)
this.UserGetter.promises.getUser
.withArgs(toUserId)
.resolves({ _id: new ObjectId(toUserId) })
await expect(
this.handler.promises.transferAllProjectsToUser({
toUserId,
fromUserId,
ipAddress,
})
).to.be.rejectedWith(/missing source user/)
this.UserGetter.promises.getUser
.withArgs(fromUserId)
.resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
this.UserGetter.promises.getUser.withArgs(toUserId).resolves(null)
await expect(
this.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 () {
this.UserGetter.promises.getUser
.withArgs(fromUserId)
.resolves({ _id: new ObjectId(fromUserId), email: fromUserEmail })
await expect(
this.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 () {
tag = {
_id: new ObjectId(),
name: 'some-tag-name',
}
projects = [
{ _id: 'project-1' },
{ _id: 'project-2' },
{ _id: 'project-3' },
]
this.UserGetter.promises.getUser.withArgs(fromUserId).resolves({
_id: new ObjectId(fromUserId),
email: fromUserEmail,
})
this.UserGetter.promises.getUser.withArgs(toUserId).resolves({
_id: new ObjectId(toUserId),
})
this.ProjectModel.find.resolves(projects)
this.TagsHandler.promises.createTag.resolves({
_id: tag._id,
name: 'some-tag-name',
})
this.TagsHandler.promises.addProjectsToTag.resolves()
})
it('creates a tag', async function () {
await this.handler.promises.transferAllProjectsToUser({
fromUserId,
toUserId,
ipAddress,
})
expect(this.TagsHandler.promises.createTag).to.have.been.calledWith(
toUserId,
`transferred-from-${fromUserEmail}`,
'#434AF0',
{ truncate: true }
)
})
it('returns a projectCount, and tag name', async function () {
const result = await this.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 () {
await this.handler.promises.transferAllProjectsToUser({
fromUserId,
toUserId,
ipAddress,
})
expect(this.UserGetter.promises.getUser).to.have.been.calledWith(
fromUserId
)
expect(this.UserGetter.promises.getUser).to.have.been.calledWith(
toUserId
)
})
it('gets the list of affected projects', async function () {
await this.handler.promises.transferAllProjectsToUser({
fromUserId,
toUserId,
ipAddress,
})
expect(this.ProjectModel.find).to.have.been.calledWith({
owner_ref: fromUserId,
})
})
it('transfers all of the projects', async function () {
await this.handler.promises.transferAllProjectsToUser({
fromUserId,
toUserId,
ipAddress,
})
expect(this.ProjectModel.updateOne.callCount).to.equal(3)
expect(this.TagsHandler.promises.addProjectsToTag.callCount).to.equal(1)
for (const project of projects) {
expect(this.ProjectModel.updateOne).to.have.been.calledWith(
{ _id: project._id },
sinon.match({ $set: { owner_ref: toUserId } })
)
}
expect(
this.TagsHandler.promises.addProjectsToTag
).to.have.been.calledWith(
toUserId,
tag._id,
projects.map(p => p._id)
)
})
})
})
})