Sfoglia il codice sorgente

Merge branch 'master' into sk-upgrade-metrics

Shane Kilkelly 9 anni fa
parent
commit
bee62400a6
39 ha cambiato i file con 541 aggiunte e 160 eliminazioni
  1. 1 0
      services/web/.nvmrc
  2. 11 3
      services/web/app/coffee/Features/Analytics/AnalyticsController.coffee
  3. 11 2
      services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee
  4. 1 0
      services/web/app/coffee/Features/Authentication/AuthenticationController.coffee
  5. 4 0
      services/web/app/coffee/Features/Authorization/AuthorizationManager.coffee
  6. 2 2
      services/web/app/coffee/Features/Blog/BlogController.coffee
  7. 1 0
      services/web/app/coffee/Features/Email/EmailSender.coffee
  8. 10 1
      services/web/app/coffee/Features/Errors/Errors.coffee
  9. 3 0
      services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee
  10. 2 4
      services/web/app/coffee/Features/Project/ProjectApiController.coffee
  11. 2 0
      services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee
  12. 12 5
      services/web/app/coffee/Features/Project/ProjectDuplicator.coffee
  13. 2 0
      services/web/app/coffee/Features/Project/ProjectLocator.coffee
  14. 2 1
      services/web/app/coffee/Features/StaticPages/StaticPagesRouter.coffee
  15. 24 18
      services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee
  16. 33 25
      services/web/app/coffee/Features/Subscription/SubscriptionController.coffee
  17. 16 3
      services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee
  18. 2 1
      services/web/app/views/project/editor/editor.pug
  19. 15 7
      services/web/app/views/project/editor/review-panel.pug
  20. 12 1
      services/web/public/coffee/ide/editor/EditorManager.coffee
  21. 51 0
      services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee
  22. 2 0
      services/web/public/coffee/ide/file-tree/FileTreeManager.coffee
  23. 37 11
      services/web/public/coffee/ide/review-panel/RangesTracker.coffee
  24. 21 3
      services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee
  25. 4 1
      services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee
  26. 2 2
      services/web/public/js/ace-1.2.5/worker-latex.js
  27. 64 38
      services/web/public/stylesheets/app/editor/review-panel.less
  28. 44 0
      services/web/test/UnitTests/coffee/Analytics/AnalyticsControllerTests.coffee
  29. 5 0
      services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee
  30. 14 1
      services/web/test/UnitTests/coffee/Authorization/AuthorizationManagerTests.coffee
  31. 5 3
      services/web/test/UnitTests/coffee/Email/EmailSenderTests.coffee
  32. 7 0
      services/web/test/UnitTests/coffee/FileStore/FileStoreHandlerTests.coffee
  33. 4 5
      services/web/test/UnitTests/coffee/Project/ProjectApiControllerTests.coffee
  34. 8 0
      services/web/test/UnitTests/coffee/Project/ProjectDetailsHandlerTests.coffee
  35. 4 4
      services/web/test/UnitTests/coffee/Project/ProjectDuplicatorTests.coffee
  36. 1 1
      services/web/test/UnitTests/coffee/Project/ProjectLocatorTests.coffee
  37. 32 0
      services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee
  38. 12 2
      services/web/test/UnitTests/coffee/Subscription/SubscriptionControllerTests.coffee
  39. 58 16
      services/web/test/UnitTests/coffee/Subscription/SubscriptionHandlerTests.coffee

+ 1 - 0
services/web/.nvmrc

@@ -0,0 +1 @@
+0.10.22

+ 11 - 3
services/web/app/coffee/Features/Analytics/AnalyticsController.coffee

@@ -1,7 +1,15 @@
 AnalyticsManager = require "./AnalyticsManager"
 AnalyticsManager = require "./AnalyticsManager"
+Errors = require "../Errors/Errors"
+AuthenticationController = require("../Authentication/AuthenticationController")
 
 
 module.exports = AnalyticsController =
 module.exports = AnalyticsController =
 	recordEvent: (req, res, next) ->
 	recordEvent: (req, res, next) ->
-		AnalyticsManager.recordEvent req.session?.user?._id, req.params.event, req.body, (error) ->
-			return next(error) if error?
-			res.send 204
+		user_id = AuthenticationController.getLoggedInUserId(req) or req.sessionID
+		AnalyticsManager.recordEvent user_id, req.params.event, req.body, (error) ->
+			if error instanceof Errors.ServiceNotConfiguredError
+				# ignore, no-op
+				return res.send(204)
+			else if error?
+				return next(error)
+			else
+				return res.send 204

+ 11 - 2
services/web/app/coffee/Features/Analytics/AnalyticsManager.coffee

@@ -2,6 +2,7 @@ settings = require "settings-sharelatex"
 logger = require "logger-sharelatex"
 logger = require "logger-sharelatex"
 _ = require "underscore"
 _ = require "underscore"
 request = require "request"
 request = require "request"
+Errors = require '../Errors/Errors'
 
 
 
 
 makeRequest = (opts, callback)->
 makeRequest = (opts, callback)->
@@ -10,12 +11,20 @@ makeRequest = (opts, callback)->
 		opts.url = "#{settings.apis.analytics.url}#{urlPath}"
 		opts.url = "#{settings.apis.analytics.url}#{urlPath}"
 		request opts, callback
 		request opts, callback
 	else
 	else
-		callback()
-
+		callback(new Errors.ServiceNotConfiguredError('Analytics service not configured'))
 
 
 
 
 module.exports =
 module.exports =
 
 
+	identifyUser: (user_id, old_user_id, callback = (error)->)->
+		opts =
+			body:
+				old_user_id:old_user_id
+			json:true
+			method:"POST"
+			timeout:1000
+			url: "/user/#{user_id}/identify"
+		makeRequest opts, callback
 
 
 	recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
 	recordEvent: (user_id, event, segmentation = {}, callback = (error) ->) ->
 		if user_id+"" == settings.smokeTest?.userId+""
 		if user_id+"" == settings.smokeTest?.userId+""

+ 1 - 0
services/web/app/coffee/Features/Authentication/AuthenticationController.coffee

@@ -87,6 +87,7 @@ module.exports = AuthenticationController =
 					LoginRateLimiter.recordSuccessfulLogin(email)
 					LoginRateLimiter.recordSuccessfulLogin(email)
 					AuthenticationController._recordSuccessfulLogin(user._id)
 					AuthenticationController._recordSuccessfulLogin(user._id)
 					Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip})
 					Analytics.recordEvent(user._id, "user-logged-in", {ip:req.ip})
+					Analytics.identifyUser(user._id, req.sessionID)
 					logger.log email: email, user_id: user._id.toString(), "successful log in"
 					logger.log email: email, user_id: user._id.toString(), "successful log in"
 					req.session.justLoggedIn = true
 					req.session.justLoggedIn = true
 					# capture the request ip for use when creating the session
 					# capture the request ip for use when creating the session

+ 4 - 0
services/web/app/coffee/Features/Authorization/AuthorizationManager.coffee

@@ -4,6 +4,8 @@ User = require("../../models/User").User
 PrivilegeLevels = require("./PrivilegeLevels")
 PrivilegeLevels = require("./PrivilegeLevels")
 PublicAccessLevels = require("./PublicAccessLevels")
 PublicAccessLevels = require("./PublicAccessLevels")
 Errors = require("../Errors/Errors")
 Errors = require("../Errors/Errors")
+ObjectId = require("mongojs").ObjectId
+
 
 
 module.exports = AuthorizationManager =
 module.exports = AuthorizationManager =
 	# Get the privilege level that the user has for the project
 	# Get the privilege level that the user has for the project
@@ -13,6 +15,8 @@ module.exports = AuthorizationManager =
 	#   * becausePublic: true if the access level is only because the project is public.
 	#   * becausePublic: true if the access level is only because the project is public.
 	getPrivilegeLevelForProject: (user_id, project_id, callback = (error, privilegeLevel, becausePublic) ->) ->
 	getPrivilegeLevelForProject: (user_id, project_id, callback = (error, privilegeLevel, becausePublic) ->) ->
 		getPublicAccessLevel = () ->
 		getPublicAccessLevel = () ->
+			if !ObjectId.isValid(project_id)
+				return callback(new Error("invalid project id"))
 			Project.findOne { _id: project_id }, { publicAccesLevel: 1 }, (error, project) ->
 			Project.findOne { _id: project_id }, { publicAccesLevel: 1 }, (error, project) ->
 				return callback(error) if error?
 				return callback(error) if error?
 				if !project?
 				if !project?

+ 2 - 2
services/web/app/coffee/Features/Blog/BlogController.coffee

@@ -10,7 +10,7 @@ module.exports = BlogController =
 		url = req.url?.toLowerCase()
 		url = req.url?.toLowerCase()
 		blogUrl = "#{settings.apis.blog.url}#{url}"
 		blogUrl = "#{settings.apis.blog.url}#{url}"
 
 
-		extensionsToProxy = [".png", ".xml", ".jpeg", ".json", ".zip", ".eps", ".gif"]
+		extensionsToProxy = [".png", ".xml", ".jpeg", ".jpg", ".json", ".zip", ".eps", ".gif"]
 
 
 		shouldProxy = _.find extensionsToProxy, (extension)->
 		shouldProxy = _.find extensionsToProxy, (extension)->
 			url.indexOf(extension) != -1
 			url.indexOf(extension) != -1
@@ -42,4 +42,4 @@ module.exports = BlogController =
 		upstream = request.get(originUrl)
 		upstream = request.get(originUrl)
 		upstream.on "error", (error) ->
 		upstream.on "error", (error) ->
 			logger.error err: error, "blog proxy error"
 			logger.error err: error, "blog proxy error"
-		upstream.pipe res
+		upstream.pipe res

