Просмотр исходного кода

Merge pull request #4870 from overleaf/jk-bg-validate-ids

RealTime: Validate IDs
GitOrigin-RevId: 884600125d362c5632faa75dc22d957cdddc101b
June Kelly 5 лет назад
Родитель
Сommit
7b1044b8a8

+ 94 - 9
services/real-time/app/js/Router.js

@@ -10,6 +10,7 @@ const HttpApiController = require('./HttpApiController')
 const bodyParser = require('body-parser')
 const base64id = require('base64id')
 const { UnexpectedArgumentsError } = require('./Errors')
+const Joi = require('@hapi/joi')
 
 const basicAuth = require('basic-auth-connect')
 const httpAuth = basicAuth(function (user, pass) {
@@ -24,6 +25,11 @@ const httpAuth = basicAuth(function (user, pass) {
 
 const HOSTNAME = require('os').hostname()
 
+const JOI_OBJECT_ID = Joi.string()
+  .required()
+  .regex(/^[0-9a-f]{24}$/)
+  .message('invalid id')
+
 let Router
 module.exports = Router = {
   _handleError(callback, error, client, method, attrs) {
@@ -34,7 +40,21 @@ module.exports = Router = {
     attrs.client_id = client.id
     attrs.err = error
     attrs.method = method
-    if (error.name === 'CodedError') {
+    if (Joi.isError(error)) {
+      logger.warn(attrs, 'validation error')
+      var message = 'invalid'
+      try {
+        message = error.details[0].message
+      } catch (e) {
+        // ignore unexpected errors
+        logger.warn({ error, e }, 'unexpected validation error')
+      }
+      const serializedError = { message }
+      metrics.inc('validation-error', 1, {
+        status: method,
+      })
+      callback(serializedError)
+    } else if (error.name === 'CodedError') {
       logger.warn(attrs, error.message)
       const serializedError = { message: error.message, code: error.info.code }
       callback(serializedError)
@@ -53,6 +73,8 @@ module.exports = Router = {
         'not authorized',
         'joinLeaveEpoch mismatch',
         'doc updater could not load requested ops',
+        'no project_id found on client',
+        'cannot join multiple projects',
       ].includes(error.message)
     ) {
       logger.warn(attrs, error.message)
@@ -66,6 +88,11 @@ module.exports = Router = {
       }
       callback(serializedError)
     }
+    if (attrs.disconnect) {
+      setTimeout(function () {
+        client.disconnect()
+      }, 100)
+    }
   },
 
   _handleInvalidArguments(client, method, args) {
@@ -189,18 +216,45 @@ module.exports = Router = {
             arguments
           )
         }
-
-        if (data.anonymousAccessToken) {
-          user.anonymousAccessToken = data.anonymousAccessToken
+        try {
+          Joi.assert(
+            data,
+            Joi.object({
+              project_id: JOI_OBJECT_ID,
+              anonymousAccessToken: Joi.string(),
+            })
+          )
+        } catch (error) {
+          return Router._handleError(callback, error, client, 'joinProject', {
+            disconnect: 1,
+          })
+        }
+        const { project_id, anonymousAccessToken } = data
+        // only allow connection to a single project
+        if (
+          client.ol_current_project_id &&
+          project_id !== client.ol_current_project_id
+        ) {
+          return Router._handleError(
+            callback,
+            new Error('cannot join multiple projects'),
+            client,
+            'joinProject',
+            { disconnect: 1 }
+          )
+        }
+        client.ol_current_project_id = project_id
+        if (anonymousAccessToken) {
+          user.anonymousAccessToken = anonymousAccessToken
         }
         WebsocketController.joinProject(
           client,
           user,
-          data.project_id,
+          project_id,
           function (err, ...args) {
             if (err) {
               Router._handleError(callback, err, client, 'joinProject', {
-                project_id: data.project_id,
+                project_id: project_id,
                 user_id: user._id,
               })
             } else {
@@ -253,7 +307,20 @@ module.exports = Router = {
         } else {
           return Router._handleInvalidArguments(client, 'joinDoc', arguments)
         }
-
+        try {
+          Joi.assert(
+            { doc_id, fromVersion, options },
+            Joi.object({
+              doc_id: JOI_OBJECT_ID,
+              fromVersion: Joi.number().integer(),
+              options: Joi.object().required(),
+            })
+          )
+        } catch (error) {
+          return Router._handleError(callback, error, client, 'joinDoc', {
+            disconnect: 1,
+          })
+        }
         WebsocketController.joinDoc(
           client,
           doc_id,
@@ -276,7 +343,13 @@ module.exports = Router = {
         if (typeof callback !== 'function') {
           return Router._handleInvalidArguments(client, 'leaveDoc', arguments)
         }
-
+        try {
+          Joi.assert(doc_id, JOI_OBJECT_ID)
+        } catch (error) {
+          return Router._handleError(callback, error, client, 'joinDoc', {
+            disconnect: 1,
+          })
+        }
         WebsocketController.leaveDoc(client, doc_id, function (err, ...args) {
           if (err) {
             Router._handleError(callback, err, client, 'leaveDoc', {
@@ -352,7 +425,19 @@ module.exports = Router = {
             arguments
           )
         }
-
+        try {
+          Joi.assert(
+            { doc_id, update },
+            Joi.object({
+              doc_id: JOI_OBJECT_ID,
+              update: Joi.object().required(),
+            })
+          )
+        } catch (error) {
+          return Router._handleError(callback, error, client, 'applyOtUpdate', {
+            disconnect: 1,
+          })
+        }
         WebsocketController.applyOtUpdate(
           client,
           doc_id,

+ 142 - 2
services/real-time/package-lock.json

@@ -561,6 +561,49 @@
         "protobufjs": "^6.8.6"
       }
     },
+    "@hapi/address": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/@hapi/address/-/address-4.1.0.tgz",
+      "integrity": "sha512-SkszZf13HVgGmChdHo/PxchnSaCJ6cetVqLzyciudzZRT0jcOouIF/Q93mgjw8cce+D+4F4C1Z/WrfFN+O3VHQ==",
+      "requires": {
+        "@hapi/hoek": "^9.0.0"
+      }
+    },
+    "@hapi/formula": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-2.0.0.tgz",
+      "integrity": "sha512-V87P8fv7PI0LH7LiVi8Lkf3x+KCO7pQozXRssAHNXXL9L1K+uyu4XypLXwxqVDKgyQai6qj3/KteNlrqDx4W5A=="
+    },
+    "@hapi/hoek": {
+      "version": "9.2.0",
+      "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.2.0.tgz",
+      "integrity": "sha512-sqKVVVOe5ivCaXDWivIJYVSaEgdQK9ul7a4Kity5Iw7u9+wBAPbX1RMSnLLmp7O4Vzj0WOWwMAJsTL00xwaNug=="
+    },
+    "@hapi/joi": {
+      "version": "17.1.1",
+      "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-17.1.1.tgz",
+      "integrity": "sha512-p4DKeZAoeZW4g3u7ZeRo+vCDuSDgSvtsB/NpfjXEHTUjSeINAi/RrVOWiVQ1isaoLzMvFEhe8n5065mQq1AdQg==",
+      "requires": {
+        "@hapi/address": "^4.0.1",
+        "@hapi/formula": "^2.0.0",
+        "@hapi/hoek": "^9.0.0",
+        "@hapi/pinpoint": "^2.0.0",
+        "@hapi/topo": "^5.0.0"
+      }
+    },
+    "@hapi/pinpoint": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.0.tgz",
+      "integrity": "sha512-vzXR5MY7n4XeIvLpfl3HtE3coZYO4raKXW766R6DZw/6aLqR26iuZ109K7a0NtF2Db0jxqh7xz2AxkUwpUFybw=="
+    },
+    "@hapi/topo": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
+      "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
+      "requires": {
+        "@hapi/hoek": "^9.0.0"
+      }
+    },
     "@humanwhocodes/config-array": {
       "version": "0.5.0",
       "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz",
@@ -1241,6 +1284,7 @@
       "requires": {
         "anymatch": "~3.1.1",
         "braces": "~3.0.2",
+        "fsevents": "~2.3.1",
         "glob-parent": "~5.1.0",
         "is-binary-path": "~2.1.0",
         "is-glob": "~4.0.1",
@@ -2130,7 +2174,18 @@
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
       "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
-      "dev": true
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.1.0"
+      },
+      "dependencies": {
+        "estraverse": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+          "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+          "dev": true
+        }
+      }
     },
     "esrecurse": {
       "version": "4.3.0",
@@ -2378,7 +2433,33 @@
       "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
       "dev": true,
       "requires": {
-        "flatted": "^3.1.0"
+        "flatted": "^3.1.0",
+        "rimraf": "^3.0.2"
+      },
+      "dependencies": {
+        "glob": {
+          "version": "7.1.7",
+          "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+          "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+          "dev": true,
+          "requires": {
+            "fs.realpath": "^1.0.0",
+            "inflight": "^1.0.4",
+            "inherits": "2",
+            "minimatch": "^3.0.4",
+            "once": "^1.3.0",
+            "path-is-absolute": "^1.0.0"
+          }
+        },
+        "rimraf": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+          "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+          "dev": true,
+          "requires": {
+            "glob": "^7.1.3"
+          }
+        }
       }
     },
     "flatted": {
@@ -2425,6 +2506,13 @@
       "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
       "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="
     },
+    "fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "optional": true
+    },
     "function-bind": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
@@ -4871,6 +4959,24 @@
           "version": "4.3.0",
           "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
           "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
           "dev": true
         }
       }
@@ -5111,9 +5217,16 @@
           "dev": true,
           "requires": {
             "fast-deep-equal": "^3.1.1",
+            "json-schema-traverse": "^1.0.0",
             "require-from-string": "^2.0.2",
             "uri-js": "^4.2.2"
           }
+        },
+        "json-schema-traverse": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+          "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+          "dev": true
         }
       }
     },
@@ -5492,8 +5605,35 @@
       "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
       "dev": true,
       "requires": {
+        "ansi-styles": "^4.0.0",
         "string-width": "^4.1.0",
         "strip-ansi": "^6.0.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "4.3.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+          "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+          "dev": true,
+          "requires": {
+            "color-convert": "^2.0.1"
+          }
+        },
+        "color-convert": {
+          "version": "2.0.1",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+          "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+          "dev": true,
+          "requires": {
+            "color-name": "~1.1.4"
+          }
+        },
+        "color-name": {
+          "version": "1.1.4",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+          "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+          "dev": true
+        }
       }
     },
     "wrappy": {

+ 1 - 0
services/real-time/package.json

@@ -22,6 +22,7 @@
     "lint:fix": "eslint --fix ."
   },
   "dependencies": {
+    "@hapi/joi": "^17.1.1",
     "@overleaf/metrics": "^3.5.1",
     "@overleaf/o-error": "^3.1.0",
     "@overleaf/redis-wrapper": "^2.0.0",

+ 173 - 1
services/real-time/test/acceptance/js/ApplyUpdateTests.js

@@ -359,7 +359,7 @@ describe('applyOtUpdate', function () {
     })
   })
 
