Explorar o código

Create joinDoc socket.io end point

James Allen %!s(int64=12) %!d(string=hai) anos
pai
achega
eb8ccc0298

+ 12 - 0
services/real-time/app/coffee/AuthorizationManager.coffee

@@ -0,0 +1,12 @@
+module.exports = AuthorizationManager =
+	assertClientCanViewProject: (client, callback = (error) ->) ->
+		AuthorizationManager._assertClientHasPrivilegeLevel client, ["readOnly", "readAndWrite", "owner"], callback
+		
+	_assertClientHasPrivilegeLevel: (client, allowedLevels, callback = (error) ->) ->
+		client.get "privilege_level", (error, privilegeLevel) ->
+			return callback(error) if error?
+			allowed = (privilegeLevel in allowedLevels)
+			if allowed
+				callback null
+			else
+				callback new Error("not authorized")

+ 26 - 0
services/real-time/app/coffee/DocumentUpdaterManager.coffee

@@ -0,0 +1,26 @@
+request = require "request"
+logger = require "logger-sharelatex"
+settings = require "settings-sharelatex"
+
+module.exports = DocumentUpdaterManager =
+	getDocument: (project_id, doc_id, fromVersion, callback = (error, exists, doclines, version) ->) ->
+		#timer = new metrics.Timer("get-document")
+		url = "#{settings.apis.documentupdater.url}/project/#{project_id}/doc/#{doc_id}?fromVersion=#{fromVersion}"
+		logger.log {project_id, doc_id, fromVersion}, "getting doc from document updater"
+		request.get url, (err, res, body) ->
+			#timer.done()
+			if err?
+				logger.error {err, url, project_id, doc_id}, "error getting doc from doc updater"
+				return callback(err)
+			if 200 <= res.statusCode < 300
+				logger.log {project_id, doc_id}, "got doc from document document updater"
+				try
+					body = JSON.parse(body)
+				catch error
+					return callback(error)
+				callback null, body?.lines, body?.version, body?.ops
+			else
+				err = new Error("doc updater returned a non-success status code: #{res.statusCode}")
+				err.statusCode = res.statusCode
+				logger.error {err, project_id, doc_id, url}, "doc updater returned a non-success status code: #{res.statusCode}"
+				callback err

+ 42 - 2
services/real-time/app/coffee/Router.coffee

@@ -3,6 +3,23 @@ logger = require "logger-sharelatex"
 WebsocketController = require "./WebsocketController"
 
 module.exports = Router =
+	# We don't want to send raw errors back to the client, in case they
+	# contain sensitive data. Instead we log them out, and send a generic
+	# JSON object which can be serialized over socket.io
+	_createCallbackWithErrorFilter: (client, method, callback) ->
+		return (err, args...) ->
+			if err?
+				
+				err = {message: "Something went wrong"}
+			callback err, args...
+			
+	# Used in error reporting
+	_getClientData: (client, callback = (error, data) ->) ->
+		client.get "user_id", (error, user_id) ->
+			client.get "project_id", (error, project_id) ->
+				client.get "doc_id", (error, doc_id) ->
+					callback null, { id: client.id, user_id, project_id, doc_id }
+
 	configure: (app, io, session) ->
 		session.on 'connection', (error, client, session) ->
 			if error?
@@ -12,7 +29,7 @@ module.exports = Router =
 			
 			Metrics.inc('socket-io.connection')
 			
-			logger.log session: session, "got session"
+			logger.log session: session, client_id: client.id, "client connected"
 			
 			user = session.user
 			if !user? or !user._id?
@@ -21,5 +38,28 @@ module.exports = Router =
 				return
 				
 			client.on "joinProject", (data = {}, callback) ->