+ 1 - 0
services/web/app/coffee/Features/Email/EmailSender.coffee

@@ -72,6 +72,7 @@ module.exports =
 			client.sendMail options, (err, res)->
 			client.sendMail options, (err, res)->
 				if err?
 				if err?
 					logger.err err:err, "error sending message"
 					logger.err err:err, "error sending message"
+					err = new Error('Cannot send email')
 				else
 				else
 					logger.log "Message sent to #{options.to}"
 					logger.log "Message sent to #{options.to}"
 				callback(err)
 				callback(err)

+ 10 - 1
services/web/app/coffee/Features/Errors/Errors.coffee

@@ -5,5 +5,14 @@ NotFoundError = (message) ->
 	return error
 	return error
 NotFoundError.prototype.__proto__ = Error.prototype
 NotFoundError.prototype.__proto__ = Error.prototype
 
 
+
+ServiceNotConfiguredError = (message) ->
+	error = new Error(message)
+	error.name = "ServiceNotConfiguredError"
+	error.__proto__ = ServiceNotConfiguredError.prototype
+	return error
+
+
 module.exports = Errors =
 module.exports = Errors =
-	NotFoundError: NotFoundError
+	NotFoundError: NotFoundError
+	ServiceNotConfiguredError: ServiceNotConfiguredError

+ 3 - 0
services/web/app/coffee/Features/FileStore/FileStoreHandler.coffee

@@ -13,6 +13,9 @@ module.exports = FileStoreHandler =
 			if err?
 			if err?
 				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "error stating file"
 				logger.err err:err, project_id:project_id, file_id:file_id, fsPath:fsPath, "error stating file"
 				callback(err)
 				callback(err)
+			if !stat?
+				logger.err project_id:project_id, file_id:file_id, fsPath:fsPath, "stat is not available, can not check file from disk"
+				return callback(new Error("error getting stat, not available"))
 			if !stat.isFile()
 			if !stat.isFile()
 				logger.log project_id:project_id, file_id:file_id, fsPath:fsPath, "tried to upload symlink, not contining"
 				logger.log project_id:project_id, file_id:file_id, fsPath:fsPath, "tried to upload symlink, not contining"
 				return callback(new Error("can not upload symlink"))
 				return callback(new Error("can not upload symlink"))

+ 2 - 4
services/web/app/coffee/Features/Project/ProjectApiController.coffee

@@ -4,11 +4,9 @@ logger = require("logger-sharelatex")
 
 
 module.exports = 
 module.exports = 
 
 
-	getProjectDetails : (req, res)->
+	getProjectDetails : (req, res, next)->
 		{project_id} = req.params
 		{project_id} = req.params
 		ProjectDetailsHandler.getDetails project_id, (err, projDetails)->
 		ProjectDetailsHandler.getDetails project_id, (err, projDetails)->
-			if err?
-				logger.log err:err, project_id:project_id, "something went wrong getting project details"
-				return res.sendStatus 500
+			return next(err) if err?
 			res.json(projDetails)
 			res.json(projDetails)
 
 

+ 2 - 0
services/web/app/coffee/Features/Project/ProjectDetailsHandler.coffee

@@ -5,6 +5,7 @@ logger = require("logger-sharelatex")
 tpdsUpdateSender = require '../ThirdPartyDataStore/TpdsUpdateSender'
 tpdsUpdateSender = require '../ThirdPartyDataStore/TpdsUpdateSender'
 _ = require("underscore")
 _ = require("underscore")
 PublicAccessLevels = require("../Authorization/PublicAccessLevels")
 PublicAccessLevels = require("../Authorization/PublicAccessLevels")
+Errors = require("../Errors/Errors")
 
 
 module.exports = 
 module.exports = 
 
 
@@ -13,6 +14,7 @@ module.exports =
 			if err?
 			if err?
 				logger.err err:err, project_id:project_id, "error getting project"
 				logger.err err:err, project_id:project_id, "error getting project"
 				return callback(err)
 				return callback(err)
+			return callback(new Errors.NotFoundError("project not found")) if !project?
 			UserGetter.getUser project.owner_ref, (err, user) ->
 			UserGetter.getUser project.owner_ref, (err, user) ->
 				return callback(err) if err?
 				return callback(err) if err?
 				details =
 				details =

+ 12 - 5
services/web/app/coffee/Features/Project/ProjectDuplicator.coffee

@@ -15,9 +15,11 @@ module.exports = ProjectDuplicator =
 	_copyDocs: (newProject, originalRootDoc, originalFolder, desFolder, docContents, callback)->
 	_copyDocs: (newProject, originalRootDoc, originalFolder, desFolder, docContents, callback)->
 		setRootDoc = _.once (doc_id)->
 		setRootDoc = _.once (doc_id)->
 			projectEntityHandler.setRootDoc newProject._id, doc_id
 			projectEntityHandler.setRootDoc newProject._id, doc_id
-
-		jobs = originalFolder.docs.map (doc)->
+		docs = originalFolder.docs or []
+		jobs = docs.map (doc)->
 			return (cb)->
 			return (cb)->
+				if !doc?._id?
+					return callback()
 				content = docContents[doc._id.toString()]
 				content = docContents[doc._id.toString()]
 				projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, (err, newDoc)->
 				projectEntityHandler.addDocWithProject newProject, desFolder._id, doc.name, content.lines, (err, newDoc)->
 					if err?
 					if err?
@@ -30,7 +32,8 @@ module.exports = ProjectDuplicator =
 		async.series jobs, callback
 		async.series jobs, callback
 
 
 	_copyFiles: (newProject, originalProject_id, originalFolder, desFolder, callback)->
 	_copyFiles: (newProject, originalProject_id, originalFolder, desFolder, callback)->
-		jobs = originalFolder.fileRefs.map (file)->
+		fileRefs = originalFolder.fileRefs or []
+		jobs = fileRefs.map (file)->
 			return (cb)->
 			return (cb)->
 				projectEntityHandler.copyFileFromExistingProjectWithProject newProject, desFolder._id, originalProject_id, file, cb
 				projectEntityHandler.copyFileFromExistingProjectWithProject newProject, desFolder._id, originalProject_id, file, cb
 		async.parallelLimit jobs, 5, callback
 		async.parallelLimit jobs, 5, callback
@@ -40,10 +43,14 @@ module.exports = ProjectDuplicator =
 		ProjectGetter.getProject newProject_id, {rootFolder:true, name:true}, (err, newProject)->
 		ProjectGetter.getProject newProject_id, {rootFolder:true, name:true}, (err, newProject)->
 			if err?
 			if err?
 				logger.err project_id:newProject_id, "could not get project"
 				logger.err project_id:newProject_id, "could not get project"
-				return cb(err)
+				return callback(err)
+
+			folders = originalFolder.folders or []
 
 
-			jobs = originalFolder.folders.map (childFolder)->
+			jobs = folders.map (childFolder)->
 				return (cb)->
 				return (cb)->
+					if !childFolder?._id?
+						return cb()
 					projectEntityHandler.addFolderWithProject newProject, desFolder?._id, childFolder.name, (err, newFolder)->
 					projectEntityHandler.addFolderWithProject newProject, desFolder?._id, childFolder.name, (err, newFolder)->
 						return cb(err) if err?
 						return cb(err) if err?
 						ProjectDuplicator._copyFolderRecursivly newProject_id, originalProject_id, originalRootDoc, childFolder, newFolder, docContents, cb
 						ProjectDuplicator._copyFolderRecursivly newProject_id, originalProject_id, originalRootDoc, childFolder, newFolder, docContents, cb

+ 2 - 0
services/web/app/coffee/Features/Project/ProjectLocator.coffee

@@ -26,6 +26,8 @@ module.exports = ProjectLocator =
 			element = _.find searchFolder[elementType], (el)-> el?._id+'' == element_id+'' #need to ToString both id's for robustness
 			element = _.find searchFolder[elementType], (el)-> el?._id+'' == element_id+'' #need to ToString both id's for robustness
 			if !element? && searchFolder.folders? && searchFolder.folders.length != 0
 			if !element? && searchFolder.folders? && searchFolder.folders.length != 0
 				_.each searchFolder.folders, (folder, index)->
 				_.each searchFolder.folders, (folder, index)->
+					if !folder?
+						return
 					newPath = {}
 					newPath = {}
 					newPath[key] = value for own key,value of path #make a value copy of the string
 					newPath[key] = value for own key,value of path #make a value copy of the string
 					newPath.fileSystem += "/#{folder.name}"
 					newPath.fileSystem += "/#{folder.name}"

+ 2 - 1
services/web/app/coffee/Features/StaticPages/StaticPagesRouter.coffee

@@ -9,6 +9,7 @@ module.exports =
 
 
 		webRouter.get '/tos', HomeController.externalPage("tos", "Terms of Service")
 		webRouter.get '/tos', HomeController.externalPage("tos", "Terms of Service")
 		webRouter.get '/about', HomeController.externalPage("about", "About Us")
 		webRouter.get '/about', HomeController.externalPage("about", "About Us")
+
 		webRouter.get '/security', HomeController.externalPage("security", "Security")
 		webRouter.get '/security', HomeController.externalPage("security", "Security")
 		webRouter.get '/privacy_policy', HomeController.externalPage("privacy", "Privacy Policy")
 		webRouter.get '/privacy_policy', HomeController.externalPage("privacy", "Privacy Policy")
 		webRouter.get '/planned_maintenance', HomeController.externalPage("planned_maintenance", "Planned Maintenance")
 		webRouter.get '/planned_maintenance', HomeController.externalPage("planned_maintenance", "Planned Maintenance")