-  return describe('when authorized to read-only with a comment update', function () {
+  describe('when authorized to read-only with a comment update', function () {
     before(function (done) {
       this.comment_update = {
         op: [{ c: 'foo', p: 42 }],
@@ -472,4 +472,176 @@ describe('applyOtUpdate', function () {
       )
     })
   })
+
+  describe('when authorized with an edit update to an invalid doc', function () {
+    before(function (done) {
+      return async.series(
+        [
+          cb => {
+            return FixturesManager.setUpProject(
+              {
+                privilegeLevel: 'readOnly',
+              },
+              (e, { project_id, user_id }) => {
+                this.project_id = project_id
+                this.user_id = user_id
+                return cb(e)
+              }
+            )
+          },
+
+          cb => {
+            return FixturesManager.setUpDoc(
+              this.project_id,
+              { lines: this.lines, version: this.version, ops: this.ops },
+              (e, { doc_id }) => {
+                this.doc_id = doc_id
+                return cb(e)
+              }
+            )
+          },
+
+          cb => {
+            this.client = RealTimeClient.connect()
+            return this.client.on('connectionAccepted', cb)
+          },
+
+          cb => {
+            return this.client.emit(
+              'joinProject',
+              { project_id: this.project_id },
+              cb
+            )
+          },
+
+          cb => {
+            return this.client.emit('joinDoc', this.doc_id, cb)
+          },
+
+          cb => {
+            return this.client.emit(
+              'applyOtUpdate',
+              'invalid-doc-id',
+              this.update,
+              error => {
+                this.error = error
+                return cb()
+              }
+            )
+          },
+        ],
+        done
+      )
+    })
+
+    it('should return an error', function () {
+      return expect(this.error).to.exist
+    })
+
+    it('should disconnect the client', function (done) {
+      return setTimeout(() => {
+        this.client.socket.connected.should.equal(false)
+        return done()
+      }, 300)
+    })
+
+    return it('should not put the update in redis', function (done) {
+      rclient.llen(
+        redisSettings.documentupdater.key_schema.pendingUpdates({
+          doc_id: this.doc_id,
+        }),
+        (error, len) => {
+          len.should.equal(0)
+          return done()
+        }
+      )
+      return null
+    })
+  })
+
+  describe('when authorized with an invalid edit update', function () {
+    before(function (done) {
+      return async.series(
+        [
+          cb => {
+            return FixturesManager.setUpProject(
+              {
+                privilegeLevel: 'readAndWrite',
+              },
+              (e, { project_id, user_id }) => {
+                this.project_id = project_id
+                this.user_id = user_id
+                return cb(e)
+              }
+            )
+          },
+
+          cb => {
+            return FixturesManager.setUpDoc(
+              this.project_id,
+              { lines: this.lines, version: this.version, ops: this.ops },
+              (e, { doc_id }) => {
+                this.doc_id = doc_id
+                return cb(e)
+              }
+            )
+          },
+
+          cb => {
+            this.client = RealTimeClient.connect()
+            return this.client.on('connectionAccepted', cb)
+          },
+
+          cb => {
+            return this.client.emit(
+              'joinProject',
+              { project_id: this.project_id },
+              cb
+            )
+          },
+
+          cb => {
+            return this.client.emit('joinDoc', this.doc_id, cb)
+          },
+
+          cb => {
+            return this.client.emit(
+              'applyOtUpdate',
+              this.doc_id,
+              'invalid-update',
+              error => {
+                this.error = error
+                return cb()
+              }
+            )
+          },
+        ],
+        done
+      )
+    })
+
+    it('should return an error', function () {
+      return expect(this.error).to.exist
+    })
+
+    it('should disconnect the client', function (done) {
+      return setTimeout(() => {
+        this.client.socket.connected.should.equal(false)
+        return done()
+      }, 300)
+    })
+
+    return it('should not put the update in redis', function (done) {
+      rclient.llen(
+        redisSettings.documentupdater.key_schema.pendingUpdates({
+          doc_id: this.doc_id,
+        }),
+        (error, len) => {
+          len.should.equal(0)
+          return done()
+        }
+      )
+      return null
+    })
+  })
 })

+ 84 - 0
services/real-time/test/acceptance/js/JoinDocTests.js

@@ -292,6 +292,90 @@ describe('joinDoc', function () {
   // project, since joinProject already catches that. If you can join a project,
   // then you can join a doc in that project.
 
+  describe('for an invalid doc', function () {
+    before(function (done) {
+      return async.series(
+        [
+          cb => {
+            return FixturesManager.setUpProject(
+              {
+                privilegeLevel: 'owner',
+              },
+              (e, { project_id, user_id }) => {
+                this.project_id = project_id
+                this.user_id = user_id
+                return cb(e)
+              }
+            )
+          },
+
+          cb => {
+            return FixturesManager.setUpDoc(
+              this.project_id,
+              {
+                lines: this.lines,
+                version: this.version,
+                ops: this.ops,
+                ranges: this.ranges,
+              },
+              (e, { doc_id }) => {
+                this.doc_id = doc_id
+                return cb(e)
+              }
+            )
+          },
+
+          cb => {
+            this.client = RealTimeClient.connect()
+            return this.client.on('connectionAccepted', cb)
+          },
+
+          cb => {
+            return this.client.emit(
+              'joinProject',
+              { project_id: this.project_id },
+              cb
+            )
+          },
+
+          cb => {
+            return this.client.emit(
+              'joinDoc',
+              'invalid-doc-id',
+              (error, ...rest) => {
+                this.error = error
+                return cb()
+              }
+            )
+          },
+        ],
+        done
+      )
+    })
+
+    it('should not get the doc from the doc updater', function () {
+      return MockDocUpdaterServer.getDocument
+        .calledWith(this.project_id, 'invalid-doc-id')
+        .should.equal(false)
+    })
+
+    it('should return an invalid id error', function () {
+      this.error.message.should.equal('invalid id')
+    })
+
+    return it('should not have joined the doc room', function (done) {
+      return RealTimeClient.getConnectedClient(
+        this.client.socket.sessionid,
+        (error, client) => {
+          expect(Array.from(client.rooms).includes('invalid-doc-id')).to.equal(
+            false
+          )
+          return done()
+        }
+      )
+    })
+  })
+
   describe('with a fromVersion', function () {
     before(function (done) {
       this.fromVersion = 36

+ 112 - 3
services/real-time/test/acceptance/js/JoinProjectTests.js

@@ -181,7 +181,7 @@ describe('joinProject', function () {
           cb => {
             return FixturesManager.setUpProject(
               {
-                project_id: 'forbidden',
+                project_id: '403403403403403403403403', // forbidden
                 privilegeLevel: 'owner',
                 project: {
                   name: 'Test Project',
@@ -242,7 +242,7 @@ describe('joinProject', function () {
           cb => {
             return FixturesManager.setUpProject(
               {
-                project_id: 'not-found',
+                project_id: '404404404404404404404404', // not-found
                 privilegeLevel: 'owner',
                 project: {
                   name: 'Test Project',
@@ -296,6 +296,115 @@ describe('joinProject', function () {
     })
   })
 
+  describe('when invalid', function () {
+    before(function (done) {
+      MockWebServer.joinProject.resetHistory()
+      return async.series(
+        [
+          cb => {
+            this.client = RealTimeClient.connect()
+            return this.client.on('connectionAccepted', cb)
+          },
+
+          cb => {
+            return this.client.emit(
+              'joinProject',
+              { project_id: 'invalid-id' },
+              error => {
+                this.error = error
+                return cb()
+              }
+            )
+          },
+        ],
+        done
+      )
+    })
+
+    it('should return an invalid id error', function () {
+      this.error.message.should.equal('invalid id')
+    })
+
+    it('should not call to web', function () {
+      MockWebServer.joinProject.called.should.equal(false)
+    })
+  })
+
+  describe('when joining more than one project', function () {
+    before(function (done) {
+      return async.series(
+        [
+          cb => {
+            return FixturesManager.setUpProject(
+              {
+                privilegeLevel: 'owner',
+                project: {
+                  name: 'Other Project',
+                },
+              },
+              (e, { project_id, user_id }) => {
+                this.other_project_id = project_id
+                this.other_user_id = user_id
+                return cb(e)
+              }
+            )
+          },
+
+          cb => {
+            return FixturesManager.setUpProject(
+              {
+                user_id: this.other_user_id,
+                privilegeLevel: 'owner',
+                project: {
+                  name: 'Test Project',
+                },
+              },
+              (e, { project_id, user_id }) => {
+                this.project_id = project_id
+                this.user_id = user_id
+                return cb(e)
+              }
+            )
+          },
+
+          cb => {
+            this.client = RealTimeClient.connect()
+            return this.client.on('connectionAccepted', cb)
+          },
+
+          cb => {
+            return this.client.emit(
+              'joinProject',
+              { project_id: this.project_id },
+              (error, project, privilegeLevel, protocolVersion) => {
+                this.project = project
+                this.privilegeLevel = privilegeLevel
+                this.protocolVersion = protocolVersion
+                return cb(error)
+              }
+            )
+          },
+
+          cb => {
+            return this.client.emit(
+              'joinProject',
+              { project_id: this.other_project_id },
+              error => {
+                this.error = error
+                return cb()
+              }
+            )
+          },
+        ],
+        done
+      )
+    })
+
+    return it('should return an error', function () {
+      this.error.message.should.equal('cannot join multiple projects')
+    })
+  })
+
   return describe('when over rate limit', function () {
     before(function (done) {
       return async.series(
@@ -308,7 +417,7 @@ describe('joinProject', function () {
           cb => {
             return this.client.emit(
               'joinProject',
-              { project_id: 'rate-limited' },
+              { project_id: '429429429429429429429429' }, // rate-limited
               error => {
                 this.error = error
                 return cb()

+ 13 - 0
services/real-time/test/acceptance/js/LeaveDocTests.js

@@ -119,6 +119,19 @@ describe('leaveDoc', function () {
       })
     })
 
+    describe('then leaving an invalid doc', function () {
+      beforeEach(function (done) {
+        return this.client.emit('leaveDoc', 'bad-id', error => {
+          this.error = error
+          return done()
+        })
+      })
+
+      return it('should return an error', function () {
+        return expect(this.error).to.exist
+      })
+    })
+
     describe('when sending a leaveDoc request before the previous joinDoc request has completed', function () {
       beforeEach(function (done) {
         this.client.emit('leaveDoc', this.doc_id, () => {})

+ 6 - 3
services/real-time/test/acceptance/js/helpers/MockWebServer.js

@@ -39,13 +39,16 @@ module.exports = MockWebServer = {
   joinProjectRequest(req, res, next) {
     const { project_id } = req.params
     const { user_id } = req.query
-    if (project_id === 'not-found') {
+    if (project_id === '404404404404404404404404') {
+      // not-found
       return res.status(404).send()
     }
-    if (project_id === 'forbidden') {
+    if (project_id === '403403403403403403403403') {
+      // forbidden
       return res.status(403).send()
     }
-    if (project_id === 'rate-limited') {
+    if (project_id === '429429429429429429429429') {
+      // rate-limited
       return res.status(429).send()
     } else {
       return MockWebServer.joinProject(