mirror of
https://github.com/overleaf/overleaf.git
synced 2025-04-03 15:42:54 +00:00
Merge pull request #1959 from overleaf/spd-integration-soft-deletion
Integration: Merge soft-deletion features into master GitOrigin-RevId: 83baf730be2f256ad0d02271600392fda144b761
This commit is contained in:
parent
032ef02a03
commit
bf740f1e25
31 changed files with 2049 additions and 700 deletions
|
@ -214,50 +214,40 @@ module.exports = DocstoreManager = {
|
|||
},
|
||||
|
||||
archiveProject(project_id, callback) {
|
||||
const url = `${settings.apis.docstore.url}/project/${project_id}/archive`
|
||||
logger.log({ project_id }, 'archiving project in docstore')
|
||||
return request.post(url, function(err, res, docs) {
|
||||
if (err != null) {
|
||||
logger.warn({ err, project_id }, 'error archving project in docstore')
|
||||
return callback(err)
|
||||
}
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
return callback()
|
||||
} else {
|
||||
const error = new Error(
|
||||
`docstore api responded with non-success code: ${res.statusCode}`
|
||||
)
|
||||
logger.warn(
|
||||
{ err: error, project_id },
|
||||
'error archiving project in docstore'
|
||||
)
|
||||
return callback(error)
|
||||
}
|
||||
})
|
||||
DocstoreManager._operateOnProject(project_id, 'archive', callback)
|
||||
},
|
||||
|
||||
unarchiveProject(project_id, callback) {
|
||||
const url = `${settings.apis.docstore.url}/project/${project_id}/unarchive`
|
||||
logger.log({ project_id }, 'unarchiving project in docstore')
|
||||
return request.post(url, function(err, res, docs) {
|
||||
DocstoreManager._operateOnProject(project_id, 'unarchive', callback)
|
||||
},
|
||||
|
||||
destroyProject(project_id, callback) {
|
||||
DocstoreManager._operateOnProject(project_id, 'destroy', callback)
|
||||
},
|
||||
|
||||
_operateOnProject(project_id, method, callback) {
|
||||
const url = `${settings.apis.docstore.url}/project/${project_id}/${method}`
|
||||
logger.log({ project_id }, `calling ${method} for project in docstore`)
|
||||
request.post(url, function(err, res, docs) {
|
||||
if (err != null) {
|
||||
logger.warn(
|
||||
{ err, project_id },
|
||||
'error unarchiving project in docstore'
|
||||
`error calling ${method} project in docstore`
|
||||
)
|
||||
return callback(err)
|
||||
}
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
return callback()
|
||||
callback()
|
||||
} else {
|
||||
const error = new Error(
|
||||
`docstore api responded with non-success code: ${res.statusCode}`
|
||||
)
|
||||
logger.warn(
|
||||
{ err: error, project_id },
|
||||
'error unarchiving project in docstore'
|
||||
`error calling ${method} project in docstore`
|
||||
)
|
||||
return callback(error)
|
||||
callback(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -161,6 +161,31 @@ module.exports = ProjectController = {
|
|||
}
|
||||
},
|
||||
|
||||
expireDeletedProjectsAfterDuration(req, res) {
|
||||
logger.log(
|
||||
'received request to look for old deleted projects and expire them'
|
||||
)
|
||||
projectDeleter.expireDeletedProjectsAfterDuration(function(err) {
|
||||
if (err != null) {
|
||||
return res.sendStatus(500)
|
||||
} else {
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
expireDeletedProject(req, res, next) {
|
||||
const { projectId } = req.params
|
||||
logger.log('received request to expire deleted project', { projectId })
|
||||
projectDeleter.expireDeletedProject(projectId, function(err) {
|
||||
if (err != null) {
|
||||
return next(err)
|
||||
} else {
|
||||
return res.sendStatus(200)
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
restoreProject(req, res) {
|
||||
const project_id = req.params.Project_id
|
||||
logger.log({ project_id }, 'received request to restore project')
|
||||
|
|
|
@ -1,9 +1,6 @@
|
|||
/* eslint-disable
|
||||
camelcase,
|
||||
handle-callback-err,
|
||||
max-len,
|
||||
no-undef,
|
||||
no-unused-vars,
|
||||
*/
|
||||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Fix any style issues and re-enable lint.
|
||||
|
@ -14,15 +11,19 @@
|
|||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const { promisify } = require('util')
|
||||
const { db } = require('../../infrastructure/mongojs')
|
||||
const { promisify, callbackify } = require('util')
|
||||
const { Project } = require('../../models/Project')
|
||||
const { DeletedProject } = require('../../models/DeletedProject')
|
||||
const Errors = require('../Errors/Errors')
|
||||
const logger = require('logger-sharelatex')
|
||||
const documentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
|
||||
const tagsHandler = require('../Tags/TagsHandler')
|
||||
const DocumentUpdaterHandler = require('../DocumentUpdater/DocumentUpdaterHandler')
|
||||
const TagsHandler = require('../Tags/TagsHandler')
|
||||
const async = require('async')
|
||||
const FileStoreHandler = require('../FileStore/FileStoreHandler')
|
||||
const ProjectDetailsHandler = require('./ProjectDetailsHandler')
|
||||
const CollaboratorsHandler = require('../Collaborators/CollaboratorsHandler')
|
||||
const DocstoreManager = require('../Docstore/DocstoreManager')
|
||||
const moment = require('moment')
|
||||
|
||||
const ProjectDeleter = {
|
||||
markAsDeletedByExternalSource(project_id, callback) {
|
||||
|
@ -52,7 +53,7 @@ const ProjectDeleter = {
|
|||
{ project_id },
|
||||
'removing flag marking project as deleted by external data source'
|
||||
)
|
||||
const conditions = { _id: project_id.toString() }
|
||||
const conditions = { _id: project_id }
|
||||
const update = { deletedByExternalDataSource: false }
|
||||
return Project.update(conditions, update, {}, callback)
|
||||
},
|
||||
|
@ -80,78 +81,43 @@ const ProjectDeleter = {
|
|||
})
|
||||
},
|
||||
|
||||
deleteProject(project_id, options, callback) {
|
||||
if (options == null) {
|
||||
options = {}
|
||||
}
|
||||
if (callback == null) {
|
||||
callback = function(error) {}
|
||||
}
|
||||
const data = {}
|
||||
logger.log({ project_id }, 'deleting project')
|
||||
|
||||
if (typeof options === 'function') {
|
||||
callback = options
|
||||
options = {}
|
||||
}
|
||||
|
||||
return async.waterfall(
|
||||
[
|
||||
cb =>
|
||||
Project.findOne({ _id: project_id }, (err, project) =>
|
||||
cb(err, project)
|
||||
),
|
||||
function(project, cb) {
|
||||
const deletedProject = new DeletedProject()
|
||||
deletedProject.project = project
|
||||
deletedProject.deleterData = {
|
||||
deletedAt: new Date(),
|
||||
deleterId:
|
||||
options.deleterUser != null ? options.deleterUser._id : undefined,
|
||||
deleterIpAddress: options.ipAddress
|
||||
}
|
||||
|
||||
if (project == null) {
|
||||
return callback(new Errors.NotFoundError('project not found'))
|
||||
}
|
||||
|
||||
return deletedProject.save(err => cb(err, deletedProject))
|
||||
expireDeletedProjectsAfterDuration(callback) {
|
||||
const DURATION = 90
|
||||
DeletedProject.find(
|
||||
{
|
||||
'deleterData.deletedAt': {
|
||||
$lt: new Date(moment().subtract(DURATION, 'days'))
|
||||
},
|
||||
(deletedProject, cb) =>
|
||||
documentUpdaterHandler.flushProjectToMongoAndDelete(project_id, err =>
|
||||
cb(err, deletedProject)
|
||||
),
|
||||
function(deletedProject, cb) {
|
||||
CollaboratorsHandler.getMemberIds(project_id, function(
|
||||
error,
|
||||
member_ids
|
||||
) {
|
||||
if (member_ids == null) {
|
||||
member_ids = []
|
||||
}
|
||||
return Array.from(member_ids).map(member_id =>
|
||||
tagsHandler.removeProjectFromAllTags(
|
||||
member_id,
|
||||
project_id,
|
||||
function(err) {}
|
||||
)
|
||||
)
|
||||
})
|
||||
return cb(null, deletedProject)
|
||||
}, // doesn't matter if this fails or the order it happens in
|
||||
(deletedProject, cb) =>
|
||||
Project.remove({ _id: project_id }, err => cb(err, deletedProject))
|
||||
],
|
||||
function(err, deletedProject) {
|
||||
project: {
|
||||
$ne: null
|
||||
}
|
||||
},
|
||||
function(err, deletedProjects) {
|
||||
if (err != null) {
|
||||
logger.warn({ err }, 'problem deleting project')
|
||||
logger.err({ err }, 'Problem with finding deletedProject')
|
||||
return callback(err)
|
||||
}
|
||||
logger.log(
|
||||
{ project_id },
|
||||
'successfully deleting project from user request'
|
||||
)
|
||||
return callback(null, deletedProject)
|
||||
|
||||
if (deletedProjects.length) {
|
||||
async.eachSeries(
|
||||
deletedProjects,
|
||||
function(deletedProject, cb) {
|
||||
ProjectDeleter.expireDeletedProject(
|
||||
deletedProject.deletedProjectId,
|
||||
cb
|
||||
)
|
||||
},
|
||||
function(err) {
|
||||
if (err != null) {
|
||||
logger.err({ err })
|
||||
}
|
||||
callback(err)
|
||||
}
|
||||
)
|
||||
} else {
|
||||
logger.log({}, 'No deleted projects for duration were found')
|
||||
callback(err)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
|
@ -190,10 +156,151 @@ const ProjectDeleter = {
|
|||
}
|
||||
}
|
||||
|
||||
// Async methods
|
||||
|
||||
async function deleteProject(project_id, options = {}) {
|
||||
logger.log({ project_id }, 'deleting project')
|
||||
|
||||
try {
|
||||
let project = await Project.findOne({ _id: project_id }).exec()
|
||||
if (!project) {
|
||||
throw new Errors.NotFoundError('project not found')
|
||||
}
|
||||
|
||||
await DeletedProject.create({
|
||||
project: project,
|
||||
deleterData: {
|
||||
deletedAt: new Date(),
|
||||
deleterId:
|
||||
options.deleterUser != null ? options.deleterUser._id : undefined,
|
||||
deleterIpAddress: options.ipAddress,
|
||||
deletedProjectId: project._id,
|
||||
deletedProjectOwnerId: project.owner_ref,
|
||||
deletedProjectCollaboratorIds: project.collaberator_refs,
|
||||
deletedProjectReadOnlyIds: project.readOnly_refs,
|
||||
deletedProjectReadWriteTokenAccessIds:
|
||||
project.tokenAccessReadAndWrite_refs,
|
||||
deletedProjectReadOnlyTokenAccessIds: project.tokenAccessReadOnly_refs,
|
||||
deletedProjectReadWriteToken: project.tokens.readAndWrite,
|
||||
deletedProjectReadOnlyToken: project.tokens.readOnly,
|
||||
deletedProjectLastUpdatedAt: project.lastUpdated
|
||||
}
|
||||
})
|
||||
|
||||
const flushProjectToMongoAndDelete = promisify(
|
||||
DocumentUpdaterHandler.flushProjectToMongoAndDelete
|
||||
)
|
||||
await flushProjectToMongoAndDelete(project_id)
|
||||
|
||||
const getMemberIds = promisify(CollaboratorsHandler.getMemberIds)
|
||||
let member_ids = await getMemberIds(project_id)
|
||||
|
||||
// fire these jobs in the background
|
||||
Array.from(member_ids).forEach(member_id =>
|
||||
TagsHandler.removeProjectFromAllTags(member_id, project_id, () => {})
|
||||
)
|
||||
|
||||
await Project.remove({ _id: project_id }).exec()
|
||||
} catch (err) {
|
||||
logger.warn({ err }, 'problem deleting project')
|
||||
throw err
|
||||
}
|
||||
|
||||
logger.log({ project_id }, 'successfully deleted project')
|
||||
}
|
||||
|
||||
async function undeleteProject(project_id) {
|
||||
let deletedProject = await DeletedProject.findOne({
|
||||
'deleterData.deletedProjectId': project_id
|
||||
}).exec()
|
||||
|
||||
if (!deletedProject) {
|
||||
throw Errors.NotFoundError('project_not_found')
|
||||
}
|
||||
|
||||
if (!deletedProject.project) {
|
||||
throw Errors.NotFoundError('project_too_old_to_restore')
|
||||
}
|
||||
|
||||
let restored = new Project(deletedProject.project)
|
||||
|
||||
// if we're undeleting, we want the document to show up
|
||||
const generateUniqueName = promisify(ProjectDetailsHandler.generateUniqueName)
|
||||
restored.name = await generateUniqueName(
|
||||
deletedProject.deleterData.deletedProjectOwnerId,
|
||||
restored.name + ' (Restored)'
|
||||
)
|
||||
restored.archived = undefined
|
||||
|
||||
// we can't use Mongoose to re-insert the project, as it won't
|
||||
// create a new document with an _id already specified. We need to
|
||||
// insert it directly into the collection
|
||||
|
||||
// db.projects.insert doesn't work with promisify
|
||||
await new Promise((resolve, reject) => {
|
||||
db.projects.insert(restored, err => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
await DeletedProject.deleteOne({ _id: deletedProject._id }).exec()
|
||||
}
|
||||
|
||||
async function expireDeletedProject(projectId) {
|
||||
try {
|
||||
const deletedProject = await DeletedProject.findOne({
|
||||
'deleterData.deletedProjectId': projectId
|
||||
}).exec()
|
||||
if (!deletedProject) {
|
||||
throw new Errors.NotFoundError(
|
||||
`No deleted project found for project id ${projectId}`
|
||||
)
|
||||
}
|
||||
if (!deletedProject.project) {
|
||||
logger.warn(
|
||||
{ projectId },
|
||||
`Attempted to expire already-expired deletedProject`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const destroyProject = promisify(DocstoreManager.destroyProject)
|
||||
await destroyProject(deletedProject.project._id)
|
||||
|
||||
await DeletedProject.update(
|
||||
{
|
||||
_id: deletedProject._id
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'deleterData.deleterIpAddress': null,
|
||||
project: null
|
||||
}
|
||||
}
|
||||
).exec()
|
||||
|
||||
logger.log({ projectId }, 'Successfully expired deleted project')
|
||||
} catch (error) {
|
||||
logger.warn({ projectId, error }, 'error expiring deleted project')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Exported class
|
||||
|
||||
const promises = {
|
||||
deleteProject: promisify(ProjectDeleter.deleteProject)
|
||||
deleteProject: deleteProject,
|
||||
undeleteProject: undeleteProject,
|
||||
expireDeletedProject: expireDeletedProject,
|
||||
deleteUsersProjects: promisify(ProjectDeleter.deleteUsersProjects)
|
||||
}
|
||||
|
||||
ProjectDeleter.promises = promises
|
||||
ProjectDeleter.deleteProject = callbackify(deleteProject)
|
||||
ProjectDeleter.undeleteProject = callbackify(undeleteProject)
|
||||
ProjectDeleter.expireDeletedProject = callbackify(expireDeletedProject)
|
||||
|
||||
module.exports = ProjectDeleter
|
||||
|
|
|
@ -22,6 +22,7 @@ const async = require('async')
|
|||
const { Project } = require('../../models/Project')
|
||||
const logger = require('logger-sharelatex')
|
||||
const LockManager = require('../../infrastructure/LockManager')
|
||||
const { DeletedProject } = require('../../models/DeletedProject')
|
||||
|
||||
module.exports = ProjectGetter = {
|
||||
EXCLUDE_DEPTH: 8,
|
||||
|
@ -206,6 +207,15 @@ module.exports = ProjectGetter = {
|
|||
}
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
getUsersDeletedProjects(user_id, callback) {
|
||||
DeletedProject.find(
|
||||
{
|
||||
'deleterData.deletedProjectOwnerId': user_id
|
||||
},
|
||||
callback
|
||||
)
|
||||
}
|
||||
}
|
||||
;['getProject', 'getProjectWithoutDocLines'].map(method =>
|
||||
|
|
|
@ -33,19 +33,6 @@ const EmailHandler = require('../Email/EmailHandler')
|
|||
|
||||
module.exports = UserController = {
|
||||
tryDeleteUser(req, res, next) {
|
||||
return UserController._tryDeleteUser(UserDeleter.deleteUser, req, res, next)
|
||||
},
|
||||
|
||||
trySoftDeleteUser(req, res, next) {
|
||||
return UserController._tryDeleteUser(
|
||||
UserDeleter.softDeleteUser,
|
||||
req,
|
||||
res,
|
||||
next
|
||||
)
|
||||
},
|
||||
|
||||
_tryDeleteUser(deleteMethod, req, res, next) {
|
||||
const user_id = AuthenticationController.getLoggedInUserId(req)
|
||||
const { password } = req.body
|
||||
logger.log({ user_id }, 'trying to delete user account')
|
||||
|
@ -56,25 +43,25 @@ module.exports = UserController = {
|
|||
)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
return AuthenticationManager.authenticate(
|
||||
{ _id: user_id },
|
||||
password,
|
||||
function(err, user) {
|
||||
if (err != null) {
|
||||
logger.warn(
|
||||
{ user_id },
|
||||
'error authenticating during attempt to delete account'
|
||||
)
|
||||
return next(err)
|
||||
}
|
||||
if (!user) {
|
||||
logger.err(
|
||||
{ user_id },
|
||||
'auth failed during attempt to delete account'
|
||||
)
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
return deleteMethod(user_id, function(err) {
|
||||
AuthenticationManager.authenticate({ _id: user_id }, password, function(
|
||||
err,
|
||||
user
|
||||
) {
|
||||
if (err != null) {
|
||||
logger.warn(
|
||||
{ user_id },
|
||||
'error authenticating during attempt to delete account'
|
||||
)
|
||||
return next(err)
|
||||
}
|
||||
if (!user) {
|
||||
logger.err({ user_id }, 'auth failed during attempt to delete account')
|
||||
return res.sendStatus(403)
|
||||
}
|
||||
UserDeleter.deleteUser(
|
||||
user_id,
|
||||
{ deleterUser: user, ipAddress: req.ip },
|
||||
function(err) {
|
||||
if (err != null) {
|
||||
if (err instanceof Errors.SubscriptionAdminDeletionError) {
|
||||
return res.status(422).json({ error: err.name })
|
||||
|
@ -87,7 +74,7 @@ module.exports = UserController = {
|
|||
if (typeof req.logout === 'function') {
|
||||
req.logout()
|
||||
}
|
||||
return req.session.destroy(function(err) {
|
||||
req.session.destroy(function(err) {
|
||||
if (err != null) {
|
||||
logger.warn({ err }, 'error destorying session')
|
||||
return next(err)
|
||||
|
@ -95,9 +82,9 @@ module.exports = UserController = {
|
|||
UserSessionsManager.untrackSession(user, sessionId)
|
||||
return res.sendStatus(200)
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
unsubscribe(req, res) {
|
||||
|
@ -260,6 +247,27 @@ module.exports = UserController = {
|
|||
})
|
||||
},
|
||||
|
||||
expireDeletedUser(req, res, next) {
|
||||
const userId = req.params.userId
|
||||
UserDeleter.expireDeletedUser(userId, error => {
|
||||
if (error) {
|
||||
return next(error)
|
||||
}
|
||||
|
||||
res.sendStatus(204)
|
||||
})
|
||||
},
|
||||
|
||||
expireDeletedUsersAfterDuration(req, res, next) {
|
||||
UserDeleter.expireDeletedUsersAfterDuration(error => {
|
||||
if (error) {
|
||||
return next(error)
|
||||
}
|
||||
|
||||
res.sendStatus(204)
|
||||
})
|
||||
},
|
||||
|
||||
register(req, res, next) {
|
||||
if (next == null) {
|
||||
next = function(error) {}
|
||||
|
|
|
@ -1,23 +1,9 @@
|
|||
/* eslint-disable
|
||||
camelcase,
|
||||
handle-callback-err,
|
||||
max-len,
|
||||
no-unused-vars,
|
||||
standard/no-callback-literal,
|
||||
*/
|
||||
// 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
|
||||
* DS207: Consider shorter variations of null checks
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
let UserDeleter
|
||||
const { User } = require('../../models/User')
|
||||
const { DeletedUser } = require('../../models/DeletedUser')
|
||||
const NewsletterManager = require('../Newsletter/NewsletterManager')
|
||||
const ProjectDeleter = require('../Project/ProjectDeleter')
|
||||
const ProjectDeleterPromises = require('../Project/ProjectDeleter').promises
|
||||
const logger = require('logger-sharelatex')
|
||||
const moment = require('moment')
|
||||
const SubscriptionHandler = require('../Subscription/SubscriptionHandler')
|
||||
const SubscriptionUpdater = require('../Subscription/SubscriptionUpdater')
|
||||
const SubscriptionLocator = require('../Subscription/SubscriptionLocator')
|
||||
|
@ -25,107 +11,131 @@ const UserMembershipsHandler = require('../UserMembership/UserMembershipsHandler
|
|||
const async = require('async')
|
||||
const InstitutionsAPI = require('../Institutions/InstitutionsAPI')
|
||||
const Errors = require('../Errors/Errors')
|
||||
const { db, ObjectId } = require('../../infrastructure/mongojs')
|
||||
const { promisify, callbackify } = require('util')
|
||||
|
||||
let UserDeleter
|
||||
module.exports = UserDeleter = {
|
||||
softDeleteUserForMigration(user_id, callback) {
|
||||
if (callback == null) {
|
||||
callback = function(err) {}
|
||||
}
|
||||
if (user_id == null) {
|
||||
logger.warn('user_id is null when trying to delete user')
|
||||
return callback(new Error('no user_id'))
|
||||
}
|
||||
return User.findById(user_id, function(err, user) {
|
||||
if (err != null) {
|
||||
return callback(err)
|
||||
}
|
||||
if (user == null) {
|
||||
return callback(new Errors.NotFoundError('user not found'))
|
||||
}
|
||||
return async.series(
|
||||
[
|
||||
cb => UserDeleter._ensureCanDeleteUser(user, cb),
|
||||
cb => UserDeleter._cleanupUser(user, cb),
|
||||
cb => ProjectDeleter.deleteUsersProjects(user._id, cb),
|
||||
function(cb) {
|
||||
user.deletedAt = new Date()
|
||||
return db.usersDeletedByMigration.insert(user, cb)
|
||||
},
|
||||
cb => user.remove(cb)
|
||||
],
|
||||
callback
|
||||
)
|
||||
})
|
||||
},
|
||||
deleteUser: callbackify(deleteUser),
|
||||
expireDeletedUser: callbackify(expireDeletedUser),
|
||||
ensureCanDeleteUser: callbackify(ensureCanDeleteUser),
|
||||
expireDeletedUsersAfterDuration: callbackify(expireDeletedUsersAfterDuration),
|
||||
|
||||
deleteUser(user_id, callback) {
|
||||
if (callback == null) {
|
||||
callback = function() {}
|
||||
}
|
||||
if (user_id == null) {
|
||||
logger.warn('user_id is null when trying to delete user')
|
||||
return callback(new Error('no user_id'))
|
||||
}
|
||||
return User.findById(user_id, function(err, user) {
|
||||
if (err != null) {
|
||||
return callback(err)
|
||||
}
|
||||
logger.log({ user }, 'deleting user')
|
||||
return async.series(
|
||||
[
|
||||
cb => UserDeleter._ensureCanDeleteUser(user, cb),
|
||||
cb => UserDeleter._cleanupUser(user, cb),
|
||||
cb => ProjectDeleter.deleteUsersProjects(user._id, cb),
|
||||
cb => user.remove(cb)
|
||||
],
|
||||
function(err) {
|
||||
if (err != null) {
|
||||
logger.warn(
|
||||
{ err, user_id },
|
||||
'something went wrong deleteing the user'
|
||||
)
|
||||
}
|
||||
return callback(err)
|
||||
}
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
_cleanupUser(user, callback) {
|
||||
if (user == null) {
|
||||
return callback(new Error('no user supplied'))
|
||||
}
|
||||
return async.series(
|
||||
[
|
||||
cb =>
|
||||
NewsletterManager.unsubscribe(user, function(err) {
|
||||
logger.err('Failed to unsubscribe user from newsletter', {
|
||||
user_id: user._id,
|
||||
error: err
|
||||
})
|
||||
return cb()
|
||||
}),
|
||||
cb => SubscriptionHandler.cancelSubscription(user, cb),
|
||||
cb => InstitutionsAPI.deleteAffiliations(user._id, cb),
|
||||
cb => SubscriptionUpdater.removeUserFromAllGroups(user._id, cb),
|
||||
cb => UserMembershipsHandler.removeUserFromAllEntities(user._id, cb)
|
||||
],
|
||||
callback
|
||||
)
|
||||
},
|
||||
|
||||
_ensureCanDeleteUser(user, callback) {
|
||||
return SubscriptionLocator.getUsersSubscription(user, function(
|
||||
error,
|
||||
subscription
|
||||
) {
|
||||
if (subscription != null) {
|
||||
if (!error) {
|
||||
error = new Errors.SubscriptionAdminDeletionError()
|
||||
}
|
||||
}
|
||||
return callback(error)
|
||||
})
|
||||
promises: {
|
||||
deleteUser: deleteUser,
|
||||
expireDeletedUser: expireDeletedUser,
|
||||
ensureCanDeleteUser: ensureCanDeleteUser,
|
||||
expireDeletedUsersAfterDuration: expireDeletedUsersAfterDuration
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId, options = {}) {
|
||||
if (!userId) {
|
||||
logger.warn('user_id is null when trying to delete user')
|
||||
throw new Error('no user_id')
|
||||
}
|
||||
|
||||
try {
|
||||
let user = await User.findById(userId).exec()
|
||||
logger.log({ user }, 'deleting user')
|
||||
|
||||
await UserDeleter.promises.ensureCanDeleteUser(user)
|
||||
await _createDeletedUser(user, options)
|
||||
await _cleanupUser(user)
|
||||
await ProjectDeleterPromises.deleteUsersProjects(user._id)
|
||||
await User.deleteOne({ _id: userId }).exec()
|
||||
} catch (error) {
|
||||
logger.warn({ error, userId }, 'something went wrong deleting the user')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function expireDeletedUser(userId) {
|
||||
let deletedUser = await DeletedUser.findOne({
|
||||
'deleterData.deletedUserId': userId
|
||||
}).exec()
|
||||
|
||||
deletedUser.user = undefined
|
||||
deletedUser.deleterData.deleterIpAddress = undefined
|
||||
await deletedUser.save()
|
||||
}
|
||||
|
||||
async function expireDeletedUsersAfterDuration() {
|
||||
const DURATION = 90
|
||||
let deletedUsers = await DeletedUser.find({
|
||||
'deleterData.deletedAt': {
|
||||
$lt: new Date(moment().subtract(DURATION, 'days'))
|
||||
},
|
||||
user: {
|
||||
$ne: null
|
||||
}
|
||||
}).exec()
|
||||
|
||||
if (deletedUsers.length === 0) {
|
||||
logger.log('No deleted users were found for duration')
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < deletedUsers.length; i++) {
|
||||
await UserDeleter.promises.expireDeletedUser(
|
||||
deletedUsers[i].deleterData.deletedUserId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCanDeleteUser(user) {
|
||||
await new Promise((resolve, reject) => {
|
||||
SubscriptionLocator.getUsersSubscription(user, (error, subscription) => {
|
||||
if (error) {
|
||||
return reject(error)
|
||||
}
|
||||
|
||||
if (subscription) {
|
||||
return reject(new Errors.SubscriptionAdminDeletionError())
|
||||
}
|
||||
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function _createDeletedUser(user, options) {
|
||||
await DeletedUser.create({
|
||||
user: user,
|
||||
deleterData: {
|
||||
deletedAt: new Date(),
|
||||
deleterId: options.deleterUser ? options.deleterUser._id : undefined,
|
||||
deleterIpAddress: options.ipAddress,
|
||||
deletedUserId: user._id,
|
||||
deletedUserLastLoggedIn: user.lastLoggedIn,
|
||||
deletedUserSignUpDate: user.signUpDate,
|
||||
deletedUserLoginCount: user.loginCount,
|
||||
deletedUserReferralId: user.referal_id,
|
||||
deletedUserReferredUsers: user.refered_users,
|
||||
deletedUserReferredUserCount: user.refered_user_count,
|
||||
deletedUserOverleafId: user.overleaf ? user.overleaf.id : undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function _cleanupUser(user) {
|
||||
if (user == null) {
|
||||
throw new Error('no user supplied')
|
||||
}
|
||||
|
||||
const runInSeries = promisify(async.series)
|
||||
|
||||
await runInSeries([
|
||||
cb =>
|
||||
NewsletterManager.unsubscribe(user, err => {
|
||||
logger.err('Failed to unsubscribe user from newsletter', {
|
||||
user_id: user._id,
|
||||
error: err
|
||||
})
|
||||
cb()
|
||||
}),
|
||||
cb => SubscriptionHandler.cancelSubscription(user, cb),
|
||||
cb => InstitutionsAPI.deleteAffiliations(user._id, cb),
|
||||
cb => SubscriptionUpdater.removeUserFromAllGroups(user._id, cb),
|
||||
cb => UserMembershipsHandler.removeUserFromAllEntities(user._id, cb)
|
||||
])
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
// Sanity-check the conversion and remove this comment.
|
||||
const mongoose = require('mongoose')
|
||||
const Settings = require('settings-sharelatex')
|
||||
const { ProjectSchema } = require('./Project.js')
|
||||
const { ProjectSchema } = require('./Project')
|
||||
|
||||
const { Schema } = mongoose
|
||||
const { ObjectId } = Schema
|
||||
|
@ -10,13 +10,22 @@ const { ObjectId } = Schema
|
|||
const DeleterDataSchema = new Schema({
|
||||
deleterId: { type: ObjectId, ref: 'User' },
|
||||
deleterIpAddress: { type: String },
|
||||
deletedAt: { type: Date }
|
||||
deletedAt: { type: Date },
|
||||
deletedProjectId: { type: ObjectId },
|
||||
deletedProjectOwnerId: { type: ObjectId, ref: 'User' },
|
||||
deletedProjectCollaboratorIds: [{ type: ObjectId, ref: 'User' }],
|
||||
deletedProjectReadOnlyIds: [{ type: ObjectId, ref: 'User' }],
|
||||
deletedProjectReadWriteTokenAccessIds: [{ type: ObjectId, ref: 'User' }],
|
||||
deletedProjectReadOnlyTokenAccessIds: [{ type: ObjectId, ref: 'User' }],
|
||||
deletedProjectReadWriteToken: { type: String },
|
||||
deletedProjectReadOnlyToken: { type: String },
|
||||
deletedProjectLastUpdatedAt: { type: Date }
|
||||
})
|
||||
|
||||
const DeletedProjectSchema = new Schema(
|
||||
{
|
||||
deleterData: [DeleterDataSchema],
|
||||
project: [ProjectSchema]
|
||||
deleterData: DeleterDataSchema,
|
||||
project: ProjectSchema
|
||||
},
|
||||
{ collection: 'deletedProjects' }
|
||||
)
|
||||
|
|
39
services/web/app/src/models/DeletedUser.js
Normal file
39
services/web/app/src/models/DeletedUser.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
const mongoose = require('mongoose')
|
||||
const Settings = require('settings-sharelatex')
|
||||
const { UserSchema } = require('./User')
|
||||
|
||||
const { Schema } = mongoose
|
||||
const { ObjectId } = Schema
|
||||
|
||||
const DeleterDataSchema = new Schema({
|
||||
deleterId: { type: ObjectId, ref: 'User' },
|
||||
deleterIpAddress: { type: String },
|
||||
deletedAt: { type: Date },
|
||||
deletedUserId: { type: ObjectId },
|
||||
deletedUserLastLoggedIn: { type: Date },
|
||||
deletedUserSignUpDate: { type: Date },
|
||||
deletedUserLoginCount: { type: Number },
|
||||
deletedUserReferralId: { type: String },
|
||||
deletedUserReferredUsers: [{ type: ObjectId, ref: 'User' }],
|
||||
deletedUserReferredUserCount: { type: Number },
|
||||
deletedUserOverleafId: { type: Number }
|
||||
})
|
||||
|
||||
const DeletedUserSchema = new Schema(
|
||||
{
|
||||
deleterData: DeleterDataSchema,
|
||||
user: UserSchema
|
||||
},
|
||||
{ collection: 'deletedUsers' }
|
||||
)
|
||||
|
||||
const conn = mongoose.createConnection(Settings.mongo.url, {
|
||||
server: { poolSize: Settings.mongo.poolSize || 10 },
|
||||
config: { autoIndex: false }
|
||||
})
|
||||
|
||||
const DeletedUser = conn.model('DeletedUser', DeletedUserSchema)
|
||||
|
||||
mongoose.model('DeletedUser', DeletedUserSchema)
|
||||
exports.DeletedUser = DeletedUser
|
||||
exports.DeletedUserSchema = DeletedUserSchema
|
|
@ -133,3 +133,4 @@ const User = conn.model('User', UserSchema)
|
|||
|
||||
const model = mongoose.model('User', UserSchema)
|
||||
exports.User = User
|
||||
exports.UserSchema = UserSchema
|
||||
|
|
|
@ -569,6 +569,27 @@ module.exports = class Router {
|
|||
MetaController.broadcastMetadataForDoc
|
||||
)
|
||||
|
||||
privateApiRouter.post(
|
||||
'/internal/expire-deleted-projects-after-duration',
|
||||
AuthenticationController.httpAuth,
|
||||
ProjectController.expireDeletedProjectsAfterDuration
|
||||
)
|
||||
privateApiRouter.post(
|
||||
'/internal/expire-deleted-users-after-duration',
|
||||
AuthenticationController.httpAuth,
|
||||
UserController.expireDeletedUsersAfterDuration
|
||||
)
|
||||
privateApiRouter.post(
|
||||
'/internal/project/:projectId/expire-deleted-project',
|
||||
AuthenticationController.httpAuth,
|
||||
ProjectController.expireDeletedProject
|
||||
)
|
||||
privateApiRouter.post(
|
||||
'/internal/users/:userId/expire',
|
||||
AuthenticationController.httpAuth,
|
||||
UserController.expireDeletedUser
|
||||
)
|
||||
|
||||
webRouter.get(
|
||||
'/tag',
|
||||
AuthenticationController.requireLogin(),
|
||||
|
|
38
services/web/npm-shrinkwrap.json
generated
38
services/web/npm-shrinkwrap.json
generated
|
@ -5148,6 +5148,16 @@
|
|||
"object-assign": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"create-thenable": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/create-thenable/-/create-thenable-1.0.2.tgz",
|
||||
"integrity": "sha512-yk1hts1Z13S2gtW0GAPicV928+VDY3snEi7gJf5hoU5bhruBDuD8U+bF/KwIqpK4UhWBzNkZr/53Qj+eBK5CZQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"object.omit": "~2.0.0",
|
||||
"unique-concat": "~0.2.2"
|
||||
}
|
||||
},
|
||||
"cross-spawn": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz",
|
||||
|
@ -13255,6 +13265,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"native-promise-only": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz",
|
||||
"integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==",
|
||||
"dev": true
|
||||
},
|
||||
"natural-compare": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
|
||||
|
@ -17247,12 +17263,28 @@
|
|||
"util": ">=0.10.3 <1"
|
||||
}
|
||||
},
|
||||
"sinon-as-promised": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sinon-as-promised/-/sinon-as-promised-4.0.3.tgz",
|
||||
"integrity": "sha512-9vApOPQydJD7ZFRGZGD4+6Am5NU3QqlFdpbbZbnvdOoZte8cp4dOb0ej8CHfMlyfJtbweXOnmj/TimBkfcB/GA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"create-thenable": "~1.0.0",
|
||||
"native-promise-only": "~0.8.1"
|
||||
}
|
||||
},
|
||||
"sinon-chai": {
|
||||
"version": "2.14.0",
|
||||
"resolved": "https://registry.npmjs.org/sinon-chai/-/sinon-chai-2.14.0.tgz",
|
||||
"integrity": "sha512-9stIF1utB0ywNHNT7RgiXbdmen8QDCRsrTjw+G9TgKt1Yexjiv8TOWZ6WHsTPz57Yky3DIswZvEqX8fpuHNDtQ==",
|
||||
"dev": true
|
||||
},
|
||||
"sinon-mongoose": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/sinon-mongoose/-/sinon-mongoose-2.3.0.tgz",
|
||||
"integrity": "sha512-d0rrL53wuDDs91GMCFAvQam64IpdVfkaxA4cGLTZfw1d5tTg6+F/D7F080d1n3d1gSHJBZLUf9pGpijC/x7xKQ==",
|
||||
"dev": true
|
||||
},
|
||||
"sixpack-client": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/sixpack-client/-/sixpack-client-1.0.0.tgz",
|
||||
|
@ -18817,6 +18849,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"unique-concat": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/unique-concat/-/unique-concat-0.2.2.tgz",
|
||||
"integrity": "sha512-nFT3frbsvTa9rrc71FJApPqXF8oIhVHbX3IWgObQi1mF7WrW48Ys70daL7o4evZUtmUf6Qn6WK0LbHhyO0hpXw==",
|
||||
"dev": true
|
||||
},
|
||||
"unique-string": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unique-string/-/unique-string-1.0.0.tgz",
|
||||
|
|
|
@ -166,7 +166,9 @@
|
|||
"requirejs": "^2.1.22",
|
||||
"sandboxed-module": "0.2.0",
|
||||
"sinon": "^1.17.0",
|
||||
"sinon-as-promised": "^4.0.3",
|
||||
"sinon-chai": "^2.14.0",
|
||||
"sinon-mongoose": "^2.3.0",
|
||||
"timekeeper": "",
|
||||
"translations-sharelatex": "git+https://github.com/sharelatex/translations-sharelatex.git#master",
|
||||
"webpack": "^3.10.0",
|
||||
|
|
54
services/web/scripts/backfill_deleted_project_ids.js
Normal file
54
services/web/scripts/backfill_deleted_project_ids.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
const { DeletedProject } = require('../app/src/models/DeletedProject')
|
||||
const Async = require('async')
|
||||
|
||||
DeletedProject.find(
|
||||
{},
|
||||
{ 'project._id': 1, 'project.owner_ref': 1 },
|
||||
(error, deletedProjects) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
|
||||
Async.eachLimit(
|
||||
deletedProjects,
|
||||
10,
|
||||
(deletedProject, cb) => {
|
||||
if (deletedProject.project) {
|
||||
const src = deletedProject.project
|
||||
DeletedProject.findOneAndUpdate(
|
||||
{ _id: deletedProject._id },
|
||||
{
|
||||
$set: {
|
||||
'deleterData.deletedProjectId': src._id,
|
||||
'deleterData.deletedProjectOwnerId': src.owner_ref,
|
||||
'deleterData.deletedProjectCollaboratorIds':
|
||||
src.collaberator_refs,
|
||||
'deleterData.deletedProjectReadOnlyIds': src.readOnly_refs,
|
||||
'deleterData.deletedProjectReadWriteToken': src.tokens
|
||||
? src.tokens.readAndWrite
|
||||
: undefined,
|
||||
'deleterData.deletedProjectReadOnlyToken': src.tokens
|
||||
? src.tokens.readOnly
|
||||
: undefined,
|
||||
'deleterData.deletedProjectReadWriteTokenAccessIds':
|
||||
src.tokenAccessReadOnly_refs,
|
||||
'deleterData.deletedProjectReadOnlyTokenAccessIds':
|
||||
src.tokenAccessReadAndWrite_refs,
|
||||
'deleterData.deletedProjectLastUpdatedAt': src.lastUpdated
|
||||
}
|
||||
},
|
||||
cb
|
||||
)
|
||||
} else {
|
||||
cb()
|
||||
}
|
||||
},
|
||||
err => {
|
||||
if (err) {
|
||||
throw err
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
97
services/web/scripts/backfill_migration_soft_deletions.js
Normal file
97
services/web/scripts/backfill_migration_soft_deletions.js
Normal file
|
@ -0,0 +1,97 @@
|
|||
const { DeletedProject } = require('../app/src/models/DeletedProject')
|
||||
const { DeletedUser } = require('../app/src/models/DeletedUser')
|
||||
const { db } = require('../app/src/infrastructure/mongojs')
|
||||
const pLimit = require('p-limit')
|
||||
|
||||
const CONCURRENCY = 10
|
||||
|
||||
function getCollectionContents(collection) {
|
||||
return new Promise((resolve, reject) => {
|
||||
collection.find({}).toArray((error, contents) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve(contents)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function deleteCollectionItem(collection, id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
collection.remove({ _id: id }, error => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function backfillUser(user) {
|
||||
await DeletedUser.create({
|
||||
user: user,
|
||||
deleterData: {
|
||||
deletedAt: new Date(),
|
||||
deletedUserId: user._id,
|
||||
deletedUserLastLoggedIn: user.lastLoggedIn,
|
||||
deletedUserSignUpDate: user.signUpDate,
|
||||
deletedUserLoginCount: user.loginCount,
|
||||
deletedUserReferralId: user.referal_id,
|
||||
deletedUserReferredUsers: user.refered_users,
|
||||
deletedUserReferredUserCount: user.refered_user_count,
|
||||
deletedUserOverleafId: user.overleaf ? user.overleaf.id : undefined
|
||||
}
|
||||
})
|
||||
await deleteCollectionItem(db.usersDeletedByMigration, user._id)
|
||||
}
|
||||
|
||||
async function backfillProject(project) {
|
||||
await DeletedProject.create({
|
||||
project: project,
|
||||
deleterData: {
|
||||
deletedAt: new Date(),
|
||||
deletedProjectId: project._id,
|
||||
deletedProjectOwnerId: project.owner_ref,
|
||||
deletedProjectCollaboratorIds: project.collaberator_refs,
|
||||
deletedProjectReadOnlyIds: project.readOnly_refs,
|
||||
deletedProjectReadWriteTokenAccessIds:
|
||||
project.tokenAccessReadAndWrite_refs,
|
||||
deletedProjectReadOnlyTokenAccessIds: project.tokenAccessReadOnly_refs,
|
||||
deletedProjectReadWriteToken: project.tokens
|
||||
? project.tokens.readAndWrite
|
||||
: undefined,
|
||||
deletedProjectReadOnlyToken: project.tokens
|
||||
? project.tokens.readOnly
|
||||
: undefined,
|
||||
deletedProjectLastUpdatedAt: project.lastUpdated
|
||||
}
|
||||
})
|
||||
await deleteCollectionItem(db.projectsDeletedByMigration, project._id)
|
||||
}
|
||||
|
||||
async function backfillUsers() {
|
||||
const limit = pLimit(CONCURRENCY)
|
||||
|
||||
const migrationUsers = await getCollectionContents(db.usersDeletedByMigration)
|
||||
console.log('Found ' + migrationUsers.length + ' users')
|
||||
await Promise.all(migrationUsers.map(user => limit(() => backfillUser(user))))
|
||||
}
|
||||
|
||||
async function backfillProjects() {
|
||||
const limit = pLimit(CONCURRENCY)
|
||||
|
||||
const migrationProjects = await getCollectionContents(
|
||||
db.projectsDeletedByMigration
|
||||
)
|
||||
console.log('Found ' + migrationProjects.length + ' projects')
|
||||
await Promise.all(
|
||||
migrationProjects.map(project => limit(() => backfillProject(project)))
|
||||
)
|
||||
}
|
||||
|
||||
Promise.all([backfillProjects(), backfillUsers()]).then(() => {
|
||||
console.log('Finished')
|
||||
process.exit(0)
|
||||
})
|
349
services/web/test/acceptance/src/DeletionTests.js
Normal file
349
services/web/test/acceptance/src/DeletionTests.js
Normal file
|
@ -0,0 +1,349 @@
|
|||
const User = require('./helpers/User')
|
||||
const request = require('./helpers/request')
|
||||
const async = require('async')
|
||||
const { expect } = require('chai')
|
||||
const settings = require('settings-sharelatex')
|
||||
const { db, ObjectId } = require('../../../app/src/infrastructure/mongojs')
|
||||
const { Subscription } = require('../../../app/src/models/Subscription')
|
||||
const SubscriptionViewModelBuilder = require('../../../app/src/Features/Subscription/SubscriptionViewModelBuilder')
|
||||
const MockDocstoreApi = require('./helpers/MockDocstoreApi')
|
||||
require('./helpers/MockTagsApi')
|
||||
require('./helpers/MockV1Api')
|
||||
|
||||
describe('Deleting a user', () => {
|
||||
beforeEach(done => {
|
||||
this.user = new User()
|
||||
async.series(
|
||||
[
|
||||
this.user.ensureUserExists.bind(this.user),
|
||||
this.user.login.bind(this.user),
|
||||
this.user.activateSudoMode.bind(this.user)
|
||||
],
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('Should remove the user from active users', done => {
|
||||
this.user.get((error, user) => {
|
||||
expect(error).not.to.exist
|
||||
expect(user).to.exist
|
||||
this.user.deleteUser(error => {
|
||||
expect(error).not.to.exist
|
||||
this.user.get((error, user) => {
|
||||
expect(error).not.to.exist
|
||||
expect(user).not.to.exist
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Should create a soft-deleted user', done => {
|
||||
this.user.get((error, user) => {
|
||||
expect(error).not.to.exist
|
||||
this.user.deleteUser(error => {
|
||||
expect(error).not.to.exist
|
||||
db.deletedUsers.findOne(
|
||||
{ 'user._id': user._id },
|
||||
(error, deletedUser) => {
|
||||
expect(error).not.to.exist
|
||||
expect(deletedUser).to.exist
|
||||
// it should set the 'deleterData' correctly
|
||||
expect(deletedUser.deleterData.deleterId.toString()).to.equal(
|
||||
user._id.toString()
|
||||
)
|
||||
expect(deletedUser.deleterData.deletedUserId.toString()).to.equal(
|
||||
user._id.toString()
|
||||
)
|
||||
expect(deletedUser.deleterData.deletedUserReferralId).to.equal(
|
||||
user.referal_id
|
||||
)
|
||||
// it should set the 'user' correctly
|
||||
expect(deletedUser.user._id.toString()).to.equal(
|
||||
user._id.toString()
|
||||
)
|
||||
expect(deletedUser.user.email).to.equal(user.email)
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Should fail if the user has a subscription', done => {
|
||||
Subscription.create(
|
||||
{
|
||||
admin_id: this.user._id,
|
||||
manager_ids: [this.user._id],
|
||||
planCode: 'collaborator'
|
||||
},
|
||||
error => {
|
||||
expect(error).not.to.exist
|
||||
SubscriptionViewModelBuilder.buildUsersSubscriptionViewModel(
|
||||
this.user,
|
||||
error => {
|
||||
expect(error).not.to.exist
|
||||
this.user.deleteUser(error => {
|
||||
expect(error).to.exist
|
||||
done()
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("Should delete the user's projects", done => {
|
||||
this.user.createProject('wombat', (error, projectId) => {
|
||||
expect(error).not.to.exist
|
||||
this.user.getProject(projectId, (error, project) => {
|
||||
expect(error).not.to.exist
|
||||
expect(project).to.exist
|
||||
|
||||
this.user.deleteUser(error => {
|
||||
expect(error).not.to.exist
|
||||
this.user.getProject(projectId, (error, project) => {
|
||||
expect(error).not.to.exist
|
||||
expect(project).not.to.exist
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when scrubbing the user', () => {
|
||||
beforeEach(done => {
|
||||
this.user.get((error, user) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
this.userId = user._id
|
||||
this.user.deleteUser(done)
|
||||
})
|
||||
})
|
||||
|
||||
it('Should remove the user data from mongo', done => {
|
||||
db.deletedUsers.findOne(
|
||||
{ 'deleterData.deletedUserId': this.userId },
|
||||
(error, deletedUser) => {
|
||||
expect(error).not.to.exist
|
||||
expect(deletedUser).to.exist
|
||||
expect(deletedUser.deleterData.deleterIpAddress).to.exist
|
||||
expect(deletedUser.user).to.exist
|
||||
|
||||
request.post(
|
||||
`/internal/users/${this.userId}/expire`,
|
||||
{
|
||||
auth: {
|
||||
user: settings.apis.web.user,
|
||||
pass: settings.apis.web.pass,
|
||||
sendImmediately: true
|
||||
}
|
||||
},
|
||||
(error, res) => {
|
||||
expect(error).not.to.exist
|
||||
expect(res.statusCode).to.equal(204)
|
||||
|
||||
db.deletedUsers.findOne(
|
||||
{ 'deleterData.deletedUserId': this.userId },
|
||||
(error, deletedUser) => {
|
||||
expect(error).not.to.exist
|
||||
expect(deletedUser).to.exist
|
||||
expect(deletedUser.deleterData.deleterIpAddress).not.to.exist
|
||||
expect(deletedUser.user).not.to.exist
|
||||
done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Deleting a project', () => {
|
||||
beforeEach(done => {
|
||||
this.user = new User()
|
||||
this.projectName = 'wombat'
|
||||
this.user.ensureUserExists(() => {
|
||||
this.user.login(() => {
|
||||
this.user.createProject(this.projectName, (_e, projectId) => {
|
||||
this.projectId = projectId
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Should remove the project from active projects', done => {
|
||||
this.user.getProject(this.projectId, (error, project) => {
|
||||
expect(error).not.to.exist
|
||||
expect(project).to.exist
|
||||
|
||||
this.user.deleteProject(this.projectId, error => {
|
||||
expect(error).not.to.exist
|
||||
|
||||
this.user.getProject(this.projectId, (error, project) => {
|
||||
expect(error).not.to.exist
|
||||
expect(project).not.to.exist
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('Should create a soft-deleted project', done => {
|
||||
this.user.getProject(this.projectId, (error, project) => {
|
||||
expect(error).not.to.exist
|
||||
|
||||
this.user.get((error, user) => {
|
||||
expect(error).not.to.exist
|
||||
|
||||
this.user.deleteProject(this.projectId, error => {
|
||||
expect(error).not.to.exist
|
||||
|
||||
db.deletedProjects.findOne(
|
||||
{ 'deleterData.deletedProjectId': project._id },
|
||||
(error, deletedProject) => {
|
||||
expect(error).not.to.exist
|
||||
expect(deletedProject).to.exist
|
||||
|
||||
// it should set the 'deleterData' correctly
|
||||
expect(deletedProject.deleterData.deleterId.toString()).to.equal(
|
||||
user._id.toString()
|
||||
)
|
||||
expect(
|
||||
deletedProject.deleterData.deletedProjectId.toString()
|
||||
).to.equal(project._id.toString())
|
||||
expect(
|
||||
deletedProject.deleterData.deletedProjectOwnerId.toString()
|
||||
).to.equal(user._id.toString())
|
||||
// it should set the 'user' correctly
|
||||
expect(deletedProject.project._id.toString()).to.equal(
|
||||
project._id.toString()
|
||||
)
|
||||
expect(deletedProject.project.name).to.equal(this.projectName)
|
||||
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('When the project has docs', () => {
|
||||
beforeEach(done => {
|
||||
this.user.getProject(this.projectId, (error, project) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
this.user.createDocInProject(
|
||||
this.projectId,
|
||||
project.rootFolder[0]._id,
|
||||
'potato',
|
||||
(error, docId) => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
this.docId = docId
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should mark the docs as deleted', done => {
|
||||
let doc =
|
||||
MockDocstoreApi.docs[this.projectId.toString()][this.docId.toString()]
|
||||
expect(doc).to.exist
|
||||
expect(doc.deleted).to.be.falsey
|
||||
this.user.deleteProject(this.projectId, error => {
|
||||
expect(error).not.to.exist
|
||||
let doc =
|
||||
MockDocstoreApi.docs[this.projectId.toString()][this.docId.toString()]
|
||||
expect(doc).to.exist
|
||||
expect(doc.deleted).to.be.truthy
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
describe('When the deleted project is expired', () => {
|
||||
beforeEach(done => {
|
||||
this.user.deleteProject(this.projectId, error => {
|
||||
if (error) {
|
||||
throw error
|
||||
}
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('Should destroy the docs', done => {
|
||||
expect(
|
||||
MockDocstoreApi.docs[this.projectId.toString()][this.docId.toString()]
|
||||
).to.exist
|
||||
|
||||
request.post(
|
||||
`/internal/project/${this.projectId}/expire-deleted-project`,
|
||||
{
|
||||
auth: {
|
||||
user: settings.apis.web.user,
|
||||
pass: settings.apis.web.pass,
|
||||
sendImmediately: true
|
||||
}
|
||||
},
|
||||
(error, res) => {
|
||||
expect(error).not.to.exist
|
||||
expect(res.statusCode).to.equal(200)
|
||||
|
||||
expect(MockDocstoreApi.docs[this.projectId.toString()]).not.to.exist
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('Should remove the project data from mongo', done => {
|
||||
db.deletedProjects.findOne(
|
||||
{ 'deleterData.deletedProjectId': ObjectId(this.projectId) },
|
||||
(error, deletedProject) => {
|
||||
expect(error).not.to.exist
|
||||
expect(deletedProject).to.exist
|
||||
expect(deletedProject.project).to.exist
|
||||
expect(deletedProject.deleterData.deleterIpAddress).to.exist
|
||||
expect(deletedProject.deleterData.deletedAt).to.exist
|
||||
|
||||
request.post(
|
||||
`/internal/project/${this.projectId}/expire-deleted-project`,
|
||||
{
|
||||
auth: {
|
||||
user: settings.apis.web.user,
|
||||
pass: settings.apis.web.pass,
|
||||
sendImmediately: true
|
||||
}
|
||||
},
|
||||
(error, res) => {
|
||||
expect(error).not.to.exist
|
||||
expect(res.statusCode).to.equal(200)
|
||||
|
||||
db.deletedProjects.findOne(
|
||||
{ 'deleterData.deletedProjectId': ObjectId(this.projectId) },
|
||||
(error, deletedProject) => {
|
||||
expect(error).not.to.exist
|
||||
expect(deletedProject).to.exist
|
||||
expect(deletedProject.project).not.to.exist
|
||||
expect(deletedProject.deleterData.deleterIpAddress).not.to
|
||||
.exist
|
||||
expect(deletedProject.deleterData.deletedAt).to.exist
|
||||
done()
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -1,10 +1,3 @@
|
|||
// TODO: This file was created by bulk-decaffeinate.
|
||||
// Sanity-check the conversion and remove this comment.
|
||||
/*
|
||||
* decaffeinate suggestions:
|
||||
* DS102: Remove unnecessary code created because of implicit returns
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const App = require('../../../app.js')
|
||||
require('logger-sharelatex').logger.level('error')
|
||||
|
||||
|
|
|
@ -76,6 +76,12 @@ module.exports = MockDocStoreApi = {
|
|||
}
|
||||
})
|
||||
|
||||
app.post('/project/:project_id/destroy', (req, res, next) => {
|
||||
const { project_id } = req.params
|
||||
delete this.docs[project_id]
|
||||
res.sendStatus(204)
|
||||
})
|
||||
|
||||
return app
|
||||
.listen(3016, function(error) {
|
||||
if (error != null) {
|
||||
|
|
|
@ -11,6 +11,10 @@ const MockTagsApi = {
|
|||
res.json(tags)
|
||||
})
|
||||
|
||||
app.delete('/user/:user_id/project/:project_id', (req, res) => {
|
||||
res.sendStatus(200)
|
||||
})
|
||||
|
||||
app
|
||||
.listen(3012, function(error) {
|
||||
if (error) {
|
||||
|
|
|
@ -141,6 +141,10 @@ module.exports = MockV1Api = {
|
|||
return res.sendStatus(201)
|
||||
})
|
||||
|
||||
app.delete('/api/v2/users/:userId/affiliations', (req, res, next) => {
|
||||
return res.sendStatus(201)
|
||||
})
|
||||
|
||||
app.delete(
|
||||
'/api/v2/users/:userId/affiliations/:email',
|
||||
(req, res, next) => {
|
||||
|
|
|
@ -341,6 +341,32 @@ class User {
|
|||
})
|
||||
}
|
||||
|
||||
deleteUser(callback) {
|
||||
this.getCsrfToken(error => {
|
||||
if (error) {
|
||||
return callback(error)
|
||||
}
|
||||
this.request.post(
|
||||
{
|
||||
url: '/user/delete',
|
||||
json: { password: this.password }
|
||||
},
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
return callback(err)
|
||||
}
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
return callback(
|
||||
new Error('Error received from API: ' + res.statusCode)
|
||||
)
|
||||
}
|
||||
|
||||
callback()
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
getProject(project_id, callback) {
|
||||
if (callback == null) {
|
||||
callback = function(error, project) {}
|
||||
|
@ -397,7 +423,7 @@ class User {
|
|||
}
|
||||
return this.request.delete(
|
||||
{
|
||||
url: `/project/${project_id}`
|
||||
url: `/project/${project_id}?forever=true`
|
||||
},
|
||||
function(error, response, body) {
|
||||
if (error != null) {
|
||||
|
|
6
services/web/test/unit/bootstrap.js
vendored
6
services/web/test/unit/bootstrap.js
vendored
|
@ -1,5 +1,11 @@
|
|||
const chai = require('chai')
|
||||
require('sinon')
|
||||
|
||||
// Load sinon-chai assertions so expect(stubFn).to.have.been.calledWith('abc')
|
||||
// has a nicer failure messages
|
||||
chai.use(require('sinon-chai'))
|
||||
|
||||
// add support for promises in sinon
|
||||
require('sinon-as-promised')
|
||||
// add support for mongoose in sinon
|
||||
require('sinon-mongoose')
|
||||
|
|
|
@ -581,4 +581,42 @@ describe('DocstoreManager', function() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('destroyProject', function() {
|
||||
describe('with a successful response code', function() {
|
||||
beforeEach(function() {
|
||||
this.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 204 })
|
||||
return this.DocstoreManager.destroyProject(
|
||||
this.project_id,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should call the callback', function() {
|
||||
return this.callback.called.should.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('with a failed response code', function() {
|
||||
beforeEach(function() {
|
||||
this.request.post = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null, { statusCode: 500 })
|
||||
return this.DocstoreManager.destroyProject(
|
||||
this.project_id,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should call the callback with an error', function() {
|
||||
return this.callback
|
||||
.calledWith(
|
||||
new Error('docstore api responded with non-success code: 500')
|
||||
)
|
||||
.should.equal(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,35 +1,33 @@
|
|||
/* eslint-disable
|
||||
camelcase,
|
||||
handle-callback-err,
|
||||
max-len,
|
||||
no-return-assign,
|
||||
no-unused-vars,
|
||||
no-useless-constructor,
|
||||
*/
|
||||
// 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
|
||||
* DS206: Consider reworking classes to avoid initClass
|
||||
* Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
|
||||
*/
|
||||
const should = require('chai').should()
|
||||
const modulePath = '../../../../app/src/Features/Project/ProjectDeleter'
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const sinon = require('sinon')
|
||||
const chai = require('chai')
|
||||
const { expect } = chai
|
||||
const tk = require('timekeeper')
|
||||
const moment = require('moment')
|
||||
const { Project } = require('../helpers/models/Project')
|
||||
const { DeletedProject } = require('../helpers/models/DeletedProject')
|
||||
const { ObjectId } = require('mongoose').Types
|
||||
|
||||
describe('ProjectDeleter', function() {
|
||||
beforeEach(function() {
|
||||
let DeletedProject
|
||||
this.project_id = '12312'
|
||||
describe('ProjectDeleter', () => {
|
||||
beforeEach(() => {
|
||||
tk.freeze(Date.now())
|
||||
this.project_id = ObjectId('588fffffffffffffffffffff')
|
||||
this.ip = '192.170.18.1'
|
||||
this.project = {
|
||||
_id: this.project_id,
|
||||
lastUpdated: new Date(),
|
||||
rootFolder: [],
|
||||
collaberator_refs: ['collab1', 'collab2'],
|
||||
readOnly_refs: ['readOnly1', 'readOnly2'],
|
||||
owner_ref: 'owner ref here',
|
||||
remove: sinon.stub().callsArg(0)
|
||||
tokenAccessReadAndWrite_refs: ['tokenCollab1', 'tokenCollab2'],
|
||||
tokenAccessReadOnly_refs: ['tokenReadOnly1', 'tokenReadOnly2'],
|
||||
owner_ref: ObjectId('588aaaaaaaaaaaaaaaaaaaaa'),
|
||||
tokens: {
|
||||
readOnly: 'wombat',
|
||||
readAndWrite: 'potato'
|
||||
},
|
||||
name: 'a very scientific analysis of spooky ghosts'
|
||||
}
|
||||
|
||||
this.user = {
|
||||
|
@ -38,23 +36,45 @@ describe('ProjectDeleter', function() {
|
|||
features: {}
|
||||
}
|
||||
|
||||
this.Project = {
|
||||
update: sinon.stub().callsArgWith(3),
|
||||
remove: sinon.stub().callsArgWith(1),
|
||||
findOne: sinon.stub().callsArgWith(1, null, this.project),
|
||||
find: sinon.stub().callsArgWith(1, null, [this.project]),
|
||||
applyToAllFilesRecursivly: sinon.stub()
|
||||
this.doc = {
|
||||
_id: '5bd975f54f62e803cb8a8fec',
|
||||
lines: ['a bunch of lines', 'for a sunny day', 'in London town'],
|
||||
ranges: {},
|
||||
project_id: '5cf9270b4eff6e186cf8b05e'
|
||||
}
|
||||
this.DeletedProject = DeletedProject = (function() {
|
||||
DeletedProject = class DeletedProject {
|
||||
static initClass() {
|
||||
this.prototype.save = sinon.stub().callsArgWith(0)
|
||||
|
||||
this.deletedProjects = [
|
||||
{
|
||||
_id: '5cf7f145c1401f0ca0eb1aaa',
|
||||
deleterData: {
|
||||
_id: '5cf7f145c1401f0ca0eb1aac',
|
||||
deletedAt: moment()
|
||||
.subtract(95, 'days')
|
||||
.toDate(),
|
||||
deleterId: '588f3ddae8ebc1bac07c9fa4',
|
||||
deleterIpAddress: '172.19.0.1'
|
||||
},
|
||||
project: {
|
||||
_id: '5cf9270b4eff6e186cf8b05e'
|
||||
}
|
||||
},
|
||||
{
|
||||
_id: '5cf8eb11c1401f0ca0eb1ad7',
|
||||
deleterData: {
|
||||
_id: '5b74360c0fbe57011ae9938f',
|
||||
deletedAt: moment()
|
||||
.subtract(95, 'days')
|
||||
.toDate(),
|
||||
deleterId: '588f3ddae8ebc1bac07c9fa4',
|
||||
deleterIpAddress: '172.20.0.1',
|
||||
deletedProjectId: '5cf8f95a0c87371362c23919'
|
||||
},
|
||||
project: {
|
||||
_id: '5cf8f95a0c87371362c23919'
|
||||
}
|
||||
constructor() {}
|
||||
}
|
||||
DeletedProject.initClass()
|
||||
return DeletedProject
|
||||
})()
|
||||
]
|
||||
|
||||
this.documentUpdaterHandler = {
|
||||
flushProjectToMongoAndDelete: sinon.stub().callsArgWith(1)
|
||||
}
|
||||
|
@ -71,208 +91,475 @@ describe('ProjectDeleter', function() {
|
|||
.withArgs(this.project_id)
|
||||
.yields(null, ['member-id-1', 'member-id-2'])
|
||||
}
|
||||
return (this.deleter = SandboxedModule.require(modulePath, {
|
||||
globals: {
|
||||
console: console
|
||||
},
|
||||
|
||||
this.logger = {
|
||||
err: sinon.stub(),
|
||||
log: sinon.stub(),
|
||||
warn: sinon.stub()
|
||||
}
|
||||
|
||||
this.ProjectDetailsHandler = {
|
||||
generateUniqueName: sinon.stub().yields(null, this.project.name)
|
||||
}
|
||||
|
||||
this.db = {
|
||||
projects: {
|
||||
insert: sinon.stub().yields()
|
||||
}
|
||||
}
|
||||
|
||||
this.DocstoreManager = {
|
||||
destroyProject: sinon.stub().yields()
|
||||
}
|
||||
|
||||
this.ProjectMock = sinon.mock(Project)
|
||||
this.DeletedProjectMock = sinon.mock(DeletedProject)
|
||||
|
||||
this.ProjectDeleter = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'../Editor/EditorController': this.editorController,
|
||||
'../../models/Project': { Project: this.Project },
|
||||
'../../models/DeletedProject': { DeletedProject: this.DeletedProject },
|
||||
'../../models/Project': { Project: Project },
|
||||
'../../models/DeletedProject': { DeletedProject: DeletedProject },
|
||||
'../DocumentUpdater/DocumentUpdaterHandler': this
|
||||
.documentUpdaterHandler,
|
||||
'../Tags/TagsHandler': this.TagsHandler,
|
||||
'../FileStore/FileStoreHandler': (this.FileStoreHandler = {}),
|
||||
'../Collaborators/CollaboratorsHandler': this.CollaboratorsHandler,
|
||||
'logger-sharelatex': {
|
||||
log() {}
|
||||
}
|
||||
'../Docstore/DocstoreManager': this.DocstoreManager,
|
||||
'./ProjectDetailsHandler': this.ProjectDetailsHandler,
|
||||
'../../infrastructure/mongojs': { db: this.db },
|
||||
'logger-sharelatex': this.logger
|
||||
},
|
||||
globals: {
|
||||
console: console
|
||||
}
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('mark as deleted by external source', function() {
|
||||
const project_id = 1234
|
||||
it('should update the project with the flag set to true', function(done) {
|
||||
return this.deleter.markAsDeletedByExternalSource(project_id, () => {
|
||||
const conditions = { _id: project_id }
|
||||
const update = { deletedByExternalDataSource: true }
|
||||
this.Project.update.calledWith(conditions, update).should.equal(true)
|
||||
return done()
|
||||
afterEach(() => {
|
||||
tk.reset()
|
||||
this.DeletedProjectMock.restore()
|
||||
this.ProjectMock.restore()
|
||||
})
|
||||
|
||||
describe('mark as deleted by external source', () => {
|
||||
beforeEach(() => {
|
||||
this.ProjectMock.expects('update')
|
||||
.withArgs(
|
||||
{ _id: this.project_id },
|
||||
{ deletedByExternalDataSource: true }
|
||||
)
|
||||
.yields()
|
||||
})
|
||||
|
||||
it('should update the project with the flag set to true', done => {
|
||||
this.ProjectDeleter.markAsDeletedByExternalSource(this.project_id, () => {
|
||||
this.ProjectMock.verify()
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should tell the editor controler so users are notified', function(done) {
|
||||
return this.deleter.markAsDeletedByExternalSource(project_id, () => {
|
||||
it('should tell the editor controler so users are notified', done => {
|
||||
this.ProjectDeleter.markAsDeletedByExternalSource(this.project_id, () => {
|
||||
this.editorController.notifyUsersProjectHasBeenDeletedOrRenamed
|
||||
.calledWith(project_id)
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('unmarkAsDeletedByExternalSource', function() {
|
||||
beforeEach(function() {
|
||||
this.Project.update = sinon.stub().callsArg(3)
|
||||
this.callback = sinon.stub()
|
||||
this.project = {
|
||||
_id: this.project_id
|
||||
}
|
||||
return this.deleter.unmarkAsDeletedByExternalSource(
|
||||
this.project_id,
|
||||
this.callback
|
||||
)
|
||||
})
|
||||
|
||||
it('should remove the flag from the project', function() {
|
||||
return this.Project.update
|
||||
.calledWith(
|
||||
describe('unmarkAsDeletedByExternalSource', done => {
|
||||
beforeEach(() => {
|
||||
this.ProjectMock.expects('update')
|
||||
.withArgs(
|
||||
{ _id: this.project_id },
|
||||
{ deletedByExternalDataSource: false }
|
||||
)
|
||||
.should.equal(true)
|
||||
.yields()
|
||||
this.ProjectDeleter.unmarkAsDeletedByExternalSource(this.project_id, done)
|
||||
})
|
||||
|
||||
it('should remove the flag from the project', () => {
|
||||
this.ProjectMock.verify()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteUsersProjects', function() {
|
||||
beforeEach(function() {
|
||||
return (this.deleter.deleteProject = sinon.stub().callsArg(1))
|
||||
describe('deleteUsersProjects', () => {
|
||||
beforeEach(() => {
|
||||
this.ProjectMock.expects('find')
|
||||
.withArgs({ owner_ref: this.user._id })
|
||||
.yields(null, [{ _id: 'wombat' }, { _id: 'potato' }])
|
||||
|
||||
this.ProjectDeleter.deleteProject = sinon.stub().yields()
|
||||
})
|
||||
|
||||
it('should find all the projects owned by the user_id', function(done) {
|
||||
return this.deleter.deleteUsersProjects(this.user._id, () => {
|
||||
sinon.assert.calledWith(this.Project.find, { owner_ref: this.user._id })
|
||||
return done()
|
||||
it('should find all the projects owned by the user_id', done => {
|
||||
this.ProjectDeleter.deleteUsersProjects(this.user._id, () => {
|
||||
this.ProjectMock.verify()
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call deleteProject on the found projects', function(done) {
|
||||
return this.deleter.deleteUsersProjects(this.user._id, () => {
|
||||
sinon.assert.calledWith(this.deleter.deleteProject, this.project._id)
|
||||
return done()
|
||||
it('should call deleteProject once for each project', done => {
|
||||
this.ProjectDeleter.deleteUsersProjects(this.user._id, () => {
|
||||
sinon.assert.calledTwice(this.ProjectDeleter.deleteProject)
|
||||
sinon.assert.calledWith(this.ProjectDeleter.deleteProject, 'wombat')
|
||||
sinon.assert.calledWith(this.ProjectDeleter.deleteProject, 'potato')
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should call deleteProject once for each project', function(done) {
|
||||
this.Project.find.callsArgWith(1, null, [
|
||||
{ _id: 'potato' },
|
||||
{ _id: 'wombat' }
|
||||
])
|
||||
return this.deleter.deleteUsersProjects(this.user._id, () => {
|
||||
sinon.assert.calledTwice(this.deleter.deleteProject)
|
||||
sinon.assert.calledWith(this.deleter.deleteProject, 'wombat')
|
||||
sinon.assert.calledWith(this.deleter.deleteProject, 'potato')
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove all the projects the user is a collaborator of', function(done) {
|
||||
return this.deleter.deleteUsersProjects(this.user._id, () => {
|
||||
this.CollaboratorsHandler.removeUserFromAllProjets
|
||||
.calledWith(this.user._id)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
it('should remove all the projects the user is a collaborator of', done => {
|
||||
this.ProjectDeleter.deleteUsersProjects(this.user._id, () => {
|
||||
sinon.assert.calledWith(
|
||||
this.CollaboratorsHandler.removeUserFromAllProjets,
|
||||
this.user._id
|
||||
)
|
||||
sinon.assert.calledOnce(
|
||||
this.CollaboratorsHandler.removeUserFromAllProjets
|
||||
)
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteProject', function() {
|
||||
beforeEach(function() {
|
||||
this.project_id = 'mock-project-id-123'
|
||||
return (this.ip = '192.170.18.1')
|
||||
describe('deleteProject', () => {
|
||||
beforeEach(() => {
|
||||
this.deleterData = {
|
||||
deletedAt: new Date(),
|
||||
deletedProjectId: this.project._id,
|
||||
deletedProjectOwnerId: this.project.owner_ref,
|
||||
deletedProjectCollaboratorIds: this.project.collaberator_refs,
|
||||
deletedProjectReadOnlyIds: this.project.readOnly_refs,
|
||||
deletedProjectReadWriteTokenAccessIds: this.project
|
||||
.tokenAccessReadAndWrite_refs,
|
||||
deletedProjectReadOnlyTokenAccessIds: this.project
|
||||
.tokenAccessReadOnly_refs,
|
||||
deletedProjectReadWriteToken: this.project.tokens.readAndWrite,
|
||||
deletedProjectReadOnlyToken: this.project.tokens.readOnly,
|
||||
deletedProjectLastUpdatedAt: this.project.lastUpdated
|
||||
}
|
||||
|
||||
this.ProjectMock.expects('findOne')
|
||||
.withArgs({ _id: this.project_id })
|
||||
.chain('exec')
|
||||
.resolves(this.project)
|
||||
})
|
||||
|
||||
it('should save a DeletedProject with additional deleterData', function(done) {
|
||||
return this.deleter.deleteProject(
|
||||
it('should save a DeletedProject with additional deleterData', done => {
|
||||
this.deleterData.deleterIpAddress = this.ip
|
||||
this.deleterData.deleterId = this.user._id
|
||||
|
||||
this.ProjectMock.expects('remove')
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
this.DeletedProjectMock.expects('create')
|
||||
.withArgs({
|
||||
project: this.project,
|
||||
deleterData: this.deleterData
|
||||
})
|
||||
.resolves()
|
||||
|
||||
this.ProjectDeleter.deleteProject(
|
||||
this.project_id,
|
||||
{ deleterUser: this.user, ipAddress: this.ip },
|
||||
(err, deletedProject) => {
|
||||
this.DeletedProject.prototype.save.called.should.equal(true)
|
||||
deletedProject.deleterData.deleterIpAddress.should.equal(this.ip)
|
||||
deletedProject.deleterData.deleterId.should.equal(this.user._id)
|
||||
return done()
|
||||
err => {
|
||||
expect(err).not.to.exist
|
||||
this.DeletedProjectMock.verify()
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should flushProjectToMongoAndDelete in doc updater', function(done) {
|
||||
return this.deleter.deleteProject(
|
||||
it('should flushProjectToMongoAndDelete in doc updater', done => {
|
||||
this.ProjectMock.expects('remove')
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
this.DeletedProjectMock.expects('create').resolves()
|
||||
|
||||
this.ProjectDeleter.deleteProject(
|
||||
this.project_id,
|
||||
{ deleterUser: this.user, ipAddress: this.ip },
|
||||
() => {
|
||||
this.documentUpdaterHandler.flushProjectToMongoAndDelete
|
||||
.calledWith(this.project_id)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('should removeProjectFromAllTags', function(done) {
|
||||
return this.deleter.deleteProject(this.project_id, () => {
|
||||
this.TagsHandler.removeProjectFromAllTags
|
||||
.calledWith('member-id-1', this.project_id)
|
||||
.should.equal(true)
|
||||
this.TagsHandler.removeProjectFromAllTags
|
||||
.calledWith('member-id-2', this.project_id)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
it('should removeProjectFromAllTags', done => {
|
||||
this.ProjectMock.expects('remove')
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
this.DeletedProjectMock.expects('create').resolves()
|
||||
|
||||
this.ProjectDeleter.deleteProject(this.project_id, () => {
|
||||
sinon.assert.calledWith(
|
||||
this.TagsHandler.removeProjectFromAllTags,
|
||||
'member-id-1',
|
||||
this.project_id
|
||||
)
|
||||
sinon.assert.calledWith(
|
||||
this.TagsHandler.removeProjectFromAllTags,
|
||||
'member-id-2',
|
||||
this.project_id
|
||||
)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove the project from Mongo', function(done) {
|
||||
return this.deleter.deleteProject(this.project_id, () => {
|
||||
this.Project.remove
|
||||
.calledWith({
|
||||
it('should remove the project from Mongo', done => {
|
||||
this.ProjectMock.expects('remove')
|
||||
.withArgs({ _id: this.project_id })
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
this.DeletedProjectMock.expects('create').resolves()
|
||||
|
||||
this.ProjectDeleter.deleteProject(this.project_id, () => {
|
||||
this.ProjectMock.verify()
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('expireDeletedProjectsAfterDuration', () => {
|
||||
beforeEach(done => {
|
||||
this.ProjectDeleter.expireDeletedProject = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, null)
|
||||
|
||||
this.DeletedProjectMock.expects('find')
|
||||
.withArgs({
|
||||
'deleterData.deletedAt': {
|
||||
$lt: new Date(moment().subtract(90, 'days'))
|
||||
},
|
||||
project: {
|
||||
$ne: null
|
||||
}
|
||||
})
|
||||
.yields(null, this.deletedProjects)
|
||||
|
||||
this.ProjectDeleter.expireDeletedProjectsAfterDuration(done)
|
||||
})
|
||||
|
||||
it('should call find with a date 90 days earlier than today', () => {
|
||||
this.DeletedProjectMock.verify()
|
||||
})
|
||||
|
||||
it('should call expireDeletedProject', done => {
|
||||
expect(this.ProjectDeleter.expireDeletedProject).to.have.been.calledWith(
|
||||
this.deletedProjects[0].deletedProjectId
|
||||
)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
describe('expireDeletedProject', () => {
|
||||
beforeEach(done => {
|
||||
this.DeletedProjectMock.expects('update')
|
||||
.withArgs(
|
||||
{
|
||||
_id: this.deletedProjects[0]._id
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
'deleterData.deleterIpAddress': null,
|
||||
project: null
|
||||
}
|
||||
}
|
||||
)
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
|
||||
this.DeletedProjectMock.expects('findOne')
|
||||
.withArgs({
|
||||
'deleterData.deletedProjectId': this.deletedProjects[0].project._id
|
||||
})
|
||||
.chain('exec')
|
||||
.resolves(this.deletedProjects[0])
|
||||
|
||||
this.ProjectDeleter.expireDeletedProject(
|
||||
this.deletedProjects[0].project._id,
|
||||
done
|
||||
)
|
||||
})
|
||||
|
||||
it('should find the specified deletedProject and remove its project and ip address', () => {
|
||||
this.DeletedProjectMock.verify()
|
||||
})
|
||||
|
||||
it('should destroy the docs in docstore', () => {
|
||||
expect(this.DocstoreManager.destroyProject).to.have.been.calledWith(
|
||||
this.deletedProjects[0].project._id
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('archiveProject', () => {
|
||||
beforeEach(() => {
|
||||
this.ProjectMock.expects('update')
|
||||
.withArgs(
|
||||
{
|
||||
_id: this.project_id
|
||||
},
|
||||
{
|
||||
$set: { archived: true }
|
||||
}
|
||||
)
|
||||
.yields()
|
||||
})
|
||||
|
||||
it('should update the project', done => {
|
||||
this.ProjectDeleter.archiveProject(this.project_id, () => {
|
||||
this.ProjectMock.verify()
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreProject', () => {
|
||||
beforeEach(() => {
|
||||
this.ProjectMock.expects('update')
|
||||
.withArgs(
|
||||
{
|
||||
_id: this.project_id
|
||||
},
|
||||
{
|
||||
$unset: { archived: true }
|
||||
}
|
||||
)
|
||||
.yields()
|
||||
})
|
||||
|
||||
it('should unset the archive attribute', done => {
|
||||
this.ProjectDeleter.restoreProject(this.project_id, () => {
|
||||
this.ProjectMock.verify()
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('undeleteProject', () => {
|
||||
beforeEach(() => {
|
||||
this.deletedProject = {
|
||||
_id: 'deleted',
|
||||
project: this.project,
|
||||
deleterData: {
|
||||
deletedProjectId: this.project._id,
|
||||
deletedProjectOwnerId: this.project.owner_ref
|
||||
}
|
||||
}
|
||||
this.purgedProject = {
|
||||
_id: 'purged',
|
||||
deleterData: {
|
||||
deletedProjectId: 'purgedProject',
|
||||
deletedProjectOwnerId: 'potato'
|
||||
}
|
||||
}
|
||||
|
||||
this.DeletedProjectMock.expects('findOne')
|
||||
.withArgs({ 'deleterData.deletedProjectId': this.project._id })
|
||||
.chain('exec')
|
||||
.resolves(this.deletedProject)
|
||||
this.DeletedProjectMock.expects('findOne')
|
||||
.withArgs({ 'deleterData.deletedProjectId': 'purgedProject' })
|
||||
.chain('exec')
|
||||
.resolves(this.purgedProject)
|
||||
this.DeletedProjectMock.expects('findOne')
|
||||
.withArgs({ 'deleterData.deletedProjectId': 'wombat' })
|
||||
.chain('exec')
|
||||
.resolves(null)
|
||||
this.DeletedProjectMock.expects('deleteOne')
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
})
|
||||
|
||||
it('should return not found if the project does not exist', done => {
|
||||
this.ProjectDeleter.undeleteProject('wombat', err => {
|
||||
expect(err).to.exist
|
||||
expect(err.name).to.equal('NotFoundError')
|
||||
expect(err.message).to.equal('project_not_found')
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should return not found if the project has been expired', done => {
|
||||
this.ProjectDeleter.undeleteProject('purgedProject', err => {
|
||||
expect(err.name).to.equal('NotFoundError')
|
||||
expect(err.message).to.equal('project_too_old_to_restore')
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should insert the project into the collection', done => {
|
||||
this.ProjectDeleter.undeleteProject(this.project._id, err => {
|
||||
expect(err).not.to.exist
|
||||
sinon.assert.calledWith(
|
||||
this.db.projects.insert,
|
||||
sinon.match({
|
||||
_id: this.project._id,
|
||||
name: this.project.name
|
||||
})
|
||||
.should.equal(true)
|
||||
return done()
|
||||
)
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('archiveProject', function() {
|
||||
beforeEach(function() {
|
||||
return this.Project.update.callsArgWith(2)
|
||||
})
|
||||
it('should remove the DeletedProject', done => {
|
||||
// need to change the mock just to include the methods we want
|
||||
this.DeletedProjectMock.restore()
|
||||
this.DeletedProjectMock = sinon.mock(DeletedProject)
|
||||
this.DeletedProjectMock.expects('findOne')
|
||||
.withArgs({ 'deleterData.deletedProjectId': this.project._id })
|
||||
.chain('exec')
|
||||
.resolves(this.deletedProject)
|
||||
this.DeletedProjectMock.expects('deleteOne')
|
||||
.withArgs({ _id: 'deleted' })
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
|
||||
it('should update the project', function(done) {
|
||||
return this.deleter.archiveProject(this.project_id, () => {
|
||||
this.Project.update
|
||||
.calledWith(
|
||||
{
|
||||
_id: this.project_id
|
||||
},
|
||||
{
|
||||
$set: { archived: true }
|
||||
}
|
||||
)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
this.ProjectDeleter.undeleteProject(this.project._id, err => {
|
||||
expect(err).not.to.exist
|
||||
this.DeletedProjectMock.verify()
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreProject', function() {
|
||||
beforeEach(function() {
|
||||
return this.Project.update.callsArgWith(2)
|
||||
it('should clear the archive bit', done => {
|
||||
this.project.archived = true
|
||||
this.ProjectDeleter.undeleteProject(this.project._id, err => {
|
||||
expect(err).not.to.exist
|
||||
sinon.assert.calledWith(
|
||||
this.db.projects.insert,
|
||||
sinon.match({ archived: undefined })
|
||||
)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should unset the archive attribute', function(done) {
|
||||
return this.deleter.restoreProject(this.project_id, () => {
|
||||
this.Project.update
|
||||
.calledWith(
|
||||
{
|
||||
_id: this.project_id
|
||||
},
|
||||
{
|
||||
$unset: { archived: true }
|
||||
}
|
||||
)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
it('should generate a unique name for the project', done => {
|
||||
this.ProjectDeleter.undeleteProject(this.project._id, err => {
|
||||
expect(err).not.to.exist
|
||||
sinon.assert.calledWith(
|
||||
this.ProjectDetailsHandler.generateUniqueName,
|
||||
this.project.owner_ref
|
||||
)
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should add a suffix to the project name', done => {
|
||||
this.ProjectDeleter.undeleteProject(this.project._id, err => {
|
||||
expect(err).not.to.exist
|
||||
sinon.assert.calledWith(
|
||||
this.ProjectDetailsHandler.generateUniqueName,
|
||||
this.project.owner_ref,
|
||||
this.project.name + ' (Restored)'
|
||||
)
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -23,6 +23,10 @@ const { assert } = require('chai')
|
|||
describe('ProjectGetter', function() {
|
||||
beforeEach(function() {
|
||||
this.callback = sinon.stub()
|
||||
this.deletedProject = { deleterData: { wombat: 'potato' } }
|
||||
this.DeletedProject = {
|
||||
find: sinon.stub().yields(null, [this.deletedProject])
|
||||
}
|
||||
return (this.ProjectGetter = SandboxedModule.require(modulePath, {
|
||||
globals: {
|
||||
console: console
|
||||
|
@ -41,6 +45,9 @@ describe('ProjectGetter', function() {
|
|||
'../../models/Project': {
|
||||
Project: (this.Project = {})
|
||||
},
|
||||
'../../models/DeletedProject': {
|
||||
DeletedProject: this.DeletedProject
|
||||
},
|
||||
'../Collaborators/CollaboratorsHandler': (this.CollaboratorsHandler = {}),
|
||||
'../../infrastructure/LockManager': (this.LockManager = {
|
||||
runWithLock: sinon.spy((namespace, id, runner, callback) =>
|
||||
|
@ -385,4 +392,28 @@ describe('ProjectGetter', function() {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getUsersDeletedProjects', function() {
|
||||
it('should look up the deleted projects by deletedProjectOwnerId', function(done) {
|
||||
this.ProjectGetter.getUsersDeletedProjects('giraffe', err => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
sinon.assert.calledWith(this.DeletedProject.find, {
|
||||
'deleterData.deletedProjectOwnerId': 'giraffe'
|
||||
})
|
||||
done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should pass the found projects to the callback', function(done) {
|
||||
this.ProjectGetter.getUsersDeletedProjects('giraffe', (err, docs) => {
|
||||
if (err) {
|
||||
return done(err)
|
||||
}
|
||||
expect(docs).to.deep.equal([this.deletedProject])
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
@ -48,7 +48,7 @@ describe('UserController', function() {
|
|||
}
|
||||
}
|
||||
|
||||
this.UserDeleter = { deleteUser: sinon.stub().callsArgWith(1) }
|
||||
this.UserDeleter = { deleteUser: sinon.stub().yields() }
|
||||
this.UserGetter = { getUser: sinon.stub().callsArgWith(1, null, this.user) }
|
||||
this.User = { findById: sinon.stub().callsArgWith(1, null, this.user) }
|
||||
this.NewsLetterManager = { unsubscribe: sinon.stub().callsArgWith(1) }
|
||||
|
@ -132,7 +132,6 @@ describe('UserController', function() {
|
|||
this.AuthenticationManager.authenticate = sinon
|
||||
.stub()
|
||||
.callsArgWith(2, null, this.user)
|
||||
return (this.UserDeleter.deleteUser = sinon.stub().callsArgWith(1, null))
|
||||
})
|
||||
|
||||
it('should send 200', function(done) {
|
||||
|
@ -214,7 +213,7 @@ describe('UserController', function() {
|
|||
beforeEach(function() {
|
||||
return (this.UserDeleter.deleteUser = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, new Error('woops')))
|
||||
.yields(new Error('woops')))
|
||||
})
|
||||
|
||||
it('should call next with an error', function(done) {
|
||||
|
|
|
@ -1,328 +1,466 @@
|
|||
/* eslint-disable
|
||||
handle-callback-err,
|
||||
max-len,
|
||||
no-return-assign,
|
||||
*/
|
||||
// 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 sinon = require('sinon')
|
||||
const chai = require('chai')
|
||||
const should = chai.should()
|
||||
const { expect } = chai
|
||||
const modulePath = '../../../../app/src/Features/User/UserDeleter.js'
|
||||
const sinon = require('sinon')
|
||||
const tk = require('timekeeper')
|
||||
const moment = require('moment')
|
||||
const SandboxedModule = require('sandboxed-module')
|
||||
const Errors = require('../../../../app/src/Features/Errors/Errors')
|
||||
const ObjectId = require('mongoose').Types.ObjectId
|
||||
const { User } = require('../helpers/models/User')
|
||||
const { DeletedUser } = require('../helpers/models/DeletedUser')
|
||||
|
||||
describe('UserDeleter', function() {
|
||||
beforeEach(function() {
|
||||
this.user = {
|
||||
_id: '12390i',
|
||||
email: 'bob@bob.com',
|
||||
remove: sinon.stub().callsArgWith(0)
|
||||
}
|
||||
const expect = chai.expect
|
||||
|
||||
this.User = { findById: sinon.stub().callsArgWith(1, null, this.user) }
|
||||
const modulePath = '../../../../app/src/Features/User/UserDeleter.js'
|
||||
|
||||
this.NewsletterManager = { unsubscribe: sinon.stub().callsArgWith(1) }
|
||||
describe('UserDeleter', () => {
|
||||
beforeEach(() => {
|
||||
tk.freeze(Date.now())
|
||||
|
||||
this.ProjectDeleter = { deleteUsersProjects: sinon.stub().callsArgWith(1) }
|
||||
this.userId = ObjectId()
|
||||
|
||||
this.SubscriptionHandler = {
|
||||
cancelSubscription: sinon.stub().callsArgWith(1)
|
||||
}
|
||||
this.UserMock = sinon.mock(User)
|
||||
this.DeletedUserMock = sinon.mock(DeletedUser)
|
||||
|
||||
this.SubscriptionUpdater = {
|
||||
removeUserFromAllGroups: sinon.stub().callsArgWith(1)
|
||||
}
|
||||
|
||||
this.SubscriptionLocator = {
|
||||
getUsersSubscription: sinon.stub().yields(null, null)
|
||||
}
|
||||
|
||||
this.UserMembershipsHandler = {
|
||||
removeUserFromAllEntities: sinon.stub().callsArgWith(1)
|
||||
}
|
||||
|
||||
this.deleteAffiliations = sinon.stub().callsArgWith(1)
|
||||
|
||||
this.mongojs = {
|
||||
db: {
|
||||
deletedUsers: {
|
||||
insert: sinon.stub().callsArg(1)
|
||||
this.mockedUser = sinon.mock(
|
||||
new User({
|
||||
_id: this.userId,
|
||||
email: 'bob@bob.com',
|
||||
lastLoggedIn: Date.now() + 1000,
|
||||
signUpDate: Date.now() + 2000,
|
||||
loginCount: 10,
|
||||
overleaf: {
|
||||
id: 1234
|
||||
},
|
||||
usersDeletedByMigration: {
|
||||
insert: sinon.stub().callsArg(1)
|
||||
}
|
||||
refered_users: ['wombat', 'potato'],
|
||||
refered_user_count: 2,
|
||||
referal_id: ['giraffe']
|
||||
})
|
||||
)
|
||||
this.user = this.mockedUser.object
|
||||
|
||||
this.NewsletterManager = { unsubscribe: sinon.stub().yields() }
|
||||
|
||||
this.ProjectDeleter = {
|
||||
promises: {
|
||||
deleteUsersProjects: sinon.stub().resolves()
|
||||
}
|
||||
}
|
||||
|
||||
return (this.UserDeleter = SandboxedModule.require(modulePath, {
|
||||
globals: {
|
||||
console: console
|
||||
},
|
||||
this.SubscriptionHandler = {
|
||||
cancelSubscription: sinon.stub().yields()
|
||||
}
|
||||
|
||||
this.SubscriptionUpdater = {
|
||||
removeUserFromAllGroups: sinon.stub().yields()
|
||||
}
|
||||
|
||||
this.SubscriptionLocator = {
|
||||
getUsersSubscription: sinon.stub().yields()
|
||||
}
|
||||
|
||||
this.UserMembershipsHandler = {
|
||||
removeUserFromAllEntities: sinon.stub().yields()
|
||||
}
|
||||
|
||||
this.InstitutionsApi = {
|
||||
deleteAffiliations: sinon.stub().yields()
|
||||
}
|
||||
|
||||
this.UserDeleter = SandboxedModule.require(modulePath, {
|
||||
requires: {
|
||||
'../../models/User': {
|
||||
User: this.User
|
||||
},
|
||||
'../../models/User': { User: User },
|
||||
'../../models/DeletedUser': { DeletedUser: DeletedUser },
|
||||
'../Newsletter/NewsletterManager': this.NewsletterManager,
|
||||
'../Subscription/SubscriptionHandler': this.SubscriptionHandler,
|
||||
'../Subscription/SubscriptionUpdater': this.SubscriptionUpdater,
|
||||
'../Subscription/SubscriptionLocator': this.SubscriptionLocator,
|
||||
'../UserMembership/UserMembershipsHandler': this.UserMembershipsHandler,
|
||||
'../Project/ProjectDeleter': this.ProjectDeleter,
|
||||
'../Institutions/InstitutionsAPI': {
|
||||
deleteAffiliations: this.deleteAffiliations
|
||||
},
|
||||
'../../infrastructure/mongojs': this.mongojs,
|
||||
'../Institutions/InstitutionsAPI': this.InstitutionsApi,
|
||||
'logger-sharelatex': (this.logger = {
|
||||
log: sinon.stub(),
|
||||
warn: sinon.stub(),
|
||||
err: sinon.stub()
|
||||
}),
|
||||
'../Errors/Errors': Errors
|
||||
},
|
||||
globals: {
|
||||
console: console
|
||||
}
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('softDeleteUserForMigration', function() {
|
||||
beforeEach(function() {
|
||||
return (this.UserDeleter._ensureCanDeleteUser = sinon.stub().yields(null))
|
||||
afterEach(() => {
|
||||
this.DeletedUserMock.restore()
|
||||
this.UserMock.restore()
|
||||
this.mockedUser.restore()
|
||||
})
|
||||
|
||||
describe('deleteUser', () => {
|
||||
beforeEach(() => {
|
||||
this.UserDeleter.promises.ensureCanDeleteUser = sinon.stub().resolves()
|
||||
|
||||
this.UserMock.expects('findById')
|
||||
.withArgs(this.userId)
|
||||
.chain('exec')
|
||||
.resolves(this.user)
|
||||
})
|
||||
|
||||
it('should delete the user in mongo', function(done) {
|
||||
return this.UserDeleter.softDeleteUserForMigration(this.user._id, err => {
|
||||
this.User.findById.calledWith(this.user._id).should.equal(true)
|
||||
this.user.remove.called.should.equal(true)
|
||||
return done()
|
||||
describe('when the user can be deleted', () => {
|
||||
beforeEach(() => {
|
||||
this.deletedUser = {
|
||||
user: this.user,
|
||||
deleterData: {
|
||||
deletedAt: new Date(),
|
||||
deletedUserId: this.userId,
|
||||
deleterIpAddress: undefined,
|
||||
deleterId: undefined,
|
||||
deletedUserLastLoggedIn: this.user.lastLoggedIn,
|
||||
deletedUserSignUpDate: this.user.signUpDate,
|
||||
deletedUserLoginCount: this.user.loginCount,
|
||||
deletedUserReferralId: this.user.referal_id,
|
||||
deletedUserReferredUsers: this.user.refered_users,
|
||||
deletedUserReferredUserCount: this.user.refered_user_count,
|
||||
deletedUserOverleafId: this.user.overleaf.id
|
||||
}
|
||||
}
|
||||
|
||||
this.UserMock.expects('deleteOne')
|
||||
.withArgs({ _id: this.userId })
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
})
|
||||
})
|
||||
|
||||
it('should add the user to the deletedUsers collection', function(done) {
|
||||
return this.UserDeleter.softDeleteUserForMigration(this.user._id, err => {
|
||||
sinon.assert.calledWith(
|
||||
this.mongojs.db.usersDeletedByMigration.insert,
|
||||
this.user
|
||||
)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
describe('when no options are passed', () => {
|
||||
beforeEach(() => {
|
||||
this.DeletedUserMock.expects('create')
|
||||
.withArgs(this.deletedUser)
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
})
|
||||
|
||||
it('should set the deletedAt field on the user', function(done) {
|
||||
return this.UserDeleter.softDeleteUserForMigration(this.user._id, err => {
|
||||
this.user.deletedAt.should.exist
|
||||
return done()
|
||||
})
|
||||
})
|
||||
it('should find and the user in mongo by its id', async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
this.UserMock.verify()
|
||||
})
|
||||
|
||||
it('should unsubscribe the user from the news letter', function(done) {
|
||||
return this.UserDeleter.softDeleteUserForMigration(this.user._id, err => {
|
||||
this.NewsletterManager.unsubscribe
|
||||
.calledWith(this.user)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should unsubscribe the user', function(done) {
|
||||
return this.UserDeleter.softDeleteUserForMigration(this.user._id, err => {
|
||||
this.SubscriptionHandler.cancelSubscription
|
||||
.calledWith(this.user)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete user affiliations', function(done) {
|
||||
return this.UserDeleter.softDeleteUserForMigration(this.user._id, err => {
|
||||
this.deleteAffiliations.calledWith(this.user._id).should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete all the projects of a user', function(done) {
|
||||
return this.UserDeleter.softDeleteUserForMigration(this.user._id, err => {
|
||||
this.ProjectDeleter.deleteUsersProjects
|
||||
.calledWith(this.user._id)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove user memberships', function(done) {
|
||||
return this.UserDeleter.softDeleteUserForMigration(this.user._id, err => {
|
||||
this.UserMembershipsHandler.removeUserFromAllEntities
|
||||
.calledWith(this.user._id)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('ensures user can be deleted first', function(done) {
|
||||
this.UserDeleter._ensureCanDeleteUser.yields(
|
||||
new Errors.SubscriptionAdminDeletionError()
|
||||
)
|
||||
return this.UserDeleter.softDeleteUserForMigration(
|
||||
this.user._id,
|
||||
error => {
|
||||
sinon.assert.calledWith(
|
||||
this.UserDeleter._ensureCanDeleteUser,
|
||||
it('should unsubscribe the user from the news letter', async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
expect(this.NewsletterManager.unsubscribe).to.have.been.calledWith(
|
||||
this.user
|
||||
)
|
||||
sinon.assert.notCalled(this.user.remove)
|
||||
expect(error).to.be.instanceof(Errors.SubscriptionAdminDeletionError)
|
||||
return done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteUser', function() {
|
||||
beforeEach(function() {
|
||||
return (this.UserDeleter._ensureCanDeleteUser = sinon.stub().yields(null))
|
||||
})
|
||||
it('should delete all the projects of a user', async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
expect(
|
||||
this.ProjectDeleter.promises.deleteUsersProjects
|
||||
).to.have.been.calledWith(this.userId)
|
||||
})
|
||||
|
||||
it('should delete the user in mongo', function(done) {
|
||||
return this.UserDeleter.deleteUser(this.user._id, err => {
|
||||
this.User.findById.calledWith(this.user._id).should.equal(true)
|
||||
this.user.remove.called.should.equal(true)
|
||||
return done()
|
||||
it("should cancel the user's subscription", async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
expect(
|
||||
this.SubscriptionHandler.cancelSubscription
|
||||
).to.have.been.calledWith(this.user)
|
||||
})
|
||||
|
||||
it('should delete user affiliations', async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
expect(
|
||||
this.InstitutionsApi.deleteAffiliations
|
||||
).to.have.been.calledWith(this.userId)
|
||||
})
|
||||
|
||||
it('should remove user from group subscriptions', async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
expect(
|
||||
this.SubscriptionUpdater.removeUserFromAllGroups
|
||||
).to.have.been.calledWith(this.userId)
|
||||
})
|
||||
|
||||
it('should remove user memberships', async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
expect(
|
||||
this.UserMembershipsHandler.removeUserFromAllEntities
|
||||
).to.have.been.calledWith(this.userId)
|
||||
})
|
||||
|
||||
it('ensures user can be deleted', async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
expect(
|
||||
this.UserDeleter.promises.ensureCanDeleteUser
|
||||
).to.have.been.calledWith(this.user)
|
||||
})
|
||||
|
||||
it('should create a deletedUser', async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
this.DeletedUserMock.verify()
|
||||
})
|
||||
|
||||
describe('when unsubscribing from mailchimp fails', () => {
|
||||
beforeEach(() => {
|
||||
this.NewsletterManager.unsubscribe = sinon
|
||||
.stub()
|
||||
.yields(new Error('something went wrong'))
|
||||
})
|
||||
|
||||
it('should not return an error', async () => {
|
||||
try {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
} catch (error) {
|
||||
expect(error).not.to.exist
|
||||
expect.fail()
|
||||
}
|
||||
// check that we called `unsubscribe` to generate the error
|
||||
expect(this.NewsletterManager.unsubscribe).to.have.been.calledWith(
|
||||
this.user
|
||||
)
|
||||
})
|
||||
|
||||
it('should delete the user', async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
this.UserMock.verify()
|
||||
})
|
||||
|
||||
it('should log an error', async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
sinon.assert.called(this.logger.err)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when called as a callback', () => {
|
||||
it('should delete the user', done => {
|
||||
this.UserDeleter.deleteUser(this.userId, err => {
|
||||
expect(err).not.to.exist
|
||||
this.UserMock.verify()
|
||||
this.DeletedUserMock.verify()
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('when a user and IP address are specified', () => {
|
||||
beforeEach(() => {
|
||||
this.ipAddress = '1.2.3.4'
|
||||
this.deleterId = ObjectId()
|
||||
|
||||
this.deletedUser.deleterData.deleterIpAddress = this.ipAddress
|
||||
this.deletedUser.deleterData.deleterId = this.deleterId
|
||||
|
||||
this.DeletedUserMock.expects('create')
|
||||
.withArgs(this.deletedUser)
|
||||
.chain('exec')
|
||||
.resolves()
|
||||
})
|
||||
|
||||
it('should add the deleted user id and ip address to the deletedUser', async () => {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId, {
|
||||
deleterUser: { _id: this.deleterId },
|
||||
ipAddress: this.ipAddress
|
||||
})
|
||||
this.DeletedUserMock.verify()
|
||||
})
|
||||
|
||||
describe('when called as a callback', () => {
|
||||
it('should delete the user', done => {
|
||||
this.UserDeleter.deleteUser(
|
||||
this.userId,
|
||||
{
|
||||
deleterUser: { _id: this.deleterId },
|
||||
ipAddress: this.ipAddress
|
||||
},
|
||||
err => {
|
||||
expect(err).not.to.exist
|
||||
this.UserMock.verify()
|
||||
this.DeletedUserMock.verify()
|
||||
done()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should unsubscribe the user from the news letter', function(done) {
|
||||
return this.UserDeleter.deleteUser(this.user._id, err => {
|
||||
this.NewsletterManager.unsubscribe
|
||||
.calledWith(this.user)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete all the projects of a user', function(done) {
|
||||
return this.UserDeleter.deleteUser(this.user._id, err => {
|
||||
this.ProjectDeleter.deleteUsersProjects
|
||||
.calledWith(this.user._id)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should unsubscribe the user', function(done) {
|
||||
return this.UserDeleter.deleteUser(this.user._id, err => {
|
||||
this.SubscriptionHandler.cancelSubscription
|
||||
.calledWith(this.user)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should delete user affiliations', function(done) {
|
||||
return this.UserDeleter.deleteUser(this.user._id, err => {
|
||||
this.deleteAffiliations.calledWith(this.user._id).should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove user from group subscriptions', function(done) {
|
||||
return this.UserDeleter.deleteUser(this.user._id, err => {
|
||||
this.SubscriptionUpdater.removeUserFromAllGroups
|
||||
.calledWith(this.user._id)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('should remove user memberships', function(done) {
|
||||
return this.UserDeleter.deleteUser(this.user._id, err => {
|
||||
this.UserMembershipsHandler.removeUserFromAllEntities
|
||||
.calledWith(this.user._id)
|
||||
.should.equal(true)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
it('ensures user can be deleted first', function(done) {
|
||||
this.UserDeleter._ensureCanDeleteUser.yields(
|
||||
new Errors.SubscriptionAdminDeletionError()
|
||||
)
|
||||
return this.UserDeleter.deleteUser(this.user._id, error => {
|
||||
sinon.assert.calledWith(
|
||||
this.UserDeleter._ensureCanDeleteUser,
|
||||
this.user
|
||||
describe('when the user cannot be deleted because they are a subscription admin', () => {
|
||||
beforeEach(() => {
|
||||
this.UserDeleter.promises.ensureCanDeleteUser.rejects(
|
||||
new Errors.SubscriptionAdminDeletionError()
|
||||
)
|
||||
sinon.assert.notCalled(this.user.remove)
|
||||
expect(error).to.be.instanceof(Errors.SubscriptionAdminDeletionError)
|
||||
return done()
|
||||
})
|
||||
})
|
||||
|
||||
describe('when unsubscribing from mailchimp fails', function() {
|
||||
beforeEach(function() {
|
||||
return (this.NewsletterManager.unsubscribe = sinon
|
||||
.stub()
|
||||
.callsArgWith(1, new Error('something went wrong')))
|
||||
})
|
||||
|
||||
it('should not return an error', function(done) {
|
||||
return this.UserDeleter.deleteUser(this.user._id, err => {
|
||||
this.NewsletterManager.unsubscribe
|
||||
.calledWith(this.user)
|
||||
.should.equal(true)
|
||||
should.not.exist(err)
|
||||
return done()
|
||||
})
|
||||
it('fails with a SubscriptionAdminDeletionError', async () => {
|
||||
let error
|
||||
try {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
} catch (e) {
|
||||
error = e
|
||||
} finally {
|
||||
expect(error).to.be.instanceof(Errors.SubscriptionAdminDeletionError)
|
||||
}
|
||||
})
|
||||
|
||||
it('should delete the user', function(done) {
|
||||
return this.UserDeleter.deleteUser(this.user._id, err => {
|
||||
this.NewsletterManager.unsubscribe
|
||||
.calledWith(this.user)
|
||||
.should.equal(true)
|
||||
this.user.remove.called.should.equal(true)
|
||||
return done()
|
||||
})
|
||||
it('should not create a deletedUser', async () => {
|
||||
try {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
} catch (e) {
|
||||
} finally {
|
||||
this.DeletedUserMock.verify()
|
||||
}
|
||||
})
|
||||
|
||||
it('should log an error', function(done) {
|
||||
return this.UserDeleter.deleteUser(this.user._id, err => {
|
||||
sinon.assert.called(this.logger.err)
|
||||
return done()
|
||||
})
|
||||
it('should not remove the user from mongo', async () => {
|
||||
try {
|
||||
await this.UserDeleter.promises.deleteUser(this.userId)
|
||||
} catch (e) {
|
||||
} finally {
|
||||
this.UserMock.verify()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('_ensureCanDeleteUser', function() {
|
||||
it('should not return error when user can be deleted', function(done) {
|
||||
describe('ensureCanDeleteUser', () => {
|
||||
it('should not return error when user can be deleted', async () => {
|
||||
this.SubscriptionLocator.getUsersSubscription.yields(null, null)
|
||||
return this.UserDeleter._ensureCanDeleteUser(this.user, function(error) {
|
||||
expect(error).to.not.exist
|
||||
return done()
|
||||
})
|
||||
let error
|
||||
try {
|
||||
await this.UserDeleter.promises.ensureCanDeleteUser(this.user)
|
||||
} catch (e) {
|
||||
error = e
|
||||
} finally {
|
||||
expect(error).not.to.exist
|
||||
}
|
||||
})
|
||||
|
||||
it('should return custom error when user is group admin', function(done) {
|
||||
it('should return custom error when user is group admin', async () => {
|
||||
this.SubscriptionLocator.getUsersSubscription.yields(null, {
|
||||
_id: '123abc'
|
||||
})
|
||||
return this.UserDeleter._ensureCanDeleteUser(this.user, function(error) {
|
||||
let error
|
||||
try {
|
||||
await this.UserDeleter.promises.ensureCanDeleteUser(this.user)
|
||||
} catch (e) {
|
||||
error = e
|
||||
} finally {
|
||||
expect(error).to.be.instanceof(Errors.SubscriptionAdminDeletionError)
|
||||
return done()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('propagate errors', function(done) {
|
||||
it('propagates errors', async () => {
|
||||
this.SubscriptionLocator.getUsersSubscription.yields(
|
||||
new Error('Some error')
|
||||
)
|
||||
return this.UserDeleter._ensureCanDeleteUser(this.user, function(error) {
|
||||
let error
|
||||
try {
|
||||
await this.UserDeleter.promises.ensureCanDeleteUser(this.user)
|
||||
} catch (e) {
|
||||
error = e
|
||||
} finally {
|
||||
expect(error).to.be.instanceof(Error)
|
||||
return done()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('expireDeletedUsersAfterDuration', () => {
|
||||
beforeEach(() => {
|
||||
this.UserDeleter.promises.expireDeletedUser = sinon.stub().resolves()
|
||||
this.deletedUsers = [
|
||||
{
|
||||
user: { _id: 'wombat' },
|
||||
deleterData: { deletedUserId: 'wombat' }
|
||||
},
|
||||
{
|
||||
user: { _id: 'potato' },
|
||||
deleterData: { deletedUserId: 'potato' }
|
||||
}
|
||||
]
|
||||
|
||||
this.DeletedUserMock.expects('find')
|
||||
.withArgs({
|
||||
'deleterData.deletedAt': {
|
||||
$lt: new Date(moment().subtract(90, 'days'))
|
||||
},
|
||||
user: {
|
||||
$ne: null
|
||||
}
|
||||
})
|
||||
.chain('exec')
|
||||
.resolves(this.deletedUsers)
|
||||
})
|
||||
|
||||
it('calls expireDeletedUser for each user', async () => {
|
||||
await this.UserDeleter.promises.expireDeletedUsersAfterDuration()
|
||||
expect(
|
||||
this.UserDeleter.promises.expireDeletedUser
|
||||
).to.have.been.calledWith('wombat')
|
||||
expect(
|
||||
this.UserDeleter.promises.expireDeletedUser
|
||||
).to.have.been.calledWith('potato')
|
||||
})
|
||||
})
|
||||
|
||||
describe('expireDeletedUser', () => {
|
||||
beforeEach(() => {
|
||||
this.mockedDeletedUser = sinon.mock(
|
||||
new DeletedUser({
|
||||
user: this.user,
|
||||
deleterData: {
|
||||
deleterIpAddress: '1.1.1.1',
|
||||
deletedUserId: this.userId
|
||||
}
|
||||
})
|
||||
)
|
||||
this.deletedUser = this.mockedDeletedUser.object
|
||||
|
||||
this.mockedDeletedUser.expects('save').resolves()
|
||||
|
||||
this.DeletedUserMock.expects('findOne')
|
||||
.withArgs({ 'deleterData.deletedUserId': 'giraffe' })
|
||||
.chain('exec')
|
||||
.resolves(this.deletedUser)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
this.mockedDeletedUser.restore()
|
||||
})
|
||||
|
||||
it('should find the user by user ID', async () => {
|
||||
await this.UserDeleter.promises.expireDeletedUser('giraffe')
|
||||
this.DeletedUserMock.verify()
|
||||
})
|
||||
|
||||
it('should remove the user data from mongo', async () => {
|
||||
await this.UserDeleter.promises.expireDeletedUser('giraffe')
|
||||
expect(this.deletedUser.user).not.to.exist
|
||||
})
|
||||
|
||||
it('should remove the IP address from mongo', async () => {
|
||||
await this.UserDeleter.promises.expireDeletedUser('giraffe')
|
||||
expect(this.deletedUser.deleterData.ipAddress).not.to.exist
|
||||
})
|
||||
|
||||
it('should not delete other deleterData fields', async () => {
|
||||
await this.UserDeleter.promises.expireDeletedUser('giraffe')
|
||||
expect(this.deletedUser.deleterData.deletedUserId).to.equal(this.userId)
|
||||
})
|
||||
|
||||
it('should save the record to mongo', async () => {
|
||||
await this.UserDeleter.promises.expireDeletedUser('giraffe')
|
||||
this.mockedDeletedUser.verify()
|
||||
})
|
||||
|
||||
describe('when called as a callback', () => {
|
||||
it('should expire the user', done => {
|
||||
this.UserDeleter.expireDeletedUser('giraffe', err => {
|
||||
expect(err).not.to.exists
|
||||
this.DeletedUserMock.verify()
|
||||
this.mockedDeletedUser.verify()
|
||||
expect(this.deletedUser.user).not.to.exist
|
||||
expect(this.deletedUser.deleterData.ipAddress).not.to.exist
|
||||
done()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
39
services/web/test/unit/src/helpers/MockModel.js
Normal file
39
services/web/test/unit/src/helpers/MockModel.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
const SandboxedModule = require('sandboxed-module')
|
||||
const mongoose = require('mongoose')
|
||||
|
||||
/**
|
||||
* Imports a model as we would usually do with e.g. `require('models/User')`
|
||||
* an returns a model an schema, but without connecting to Mongo. This allows
|
||||
* us to use model classes in tests, and to use them with `sinon-mongoose`
|
||||
*
|
||||
* @param modelName the name of the model - e.g. 'User'
|
||||
* @param requires additional `requires` to be passed to SanboxedModule in
|
||||
* the event that these also need to be stubbed. For example,
|
||||
* additional dependent models to be included
|
||||
*
|
||||
* @return model and schema pair - e.g. { User, UserSchema }
|
||||
*/
|
||||
|
||||
module.exports = (modelName, requires = {}) => {
|
||||
let model = {}
|
||||
|
||||
requires.mongoose = {
|
||||
createConnection: () => {
|
||||
return {
|
||||
model: () => {}
|
||||
}
|
||||
},
|
||||
model: (modelName, schema) => {
|
||||
model[modelName + 'Schema'] = schema
|
||||
model[modelName] = mongoose.model(modelName, schema)
|
||||
},
|
||||
Schema: mongoose.Schema,
|
||||
Types: mongoose.Types
|
||||
}
|
||||
|
||||
SandboxedModule.require('../../../../app/src/models/' + modelName, {
|
||||
requires: requires
|
||||
})
|
||||
|
||||
return model
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
const mockModel = require('../MockModel')
|
||||
|
||||
module.exports = mockModel('DeletedProject', {
|
||||
'./Project': require('./Project')
|
||||
})
|
5
services/web/test/unit/src/helpers/models/DeletedUser.js
Normal file
5
services/web/test/unit/src/helpers/models/DeletedUser.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
const mockModel = require('../MockModel')
|
||||
|
||||
module.exports = mockModel('DeletedUser', {
|
||||
'./User': require('./User')
|
||||
})
|
3
services/web/test/unit/src/helpers/models/Project.js
Normal file
3
services/web/test/unit/src/helpers/models/Project.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
const mockModel = require('../MockModel')
|
||||
|
||||
module.exports = mockModel('Project')
|
5
services/web/test/unit/src/helpers/models/User.js
Normal file
5
services/web/test/unit/src/helpers/models/User.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
const mockModel = require('../MockModel')
|
||||
|
||||
module.exports = mockModel('User', {
|
||||
'./Project': require('./Project')
|
||||
})
|
Loading…
Reference in a new issue