-				WebsocketController.joinProject(client, user, data?.project_id, callback)
+				WebsocketController.joinProject client, user, data.project_id, (err, args...) ->
+					if err?
+						Router._getClientData client, (_, client) ->
+							logger.error {err, client, project_id: data.project_id}, "server side error in joinProject"
+						# Don't return raw error to prevent leaking server side info
+						return callback {message: "Something went wrong"}
+					else
+						callback(null, args...)
+						
+				
+			client.on "joinDoc", (doc_id, fromVersion, callback) ->
+				# fromVersion is optional
+				if typeof fromVersion == "function"
+					callback = fromVersion
+					fromVersion = -1
+				
+				WebsocketController.joinDoc client, doc_id, fromVersion, (err, args...) ->
+					if err?
+						Router._getClientData client, (_, client) ->
+							logger.error {err, client, doc_id, fromVersion}, "server side error in joinDoc"
+						# Don't return raw error to prevent leaking server side info
+						return callback {message: "Something went wrong"}
+					else
+						callback(null, args...)
 				

+ 33 - 6
services/real-time/app/coffee/WebsocketController.coffee

@@ -1,5 +1,7 @@
 logger = require "logger-sharelatex"
 WebApiManager = require "./WebApiManager"
+AuthorizationManager = require "./AuthorizationManager"
+DocumentUpdaterManager = require "./DocumentUpdaterManager"
 
 module.exports = WebsocketController =
 	# If the protocol version changes when the client reconnects,
@@ -9,17 +11,16 @@ module.exports = WebsocketController =
 	
 	joinProject: (client, user, project_id, callback = (error, project, privilegeLevel, protocolVersion) ->) ->
 		user_id = user?._id
-		logger.log {user_id, project_id}, "user joining project"
+		logger.log {user_id, project_id, client_id: client.id}, "user joining project"
 		WebApiManager.joinProject project_id, user_id, (error, project, privilegeLevel) ->
 			return callback(error) if error?
 
 			if !privilegeLevel or privilegeLevel == ""
 				err = new Error("not authorized")
-				logger.error {err, project_id, user_id}, "user is not authorized to join project"
-				# Don't send an error object since socket.io can apparently
-				# only serialize JSON.
-				return callback({message: err.message})
+				logger.error {err, project_id, user_id, client_id: client.id}, "user is not authorized to join project"
+				return callback(err)
 
+			client.set("privilege_level", privilegeLevel)
 			client.set("user_id", user_id)
 			client.set("project_id", project_id)
 			client.set("owner_id", project?.owner?._id)
@@ -30,4 +31,30 @@ module.exports = WebsocketController =
 			client.set("signup_date", user?.signUpDate)
 			client.set("login_count", user?.loginCount)
 			
-			callback null, project, privilegeLevel, WebsocketController.PROTOCOL_VERSION
+			callback null, project, privilegeLevel, WebsocketController.PROTOCOL_VERSION
+			
+	joinDoc: (client, doc_id, fromVersion = -1, callback = (error, doclines, version, ops) ->) ->
+		client.get "user_id", (error, user_id) ->
+			client.get "project_id", (error, project_id) ->
+				logger.log {user_id, project_id, doc_id, fromVersion, client_id: client.id}, "client joining doc"
+					
+		AuthorizationManager.assertClientCanViewProject client, (error) ->
+			return callback(error) if error?
+			client.get "project_id", (error, project_id) ->
+				return callback(error) if error?
+				return callback(new Error("no project_id found on client")) if !project_id?
+				DocumentUpdaterManager.getDocument project_id, doc_id, fromVersion, (error, lines, version, ops) ->
+					return callback(error) if error?
+					# Encode any binary bits of data so it can go via WebSockets
+					# See http://ecmanaut.blogspot.co.uk/2006/07/encoding-decoding-utf8-in-javascript.html
+					escapedLines = []
+					for line in lines
+						try
+							line = unescape(encodeURIComponent(line))
+						catch err
+							logger.err {err, project_id, doc_id, fromVersion, line, client_id: client.id}, "error encoding line uri component"
+							return callback(err)
+						escapedLines.push line
+					client.join(doc_id)
+					callback null, escapedLines, version, ops
+			

+ 2 - 0
services/real-time/config/settings.defaults.coffee

@@ -13,6 +13,8 @@ module.exports =
 	apis:
 		web:
 			url: "http://localhost:3000"
+		documentupdater:
+			url: "http://localhost:3003"
 			
 	security:
 		sessionSecret: "secret-please-change"

+ 118 - 0
services/real-time/test/acceptance/coffee/JoinDocTests.coffee

