diff --git a/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee b/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee index 49bd994b2c..f381735969 100644 --- a/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee +++ b/services/web/app/coffee/Features/Authentication/AuthenticationManager.coffee @@ -3,6 +3,7 @@ User = require("../../models/User").User {db, ObjectId} = require("../../infrastructure/mongojs") crypto = require 'crypto' bcrypt = require 'bcrypt' +EmailHelper = require("../Helpers/EmailHelper") BCRYPT_ROUNDS = Settings?.security?.bcryptRounds or 12 @@ -28,13 +29,26 @@ module.exports = AuthenticationManager = else callback null, null - setUserPassword: (user_id, password, callback = (error) ->) -> + validateEmail: (email) -> + parsed = EmailHelper.parseEmail(email) + if !parsed? + return { message: 'email not valid' } + return null + + validatePassword: (password) -> + if !password? + return { message: 'password not set' } if (Settings.passwordStrengthOptions?.length?.max? and - Settings.passwordStrengthOptions?.length?.max < password.length) - return callback("password is too long") + password.length > Settings.passwordStrengthOptions?.length?.max) + return { message: "password is too long" } if (Settings.passwordStrengthOptions?.length?.min? and - Settings.passwordStrengthOptions?.length?.min > password.length) - return callback("password is too short") + password.length < Settings.passwordStrengthOptions?.length?.min) + return { message: 'password is too short' } + return null + + setUserPassword: (user_id, password, callback = (error) ->) -> + validation = @validatePassword(password) + return callback(validation.message) if validation? bcrypt.genSalt BCRYPT_ROUNDS, (error, salt) -> return callback(error) if error? diff --git a/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee b/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee index bffa485f07..192f572beb 100644 --- a/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee +++ b/services/web/app/coffee/Features/Email/Bodies/SingleCTAEmailBody.coffee @@ -32,6 +32,11 @@ module.exports = _.template """ <%= secondaryMessage %>

<% } %> +
 
+

+ If the button above does not appear, please open the link in your browser here:
+ <%= ctaURL %> +

diff --git a/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee b/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee index ee9907c019..2b522d6c6c 100644 --- a/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee +++ b/services/web/app/coffee/Features/Email/Bodies/ol-SingleCTAEmailBody.coffee @@ -32,6 +32,11 @@ module.exports = _.template """ <%= secondaryMessage %>

<% } %> +
 
+

+ If the button above does not appear, please open the link in your browser here:
+ <%= ctaURL %> +

