Merge pull request #2164 from overleaf/em-ownership-transfer

Project ownership transfer backend endpoint

GitOrigin-RevId: b7d267f2c105e8f51d5013289ac4afeb077c1e21
This commit is contained in:
Eric Mc Sween 2019-09-30 10:46:15 -04:00 committed by sharelatex
parent acd926e2e0
commit 3ec74ac6f2
14 changed files with 867 additions and 777 deletions

View file

@ -111,6 +111,33 @@ class SubscriptionAdminDeletionError extends OError {
}
}
class ProjectNotFoundError extends OError {
constructor(options) {
super({
message: 'project not found',
...options
})
}
}
class UserNotFoundError extends OError {
constructor(options) {
super({
message: 'user not found',
...options
})
}
}
class UserNotCollaboratorError extends OError {
constructor(options) {
super({
message: 'user not a collaborator',
...options
})
}
}
module.exports = {
OError,
BackwardCompatibleError,
@ -134,5 +161,8 @@ module.exports = {
SLInV2Error,
ThirdPartyIdentityExistsError,
ThirdPartyUserNotFoundError,
SubscriptionAdminDeletionError
SubscriptionAdminDeletionError,
ProjectNotFoundError,
UserNotFoundError,
UserNotCollaboratorError
}

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ const ProjectTokenGenerator = require('./ProjectTokenGenerator')
const ProjectEntityHandler = require('./ProjectEntityHandler')
const ProjectHelper = require('./ProjectHelper')
const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
const settings = require('settings-sharelatex')
const { callbackify } = require('util')
@ -100,35 +101,40 @@ async function setProjectDescription(projectId, description) {
}
}
async function transferOwnership(projectId, userId, suffix = '') {
async function transferOwnership(projectId, toUserId, options = {}) {
const project = await ProjectGetter.promises.getProject(projectId, {
owner_ref: true,
name: true
owner_ref: 1,
collaberator_refs: 1
})
if (project == null) {
throw new Errors.NotFoundError('project not found')
throw new Errors.ProjectNotFoundError({ info: { projectId: projectId } })
}
if (project.owner_ref === userId) {
const fromUserId = project.owner_ref
if (fromUserId.equals(toUserId)) {
return
}
const user = await UserGetter.promises.getUser(userId)
if (user == null) {
throw new Errors.NotFoundError('user not found')
const toUser = await UserGetter.promises.getUser(toUserId)
if (toUser == null) {
throw new Errors.UserNotFoundError({ info: { userId: toUserId } })
}
// we make sure the user to which the project is transferred is not a collaborator for the project,
// this prevents any conflict during unique name generation
await CollaboratorsHandler.promises.removeUserFromProject(projectId, userId)
const name = await generateUniqueName(userId, project.name + suffix)
const collaboratorIds = project.collaberator_refs || []
if (
!options.allowTransferToNonCollaborators &&
!collaboratorIds.some(collaboratorId => collaboratorId.equals(toUser._id))
) {
throw new Errors.UserNotCollaboratorError({ info: { userId: toUserId } })
}
await CollaboratorsHandler.promises.removeUserFromProject(projectId, toUserId)
await Project.update(
{ _id: projectId },
{
$set: {
owner_ref: userId,
name
}
}
{ $set: { owner_ref: toUserId } }
).exec()
await CollaboratorsHandler.promises.addUserIdToProject(
projectId,
toUserId,
fromUserId,
PrivilegeLevels.READ_AND_WRITE
)
await ProjectEntityHandler.promises.flushProjectToThirdPartyDataStore(
projectId
)

View file

@ -485,7 +485,11 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
AuthorizationMiddleware.ensureUserCanAdminProject,
ProjectController.renameProject
)
webRouter.post(
'/project/:Project_id/transfer-ownership',
AuthorizationMiddleware.ensureUserCanAdminProject,
ProjectController.transferOwnership
)
webRouter.get(
'/project/:Project_id/updates',
AuthorizationMiddleware.ensureUserCanReadProject,
@ -598,7 +602,6 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
AuthenticationController.requireLogin(),
MetaController.broadcastMetadataForDoc
)
privateApiRouter.post(
'/internal/expire-deleted-projects-after-duration',
AuthenticationController.httpAuth,

View file

@ -12,7 +12,7 @@
"public": "./public"
},
"scripts": {
"test:acceptance:run_dir": "mocha --recursive --reporter spec --timeout 25000 --exit --grep=$MOCHA_GREP $@",
"test:acceptance:run_dir": "mocha --recursive --reporter spec --require test/acceptance/bootstrap.js --timeout 25000 --exit --grep=$MOCHA_GREP $@",
"test:unit": "bin/unit_test --grep=$MOCHA_GREP $@",
"test:unit:ci": "bin/unit_test --timeout 10000",
"test:unit:app": "bin/unit_test_app $@",

View file

@ -0,0 +1,2 @@
const chai = require('chai')
chai.use(require('chai-as-promised'))

View file

@ -278,7 +278,7 @@ describe('Authorization', function() {
if (err != null) {
return cb(err)
}
this.site_admin.ensure_admin(cb)
return this.site_admin.ensureAdmin(cb)
})
}
],

View file

@ -0,0 +1,87 @@
const { expect } = require('chai')
const User = require('./helpers/User').promises
describe('Project ownership transfer', function() {
beforeEach(async function() {
this.ownerSession = new User()
this.collaboratorSession = new User()
this.strangerSession = new User()
this.adminSession = new User()
await this.adminSession.ensureUserExists()
await this.adminSession.ensureAdmin()
await this.ownerSession.login()
await this.collaboratorSession.login()
await this.strangerSession.login()
await this.adminSession.login()
this.owner = await this.ownerSession.get()
this.collaborator = await this.collaboratorSession.get()
this.stranger = await this.strangerSession.get()
this.admin = await this.adminSession.get()
this.projectId = await this.ownerSession.createProject('Test project')
await this.ownerSession.addUserToProject(
this.projectId,
this.collaborator,
'readAndWrite'
)
})
describe('happy path', function() {
beforeEach(async function() {
await this.ownerSession.transferProjectOwnership(
this.projectId,
this.collaborator._id
)
})
it('changes the project owner', async function() {
const project = await this.collaboratorSession.getProject(this.projectId)
expect(project.owner_ref.toString()).to.equal(
this.collaborator._id.toString()
)
})
it('adds the previous owner as a read/write collaborator', async function() {
const project = await this.collaboratorSession.getProject(this.projectId)
expect(project.collaberator_refs.map(x => x.toString())).to.have.members([
this.owner._id.toString()
])
})
it('lets the new owner open the project', async function() {
await this.collaboratorSession.openProject(this.projectId)
})
it('lets the previous owner open the project', async function() {
await this.ownerSession.openProject(this.projectId)
})
})
describe('validation', function() {
it('lets only the project owner transfer ownership', async function() {
await expect(
this.collaboratorSession.transferProjectOwnership(
this.projectId,
this.collaborator._id
)
).to.be.rejectedWith('Unexpected status code: 403')
})
it('prevents transfers to a non-collaborator', async function() {
await expect(
this.ownerSession.transferProjectOwnership(
this.projectId,
this.stranger._id
)
).to.be.rejectedWith('Unexpected status code: 403')
})
it('allows an admin to transfer to any project to a non-collaborator', async function() {
await expect(
this.adminSession.transferProjectOwnership(
this.projectId,
this.stranger._id
)
).to.be.fulfilled
})
})
})

View file

@ -143,8 +143,8 @@ describe('Registration', function() {
return (this.password = 'password11')
})
afterEach(function() {
return this.user.full_delete_user(this.email)
afterEach(function(done) {
return this.user.fullDeleteUser(this.email, done)
})
it('should register with the csrf token', function(done) {

View file

@ -128,7 +128,7 @@ describe('UserMembershipAuthorization', function() {
const url = `/metrics/institutions/${this.institution.v1Id}`
async.series(
[
this.user.ensure_admin.bind(this.user),
this.user.ensureAdmin.bind(this.user),
this.user.login.bind(this.user),
expectAccess(this.user, url, 200)
],
@ -317,7 +317,7 @@ describe('UserMembershipAuthorization', function() {
async.series(
[
expectAccess(this.user, url, 403),
this.user.ensure_admin.bind(this.user),
this.user.ensureAdmin.bind(this.user),
this.user.login.bind(this.user),
expectAccess(this.user, url, 200)
],
@ -329,7 +329,7 @@ describe('UserMembershipAuthorization', function() {
const url = '/metrics/templates/789'
async.series(
[
this.user.ensure_admin.bind(this.user),
this.user.ensureAdmin.bind(this.user),
this.user.login.bind(this.user),
expectAccess(this.user, url, 404)
],
@ -345,7 +345,7 @@ describe('UserMembershipAuthorization', function() {
[
this.user.login.bind(this.user),
expectAccess(this.user, url, 403),
this.user.ensure_admin.bind(this.user),
this.user.ensureAdmin.bind(this.user),
this.user.login.bind(this.user),
expectAccess(this.user, url, 200)
],
@ -382,7 +382,7 @@ describe('UserMembershipAuthorization', function() {
it('should allow admin users', function(done) {
async.series(
[
this.user.ensure_admin.bind(this.user),
this.user.ensureAdmin.bind(this.user),
this.user.login.bind(this.user),
expectAccess(this.user, '/metrics/admin', 200)
],

View file

@ -28,7 +28,7 @@ describe('ThirdPartyIdentityManager', function() {
})
afterEach(function(done) {
return this.user.full_delete_user(this.user.email, done)
return this.user.fullDeleteUser(this.user.email, done)
})
describe('login', function() {
@ -175,7 +175,7 @@ describe('ThirdPartyIdentityManager', function() {
// return done()
// }
// )
// this.user2.full_delete_user(this.user2.email, done)
// this.user2.fullDeleteUser(this.user2.email, done)
// })
// })
})

View file

@ -1,22 +1,4 @@
/* eslint-disable
camelcase,
handle-callback-err,
max-len,
no-return-assign,
no-undef,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* DS103: Rewrite code to no longer use __guard__
* DS207: Consider shorter variations of null checks
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const request = require('./request')
const _ = require('underscore')
const settings = require('settings-sharelatex')
const { db, ObjectId } = require('../../../../app/src/infrastructure/mongojs')
const UserModel = require('../../../../app/src/models/User').User
@ -53,42 +35,30 @@ class User {
this.id = user._id.toString()
this._id = user._id.toString()
this.first_name = user.first_name
return (this.referal_id = user.referal_id)
this.referal_id = user.referal_id
}
get(callback) {
if (callback == null) {
callback = function(error, user) {}
}
return db.users.findOne({ _id: ObjectId(this._id) }, callback)
db.users.findOne({ _id: ObjectId(this._id) }, callback)
}
mongoUpdate(updateOp, callback) {
if (callback == null) {
callback = function(error) {}
}
return db.users.update({ _id: ObjectId(this._id) }, updateOp, callback)
db.users.update({ _id: ObjectId(this._id) }, updateOp, callback)
}
register(callback) {
if (callback == null) {
callback = function(error, user) {}
}
return this.registerWithQuery('', callback)
this.registerWithQuery('', callback)
}
registerWithQuery(query, callback) {
if (callback == null) {
callback = function(error, user) {}
}
if (this._id != null) {
return callback(new Error('User already registered'))
}
return this.getCsrfToken(error => {
this.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
return this.request.post(
this.request.post(
{
url: `/register${query}`,
json: { email: this.email, password: this.password }
@ -97,12 +67,12 @@ class User {
if (error != null) {
return callback(error)
}
return db.users.findOne({ email: this.email }, (error, user) => {
db.users.findOne({ email: this.email }, (error, user) => {
if (error != null) {
return callback(error)
}
this.setExtraAttributes(user)
return callback(null, user)
callback(null, user)
})
}
)
@ -110,25 +80,19 @@ class User {
}
login(callback) {
if (callback == null) {
callback = function(error) {}
}
return this.loginWith(this.email, callback)
this.loginWith(this.email, callback)
}
loginWith(email, callback) {
if (callback == null) {
callback = function(error) {}
}
return this.ensureUserExists(error => {
this.ensureUserExists(error => {
if (error != null) {
return callback(error)
}
return this.getCsrfToken(error => {
this.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
return this.request.post(
this.request.post(
{
url: settings.enableLegacyLogin ? '/login/legacy' : '/login',
json: { email, password: this.password }
@ -140,23 +104,20 @@ class User {
}
ensureUserExists(callback) {
if (callback == null) {
callback = function(error) {}
}
const filter = { email: this.email }
const options = { upsert: true, new: true, setDefaultsOnInsert: true }
return UserModel.findOneAndUpdate(filter, {}, options, (error, user) => {
UserModel.findOneAndUpdate(filter, {}, options, (error, user) => {
if (error != null) {
return callback(error)
}
return AuthenticationManager.setUserPasswordInV2(
AuthenticationManager.setUserPasswordInV2(
user._id,
this.password,
error => {
if (error != null) {
return callback(error)
}
return UserUpdater.updateUser(
UserUpdater.updateUser(
user._id,
{ $set: { emails: this.emails } },
error => {
@ -164,7 +125,7 @@ class User {
return callback(error)
}
this.setExtraAttributes(user)
return callback(null, this.password)
callback(null, this.password)
}
)
}
@ -173,37 +134,24 @@ class User {
}
setFeatures(features, callback) {
if (callback == null) {
callback = function(error) {}
}
const update = {}
for (let key in features) {
const value = features[key]
update[`features.${key}`] = value
}
return UserModel.update({ _id: this.id }, update, callback)
UserModel.update({ _id: this.id }, update, callback)
}
setOverleafId(overleaf_id, callback) {
if (callback == null) {
callback = function(error) {}
}
return UserModel.update(
{ _id: this.id },
{ 'overleaf.id': overleaf_id },
callback
)
setOverleafId(overleafId, callback) {
UserModel.update({ _id: this.id }, { 'overleaf.id': overleafId }, callback)
}
logout(callback) {
if (callback == null) {
callback = function(error) {}
}
return this.getCsrfToken(error => {
this.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
return this.request.post(
this.request.post(
{
url: '/logout',
json: {
@ -215,17 +163,16 @@ class User {
if (error != null) {
return callback(error)
}
return db.users.findOne({ email: this.email }, (error, user) => {
db.users.findOne({ email: this.email }, (error, user) => {
if (error != null) {
return callback(error)
}
this.id = __guard__(user != null ? user._id : undefined, x =>
x.toString()
)
this._id = __guard__(user != null ? user._id : undefined, x1 =>
x1.toString()
)
return callback()
if (user == null) {
return callback()
}
this.id = user._id.toString()
this._id = user._id.toString()
callback()
})
}
)
@ -233,31 +180,22 @@ class User {
}
addEmail(email, callback) {
if (callback == null) {
callback = function(error) {}
}
this.emails.push({ email, createdAt: new Date() })
return UserUpdater.addEmailAddress(this.id, email, callback)
UserUpdater.addEmailAddress(this.id, email, callback)
}
confirmEmail(email, callback) {
if (callback == null) {
callback = function(error) {}
}
for (let idx = 0; idx < this.emails.length; idx++) {
const emailData = this.emails[idx]
if (emailData.email === email) {
this.emails[idx].confirmedAt = new Date()
}
}
return UserUpdater.confirmEmail(this.id, email, callback)
UserUpdater.confirmEmail(this.id, email, callback)
}
ensure_admin(callback) {
if (callback == null) {
callback = function(error) {}
}
return db.users.update(
ensureAdmin(callback) {
db.users.update(
{ _id: ObjectId(this.id) },
{ $set: { isAdmin: true } },
callback
@ -267,13 +205,10 @@ class User {
ensureStaffAccess(flag, callback) {
const update = { $set: {} }
update.$set[`staffAccess.${flag}`] = true
return db.users.update({ _id: ObjectId(this.id) }, update, callback)
db.users.update({ _id: ObjectId(this.id) }, update, callback)
}
upgradeFeatures(callback) {
if (callback == null) {
callback = function(error) {}
}
const features = {
collaborators: -1, // Infinite
versioning: true,
@ -285,7 +220,7 @@ class User {
trackChanges: true,
trackChangesVisible: true
}
return db.users.update(
db.users.update(
{ _id: ObjectId(this.id) },
{ $set: { features } },
callback
@ -293,9 +228,6 @@ class User {
}
downgradeFeatures(callback) {
if (callback == null) {
callback = function(error) {}
}
const features = {
collaborators: 1,
versioning: false,
@ -307,7 +239,7 @@ class User {
trackChanges: false,
trackChangesVisible: false
}
return db.users.update(
db.users.update(
{ _id: ObjectId(this.id) },
{ $set: { features } },
callback
@ -315,11 +247,8 @@ class User {
}
defaultFeatures(callback) {
if (callback == null) {
callback = function(error) {}
}
const features = settings.defaultFeatures
return db.users.update(
db.users.update(
{ _id: ObjectId(this.id) },
{ $set: { features } },
callback
@ -327,31 +256,30 @@ class User {
}
getFeatures(callback) {
const features = settings.defaultFeatures
return db.users.findOne(
db.users.findOne(
{ _id: ObjectId(this.id) },
{ features: 1 },
(error, user) => callback(error, user && user.features)
)
}
full_delete_user(email, callback) {
if (callback == null) {
callback = function(error) {}
}
return db.users.findOne({ email }, (error, user) => {
fullDeleteUser(email, callback) {
db.users.findOne({ email }, (error, user) => {
if (error != null) {
return callback(error)
}
if (user == null) {
return callback()
}
const user_id = user._id
return db.projects.remove(
{ owner_ref: ObjectId(user_id) },
const userId = user._id
db.projects.remove(
{ owner_ref: ObjectId(userId) },
{ multi: true },
err => {
if (err != null) {
callback(err)
}
return db.users.remove({ _id: ObjectId(user_id) }, callback)
db.users.remove({ _id: ObjectId(userId) }, callback)
}
)
})
@ -383,33 +311,21 @@ class User {
})
}
getProject(project_id, callback) {
if (callback == null) {
callback = function(error, project) {}
}
return db.projects.findOne(
{ _id: ObjectId(project_id.toString()) },
callback
)
getProject(projectId, callback) {
db.projects.findOne({ _id: ObjectId(projectId.toString()) }, callback)
}
saveProject(project, callback) {
if (callback == null) {
callback = function(error) {}
}
return db.projects.update({ _id: project._id }, project, callback)
db.projects.update({ _id: project._id }, project, callback)
}
createProject(name, options, callback) {
if (callback == null) {
callback = function(error, oroject_id) {}
}
if (typeof options === 'function') {
callback = options
options = {}
}
return this.request.post(
this.request.post(
{
url: '/project/new',
json: Object.assign({ projectName: name }, options)
@ -429,49 +345,38 @@ class User {
body
])
)
return callback(error)
callback(error)
} else {
return callback(null, body.project_id)
callback(null, body.project_id)
}
}
)
}
deleteProject(project_id, callback) {
if (callback == null) {
callback = error
}
return this.request.delete(
deleteProject(projectId, callback) {
this.request.delete(
{
url: `/project/${project_id}?forever=true`
url: `/project/${projectId}?forever=true`
},
(error, response, body) => {
if (error != null) {
return callback(error)
}
return callback(null)
callback(null)
}
)
}
deleteProjects(callback) {
if (callback == null) {
callback = error
}
return db.projects.remove(
{ owner_ref: ObjectId(this.id) },
{ multi: true },
err => callback(err)
db.projects.remove({ owner_ref: ObjectId(this.id) }, { multi: true }, err =>
callback(err)
)
}
openProject(project_id, callback) {
if (callback == null) {
callback = error
}
return this.request.get(
openProject(projectId, callback) {
this.request.get(
{
url: `/project/${project_id}`
url: `/project/${projectId}`
},
(error, response, body) => {
if (error != null) {
@ -483,56 +388,50 @@ class User {
)
return callback(err)
}
return callback(null)
callback(null)
}
)
}
createDocInProject(project_id, parent_folder_id, name, callback) {
if (callback == null) {
callback = function(error, doc_id) {}
}
return this.getCsrfToken(error => {
createDocInProject(projectId, parentFolderId, name, callback) {
this.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
return this.request.post(
this.request.post(
{
url: `/project/${project_id}/doc`,
url: `/project/${projectId}/doc`,
json: {
name,
parent_folder_id
parentFolderId
}
},
(error, response, body) => {
return callback(null, body._id)
if (error != null) {
return callback(error)
}
callback(null, body._id)
}
)
})
}
addUserToProject(project_id, user, privileges, callback) {
addUserToProject(projectId, user, privileges, callback) {
let updateOp
if (callback == null) {
callback = function(error, user) {}
}
if (privileges === 'readAndWrite') {
updateOp = { $addToSet: { collaberator_refs: user._id.toString() } }
updateOp = { $addToSet: { collaberator_refs: user._id } }
} else if (privileges === 'readOnly') {
updateOp = { $addToSet: { readOnly_refs: user._id.toString() } }
updateOp = { $addToSet: { readOnly_refs: user._id } }
}
return db.projects.update({ _id: db.ObjectId(project_id) }, updateOp, err =>
db.projects.update({ _id: db.ObjectId(projectId) }, updateOp, err =>
callback(err)
)
}
makePublic(project_id, level, callback) {
if (callback == null) {
callback = function(error) {}
}
return this.request.post(
makePublic(projectId, level, callback) {
this.request.post(
{
url: `/project/${project_id}/settings/admin`,
url: `/project/${projectId}/settings/admin`,
json: {
publicAccessLevel: level
}
@ -541,18 +440,15 @@ class User {
if (error != null) {
return callback(error)
}
return callback(null)
callback(null)
}
)
}
makePrivate(project_id, callback) {
if (callback == null) {
callback = function(error) {}
}
return this.request.post(
makePrivate(projectId, callback) {
this.request.post(
{
url: `/project/${project_id}/settings/admin`,
url: `/project/${projectId}/settings/admin`,
json: {
publicAccessLevel: 'private'
}
@ -561,18 +457,15 @@ class User {
if (error != null) {
return callback(error)
}
return callback(null)
callback(null)
}
)
}
makeTokenBased(project_id, callback) {
if (callback == null) {
callback = function(error) {}
}
return this.request.post(
makeTokenBased(projectId, callback) {
this.request.post(
{
url: `/project/${project_id}/settings/admin`,
url: `/project/${projectId}/settings/admin`,
json: {
publicAccessLevel: 'tokenBased'
}
@ -581,16 +474,13 @@ class User {
if (error != null) {
return callback(error)
}
return callback(null)
callback(null)
}
)
}
getCsrfToken(callback) {
if (callback == null) {
callback = function(error) {}
}
return this.request.get(
this.request.get(
{
url: '/dev/csrf'
},
@ -604,20 +494,17 @@ class User {
'x-csrf-token': this.csrfToken
}
})
return callback()
callback()
}
)
}
changePassword(callback) {
if (callback == null) {
callback = function(error) {}
}
return this.getCsrfToken(error => {
this.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
return this.request.post(
this.request.post(
{
url: '/user/password/update',
json: {
@ -630,48 +517,42 @@ class User {
if (error != null) {
return callback(error)
}
return db.users.findOne({ email: this.email }, (error, user) => {
db.users.findOne({ email: this.email }, (error, user) => {
if (error != null) {
return callback(error)
}
return callback()
callback()
})
}
)
})
}
reconfirmAccountRequest(user_email, callback) {
if (callback == null) {
callback = function(error) {}
}
return this.getCsrfToken(error => {
reconfirmAccountRequest(userEmail, callback) {
this.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
return this.request.post(
this.request.post(
{
url: '/user/reconfirm',
json: {
email: user_email
email: userEmail
}
},
(error, response, body) => {
return callback(error, response)
callback(error, response)
}
)
})
}
getUserSettingsPage(callback) {
if (callback == null) {
callback = function(error, statusCode) {}
}
return this.getCsrfToken(error => {
this.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
return this.request.get(
this.request.get(
{
url: '/user/settings'
},
@ -679,21 +560,18 @@ class User {
if (error != null) {
return callback(error)
}
return callback(null, response.statusCode)
callback(null, response.statusCode)
}
)
})
}
activateSudoMode(callback) {
if (callback == null) {
callback = function(error) {}
}
return this.getCsrfToken(error => {
this.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
return this.request.post(
this.request.post(
{
uri: '/confirm-password',
json: {
@ -706,14 +584,11 @@ class User {
}
updateSettings(newSettings, callback) {
if (callback == null) {
callback = function(error, response, body) {}
}
return this.getCsrfToken(error => {
this.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
return this.request.post(
this.request.post(
{
url: '/user/settings',
json: newSettings
@ -724,14 +599,11 @@ class User {
}
getProjectListPage(callback) {
if (callback == null) {
callback = function(error, statusCode) {}
}
return this.getCsrfToken(error => {
this.getCsrfToken(error => {
if (error != null) {
return callback(error)
}
return this.request.get(
this.request.get(
{
url: '/project'
},
@ -739,26 +611,23 @@ class User {
if (error != null) {
return callback(error)
}
return callback(null, response.statusCode)
callback(null, response.statusCode)
}
)
})
}
isLoggedIn(callback) {
if (callback == null) {
callback = function(error, loggedIn) {}
}
return this.request.get('/user/personal_info', (error, response, body) => {
this.request.get('/user/personal_info', (error, response, body) => {
if (error != null) {
return callback(error)
}
if (response.statusCode === 200) {
return callback(null, true)
callback(null, true)
} else if (response.statusCode === 302) {
return callback(null, false)
callback(null, false)
} else {
return callback(
callback(
new Error(
`unexpected status code from /user/personal_info: ${
response.statusCode
@ -769,8 +638,35 @@ class User {
})
}
transferProjectOwnership(projectId, userId, callback) {
this.getCsrfToken(err => {
if (err != null) {
return callback(err)
}
this.request.post(
{
url: `/project/${projectId.toString()}/transfer-ownership`,
json: {
user_id: userId.toString()
}
},
(err, response) => {
if (err != null) {
return callback(err)
}
if (response.statusCode !== 204) {
return callback(
new Error(`Unexpected status code: ${response.statusCode}`)
)
}
callback()
}
)
})
}
setV1Id(v1Id, callback) {
return UserModel.update(
UserModel.update(
{
_id: this._id
},
@ -809,9 +705,3 @@ Object.getOwnPropertyNames(User.prototype).forEach(methodName => {
})
module.exports = User
function __guard__(value, transform) {
return typeof value !== 'undefined' && value !== null
? transform(value)
: undefined
}

View file

@ -1,33 +1,22 @@
/* eslint-disable
max-len,
no-return-assign,
no-unused-vars,
*/
// TODO: This file was created by bulk-decaffeinate.
// Fix any style issues and re-enable lint.
/*
* decaffeinate suggestions:
* DS102: Remove unnecessary code created because of implicit returns
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
*/
const should = require('chai').should()
const SandboxedModule = require('sandboxed-module')
const assert = require('assert')
const path = require('path')
const sinon = require('sinon')
const modulePath = path.join(
const { expect } = require('chai')
const HttpErrors = require('@overleaf/o-error/http')
const { ObjectId } = require('mongodb')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const MODULE_PATH = path.join(
__dirname,
'../../../../app/src/Features/Project/ProjectController'
)
const { expect } = require('chai')
const Errors = require('../../../../app/src/Features/Errors/Errors')
describe('ProjectController', function() {
beforeEach(function() {
this.project_id = '123213jlkj9kdlsaj'
this.project_id = ObjectId('abcdefabcdefabcdefabcdef')
this.user = {
_id: '588f3ddae8ebc1bac07c9fa4',
_id: ObjectId('123456123456123456123456'),
first_name: 'bjkdsjfk',
features: {}
}
@ -79,6 +68,9 @@ describe('ProjectController', function() {
findAllUsersProjects: sinon.stub(),
getProject: sinon.stub()
}
this.ProjectDetailsHandler = {
transferOwnership: sinon.stub().yields()
}
this.ProjectHelper = {
isArchived: sinon.stub(),
isTrashed: sinon.stub(),
@ -121,7 +113,7 @@ describe('ProjectController', function() {
}
this.getUserAffiliations = sinon.stub().callsArgWith(1, null, [])
this.ProjectController = SandboxedModule.require(modulePath, {
this.ProjectController = SandboxedModule.require(MODULE_PATH, {
globals: {
console: console
},
@ -137,6 +129,7 @@ describe('ProjectController', function() {
},
inc() {}
},
'@overleaf/o-error/http': HttpErrors,
'./ProjectDeleter': this.ProjectDeleter,
'./ProjectDuplicator': this.ProjectDuplicator,
'./ProjectCreationHandler': this.ProjectCreationHandler,
@ -155,6 +148,7 @@ describe('ProjectController', function() {
'../ReferencesSearch/ReferencesSearchHandler': this
.ReferencesSearchHandler,
'./ProjectGetter': this.ProjectGetter,
'./ProjectDetailsHandler': this.ProjectDetailsHandler,
'../Authentication/AuthenticationController': this
.AuthenticationController,
'../Analytics/AnalyticsManager': this.AnalyticsManager,
@ -196,12 +190,12 @@ describe('ProjectController', function() {
},
ip: '192.170.18.1'
}
return (this.res = {
this.res = {
locals: {
jsPath: 'js path here'
},
setTimeout: sinon.stub()
})
}
})
describe('updateProjectSettings', function() {
@ -213,9 +207,9 @@ describe('ProjectController', function() {
.calledWith(this.project_id, this.name)
.should.equal(true)
code.should.equal(204)
return done()
done()
}
return this.ProjectController.updateProjectSettings(this.req, this.res)
this.ProjectController.updateProjectSettings(this.req, this.res)
})
it('should update the compiler', function(done) {
@ -226,9 +220,9 @@ describe('ProjectController', function() {
.calledWith(this.project_id, this.compiler)
.should.equal(true)
code.should.equal(204)
return done()
done()
}
return this.ProjectController.updateProjectSettings(this.req, this.res)
this.ProjectController.updateProjectSettings(this.req, this.res)
})
it('should update the imageName', function(done) {
@ -239,9 +233,9 @@ describe('ProjectController', function() {
.calledWith(this.project_id, this.imageName)
.should.equal(true)
code.should.equal(204)
return done()
done()
}
return this.ProjectController.updateProjectSettings(this.req, this.res)
this.ProjectController.updateProjectSettings(this.req, this.res)
})
it('should update the spell check language', function(done) {
@ -252,9 +246,9 @@ describe('ProjectController', function() {
.calledWith(this.project_id, this.languageCode)
.should.equal(true)
code.should.equal(204)
return done()
done()
}
return this.ProjectController.updateProjectSettings(this.req, this.res)
this.ProjectController.updateProjectSettings(this.req, this.res)
})
it('should update the root doc', function(done) {
@ -265,9 +259,9 @@ describe('ProjectController', function() {
.calledWith(this.project_id, this.rootDocId)
.should.equal(true)
code.should.equal(204)
return done()
done()
}
return this.ProjectController.updateProjectSettings(this.req, this.res)
this.ProjectController.updateProjectSettings(this.req, this.res)
})
})
@ -282,12 +276,9 @@ describe('ProjectController', function() {
.calledWith(this.project_id, this.publicAccessLevel)
.should.equal(true)
code.should.equal(204)
return done()
done()
}
return this.ProjectController.updateProjectAdminSettings(
this.req,
this.res
)
this.ProjectController.updateProjectAdminSettings(this.req, this.res)
})
})
@ -298,9 +289,9 @@ describe('ProjectController', function() {
.calledWith(this.project_id)
.should.equal(true)
code.should.equal(200)
return done()
done()
}
return this.ProjectController.deleteProject(this.req, this.res)
this.ProjectController.deleteProject(this.req, this.res)
})
it('should tell the project deleter to delete when forever=true', function(done) {
@ -313,9 +304,9 @@ describe('ProjectController', function() {
})
.should.equal(true)
code.should.equal(200)
return done()
done()
}
return this.ProjectController.deleteProject(this.req, this.res)
this.ProjectController.deleteProject(this.req, this.res)
})
})
@ -326,9 +317,9 @@ describe('ProjectController', function() {
.calledWith(this.project_id)
.should.equal(true)
code.should.equal(200)
return done()
done()
}
return this.ProjectController.restoreProject(this.req, this.res)
this.ProjectController.restoreProject(this.req, this.res)
})
})
@ -339,9 +330,9 @@ describe('ProjectController', function() {
.calledWith(this.user, this.project_id, this.projectName)
.should.equal(true)
json.project_id.should.equal(this.project_id)
return done()
done()
}
return this.ProjectController.cloneProject(this.req, this.res)
this.ProjectController.cloneProject(this.req, this.res)
})
})
@ -355,9 +346,9 @@ describe('ProjectController', function() {
this.ProjectCreationHandler.createBasicProject.called.should.equal(
false
)
return done()
done()
}
return this.ProjectController.newProject(this.req, this.res)
this.ProjectController.newProject(this.req, this.res)
})
it('should call the projectCreationHandler with createBasicProject', function(done) {
@ -369,9 +360,9 @@ describe('ProjectController', function() {
this.ProjectCreationHandler.createBasicProject
.calledWith(this.user._id, this.projectName)
.should.equal(true)
return done()
done()
}
return this.ProjectController.newProject(this.req, this.res)
this.ProjectController.newProject(this.req, this.res)
})
})
@ -411,10 +402,10 @@ describe('ProjectController', function() {
}
this.users[this.user._id] = this.user // Owner
this.UserModel.findById = (id, fields, callback) => {
return callback(null, this.users[id])
callback(null, this.users[id])
}
this.UserGetter.getUserOrUserStubById = (id, fields, callback) => {
return callback(null, this.users[id])
callback(null, this.users[id])
}
this.LimitationsManager.hasPaidSubscription.callsArgWith(1, null, false)
@ -427,7 +418,7 @@ describe('ProjectController', function() {
null,
this.allProjects
)
return this.Modules.hooks.fire
this.Modules.hooks.fire
.withArgs('findAllV1Projects', this.user._id)
.yields(undefined)
}) // Without integration module hook, cb returns undefined
@ -435,26 +426,26 @@ describe('ProjectController', function() {
it('should render the project/list page', function(done) {
this.res.render = (pageName, opts) => {
pageName.should.equal('project/list')
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should send the tags', function(done) {
this.res.render = (pageName, opts) => {
opts.tags.length.should.equal(this.tags.length)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should create trigger ip matcher notifications', function(done) {
this.settings.overleaf = true
this.res.render = (pageName, opts) => {
this.NotificationBuilder.ipMatcherAffiliation.called.should.equal(true)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should send the projects', function(done) {
@ -466,17 +457,17 @@ describe('ProjectController', function() {
this.tokenReadAndWrite.length +
this.tokenReadOnly.length
)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should send the user', function(done) {
this.res.render = (pageName, opts) => {
opts.user.should.deep.equal(this.user)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should inject the users', function(done) {
@ -490,17 +481,17 @@ describe('ProjectController', function() {
opts.projects[1].lastUpdatedBy.should.equal(
this.users[this.projects[1].lastUpdatedBy]
)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should send hasSubscription == false when no subscription', function(done) {
this.res.render = (pageName, opts) => {
opts.hasSubscription.should.equal(false)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should send hasSubscription == true when there is a subscription', function(done) {
@ -509,16 +500,16 @@ describe('ProjectController', function() {
.callsArgWith(1, null, true)
this.res.render = (pageName, opts) => {
opts.hasSubscription.should.equal(true)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
describe('front widget', function(done) {
beforeEach(function() {
return (this.settings.overleaf = {
this.settings.overleaf = {
front_chat_widget_room_id: 'chat-room-id'
})
}
})
it('should show for paid users', function(done) {
@ -528,29 +519,29 @@ describe('ProjectController', function() {
opts.frontChatWidgetRoomId.should.equal(
this.settings.overleaf.front_chat_widget_room_id
)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should show for sample users', function(done) {
this.user._id = '588f3ddae8ebc1bac07c9f00' // last two digits
this.user._id = ObjectId('588f3ddae8ebc1bac07c9f00') // last two digits
this.res.render = (pageName, opts) => {
opts.frontChatWidgetRoomId.should.equal(
this.settings.overleaf.front_chat_widget_room_id
)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should not show for non sample users', function(done) {
this.user._id = '588f3ddae8ebc1bac07c9fff' // last two digits
this.user._id = ObjectId('588f3ddae8ebc1bac07c9fff') // last two digits
this.res.render = (pageName, opts) => {
expect(opts.frontChatWidgetRoomId).to.equal(undefined)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
})
@ -579,7 +570,7 @@ describe('ProjectController', function() {
],
tags: [{ name: 'mock tag', project_ids: ['123mockV1Id'] }]
}
return this.Modules.hooks.fire
this.Modules.hooks.fire
.withArgs('findAllV1Projects', this.user._id)
.yields(null, [this.V1Response])
}) // Need to wrap response in array, as multiple hooks could fire
@ -600,11 +591,11 @@ describe('ProjectController', function() {
expect(p).to.have.property('name')
expect(p).to.have.property('lastUpdated')
expect(p).to.have.property('accessLevel')
return expect(p).to.have.property('archived')
expect(p).to.have.property('archived')
})
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should include V1 tags', function(done) {
@ -614,19 +605,19 @@ describe('ProjectController', function() {
)
opts.tags.forEach(t => {
expect(t).to.have.property('name')
return expect(t).to.have.property('project_ids')
expect(t).to.have.property('project_ids')
})
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should have isShowingV1Projects flag', function(done) {
this.res.render = (pageName, opts) => {
opts.isShowingV1Projects.should.equal(true)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
})
})
@ -670,7 +661,7 @@ describe('ProjectController', function() {
}
this.users[this.user._id] = this.user // Owner
this.UserModel.findById = (id, fields, callback) => {
return callback(null, this.users[id])
callback(null, this.users[id])
}
this.LimitationsManager.hasPaidSubscription.callsArgWith(1, null, false)
@ -683,7 +674,7 @@ describe('ProjectController', function() {
null,
this.allProjects
)
return this.Modules.hooks.fire
this.Modules.hooks.fire
.withArgs('findAllV1Projects', this.user._id)
.yields(undefined)
}) // Without integration module hook, cb returns undefined
@ -691,9 +682,9 @@ describe('ProjectController', function() {
it('should render the project/list page', function(done) {
this.res.render = (pageName, opts) => {
pageName.should.equal('project/list')
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
it('should omit one of the projects', function(done) {
@ -706,16 +697,16 @@ describe('ProjectController', function() {
this.tokenReadOnly.length -
1
)
return done()
done()
}
return this.ProjectController.projectListPage(this.req, this.res)
this.ProjectController.projectListPage(this.req, this.res)
})
})
describe('renameProject', function() {
beforeEach(function() {
this.newProjectName = 'my supper great new project'
return (this.req.body.newProjectName = this.newProjectName)
this.req.body.newProjectName = this.newProjectName
})
it('should call the editor controller', function(done) {
@ -725,9 +716,9 @@ describe('ProjectController', function() {
this.EditorController.renameProject
.calledWith(this.project_id, this.newProjectName)
.should.equal(true)
return done()
done()
}
return this.ProjectController.renameProject(this.req, this.res)
this.ProjectController.renameProject(this.req, this.res)
})
it('should send an error to next() if there is a problem', function(done) {
@ -738,9 +729,9 @@ describe('ProjectController', function() {
)
const next = e => {
e.should.equal(error)
return done()
done()
}
return this.ProjectController.renameProject(this.req, this.res, next)
this.ProjectController.renameProject(this.req, this.res, next)
})
})
@ -777,32 +768,32 @@ describe('ProjectController', function() {
this.ProjectDeleter.unmarkAsDeletedByExternalSource = sinon.stub()
this.InactiveProjectManager.reactivateProjectIfRequired.callsArgWith(1)
this.AnalyticsManager.getLastOccurrence.yields(null, { mock: 'event' })
return this.ProjectUpdateHandler.markAsOpened.callsArgWith(1)
this.ProjectUpdateHandler.markAsOpened.callsArgWith(1)
})
it('should render the project/editor page', function(done) {
this.res.render = (pageName, opts) => {
pageName.should.equal('project/editor')
return done()
done()
}
return this.ProjectController.loadEditor(this.req, this.res)
this.ProjectController.loadEditor(this.req, this.res)
})
it('should add user', function(done) {
this.res.render = (pageName, opts) => {
opts.user.email.should.equal(this.user.email)
return done()
done()
}
return this.ProjectController.loadEditor(this.req, this.res)
this.ProjectController.loadEditor(this.req, this.res)
})
it('should add on userSettings', function(done) {
this.res.render = (pageName, opts) => {
opts.userSettings.fontSize.should.equal(this.user.ace.fontSize)
opts.userSettings.editorTheme.should.equal(this.user.ace.theme)
return done()
done()
}
return this.ProjectController.loadEditor(this.req, this.res)
this.ProjectController.loadEditor(this.req, this.res)
})
it('should add isRestrictedTokenMember', function(done) {
@ -848,9 +839,9 @@ describe('ProjectController', function() {
this.settings.editorIsOpen = false
this.res.render = (pageName, opts) => {
pageName.should.equal('general/closed')
return done()
done()
}
return this.ProjectController.loadEditor(this.req, this.res)
this.ProjectController.loadEditor(this.req, this.res)
})
it('should not render the page if the project can not be accessed', function(done) {
@ -859,9 +850,9 @@ describe('ProjectController', function() {
.callsArgWith(3, null, null)
this.res.sendStatus = (resCode, opts) => {
resCode.should.equal(401)
return done()
done()
}
return this.ProjectController.loadEditor(this.req, this.res)
this.ProjectController.loadEditor(this.req, this.res)
})
it('should reactivateProjectIfRequired', function(done) {
@ -869,9 +860,9 @@ describe('ProjectController', function() {
this.InactiveProjectManager.reactivateProjectIfRequired
.calledWith(this.project_id)
.should.equal(true)
return done()
done()
}
return this.ProjectController.loadEditor(this.req, this.res)
this.ProjectController.loadEditor(this.req, this.res)
})
it('should mark project as opened', function(done) {
@ -879,9 +870,9 @@ describe('ProjectController', function() {
this.ProjectUpdateHandler.markAsOpened
.calledWith(this.project_id)
.should.equal(true)
return done()
done()
}
return this.ProjectController.loadEditor(this.req, this.res)
this.ProjectController.loadEditor(this.req, this.res)
})
it('should call the brand variations handler for branded projects', function(done) {
@ -890,9 +881,9 @@ describe('ProjectController', function() {
this.BrandVariationsHandler.getBrandVariationById
.calledWith()
.should.equal(true)
return done()
done()
}
return this.ProjectController.loadEditor(this.req, this.res)
this.ProjectController.loadEditor(this.req, this.res)
})
it('should not call the brand variations handler for unbranded projects', function(done) {
@ -900,18 +891,18 @@ describe('ProjectController', function() {
this.BrandVariationsHandler.getBrandVariationById.called.should.equal(
false
)
return done()
done()
}
return this.ProjectController.loadEditor(this.req, this.res)
this.ProjectController.loadEditor(this.req, this.res)
})
it('should expose the brand variation details as locals for branded projects', function(done) {
this.ProjectGetter.getProject.callsArgWith(2, null, this.brandedProject)
this.res.render = (pageName, opts) => {
opts.brandVariation.should.deep.equal(this.brandVariationDetails)
return done()
done()
}
return this.ProjectController.loadEditor(this.req, this.res)
this.ProjectController.loadEditor(this.req, this.res)
})
})
@ -971,7 +962,7 @@ describe('ProjectController', function() {
this.AuthenticationController.getLoggedInUserId = sinon
.stub()
.returns(this.user._id)
return done()
done()
})
it('should produce a list of projects', function(done) {
@ -982,13 +973,9 @@ describe('ProjectController', function() {
{ _id: 'd', name: 'D', accessLevel: 'd' }
]
})
return done()
done()
}
return this.ProjectController.userProjectsJson(
this.req,
this.res,
this.next
)
this.ProjectController.userProjectsJson(this.req, this.res, this.next)
})
})
@ -1007,9 +994,9 @@ describe('ProjectController', function() {
this.ProjectGetter.getProject = sinon
.stub()
.callsArgWith(1, null, this.project)
return (this.ProjectEntityHandler.getAllEntitiesFromProject = sinon
this.ProjectEntityHandler.getAllEntitiesFromProject = sinon
.stub()
.callsArgWith(1, null, this.docs, this.files))
.callsArgWith(1, null, this.docs, this.files)
})
it('should produce a list of entities', function(done) {
@ -1026,13 +1013,9 @@ describe('ProjectController', function() {
expect(
this.ProjectEntityHandler.getAllEntitiesFromProject.callCount
).to.equal(1)
return done()
done()
}
return this.ProjectController.projectEntitiesJson(
this.req,
this.res,
this.next
)
this.ProjectController.projectEntitiesJson(this.req, this.res, this.next)
})
})
@ -1115,7 +1098,7 @@ describe('ProjectController', function() {
})
describe('_isInPercentageRollout', function() {
before(function() {
return (this.ids = [
this.ids = [
'5a05cd7621f9fe22be131740',
'5a05cd7821f9fe22be131741',
'5a05cd7921f9fe22be131742',
@ -1136,14 +1119,14 @@ describe('ProjectController', function() {
'5a05cd8421f9fe22be131751',
'5a05cd8421f9fe22be131752',
'5a05cd8521f9fe22be131753'
])
]
})
it('should produce the expected results', function() {
expect(
this.ids.map(i => {
return this.ProjectController._isInPercentageRollout('abcd', i, 50)
})
this.ids.map(i =>
this.ProjectController._isInPercentageRollout('abcd', i, 50)
)
).to.deep.equal([
false,
false,
@ -1166,10 +1149,10 @@ describe('ProjectController', function() {
false,
true
])
return expect(
this.ids.map(i => {
return this.ProjectController._isInPercentageRollout('efgh', i, 50)
})
expect(
this.ids.map(i =>
this.ProjectController._isInPercentageRollout('efgh', i, 50)
)
).to.deep.equal([
false,
false,
@ -1194,4 +1177,46 @@ describe('ProjectController', function() {
])
})
})
describe('transferOwnership', function() {
beforeEach(function() {
this.req.body = { user_id: this.user._id.toString() }
})
it('validates the request body', function(done) {
this.req.body = {}
this.ProjectController.transferOwnership(this.req, this.res, err => {
expect(err).to.be.instanceof(HttpErrors.BadRequestError)
done()
})
})
it('returns 204 on success', function(done) {
this.res.sendStatus = status => {
expect(status).to.equal(204)
done()
}
this.ProjectController.transferOwnership(this.req, this.res)
})
it('returns 404 if the project does not exist', function(done) {
this.ProjectDetailsHandler.transferOwnership.yields(
new Errors.ProjectNotFoundError()
)
this.ProjectController.transferOwnership(this.req, this.res, err => {
expect(err).to.be.instanceof(HttpErrors.NotFoundError)
done()
})
})
it('returns 404 if the user does not exist', function(done) {
this.ProjectDetailsHandler.transferOwnership.yields(
new Errors.UserNotFoundError()
)
this.ProjectController.transferOwnership(this.req, this.res, err => {
expect(err).to.be.instanceof(HttpErrors.NotFoundError)
done()
})
})
})
})

View file

@ -1,22 +1,30 @@
const Errors = require('../../../../app/src/Features/Errors/Errors')
const SandboxedModule = require('sandboxed-module')
const sinon = require('sinon')
const { expect } = require('chai')
const { ObjectId } = require('mongodb')
const Errors = require('../../../../app/src/Features/Errors/Errors')
const PrivilegeLevels = require('../../../../app/src/Features/Authorization/PrivilegeLevels')
const MODULE_PATH = '../../../../app/src/Features/Project/ProjectDetailsHandler'
describe('ProjectDetailsHandler', function() {
beforeEach(function() {
this.project_id = '321l3j1kjkjl'
this.user_id = 'user-id-123'
this.user = {
_id: ObjectId('abcdefabcdefabcdefabcdef'),
features: 'mock-features'
}
this.collaborator = {
_id: ObjectId('123456123456123456123456')
}
this.project = {
_id: ObjectId('5d5dabdbb351de090cdff0b2'),
name: 'project',
description: 'this is a great project',
something: 'should not exist',
compiler: 'latexxxxxx',
owner_ref: this.user_id
owner_ref: this.user._id,
collaberator_refs: [this.collaborator._id]
}
this.user = { features: 'mock-features' }
this.ProjectGetter = {
promises: {
getProjectWithoutDocLines: sinon.stub().resolves(this.project),
@ -52,7 +60,8 @@ describe('ProjectDetailsHandler', function() {
}
this.CollaboratorsHandler = {
promises: {
removeUserFromProject: sinon.stub().resolves()
removeUserFromProject: sinon.stub().resolves(),
addUserIdToProject: sinon.stub().resolves()
}
}
this.ProjectTokenGenerator = {
@ -91,7 +100,7 @@ describe('ProjectDetailsHandler', function() {
describe('getDetails', function() {
it('should find the project and owner', async function() {
const details = await this.handler.promises.getDetails(this.project_id)
const details = await this.handler.promises.getDetails(this.project._id)
details.name.should.equal(this.project.name)
details.description.should.equal(this.project.description)
details.compiler.should.equal(this.project.compiler)
@ -101,7 +110,7 @@ describe('ProjectDetailsHandler', function() {
it('should find overleaf metadata if it exists', async function() {
this.project.overleaf = { id: 'id' }
const details = await this.handler.promises.getDetails(this.project_id)
const details = await this.handler.promises.getDetails(this.project._id)
details.overleaf.should.equal(this.project.overleaf)
expect(details.something).to.be.undefined
})
@ -115,95 +124,122 @@ describe('ProjectDetailsHandler', function() {
it('should return the default features if no owner found', async function() {
this.UserGetter.promises.getUser.resolves(null)
const details = await this.handler.promises.getDetails(this.project_id)
const details = await this.handler.promises.getDetails(this.project._id)
details.features.should.equal(this.settings.defaultFeatures)
})
it('should rethrow any error', async function() {
this.ProjectGetter.promises.getProject.rejects(new Error('boom'))
await expect(this.handler.promises.getDetails(this.project_id)).to.be
await expect(this.handler.promises.getDetails(this.project._id)).to.be
.rejected
})
})
describe('transferOwnership', function() {
beforeEach(function() {
this.ProjectGetter.promises.findAllUsersProjects.resolves({
owned: [{ name: this.project.name }],
readAndWrite: [],
readOnly: [],
tokenReadAndWrite: [],
tokenReadOnly: []
})
this.UserGetter.promises.getUser.resolves(this.collaborator)
})
it("should return a not found error if the project can't be found", async function() {
this.ProjectGetter.promises.getProject.resolves(null)
await expect(
this.handler.promises.transferOwnership('abc', '123')
).to.be.rejectedWith(Errors.NotFoundError)
this.handler.promises.transferOwnership('abc', this.collaborator._id)
).to.be.rejectedWith(Errors.ProjectNotFoundError)
})
it("should return a not found error if the user can't be found", async function() {
this.UserGetter.promises.getUser.resolves(null)
await expect(
this.handler.promises.transferOwnership('abc', '123')
).to.be.rejectedWith(Errors.NotFoundError)
this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
).to.be.rejectedWith(Errors.UserNotFoundError)
})
it('should return an error if user cannot be removed as collaborator ', async function() {
this.CollaboratorsHandler.promises.removeUserFromProject.rejects(
new Error('user-cannot-be-removed')
)
await expect(this.handler.promises.transferOwnership('abc', '123')).to.be
.rejected
await expect(
this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
).to.be.rejected
})
it('should transfer ownership of the project', async function() {
await this.handler.promises.transferOwnership('abc', '123')
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: 'abc' },
sinon.match({ $set: { owner_ref: '123' } })
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: this.project._id },
sinon.match({ $set: { owner_ref: this.collaborator._id } })
)
})
it('should do nothing if transferring back to the owner', async function() {
await this.handler.promises.transferOwnership(
this.project._id,
this.user._id
)
expect(this.ProjectModel.update).not.to.have.been.called
})
it("should remove the user from the project's collaborators", async function() {
await this.handler.promises.transferOwnership('abc', '123')
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(
this.CollaboratorsHandler.promises.removeUserFromProject
).to.have.been.calledWith('abc', '123')
).to.have.been.calledWith(this.project._id, this.collaborator._id)
})
it('should add the former project owner as a read/write collaborator', async function() {
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(
this.CollaboratorsHandler.promises.addUserIdToProject
).to.have.been.calledWith(
this.project._id,
this.collaborator._id,
this.user._id,
PrivilegeLevels.READ_AND_WRITE
)
})
it('should flush the project to tpds', async function() {
await this.handler.promises.transferOwnership('abc', '123')
await this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
expect(
this.ProjectEntityHandler.promises.flushProjectToThirdPartyDataStore
).to.have.been.calledWith('abc')
).to.have.been.calledWith(this.project._id)
})
it('should generate a unique name for the project', async function() {
await this.handler.promises.transferOwnership('abc', '123')
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: 'abc' },
sinon.match({ $set: { name: `${this.project.name} (1)` } })
)
})
it('should append the supplied suffix to the project name, if passed', async function() {
await this.handler.promises.transferOwnership('abc', '123', ' wombat')
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: 'abc' },
sinon.match({ $set: { name: `${this.project.name} wombat` } })
)
it('should decline to transfer ownership to a non-collaborator', async function() {
this.project.collaberator_refs = []
await expect(
this.handler.promises.transferOwnership(
this.project._id,
this.collaborator._id
)
).to.be.rejectedWith(Errors.UserNotCollaboratorError)
})
})
describe('getProjectDescription', function() {
it('should make a call to mongo just for the description', async function() {
this.ProjectGetter.promises.getProject.resolves()
await this.handler.promises.getProjectDescription(this.project_id)
await this.handler.promises.getProjectDescription(this.project._id)
expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
this.project_id,
this.project._id,
{ description: true }
)
})
@ -214,7 +250,7 @@ describe('ProjectDetailsHandler', function() {
description: expectedDescription
})
const description = await this.handler.promises.getProjectDescription(
this.project_id
this.project._id
)
expect(description).to.equal(expectedDescription)
})
@ -227,11 +263,11 @@ describe('ProjectDetailsHandler', function() {
it('should update the project detials', async function() {
await this.handler.promises.setProjectDescription(
this.project_id,
this.project._id,
this.description
)
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: this.project_id },
{ _id: this.project._id },
{ description: this.description }
)
})
@ -243,18 +279,18 @@ describe('ProjectDetailsHandler', function() {
})
it('should update the project with the new name', async function() {
await this.handler.promises.renameProject(this.project_id, this.newName)
await this.handler.promises.renameProject(this.project._id, this.newName)
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: this.project_id },
{ _id: this.project._id },
{ name: this.newName }
)
})
it('should tell the TpdsUpdateSender', async function() {
await this.handler.promises.renameProject(this.project_id, this.newName)
await this.handler.promises.renameProject(this.project._id, this.newName)
expect(this.TpdsUpdateSender.promises.moveEntity).to.have.been.calledWith(
{
project_id: this.project_id,
project_id: this.project._id,
project_name: this.project.name,
newProjectName: this.newName
}
@ -262,7 +298,7 @@ describe('ProjectDetailsHandler', function() {
})
it('should not do anything with an invalid name', async function() {
await expect(this.handler.promises.renameProject(this.project_id)).to.be
await expect(this.handler.promises.renameProject(this.project._id)).to.be
.rejected
expect(this.TpdsUpdateSender.promises.moveEntity).not.to.have.been.called
expect(this.ProjectModel.update).not.to.have.been.called
@ -358,7 +394,7 @@ describe('ProjectDetailsHandler', function() {
it('should leave a unique name unchanged', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user_id,
this.user._id,
'unique-name',
['-test-suffix']
)
@ -367,7 +403,7 @@ describe('ProjectDetailsHandler', function() {
it('should append a suffix to an existing name', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user_id,
this.user._id,
'name1',
['-test-suffix']
)
@ -376,7 +412,7 @@ describe('ProjectDetailsHandler', function() {
it('should fallback to a second suffix when needed', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user_id,
this.user._id,
'name1',
['1', '-test-suffix']
)
@ -385,7 +421,7 @@ describe('ProjectDetailsHandler', function() {
it('should truncate the name when append a suffix if the result is too long', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user_id,
this.user._id,
this.longName,
['-test-suffix']
)
@ -397,7 +433,7 @@ describe('ProjectDetailsHandler', function() {
it('should use a numeric index if no suffix is supplied', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user_id,
this.user._id,
'name1',
[]
)
@ -406,7 +442,7 @@ describe('ProjectDetailsHandler', function() {
it('should use a numeric index if all suffixes are exhausted', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user_id,
this.user._id,
'name',
['1', '11']
)
@ -415,7 +451,7 @@ describe('ProjectDetailsHandler', function() {
it('should find the next lowest available numeric index for the base name', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user_id,
this.user._id,
'numeric',
[]
)
@ -424,7 +460,7 @@ describe('ProjectDetailsHandler', function() {
it('should find the next available numeric index when a numeric index is already present', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user_id,
this.user._id,
'numeric (5)',
[]
)
@ -433,7 +469,7 @@ describe('ProjectDetailsHandler', function() {
it('should not find a numeric index lower than the one already present', async function() {
const name = await this.handler.promises.generateUniqueName(
this.user_id,
this.user._id,
'numeric (31)',
[]
)
@ -472,11 +508,11 @@ describe('ProjectDetailsHandler', function() {
it('should update the project with the new level', async function() {
await this.handler.promises.setPublicAccessLevel(
this.project_id,
this.project._id,
this.accessLevel
)
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: this.project_id },
{ _id: this.project._id },
{ publicAccesLevel: this.accessLevel }
)
})
@ -484,7 +520,7 @@ describe('ProjectDetailsHandler', function() {
it('should not produce an error', async function() {
await expect(
this.handler.promises.setPublicAccessLevel(
this.project_id,
this.project._id,
this.accessLevel
)
).to.be.resolved
@ -498,7 +534,7 @@ describe('ProjectDetailsHandler', function() {
it('should produce an error', async function() {
await expect(
this.handler.promises.setPublicAccessLevel(
this.project_id,
this.project._id,
this.accessLevel
)
).to.be.rejected
@ -510,7 +546,7 @@ describe('ProjectDetailsHandler', function() {
describe('when the project has tokens', function() {
beforeEach(function() {
this.project = {
_id: this.project_id,
_id: this.project._id,
tokens: {
readOnly: 'aaa',
readAndWrite: '42bbb',
@ -521,10 +557,10 @@ describe('ProjectDetailsHandler', function() {
})
it('should get the project', async function() {
await this.handler.promises.ensureTokensArePresent(this.project_id)
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
this.project_id,
this.project._id,
{
tokens: 1
}
@ -532,13 +568,13 @@ describe('ProjectDetailsHandler', function() {
})
it('should not update the project with new tokens', async function() {
await this.handler.promises.ensureTokensArePresent(this.project_id)
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.ProjectModel.update).not.to.have.been.called
})
it('should produce the tokens without error', async function() {
const tokens = await this.handler.promises.ensureTokensArePresent(
this.project_id
this.project._id
)
expect(tokens).to.deep.equal(this.project.tokens)
})
@ -546,7 +582,7 @@ describe('ProjectDetailsHandler', function() {
describe('when tokens are missing', function() {
beforeEach(function() {
this.project = { _id: this.project_id }
this.project = { _id: this.project._id }
this.ProjectGetter.promises.getProject.resolves(this.project)
this.readOnlyToken = 'abc'
this.readAndWriteToken = '42def'
@ -561,10 +597,10 @@ describe('ProjectDetailsHandler', function() {
})
it('should get the project', async function() {
await this.handler.promises.ensureTokensArePresent(this.project_id)
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.ProjectGetter.promises.getProject).to.have.been.calledOnce
expect(this.ProjectGetter.promises.getProject).to.have.been.calledWith(
this.project_id,
this.project._id,
{
tokens: 1
}
@ -572,14 +608,14 @@ describe('ProjectDetailsHandler', function() {
})
it('should update the project with new tokens', async function() {
await this.handler.promises.ensureTokensArePresent(this.project_id)
await this.handler.promises.ensureTokensArePresent(this.project._id)
expect(this.ProjectTokenGenerator.promises.generateUniqueReadOnlyToken)
.to.have.been.calledOnce
expect(this.ProjectTokenGenerator.readAndWriteToken).to.have.been
.calledOnce
expect(this.ProjectModel.update).to.have.been.calledOnce
expect(this.ProjectModel.update).to.have.been.calledWith(
{ _id: this.project_id },
{ _id: this.project._id },
{
$set: {
tokens: {
@ -594,7 +630,7 @@ describe('ProjectDetailsHandler', function() {
it('should produce the tokens without error', async function() {
const tokens = await this.handler.promises.ensureTokensArePresent(
this.project_id
this.project._id
)
expect(tokens).to.deep.equal({
readOnly: this.readOnlyToken,