@@ -0,0 +1,118 @@
+chai = require("chai")
+expect = chai.expect
+chai.should()
+
+RealTimeClient = require "./helpers/RealTimeClient"
+MockDocUpdaterServer = require "./helpers/MockDocUpdaterServer"
+FixturesManager = require "./helpers/FixturesManager"
+
+describe "joinDoc", ->
+	before ->
+		@lines = ["test", "doc", "lines"]
+		@version = 42
+		@ops = ["mock", "doc", "ops"]
+			
+	describe "when authorised readAndWrite", ->
+		before (done) ->
+			FixturesManager.setUpProject {
+				privilegeLevel: "readAndWrite"
+			}, (error, data) =>
+				throw error if error?
+				{@project_id, @user_id} = data
+				FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (error, data) =>
+					throw error if error?
+					{@doc_id} = data
+					@client = RealTimeClient.connect()
+					@client.emit "joinProject", project_id: @project_id, (error) =>
+						throw error if error?
+						@client.emit "joinDoc", @doc_id, (error, @returnedArgs...) =>
+							throw error if error?
+							done()
+
+		it "should get the doc from the doc updater", ->
+			MockDocUpdaterServer.getDocument
+				.calledWith(@project_id, @doc_id, -1)
+				.should.equal true
+		
+		it "should return the doc lines, version and ops", ->
+			@returnedArgs.should.deep.equal [@lines, @version, @ops]
+		
+	describe "when authorised readOnly", ->
+		before (done) ->
+			FixturesManager.setUpProject {
+				privilegeLevel: "readOnly"
+			}, (error, data) =>
+				throw error if error?
+				{@project_id, @user_id} = data
+				FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (error, data) =>
+					throw error if error?
+					{@doc_id} = data
+					@client = RealTimeClient.connect()
+					@client.emit "joinProject", project_id: @project_id, (error) =>
+						throw error if error?
+						@client.emit "joinDoc", @doc_id, (error, @returnedArgs...) =>
+							throw error if error?
+							done()
+
+		it "should get the doc from the doc updater", ->
+			MockDocUpdaterServer.getDocument
+				.calledWith(@project_id, @doc_id, -1)
+				.should.equal true
+		
+		it "should return the doc lines, version and ops", ->
+			@returnedArgs.should.deep.equal [@lines, @version, @ops]
+		
+	describe "when authorised as owner", ->
+		before (done) ->
+			FixturesManager.setUpProject {
+				privilegeLevel: "owner"
+			}, (error, data) =>
+				throw error if error?
+				{@project_id, @user_id} = data
+				FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (error, data) =>
+					throw error if error?
+					{@doc_id} = data
+					@client = RealTimeClient.connect()
+					@client.emit "joinProject", project_id: @project_id, (error) =>
+						throw error if error?
+						@client.emit "joinDoc", @doc_id, (error, @returnedArgs...) =>
+							throw error if error?
+							done()
+
+		it "should get the doc from the doc updater", ->
+			MockDocUpdaterServer.getDocument
+				.calledWith(@project_id, @doc_id, -1)
+				.should.equal true
+		
+		it "should return the doc lines, version and ops", ->
+			@returnedArgs.should.deep.equal [@lines, @version, @ops]
+
+	# It is impossible to write an acceptance test to test joining an unauthorized
+	# project, since joinProject already catches that. If you can join a project,
+	# then you can join a doc in that project.
+			
+	describe "with a fromVersion", ->
+		before (done) ->
+			@fromVersion = 36
+			FixturesManager.setUpProject {
+				privilegeLevel: "readAndWrite"
+			}, (error, data) =>
+				throw error if error?
+				{@project_id, @user_id} = data
+				FixturesManager.setUpDoc @project_id, {@lines, @version, @ops}, (error, data) =>
+					throw error if error?
+					{@doc_id} = data
+					@client = RealTimeClient.connect()
+					@client.emit "joinProject", project_id: @project_id, (error) =>
+						throw error if error?
+						@client.emit "joinDoc", @doc_id, @fromVersion, (error, @returnedArgs...) =>
+							throw error if error?
+							done()
+
+		it "should get the doc from the doc updater with the fromVersion", ->
+			MockDocUpdaterServer.getDocument
+				.calledWith(@project_id, @doc_id, @fromVersion)
+				.should.equal true
+		
+		it "should return the doc lines, version and ops", ->
+			@returnedArgs.should.deep.equal [@lines, @version, @ops]

