Procházet zdrojové kódy

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
Jakob Ackermann před 1 rokem
rodič
revize
6a92acf542

+ 68 - 2
services/web/app/src/Features/Collaborators/OwnershipTransferHandler.js

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

+ 46 - 0
services/web/modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs

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

+ 167 - 144
services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs

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

+ 1 - 1
services/web/test/acceptance/src/mocks/AbstractMockApi.mjs

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

+ 166 - 1
services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js

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