@@ -20,4 +21,4 @@ module.exports =
 		webRouter.get '/dropbox', HomeController.externalPage("dropbox", "Dropbox and ShareLaTeX")
 		webRouter.get '/dropbox', HomeController.externalPage("dropbox", "Dropbox and ShareLaTeX")
 
 
 		webRouter.get '/university', UniversityController.getIndexPage
 		webRouter.get '/university', UniversityController.getIndexPage
-		webRouter.get '/university/*', UniversityController.getPage
+		webRouter.get '/university/*', UniversityController.getPage

+ 24 - 18
services/web/app/coffee/Features/Subscription/RecurlyWrapper.coffee

@@ -469,33 +469,39 @@ module.exports = RecurlyWrapper =
 				logger.err err:error,  subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire,  "error exending trial"
 				logger.err err:error,  subscriptionId:subscriptionId, daysUntilExpire:daysUntilExpire,  "error exending trial"
 			callback(error)
 			callback(error)
 		)
 		)
-
-	_parseSubscriptionXml: (xml, callback) ->
-		RecurlyWrapper._parseXml xml, (error, data) ->
+	
+	listAccountActiveSubscriptions: (account_id, callback = (error, subscriptions) ->) ->
+		RecurlyWrapper.apiRequest {
+			url: "accounts/#{account_id}/subscriptions"
+			qs:
+				state: "active"
+			expect404: true
+		}, (error, response, body) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			if data? and data.subscription?
-				recurlySubscription = data.subscription
+			if response.statusCode == 404
+				return callback null, []
 			else
 			else
-				return callback "I don't understand the response from Recurly"
-			callback null, recurlySubscription
+				RecurlyWrapper._parseSubscriptionsXml body, callback
+
+	_parseSubscriptionsXml: (xml, callback) ->
+		RecurlyWrapper._parseXmlAndGetAttribute xml, "subscriptions", callback
+
+	_parseSubscriptionXml: (xml, callback) ->
+		RecurlyWrapper._parseXmlAndGetAttribute xml, "subscription", callback
 
 
 	_parseAccountXml: (xml, callback) ->
 	_parseAccountXml: (xml, callback) ->
-		RecurlyWrapper._parseXml xml, (error, data) ->
-			return callback(error) if error?
-			if data? and data.account?
-				account = data.account
-			else
-				return callback "I don't understand the response from Recurly"
-			callback null, account
+		RecurlyWrapper._parseXmlAndGetAttribute xml, "account", callback
 
 
 	_parseBillingInfoXml: (xml, callback) ->
 	_parseBillingInfoXml: (xml, callback) ->
+		RecurlyWrapper._parseXmlAndGetAttribute xml, "billing_info", callback
+
+	_parseXmlAndGetAttribute: (xml, attribute, callback) ->
 		RecurlyWrapper._parseXml xml, (error, data) ->
 		RecurlyWrapper._parseXml xml, (error, data) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			if data? and data.billing_info?
-				billingInfo = data.billing_info
+			if data? and data[attribute]?
+				return callback null, data[attribute]
 			else
 			else
-				return callback "I don't understand the response from Recurly"
-			callback null, billingInfo
+				return callback(new Error("I don't understand the response from Recurly"))
 
 
 	_parseXml: (xml, callback) ->
 	_parseXml: (xml, callback) ->
 		convertDataTypes = (data) ->
 		convertDataTypes = (data) ->

+ 33 - 25
services/web/app/coffee/Features/Subscription/SubscriptionController.coffee

@@ -46,31 +46,39 @@ module.exports = SubscriptionController =
 			if hasSubscription or !plan?
 			if hasSubscription or !plan?
 				res.redirect "/user/subscription"
 				res.redirect "/user/subscription"
 			else
 			else
-				currency = req.query.currency?.toUpperCase()
-				GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency, countryCode)->
-					return next(err) if err?
-					if recomendedCurrency? and !currency?
-						currency = recomendedCurrency
-					RecurlyWrapper.sign {
-						subscription:
-							plan_code : req.query.planCode
-							currency: currency
-						account_code: user._id
-					}, (error, signature) ->
-						return next(error) if error?
-						res.render "subscriptions/new",
-							title      : "subscribe"
-							plan_code: req.query.planCode
-							currency: currency
-							countryCode:countryCode
-							plan:plan
-							showStudentPlan: req.query.ssp
-							recurlyConfig: JSON.stringify
-								currency: currency
-								subdomain: Settings.apis.recurly.subdomain
-							showCouponField: req.query.scf
-							showVatField: req.query.svf
-							couponCode:      req.query.cc or ""
+				# LimitationsManager.userHasSubscription only checks Mongo. Double check with
+				# Recurly as well at this point (we don't do this most places for speed).
+				SubscriptionHandler.validateNoSubscriptionInRecurly user._id, (error, valid) ->
+					return next(error) if error?
+					if !valid
+						res.redirect "/user/subscription"
+						return
+					else
+						currency = req.query.currency?.toUpperCase()
+						GeoIpLookup.getCurrencyCode req.query?.ip || req.ip, (err, recomendedCurrency, countryCode)->
+							return next(err) if err?
+							if recomendedCurrency? and !currency?
+								currency = recomendedCurrency
+							RecurlyWrapper.sign {
+								subscription:
+									plan_code : req.query.planCode
+									currency: currency
+								account_code: user._id
+							}, (error, signature) ->
+								return next(error) if error?
+								res.render "subscriptions/new",
+									title      : "subscribe"
+									plan_code: req.query.planCode
+									currency: currency
+									countryCode:countryCode
+									plan:plan
+									showStudentPlan: req.query.ssp
+									recurlyConfig: JSON.stringify
+										currency: currency
+										subdomain: Settings.apis.recurly.subdomain
+									showCouponField: req.query.scf
+									showVatField: req.query.svf
+									couponCode:      req.query.cc or ""
 
 
 
 
 
 

+ 16 - 3
services/web/app/coffee/Features/Subscription/SubscriptionHandler.coffee

@@ -11,15 +11,28 @@ Analytics = require("../Analytics/AnalyticsManager")
 
 
 
 
 module.exports =
 module.exports =
+	validateNoSubscriptionInRecurly: (user_id, callback = (error, valid) ->) ->
+		RecurlyWrapper.listAccountActiveSubscriptions user_id, (error, subscriptions = []) ->
+			return callback(error) if error?
+			if subscriptions.length > 0
+				SubscriptionUpdater.syncSubscription subscriptions[0], user_id, (error) ->
+					return callback(error) if error?
+					return callback(null, false)
+			else
+				return callback(null, true)
 
 
 	createSubscription: (user, subscriptionDetails, recurly_token_id, callback)->
 	createSubscription: (user, subscriptionDetails, recurly_token_id, callback)->
 		self = @
 		self = @
 		clientTokenId = ""
 		clientTokenId = ""
-		RecurlyWrapper.createSubscription user, subscriptionDetails, recurly_token_id, (error, recurlySubscription)->
+		@validateNoSubscriptionInRecurly user._id, (error, valid) ->
 			return callback(error) if error?
 			return callback(error) if error?
-			SubscriptionUpdater.syncSubscription recurlySubscription, user._id, (error) ->
+			if !valid
+				return callback(new Error("user already has subscription in recurly"))
+			RecurlyWrapper.createSubscription user, subscriptionDetails, recurly_token_id, (error, recurlySubscription)->
 				return callback(error) if error?
 				return callback(error) if error?
-				callback()
+				SubscriptionUpdater.syncSubscription recurlySubscription, user._id, (error) ->
+					return callback(error) if error?
+					callback()
 
 
 	updateSubscription: (user, plan_code, coupon_code, callback)->
 	updateSubscription: (user, plan_code, coupon_code, callback)->
 		logger.log user:user, plan_code:plan_code, coupon_code:coupon_code, "updating subscription"
 		logger.log user:user, plan_code:plan_code, coupon_code:coupon_code, "updating subscription"

+ 2 - 1
services/web/app/views/project/editor/editor.pug

@@ -19,7 +19,8 @@ div.full-size(
 			'rp-size-mini': (!ui.reviewPanelOpen && reviewPanel.hasEntries),\
 			'rp-size-mini': (!ui.reviewPanelOpen && reviewPanel.hasEntries),\
 			'rp-size-expanded': ui.reviewPanelOpen,\
 			'rp-size-expanded': ui.reviewPanelOpen,\
 			'rp-layout-left': reviewPanel.layoutToLeft,\
 			'rp-layout-left': reviewPanel.layoutToLeft,\
-			'rp-loading-threads': reviewPanel.loadingThreads\
+			'rp-loading-threads': reviewPanel.loadingThreads,\
+			'rp-new-comment-ui': reviewPanel.newAddCommentUI\
 		}"
 		}"
 	)
 	)
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")
 		.loading-panel(ng-show="!editor.sharejs_doc || editor.opening")

+ 15 - 7
services/web/app/views/project/editor/review-panel.pug

@@ -1,10 +1,18 @@
 #review-panel
 #review-panel