+ 30 - 36
services/real-time/test/acceptance/coffee/JoinProjectTests.coffee

@@ -3,33 +3,30 @@ expect = chai.expect
 chai.should()
 
 RealTimeClient = require "./helpers/RealTimeClient"
-MockWebClient = require "./helpers/MockWebClient"
+MockWebServer = require "./helpers/MockWebServer"
+FixturesManager = require "./helpers/FixturesManager"
+
 
 describe "joinProject", ->
 	describe "when authorized", ->
 		before (done) ->
-			@user_id    = "mock-user-id"
-			@project_id = "mock-project-id"
-			privileges = {}
-			privileges[@user_id] = "owner"
-			MockWebClient.createMockProject(@project_id, privileges, {
-				name: "Test Project"
-			})
-			MockWebClient.run (error) =>
+			FixturesManager.setUpProject {
+				privilegeLevel: "owner"
+				project: {
+					name: "Test Project"
+				}
+			}, (error, data) =>
 				throw error if error?
-				RealTimeClient.setSession {
-					user: { _id: @user_id }
-				}, (error) =>
+				{@user_id, @project_id} = data
+				@client = RealTimeClient.connect()
+				@client.emit "joinProject", {
+					project_id: @project_id
+				}, (error, @project, @privilegeLevel, @protocolVersion) =>
 					throw error if error?
-					@client = RealTimeClient.connect()
-					@client.emit "joinProject", {
-						project_id: @project_id
-					}, (error, @project, @privilegeLevel, @protocolVersion) =>
-						throw error if error?
-						done()
+					done()
 					
 		it "should get the project from web", ->
-			MockWebClient.joinProject
+			MockWebServer.joinProject
 				.calledWith(@project_id, @user_id)
 				.should.equal true
 					
@@ -46,23 +43,20 @@ describe "joinProject", ->
 			
 	describe "when not authorized", ->
 		before (done) ->
-			@user_id    = "mock-user-id-2"
-			@project_id = "mock-project-id-2"
-			privileges = {}
-			MockWebClient.createMockProject(@project_id, privileges, {
-				name: "Test Project"
-			})
-			MockWebClient.run (error) =>
+			FixturesManager.setUpProject {
+				privilegeLevel: null
+				project: {
+					name: "Test Project"
+				}
+			}, (error, data) =>
 				throw error if error?
-				RealTimeClient.setSession {
-					user: { _id: @user_id }
-				}, (error) =>
-					throw error if error?
-					@client = RealTimeClient.connect()
-					@client.emit "joinProject", {
-						project_id: @project_id
-					}, (@error, @project, @privilegeLevel, @protocolVersion) =>
-						done()
+				{@user_id, @project_id} = data
+				@client = RealTimeClient.connect()
+				@client.emit "joinProject", {
+					project_id: @project_id
+				}, (@error, @project, @privilegeLevel, @protocolVersion) =>
+					done()
 		
 		it "should return an error", ->
-			@error.message.should.equal "not authorized"
+			# We don't return specific errors
+			@error.message.should.equal "Something went wrong"

+ 42 - 0
services/real-time/test/acceptance/coffee/helpers/FixturesManager.coffee

