From 3ae228ff28a156ddaf99cc6ebc6f7ef0b225b8b4 Mon Sep 17 00:00:00 2001 From: Jakob Ackermann Date: Tue, 29 Jul 2025 15:21:51 +0200 Subject: [PATCH] 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 --- .../Collaborators/OwnershipTransferHandler.js | 70 +++- .../scripts/transfer-all-projects-to-user.mjs | 46 +++ .../acceptance/src/ServerCEScriptsTests.mjs | 311 ++++++++++-------- .../acceptance/src/mocks/AbstractMockApi.mjs | 2 +- .../OwnershipTransferHandlerTests.js | 167 +++++++++- 5 files changed, 448 insertions(+), 148 deletions(-) create mode 100644 services/web/modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs diff --git a/services/web/app/src/Features/Collaborators/OwnershipTransferHandler.js b/services/web/app/src/Features/Collaborators/OwnershipTransferHandler.js index e22818ebb8..81ec5ccb0a 100644 --- a/services/web/app/src/Features/Collaborators/OwnershipTransferHandler.js +++ b/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) } } diff --git a/services/web/modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs b/services/web/modules/server-ce-scripts/scripts/transfer-all-projects-to-user.mjs new file mode 100644 index 0000000000..8c59513344 --- /dev/null +++ b/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} + */ +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) + }) diff --git a/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs b/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs index f8d458ff6b..2e38148fa5 100644 --- a/services/web/modules/server-ce-scripts/test/acceptance/src/ServerCEScriptsTests.mjs +++ b/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} */ -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()) + } + }) + }) }) diff --git a/services/web/test/acceptance/src/mocks/AbstractMockApi.mjs b/services/web/test/acceptance/src/mocks/AbstractMockApi.mjs index 37f5cd3b24..fe9317ad5b 100644 --- a/services/web/test/acceptance/src/mocks/AbstractMockApi.mjs +++ b/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) } diff --git a/services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js b/services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js index 4994a3f129..1e0b38ddc9 100644 --- a/services/web/test/unit/src/Collaborators/OwnershipTransferHandlerTests.js +++ b/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) + ) + }) + }) + }) })