-	a.rp-track-changes-indicator(
-		href
-		ng-if="editor.wantTrackChanges"
-		ng-click="toggleReviewPanel();"
-		ng-class="{ 'rp-track-changes-indicator-on-dark' : darkTheme }"
-	) !{translate("track_changes_is_on")}
+	.rp-in-editor-widgets
+		a.rp-track-changes-indicator(
+			href
+			ng-if="editor.wantTrackChanges"
+			ng-click="toggleReviewPanel();"
+			ng-class="{ 'rp-track-changes-indicator-on-dark' : darkTheme }"
+		) !{translate("track_changes_is_on")}
+		a.rp-add-comment-btn(
+			href
+			ng-if="reviewPanel.newAddCommentUI && reviewPanel.entries[editor.open_doc_id]['add-comment'] != null"
+			ng-click="addNewComment();"
+		) 
+			i.fa.fa-comment
+			|  #{translate("add_comment")}
 	.review-panel-toolbar
 	.review-panel-toolbar
 		resolved-comments-dropdown(
 		resolved-comments-dropdown(
 			class="rp-flex-block"
 			class="rp-flex-block"
@@ -314,7 +322,7 @@ script(type='text/ng-template', id='resolvedCommentEntryTemplate')
 script(type='text/ng-template', id='addCommentEntryTemplate')
 script(type='text/ng-template', id='addCommentEntryTemplate')
 	div
 	div
 		.rp-entry-callout.rp-entry-callout-add-comment
 		.rp-entry-callout.rp-entry-callout-add-comment
-		.rp-entry-indicator(
+		.rp-entry-indicator.rp-entry-indicator-add-comment(
 			ng-if="!commentState.adding"
 			ng-if="!commentState.adding"
 			ng-click="startNewComment(); onIndicatorClick();"
 			ng-click="startNewComment(); onIndicatorClick();"
 			tooltip=translate("add_comment")
 			tooltip=translate("add_comment")

+ 12 - 1
services/web/public/coffee/ide/editor/EditorManager.coffee

@@ -121,11 +121,22 @@ define [
 
 
 		_bindToDocumentEvents: (doc, sharejs_doc) ->
 		_bindToDocumentEvents: (doc, sharejs_doc) ->
 			sharejs_doc.on "error", (error, meta) =>
 			sharejs_doc.on "error", (error, meta) =>
-				if error?.message?.match "maxDocLength"
+				if error?.message?
+					message = error.message
+				else if typeof error == "string"
+					message = error
+				else
+					message = ""
+				if message.match "maxDocLength"
 					@ide.showGenericMessageModal(
 					@ide.showGenericMessageModal(
 						"Document Too Long"
 						"Document Too Long"
 						"Sorry, this file is too long to be edited manually. Please upload it directly."
 						"Sorry, this file is too long to be edited manually. Please upload it directly."
 					)
 					)
+				else if message.match "too many comments or tracked changes"
+					@ide.showGenericMessageModal(
+						"Too many comments or tracked changes"
+						"Sorry, this file has too many comments or tracked changes. Please try accepting or rejecting some existing changes, or resolving and deleting some comments."
+					)
 				else
 				else
 					@ide.socket.disconnect()
 					@ide.socket.disconnect()
 					@ide.reportError(error, meta)
 					@ide.reportError(error, meta)

+ 51 - 0
services/web/public/coffee/ide/editor/directives/aceEditor/track-changes/TrackChangesManager.coffee

@@ -73,16 +73,24 @@ define [
 						_scrollTimeout = null
 						_scrollTimeout = null
 					, 200
 					, 200
 
 
+			@_resetCutState()
+			onCut = () => @onCut()
+			onPaste = () => @onPaste()
+
 			bindToAce = () =>
 			bindToAce = () =>
 				@editor.on "changeSelection", onChangeSelection
 				@editor.on "changeSelection", onChangeSelection
 				@editor.on "change", onChangeSelection # Selection also moves with updates elsewhere in the document
 				@editor.on "change", onChangeSelection # Selection also moves with updates elsewhere in the document
 				@editor.on "changeSession", onChangeSession
 				@editor.on "changeSession", onChangeSession
+				@editor.on "cut", onCut
+				@editor.on "paste", onPaste
 				@editor.renderer.on "resize", onResize
 				@editor.renderer.on "resize", onResize
 
 
 			unbindFromAce = () =>
 			unbindFromAce = () =>
 				@editor.off "changeSelection", onChangeSelection
 				@editor.off "changeSelection", onChangeSelection
 				@editor.off "change", onChangeSelection
 				@editor.off "change", onChangeSelection
 				@editor.off "changeSession", onChangeSession
 				@editor.off "changeSession", onChangeSession
+				@editor.off "cut", onCut
+				@editor.off "paste", onPaste
 				@editor.renderer.off "resize", onResize
 				@editor.renderer.off "resize", onResize
 
 
 			@$scope.$watch "trackChangesEnabled", (enabled) =>
 			@$scope.$watch "trackChangesEnabled", (enabled) =>
@@ -244,6 +252,49 @@ define [
 					@_onCommentAdded(comment)
 					@_onCommentAdded(comment)
 			@broadcastChange()
 			@broadcastChange()
 
 
+		_resetCutState: () ->
+			@_cutState = {
+				text: null
+				comments: []
+				docId: null
+			}
+
+		onCut: () ->
+			@_resetCutState()
+			selection = @editor.getSelectionRange()
+			selection_start = @_aceRangeToShareJs(selection.start)
+			selection_end = @_aceRangeToShareJs(selection.end)
+			@_cutState.text = @editor.getSelectedText()
+			@_cutState.docId = @$scope.docId
+			for comment in @rangesTracker.comments
+				comment_start = comment.op.p
+				comment_end = comment_start + comment.op.c.length
+				if selection_start <= comment_start and comment_end <= selection_end
+					@_cutState.comments.push {
+						offset: comment.op.p - selection_start
+						text: comment.op.c
+						comment: comment
+					}
+
+		onPaste: () =>
+			@editor.once "change", (change) =>
+				return if change.action != "insert"
+				pasted_text = change.lines.join("\n")
+				paste_offset = @_aceRangeToShareJs(change.start)
+				# We have to wait until the change has been processed by the range tracker, 
+				# since if we move the ops into place beforehand, they will be moved again
+				# when the changes are processed by the range tracker. This ranges:dirty
+				# event is fired after the doc has applied the changes to the range tracker.
+				@$scope.sharejsDoc.on "ranges:dirty.paste", () =>
+					@$scope.sharejsDoc.off "ranges:dirty.paste" # Doc event emitter uses namespaced events
+					if pasted_text == @_cutState.text and @$scope.docId == @_cutState.docId
+						for {comment, offset, text} in @_cutState.comments
+							op = { c: text, p: paste_offset + offset, t: comment.id }
+							@$scope.sharejsDoc.submitOp op # Resubmitting an existing comment op (by thread id) will move it
+					@_resetCutState()
+					# Check that comments still match text. Will throw error if not.
+					@rangesTracker.validate(@editor.getValue())
+
 		checkMapping: () ->
 		checkMapping: () ->
 			# TODO: reintroduce this check
 			# TODO: reintroduce this check
 			session = @editor.getSession()
 			session = @editor.getSession()

+ 2 - 0
services/web/public/coffee/ide/file-tree/FileTreeManager.coffee

@@ -173,6 +173,8 @@ define [
 			@_findEntityByPathInFolder @$scope.rootFolder, path
 			@_findEntityByPathInFolder @$scope.rootFolder, path
 
 
 		_findEntityByPathInFolder: (folder, path) ->
 		_findEntityByPathInFolder: (folder, path) ->
+			if !path? or !folder?
+				return null
 			parts = path.split("/")
 			parts = path.split("/")
 			name = parts.shift()
 			name = parts.shift()
 			rest = parts.join("/")
 			rest = parts.join("/")

+ 37 - 11
services/web/public/coffee/ide/review-panel/RangesTracker.coffee

@@ -1,3 +1,6 @@
+# This file is shared between document-updater and web, so that the server and client share
+# an identical track changes implementation. Do not edit it directly in web or document-updater,
+# instead edit it at https://github.com/sharelatex/ranges-tracker, where it has a suite of tests
 load = () ->
 load = () ->
 	class RangesTracker
 	class RangesTracker
 		# The purpose of this class is to track a set of inserts and deletes to a document, like
 		# The purpose of this class is to track a set of inserts and deletes to a document, like
@@ -78,6 +81,13 @@ load = () ->
 			@comments = @comments.filter (c) -> c.id != comment_id
 			@comments = @comments.filter (c) -> c.id != comment_id
 			@_markAsDirty comment, "comment", "removed"
 			@_markAsDirty comment, "comment", "removed"
 		
 		
+		moveCommentId: (comment_id, position, text) ->
+			for comment in @comments
+				if comment.id == comment_id
+					comment.op.p = position
+					comment.op.c = text
+					@_markAsDirty comment, "comment", "moved"
+
 		getChange: (change_id) ->
 		getChange: (change_id) ->
 			change = null
 			change = null
 			for c in @changes
 			for c in @changes
@@ -90,6 +100,18 @@ load = () ->
 			change = @getChange(change_id)
 			change = @getChange(change_id)
 			return if !change?
 			return if !change?
 			@_removeChange(change)
 			@_removeChange(change)
+		
+		validate: (text) ->
+			for change in @changes
+				if change.op.i?
+					content = text.slice(change.op.p, change.op.p + change.op.i.length)
+					if content != change.op.i
+						throw new Error("Change (#{JSON.stringify(change)}) doesn't match text (#{JSON.stringify(content)})")
+			for comment in @comments
+				content = text.slice(comment.op.p, comment.op.p + comment.op.c.length)
+				if content != comment.op.c
+					throw new Error("Comment (#{JSON.stringify(comment)}) doesn't match text (#{JSON.stringify(content)})")
+			return true
 
 
 		applyOp: (op, metadata = {}) ->
 		applyOp: (op, metadata = {}) ->
 			metadata.ts ?= new Date()
 			metadata.ts ?= new Date()
@@ -110,17 +132,21 @@ load = () ->
 				@applyOp(op, metadata)
 				@applyOp(op, metadata)
 
 
 		addComment: (op, metadata) ->
 		addComment: (op, metadata) ->
-			# TODO: Don't allow overlapping comments?
-			@comments.push comment = {
-				id: op.t or @newId()
-				op: # Copy because we'll modify in place
-					c: op.c
-					p: op.p
-					t: op.t
-				metadata
-			}
-			@_markAsDirty comment, "comment", "added"
-			return comment
+			existing = @getComment(op.t)
+			if existing?
+				@moveCommentId(op.t, op.p, op.c)
+				return existing
+			else
+				@comments.push comment = {
+					id: op.t or @newId()
+					op: # Copy because we'll modify in place
+						c: op.c
+						p: op.p
+						t: op.t
+					metadata
+				}
+				@_markAsDirty comment, "comment", "added"
+				return comment
 		
 		
 		applyInsertToComments: (op) ->
 		applyInsertToComments: (op) ->
 			for comment in @comments
 			for comment in @comments

+ 21 - 3
services/web/public/coffee/ide/review-panel/controllers/ReviewPanelController.coffee

@@ -4,7 +4,7 @@ define [
 	"ide/colors/ColorManager"
 	"ide/colors/ColorManager"
 	"ide/review-panel/RangesTracker"
 	"ide/review-panel/RangesTracker"
 ], (App, EventEmitter, ColorManager, RangesTracker) ->
 ], (App, EventEmitter, ColorManager, RangesTracker) ->
-	App.controller "ReviewPanelController", ($scope, $element, ide, $timeout, $http, $modal, event_tracking, localStorage) ->
+	App.controller "ReviewPanelController", ($scope, $element, ide, $timeout, $http, $modal, event_tracking, sixpack, localStorage) ->
 		$reviewPanelEl = $element.find "#review-panel"
 		$reviewPanelEl = $element.find "#review-panel"
 
 
 		$scope.SubViews =
 		$scope.SubViews =
@@ -27,6 +27,14 @@ define [
 			layoutToLeft: false
 			layoutToLeft: false
 			rendererData: {}
 			rendererData: {}
 			loadingThreads: false
 			loadingThreads: false
+			newAddCommentUI: false # Test new UI for adding comments; remove afterwards.
+
+		$scope.shouldABAddCommentBtn = false
+		if $scope.user.signUpDate >= '2017-03-27'
+			sixpack.participate "add-comment-btn", [ "default", "editor-corner" ], (variation) ->
+				$scope.shouldABAddCommentBtn = true
+				$scope.variationABAddCommentBtn = variation
+				$scope.reviewPanel.newAddCommentUI = (variation == "editor-corner")
 
 
 		window.addEventListener "beforeunload", () ->
 		window.addEventListener "beforeunload", () ->
 			collapsedStates = {}
 			collapsedStates = {}
@@ -163,7 +171,11 @@ define [
 
 
 		$scope.$watch (() ->
 		$scope.$watch (() ->
 			entries = $scope.reviewPanel.entries[$scope.editor.open_doc_id] or {}
 			entries = $scope.reviewPanel.entries[$scope.editor.open_doc_id] or {}
-			Object.keys(entries).length
+			permEntries = {}
+			for entry, entryData of entries
+				if entry != "add-comment" or !$scope.reviewPanel.newAddCommentUI
+					permEntries[entry] = entryData 
+			Object.keys(permEntries).length
 		), (nEntries) ->
 		), (nEntries) ->
 			$scope.reviewPanel.hasEntries = nEntries > 0 and $scope.project.features.trackChangesVisible
 			$scope.reviewPanel.hasEntries = nEntries > 0 and $scope.project.features.trackChangesVisible
 
 
@@ -323,11 +335,17 @@ define [
 			$scope.$broadcast "change:reject", entry_id
 			$scope.$broadcast "change:reject", entry_id
 			event_tracking.sendMB "rp-change-rejected", { view: if $scope.ui.reviewPanelOpen then $scope.reviewPanel.subView else 'mini' }
 			event_tracking.sendMB "rp-change-rejected", { view: if $scope.ui.reviewPanelOpen then $scope.reviewPanel.subView else 'mini' }
 		
 		
+		$scope.addNewComment = () ->
+			$scope.$broadcast "comment:start_adding"
+			$scope.toggleReviewPanel()
+
 		$scope.startNewComment = () ->
 		$scope.startNewComment = () ->
 			$scope.$broadcast "comment:select_line"
 			$scope.$broadcast "comment:select_line"
 			$timeout () ->
 			$timeout () ->
 				$scope.$broadcast "review-panel:layout"
 				$scope.$broadcast "review-panel:layout"
-		
+			if $scope.shouldABAddCommentBtn and !$scope.ui.reviewPanelOpen
+				sixpack.convert "add-comment-btn"
+
 		$scope.submitNewComment = (content) ->
 		$scope.submitNewComment = (content) ->
 			return if !content? or content == ""
 			return if !content? or content == ""
 			doc_id = $scope.editor.open_doc_id
 			doc_id = $scope.editor.open_doc_id

+ 4 - 1
services/web/public/coffee/ide/review-panel/directives/addCommentEntry.coffee

@@ -8,13 +8,16 @@ define [
 			onStartNew: "&"
 			onStartNew: "&"
 			onSubmit: "&"
 			onSubmit: "&"
 			onCancel: "&"
 			onCancel: "&"
-			onIndicatorClick: "&"
+			onIndicatorClick: "&"	
 			layoutToLeft: "="
 			layoutToLeft: "="
 		link: (scope, element, attrs) ->
 		link: (scope, element, attrs) ->
 			scope.state =
 			scope.state =
 				isAdding: false
 				isAdding: false
 				content: ""
 				content: ""
 
 
+			scope.$on "comment:start_adding", () ->
+				scope.startNewComment()
+
 			scope.startNewComment = () ->
 			scope.startNewComment = () ->
 				scope.state.isAdding = true
 				scope.state.isAdding = true
 				scope.onStartNew()
 				scope.onStartNew()

+ 2 - 2
services/web/public/js/ace-1.2.5/worker-latex.js

@@ -1931,7 +1931,7 @@ var InterpretTokens = function (TokeniseResult, ErrorReporter) {
                 if (newPos === null) { continue; } else {i = newPos;};
                 if (newPos === null) { continue; } else {i = newPos;};
             } else if (seq === "hbox" || seq === "text" || seq === "mbox" || seq === "footnote" || seq === "intertext" || seq === "shortintertext" || seq === "textnormal" || seq === "tag" || seq === "reflectbox" || seq === "textrm") {
             } else if (seq === "hbox" || seq === "text" || seq === "mbox" || seq === "footnote" || seq === "intertext" || seq === "shortintertext" || seq === "textnormal" || seq === "tag" || seq === "reflectbox" || seq === "textrm") {
                 nextGroupMathMode = false;
                 nextGroupMathMode = false;
-            } else if (seq === "rotatebox" || seq === "scalebox") {
+            } else if (seq === "rotatebox" || seq === "scalebox"  || seq == "feynmandiagram") {
                 newPos = readOptionalGeneric(TokeniseResult, i);
                 newPos = readOptionalGeneric(TokeniseResult, i);
                 if (newPos === null) { /* do nothing */ } else {i = newPos;};
                 if (newPos === null) { /* do nothing */ } else {i = newPos;};
                 newPos = readDefinition(TokeniseResult, i);
                 newPos = readDefinition(TokeniseResult, i);
@@ -2179,7 +2179,7 @@ var EnvHandler = function (ErrorReporter) {
     this._beginMathMode = function (thisEnv) {
     this._beginMathMode = function (thisEnv) {
         var currentMathMode = this.getMathMode(); // undefined, null, $, $$, name of mathmode env
         var currentMathMode = this.getMathMode(); // undefined, null, $, $$, name of mathmode env
         if (currentMathMode) {
         if (currentMathMode) {
-            ErrorFrom(thisEnv, thisEnv.name + " used inside existing math mode " + getName(currentMathMode),
+            ErrorFrom(thisEnv, getName(thisEnv) + " used inside existing math mode " + getName(currentMathMode),
                       {suppressIfEditing:true, errorAtStart: true, mathMode:true});
                       {suppressIfEditing:true, errorAtStart: true, mathMode:true});
         };
         };
         thisEnv.mathMode = thisEnv;
         thisEnv.mathMode = thisEnv;

+ 64 - 38
services/web/public/stylesheets/app/editor/review-panel.less

@@ -9,13 +9,10 @@
 @rp-border-grey         : #d9d9d9;
 @rp-border-grey         : #d9d9d9;
 
 
 @rp-green               : #2c8e30;
 @rp-green               : #2c8e30;
-@rp-dim-green           : #cae3cb;
 @rp-green-on-dark		: rgba(37, 107, 41, 0.5);
 @rp-green-on-dark		: rgba(37, 107, 41, 0.5);
 @rp-red                 : #c5060b;
 @rp-red                 : #c5060b;
-@rp-dim-red             : #f3cdce;
 @rp-yellow              : #f3b111;
 @rp-yellow              : #f3b111;
 @rp-yellow-on-dark		: rgba(194, 93, 11, 0.5);
 @rp-yellow-on-dark		: rgba(194, 93, 11, 0.5);
-@rp-dim-yellow          : #ffe9b2;
 @rp-grey                : #aaaaaa;
 @rp-grey                : #aaaaaa;
 
 
 @rp-type-blue           : #6b7797;
 @rp-type-blue           : #6b7797;
@@ -93,7 +90,7 @@
 	}
 	}
 
 
 #review-panel {
 #review-panel {
-	display: none;
+	display: block;
 	.rp-size-expanded & {
 	.rp-size-expanded & {
 		display: flex;
 		display: flex;
 		flex-direction: column;
 		flex-direction: column;
@@ -101,7 +98,6 @@
 		overflow: visible;
 		overflow: visible;
 	}
 	}
 	.rp-size-mini & {
 	.rp-size-mini & {
-		display: block;
 		width: @review-off-width;
 		width: @review-off-width;
 		z-index: 6;
 		z-index: 6;
 	}
 	}
@@ -155,7 +151,13 @@
 	}
 	}
 
 
 .rp-entry-list {
 .rp-entry-list {
+	display: none;
 	width: 100%;
 	width: 100%;
+
+	.rp-size-expanded &,
+	.rp-size-mini & {
+		display: block;
+	}
 	
 	
 	.rp-state-current-file & {
 	.rp-state-current-file & {
 		position: absolute;
 		position: absolute;
@@ -197,6 +199,10 @@
 		right: 4px;
 		right: 4px;
 		z-index: 1;
 		z-index: 1;
 	}
 	}
+
+	.rp-new-comment-ui &-add-comment {
+		display: none;
+	}
 }
 }
 
 
 .rp-entry-wrapper {
 .rp-entry-wrapper {
@@ -448,6 +454,12 @@
 		display: block;
 		display: block;
 		padding: 5px 10px;
 		padding: 5px 10px;
 		border-radius: 3px;
 		border-radius: 3px;
+
+		.rp-in-editor-widgets & {
+			white-space: nowrap;
+			border-radius: 0;
+			border-bottom-left-radius: 3px;
+		}
 	}
 	}
 
 
 	.rp-new-comment {
 	.rp-new-comment {
@@ -563,6 +575,10 @@
 			border-color: @rp-yellow;
 			border-color: @rp-yellow;
 		}
 		}
 	}
 	}
+
+	.rp-size-mini.rp-new-comment-ui &-add-comment {
+		display: none;
+	}
 }
 }
 
 
 .rp-overview-file-header {
 .rp-overview-file-header {
@@ -610,11 +626,12 @@
 }
 }
 
 
 .rp-nav {
 .rp-nav {
-	display: flex;
+	display: none;
 	flex-shrink: 0;
 	flex-shrink: 0;
-	.rp-size-mini & {
-		display: none;
+	.rp-size-expanded & {
+		display: flex;
 	}
 	}
+
 	.rp-state-current-file & {
 	.rp-state-current-file & {
 		position: absolute;
 		position: absolute;
 		bottom: 0;
 		bottom: 0;
@@ -748,13 +765,16 @@
 		.rp-loading-threads & {
 		.rp-loading-threads & {
 			display: none;
 			display: none;
 		}
 		}
+		z-index: 6 // Appear above text selection
 	}
 	}
 
 
 	.track-changes-comment-marker {
 	.track-changes-comment-marker {
-		background-color: @rp-dim-yellow;
+		background-color: @rp-yellow;
+		opacity: 0.3;
 	}
 	}
 	.track-changes-added-marker {
 	.track-changes-added-marker {
-		background-color: @rp-dim-green;
+		background-color: @rp-green;
+		opacity: 0.3;
 	}
 	}
 	.track-changes-deleted-marker {
 	.track-changes-deleted-marker {
 		border-left: 2px dotted @rp-red;
 		border-left: 2px dotted @rp-red;
@@ -871,43 +891,49 @@
 	}
 	}
 }
 }
 
 
-.rp-track-changes-indicator {
-	display: none;
+.rp-in-editor-widgets {
 	position: absolute;
 	position: absolute;
 	top: 0;
 	top: 0;
-	right: @review-off-width;
-	padding: 5px 10px;
-	background-color: rgba(240, 240, 240, 0.9);
-	color: @rp-type-blue;
-	text-align: center;
-	border-bottom-left-radius: 3px;
-	font-size: 10px;
+	right: 0;
+	font-size: 11px;
 	z-index: 2;
 	z-index: 2;
-	white-space: nowrap;
+	
+	.rp-size-mini & {
+		right: @review-off-width;
+	}
+}
+	.rp-track-changes-indicator {
+		display: block;
+		padding: 5px 10px;
+		background-color: rgba(240, 240, 240, 0.9);
+		color: @rp-type-blue;
+		text-align: center;
+		border-bottom-left-radius: 3px;
+		white-space: nowrap;
 
 
-	&.rp-track-changes-indicator-on-dark {
-		background-color: rgba(88, 88, 88, .8);
-		color: #FFF;
-		
-		&:hover,
-		&:focus {
-			background-color: rgba(88, 88, 88, 1);
+		&.rp-track-changes-indicator-on-dark {
+			background-color: rgba(88, 88, 88, .8);
 			color: #FFF;
 			color: #FFF;
+			
+			&:hover,
+			&:focus {
+				background-color: rgba(88, 88, 88, 1);
+				color: #FFF;
+			}
 		}
 		}
-	}
 
 
-	&:hover,
-	&:focus {
-		outline: 0;
-		text-decoration: none;
-		background-color: rgba(240, 240, 240, 1);
-		color: @rp-type-blue;
-	}
+		&:hover,
+		&:focus {
+			outline: 0;
+			text-decoration: none;
+			background-color: rgba(240, 240, 240, 1);
+			color: @rp-type-blue;
+		}
 
 
-	.rp-size-mini & {
-		display: block;
+		.rp-size-expanded & {
+			display: none;
+		}
 	}
 	}
-}
 
 
 // Helper class for elements which aren't treated as flex-items by IE10, e.g:
 // Helper class for elements which aren't treated as flex-items by IE10, e.g:
 // * inline items;
 // * inline items;

+ 44 - 0
services/web/test/UnitTests/coffee/Analytics/AnalyticsControllerTests.coffee

@@ -0,0 +1,44 @@
+should = require('chai').should()
+SandboxedModule = require('sandboxed-module')
+assert = require('assert')
+path = require('path')
+modulePath = path.join __dirname, '../../../../app/js/Features/Analytics/AnalyticsController'
+sinon = require("sinon")
+expect = require("chai").expect
+
+
+describe 'AnalyticsController', ->
+
+	beforeEach ->
+		@AuthenticationController =
+			getLoggedInUserId: sinon.stub()
+
+		@AnalyticsManager =
+			recordEvent: sinon.stub().callsArgWith(3)
+
+		@req = 
+			params:
+				event:"i_did_something"
+			body:"stuff"
+			sessionID: "sessionIDHere"
+
+		@res =
+			send:->
+		@controller = SandboxedModule.require modulePath, requires:
+			"./AnalyticsManager":@AnalyticsManager
+			"../Authentication/AuthenticationController":@AuthenticationController
+			"logger-sharelatex":
+				log:->
+
+	describe "recordEvent", ->
+
+		it "should use the user_id", (done)->
+			@AuthenticationController.getLoggedInUserId.returns("1234")
+			@controller.recordEvent @req, @res
+			@AnalyticsManager.recordEvent.calledWith("1234", @req.params["event"], @req.body).should.equal true
+			done()
+
+		it "should use the session id", (done)->
+			@controller.recordEvent @req, @res
+			@AnalyticsManager.recordEvent.calledWith(@req.sessionID, @req.params["event"], @req.body).should.equal true
+			done()

+ 5 - 0
services/web/test/UnitTests/coffee/Authentication/AuthenticationControllerTests.coffee

@@ -254,6 +254,8 @@ describe "AuthenticationController", ->
 				@cb = sinon.stub()
 				@cb = sinon.stub()
 				@LoginRateLimiter.processLoginRequest.callsArgWith(1, null, true)
 				@LoginRateLimiter.processLoginRequest.callsArgWith(1, null, true)
 				@AuthenticationManager.authenticate = sinon.stub().callsArgWith(2, null, @user)
 				@AuthenticationManager.authenticate = sinon.stub().callsArgWith(2, null, @user)
+				@req.sessionID = Math.random()
+				@AnalyticsManager.identifyUser = sinon.stub()
 				@AuthenticationController.doPassportLogin(@req, @req.body.email, @req.body.password, @cb)
 				@AuthenticationController.doPassportLogin(@req, @req.body.email, @req.body.password, @cb)
 
 
 			it "should attempt to authorise the user", ->
 			it "should attempt to authorise the user", ->
@@ -261,6 +263,9 @@ describe "AuthenticationController", ->
 					.calledWith(email: @email.toLowerCase(), @password)
 					.calledWith(email: @email.toLowerCase(), @password)
 					.should.equal true
 					.should.equal true
 
 
+			it "should call identifyUser", ->
+				@AnalyticsManager.identifyUser.calledWith(@user._id, @req.sessionID).should.equal true
+
 			it "should setup the user data in the background", ->
 			it "should setup the user data in the background", ->
 				@UserHandler.setupLoginData.calledWith(@user).should.equal true
 				@UserHandler.setupLoginData.calledWith(@user).should.equal true
 
 

+ 14 - 1
services/web/test/UnitTests/coffee/Authorization/AuthorizationManagerTests.coffee

@@ -136,7 +136,20 @@ describe "AuthorizationManager", ->
 			it "should return a NotFoundError", ->
 			it "should return a NotFoundError", ->
 				@AuthorizationManager.getPrivilegeLevelForProject @user_id, @project_id, (error) ->
 				@AuthorizationManager.getPrivilegeLevelForProject @user_id, @project_id, (error) ->
 					error.should.be.instanceof Errors.NotFoundError
 					error.should.be.instanceof Errors.NotFoundError
-	
+
+		describe "when the project id is not valid", ->
+			beforeEach ->
+				@AuthorizationManager.isUserSiteAdmin.withArgs(@user_id).yields(null, false)
+				@CollaboratorsHandler.getMemberIdPrivilegeLevel
+					.withArgs(@user_id, @project_id)
+					.yields(null, "readOnly")
+
+			it "should return a error", (done)->
+				@AuthorizationManager.getPrivilegeLevelForProject undefined, "not project id", (err) =>
+					@Project.findOne.called.should.equal false
+					expect(err).to.exist
+					done()
+
 	describe "canUserReadProject", ->
 	describe "canUserReadProject", ->
 		beforeEach ->
 		beforeEach ->
 			@AuthorizationManager.getPrivilegeLevelForProject = sinon.stub()
 			@AuthorizationManager.getPrivilegeLevelForProject = sinon.stub()

+ 5 - 3
services/web/test/UnitTests/coffee/Email/EmailSenderTests.coffee

@@ -51,17 +51,19 @@ describe "EmailSender", ->
 		it "should set the properties on the email to send", (done)->
 		it "should set the properties on the email to send", (done)->
 			@sesClient.sendMail.callsArgWith(1)
 			@sesClient.sendMail.callsArgWith(1)
 
 
-			@sender.sendEmail @opts, =>
+			@sender.sendEmail @opts, (err) =>
+				expect(err).to.not.exist
 				args = @sesClient.sendMail.args[0][0]
 				args = @sesClient.sendMail.args[0][0]
 				args.html.should.equal @opts.html
 				args.html.should.equal @opts.html
 				args.to.should.equal @opts.to
 				args.to.should.equal @opts.to
 				args.subject.should.equal @opts.subject
 				args.subject.should.equal @opts.subject
 				done()
 				done()
 
 
-		it "should return the error", (done)->
+		it "should return a non-specific error", (done)->
 			@sesClient.sendMail.callsArgWith(1, "error")
 			@sesClient.sendMail.callsArgWith(1, "error")
 			@sender.sendEmail {}, (err)=>
 			@sender.sendEmail {}, (err)=>
-				err.should.equal "error"
+				err.should.exist
+				err.toString().should.equal 'Error: Cannot send email'
 				done()
 				done()
 
 
 
 

+ 7 - 0
services/web/test/UnitTests/coffee/FileStore/FileStoreHandlerTests.coffee

@@ -96,6 +96,13 @@ describe "FileStoreHandler", ->
 					@fs.createReadStream.called.should.equal false
 					@fs.createReadStream.called.should.equal false
 					done()
 					done()
 
 
+		describe "symlink", ->
+			it "should not read file stat returns nothing", (done)->
+				@fs.lstat = sinon.stub().callsArgWith(1, null, null)
+				@handler.uploadFileFromDisk @project_id, @file_id, @fsPath, =>
+					@fs.createReadStream.called.should.equal false
+					done()
+
 		describe "when upload fails", ->
 		describe "when upload fails", ->
 			beforeEach ->
 			beforeEach ->
 				@writeStream.on = (type, cb) ->
 				@writeStream.on = (type, cb) ->

+ 4 - 5
services/web/test/UnitTests/coffee/Project/ProjectApiControllerTests.coffee

@@ -20,6 +20,7 @@ describe 'Project api controller', ->
 			session:
 			session:
 				destroy:sinon.stub()
 				destroy:sinon.stub()
 		@res = {}
 		@res = {}
+		@next = sinon.stub()
 		@projDetails = {name:"something"}
 		@projDetails = {name:"something"}
 
 
 
 
@@ -34,9 +35,7 @@ describe 'Project api controller', ->
 			@controller.getProjectDetails @req, @res
 			@controller.getProjectDetails @req, @res
 
 
 
 
-		it "should send a 500 if there is an error", (done)->
+		it "should send a 500 if there is an error", ()->
 			@ProjectDetailsHandler.getDetails.callsArgWith(1, "error")
 			@ProjectDetailsHandler.getDetails.callsArgWith(1, "error")
-			@res.sendStatus = (resCode)=>
-				resCode.should.equal 500
-				done()
-			@controller.getProjectDetails @req, @res
+			@controller.getProjectDetails @req, @res, @next
+			@next.calledWith("error").should.equal true

+ 8 - 0
services/web/test/UnitTests/coffee/Project/ProjectDetailsHandlerTests.coffee

@@ -1,5 +1,6 @@
 should = require('chai').should()
 should = require('chai').should()
 modulePath = "../../../../app/js/Features/Project/ProjectDetailsHandler"
 modulePath = "../../../../app/js/Features/Project/ProjectDetailsHandler"
+Errors = require "../../../../app/js/Features/Errors/Errors"
 SandboxedModule = require('sandboxed-module')
 SandboxedModule = require('sandboxed-module')
 sinon = require('sinon')
 sinon = require('sinon')
 assert = require("chai").assert
 assert = require("chai").assert
@@ -48,6 +49,13 @@ describe 'ProjectDetailsHandler', ->
 				assert.equal(details.something, undefined)
 				assert.equal(details.something, undefined)
 				done()
 				done()
 
 
+		it "should return an error for a non-existent project", (done)->
+			@ProjectGetter.getProject.callsArg(2, null, null)
+			err = new Errors.NotFoundError("project not found")
+			@handler.getDetails "0123456789012345678901234", (error, details) =>
+				err.should.eql error
+				done()
+
 		it "should return the error", (done)->
 		it "should return the error", (done)->
 			error = "some error"
 			error = "some error"
 			@ProjectGetter.getProject.callsArgWith(2, error)
 			@ProjectGetter.getProject.callsArgWith(2, error)

+ 4 - 4
services/web/test/UnitTests/coffee/Project/ProjectDuplicatorTests.coffee

@@ -9,7 +9,7 @@ describe 'ProjectDuplicator', ->
 		@level2folder =
 		@level2folder =
 			name: "level2folderName"
 			name: "level2folderName"
 			_id:"level2folderId"
 			_id:"level2folderId"
-			docs:[@doc2 = {_id: "doc2_id", name:"level2folderDocName"}]
+			docs:[@doc2 = {_id: "doc2_id", name:"level2folderDocName"}, undefined]
 			folders:[]
 			folders:[]
 			fileRefs:[{name:"file2", _id:"file2"}]
 			fileRefs:[{name:"file2", _id:"file2"}]
 		@level1folder =
 		@level1folder =
@@ -17,12 +17,12 @@ describe 'ProjectDuplicator', ->
 			_id:"level1folderId"
 			_id:"level1folderId"
 			docs:[@doc1 = {_id: "doc1_id", name:"level1folderDocName"}]
 			docs:[@doc1 = {_id: "doc1_id", name:"level1folderDocName"}]
 			folders:[@level2folder]
 			folders:[@level2folder]
-			fileRefs:[{name:"file1", _id:"file1"}]
+			fileRefs:[{name:"file1", _id:"file1"}, null]
 		@rootFolder = 
 		@rootFolder = 
 			name:"rootFolder"
 			name:"rootFolder"
 			_id:"rootFolderId"
 			_id:"rootFolderId"
 			docs:[@doc0 = {_id: "doc0_id", name:"rootDocHere"}]
 			docs:[@doc0 = {_id: "doc0_id", name:"rootDocHere"}]
-			folders:[@level1folder]
+			folders:[@level1folder, {}]
 			fileRefs:[{name:"file0", _id:"file0"}]
 			fileRefs:[{name:"file0", _id:"file0"}]
 		@project = 
 		@project = 
 			_id: @old_project_id = "this_is_the_old_project_id"
 			_id: @old_project_id = "this_is_the_old_project_id"
@@ -117,7 +117,7 @@ describe 'ProjectDuplicator', ->
 			@projectOptionsHandler.setCompiler.calledWith(@stubbedNewProject._id, @project.compiler).should.equal true
 			@projectOptionsHandler.setCompiler.calledWith(@stubbedNewProject._id, @project.compiler).should.equal true
 			done()
 			done()
 	
 	
-	it 'should use the same root docccccccc', (done)->
+	it 'should use the same root doc', (done)->
 		@entityHandler.addDocWithProject.callsArgWith(4, null, @rootFolder.docs[0])
 		@entityHandler.addDocWithProject.callsArgWith(4, null, @rootFolder.docs[0])
 		@duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=>
 		@duplicator.duplicate @owner, @old_project_id, "", (err, newProject)=>
 			@entityHandler.setRootDoc.calledWith(@stubbedNewProject._id, @rootFolder.docs[0]._id).should.equal true
 			@entityHandler.setRootDoc.calledWith(@stubbedNewProject._id, @rootFolder.docs[0]._id).should.equal true

+ 1 - 1
services/web/test/UnitTests/coffee/Project/ProjectLocatorTests.coffee

@@ -17,7 +17,7 @@ file1 = name:"file1", _id:"dsa9lkdsad"
 subSubFile = name:"subSubFile", _id:"d1d2dk"
 subSubFile = name:"subSubFile", _id:"d1d2dk"
 subSubDoc = name:"subdoc.txt", _id:"321dmdwi"
 subSubDoc = name:"subdoc.txt", _id:"321dmdwi"
 secondSubFolder = name:"secondSubFolder", _id:"dsa3e23", docs:[subSubDoc], fileRefs:[subSubFile], folders:[]
 secondSubFolder = name:"secondSubFolder", _id:"dsa3e23", docs:[subSubDoc], fileRefs:[subSubFile], folders:[]
-subFolder = name:"subFolder", _id:"dsadsa93", folders:[secondSubFolder], docs:[], fileRefs:[]
+subFolder = name:"subFolder", _id:"dsadsa93", folders:[secondSubFolder, null], docs:[], fileRefs:[]
 subFolder1 = name:"subFolder1", _id:"123asdjoij"
 subFolder1 = name:"subFolder1", _id:"123asdjoij"
 
 
 rootFolder =
 rootFolder =

+ 32 - 0
services/web/test/UnitTests/coffee/Subscription/RecurlyWrapperTests.coffee

@@ -1030,3 +1030,35 @@ describe "RecurlyWrapper", ->
 					@call (err, result) =>
 					@call (err, result) =>
 						expect(err).to.be.instanceof Error
 						expect(err).to.be.instanceof Error
 						done()
 						done()
+
+	describe "listAccountActiveSubscriptions", ->
+		beforeEach ->
+			@user_id = "mock-user-id"
+			@callback = sinon.stub()
+			@RecurlyWrapper.apiRequest = sinon.stub().yields(null, @response = {"mock": "response"}, @body = "<mock body/>")
+			@RecurlyWrapper._parseSubscriptionsXml = sinon.stub().yields(null, @subscriptions = ["mock", "subscriptions"])
+			
+		describe "with an account", ->
+			beforeEach ->
+				@RecurlyWrapper.listAccountActiveSubscriptions @user_id, @callback
+			
+			it "should send a request to Recurly", ->
+				@RecurlyWrapper.apiRequest
+					.calledWith({
+						url: "accounts/#{@user_id}/subscriptions"
+						qs:
+							state: "active"
+						expect404: true
+					})
+					.should.equal true
+
+			it "should return the subscriptions", ->
+				@callback.calledWith(null, @subscriptions).should.equal true
+			
+		describe "without an account", ->
+			beforeEach ->
+				@response.statusCode = 404
+				@RecurlyWrapper.listAccountActiveSubscriptions @user_id, @callback
+
+			it "should return an empty array of subscriptions", ->
+				@callback.calledWith(null, []).should.equal true

+ 12 - 2
services/web/test/UnitTests/coffee/Subscription/SubscriptionControllerTests.coffee

@@ -17,8 +17,7 @@ mockSubscriptions =
 		account:
 		account:
 			account_code: "user-123"
 			account_code: "user-123"
 
 
-describe "SubscriptionController sanboxed", ->
-
+describe "SubscriptionController", ->
 	beforeEach ->
 	beforeEach ->
 		@user = {email:"tom@yahoo.com", _id: 'one', signUpDate: new Date('2000-10-01')}
 		@user = {email:"tom@yahoo.com", _id: 'one', signUpDate: new Date('2000-10-01')}
 		@activeRecurlySubscription = mockSubscriptions["subscription-123-active"]
 		@activeRecurlySubscription = mockSubscriptions["subscription-123-active"]
@@ -150,6 +149,7 @@ describe "SubscriptionController sanboxed", ->
 	describe "paymentPage", ->
 	describe "paymentPage", ->
 		beforeEach ->
 		beforeEach ->
 			@req.headers = {}
 			@req.headers = {}
+			@SubscriptionHandler.validateNoSubscriptionInRecurly = sinon.stub().yields(null, true)
 			@GeoIpLookup.getCurrencyCode.callsArgWith(1, null, @stubbedCurrencyCode)
 			@GeoIpLookup.getCurrencyCode.callsArgWith(1, null, @stubbedCurrencyCode)
 
 
 		describe "with a user without a subscription", ->
 		describe "with a user without a subscription", ->
@@ -209,6 +209,16 @@ describe "SubscriptionController sanboxed", ->
 					opts.currency.should.equal @stubbedCurrencyCode
 					opts.currency.should.equal @stubbedCurrencyCode
 					done()
 					done()
 				@SubscriptionController.paymentPage @req, @res
 				@SubscriptionController.paymentPage @req, @res
+		
+		describe "with a recurly subscription already", ->
+			it "should redirect to the subscription dashboard", (done)->
+				@LimitationsManager.userHasSubscription.callsArgWith(1, null, false)
+				@SubscriptionHandler.validateNoSubscriptionInRecurly = sinon.stub().yields(null, false)
+				@res.redirect = (url)=>
+					url.should.equal "/user/subscription"
+					done()
+				@SubscriptionController.paymentPage(@req, @res)
+			
 
 
 	describe "successful_subscription", ->
 	describe "successful_subscription", ->
 		beforeEach (done) ->
 		beforeEach (done) ->

+ 58 - 16
services/web/test/UnitTests/coffee/Subscription/SubscriptionHandlerTests.coffee

@@ -16,7 +16,7 @@ mockRecurlySubscriptions =
 		account:
 		account:
 			account_code: "user-123"
 			account_code: "user-123"
 			
 			
-describe "Subscription Handler sanboxed", ->
+describe "SubscriptionHandler", ->
 
 
 	beforeEach ->
 	beforeEach ->
 		@Settings = 
 		@Settings = 
@@ -33,7 +33,7 @@ describe "Subscription Handler sanboxed", ->
 		@activeRecurlySubscription = mockRecurlySubscriptions["subscription-123-active"]
 		@activeRecurlySubscription = mockRecurlySubscriptions["subscription-123-active"]
 		@User = {}
 		@User = {}
 		@user =
 		@user =
-			_id: "user_id_here_"
+			_id: @user_id = "user_id_here_"
 		@subscription = 
 		@subscription = 
 			recurlySubscription_id: @activeRecurlySubscription.uuid
 			recurlySubscription_id: @activeRecurlySubscription.uuid
 		@RecurlyWrapper = 
 		@RecurlyWrapper = 
@@ -77,23 +77,33 @@ describe "Subscription Handler sanboxed", ->
 
 
 
 
 	describe "createSubscription", ->
 	describe "createSubscription", ->
-		beforeEach (done) ->
+		beforeEach ->
+			@callback = sinon.stub()
 			@subscriptionDetails = 
 			@subscriptionDetails = 
 				cvv:"123"
 				cvv:"123"
 				number:"12345"
 				number:"12345"
 			@recurly_token_id = "45555666"
 			@recurly_token_id = "45555666"
-			@SubscriptionHandler.createSubscription(@user, @subscriptionDetails, @recurly_token_id, done)
+			@SubscriptionHandler.validateNoSubscriptionInRecurly = sinon.stub().yields(null, true)
 
 
-		it "should create the subscription with the wrapper", (done)->
-			@RecurlyWrapper.createSubscription.calledWith(@user, @subscriptionDetails, @recurly_token_id).should.equal true
-			done()
+		describe "successfully", ->
+			beforeEach ->
+				@SubscriptionHandler.createSubscription(@user, @subscriptionDetails, @recurly_token_id, @callback)
 
 
-		it "should sync the subscription to the user", (done)->
-			@SubscriptionUpdater.syncSubscription.calledOnce.should.equal true
-			@SubscriptionUpdater.syncSubscription.args[0][0].should.deep.equal @activeRecurlySubscription
-			@SubscriptionUpdater.syncSubscription.args[0][1].should.deep.equal @user._id
-			done()
+			it "should create the subscription with the wrapper", ->
+				@RecurlyWrapper.createSubscription.calledWith(@user, @subscriptionDetails, @recurly_token_id).should.equal true
 
 
+			it "should sync the subscription to the user", ->
+				@SubscriptionUpdater.syncSubscription.calledOnce.should.equal true
+				@SubscriptionUpdater.syncSubscription.args[0][0].should.deep.equal @activeRecurlySubscription
+				@SubscriptionUpdater.syncSubscription.args[0][1].should.deep.equal @user._id
+
+		describe "when there is already a subscription in Recurly", ->
+			beforeEach ->
+				@SubscriptionHandler.validateNoSubscriptionInRecurly = sinon.stub().yields(null, false)
+				@SubscriptionHandler.createSubscription(@user, @subscriptionDetails, @recurly_token_id, @callback)
+			
+			it "should return an error", ->
+				@callback.calledWith(new Error("user already has subscription in recurly"))
 
 
 	describe "updateSubscription", ->
 	describe "updateSubscription", ->
 		describe "with a user with a subscription", ->
 		describe "with a user with a subscription", ->
@@ -145,8 +155,6 @@ describe "Subscription Handler sanboxed", ->
 				updateOptions = @RecurlyWrapper.updateSubscription.args[0][1]
 				updateOptions = @RecurlyWrapper.updateSubscription.args[0][1]
 				updateOptions.plan_code.should.equal @plan_code
 				updateOptions.plan_code.should.equal @plan_code
 
 
-
-
 	describe "cancelSubscription", ->
 	describe "cancelSubscription", ->
 		describe "with a user without a subscription", ->
 		describe "with a user without a subscription", ->
 			beforeEach (done) ->
 			beforeEach (done) ->
@@ -210,5 +218,39 @@ describe "Subscription Handler sanboxed", ->
 				@SubscriptionUpdater.syncSubscription.args[0][0].should.deep.equal @activeRecurlySubscription
 				@SubscriptionUpdater.syncSubscription.args[0][0].should.deep.equal @activeRecurlySubscription
 				@SubscriptionUpdater.syncSubscription.args[0][1].should.deep.equal @user._id
 				@SubscriptionUpdater.syncSubscription.args[0][1].should.deep.equal @user._id
 
 
-
-
+	describe "validateNoSubscriptionInRecurly", ->
+		beforeEach ->
+			@subscriptions = []
+			@RecurlyWrapper.listAccountActiveSubscriptions = sinon.stub().yields(null, @subscriptions)
+			@SubscriptionUpdater.syncSubscription = sinon.stub().yields()
+			@callback = sinon.stub()
+
+		describe "with no subscription in recurly", ->
+			beforeEach ->
+				@subscriptions.push @subscription = { "mock": "subscription" }
+				@SubscriptionHandler.validateNoSubscriptionInRecurly @user_id, @callback
+
+			it "should call RecurlyWrapper.listAccountActiveSubscriptions with the user id", ->
+				@RecurlyWrapper.listAccountActiveSubscriptions
+					.calledWith(@user_id)
+					.should.equal true
+
+			it "should sync the subscription", ->
+				@SubscriptionUpdater.syncSubscription
+					.calledWith(@subscription, @user_id)
+					.should.equal true
+
+			it "should call the callback with valid == false", ->
+				@callback.calledWith(null, false).should.equal true
+
+		describe "with a subscription in recurly", ->
+			beforeEach ->
+				@SubscriptionHandler.validateNoSubscriptionInRecurly @user_id, @callback
+
+			it "should not sync the subscription", ->
+				@SubscriptionUpdater.syncSubscription
+					.called
+					.should.equal false
+
+			it "should call the callback with valid == true", ->
+				@callback.calledWith(null, true).should.equal true