@@ -0,0 +1,42 @@
+RealTimeClient = require "./RealTimeClient"
+MockWebServer = require "./MockWebServer"
+MockDocUpdaterServer = require "./MockDocUpdaterServer"
+
+module.exports = FixturesManager =
+	setUpProject: (options = {}, callback = (error, data) ->) ->
+		options.user_id        ||= FixturesManager.getRandomId()
+		options.project_id     ||= FixturesManager.getRandomId()
+		options.project        ||= { name: "Test Project" }
+		{project_id, user_id, privilegeLevel, project} = options
+		
+		privileges = {}
+		privileges[user_id] = privilegeLevel
+		
+		MockWebServer.createMockProject(project_id, privileges, project)
+		MockWebServer.run (error) =>
+			throw error if error?
+			RealTimeClient.setSession {
+				user: { _id: user_id }
+			}, (error) =>
+				throw error if error?
+				callback null, {project_id, user_id, privilegeLevel, project}
+				
+	setUpDoc: (project_id, options = {}, callback = (error, data) ->) ->
+		options.doc_id  ||= FixturesManager.getRandomId()
+		options.lines   ||= ["doc", "lines"]
+		options.version ||= 42
+		options.ops     ||= ["mock", "ops"]
+		{doc_id, lines, version, ops} = options
+				
+		MockDocUpdaterServer.createMockDoc project_id, doc_id, {lines, version, ops}
+		MockDocUpdaterServer.run (error) =>
+			throw error if error?
+			callback null, {project_id, doc_id, lines, version, ops}
+		
+	getRandomId: () ->
+		return require("crypto")
+			.createHash("sha1")
+			.update(Math.random().toString())
+			.digest("hex")
+			.slice(0,24)
+		

+ 33 - 0
services/real-time/test/acceptance/coffee/helpers/MockDocUpdaterServer.coffee

@@ -0,0 +1,33 @@
+sinon = require "sinon"
+express = require "express"
+
+module.exports = MockDocUpdaterServer =
+	docs: {}
+	
+	createMockDoc: (project_id, doc_id, data) ->
+		MockDocUpdaterServer.docs["#{project_id}:#{doc_id}"] = data
+		
+	getDocument: (project_id, doc_id, fromVersion, callback = (error, data) ->) ->
+		callback(
+			null, MockDocUpdaterServer.docs["#{project_id}:#{doc_id}"]
+		)
+		
+	getDocumentRequest: (req, res, next) ->
+		{project_id, doc_id} = req.params
+		{fromVersion} = req.query
+		fromVersion = parseInt(fromVersion, 10)
+		MockDocUpdaterServer.getDocument project_id, doc_id, fromVersion, (error, data) ->
+			return next(error) if error?
+			res.json data
+	
+	running: false
+	run: (callback = (error) ->) ->
+		if MockDocUpdaterServer.running
+			return callback()
+		app = express()
+		app.get "/project/:project_id/doc/:doc_id", MockDocUpdaterServer.getDocumentRequest
+		app.listen 3003, (error) ->
+			MockDocUpdaterServer.running = true
+			callback(error)
+			
+sinon.spy MockDocUpdaterServer, "getDocument"

+ 10 - 10
services/real-time/test/acceptance/coffee/helpers/MockWebClient.coffee → services/real-time/test/acceptance/coffee/helpers/MockWebServer.coffee

@@ -1,25 +1,25 @@
 sinon = require "sinon"
 express = require "express"
 
-module.exports = MockWebClient =
+module.exports = MockWebServer =
 	projects: {}
 	privileges: {}
 	
 	createMockProject: (project_id, privileges, project) ->
-		MockWebClient.privileges[project_id] = privileges
-		MockWebClient.projects[project_id] = project
+		MockWebServer.privileges[project_id] = privileges
+		MockWebServer.projects[project_id] = project
 		
 	joinProject: (project_id, user_id, callback = (error, project, privilegeLevel) ->) ->
 		callback(
 			null,
-			MockWebClient.projects[project_id], 
-			MockWebClient.privileges[project_id][user_id]
+			MockWebServer.projects[project_id], 
+			MockWebServer.privileges[project_id][user_id]
 		)
 		
 	joinProjectRequest: (req, res, next) ->
 		{project_id} = req.params
 		{user_id} = req.query
