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