diff --git a/services/web/app/coffee/Features/Email/EmailBuilder.coffee b/services/web/app/coffee/Features/Email/EmailBuilder.coffee index f88fff3057..2f37901dbe 100644 --- a/services/web/app/coffee/Features/Email/EmailBuilder.coffee +++ b/services/web/app/coffee/Features/Email/EmailBuilder.coffee @@ -51,8 +51,6 @@ templates.accountMergeToOverleafAddress = CTAEmailTemplate({ """ ctaText: () -> "Confirm Account Merge" ctaURL: (opts) -> opts.tokenLinkUrl - secondaryMessage: (opts) -> - "If the button does not appear, open this link in your browser: #{opts.tokenLinkUrl}" }) templates.accountMergeToSharelatexAddress = templates.accountMergeToOverleafAddress @@ -97,8 +95,6 @@ templates.confirmEmail = CTAEmailTemplate({ title: () -> "Confirm Email" message: () -> "Please confirm your email on #{settings.appName}." ctaText: () -> "Confirm Email" - secondaryMessage: (opts) -> - "If the button does not appear, open this link in your browser: #{opts.confirmEmailUrl}" ctaURL: (opts) -> opts.confirmEmailUrl }) diff --git a/services/web/app/coffee/Features/Errors/ErrorController.coffee b/services/web/app/coffee/Features/Errors/ErrorController.coffee index c4f3089fe0..25760bb1c3 100644 --- a/services/web/app/coffee/Features/Errors/ErrorController.coffee +++ b/services/web/app/coffee/Features/Errors/ErrorController.coffee @@ -13,6 +13,11 @@ module.exports = ErrorController = res.render 'general/500', title: "Server Error" + accountMergeError: (req, res)-> + res.status(500) + res.render 'general/account-merge-error', + title: "Account Access Error" + handleError: (error, req, res, next) -> user = AuthenticationController.getSessionUser(req) if error?.code is 'EBADCSRFTOKEN' @@ -25,10 +30,17 @@ module.exports = ErrorController = else if error instanceof Errors.TooManyRequestsError logger.warn {err: error, url: req.url}, "too many requests error" res.sendStatus(429) + else if error instanceof Errors.InvalidError + logger.warn {err: error, url: req.url}, "invalid error" + res.status(400) + res.send(error.message) else if error instanceof Errors.InvalidNameError logger.warn {err: error, url: req.url}, "invalid name error" res.status(400) res.send(error.message) + else if error instanceof Errors.AccountMergeError + logger.error err: error, "account merge error" + ErrorController.accountMergeError req, res else logger.error err: error, url:req.url, method:req.method, user:user, "error passed to top level next middlewear" ErrorController.serverError req, res diff --git a/services/web/app/coffee/Features/Errors/Errors.coffee b/services/web/app/coffee/Features/Errors/Errors.coffee index 94aeaa2a90..0b20992faa 100644 --- a/services/web/app/coffee/Features/Errors/Errors.coffee +++ b/services/web/app/coffee/Features/Errors/Errors.coffee @@ -82,6 +82,20 @@ EmailExistsError = (message) -> return error EmailExistsError.prototype.__proto__ = Error.prototype +InvalidError = (message) -> + error = new Error(message) + error.name = "InvalidError" + error.__proto__ = InvalidError.prototype + return error +InvalidError.prototype.__proto__ = Error.prototype + +AccountMergeError = (message) -> + error = new Error(message) + error.name = "AccountMergeError" + error.__proto__ = AccountMergeError.prototype + return error +AccountMergeError.prototype.__proto__ = Error.prototype + module.exports = Errors = NotFoundError: NotFoundError ServiceNotConfiguredError: ServiceNotConfiguredError @@ -95,3 +109,5 @@ module.exports = Errors = V1ConnectionError: V1ConnectionError UnconfirmedEmailError: UnconfirmedEmailError EmailExistsError: EmailExistsError + InvalidError: InvalidError + AccountMergeError: AccountMergeError diff --git a/services/web/app/coffee/Features/Exports/ExportsController.coffee b/services/web/app/coffee/Features/Exports/ExportsController.coffee index ea82cf6a04..fc339f9957 100644 --- a/services/web/app/coffee/Features/Exports/ExportsController.coffee +++ b/services/web/app/coffee/Features/Exports/ExportsController.coffee @@ -46,10 +46,11 @@ module.exports = } res.send export_json: json - exportZip: (req, res) -> - {export_id} = req.params + exportDownload: (req, res) -> + {type, export_id} = req.params + AuthenticationController.getLoggedInUserId(req) - ExportsHandler.fetchZip export_id, (err, export_zip_url) -> + ExportsHandler.fetchDownload export_id, type, (err, export_file_url) -> return err if err? - res.redirect export_zip_url + res.redirect export_file_url diff --git a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee index 885f063c8b..18644a66e6 100644 --- a/services/web/app/coffee/Features/Exports/ExportsHandler.coffee +++ b/services/web/app/coffee/Features/Exports/ExportsHandler.coffee @@ -122,10 +122,9 @@ module.exports = ExportsHandler = self = logger.err err:err, export:export_id, "v1 export returned failure status code: #{res.statusCode}" callback err - fetchZip: (export_id, callback=(err, zip_url) ->) -> - console.log("#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/zip_url") + fetchDownload: (export_id, type, callback=(err, file_url) ->) -> request.get { - url: "#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/zip_url" + url: "#{settings.apis.v1.url}/api/v1/sharelatex/exports/#{export_id}/#{type}_url" auth: {user: settings.apis.v1.user, pass: settings.apis.v1.pass } }, (err, res, body) -> if err? diff --git a/services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee b/services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee new file mode 100644 index 0000000000..013bd2ac00 --- /dev/null +++ b/services/web/app/coffee/Features/Institutions/InstitutionsLocator.coffee @@ -0,0 +1,9 @@ +Institution = require('../../models/Institution').Institution +logger = require("logger-sharelatex") +ObjectId = require('mongoose').Types.ObjectId + +module.exports = InstitutionLocator = + + findManagedInstitution: (managerId, callback)-> + logger.log managerId: managerId, "finding managed Institution" + Institution.findOne managerIds: managerId, callback diff --git a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee index b7be80cb47..079065a948 100644 --- a/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee +++ b/services/web/app/coffee/Features/Project/ProjectEntityUpdateHandler.coffee @@ -129,6 +129,8 @@ module.exports = ProjectEntityUpdateHandler = self = Project.update {_id:project_id}, {$unset: {rootDoc_id: true}}, {}, callback addDoc: wrapWithLock (project_id, folder_id, docName, docLines, userId, callback = (error, doc, folder_id) ->)=> + if not SafePath.isCleanFilename docName + return callback new Errors.InvalidNameError("invalid element name") self.addDocWithoutUpdatingHistory.withoutLock project_id, folder_id, docName, docLines, userId, (error, doc, folder_id, path, project) -> return callback(error) if error? projectHistoryId = project.overleaf?.history?.id @@ -166,6 +168,8 @@ module.exports = ProjectEntityUpdateHandler = self = addFile: wrapWithLock beforeLock: (next) -> (project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback) -> + if not SafePath.isCleanFilename fileName + return callback new Errors.InvalidNameError("invalid element name") ProjectEntityUpdateHandler._uploadFile project_id, folder_id, fileName, fsPath, linkedFileData, userId, (error, fileRef, fileStoreUrl) -> return callback(error) if error? next(project_id, folder_id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback) @@ -241,6 +245,8 @@ module.exports = ProjectEntityUpdateHandler = self = # the history unless you are making sure it is updated in some other way. beforeLock: (next) -> (project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback) -> + if not SafePath.isCleanFilename fileName + return callback(new Errors.InvalidNameError("invalid element name")) ProjectEntityUpdateHandler._uploadFile project_id, folder_id, fileName, fsPath, linkedFileData, userId, (error, fileRef, fileStoreUrl) -> return callback(error) if error? next(project_id, folder_id, fileName, fsPath, linkedFileData, userId, fileRef, fileStoreUrl, callback) @@ -250,6 +256,8 @@ module.exports = ProjectEntityUpdateHandler = self = callback(null, fileRef, folder_id, result?.path?.fileSystem, fileStoreUrl) upsertDoc: wrapWithLock (project_id, folder_id, docName, docLines, source, userId, callback = (err, doc, folder_id, isNewDoc)->)-> + if not SafePath.isCleanFilename docName + return callback new Errors.InvalidNameError("invalid element name") ProjectLocator.findElement project_id: project_id, element_id: folder_id, type: "folder", (error, folder) -> return callback(error) if error? return callback(new Error("Couldn't find folder")) if !folder? @@ -272,6 +280,8 @@ module.exports = ProjectEntityUpdateHandler = self = upsertFile: wrapWithLock beforeLock: (next) -> (project_id, folder_id, fileName, fsPath, linkedFileData, userId, callback)-> + if not SafePath.isCleanFilename fileName + return callback new Errors.InvalidNameError("invalid element name") # create a new file fileRef = new File( name: fileName @@ -301,6 +311,8 @@ module.exports = ProjectEntityUpdateHandler = self = callback null, newFileRef, !existingFile?, existingFile upsertDocWithPath: wrapWithLock (project_id, elementPath, docLines, source, userId, callback) -> + if not SafePath.isCleanPath elementPath + return callback new Errors.InvalidNameError("invalid element name") docName = path.basename(elementPath) folderPath = path.dirname(elementPath) self.mkdirp.withoutLock project_id, folderPath, (err, newFolders, folder) -> @@ -312,6 +324,8 @@ module.exports = ProjectEntityUpdateHandler = self = upsertFileWithPath: wrapWithLock beforeLock: (next) -> (project_id, elementPath, fsPath, linkedFileData, userId, callback)-> + if not SafePath.isCleanPath elementPath + return callback new Errors.InvalidNameError("invalid element name") fileName = path.basename(elementPath) folderPath = path.dirname(elementPath) # create a new file @@ -351,6 +365,9 @@ module.exports = ProjectEntityUpdateHandler = self = self.deleteEntity.withoutLock project_id, element._id, type, userId, callback mkdirp: wrapWithLock (project_id, path, callback = (err, newlyCreatedFolders, lastFolderInPath)->)-> + for folder in path.split('/') + if folder.length > 0 and not SafePath.isCleanFilename folder + return callback new Errors.InvalidNameError("invalid element name") ProjectEntityMongoUpdateHandler.mkdirp project_id, path, callback addFolder: wrapWithLock (project_id, parentFolder_id, folderName, callback) -> diff --git a/services/web/app/coffee/Features/Project/SafePath.coffee b/services/web/app/coffee/Features/Project/SafePath.coffee index a77f83ce2b..4e5a288ea9 100644 --- a/services/web/app/coffee/Features/Project/SafePath.coffee +++ b/services/web/app/coffee/Features/Project/SafePath.coffee @@ -52,6 +52,7 @@ load = () -> MAX_PATH = 1024 # Maximum path length, in characters. This is fairly arbitrary. SafePath = + # convert any invalid characters to underscores in the given filename clean: (filename) -> filename = filename.replace BADCHAR_RX, '_' # for BADFILE_RX replace any matches with an equal number of underscores @@ -61,12 +62,27 @@ load = () -> filename = filename.replace BLOCKEDFILE_RX, "@$1" return filename + # returns whether the filename is 'clean' (does not contain any invalid + # characters or reserved words) isCleanFilename: (filename) -> return SafePath.isAllowedLength(filename) && !BADCHAR_RX.test(filename) && !BADFILE_RX.test(filename) && !BLOCKEDFILE_RX.test(filename) + # returns whether a full path is 'clean' - e.g. is a full or relative path + # that points to a file, and each element passes the rules in 'isCleanFilename' + isCleanPath: (path) -> + elements = path.split('/') + + lastElementIsEmpty = elements[elements.length - 1].length == 0 + return false if lastElementIsEmpty + + for element in elements + return false if element.length > 0 && !SafePath.isCleanFilename element + + return true + isAllowedLength: (pathname) -> return pathname.length > 0 && pathname.length <= MAX_PATH diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee index 0fb7b6e7c9..3ebd096b70 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupController.coffee @@ -52,18 +52,6 @@ module.exports = return res.sendStatus 500 res.send() - renderSubscriptionGroupAdminPage: (req, res, next)-> - user_id = AuthenticationController.getLoggedInUserId(req) - getManagedSubscription user_id, (error, subscription)-> - return next(error) if error? - if !subscription?.groupPlan - return res.redirect("/user/subscription") - SubscriptionGroupHandler.getPopulatedListOfMembers subscription._id, (err, users)-> - res.render "subscriptions/group_admin", - title: 'group_admin' - users: users - subscription: subscription - exportGroupCsv: (req, res)-> user_id = AuthenticationController.getLoggedInUserId(req) logger.log user_id: user_id, "exporting group csv" diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee index badcde4c36..bb849fa7bd 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionGroupHandler.coffee @@ -11,6 +11,7 @@ TeamInvitesHandler = require("./TeamInvitesHandler") EmailHandler = require("../Email/EmailHandler") settings = require("settings-sharelatex") NotificationsBuilder = require("../Notifications/NotificationsBuilder") +UserMembershipViewModel = require("../UserMembership/UserMembershipViewModel") module.exports = SubscriptionGroupHandler = @@ -31,12 +32,12 @@ module.exports = SubscriptionGroupHandler = logger.err err:err, "error adding user to group" return callback(err) NotificationsBuilder.groupPlan(user, {subscription_id:subscription._id}).read() - userViewModel = buildUserViewModel(user) + userViewModel = UserMembershipViewModel.build(user) callback(err, userViewModel) else TeamInvitesHandler.createInvite subscriptionId, newEmail, (err) -> return callback(err) if err? - userViewModel = buildEmailInviteViewModel(newEmail) + userViewModel = UserMembershipViewModel.build(newEmail) callback(err, userViewModel) removeUserFromGroup: (subscriptionId, userToRemove_id, callback)-> @@ -51,28 +52,6 @@ module.exports = SubscriptionGroupHandler = replaceInArray Subscription, "member_ids", oldId, newId, callback - getPopulatedListOfMembers: (subscriptionId, callback)-> - SubscriptionLocator.getSubscription subscriptionId, (err, subscription)-> - users = [] - - for email in subscription.invited_emails or [] - users.push buildEmailInviteViewModel(email) - - for teamInvite in subscription.teamInvites or [] - users.push buildEmailInviteViewModel(teamInvite.email) - - jobs = _.map subscription.member_ids, (user_id)-> - return (cb)-> - UserGetter.getUser user_id, (err, user)-> - if err? or !user? - users.push _id:user_id - return cb() - userViewModel = buildUserViewModel(user) - users.push(userViewModel) - cb() - async.series jobs, (err)-> - callback(err, users) - isUserPartOfGroup: (user_id, subscription_id, callback=(err, partOfGroup)->)-> SubscriptionLocator.getSubscriptionByMemberIdAndId user_id, subscription_id, (err, subscription)-> if subscription? @@ -99,18 +78,3 @@ replaceInArray = (model, property, oldValue, newValue, callback) -> model.update query, { $addToSet: setNewValue }, { multi: true }, (error) -> return callback(error) if error? model.update query, { $pull: setOldValue }, { multi: true }, callback - -buildUserViewModel = (user)-> - u = - email: user.email - first_name: user.first_name - last_name: user.last_name - invite: user.holdingAccount - _id: user._id - return u - -buildEmailInviteViewModel = (email) -> - return { - email: email - invite: true - } diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee index 8950e3c5bd..79954b33e0 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionRouter.coffee @@ -20,7 +20,8 @@ module.exports = webRouter.get '/user/subscription/thank-you', AuthenticationController.requireLogin(), SubscriptionController.successful_subscription - webRouter.get '/subscription/group', AuthenticationController.requireLogin(), SubscriptionGroupController.renderSubscriptionGroupAdminPage + webRouter.get '/subscription/group', AuthenticationController.requireLogin(), (req, res, next) -> + res.redirect('/manage/group/members') # legacy route webRouter.post '/subscription/group/user', AuthenticationController.requireLogin(), SubscriptionGroupController.addUserToGroup webRouter.get '/subscription/group/export', AuthenticationController.requireLogin(), SubscriptionGroupController.exportGroupCsv webRouter.delete '/subscription/group/user/:user_id', AuthenticationController.requireLogin(), SubscriptionGroupController.removeUserFromGroup diff --git a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee index edcc58d57e..299320f061 100644 --- a/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee +++ b/services/web/app/coffee/Features/Subscription/SubscriptionUpdater.coffee @@ -55,7 +55,10 @@ module.exports = SubscriptionUpdater = if err? logger.err err:err, searchOps:searchOps, removeOperation:removeOperation, "error removing user from group" return callback(err) - FeaturesUpdater.refreshFeatures user_id, callback + UserGetter.getUserOrUserStubById user_id, {}, (error, user, isStub) -> + return callback(error) if error + return callback() if isStub + FeaturesUpdater.refreshFeatures user_id, callback deleteWithV1Id: (v1TeamId, callback)-> Subscription.deleteOne { "overleaf.id": v1TeamId }, callback diff --git a/services/web/app/coffee/Features/User/UserGetter.coffee b/services/web/app/coffee/Features/User/UserGetter.coffee index f6d16f5c75..7eb1e4ba20 100644 --- a/services/web/app/coffee/Features/User/UserGetter.coffee +++ b/services/web/app/coffee/Features/User/UserGetter.coffee @@ -71,15 +71,18 @@ module.exports = UserGetter = db.users.find { _id: { $in: user_ids} }, projection, callback - getUserOrUserStubById: (user_id, projection, callback = (error, user) ->) -> + getUserOrUserStubById: (user_id, projection, callback = (error, user, isStub) ->) -> try query = _id: ObjectId(user_id.toString()) catch e return callback(new Error(e)) db.users.findOne query, projection, (error, user) -> return callback(error) if error? - return callback(null, user) if user? - db.userstubs.findOne query, projection, callback + return callback(null, user, false) if user? + db.userstubs.findOne query, projection, (error, user) -> + return callback(error) if error + return callback() if !user? + callback(null, user, true) # check for duplicate email address. This is also enforced at the DB level ensureUniqueEmailAddress: (newEmail, callback) -> diff --git a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee index 1291142dab..52d731c4bc 100644 --- a/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee +++ b/services/web/app/coffee/Features/User/UserRegistrationHandler.coffee @@ -13,17 +13,10 @@ settings = require "settings-sharelatex" EmailHelper = require("../Helpers/EmailHelper") module.exports = UserRegistrationHandler = - hasZeroLengths : (props) -> - hasZeroLength = false - props.forEach (prop) -> - if prop.length == 0 - hasZeroLength = true - return hasZeroLength - _registrationRequestIsValid : (body, callback)-> - email = EmailHelper.parseEmail(body.email) or '' - password = body.password - if @hasZeroLengths([password, email]) + invalidEmail = AuthenticationManager.validateEmail(body.email or '') + invalidPassword = AuthenticationManager.validatePassword(body.password or '') + if invalidEmail? or invalidPassword? return false else return true diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee new file mode 100644 index 0000000000..bc673e1d5c --- /dev/null +++ b/services/web/app/coffee/Features/UserMembership/UserMembershipController.coffee @@ -0,0 +1,65 @@ +AuthenticationController = require('../Authentication/AuthenticationController') +UserMembershipHandler = require('./UserMembershipHandler') +logger = require("logger-sharelatex") + +module.exports = + index: (entityName, req, res, next)-> + userId = AuthenticationController.getLoggedInUserId(req) + + UserMembershipHandler.getEntity entityName, userId, (error, entity)-> + return next(error) if error? + UserMembershipHandler.getUsers entityName, entity, (error, users)-> + return next(error) if error? + res.render "user_membership/index", + users: users + entity: entity + translations: getTranslationsFor(entityName) + paths: getPathsFor(entityName) + + add: (entityName, req, res, next)-> + userId = AuthenticationController.getLoggedInUserId(req) + email = req.body.email + return res.sendStatus 422 unless email + + UserMembershipHandler.getEntity entityName, userId, (error, entity)-> + return next(error) if error? + UserMembershipHandler.addUser entityName, entity, email, (error, user)-> + return next(error) if error? + res.json(user: user) + + remove: (entityName, req, res, next)-> + loggedInUserId = AuthenticationController.getLoggedInUserId(req) + userId = req.params.userId + + UserMembershipHandler.getEntity entityName, loggedInUserId, (error, entity)-> + return next(error) if error? + UserMembershipHandler.removeUser entityName, entity, userId, (error, user)-> + return next(error) if error? + res.send() + +getTranslationsFor = (entityName) -> + switch entityName + when 'group' + title: 'group_account' + remove: 'remove_from_group' + when 'groupManagers' + title: 'group_managers' + remove: 'remove_manager' + when 'institution' + title: 'institution_managers' + remove: 'remove_manager' + + +getPathsFor = (entityName) -> + switch entityName + when 'group' + addMember: '/subscription/invites' + removeMember: '/subscription/group/user' + removeInvite: '/subscription/invites' + exportMembers: '/subscription/group/export' + when 'groupManagers' + addMember: "/manage/group/managers" + removeMember: "/manage/group/managers" + when 'institution' + addMember: "/manage/institution/managers" + removeMember: "/manage/institution/managers" diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee new file mode 100644 index 0000000000..fa7b75fcb9 --- /dev/null +++ b/services/web/app/coffee/Features/UserMembership/UserMembershipHandler.coffee @@ -0,0 +1,85 @@ +async = require("async") +Errors = require('../Errors/Errors') +SubscriptionLocator = require('../Subscription/SubscriptionLocator') +InstitutionsLocator = require('../Institutions/InstitutionsLocator') +UserMembershipViewModel = require('./UserMembershipViewModel') +UserGetter = require('../User/UserGetter') +logger = require('logger-sharelatex') + +module.exports = + getEntity: (entityName, userId, callback = (error, entity) ->) -> + switch entityName + when 'group' then getGroupSubscription(userId, callback) + when 'groupManagers' + getGroupSubscription userId, (error, subscription) -> + subscription.membersLimit = null if subscription # managers are unlimited + callback(error, subscription) + when 'institution' then getInstitution(userId, callback) + else callback(new Errors.NotFoundError("No such entity: #{entityName}")) + + getUsers: (entityName, entity, callback = (error, users) ->) -> + attributes = switch entityName + when 'group' then ['invited_emails', 'teamInvites', 'member_ids'] + when 'groupManagers' then ['manager_ids'] + when 'institution' then ['managerIds'] + getPopulatedListOfMembers(entity, attributes, callback) + + addUser: (entityName, entity, email, callback = (error, user) ->) -> + attribute = switch entityName + when 'groupManagers' then 'manager_ids' + when 'institution' then 'managerIds' + unless attribute + return callback(new Errors.NotFoundError("Cannot add user to entity: #{entityName}")) + UserGetter.getUserByAnyEmail email, (error, user) -> + error ||= new Errors.NotFoundError("No user found with email #{email}") unless user + return callback(error) if error? + addUserToEntity entity, attribute, user, (error) -> + callback(error, UserMembershipViewModel.build(user)) + + removeUser: (entityName, entity, userId, callback = (error) ->) -> + attribute = switch entityName + when 'groupManagers' then 'manager_ids' + when 'institution' then 'managerIds' + else callback(new Errors.NotFoundError("Cannot remove user from entity: #{entityName}")) + removeUserFromEntity entity, attribute, userId, callback + +getGroupSubscription = (managerId, callback = (error, subscription) ->) -> + SubscriptionLocator.findManagedSubscription managerId, (err, subscription)-> + if subscription? and subscription.groupPlan + logger.log managerId: managerId, 'got managed subscription' + else + err ||= new Errors.NotFoundError("No subscription found managed by user #{managerId}") + + callback(err, subscription) + +getInstitution = (managerId, callback = (error, institution) ->) -> + InstitutionsLocator.findManagedInstitution managerId, (err, institution)-> + if institution? + logger.log managerId: managerId, 'got managed subscription' + else + err ||= new Errors.NotFoundError("No institution found managed by user #{managerId}") + + callback(err, institution) + +getPopulatedListOfMembers = (entity, attributes, callback = (error, users)->)-> + userObjects = [] + + for attribute in attributes + for userObject in entity[attribute] or [] + # userObject can be an email as String, a user id as ObjectId or an + # invite as Object with an email attribute as String. We want to pass to + # UserMembershipViewModel either an email as (String) or a user id (ObjectId) + userIdOrEmail = userObject.email || userObject + userObjects.push userIdOrEmail + + async.map userObjects, UserMembershipViewModel.buildAsync, callback + +addUserToEntity = (entity, attribute, user, callback = (error)->) -> + fieldUpdate = {} + fieldUpdate[attribute] = user._id + entity.update { $addToSet: fieldUpdate }, callback + +removeUserFromEntity = (entity, attribute, userId, callback = (error)->) -> + fieldUpdate = {} + fieldUpdate[attribute] = userId + entity.update { $pull: fieldUpdate }, callback diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee new file mode 100644 index 0000000000..223b412020 --- /dev/null +++ b/services/web/app/coffee/Features/UserMembership/UserMembershipRouter.coffee @@ -0,0 +1,26 @@ +AuthenticationController = require('../Authentication/AuthenticationController') +UserMembershipController = require './UserMembershipController' + +module.exports = + apply: (webRouter) -> + webRouter.get '/manage/group/members', + AuthenticationController.requireLogin(), + (req, res, next) -> UserMembershipController.index('group', req, res, next) + + + regularEntitites = + group: 'groupManagers' + institution: 'institution' + for pathName, entityName of regularEntitites + do (pathName, entityName) -> + webRouter.get "/manage/#{pathName}/managers", + AuthenticationController.requireLogin(), + (req, res, next) -> UserMembershipController.index(entityName, req, res, next) + + webRouter.post "/manage/#{pathName}/managers", + AuthenticationController.requireLogin(), + (req, res, next) -> UserMembershipController.add(entityName, req, res, next) + + webRouter.delete "/manage/#{pathName}/managers/:userId", + AuthenticationController.requireLogin(), + (req, res, next) -> UserMembershipController.remove(entityName, req, res, next) diff --git a/services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee b/services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee new file mode 100644 index 0000000000..ac5ba42168 --- /dev/null +++ b/services/web/app/coffee/Features/UserMembership/UserMembershipViewModel.coffee @@ -0,0 +1,45 @@ +ObjectId = require('mongojs').ObjectId +UserGetter = require('../User/UserGetter') + +module.exports = UserMembershipViewModel = + build: (userOrEmail) -> + if userOrEmail._id + buildUserViewModel userOrEmail + else + buildUserViewModelWithEmail userOrEmail + + + buildAsync: (userOrIdOrEmail, callback = (error, viewModel)->) -> + unless userOrIdOrEmail instanceof ObjectId + # userOrIdOrEmail is a user or an email and can be parsed by #build + return callback(null, UserMembershipViewModel.build(userOrIdOrEmail)) + + userId = userOrIdOrEmail + projection = { email: 1, first_name: 1, last_name: 1 } + UserGetter.getUserOrUserStubById userId, projection, (error, user, isStub) -> + if error? or !user? + return callback(null, buildUserViewModelWithId(userId.toString())) + if isStub + return callback(null, buildUserViewModelWithStub(user)) + callback(null, buildUserViewModel(user)) + + +buildUserViewModel = (user, isInvite = false) -> + _id: user._id or null + email: user.email or null + first_name: user.first_name or null + last_name: user.last_name or null + invite: isInvite + + +buildUserViewModelWithEmail = (email) -> + buildUserViewModel({ email }, true) + + +buildUserViewModelWithStub = (user) -> + # user stubs behave as invites + buildUserViewModel(user, true) + + +buildUserViewModelWithId = (id) -> + buildUserViewModel({ _id: id }, false) diff --git a/services/web/app/coffee/models/Institution.coffee b/services/web/app/coffee/models/Institution.coffee new file mode 100644 index 0000000000..decf114a96 --- /dev/null +++ b/services/web/app/coffee/models/Institution.coffee @@ -0,0 +1,11 @@ +mongoose = require 'mongoose' +Schema = mongoose.Schema +ObjectId = Schema.ObjectId + +InstitutionSchema = new Schema + v1Id: { type: Number, required: true } + managerIds: [ type:ObjectId, ref:'User' ] + +mongoose.model 'Institution', InstitutionSchema +exports.Institution = mongoose.model 'Institution' +exports.InstitutionSchema = InstitutionSchema diff --git a/services/web/app/coffee/models/User.coffee b/services/web/app/coffee/models/User.coffee index 2efb45370a..8a29a800b0 100644 --- a/services/web/app/coffee/models/User.coffee +++ b/services/web/app/coffee/models/User.coffee @@ -20,7 +20,6 @@ UserSchema = new Schema institution : {type : String, default : ''} hashedPassword : String isAdmin : {type : Boolean, default : false} - confirmed : {type : Boolean, default : false} signUpDate : {type : Date, default: () -> new Date() } lastLoggedIn : {type : Date} lastLoginIp : {type : String, default : ''} diff --git a/services/web/app/coffee/router.coffee b/services/web/app/coffee/router.coffee index e072d6d0f9..592ddaa061 100644 --- a/services/web/app/coffee/router.coffee +++ b/services/web/app/coffee/router.coffee @@ -51,6 +51,7 @@ Features = require('./infrastructure/Features') LinkedFilesRouter = require './Features/LinkedFiles/LinkedFilesRouter' TemplatesRouter = require './Features/Templates/TemplatesRouter' InstitutionsController = require './Features/Institutions/InstitutionsController' +UserMembershipRouter = require './Features/UserMembership/UserMembershipRouter' logger = require("logger-sharelatex") _ = require("underscore") @@ -85,6 +86,7 @@ module.exports = class Router AnalyticsRouter.apply(webRouter, privateApiRouter, publicApiRouter) LinkedFilesRouter.apply(webRouter, privateApiRouter, publicApiRouter) TemplatesRouter.apply(webRouter) + UserMembershipRouter.apply(webRouter) Modules.applyRouter(webRouter, privateApiRouter, publicApiRouter) @@ -248,7 +250,7 @@ module.exports = class Router webRouter.post '/project/:project_id/export/:brand_variation_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportProject webRouter.get '/project/:project_id/export/:export_id', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportStatus - webRouter.get '/project/:project_id/export/:export_id/zip', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportZip + webRouter.get '/project/:project_id/export/:export_id/:type', AuthorizationMiddlewear.ensureUserCanAdminProject, ExportsController.exportDownload webRouter.get '/Project/:Project_id/download/zip', AuthorizationMiddlewear.ensureUserCanReadProject, ProjectDownloadsController.downloadProject webRouter.get '/project/download/zip', AuthorizationMiddlewear.ensureUserCanReadMultipleProjects, ProjectDownloadsController.downloadMultipleProjects diff --git a/services/web/app/views/_metadata.pug b/services/web/app/views/_metadata.pug index 4857162a9e..15f08b038b 100644 --- a/services/web/app/views/_metadata.pug +++ b/services/web/app/views/_metadata.pug @@ -25,10 +25,16 @@ //- Image -if (metadata && metadata.image) + //- from the CMS meta(itemprop="image", name="image", content=metadata.image.fields.file.url) +-else if (metadata && metadata.image_src) + //- pages with custom metadata images, metadata.image_src is the full image URL + meta(itemprop="image", name="image", content=metadata.image_src) -else if (settings.overleaf) + //- the default image for Overleaf meta(itemprop="image", name="image", content=buildImgPath('ol-brand/overleaf_og_logo.png')) -else + //- the default image for ShareLaTeX meta(itemprop="image", name="image", content='/touch-icon-192x192.png') //- Keywords @@ -45,8 +51,15 @@ meta(name="twitter:card", content=metadata.twitterCardType ? metadata.twitterCar -if (metadata && metadata.twitterDescription) meta(itemprop="twitter:description", content=metadata.twitterDescription) -if (metadata && metadata.twitterImage) + //- from the CMS meta(itemprop="image", name="twitter:image", content=metadata.twitterImage.fields.file.url) meta(itemprop="image", name="twitter:image:alt", content=metadata.twitterImage.fields.title) +-else if (settings.overleaf) + //- the default image for Overleaf + meta(itemprop="image", name="twitter:image", content=buildImgPath('ol-brand/overleaf_og_logo.png')) +-else + //- the default image for ShareLaTeX + meta(itemprop="image", name="twitter:image", content='/touch-icon-192x192.png') //- Open Graph //- to do - add og:url @@ -55,7 +68,14 @@ meta(name="twitter:card", content=metadata.twitterCardType ? metadata.twitterCar -if (metadata && metadata.openGraphDescription) meta(itemprop="description", name="og:description", content=metadata.openGraphDescription) -if (metadata && metadata.openGraphImage) + //- from the CMS meta(itemprop="image", name="og:image", content=metadata.openGraphImage.fields.file.url) +-else if (settings.overleaf) + //- the default image for Overleaf + meta(itemprop="image", name="og:image", content=buildImgPath('ol-brand/overleaf_og_logo.png')) +-else + //- the default image for ShareLaTeX + meta(itemprop="image", name="og:image", content='/touch-icon-192x192.png') -if (metadata && metadata.openGraphType) meta(name="og:type", metadata.openGraphType) -else diff --git a/services/web/app/views/general/account-merge-error.pug b/services/web/app/views/general/account-merge-error.pug new file mode 100644 index 0000000000..68af40a267 --- /dev/null +++ b/services/web/app/views/general/account-merge-error.pug @@ -0,0 +1,11 @@ +extends ../layout + +block content + .content.content-alt + .container + .row + .col-md-6.col-md-offset-3 + .card + .page-header + h1 Account Access Error + p.text-danger Sorry, an error occurred accessing your account. Please #[a(href="" ng-controller="ContactModal" ng-click="contactUsModal()") contact support] and provide any email addresses that you have used to sign in to Overleaf and/or ShareLaTeX for assistance. diff --git a/services/web/app/views/project/editor/pdf.pug b/services/web/app/views/project/editor/pdf.pug index 44410a44a8..f0d1a11666 100644 --- a/services/web/app/views/project/editor/pdf.pug +++ b/services/web/app/views/project/editor/pdf.pug @@ -106,7 +106,7 @@ div.full-size.pdf(ng-controller="PdfController") | #{translate("code_check_failed")} a( href, - ng-click="switchToFlatLayout()" + ng-click="switchToFlatLayout('pdf')" ng-show="ui.pdfLayout == 'sideBySide'" tooltip=translate('full_screen') tooltip-placement="bottom" @@ -116,7 +116,7 @@ div.full-size.pdf(ng-controller="PdfController") i.full-screen a( href, - ng-click="switchToSideBySideLayout()" + ng-click="switchToSideBySideLayout('editor')" ng-show="ui.pdfLayout == 'flat'" tooltip=translate('split_screen') tooltip-placement="bottom" diff --git a/services/web/app/views/project/editor/share.pug b/services/web/app/views/project/editor/share.pug index 17da2df67a..ac238d7ad5 100644 --- a/services/web/app/views/project/editor/share.pug +++ b/services/web/app/views/project/editor/share.pug @@ -19,7 +19,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate') ) #{translate('turn_on_link_sharing')} span    a( - href="/learn/Kb/what_is_link_sharing" + href="/learn/how-to/What_is_Link_Sharing%3F" target="_blank" ) i.fa.fa-question-circle( @@ -38,7 +38,7 @@ script(type='text/ng-template', id='shareProjectModalTemplate') ) #{translate('turn_off_link_sharing')} span    a( - href="/learn/Kb/what_is_link_sharing" + href="/learn/how-to/What_is_Link_Sharing%3F" target="_blank" ) i.fa.fa-question-circle( diff --git a/services/web/app/views/project/list/modals.pug b/services/web/app/views/project/list/modals.pug index 50d7f8d2ef..f89b26925c 100644 --- a/services/web/app/views/project/list/modals.pug +++ b/services/web/app/views/project/list/modals.pug @@ -98,7 +98,7 @@ script(type='text/ng-template', id='renameProjectModalTemplate') ) × h3 #{translate("rename_project")} .modal-body - .alert.alert-danger(ng-show="state.error.message") {{ state.error.message}} + .alert.alert-danger(ng-show="state.error.message") {{state.error.message}} .alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")} form(name="renameProjectForm", novalidate) input.form-control( @@ -127,7 +127,7 @@ script(type='text/ng-template', id='cloneProjectModalTemplate') ) × h3 #{translate("copy_project")} .modal-body - .alert.alert-danger(ng-show="state.error.message") {{ state.error.message}} + .alert.alert-danger(ng-show="state.error.message") {{state.error.message === "invalid element name" ? "#{translate("invalid_element_name")}" : state.error.message}} .alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")} form(name="cloneProjectForm", novalidate) .form-group @@ -161,7 +161,7 @@ script(type='text/ng-template', id='newProjectModalTemplate') ) × h3 #{translate("new_project")} .modal-body - .alert.alert-danger(ng-show="state.error.message") {{ state.error.message}} + .alert.alert-danger(ng-show="state.error.message") {{state.error.message}} .alert.alert-danger(ng-show="state.error && !state.error.message") #{translate("generic_something_went_wrong")} form(novalidate, name="newProjectForm") input.form-control( @@ -262,6 +262,20 @@ script(type="text/ng-template", id="uploadProjectModalTemplate") .modal-footer button.btn.btn-default(ng-click="cancel()") #{translate("cancel")} +script(type="text/ng-template", id="showErrorModalTemplate") + .modal-header + button.close( + type="button" + data-dismiss="modal" + ng-click="cancel()" + ) × + h3 #{translate("generic_something_went_wrong")} + .modal-body + .alert.alert-danger(ng-show="error.message") {{error.message === "invalid element name" ? "#{translate("invalid_element_name")}" : error.message}} + .alert.alert-danger(ng-show="error && !error.message") #{translate("generic_something_went_wrong")} + .modal-footer + button.btn.btn-default(ng-click="cancel()") #{translate("cancel")} + script(type="text/ng-template", id="userProfileModalTemplate") .modal-header button.close( diff --git a/services/web/app/views/user/settings.pug b/services/web/app/views/user/settings.pug index 9acf763bfe..b7168de1cf 100644 --- a/services/web/app/views/user/settings.pug +++ b/services/web/app/views/user/settings.pug @@ -33,7 +33,7 @@ block content required, ng-model="email", ng-init="email = "+JSON.stringify(user.email), - ng-model-options="{ updateOn: 'blur' }" + ng-model-options="{ pdateOn: 'blur' }" ) span.small.text-primary(ng-show="settingsForm.email.$invalid && settingsForm.email.$dirty") | #{translate("must_be_email_address")} @@ -73,11 +73,24 @@ block content label.control-label #{translate("last_name")} div.form-control(readonly="true") #{user.last_name} - - if !externalAuthenticationSystemUsed() - .col-md-5.col-md-offset-1 - h3 #{translate("change_password")} - form(async-form="changepassword", name="changePasswordForm", action="/user/password/update", method="POST", novalidate) + .col-md-5.col-md-offset-1 + h3 #{translate("change_password")} + if externalAuthenticationSystemUsed() && !settings.overleaf + p + Password settings are managed externally + else + - var submitAction + if settings.overleaf + - submitAction = '/user/change_password/v1' + else + - submitAction = '/user/password/update' + form( + async-form="changepassword" + name="changePasswordForm" + action=submitAction + method="POST" + novalidate + ) input(type="hidden", name="_csrf", value=csrfToken) .form-group label(for='currentPassword') #{translate("current_password")} @@ -121,15 +134,6 @@ block content ng-disabled="changePasswordForm.$invalid" ) #{translate("change")} - else - if settings.overleaf && settings.createV1AccountOnLogin - .col-md-5.col-md-offset-1 - h3 #{translate("change_password")} - p - | To change your password, - | please go to #[a(href='/sign_in_to_v1?return_to=/users/edit%23details') Overleaf v1 settings] - - | !{moduleIncludes("userSettings", locals)} //- The beta program doesn't make much sense to include while v2 is going diff --git a/services/web/app/views/subscriptions/group_admin.pug b/services/web/app/views/user_membership/index.pug similarity index 68% rename from services/web/app/views/subscriptions/group_admin.pug rename to services/web/app/views/user_membership/index.pug index cdb18a0320..4a512a1c11 100644 --- a/services/web/app/views/subscriptions/group_admin.pug +++ b/services/web/app/views/user_membership/index.pug @@ -5,16 +5,16 @@ block content .container .row .col-md-10.col-md-offset-1 - .card(ng-controller="SubscriptionGroupMembersController") + .card(ng-controller="UserMembershipController") .page-header .pull-right(ng-cloak) - small(ng-show="selectedUsers.length == 0") !{translate("you_have_added_x_of_group_size_y", {addedUsersSize:"{{ users.length }}", groupSize:"{{ groupSize }}"})} + small(ng-show="groupSize && selectedUsers.length == 0") !{translate("you_have_added_x_of_group_size_y", {addedUsersSize:"{{ users.length }}", groupSize:"{{ groupSize }}"})} a.btn.btn-danger( href, ng-show="selectedUsers.length > 0" ng-click="removeMembers()" - ) #{translate("remove_from_group")} - h1 #{translate("group_account")} + ) #{translate(translations.remove)} + h1 #{translate(translations.title)} .row-spaced-small ul.list-unstyled.structured-list( @@ -35,7 +35,7 @@ block content span.header #{translate("accepted_invite")} li.container-fluid( ng-repeat="user in users | orderBy:'email':true", - ng-controller="SubscriptionGroupMemberListItemController" + ng-controller="UserMembershipListItemController" ) .row .col-md-5 @@ -60,7 +60,7 @@ block content small #{translate("no_members")} hr - div(ng-if="users.length < groupSize", ng-cloak) + div(ng-if="!groupSize || users.length < groupSize", ng-cloak) p.small #{translate("add_more_members")} form.form .row @@ -74,18 +74,16 @@ block content ) .col-xs-4 button.btn.btn-primary(ng-click="addMembers()") #{translate("add")} - .col-xs-2 - a(href="/subscription/group/export") Export CSV + .col-xs-2(ng-if="paths.exportMembers", ng-cloak) + a(href=paths.exportMembers) Export CSV - div(ng-if="users.length >= groupSize && users.length > 0", ng-cloak) + div(ng-if="groupSize && users.length >= groupSize && users.length > 0", ng-cloak) .row - .col-xs-2.col-xs-offset-10 - a(href="/subscription/group/export") Export CSV + .col-xs-2.col-xs-offset-10(ng-if="paths.exportMembers", ng-cloak) + a(href=paths.exportMembers) Export CSV script(type="text/javascript"). window.users = !{JSON.stringify(users)}; - window.groupSize = #{subscription.membersLimit}; - - - + window.paths = !{JSON.stringify(paths)}; + window.groupSize = #{entity.membersLimit || 'null'}; diff --git a/services/web/config/settings.defaults.coffee b/services/web/config/settings.defaults.coffee index 9097c4e433..5136ef19f2 100644 --- a/services/web/config/settings.defaults.coffee +++ b/services/web/config/settings.defaults.coffee @@ -91,7 +91,7 @@ module.exports = settings = # running which conflict, or want to run the web process on port 80. internal: web: - port: webPort = 3000 + port: webPort = process.env['WEB_PORT'] or 3000 host: process.env['LISTEN_ADDRESS'] or 'localhost' documentupdater: port: docUpdaterPort = 3003 @@ -192,7 +192,7 @@ module.exports = settings = #clsiCookieKey: "clsiserver" # Same, but with http auth credentials. - httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@localhost:3000' + httpAuthSiteUrl: 'http://#{httpAuthUser}:#{httpAuthPass}@#{siteUrl}' maxEntitiesPerProject: 2000 diff --git a/services/web/npm-shrinkwrap.json b/services/web/npm-shrinkwrap.json index 8fe259f82c..37a05d1681 100644 --- a/services/web/npm-shrinkwrap.json +++ b/services/web/npm-shrinkwrap.json @@ -6962,9 +6962,9 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz" }, "metrics-sharelatex": { - "version": "1.7.1", - "from": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.7.1", - "resolved": "git+https://github.com/sharelatex/metrics-sharelatex.git#166961924c599b1f9468f2e17846fa2a9d12372d", + "version": "1.8.0", + "from": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.8.0", + "resolved": "git+https://github.com/sharelatex/metrics-sharelatex.git#e57f1a84539cdf0398d0768b7f7af0c79ea5b05b", "dependencies": { "coffee-script": { "version": "1.6.0", diff --git a/services/web/package.json b/services/web/package.json index 0bacc0d2d1..d43544dcbe 100644 --- a/services/web/package.json +++ b/services/web/package.json @@ -62,7 +62,7 @@ "mailchimp-api-v3": "^1.12.0", "marked": "^0.3.5", "method-override": "^2.3.3", - "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.7.1", + "metrics-sharelatex": "git+https://github.com/sharelatex/metrics-sharelatex.git#v1.8.0", "minimist": "1.2.0", "mocha": "^5.0.1", "mongojs": "2.4.0", diff --git a/services/web/public/coffee/directives/asyncForm.coffee b/services/web/public/coffee/directives/asyncForm.coffee index acafec563d..9baadfd1f2 100644 --- a/services/web/public/coffee/directives/asyncForm.coffee +++ b/services/web/public/coffee/directives/asyncForm.coffee @@ -70,7 +70,11 @@ define [ onErrorHandler(httpResponse) return - if status == 403 # Forbidden + if status == 400 # Bad Request + response.message = + text: "Invalid Request. Please correct the data and try again." + type: 'error' + else if status == 403 # Forbidden response.message = text: "Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies." type: "error" diff --git a/services/web/public/coffee/ide.coffee b/services/web/public/coffee/ide.coffee index 1b9eda57d6..12f6dba2ce 100644 --- a/services/web/public/coffee/ide.coffee +++ b/services/web/public/coffee/ide.coffee @@ -190,6 +190,23 @@ define [ ide.localStorage = localStorage ide.browserIsSafari = false + + $scope.switchToFlatLayout = (view) -> + $scope.ui.pdfLayout = 'flat' + $scope.ui.view = view + ide.localStorage "pdf.layout", "flat" + + $scope.switchToSideBySideLayout = (view) -> + $scope.ui.pdfLayout = 'sideBySide' + $scope.ui.view = view + localStorage "pdf.layout", "split" + + if pdfLayout = localStorage("pdf.layout") + $scope.switchToSideBySideLayout() if pdfLayout == "split" + $scope.switchToFlatLayout() if pdfLayout == "flat" + else + $scope.switchToSideBySideLayout() + try userAgent = navigator.userAgent ide.browserIsSafari = ( diff --git a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee index 001178c5dd..52295809d5 100644 --- a/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee +++ b/services/web/public/coffee/ide/pdf/controllers/PdfController.coffee @@ -71,7 +71,7 @@ define [ autoCompileInterval = null autoCompileIfReady = () -> - if $scope.pdf.compiling + if $scope.pdf.compiling or !$scope.autocompile_enabled return # Only checking linting if syntaxValidation is on and visible to the user @@ -125,6 +125,9 @@ define [ $scope.uncompiledChanges = false recalculateUncompiledChanges = () -> + if !$scope.autocompile_enabled + # Auto-compile was disabled + $scope.uncompiledChanges = false if $scope.ui.pdfHidden # Don't bother auto-compiling if pdf isn't visible $scope.uncompiledChanges = false @@ -588,22 +591,6 @@ define [ {doc, line} = data ide.editorManager.openDoc(doc, gotoLine: line) - $scope.switchToFlatLayout = () -> - $scope.ui.pdfLayout = 'flat' - $scope.ui.view = 'pdf' - ide.localStorage "pdf.layout", "flat" - - $scope.switchToSideBySideLayout = () -> - $scope.ui.pdfLayout = 'sideBySide' - $scope.ui.view = 'editor' - localStorage "pdf.layout", "split" - - if pdfLayout = localStorage("pdf.layout") - $scope.switchToSideBySideLayout() if pdfLayout == "split" - $scope.switchToFlatLayout() if pdfLayout == "flat" - else - $scope.switchToSideBySideLayout() - App.factory "synctex", ["ide", "$http", "$q", (ide, $http, $q) -> # enable per-user containers by default perUserCompile = true diff --git a/services/web/public/coffee/main.coffee b/services/web/public/coffee/main.coffee index fc62c6bc38..49b211b0aa 100644 --- a/services/web/public/coffee/main.coffee +++ b/services/web/public/coffee/main.coffee @@ -5,7 +5,7 @@ define [ "main/clear-sessions" "main/account-upgrade" "main/plans" - "main/group-members" + "main/user-membership" "main/scribtex-popup" "main/event" "main/bonus" diff --git a/services/web/public/coffee/main/project-list/modal-controllers.coffee b/services/web/public/coffee/main/project-list/modal-controllers.coffee index 27124e0951..3d15797940 100644 --- a/services/web/public/coffee/main/project-list/modal-controllers.coffee +++ b/services/web/public/coffee/main/project-list/modal-controllers.coffee @@ -2,9 +2,9 @@ define [ "base" ], (App) -> App.controller 'RenameProjectModalController', ($scope, $modalInstance, $timeout, project, queuedHttp) -> - $scope.inputs = + $scope.inputs = projectName: project.name - + $scope.state = inflight: false error: false @@ -35,7 +35,7 @@ define [ $modalInstance.dismiss('cancel') App.controller 'CloneProjectModalController', ($scope, $modalInstance, $timeout, project) -> - $scope.inputs = + $scope.inputs = projectName: project.name + " (Copy)" $scope.state = inflight: false @@ -66,7 +66,7 @@ define [ $modalInstance.dismiss('cancel') App.controller 'NewProjectModalController', ($scope, $modalInstance, $timeout, template) -> - $scope.inputs = + $scope.inputs = projectName: "" $scope.state = inflight: false @@ -123,7 +123,6 @@ define [ $scope.cancel = () -> $modalInstance.dismiss('cancel') - App.controller 'UploadProjectModalController', ($scope, $modalInstance, $timeout) -> $scope.cancel = () -> $modalInstance.dismiss('cancel') @@ -137,3 +136,8 @@ define [ $scope.dismiss = () -> $modalInstance.dismiss('cancel') + + App.controller 'ShowErrorModalController', ($scope, $modalInstance, error) -> + $scope.error = error + $scope.cancel = () -> + $modalInstance.dismiss('cancel') diff --git a/services/web/public/coffee/main/project-list/project-list.coffee b/services/web/public/coffee/main/project-list/project-list.coffee index 9a8d7f7a73..8bdec6f42a 100644 --- a/services/web/public/coffee/main/project-list/project-list.coffee +++ b/services/web/public/coffee/main/project-list/project-list.coffee @@ -13,7 +13,7 @@ define [ $scope.predicate = "lastUpdated" $scope.nUntagged = 0 $scope.reverse = true - $scope.searchText = + $scope.searchText = value : "" $timeout () -> @@ -37,7 +37,7 @@ define [ angular.element($window).bind "resize", () -> recalculateProjectListHeight() $scope.$apply() - + # Allow tags to be accessed on projects as well projectsById = {} for project in $scope.projects @@ -56,7 +56,7 @@ define [ tag.selected = true else tag.selected = false - + $scope.changePredicate = (newPredicate)-> if $scope.predicate == newPredicate $scope.reverse = !$scope.reverse @@ -145,7 +145,7 @@ define [ # We don't want hidden selections project.selected = false - localStorage("project_list", JSON.stringify({ + localStorage("project_list", JSON.stringify({ filter: $scope.filter, selectedTagId: selectedTag?._id })) @@ -461,7 +461,7 @@ define [ resolve: project: () -> project ) - + if storedUIOpts?.filter? if storedUIOpts.filter == "tag" and storedUIOpts.selectedTagId? markTagAsSelected(storedUIOpts.selectedTagId) @@ -505,7 +505,16 @@ define [ $scope.project.isTableActionInflight = true $scope.cloneProject($scope.project, "#{$scope.project.name} (Copy)") .then () -> $scope.project.isTableActionInflight = false - .catch () -> $scope.project.isTableActionInflight = false + .catch (response) -> + { data, status } = response + error = if status == 400 then message: data else true + modalInstance = $modal.open( + templateUrl: "showErrorModalTemplate" + controller: "ShowErrorModalController" + resolve: + error: () -> error + ) + $scope.project.isTableActionInflight = false $scope.download = (e) -> e.stopPropagation() @@ -535,11 +544,11 @@ define [ url: "/project/#{$scope.project.id}?forever=true" headers: "X-CSRF-Token": window.csrfToken - }).then () -> + }).then () -> $scope.project.isTableActionInflight = false $scope._removeProjectFromList $scope.project for tag in $scope.tags $scope._removeProjectIdsFromTagArray(tag, [ $scope.project.id ]) $scope.updateVisibleProjects() - .catch () -> + .catch () -> $scope.project.isTableActionInflight = false diff --git a/services/web/public/coffee/main/subscription-dashboard.coffee b/services/web/public/coffee/main/subscription-dashboard.coffee index 28d5af013b..7ae01d1b52 100644 --- a/services/web/public/coffee/main/subscription-dashboard.coffee +++ b/services/web/public/coffee/main/subscription-dashboard.coffee @@ -36,7 +36,7 @@ define [ $scope.pricing = MultiCurrencyPricing # $scope.plans = MultiCurrencyPricing.plans - $scope.currencySymbol = MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode].symbol + $scope.currencySymbol = MultiCurrencyPricing.plans[MultiCurrencyPricing.currencyCode]?.symbol $scope.currencyCode = MultiCurrencyPricing.currencyCode diff --git a/services/web/public/coffee/main/group-members.coffee b/services/web/public/coffee/main/user-membership.coffee similarity index 75% rename from services/web/public/coffee/main/group-members.coffee rename to services/web/public/coffee/main/user-membership.coffee index 028639c741..1d2df70407 100644 --- a/services/web/public/coffee/main/group-members.coffee +++ b/services/web/public/coffee/main/user-membership.coffee @@ -1,9 +1,10 @@ define [ "base" ], (App) -> - App.controller "SubscriptionGroupMembersController", ($scope, queuedHttp) -> + App.controller "UserMembershipController", ($scope, queuedHttp) -> $scope.users = window.users $scope.groupSize = window.groupSize + $scope.paths = window.paths $scope.selectedUsers = [] $scope.inputs = @@ -22,7 +23,7 @@ define [ emails = parseEmails($scope.inputs.emails) for email in emails queuedHttp - .post("/subscription/invites", { + .post(paths.addMember, { email: email, _csrf: window.csrfToken }) @@ -34,10 +35,12 @@ define [ $scope.removeMembers = () -> for user in $scope.selectedUsers do (user) -> - if user.invite and !user._id? - url = "/subscription/invites/#{encodeURIComponent(user.email)}" + if paths.removeInvite and user.invite and !user._id? + url = "#{paths.removeInvite}/#{encodeURIComponent(user.email)}" + else if paths.removeMember and user._id? + url = "#{paths.removeMember}/#{user._id}" else - url = "/subscription/group/user/#{user._id}" + return queuedHttp({ method: "DELETE", url: url @@ -53,7 +56,7 @@ define [ $scope.updateSelectedUsers = () -> $scope.selectedUsers = $scope.users.filter (user) -> user.selected - App.controller "SubscriptionGroupMemberListItemController", ($scope) -> + App.controller "UserMembershipListItemController", ($scope) -> $scope.$watch "user.selected", (value) -> if value? $scope.updateSelectedUsers() diff --git a/services/web/public/stylesheets/app/homepage.less b/services/web/public/stylesheets/app/homepage.less index 795d04488f..9f60c58cb3 100644 --- a/services/web/public/stylesheets/app/homepage.less +++ b/services/web/public/stylesheets/app/homepage.less @@ -94,6 +94,14 @@ border-radius: 9999px; } } + .hp-register-password-error { + margin-bottom: 9px; + } + .register-banner__password-error { + padding: 5px 9px; + border: none; + border-radius: @btn-border-radius-base; + } .screenshot { height: 600px; margin: auto; diff --git a/services/web/public/stylesheets/app/templates-v2.less b/services/web/public/stylesheets/app/templates-v2.less index 7f708670b4..8d8358a6b4 100644 --- a/services/web/public/stylesheets/app/templates-v2.less +++ b/services/web/public/stylesheets/app/templates-v2.less @@ -16,10 +16,14 @@ } .cta-links { - margin-bottom: @margin-md; - .cta-link.btn { - margin-right: @margin-sm; + margin: 0 @margin-sm @margin-sm 0; + } +} + +.popular-tags { + .template-thumbnail { + margin: 0 0 1em 0!important; } } @@ -28,25 +32,24 @@ } .templates-container { - column-count: 3; - column-gap: 1em; + column-count: 2; + column-gap: 2em; } .template-thumbnail { - &.template-thumbnail__container { - display: inline-block; - margin: 0 0 1em; - width: 100%; - } + display: inline-block; + margin: 0 0 2em; + width: 100%; .thumbnail { box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - margin: 5% 0; + margin: 0 0 @margin-sm 0; padding:0px; display: flex; justify-content: center; align-items: center; overflow: hidden; + width: 100%; h3 { color:@link-color; @@ -67,33 +70,37 @@ .caption__description { font-style: italic; - padding: 5px 0; + padding: 0 0 5px 0; .text-overflow(); } .caption__title { display: inline-block; - max-width: 100%; + width: 100%; + text-align: center; .text-overflow(); } +} - /* Media Queries */ - @media (max-width: @screen-md-min) { - .thumbnail { - margin: 5% auto; - } +.template-large-pdf-preview { + border: solid 1px @gray-lightest; + margin-top: @margin-lg; +} - .caption .description { - padding: 5px 50px; - } +/* Media Queries */ +@media (min-width: @screen-sm-min) { + .templates-container { + column-count: 3; + column-gap: 3em; } } - .section-tags { - margin-bottom: @margin-xl; - margin-top: @margin-md; - } - +@media (min-width: @screen-md-min) { .template-large-pdf-preview { - border: solid 1px @gray-lightest; - margin-bottom: 30px; - } \ No newline at end of file + margin-top: 0; + } +} + +.section-tags { + margin-bottom: @margin-xl; + margin-top: @margin-md; +} \ No newline at end of file diff --git a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee index 78f7b288f6..2f18743a1c 100644 --- a/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee +++ b/services/web/test/unit/coffee/Authentication/AuthenticationControllerTests.coffee @@ -30,6 +30,8 @@ describe "AuthenticationController", -> revokeAllUserSessions: sinon.stub().callsArgWith(1, null) "../../infrastructure/Modules": @Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, [])}} "../SudoMode/SudoModeHandler": @SudoModeHandler = {activateSudoMode: sinon.stub().callsArgWith(1, null)} + "../Notifications/NotificationsBuilder": @NotificationsBuilder = + ipMatcherAffiliation: sinon.stub() @user = _id: ObjectId() email: @email = "USER@example.com" diff --git a/services/web/test/unit/coffee/Authentication/AuthenticationManagerTests.coffee b/services/web/test/unit/coffee/Authentication/AuthenticationManagerTests.coffee index 0a041a0865..39880b112e 100644 --- a/services/web/test/unit/coffee/Authentication/AuthenticationManagerTests.coffee +++ b/services/web/test/unit/coffee/Authentication/AuthenticationManagerTests.coffee @@ -94,6 +94,50 @@ describe "AuthenticationManager", -> it "should not return a user", -> @callback.calledWith(null, null).should.equal true + describe "validateEmail", -> + describe "valid", -> + it "should return null", -> + result = @AuthenticationManager.validateEmail 'foo@example.com' + expect(result).to.equal null + + describe "invalid", -> + it "should return validation error object for no email", -> + result = @AuthenticationManager.validateEmail '' + expect(result).to.not.equal null + expect(result.message).to.equal 'email not valid' + + it "should return validation error object for invalid", -> + result = @AuthenticationManager.validateEmail 'notanemail' + expect(result).to.not.equal null + expect(result.message).to.equal 'email not valid' + + describe "validatePassword", -> + it "should return null if valid", -> + result = @AuthenticationManager.validatePassword 'banana' + expect(result).to.equal null + + describe "invalid", -> + beforeEach -> + @settings.passwordStrengthOptions = + length: + max:10 + min:6 + + it "should return validation error object if not set", -> + result = @AuthenticationManager.validatePassword() + expect(result).to.not.equal null + expect(result.message).to.equal 'password not set' + + it "should return validation error object if too short", -> + result = @AuthenticationManager.validatePassword 'dsd' + expect(result).to.not.equal null + expect(result.message).to.equal 'password is too short' + + it "should return validation error object if too long", -> + result = @AuthenticationManager.validatePassword 'dsdsadsadsadsadsadkjsadjsadjsadljs' + expect(result).to.not.equal null + expect(result.message).to.equal 'password is too long' + describe "setUserPassword", -> beforeEach -> @user_id = ObjectId() diff --git a/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee b/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee index 103a1f9eea..0d070d687e 100644 --- a/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee +++ b/services/web/test/unit/coffee/Compile/CompileControllerTests.coffee @@ -194,7 +194,6 @@ describe "CompileController", -> .should.equal true it "should set the content-disposition header with a safe version of the project name", -> - console.log @res.setContentDisposition.args[0] @res.setContentDisposition .calledWith('', filename: "test_nam_.pdf") .should.equal true diff --git a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee index edd1ce127a..f766feb6dd 100644 --- a/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee +++ b/services/web/test/unit/coffee/Exports/ExportsHandlerTests.coffee @@ -301,7 +301,7 @@ describe 'ExportsHandler', -> @callback.calledWith(null, { body: @body }) .should.equal true - describe 'fetchZip', -> + describe 'fetchDownload', -> beforeEach (done) -> @settings.apis = v1: @@ -316,7 +316,7 @@ describe 'ExportsHandler', -> describe "when all goes well", -> beforeEach (done) -> @stubRequest.get = @stubGet - @ExportsHandler.fetchZip @export_id, (error, body) => + @ExportsHandler.fetchDownload @export_id, 'zip', (error, body) => @callback(error, body) done() diff --git a/services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee b/services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee new file mode 100644 index 0000000000..3edb55e09a --- /dev/null +++ b/services/web/test/unit/coffee/Institutions/InstitutionsLocatorTests.coffee @@ -0,0 +1,29 @@ +SandboxedModule = require('sandboxed-module') +should = require('chai').should() +sinon = require('sinon') +assertCalledWith = sinon.assert.calledWith +assertNotCalled = sinon.assert.notCalled +modulePath = "../../../../app/js/Features/Institutions/InstitutionsLocator" +assert = require("chai").assert +ObjectId = require('mongoose').Types.ObjectId + +describe 'InstitutionsLocator', -> + beforeEach -> + @user = + _id: "5208dd34438842e2db333333" + @institution = + v1Id: 123 + managersIds: [ObjectId(), ObjectId()] + @Institution = + findOne: sinon.stub().yields(null, @institution) + @InstitutionsLocator = SandboxedModule.require modulePath, requires: + '../../models/Institution': Institution: @Institution + "logger-sharelatex": log:-> + + describe "finding managed institution", -> + + it "should query the database", (done) -> + @InstitutionsLocator.findManagedInstitution @user._id, (err, institution)=> + assertCalledWith(@Institution.findOne, { managerIds: @user._id }) + institution.should.equal @institution + done() diff --git a/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee b/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee index 3de4546e6d..5c31113552 100644 --- a/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee +++ b/services/web/test/unit/coffee/Project/ProjectEntityUpdateHandlerTests.coffee @@ -289,71 +289,101 @@ describe 'ProjectEntityUpdateHandler', -> .should.equal true describe 'addDoc', -> - beforeEach -> - @path = "/path/to/doc" + describe 'adding a doc', -> + beforeEach -> + @path = "/path/to/doc" - @newDoc = _id: doc_id - @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory = - withoutLock: sinon.stub().yields(null, @newDoc, folder_id, @path, @project) - @ProjectEntityUpdateHandler.addDoc project_id, folder_id, @docName, @docLines, userId, @callback + @newDoc = _id: doc_id + @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory = + withoutLock: sinon.stub().yields(null, @newDoc, folder_id, @path, @project) + @ProjectEntityUpdateHandler.addDoc project_id, folder_id, @docName, @docLines, userId, @callback - it "creates the doc without history", () -> - @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory.withoutLock - .calledWith(project_id, folder_id, @docName, @docLines, userId) - .should.equal true + it "creates the doc without history", () -> + @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory.withoutLock + .calledWith(project_id, folder_id, @docName, @docLines, userId) + .should.equal true - it "sends the change in project structure to the doc updater", () -> - newDocs = [ - doc: @newDoc - path: @path - docLines: @docLines.join('\n') - ] - @DocumentUpdaterHandler.updateProjectStructure - .calledWith(project_id, projectHistoryId, userId, {newDocs}) - .should.equal true + it "sends the change in project structure to the doc updater", () -> + newDocs = [ + doc: @newDoc + path: @path + docLines: @docLines.join('\n') + ] + @DocumentUpdaterHandler.updateProjectStructure + .calledWith(project_id, projectHistoryId, userId, {newDocs}) + .should.equal true + + describe 'adding a doc with an invalid name', -> + beforeEach -> + @path = "/path/to/doc" + + @newDoc = _id: doc_id + @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory = + withoutLock: sinon.stub().yields(null, @newDoc, folder_id, @path, @project) + @ProjectEntityUpdateHandler.addDoc project_id, folder_id, "*" + @docName, @docLines, userId, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true describe 'addFile', -> - beforeEach -> - @path = "/path/to/file" + describe 'adding a file', -> + beforeEach -> + @path = "/path/to/file" - @newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData} - @TpdsUpdateSender.addFile = sinon.stub().yields() - @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project) - @ProjectEntityUpdateHandler.addFile project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback + @newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData} + @TpdsUpdateSender.addFile = sinon.stub().yields() + @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project) + @ProjectEntityUpdateHandler.addFile project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback - it "updates the file in the filestore", () -> - @FileStoreHandler.uploadFileFromDisk - .calledWith(project_id, file_id, @fileSystemPath) - .should.equal true + it "updates the file in the filestore", () -> + @FileStoreHandler.uploadFileFromDisk + .calledWith(project_id, file_id, @fileSystemPath) + .should.equal true - it "updates the file in mongo", () -> - fileMatcher = sinon.match (file) => - file.name == @fileName + it "updates the file in mongo", () -> + fileMatcher = sinon.match (file) => + file.name == @fileName - @ProjectEntityMongoUpdateHandler.addFile - .calledWithMatch(project_id, folder_id, fileMatcher) - .should.equal true + @ProjectEntityMongoUpdateHandler.addFile + .calledWithMatch(project_id, folder_id, fileMatcher) + .should.equal true - it "notifies the tpds", () -> - @TpdsUpdateSender.addFile - .calledWith({ - project_id: project_id - project_name: @project.name - file_id: file_id - rev: 0 + it "notifies the tpds", () -> + @TpdsUpdateSender.addFile + .calledWith({ + project_id: project_id + project_name: @project.name + file_id: file_id + rev: 0 + path: @path + }) + .should.equal true + + it "sends the change in project structure to the doc updater", () -> + newFiles = [ + file: @newFile path: @path - }) - .should.equal true + url: @fileUrl + ] + @DocumentUpdaterHandler.updateProjectStructure + .calledWith(project_id, projectHistoryId, userId, {newFiles}) + .should.equal true - it "sends the change in project structure to the doc updater", () -> - newFiles = [ - file: @newFile - path: @path - url: @fileUrl - ] - @DocumentUpdaterHandler.updateProjectStructure - .calledWith(project_id, projectHistoryId, userId, {newFiles}) - .should.equal true + describe 'adding a file with an invalid name', -> + beforeEach -> + @path = "/path/to/file" + + @newFile = {_id: file_id, rev: 0, name: @fileName, linkedFileData: @linkedFileData} + @TpdsUpdateSender.addFile = sinon.stub().yields() + @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project) + @ProjectEntityUpdateHandler.addFile project_id, folder_id, "*" + @fileName, @fileSystemPath, @linkedFileData, userId, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true describe 'replaceFile', -> beforeEach -> @@ -404,83 +434,116 @@ describe 'ProjectEntityUpdateHandler', -> .should.equal true describe 'addDocWithoutUpdatingHistory', -> - beforeEach -> - @path = "/path/to/doc" + describe 'adding a doc', -> + beforeEach -> + @path = "/path/to/doc" - @project = _id: project_id, name: 'some project' + @project = _id: project_id, name: 'some project' - @TpdsUpdateSender.addDoc = sinon.stub().yields() - @DocstoreManager.updateDoc = sinon.stub().yields(null, false, @rev = 5) - @ProjectEntityMongoUpdateHandler.addDoc = sinon.stub().yields(null, {path: fileSystem: @path}, @project) - @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory project_id, folder_id, @docName, @docLines, userId, @callback + @TpdsUpdateSender.addDoc = sinon.stub().yields() + @DocstoreManager.updateDoc = sinon.stub().yields(null, false, @rev = 5) + @ProjectEntityMongoUpdateHandler.addDoc = sinon.stub().yields(null, {path: fileSystem: @path}, @project) + @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory project_id, folder_id, @docName, @docLines, userId, @callback - it "updates the doc in the docstore", () -> - @DocstoreManager.updateDoc - .calledWith(project_id, doc_id, @docLines, 0, {}) - .should.equal true + it "updates the doc in the docstore", () -> + @DocstoreManager.updateDoc + .calledWith(project_id, doc_id, @docLines, 0, {}) + .should.equal true - it "updates the doc in mongo", () -> - docMatcher = sinon.match (doc) => - doc.name == @docName + it "updates the doc in mongo", () -> + docMatcher = sinon.match (doc) => + doc.name == @docName - @ProjectEntityMongoUpdateHandler.addDoc - .calledWithMatch(project_id, folder_id, docMatcher) - .should.equal true + @ProjectEntityMongoUpdateHandler.addDoc + .calledWithMatch(project_id, folder_id, docMatcher) + .should.equal true - it "notifies the tpds", () -> - @TpdsUpdateSender.addDoc - .calledWith({ - project_id: project_id - project_name: @project.name - doc_id: doc_id - rev: 0 - path: @path - }) - .should.equal true + it "notifies the tpds", () -> + @TpdsUpdateSender.addDoc + .calledWith({ + project_id: project_id + project_name: @project.name + doc_id: doc_id + rev: 0 + path: @path + }) + .should.equal true - it "should not should send the change in project structure to the doc updater", () -> - @DocumentUpdaterHandler.updateProjectStructure - .called - .should.equal false + it "should not should send the change in project structure to the doc updater", () -> + @DocumentUpdaterHandler.updateProjectStructure + .called + .should.equal false + + describe 'adding a doc with an invalid name', -> + beforeEach -> + @path = "/path/to/doc" + + @project = _id: project_id, name: 'some project' + + @TpdsUpdateSender.addDoc = sinon.stub().yields() + @DocstoreManager.updateDoc = sinon.stub().yields(null, false, @rev = 5) + @ProjectEntityMongoUpdateHandler.addDoc = sinon.stub().yields(null, {path: fileSystem: @path}, @project) + @ProjectEntityUpdateHandler.addDocWithoutUpdatingHistory project_id, folder_id, "*" + @docName, @docLines, userId, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true describe 'addFileWithoutUpdatingHistory', -> - beforeEach -> - @path = "/path/to/file" + describe 'adding a file', -> + beforeEach -> + @path = "/path/to/file" - @project = _id: project_id, name: 'some project' + @project = _id: project_id, name: 'some project' - @TpdsUpdateSender.addFile = sinon.stub().yields() - @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project) - @ProjectEntityUpdateHandler.addFileWithoutUpdatingHistory project_id, folder_id, @fileName, @fileSystemPath, userId, @callback + @TpdsUpdateSender.addFile = sinon.stub().yields() + @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project) + @ProjectEntityUpdateHandler.addFileWithoutUpdatingHistory project_id, folder_id, @fileName, @fileSystemPath, @linkedFileData, userId, @callback - it "updates the file in the filestore", () -> - @FileStoreHandler.uploadFileFromDisk - .calledWith(project_id, file_id, @fileSystemPath) - .should.equal true + it "updates the file in the filestore", () -> + @FileStoreHandler.uploadFileFromDisk + .calledWith(project_id, file_id, @fileSystemPath) + .should.equal true - it "updates the file in mongo", () -> - fileMatcher = sinon.match (file) => - file.name == @fileName + it "updates the file in mongo", () -> + fileMatcher = sinon.match (file) => + file.name == @fileName - @ProjectEntityMongoUpdateHandler.addFile - .calledWithMatch(project_id, folder_id, fileMatcher) - .should.equal true + @ProjectEntityMongoUpdateHandler.addFile + .calledWithMatch(project_id, folder_id, fileMatcher) + .should.equal true - it "notifies the tpds", () -> - @TpdsUpdateSender.addFile - .calledWith({ - project_id: project_id - project_name: @project.name - file_id: file_id - rev: 0 - path: @path - }) - .should.equal true + it "notifies the tpds", () -> + @TpdsUpdateSender.addFile + .calledWith({ + project_id: project_id + project_name: @project.name + file_id: file_id + rev: 0 + path: @path + }) + .should.equal true - it "should not should send the change in project structure to the doc updater", () -> - @DocumentUpdaterHandler.updateProjectStructure - .called - .should.equal false + it "should not should send the change in project structure to the doc updater", () -> + @DocumentUpdaterHandler.updateProjectStructure + .called + .should.equal false + + describe 'adding a file with an invalid name', -> + beforeEach -> + @path = "/path/to/file" + + @project = _id: project_id, name: 'some project' + + @TpdsUpdateSender.addFile = sinon.stub().yields() + @ProjectEntityMongoUpdateHandler.addFile = sinon.stub().yields(null, {path: fileSystem: @path}, @project) + @ProjectEntityUpdateHandler.addFileWithoutUpdatingHistory project_id, folder_id, "*" + @fileName, @fileSystemPath, @linkedFileData, userId, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true describe 'upsertDoc', -> describe 'upserting into an invalid folder', -> @@ -543,6 +606,20 @@ describe 'ProjectEntityUpdateHandler', -> it 'returns the doc', -> @callback.calledWith(null, @newDoc, true) + describe 'upserting a new doc with an invalid name', -> + beforeEach -> + @folder = _id: folder_id, docs: [] + @newDoc = _id: doc_id + @ProjectLocator.findElement = sinon.stub().yields(null, @folder) + @ProjectEntityUpdateHandler.addDoc = withoutLock: sinon.stub().yields(null, @newDoc) + + @ProjectEntityUpdateHandler.upsertDoc project_id, folder_id, "*" + @docName, @docLines, @source, userId, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true + describe 'upsertFile', -> describe 'upserting into an invalid folder', -> beforeEach -> @@ -593,63 +670,155 @@ describe 'ProjectEntityUpdateHandler', -> it 'returns the file', -> @callback.calledWith(null, @newFile, true) + describe 'upserting a new file with an invalid name', -> + beforeEach -> + @folder = _id: folder_id, fileRefs: [] + @newFile = _id: file_id + @ProjectLocator.findElement = sinon.stub().yields(null, @folder) + @ProjectEntityUpdateHandler.addFile = mainTask: sinon.stub().yields(null, @newFile) + + @ProjectEntityUpdateHandler.upsertFile project_id, folder_id, '*' + @fileName, @fileSystemPath, @linkedFileData, userId, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true + describe 'upsertDocWithPath', -> - beforeEach -> - @path = "/folder/doc.tex" - @newFolders = [ 'mock-a', 'mock-b' ] - @folder = _id: folder_id - @doc = _id: doc_id - @isNewDoc = true - @ProjectEntityUpdateHandler.mkdirp = - withoutLock: sinon.stub().yields(null, @newFolders, @folder) - @ProjectEntityUpdateHandler.upsertDoc = - withoutLock: sinon.stub().yields(null, @doc, @isNewDoc) + describe 'upserting a doc', -> + beforeEach -> + @path = "/folder/doc.tex" + @newFolders = [ 'mock-a', 'mock-b' ] + @folder = _id: folder_id + @doc = _id: doc_id + @isNewDoc = true + @ProjectEntityUpdateHandler.mkdirp = + withoutLock: sinon.stub().yields(null, @newFolders, @folder) + @ProjectEntityUpdateHandler.upsertDoc = + withoutLock: sinon.stub().yields(null, @doc, @isNewDoc) - @ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback + @ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback - it 'creates any necessary folders', -> - @ProjectEntityUpdateHandler.mkdirp.withoutLock - .calledWith(project_id, '/folder') - .should.equal true + it 'creates any necessary folders', -> + @ProjectEntityUpdateHandler.mkdirp.withoutLock + .calledWith(project_id, '/folder') + .should.equal true - it 'upserts the doc', -> - @ProjectEntityUpdateHandler.upsertDoc.withoutLock - .calledWith(project_id, @folder._id, 'doc.tex', @docLines, @source, userId) - .should.equal true + it 'upserts the doc', -> + @ProjectEntityUpdateHandler.upsertDoc.withoutLock + .calledWith(project_id, @folder._id, 'doc.tex', @docLines, @source, userId) + .should.equal true - it 'calls the callback', -> - @callback - .calledWith(null, @doc, @isNewDoc, @newFolders, @folder) - .should.equal true + it 'calls the callback', -> + @callback + .calledWith(null, @doc, @isNewDoc, @newFolders, @folder) + .should.equal true + + describe 'upserting a doc with an invalid path', -> + beforeEach -> + @path = "/*folder/doc.tex" + @newFolders = [ 'mock-a', 'mock-b' ] + @folder = _id: folder_id + @doc = _id: doc_id + @isNewDoc = true + @ProjectEntityUpdateHandler.mkdirp = + withoutLock: sinon.stub().yields(null, @newFolders, @folder) + @ProjectEntityUpdateHandler.upsertDoc = + withoutLock: sinon.stub().yields(null, @doc, @isNewDoc) + + @ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true + + describe 'upserting a doc with an invalid name', -> + beforeEach -> + @path = "/folder/*doc.tex" + @newFolders = [ 'mock-a', 'mock-b' ] + @folder = _id: folder_id + @doc = _id: doc_id + @isNewDoc = true + @ProjectEntityUpdateHandler.mkdirp = + withoutLock: sinon.stub().yields(null, @newFolders, @folder) + @ProjectEntityUpdateHandler.upsertDoc = + withoutLock: sinon.stub().yields(null, @doc, @isNewDoc) + + @ProjectEntityUpdateHandler.upsertDocWithPath project_id, @path, @docLines, @source, userId, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true describe 'upsertFileWithPath', -> - beforeEach -> - @path = "/folder/file.png" - @newFolders = [ 'mock-a', 'mock-b' ] - @folder = _id: folder_id - @file = _id: file_id - @isNewFile = true - @ProjectEntityUpdateHandler.mkdirp = - withoutLock: sinon.stub().yields(null, @newFolders, @folder) - @ProjectEntityUpdateHandler.upsertFile = - mainTask: sinon.stub().yields(null, @file, @isNewFile) + describe 'upserting a file', -> + beforeEach -> + @path = "/folder/file.png" + @newFolders = [ 'mock-a', 'mock-b' ] + @folder = _id: folder_id + @file = _id: file_id + @isNewFile = true + @ProjectEntityUpdateHandler.mkdirp = + withoutLock: sinon.stub().yields(null, @newFolders, @folder) + @ProjectEntityUpdateHandler.upsertFile = + mainTask: sinon.stub().yields(null, @file, @isNewFile) - @ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback + @ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback - it 'creates any necessary folders', -> - @ProjectEntityUpdateHandler.mkdirp.withoutLock - .calledWith(project_id, '/folder') - .should.equal true + it 'creates any necessary folders', -> + @ProjectEntityUpdateHandler.mkdirp.withoutLock + .calledWith(project_id, '/folder') + .should.equal true - it 'upserts the file', -> - @ProjectEntityUpdateHandler.upsertFile.mainTask - .calledWith(project_id, @folder._id, 'file.png', @fileSystemPath, @linkedFileData, userId) - .should.equal true + it 'upserts the file', -> + @ProjectEntityUpdateHandler.upsertFile.mainTask + .calledWith(project_id, @folder._id, 'file.png', @fileSystemPath, @linkedFileData, userId) + .should.equal true - it 'calls the callback', -> - @callback - .calledWith(null, @file, @isNewFile, undefined, @newFolders, @folder) - .should.equal true + it 'calls the callback', -> + @callback + .calledWith(null, @file, @isNewFile, undefined, @newFolders, @folder) + .should.equal true + + describe 'upserting a file with an invalid path', -> + beforeEach -> + @path = "/*folder/file.png" + @newFolders = [ 'mock-a', 'mock-b' ] + @folder = _id: folder_id + @file = _id: file_id + @isNewFile = true + @ProjectEntityUpdateHandler.mkdirp = + withoutLock: sinon.stub().yields(null, @newFolders, @folder) + @ProjectEntityUpdateHandler.upsertFile = + mainTask: sinon.stub().yields(null, @file, @isNewFile) + + @ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true + + describe 'upserting a file with an invalid name', -> + beforeEach -> + @path = "/folder/*file.png" + @newFolders = [ 'mock-a', 'mock-b' ] + @folder = _id: folder_id + @file = _id: file_id + @isNewFile = true + @ProjectEntityUpdateHandler.mkdirp = + withoutLock: sinon.stub().yields(null, @newFolders, @folder) + @ProjectEntityUpdateHandler.upsertFile = + mainTask: sinon.stub().yields(null, @file, @isNewFile) + + @ProjectEntityUpdateHandler.upsertFileWithPath project_id, @path, @fileSystemPath, @linkedFileData, userId, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true describe 'deleteEntity', -> beforeEach -> @@ -721,16 +890,29 @@ describe 'ProjectEntityUpdateHandler', -> .should.equal true describe 'addFolder', -> - beforeEach -> - @parentFolder_id = '123asdf' - @folderName = 'new-folder' - @ProjectEntityMongoUpdateHandler.addFolder = sinon.stub().yields() - @ProjectEntityUpdateHandler.addFolder project_id, @parentFolder_id, @folderName, @callback + describe 'adding a folder', -> + beforeEach -> + @parentFolder_id = '123asdf' + @folderName = 'new-folder' + @ProjectEntityMongoUpdateHandler.addFolder = sinon.stub().yields() + @ProjectEntityUpdateHandler.addFolder project_id, @parentFolder_id, @folderName, @callback - it 'calls ProjectEntityMongoUpdateHandler', -> - @ProjectEntityMongoUpdateHandler.addFolder - .calledWith(project_id, @parentFolder_id, @folderName) - .should.equal true + it 'calls ProjectEntityMongoUpdateHandler', -> + @ProjectEntityMongoUpdateHandler.addFolder + .calledWith(project_id, @parentFolder_id, @folderName) + .should.equal true + + describe 'adding a folder with an invalid name', -> + beforeEach -> + @parentFolder_id = '123asdf' + @folderName = '*new-folder' + @ProjectEntityMongoUpdateHandler.addFolder = sinon.stub().yields() + @ProjectEntityUpdateHandler.addFolder project_id, @parentFolder_id, @folderName, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true describe 'moveEntity', -> beforeEach -> @@ -763,35 +945,57 @@ describe 'ProjectEntityUpdateHandler', -> .should.equal true describe "renameEntity", -> - beforeEach -> - @project_name = 'project name' - @startPath = '/folder/a.tex' - @endPath = '/folder/b.tex' - @rev = 2 - @changes = newDocs: ['old-doc'], newFiles: ['old-file'] - @newDocName = 'b.tex' - @ProjectEntityMongoUpdateHandler.renameEntity = sinon.stub().yields( - null, @project, @startPath, @endPath, @rev, @changes - ) - @TpdsUpdateSender.moveEntity = sinon.stub() - @DocumentUpdaterHandler.updateProjectStructure = sinon.stub() + describe 'renaming an entity', -> + beforeEach -> + @project_name = 'project name' + @startPath = '/folder/a.tex' + @endPath = '/folder/b.tex' + @rev = 2 + @changes = newDocs: ['old-doc'], newFiles: ['old-file'] + @newDocName = 'b.tex' + @ProjectEntityMongoUpdateHandler.renameEntity = sinon.stub().yields( + null, @project, @startPath, @endPath, @rev, @changes + ) + @TpdsUpdateSender.moveEntity = sinon.stub() + @DocumentUpdaterHandler.updateProjectStructure = sinon.stub() - @ProjectEntityUpdateHandler.renameEntity project_id, doc_id, 'doc', @newDocName, userId, @callback + @ProjectEntityUpdateHandler.renameEntity project_id, doc_id, 'doc', @newDocName, userId, @callback - it 'moves the entity in mongo', -> - @ProjectEntityMongoUpdateHandler.renameEntity - .calledWith(project_id, doc_id, 'doc', @newDocName) - .should.equal true + it 'moves the entity in mongo', -> + @ProjectEntityMongoUpdateHandler.renameEntity + .calledWith(project_id, doc_id, 'doc', @newDocName) + .should.equal true - it 'notifies tpds', -> - @TpdsUpdateSender.moveEntity - .calledWith({project_id, @project_name, @startPath, @endPath, @rev}) - .should.equal true + it 'notifies tpds', -> + @TpdsUpdateSender.moveEntity + .calledWith({project_id, @project_name, @startPath, @endPath, @rev}) + .should.equal true - it 'sends the changes in project structure to the doc updater', -> - @DocumentUpdaterHandler.updateProjectStructure - .calledWith(project_id, projectHistoryId, userId, @changes, @callback) - .should.equal true + it 'sends the changes in project structure to the doc updater', -> + @DocumentUpdaterHandler.updateProjectStructure + .calledWith(project_id, projectHistoryId, userId, @changes, @callback) + .should.equal true + + describe 'renaming an entity to an invalid name', -> + beforeEach -> + @project_name = 'project name' + @startPath = '/folder/a.tex' + @endPath = '/folder/b.tex' + @rev = 2 + @changes = newDocs: ['old-doc'], newFiles: ['old-file'] + @newDocName = '*b.tex' + @ProjectEntityMongoUpdateHandler.renameEntity = sinon.stub().yields( + null, @project, @startPath, @endPath, @rev, @changes + ) + @TpdsUpdateSender.moveEntity = sinon.stub() + @DocumentUpdaterHandler.updateProjectStructure = sinon.stub() + + @ProjectEntityUpdateHandler.renameEntity project_id, doc_id, 'doc', @newDocName, userId, @callback + + it 'returns an error', -> + errorMatcher = sinon.match.instanceOf(Errors.InvalidNameError) + @callback.calledWithMatch(errorMatcher) + .should.equal true describe "resyncProjectHistory", -> describe "a deleted project", -> @@ -998,5 +1202,3 @@ describe 'ProjectEntityUpdateHandler', -> it "should call the callback", -> @callback.called.should.equal true - - diff --git a/services/web/test/unit/coffee/Project/SafePathTests.coffee b/services/web/test/unit/coffee/Project/SafePathTests.coffee index 6e0c55a5bc..afea03fdea 100644 --- a/services/web/test/unit/coffee/Project/SafePathTests.coffee +++ b/services/web/test/unit/coffee/Project/SafePathTests.coffee @@ -83,6 +83,59 @@ describe 'SafePath', -> result = @SafePath.isCleanFilename 'foo\\bar' result.should.equal false + describe 'isCleanPath', -> + it 'should accept a valid filename "main.tex"', -> + result = @SafePath.isCleanPath 'main.tex' + result.should.equal true + + it 'should accept a valid path "foo/main.tex"', -> + result = @SafePath.isCleanPath 'foo/main.tex' + result.should.equal true + + it 'should accept empty path elements', -> + result = @SafePath.isCleanPath 'foo//main.tex' + result.should.equal true + + it 'should not accept an empty filename', -> + result = @SafePath.isCleanPath 'foo/bar/' + result.should.equal false + + it 'should accept a path that starts with a slash', -> + result = @SafePath.isCleanPath '/etc/passwd' + result.should.equal true + + it 'should not accept a path that has an asterisk as the 0th element', -> + result = @SafePath.isCleanPath '*/foo/bar' + result.should.equal false + + it 'should not accept a path that has an asterisk as a middle element', -> + result = @SafePath.isCleanPath 'foo/*/bar' + result.should.equal false + + it 'should not accept a path that has an asterisk as the filename', -> + result = @SafePath.isCleanPath 'foo/bar/*' + result.should.equal false + + it 'should not accept a path that contains an asterisk in the 0th element', -> + result = @SafePath.isCleanPath 'f*o/bar/baz' + result.should.equal false + + it 'should not accept a path that contains an asterisk in a middle element', -> + result = @SafePath.isCleanPath 'foo/b*r/baz' + result.should.equal false + + it 'should not accept a path that contains an asterisk in the filename', -> + result = @SafePath.isCleanPath 'foo/bar/b*z' + result.should.equal false + + it 'should not accept multiple problematic elements', -> + result = @SafePath.isCleanPath 'f*o/b*r/b*z' + result.should.equal false + + it 'should not accept a problematic path with an empty element', -> + result = @SafePath.isCleanPath 'foo//*/bar' + result.should.equal false + describe 'isAllowedLength', -> it 'should accept a valid path "main.tex"', -> result = @SafePath.isAllowedLength 'main.tex' @@ -96,7 +149,7 @@ describe 'SafePath', -> it 'should not accept an empty path', -> result = @SafePath.isAllowedLength '' result.should.equal false - + describe 'clean', -> it 'should not modify a valid filename', -> result = @SafePath.clean 'main.tex' @@ -105,7 +158,7 @@ describe 'SafePath', -> it 'should replace invalid characters with _', -> result = @SafePath.clean 'foo/bar*/main.tex' result.should.equal 'foo_bar__main.tex' - + it 'should replace "." with "_"', -> result = @SafePath.clean '.' result.should.equal '_' @@ -133,7 +186,7 @@ describe 'SafePath', -> it 'should prefix javascript property names with @', -> result = @SafePath.clean 'prototype' result.should.equal '@prototype' - + it 'should prefix javascript property names in the prototype with @', -> result = @SafePath.clean 'hasOwnProperty' result.should.equal '@hasOwnProperty' diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee index a399496399..91706130f3 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupControllerTests.coffee @@ -81,25 +81,6 @@ describe "SubscriptionGroupController", -> done() @Controller.removeUserFromGroup @req, res - describe "renderSubscriptionGroupAdminPage", -> - it "should redirect you if you don't have a group account", (done)-> - @subscription.groupPlan = false - - res = - redirect : (path)=> - path.should.equal("/user/subscription") - done() - @Controller.renderSubscriptionGroupAdminPage @req, res - - it "should redirect you don't have a subscription", (done)-> - @SubscriptionLocator.getUsersSubscription = sinon.stub().callsArgWith(1) - - res = - redirect : (path)=> - path.should.equal("/user/subscription") - done() - @Controller.renderSubscriptionGroupAdminPage @req, res - describe "exportGroupCsv", -> beforeEach -> diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee index 78f6ac3805..0f2290f014 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionGroupHandlerTests.coffee @@ -157,52 +157,6 @@ describe "SubscriptionGroupHandler", -> { $pull: { member_ids: @oldId } } ).should.equal true - describe "getPopulatedListOfMembers", -> - beforeEach -> - @subscription = {} - @SubscriptionLocator.getSubscription.callsArgWith(1, null, @subscription) - @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) - - it "should locate the subscription", (done)-> - @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) - @Handler.getPopulatedListOfMembers @subscriptionId, (err, users)=> - @SubscriptionLocator.getSubscription.calledWith(@subscriptionId).should.equal true - done() - - it "should get the users by id", (done)-> - @UserGetter.getUser.callsArgWith(1, null, {_id:"31232"}) - @subscription.member_ids = ["1234", "342432", "312312"] - @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> - @UserGetter.getUser.calledWith(@subscription.member_ids[0]).should.equal true - @UserGetter.getUser.calledWith(@subscription.member_ids[1]).should.equal true - @UserGetter.getUser.calledWith(@subscription.member_ids[2]).should.equal true - users.length.should.equal @subscription.member_ids.length - done() - - it "should just return the id if the user can not be found as they may have deleted their account", (done)-> - @UserGetter.getUser.callsArgWith(1) - @subscription.member_ids = ["1234", "342432", "312312"] - @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> - assert.deepEqual users[0], {_id:@subscription.member_ids[0]} - assert.deepEqual users[1], {_id:@subscription.member_ids[1]} - assert.deepEqual users[2], {_id:@subscription.member_ids[2]} - done() - - it "should return any invited users", (done) -> - @subscription.invited_emails = [ "jo@example.com" ] - - @subscription.teamInvites = [ - { email: "charlie@example.com" } - ] - - @Handler.getPopulatedListOfMembers @adminUser_id, (err, users)=> - users[0].email.should.equal "jo@example.com" - users[0].invite.should.equal true - users[1].email.should.equal "charlie@example.com" - users[1].invite.should.equal true - users.length.should.equal @subscription.teamInvites.length + @subscription.invited_emails.length - done() - describe "isUserPartOfGroup", -> beforeEach -> @subscription_id = "123ed13123" diff --git a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee index a66d7f5dec..1a67a183b4 100644 --- a/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee +++ b/services/web/test/unit/coffee/Subscription/SubscriptionUpdaterTests.coffee @@ -17,6 +17,7 @@ describe "SubscriptionUpdater", -> _id: @adminuser_id = "5208dd34438843e2db000007" @otherUserId = "5208dd34438842e2db000005" @allUserIds = ["13213", "dsadas", "djsaiud89"] + @userStub = _id: 'mock-user-stub-id', email: 'mock-stub-email@baz.com' @subscription = subscription = _id: "111111111111111111111111" admin_id: @adminUser._id @@ -67,6 +68,7 @@ describe "SubscriptionUpdater", -> getUsers: (memberIds, projection, callback) -> users = memberIds.map (id) -> { _id: id } callback(null, users) + getUserOrUserStubById: sinon.stub() @ReferalFeatures = getBonusFeatures: sinon.stub().callsArgWith(1) @Modules = {hooks: {fire: sinon.stub().callsArgWith(2, null, null)}} @@ -190,6 +192,7 @@ describe "SubscriptionUpdater", -> describe "removeUserFromGroup", -> beforeEach -> @FeaturesUpdater.refreshFeatures = sinon.stub().callsArgWith(1) + @UserGetter.getUserOrUserStubById.yields(null, {}, false) it "should pull the users id from the group", (done)-> @SubscriptionUpdater.removeUserFromGroup @subscription._id, @otherUserId, => @@ -205,6 +208,12 @@ describe "SubscriptionUpdater", -> @FeaturesUpdater.refreshFeatures.calledWith(@otherUserId).should.equal true done() + it "should not update features for user stubs", (done)-> + @UserGetter.getUserOrUserStubById.yields(null, {}, true) + @SubscriptionUpdater.removeUserFromGroup @subscription._id, @userStub._id, => + @FeaturesUpdater.refreshFeatures.called.should.equal false + done() + describe "deleteSubscription", -> beforeEach (done) -> @subscription_id = ObjectId().toString() diff --git a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee index a08789ede9..1e61b14c0b 100644 --- a/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee +++ b/services/web/test/unit/coffee/Templates/TemplatesControllerTests.coffee @@ -59,7 +59,7 @@ describe 'TemplatesController', -> "uuid":v4:=>@uuid "request": @request "fs":@fs - "../../../../app/js/models/Project": {Project: @Project} + "../../../js/models/Project": {Project: @Project} @zipUrl = "%2Ftemplates%2F52fb86a81ae1e566597a25f6%2Fv%2F4%2Fzip&templateName=Moderncv%20Banking&compiler=pdflatex" @templateName = "project name here" @user_id = "1234" diff --git a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee index f8bcce30ce..7fcd2147a5 100644 --- a/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee +++ b/services/web/test/unit/coffee/User/UserRegistrationHandlerTests.coffee @@ -19,6 +19,8 @@ describe "UserRegistrationHandler", -> @UserCreator = createNewUser:sinon.stub().callsArgWith(1, null, @user) @AuthenticationManager = + validateEmail: sinon.stub().returns(null) + validatePassword: sinon.stub().returns(null) setUserPassword: sinon.stub().callsArgWith(2) @NewsLetterManager = subscribe: sinon.stub().callsArgWith(1) @@ -44,28 +46,25 @@ describe "UserRegistrationHandler", -> describe 'validate Register Request', -> - - - it 'allow working account through', -> + it 'allows passing validation through', -> result = @handler._registrationRequestIsValid @passingRequest result.should.equal true - - it 'not allow not valid email through ', ()-> - @passingRequest.email = "notemail" - result = @handler._registrationRequestIsValid @passingRequest - result.should.equal false - it 'not allow no email through ', -> - @passingRequest.email = "" - result = @handler._registrationRequestIsValid @passingRequest - result.should.equal false - - it 'not allow no password through ', ()-> - @passingRequest.password= "" - result = @handler._registrationRequestIsValid @passingRequest - result.should.equal false + describe 'failing email validation', -> + beforeEach -> + @AuthenticationManager.validateEmail.returns({ message: 'email not set' }) + it 'does not allow through', -> + result = @handler._registrationRequestIsValid @passingRequest + result.should.equal false + describe 'failing password validation', -> + beforeEach -> + @AuthenticationManager.validatePassword.returns({ message: 'password is too short' }) + + it 'does not allow through', -> + result = @handler._registrationRequestIsValid @passingRequest + result.should.equal false describe "registerNewUser", -> diff --git a/services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee b/services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee new file mode 100644 index 0000000000..f890abfefa --- /dev/null +++ b/services/web/test/unit/coffee/UserMembership/UserMembershipControllerTests.coffee @@ -0,0 +1,95 @@ +sinon = require('sinon') +assertCalledWith = sinon.assert.calledWith +assertNotCalled = sinon.assert.notCalled +chai = require('chai') +should = chai.should() +assert = chai.assert +expect = require('chai').expect +modulePath = "../../../../app/js/Features/UserMembership/UserMembershipController.js" +SandboxedModule = require('sandboxed-module') +MockRequest = require "../helpers/MockRequest" +MockResponse = require "../helpers/MockResponse" + +describe "UserMembershipController", -> + beforeEach -> + @req = new MockRequest() + @user = _id: 'mock-user-id' + @newUser = _id: 'mock-new-user-id', email: 'new-user-email@foo.bar' + @subscription = { _id: 'mock-subscription-id'} + @users = [{ _id: 'mock-member-id-1' }, { _id: 'mock-member-id-2' }] + + @AuthenticationController = + getLoggedInUserId: sinon.stub().returns(@user._id) + @UserMembershipHandler = + getEntity: sinon.stub().yields(null, @subscription) + getUsers: sinon.stub().yields(null, @users) + addUser: sinon.stub().yields(null, @newUser) + removeUser: sinon.stub().yields(null) + @UserMembershipController = SandboxedModule.require modulePath, requires: + '../Authentication/AuthenticationController': @AuthenticationController + './UserMembershipHandler': @UserMembershipHandler + "logger-sharelatex": + log: -> + err: -> + + describe 'index', -> + it 'get entity', (done) -> + @UserMembershipController.index 'group', @req, render: () => + sinon.assert.calledWith(@UserMembershipHandler.getEntity, 'group', @user._id) + done() + + it 'get users', (done) -> + @UserMembershipController.index 'group', @req, render: () => + sinon.assert.calledWith(@UserMembershipHandler.getUsers, 'group', @subscription) + done() + + it 'render group view', (done) -> + @UserMembershipController.index 'group', @req, render: (viewPath, viewParams) => + expect(viewPath).to.equal 'user_membership/index' + expect(viewParams.entity).to.deep.equal @subscription + expect(viewParams.users).to.deep.equal @users + expect(viewParams.translations.title).to.equal 'group_account' + expect(viewParams.paths.addMember).to.equal '/subscription/invites' + done() + + it 'render group managers view', (done) -> + @UserMembershipController.index 'groupManagers', @req, render: (viewPath, viewParams) => + expect(viewPath).to.equal 'user_membership/index' + expect(viewParams.translations.title).to.equal 'group_managers' + expect(viewParams.paths.exportMembers).to.be.undefined + done() + + it 'render institution view', (done) -> + @UserMembershipController.index 'institution', @req, render: (viewPath, viewParams) => + expect(viewPath).to.equal 'user_membership/index' + expect(viewParams.translations.title).to.equal 'institution_managers' + expect(viewParams.paths.exportMembers).to.be.undefined + done() + + describe 'add', -> + beforeEach -> + @req.body.email = @newUser.email + + it 'get entity', (done) -> + @UserMembershipController.add 'groupManagers', @req, json: () => + sinon.assert.calledWith(@UserMembershipHandler.getEntity, 'groupManagers', @user._id) + done() + + it 'add user', (done) -> + @UserMembershipController.add 'groupManagers', @req, json: () => + sinon.assert.calledWith(@UserMembershipHandler.addUser, 'groupManagers', @subscription, @newUser.email) + done() + + it 'return user object', (done) -> + @UserMembershipController.add 'groupManagers', @req, json: (payload) => + payload.user.should.equal @newUser + done() + + describe 'remove', -> + beforeEach -> + @req.params.userId = @newUser._id + + it 'remove user', (done) -> + @UserMembershipController.remove 'groupManagers', @req, send: () => + sinon.assert.calledWith(@UserMembershipHandler.removeUser, 'groupManagers', @subscription, @newUser._id) + done() diff --git a/services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee b/services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee new file mode 100644 index 0000000000..23598a82a1 --- /dev/null +++ b/services/web/test/unit/coffee/UserMembership/UserMembershipHandlerTests.coffee @@ -0,0 +1,172 @@ +chai = require('chai') +should = chai.should() +expect = require('chai').expect +sinon = require('sinon') +assertCalledWith = sinon.assert.calledWith +assertNotCalled = sinon.assert.notCalled +ObjectId = require("../../../../app/js/infrastructure/mongojs").ObjectId +modulePath = "../../../../app/js/Features/UserMembership/UserMembershipHandler" +SandboxedModule = require("sandboxed-module") +Errors = require("../../../../app/js/Features/Errors/Errors") + +describe 'UserMembershipHandler', -> + beforeEach -> + @user = _id: 'mock-user-id' + @newUser = _id: 'mock-new-user-id', email: 'new-user-email@foo.bar' + @subscription = + _id: 'mock-subscription-id' + groupPlan: true + membersLimit: 10 + member_ids: [ObjectId(), ObjectId()] + manager_ids: [ObjectId()] + invited_emails: ['mock-email-1@foo.com'] + teamInvites: [{ email: 'mock-email-1@bar.com' }] + update: sinon.stub().yields(null) + @institution = + _id: 'mock-institution-id' + v1Id: 123 + managerIds: [ObjectId(), ObjectId(), ObjectId()] + update: sinon.stub().yields(null) + + @SubscriptionLocator = + findManagedSubscription: sinon.stub().yields(null, @subscription) + @InstitutionsLocator = + findManagedInstitution: sinon.stub().yields(null, @institution) + @UserMembershipViewModel = + buildAsync: sinon.stub().yields(null, { _id: 'mock-member-id'}) + build: sinon.stub().returns(@newUser) + @UserGetter = + getUserByAnyEmail: sinon.stub().yields(null, @newUser) + @UserMembershipHandler = SandboxedModule.require modulePath, requires: + '../Subscription/SubscriptionLocator': @SubscriptionLocator + '../Institutions/InstitutionsLocator': @InstitutionsLocator + './UserMembershipViewModel': @UserMembershipViewModel + '../User/UserGetter': @UserGetter + '../Errors/Errors': Errors + 'logger-sharelatex': + log: -> + err: -> + + describe 'getEntty', -> + it 'validate type', (done) -> + @UserMembershipHandler.getEntity 'foo', null, (error) -> + should.exist(error) + expect(error.message).to.match /No such entity/ + done() + + describe 'group subscriptions', -> + it 'get subscription', (done) -> + @UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) => + should.not.exist(error) + assertCalledWith(@SubscriptionLocator.findManagedSubscription, @user._id) + expect(subscription).to.equal @subscription + expect(subscription.membersLimit).to.equal 10 + done() + + it 'check subscription is a group', (done) -> + @SubscriptionLocator.findManagedSubscription.yields(null, { groupPlan: false }) + @UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) -> + should.exist(error) + done() + + it 'handle error', (done) -> + @SubscriptionLocator.findManagedSubscription.yields(new Error('some error')) + @UserMembershipHandler.getEntity 'group', @user._id, (error, subscription) => + should.exist(error) + done() + + describe 'group managers', -> + it 'has no members limit', (done) -> + @UserMembershipHandler.getEntity 'groupManagers', @user._id, (error, subscription) => + should.not.exist(error) + assertCalledWith(@SubscriptionLocator.findManagedSubscription, @user._id) + expect(subscription.membersLimit).to.equal null + done() + + describe 'institutions', -> + it 'get institution', (done) -> + @UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) => + should.not.exist(error) + assertCalledWith(@InstitutionsLocator.findManagedInstitution, @user._id) + expect(institution).to.equal @institution + done() + + it 'handle institution not found', (done) -> + @InstitutionsLocator.findManagedInstitution.yields(null, null) + @UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) => + should.exist(error) + expect(error).to.be.an.instanceof(Errors.NotFoundError) + done() + + it 'handle errors', (done) -> + @InstitutionsLocator.findManagedInstitution.yields(new Error('nope')) + @UserMembershipHandler.getEntity 'institution', @user._id, (error, institution) => + should.exist(error) + expect(error).to.not.be.an.instanceof(Errors.NotFoundError) + done() + + describe 'getUsers', -> + describe 'group', -> + it 'build view model for all users', (done) -> + @UserMembershipHandler.getUsers 'group', @subscription, (error, users) => + expectedCallcount = + @subscription.member_ids.length + + @subscription.invited_emails.length + + @subscription.teamInvites.length + expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount + done() + + describe 'group mamagers', -> + it 'build view model for all managers', (done) -> + @UserMembershipHandler.getUsers 'groupManagers', @subscription, (error, users) => + expectedCallcount = @subscription.manager_ids.length + expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount + done() + + describe 'institution', -> + it 'build view model for all managers', (done) -> + @UserMembershipHandler.getUsers 'institution', @institution, (error, users) => + expectedCallcount = @institution.managerIds.length + expect(@UserMembershipViewModel.buildAsync.callCount).to.equal expectedCallcount + done() + + describe 'addUser', -> + beforeEach -> + @email = @newUser.email + + describe 'group', -> + it 'fails', (done) -> + @UserMembershipHandler.addUser 'group', @subscription, @email, (error) => + expect(error).to.exist + done() + + describe 'institution', -> + it 'get user', (done) -> + @UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) => + assertCalledWith(@UserGetter.getUserByAnyEmail, @email) + done() + + it 'handle user not found', (done) -> + @UserGetter.getUserByAnyEmail.yields(null, null) + @UserMembershipHandler.addUser 'institution', @institution, @email, (error) => + expect(error).to.exist + expect(error).to.be.an.instanceof(Errors.NotFoundError) + done() + + it 'add user to institution', (done) -> + @UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) => + assertCalledWith(@institution.update, { $addToSet: managerIds: @newUser._id }) + done() + + it 'return user view', (done) -> + @UserMembershipHandler.addUser 'institution', @institution, @email, (error, user) => + user.should.equal @newUser + done() + + describe 'removeUser', -> + describe 'institution', -> + it 'remove user from institution', (done) -> + @UserMembershipHandler.removeUser 'institution', @institution, @newUser._id, (error, user) => + lastCall = @institution.update.lastCall + assertCalledWith(@institution.update, { $pull: managerIds: @newUser._id }) + done() diff --git a/services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee b/services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee new file mode 100644 index 0000000000..7dc7dbeff0 --- /dev/null +++ b/services/web/test/unit/coffee/UserMembership/UserMembershipViewModelTests.coffee @@ -0,0 +1,83 @@ +chai = require('chai') +should = chai.should() +expect = require('chai').expect +sinon = require('sinon') +assertCalledWith = sinon.assert.calledWith +assertNotCalled = sinon.assert.notCalled +mongojs = require('mongojs') +ObjectId = mongojs.ObjectId +modulePath = "../../../../app/js/Features/UserMembership/UserMembershipViewModel" +SandboxedModule = require("sandboxed-module") + +describe 'UserMembershipViewModel', -> + beforeEach -> + @UserGetter = + getUserOrUserStubById: sinon.stub() + @UserMembershipViewModel = SandboxedModule.require modulePath, requires: + 'mongojs': mongojs + '../User/UserGetter': @UserGetter + @email = 'mock-email@bar.com' + @user = _id: 'mock-user-id', email: 'mock-email@baz.com', first_name: 'Name' + @userStub = _id: 'mock-user-stub-id', email: 'mock-stub-email@baz.com' + + describe 'build', -> + it 'build email', -> + viewModel = @UserMembershipViewModel.build(@email) + expect(viewModel).to.deep.equal + email: @email + invite: true + first_name: null + last_name: null + _id: null + + it 'build user', -> + viewModel = @UserMembershipViewModel.build(@user) + expect(viewModel._id).to.equal @user._id + expect(viewModel.email).to.equal @user.email + expect(viewModel.invite).to.equal false + + describe 'build async', -> + beforeEach -> + @UserMembershipViewModel.build = sinon.stub() + + it 'build email', (done) -> + @UserMembershipViewModel.buildAsync @email, (error, viewModel) => + assertCalledWith(@UserMembershipViewModel.build, @email) + done() + + it 'build user', (done) -> + @UserMembershipViewModel.buildAsync @user, (error, viewModel) => + assertCalledWith(@UserMembershipViewModel.build, @user) + done() + + it 'build user id', (done) -> + @UserGetter.getUserOrUserStubById.yields(null, @user, false) + @UserMembershipViewModel.buildAsync ObjectId(), (error, viewModel) => + should.not.exist(error) + assertNotCalled(@UserMembershipViewModel.build) + expect(viewModel._id).to.equal @user._id + expect(viewModel.email).to.equal @user.email + expect(viewModel.first_name).to.equal @user.first_name + expect(viewModel.invite).to.equal false + should.exist(viewModel.email) + done() + + it 'build user stub id', (done) -> + @UserGetter.getUserOrUserStubById.yields(null, @userStub, true) + @UserMembershipViewModel.buildAsync ObjectId(), (error, viewModel) => + should.not.exist(error) + assertNotCalled(@UserMembershipViewModel.build) + expect(viewModel._id).to.equal @userStub._id + expect(viewModel.email).to.equal @userStub.email + expect(viewModel.invite).to.equal true + done() + + it 'build user id with error', (done) -> + @UserGetter.getUserOrUserStubById.yields(new Error('nope')) + userId = ObjectId() + @UserMembershipViewModel.buildAsync userId, (error, viewModel) => + should.not.exist(error) + assertNotCalled(@UserMembershipViewModel.build) + expect(viewModel._id).to.equal userId.toString() + should.not.exist(viewModel.email) + done()