-		MockWebClient.joinProject project_id, user_id, (error, project, privilegeLevel) ->
+		MockWebServer.joinProject project_id, user_id, (error, project, privilegeLevel) ->
 			return next(error) if error?
 			res.json {
 				project: project
@@ -28,12 +28,12 @@ module.exports = MockWebClient =
 	
 	running: false
 	run: (callback = (error) ->) ->
-		if MockWebClient.running
+		if MockWebServer.running
 			return callback()
 		app = express()
-		app.post "/project/:project_id/join", MockWebClient.joinProjectRequest
+		app.post "/project/:project_id/join", MockWebServer.joinProjectRequest
 		app.listen 3000, (error) ->
-			MockWebClient.running = true
+			MockWebServer.running = true
 			callback(error)
 			
-sinon.spy MockWebClient, "joinProject"
+sinon.spy MockWebServer, "joinProject"

+ 39 - 0
services/real-time/test/unit/coffee/AuthorizationManagerTests.coffee

@@ -0,0 +1,39 @@
+chai = require "chai"
+chai.should()
+expect = chai.expect
+sinon = require("sinon")
+SandboxedModule = require('sandboxed-module')
+path = require "path"
+modulePath = '../../../app/js/AuthorizationManager'
+
+describe 'AuthorizationManager', ->
+	beforeEach ->
+		@client =
+			params: {}
+			get: (param, cb) -> cb null, @params[param]
+		@AuthorizationManager = SandboxedModule.require modulePath, requires: {}
+
+	describe "assertClientCanViewProject", ->
+		it "should allow the readOnly privilegeLevel", (done) ->
+			@client.params.privilege_level = "readOnly"
+			@AuthorizationManager.assertClientCanViewProject @client, (error) ->
+				expect(error).to.be.null
+				done()
+	
+		it "should allow the readAndWrite privilegeLevel", (done) ->
+			@client.params.privilege_level = "readAndWrite"
+			@AuthorizationManager.assertClientCanViewProject @client, (error) ->
+				expect(error).to.be.null
+				done()
+				
+		it "should allow the owner privilegeLevel", (done) ->
+			@client.params.privilege_level = "owner"
+			@AuthorizationManager.assertClientCanViewProject @client, (error) ->
+				expect(error).to.be.null
+				done()
+				
+		it "should return an error with any other privilegeLevel", (done) ->
+			@client.params.privilege_level = "unknown"
+			@AuthorizationManager.assertClientCanViewProject @client, (error) ->
+				error.message.should.equal "not authorized"
+				done()

+ 60 - 0
services/real-time/test/unit/coffee/DocumentUpdaterManagerTests.coffee

@@ -0,0 +1,60 @@
+require('chai').should()
+sinon = require("sinon")
+SandboxedModule = require('sandboxed-module')
+path = require "path"
+modulePath = '../../../app/js/DocumentUpdaterManager'
+
+describe 'DocumentUpdaterManager', ->
+	beforeEach ->
+		@project_id = "project-id-923"
+		@doc_id = "doc-id-394"
+		@lines = ["one", "two", "three"]
+		@version = 42
+		@settings = 
+			apis: documentupdater: url: "http://doc-updater.example.com"
+			
+		@DocumentUpdaterManager = SandboxedModule.require modulePath, requires:
+			'settings-sharelatex':@settings
+			'logger-sharelatex': @logger = {log: sinon.stub(), error: sinon.stub()}
+			'request': @request = {}
+
+	describe "getDocument", ->
+		beforeEach ->
+			@callback = sinon.stub()
+
+		describe "successfully", ->
+			beforeEach ->
+				@body = JSON.stringify
+					lines: @lines
+					version: @version
+					ops: @ops = ["mock-op-1", "mock-op-2"]
+				@fromVersion = 2
+				@request.get = sinon.stub().callsArgWith(1, null, {statusCode: 200}, @body)
+				@DocumentUpdaterManager.getDocument @project_id, @doc_id, @fromVersion, @callback
+
+			it 'should get the document from the document updater', ->
+				url = "#{@settings.apis.documentupdater.url}/project/#{@project_id}/doc/#{@doc_id}?fromVersion=#{@fromVersion}"
+				@request.get.calledWith(url).should.equal true
+
+			it "should call the callback with the lines and version", ->
+				@callback.calledWith(null, @lines, @version, @ops).should.equal true
+
+		describe "when the document updater API returns an error", ->
+			beforeEach ->
+				@request.get = sinon.stub().callsArgWith(1, @error = new Error("something went wrong"), null, null)
+				@DocumentUpdaterManager.getDocument @project_id, @doc_id, @fromVersion, @callback
+
+			it "should return an error to the callback", ->
+				@callback.calledWith(@error).should.equal true
+
+		describe "when the document updater returns a failure error code", ->
+			beforeEach ->
+				@request.get = sinon.stub().callsArgWith(1, null, { statusCode: 500 }, "")
+				@DocumentUpdaterManager.getDocument @project_id, @doc_id, @fromVersion, @callback
+
+			it "should return the callback with an error", ->
+				err = new Error("doc updater returned failure status code: 500")
+				err.statusCode = 500
+				@callback
+					.calledWith(err)
+					.should.equal true

+ 69 - 2
services/real-time/test/unit/coffee/WebsocketControllerTests.coffee

@@ -19,10 +19,14 @@ describe 'WebsocketController', ->
 		}
 		@callback = sinon.stub()
 		@client =
