diff --git a/services/real-time/app/coffee/Router.coffee b/services/real-time/app/coffee/Router.coffee index fc363d30ee..73c3aa0acd 100644 --- a/services/real-time/app/coffee/Router.coffee +++ b/services/real-time/app/coffee/Router.coffee @@ -1,5 +1,6 @@ Metrics = require "metrics-sharelatex" logger = require "logger-sharelatex" +WebsocketController = require "./WebsocketController" module.exports = Router = configure: (app, io, session) -> @@ -14,7 +15,11 @@ module.exports = Router = logger.log session: session, "got session" user = session.user - if !user? + if !user? or !user._id? logger.log "terminating session without authenticated user" client.disconnect() - return \ No newline at end of file + return + + client.on "joinProject", (data = {}, callback) -> + WebsocketController.joinProject(client, user, data.project_id, callback) + \ No newline at end of file diff --git a/services/real-time/app/coffee/WebApiManager.coffee b/services/real-time/app/coffee/WebApiManager.coffee new file mode 100644 index 0000000000..f18f25c492 --- /dev/null +++ b/services/real-time/app/coffee/WebApiManager.coffee @@ -0,0 +1,21 @@ +request = require "request" +settings = require "settings-sharelatex" +logger = require "logger-sharelatex" + +module.exports = WebApiManager = + joinProject: (project_id, user_id, callback = (error, project, privilegeLevel) ->) -> + logger.log {project_id, user_id}, "sending join project request to web" + url = "#{settings.apis.web.url}/project/#{project_id}/join" + request.post { + url: url + qs: {user_id} + json: true + jar: false + }, (error, response, data) -> + return callback(error) if error? + if 200 <= response.statusCode < 300 + callback null, data?.project, data?.privilegeLevel + else + err = new Error("non-success status code from web: #{response.statusCode}") + logger.error {err, project_id, user_id}, "error accessing web api" + callback err \ No newline at end of file diff --git a/services/real-time/app/coffee/WebsocketController.coffee b/services/real-time/app/coffee/WebsocketController.coffee new file mode 100644 index 0000000000..bcf346d314 --- /dev/null +++ b/services/real-time/app/coffee/WebsocketController.coffee @@ -0,0 +1,31 @@ +logger = require "logger-sharelatex" +WebApiManager = require "./WebApiManager" + +module.exports = WebsocketController = + # If the protocol version changes when the client reconnects, + # it will force a full refresh of the page. Useful for non-backwards + # compatible protocol changes. Use only in extreme need. + PROTOCOL_VERSION: 2 + + joinProject: (client, user, project_id, callback = (error, project, privilegeLevel, protocolVersion) ->) -> + user_id = user?._id + logger.log {user_id, project_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" + return callback(err) + + client.set("user_id", user_id) + client.set("project_id", project_id) + client.set("owner_id", project?.owner?._id) + client.set("first_name", user?.first_name) + client.set("last_name", user?.last_name) + client.set("email", user?.email) + client.set("connected_time", new Date()) + client.set("signup_date", user?.signUpDate) + client.set("login_count", user?.loginCount) + + callback null, project, privilegeLevel, WebsocketController.PROTOCOL_VERSION \ No newline at end of file diff --git a/services/real-time/config/settings.defaults.coffee b/services/real-time/config/settings.defaults.coffee index 7b069975b1..f1b46c4638 100644 --- a/services/real-time/config/settings.defaults.coffee +++ b/services/real-time/config/settings.defaults.coffee @@ -10,6 +10,10 @@ module.exports = port: 3026 host: "localhost" + apis: + web: + url: "http://localhost:3000" + security: sessionSecret: "secret-please-change" diff --git a/services/real-time/package.json b/services/real-time/package.json index 01b664e945..5bc6c6b90e 100644 --- a/services/real-time/package.json +++ b/services/real-time/package.json @@ -34,6 +34,7 @@ "request": "~2.34.0", "sandboxed-module": "~0.3.0", "sinon": "~1.5.2", - "uid-safe": "^1.0.1" + "uid-safe": "^1.0.1", + "timekeeper": "0.0.4" } } diff --git a/services/real-time/test/acceptance/coffee/JoinProjectTests.coffee b/services/real-time/test/acceptance/coffee/JoinProjectTests.coffee new file mode 100644 index 0000000000..ef97f4783b --- /dev/null +++ b/services/real-time/test/acceptance/coffee/JoinProjectTests.coffee @@ -0,0 +1,44 @@ +chai = require("chai") +expect = chai.expect +chai.should() + +RealTimeClient = require "./helpers/RealTimeClient" +MockWebClient = require "./helpers/MockWebClient" + +describe "joinProject", -> + 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) => + 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) => + throw error if error? + done() + + it "should get the project from web", -> + MockWebClient.joinProject + .calledWith(@project_id, @user_id) + .should.equal true + + it "should return the project", -> + @project.should.deep.equal { + name: "Test Project" + } + + it "should return the privilege level", -> + @privilegeLevel.should.equal "owner" + + it "should return the protocolVersion", -> + @protocolVersion.should.equal 2 diff --git a/services/real-time/test/acceptance/coffee/SessionTests.coffee b/services/real-time/test/acceptance/coffee/SessionTests.coffee index 01b31cee01..2eb0d8206a 100644 --- a/services/real-time/test/acceptance/coffee/SessionTests.coffee +++ b/services/real-time/test/acceptance/coffee/SessionTests.coffee @@ -6,6 +6,7 @@ RealTimeClient = require "./helpers/RealTimeClient" describe "Session", -> describe "with an established session", -> beforeEach (done) -> + @user_id = "mock-user-id" RealTimeClient.setSession { user: { _id: @user_id } }, (error) => @@ -33,7 +34,7 @@ describe "Session", -> @client.on "disconnect", () -> done() - describe "with a user set on the session", -> + describe "without a valid user set on the session", -> beforeEach (done) -> RealTimeClient.setSession { foo: "bar" diff --git a/services/real-time/test/acceptance/coffee/helpers/MockWebClient.coffee b/services/real-time/test/acceptance/coffee/helpers/MockWebClient.coffee new file mode 100644 index 0000000000..b7c4ead1fc --- /dev/null +++ b/services/real-time/test/acceptance/coffee/helpers/MockWebClient.coffee @@ -0,0 +1,39 @@ +sinon = require "sinon" +express = require "express" + +module.exports = MockWebClient = + projects: {} + privileges: {} + + createMockProject: (project_id, privileges, project) -> + MockWebClient.privileges[project_id] = privileges + MockWebClient.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] + ) + + joinProjectRequest: (req, res, next) -> + {project_id} = req.params + {user_id} = req.query + MockWebClient.joinProject project_id, user_id, (error, project, privilegeLevel) -> + return next(error) if error? + res.json { + project: project + privilegeLevel: privilegeLevel + } + + running: false + run: (callback = (error) ->) -> + if MockWebClient.running + return callback() + app = express() + app.post "/project/:project_id/join", MockWebClient.joinProjectRequest + app.listen 3000, (error) -> + MockWebClient.running = true + callback(error) + +sinon.spy MockWebClient, "joinProject" \ No newline at end of file diff --git a/services/real-time/test/unit/coffee/WebApiManagerTests.coffee b/services/real-time/test/unit/coffee/WebApiManagerTests.coffee new file mode 100644 index 0000000000..4cc949dc9c --- /dev/null +++ b/services/real-time/test/unit/coffee/WebApiManagerTests.coffee @@ -0,0 +1,55 @@ +chai = require('chai') +should = chai.should() +sinon = require("sinon") +modulePath = "../../../app/js/WebApiManager.js" +SandboxedModule = require('sandboxed-module') + +describe 'WebApiManager', -> + beforeEach -> + @project_id = "project-id-123" + @user_id = "user-id-123" + @callback = sinon.stub() + @WebApiManager = SandboxedModule.require modulePath, requires: + "request": @request = {} + "settings-sharelatex": @settings = + apis: + web: + url: "http://web.example.com" + "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() } + + describe "joinProject", -> + describe "successfully", -> + beforeEach -> + @response = { + project: { name: "Test project" } + privilegeLevel: "owner" + } + @request.post = sinon.stub().callsArgWith(1, null, {statusCode: 200}, @response) + @WebApiManager.joinProject @project_id, @user_id, @callback + + it "should send a request to web to join the project", -> + @request.post + .calledWith({ + url: "#{@settings.apis.web.url}/project/#{@project_id}/join" + qs: + user_id: @user_id + json: true + jar: false + }) + .should.equal true + + it "should return the project and privilegeLevel", -> + @callback + .calledWith(null, @response.project, @response.privilegeLevel) + .should.equal true + + describe "with an error from web", -> + beforeEach -> + @request.post = sinon.stub().callsArgWith(1, null, {statusCode: 500}, null) + @WebApiManager.joinProject @project_id, @user_id, @callback + + it "should call the callback with an error", -> + @callback + .calledWith(new Error("non-success code from web: 500")) + .should.equal true + \ No newline at end of file diff --git a/services/real-time/test/unit/coffee/WebsocketControllerTests.coffee b/services/real-time/test/unit/coffee/WebsocketControllerTests.coffee new file mode 100644 index 0000000000..be9ede3dad --- /dev/null +++ b/services/real-time/test/unit/coffee/WebsocketControllerTests.coffee @@ -0,0 +1,89 @@ +chai = require('chai') +should = chai.should() +sinon = require("sinon") +modulePath = "../../../app/js/WebsocketController.js" +SandboxedModule = require('sandboxed-module') +tk = require "timekeeper" + +describe 'WebsocketController', -> + beforeEach -> + tk.freeze(new Date()) + @project_id = "project-id-123" + @user = { + _id: "user-id-123" + first_name: "James" + last_name: "Allen" + email: "james@example.com" + signUpDate: new Date("2014-01-01") + loginCount: 42 + } + @callback = sinon.stub() + @client = + set: sinon.stub() + join: sinon.stub() + @WebsocketController = SandboxedModule.require modulePath, requires: + "./WebApiManager": @WebApiManager = {} + "logger-sharelatex": @logger = { log: sinon.stub(), error: sinon.stub() } + + afterEach -> + tk.reset() + + describe "joinProject", -> + describe "when authorised", -> + beforeEach -> + @project = { + name: "Test Project" + owner: { + _id: @owner_id = "mock-owner-id-123" + } + } + @privilegeLevel = "owner" + @WebApiManager.joinProject = sinon.stub().callsArgWith(2, null, @project, @privilegeLevel) + @WebsocketController.joinProject @client, @user, @project_id, @callback + + it "should load the project from web", -> + @WebApiManager.joinProject + .calledWith(@project_id, @user._id) + .should.equal true + + it "should set the user's id on the client", -> + @client.set.calledWith("user_id", @user._id).should.equal true + + it "should set the user's email on the client", -> + @client.set.calledWith("email", @user.email).should.equal true + + it "should set the user's first_name on the client", -> + @client.set.calledWith("first_name", @user.first_name).should.equal true + + it "should set the user's last_name on the client", -> + @client.set.calledWith("last_name", @user.last_name).should.equal true + + it "should set the user's sign up date on the client", -> + @client.set.calledWith("signup_date", @user.signUpDate).should.equal true + + it "should set the user's login_count on the client", -> + @client.set.calledWith("login_count", @user.loginCount).should.equal true + + it "should set the connected time on the client", -> + @client.set.calledWith("connected_time", new Date()).should.equal true + + it "should set the project_id on the client", -> + @client.set.calledWith("project_id", @project_id).should.equal true + + it "should set the project owner id on the client", -> + @client.set.calledWith("owner_id", @owner_id).should.equal true + + it "should call the callback with the project, privilegeLevel and protocolVersion", -> + @callback + .calledWith(null, @project, @privilegeLevel, @WebsocketController.PROTOCOL_VERSION) + .should.equal true + + describe "when not authorized", -> + beforeEach -> + @WebApiManager.joinProject = sinon.stub().callsArgWith(2, null, null, null) + @WebsocketController.joinProject @client, @user, @project_id, @callback + + it "should return an error", -> + @callback + .calledWith(new Error("not authorized")) + .should.equal true \ No newline at end of file