+			params: {}
 			set: sinon.stub()
+			get: (param, cb) -> cb null, @params[param]
 			join: sinon.stub()
 		@WebsocketController = SandboxedModule.require modulePath, requires:
 			"./WebApiManager": @WebApiManager = {}
+			"./AuthorizationManager": @AuthorizationManager = {}
+			"./DocumentUpdaterManager": @DocumentUpdaterManager = {}
 			"logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() }
 	
 	afterEach ->
@@ -46,6 +50,9 @@ describe 'WebsocketController', ->
 					.calledWith(@project_id, @user._id)
 					.should.equal true
 					
+			it "should set the privilege level on the client", ->
+				@client.set.calledWith("privilege_level", @privilegeLevel).should.equal true
+					
 			it "should set the user's id on the client", ->
 				@client.set.calledWith("user_id", @user._id).should.equal true
 					
@@ -85,5 +92,65 @@ describe 'WebsocketController', ->
 
 			it "should return an error", ->
 				@callback
-					.calledWith({message: "not authorized"})
-					.should.equal true
+					.calledWith(new Error("not authorized"))
+					.should.equal true
+					
+	describe "joinDoc", ->
+		beforeEach ->
+			@doc_id = "doc-id-123"
+			@doc_lines = ["doc", "lines"]
+			@version = 42
+			@ops = ["mock", "ops"]
+			
+			@client.params.project_id = @project_id
+			
+			@AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, null)
+			@DocumentUpdaterManager.getDocument = sinon.stub().callsArgWith(3, null, @doc_lines, @version, @ops)
+			
+		describe "with a fromVersion", ->
+			beforeEach ->
+				@fromVersion = 40
+				@WebsocketController.joinDoc @client, @doc_id, @fromVersion, @callback
+				
+			it "should check that the client is authorized to view the project", ->
+				@AuthorizationManager.assertClientCanViewProject
+					.calledWith(@client)
+					.should.equal true
+					
+			it "should get the document from the DocumentUpdaterManager", ->
+				@DocumentUpdaterManager.getDocument
+					.calledWith(@project_id, @doc_id, @fromVersion)
+					.should.equal true
+					
+			it "should join the client to room for the doc_id", ->
+				@client.join
+					.calledWith(@doc_id)
+					.should.equal true
+					
+			it "should call the callback with the lines, version and ops", ->
+				@callback
+					.calledWith(null, @doc_lines, @version, @ops)
+					.should.equal true
+					
+		describe "with doclines that need escaping", ->
+			beforeEach ->
+				@doc_lines.push ["räksmörgås"]
+				@WebsocketController.joinDoc @client, @doc_id, -1, @callback
+						
+			it "should call the callback with the escaped lines", ->
+				escaped_lines = @callback.args[0][1]
+				escaped_word = escaped_lines.pop()
+				escaped_word.should.equal 'räksmörgås'
+				# Check that unescaping works
+				decodeURIComponent(escape(escaped_word)).should.equal "räksmörgås"
+				
+		describe "when not authorized", ->
+			beforeEach ->
+				@AuthorizationManager.assertClientCanViewProject = sinon.stub().callsArgWith(1, @err = new Error("not authorized"))
+				@WebsocketController.joinDoc @client, @doc_id, -1, @callback
+				
+			it "should call the callback with an error", ->
+				@callback.calledWith(@err).should.equal true
+			
+			it "should not call the DocumentUpdaterManager", ->
+				@DocumentUpdaterManager.getDocument.called.should.equal false