Procházet zdrojové kódy

Merge pull request #2202 from overleaf/em-collab-set-permissions

Endpoint for setting a collaborator's permissions

GitOrigin-RevId: eb4d4dcc476908f5a42fefd7b81ef6fcc000be5b
Eric Mc Sween před 6 roky
rodič
revize
45e5808a35

+ 24 - 1
services/web/app/src/Features/Collaborators/CollaboratorsController.js

@@ -1,16 +1,19 @@
 const OError = require('@overleaf/o-error')
 const OError = require('@overleaf/o-error')
+const HttpErrors = require('@overleaf/o-error/http')
 const CollaboratorsHandler = require('./CollaboratorsHandler')
 const CollaboratorsHandler = require('./CollaboratorsHandler')
 const CollaboratorsGetter = require('./CollaboratorsGetter')
 const CollaboratorsGetter = require('./CollaboratorsGetter')
 const AuthenticationController = require('../Authentication/AuthenticationController')
 const AuthenticationController = require('../Authentication/AuthenticationController')
 const EditorRealTimeController = require('../Editor/EditorRealTimeController')
 const EditorRealTimeController = require('../Editor/EditorRealTimeController')
 const TagsHandler = require('../Tags/TagsHandler')
 const TagsHandler = require('../Tags/TagsHandler')
+const Errors = require('../Errors/Errors')
 const logger = require('logger-sharelatex')
 const logger = require('logger-sharelatex')
 const { expressify } = require('../../util/promises')
 const { expressify } = require('../../util/promises')
 
 
 module.exports = {
 module.exports = {
   removeUserFromProject: expressify(removeUserFromProject),
   removeUserFromProject: expressify(removeUserFromProject),
   removeSelfFromProject: expressify(removeSelfFromProject),
   removeSelfFromProject: expressify(removeSelfFromProject),
-  getAllMembers: expressify(getAllMembers)
+  getAllMembers: expressify(getAllMembers),
+  setCollaboratorInfo: expressify(setCollaboratorInfo)
 }
 }
 
 
 async function removeUserFromProject(req, res, next) {
 async function removeUserFromProject(req, res, next) {
@@ -45,6 +48,26 @@ async function getAllMembers(req, res, next) {
   res.json({ members })
   res.json({ members })
 }
 }
 
 
+async function setCollaboratorInfo(req, res, next) {
+  const projectId = req.params.Project_id
+  const userId = req.params.user_id
+  const { privilegeLevel } = req.body
+  try {
+    await CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
+      projectId,
+      userId,
+      privilegeLevel
+    )
+  } catch (err) {
+    if (err instanceof Errors.NotFoundError) {
+      throw new HttpErrors.NotFoundError({})
+    } else {
+      throw err
+    }
+  }
+  res.sendStatus(204)
+}
+
 async function _removeUserIdFromProject(projectId, userId) {
 async function _removeUserIdFromProject(projectId, userId) {
   await CollaboratorsHandler.promises.removeUserFromProject(projectId, userId)
   await CollaboratorsHandler.promises.removeUserFromProject(projectId, userId)
   EditorRealTimeController.emitToRoom(
   EditorRealTimeController.emitToRoom(

+ 42 - 1
services/web/app/src/Features/Collaborators/CollaboratorsHandler.js

@@ -7,6 +7,7 @@ const ContactManager = require('../Contacts/ContactManager')
 const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
 const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
 const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
 const ProjectEntityHandler = require('../Project/ProjectEntityHandler')
 const CollaboratorsGetter = require('./CollaboratorsGetter')
 const CollaboratorsGetter = require('./CollaboratorsGetter')
+const Errors = require('../Errors/Errors')
 
 
 module.exports = {
 module.exports = {
   removeUserFromProject: callbackify(removeUserFromProject),
   removeUserFromProject: callbackify(removeUserFromProject),
@@ -17,7 +18,8 @@ module.exports = {
     removeUserFromProject,
     removeUserFromProject,
     removeUserFromAllProjects,
     removeUserFromAllProjects,
     addUserIdToProject,
     addUserIdToProject,
-    transferProjects
+    transferProjects,
+    setCollaboratorPrivilegeLevel
   }
   }
 }
 }
 
 
@@ -150,6 +152,45 @@ async function transferProjects(fromUserId, toUserId) {
   })
   })
 }
 }
 
 
+async function setCollaboratorPrivilegeLevel(
+  projectId,
+  userId,
+  privilegeLevel
+) {
+  // Make sure we're only updating the project if the user is already a
+  // collaborator
+  const query = {
+    _id: projectId,
+    $or: [{ collaberator_refs: userId }, { readOnly_refs: userId }]
+  }
+  let update
+  switch (privilegeLevel) {
+    case PrivilegeLevels.READ_AND_WRITE: {
+      update = {
+        $pull: { readOnly_refs: userId },
+        $addToSet: { collaberator_refs: userId }
+      }
+      break
+    }
+    case PrivilegeLevels.READ_ONLY: {
+      update = {
+        $pull: { collaberator_refs: userId },
+        $addToSet: { readOnly_refs: userId }
+      }
+      break
+    }
+    default: {
+      throw new OError({
+        message: `unknown privilege level: ${privilegeLevel}`
+      })
+    }
+  }
+  const mongoResponse = await Project.updateOne(query, update).exec()
+  if (mongoResponse.n === 0) {
+    throw new Errors.NotFoundError('project or collaborator not found')
+  }
+}
+
 async function _flushProjects(projectIds) {
 async function _flushProjects(projectIds) {
   for (const projectId of projectIds) {
   for (const projectId of projectIds) {
     await ProjectEntityHandler.promises.flushProjectToThirdPartyDataStore(
     await ProjectEntityHandler.promises.flushProjectToThirdPartyDataStore(

+ 21 - 11
services/web/app/src/Features/Collaborators/CollaboratorsRouter.js

@@ -1,19 +1,11 @@
-/* eslint-disable
-    max-len,
-*/
-// TODO: This file was created by bulk-decaffeinate.
-// Fix any style issues and re-enable lint.
-/*
- * decaffeinate suggestions:
- * DS102: Remove unnecessary code created because of implicit returns
- * Full docs: https://github.com/decaffeinate/decaffeinate/blob/master/docs/suggestions.md
- */
 const CollaboratorsController = require('./CollaboratorsController')
 const CollaboratorsController = require('./CollaboratorsController')
 const AuthenticationController = require('../Authentication/AuthenticationController')
 const AuthenticationController = require('../Authentication/AuthenticationController')
 const AuthorizationMiddleware = require('../Authorization/AuthorizationMiddleware')
 const AuthorizationMiddleware = require('../Authorization/AuthorizationMiddleware')
+const PrivilegeLevels = require('../Authorization/PrivilegeLevels')
 const CollaboratorsInviteController = require('./CollaboratorsInviteController')
 const CollaboratorsInviteController = require('./CollaboratorsInviteController')
 const RateLimiterMiddleware = require('../Security/RateLimiterMiddleware')
 const RateLimiterMiddleware = require('../Security/RateLimiterMiddleware')
 const CaptchaMiddleware = require('../Captcha/CaptchaMiddleware')
 const CaptchaMiddleware = require('../Captcha/CaptchaMiddleware')
+const { Joi, validate } = require('../../infrastructure/Validation')
 
 
 module.exports = {
 module.exports = {
   apply(webRouter, apiRouter) {
   apply(webRouter, apiRouter) {
@@ -23,6 +15,24 @@ module.exports = {
       CollaboratorsController.removeSelfFromProject
       CollaboratorsController.removeSelfFromProject
     )
     )
 
 
+    webRouter.put(
+      '/project/:Project_id/users/:user_id',
+      AuthenticationController.requireLogin(),
+      validate({
+        params: Joi.object({
+          Project_id: Joi.objectId(),
+          user_id: Joi.objectId()
+        }),
+        body: Joi.object({
+          privilegeLevel: Joi.string()
+            .valid(PrivilegeLevels.READ_ONLY, PrivilegeLevels.READ_AND_WRITE)
+            .required()
+        })
+      }),
+      AuthorizationMiddleware.ensureUserCanAdminProject,
+      CollaboratorsController.setCollaboratorInfo
+    )
+
     webRouter.delete(
     webRouter.delete(
       '/project/:Project_id/users/:user_id',
       '/project/:Project_id/users/:user_id',
       AuthenticationController.requireLogin(),
       AuthenticationController.requireLogin(),
@@ -91,7 +101,7 @@ module.exports = {
       CollaboratorsInviteController.viewInvite
       CollaboratorsInviteController.viewInvite
     )
     )
 
 
-    return webRouter.post(
+    webRouter.post(
       '/project/:Project_id/invite/token/:token/accept',
       '/project/:Project_id/invite/token/:token/accept',
       AuthenticationController.requireLogin(),
       AuthenticationController.requireLogin(),
       CollaboratorsInviteController.acceptInvite
       CollaboratorsInviteController.acceptInvite

+ 6 - 0
services/web/app/src/infrastructure/Server.js

@@ -5,6 +5,7 @@ const logger = require('logger-sharelatex')
 const metrics = require('metrics-sharelatex')
 const metrics = require('metrics-sharelatex')
 const crawlerLogger = require('./CrawlerLogger')
 const crawlerLogger = require('./CrawlerLogger')
 const expressLocals = require('./ExpressLocals')
 const expressLocals = require('./ExpressLocals')
+const Validation = require('./Validation')
 const Router = require('../router')
 const Router = require('../router')
 const helmet = require('helmet')
 const helmet = require('helmet')
 const UserSessionsRedis = require('../Features/User/UserSessionsRedis')
 const UserSessionsRedis = require('../Features/User/UserSessionsRedis')
@@ -242,6 +243,7 @@ const enableApiRouter =
 if (enableApiRouter || notDefined(enableApiRouter)) {
 if (enableApiRouter || notDefined(enableApiRouter)) {
   logger.info('providing api router')
   logger.info('providing api router')
   app.use(privateApiRouter)
   app.use(privateApiRouter)
+  app.use(Validation.errorMiddleware)
   app.use(HttpErrorController.handleError)
   app.use(HttpErrorController.handleError)
   app.use(ErrorController.handleApiError)
   app.use(ErrorController.handleApiError)
 }
 }
@@ -250,10 +252,14 @@ const enableWebRouter =
   Settings.web != null ? Settings.web.enableWebRouter : undefined
   Settings.web != null ? Settings.web.enableWebRouter : undefined
 if (enableWebRouter || notDefined(enableWebRouter)) {
 if (enableWebRouter || notDefined(enableWebRouter)) {
   logger.info('providing web router')
   logger.info('providing web router')
+
   app.use(publicApiRouter) // public API goes with web router for public access
   app.use(publicApiRouter) // public API goes with web router for public access
+  app.use(Validation.errorMiddleware)
   app.use(HttpErrorController.handleError)
   app.use(HttpErrorController.handleError)
   app.use(ErrorController.handleApiError)
   app.use(ErrorController.handleApiError)
+
   app.use(webRouter)
   app.use(webRouter)
+  app.use(Validation.errorMiddleware)
   app.use(HttpErrorController.handleError)
   app.use(HttpErrorController.handleError)
   app.use(ErrorController.handleError)
   app.use(ErrorController.handleError)
 }
 }

+ 14 - 0
services/web/app/src/infrastructure/Validation.js

@@ -0,0 +1,14 @@
+const { Joi: CelebrateJoi, celebrate, errors } = require('celebrate')
+const JoiObjectId = require('joi-mongodb-objectid')
+
+const Joi = CelebrateJoi.extend(JoiObjectId)
+const errorMiddleware = errors()
+
+module.exports = { Joi, validate, errorMiddleware }
+
+/**
+ * Validation middleware
+ */
+function validate(schema) {
+  return celebrate(schema, { allowUnknown: true })
+}

+ 84 - 0
services/web/package-lock.json

@@ -1864,6 +1864,40 @@
         }
         }
       }
       }
     },
     },
+    "@hapi/address": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.1.2.tgz",
+      "integrity": "sha512-O4QDrx+JoGKZc6aN64L04vqa7e41tIiLU+OvKdcYaEMP97UttL0f9GIi9/0A4WAMx0uBd6SidDIhktZhgOcN8Q=="
+    },
+    "@hapi/bourne": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/@hapi/bourne/-/bourne-1.3.2.tgz",
+      "integrity": "sha512-1dVNHT76Uu5N3eJNTYcvxee+jzX4Z9lfciqRRHCU27ihbUcYi+iSc2iml5Ke1LXe1SyJCLA0+14Jh4tXJgOppA=="
+    },
+    "@hapi/hoek": {
+      "version": "8.2.5",
+      "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.2.5.tgz",
+      "integrity": "sha512-rmGFzok1zR3xZKd5m3ihWdqafXFxvPHoQ/78+AG5URKbEbJiwBBfRgzbu+07W5f3+07JRshw6QqGbVmCp8ntig=="
+    },
+    "@hapi/joi": {
+      "version": "15.1.1",
+      "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-15.1.1.tgz",
+      "integrity": "sha512-entf8ZMOK8sc+8YfeOlM8pCfg3b5+WZIKBfUaaJT8UsjAAPjartzxIYm3TIbjvA4u+u++KbcXD38k682nVHDAQ==",
+      "requires": {
+        "@hapi/address": "2.x.x",
+        "@hapi/bourne": "1.x.x",
+        "@hapi/hoek": "8.x.x",
+        "@hapi/topo": "3.x.x"
+      }
+    },
+    "@hapi/topo": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-3.1.4.tgz",
+      "integrity": "sha512-aVWQTOI9wBD6zawmOr6f+tdEIxQC8JXfQVLTjgGe8YEStAWGn/GNNVTobKJhbWKveQj2RyYF3oYbO9SC8/eOCA==",
+      "requires": {
+        "@hapi/hoek": "8.x.x"
+      }
+    },
     "@overleaf/o-error": {
     "@overleaf/o-error": {
       "version": "2.1.0",
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/@overleaf/o-error/-/o-error-2.1.0.tgz",
       "resolved": "https://registry.npmjs.org/@overleaf/o-error/-/o-error-2.1.0.tgz",
@@ -4169,6 +4203,22 @@
       "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
       "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
       "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="
       "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="
     },
     },
+    "celebrate": {
+      "version": "10.0.1",
+      "resolved": "https://registry.npmjs.org/celebrate/-/celebrate-10.0.1.tgz",
+      "integrity": "sha512-Eke/caDOlcLjwk8WN4+bX9WyiiIgJ+zjbsh368FAtEj74bNC3Y+gVfFF8efmP9iYVnFSkLy31+6bKCQm/Zza+g==",
+      "requires": {
+        "@hapi/joi": "15.x.x",
+        "escape-html": "1.0.3"
+      },
+      "dependencies": {
+        "escape-html": {
+          "version": "1.0.3",
+          "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+          "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg="
+        }
+      }
+    },
     "center-align": {
     "center-align": {
       "version": "0.1.3",
       "version": "0.1.3",
       "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz",
       "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz",
@@ -4197,6 +4247,12 @@
         "check-error": "^1.0.2"
         "check-error": "^1.0.2"
       }
       }
     },
     },
+    "chaid": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/chaid/-/chaid-1.0.2.tgz",
+      "integrity": "sha1-9Y6UNgUoq9qkas8LOlF0oG4Vd1A=",
+      "dev": true
+    },
     "chalk": {
     "chalk": {
       "version": "1.1.3",
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
@@ -10675,6 +10731,34 @@
       "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz",
       "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.15.0.tgz",
       "integrity": "sha512-+kHj8HXArPfpPEKGLZ+kB5ONRTCiGQXo8RQYL0hH8t6pWXUBBK5KkkQmTNOwKK4LEsd0yTsgtjJVm4UBSZea4w=="
       "integrity": "sha512-+kHj8HXArPfpPEKGLZ+kB5ONRTCiGQXo8RQYL0hH8t6pWXUBBK5KkkQmTNOwKK4LEsd0yTsgtjJVm4UBSZea4w=="
     },
     },
+    "joi-mongodb-objectid": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/joi-mongodb-objectid/-/joi-mongodb-objectid-0.1.0.tgz",
+      "integrity": "sha512-5N86VRXOd8TZ2nEvlg/EvIeF/StFrB2VAI9iD46di2B2eR7dU2kLhsYTwiAOQMFozIg64qfWs/eEed52w9YpBw==",
+      "requires": {
+        "bson": "^4.0.0"
+      },
+      "dependencies": {
+        "bson": {
+          "version": "4.0.2",
+          "resolved": "https://registry.npmjs.org/bson/-/bson-4.0.2.tgz",
+          "integrity": "sha512-rBdCxMBCg2aR420e1oKUejjcuPZLTibA7zEhWAlliFWEwzuBCC9Dkp5r7VFFIQB2t1WVsvTbohry575mc7Xw5A==",
+          "requires": {
+            "buffer": "^5.1.0",
+            "long": "^4.0.0"
+          }
+        },
+        "buffer": {
+          "version": "5.4.3",
+          "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz",
+          "integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==",
+          "requires": {
+            "base64-js": "^1.0.2",
+            "ieee754": "^1.1.4"
+          }
+        }
+      }
+    },
     "jquery": {
     "jquery": {
       "version": "1.11.1",
       "version": "1.11.1",
       "resolved": "https://registry.npmjs.org/jquery/-/jquery-1.11.1.tgz",
       "resolved": "https://registry.npmjs.org/jquery/-/jquery-1.11.1.tgz",

+ 3 - 0
services/web/package.json

@@ -41,6 +41,7 @@
     "bcrypt": "^3.0.4",
     "bcrypt": "^3.0.4",
     "body-parser": "^1.13.1",
     "body-parser": "^1.13.1",
     "bufferedstream": "1.6.0",
     "bufferedstream": "1.6.0",
+    "celebrate": "^10.0.1",
     "chai-as-promised": "^7.1.1",
     "chai-as-promised": "^7.1.1",
     "codemirror": "^5.33.0",
     "codemirror": "^5.33.0",
     "connect-redis": "^3.1.0",
     "connect-redis": "^3.1.0",
@@ -63,6 +64,7 @@
     "helmet": "^3.8.1",
     "helmet": "^3.8.1",
     "http-proxy": "^1.8.1",
     "http-proxy": "^1.8.1",
     "is-utf8": "^0.2.1",
     "is-utf8": "^0.2.1",
+    "joi-mongodb-objectid": "^0.1.0",
     "jquery": "^1.11.1",
     "jquery": "^1.11.1",
     "json2csv": "^4.3.3",
     "json2csv": "^4.3.3",
     "jsonwebtoken": "^8.0.1",
     "jsonwebtoken": "^8.0.1",
@@ -125,6 +127,7 @@
     "babel-plugin-angularjs-annotate": "^0.10.0",
     "babel-plugin-angularjs-annotate": "^0.10.0",
     "chai": "3.5.0",
     "chai": "3.5.0",
     "chai-as-promised": "^7.1.1",
     "chai-as-promised": "^7.1.1",
+    "chaid": "^1.0.2",
     "clean-css-cli": "^4.2.1",
     "clean-css-cli": "^4.2.1",
     "es6-promise": "^4.0.5",
     "es6-promise": "^4.0.5",
     "eslint": "^4.18.1",
     "eslint": "^4.18.1",

+ 1 - 0
services/web/test/acceptance/bootstrap.js

@@ -1,2 +1,3 @@
 const chai = require('chai')
 const chai = require('chai')
 chai.use(require('chai-as-promised'))
 chai.use(require('chai-as-promised'))
+chai.use(require('chaid'))

+ 102 - 0
services/web/test/acceptance/src/SharingTests.js

@@ -0,0 +1,102 @@
+const { expect } = require('chai')
+const User = require('./helpers/User').promises
+
+describe('Sharing', function() {
+  beforeEach(async function() {
+    this.ownerSession = new User()
+    this.collaboratorSession = new User()
+    this.strangerSession = new User()
+    await this.ownerSession.login()
+    await this.collaboratorSession.login()
+    await this.strangerSession.login()
+    this.owner = await this.ownerSession.get()
+    this.collaborator = await this.collaboratorSession.get()
+    this.stranger = await this.strangerSession.get()
+    this.projectId = await this.ownerSession.createProject('Test project')
+  })
+
+  describe('with read-only collaborator', function() {
+    beforeEach(async function() {
+      await this.ownerSession.addUserToProject(
+        this.projectId,
+        this.collaborator,
+        'readOnly'
+      )
+    })
+
+    it('sets the privilege level to read-write', async function() {
+      await this.ownerSession.setCollaboratorInfo(
+        this.projectId,
+        this.collaborator._id,
+        { privilegeLevel: 'readAndWrite' }
+      )
+      const project = await this.ownerSession.getProject(this.projectId)
+      expect(project.collaberator_refs).to.be.unordered.ids([
+        this.collaborator._id
+      ])
+      expect(project.readOnly_refs).to.deep.equal([])
+    })
+
+    it('treats setting the privilege to read-only as a noop', async function() {
+      await this.ownerSession.setCollaboratorInfo(
+        this.projectId,
+        this.collaborator._id,
+        { privilegeLevel: 'readOnly' }
+      )
+      const project = await this.ownerSession.getProject(this.projectId)
+      expect(project.collaberator_refs).to.deep.equal([])
+      expect(project.readOnly_refs).to.be.unordered.ids([this.collaborator._id])
+    })
+
+    it('prevents non-owners to set the privilege level', async function() {
+      await expect(
+        this.collaboratorSession.setCollaboratorInfo(
+          this.projectId,
+          this.collaborator._id,
+          { privilegeLevel: 'readAndWrite' }
+        )
+      ).to.be.rejectedWith('Unexpected status code: 403')
+    })
+
+    it('validates the privilege level', async function() {
+      await expect(
+        this.collaboratorSession.setCollaboratorInfo(
+          this.projectId,
+          this.collaborator._id,
+          { privilegeLevel: 'superpowers' }
+        )
+      ).to.be.rejectedWith('Unexpected status code: 400')
+    })
+
+    it('returns 404 if the user is not already a collaborator', async function() {
+      await expect(
+        this.ownerSession.setCollaboratorInfo(
+          this.projectId,
+          this.stranger._id,
+          { privilegeLevel: 'readOnly' }
+        )
+      ).to.be.rejectedWith('Unexpected status code: 404')
+    })
+  })
+
+  describe('with read-write collaborator', function() {
+    beforeEach(async function() {
+      await this.ownerSession.addUserToProject(
+        this.projectId,
+        this.collaborator,
+        'readAndWrite'
+      )
+    })
+
+    it('sets the privilege level to read-only', async function() {
+      await this.ownerSession.setCollaboratorInfo(
+        this.projectId,
+        this.collaborator._id,
+        { privilegeLevel: 'readOnly' }
+      )
+      const project = await this.ownerSession.getProject(this.projectId)
+      expect(project.collaberator_refs).to.deep.equal([])
+      expect(project.readOnly_refs).to.be.unordered.ids([this.collaborator._id])
+    })
+  })
+})

+ 25 - 0
services/web/test/acceptance/src/helpers/User.js

@@ -678,6 +678,31 @@ class User {
       callback
       callback
     )
     )
   }
   }
+
+  setCollaboratorInfo(projectId, userId, info, callback) {
+    this.getCsrfToken(err => {
+      if (err != null) {
+        return callback(err)
+      }
+      this.request.put(
+        {
+          url: `/project/${projectId.toString()}/users/${userId.toString()}`,
+          json: info
+        },
+        (err, response) => {
+          if (err != null) {
+            return callback(err)
+          }
+          if (response.statusCode !== 204) {
+            return callback(
+              new Error(`Unexpected status code: ${response.statusCode}`)
+            )
+          }
+          callback()
+        }
+      )
+    })
+  }
 }
 }
 
 
 User.promises = class extends User {
 User.promises = class extends User {

+ 57 - 16
services/web/test/unit/src/Collaborators/CollaboratorsControllerTests.js

@@ -1,6 +1,9 @@
 const sinon = require('sinon')
 const sinon = require('sinon')
 const { expect } = require('chai')
 const { expect } = require('chai')
 const SandboxedModule = require('sandboxed-module')
 const SandboxedModule = require('sandboxed-module')
+const { ObjectId } = require('mongodb')
+const HttpErrors = require('@overleaf/o-error/http')
+const Errors = require('../../../../app/src/Features/Errors/Errors')
 const MockRequest = require('../helpers/MockRequest')
 const MockRequest = require('../helpers/MockRequest')
 const MockResponse = require('../helpers/MockResponse')
 const MockResponse = require('../helpers/MockResponse')
 
 
@@ -12,13 +15,14 @@ describe('CollaboratorsController', function() {
     this.res = new MockResponse()
     this.res = new MockResponse()
     this.req = new MockRequest()
     this.req = new MockRequest()
 
 
-    this.user_id = 'user-id-123'
-    this.project_id = 'project-id-123'
+    this.userId = ObjectId()
+    this.projectId = ObjectId()
     this.callback = sinon.stub()
     this.callback = sinon.stub()
 
 
     this.CollaboratorsHandler = {
     this.CollaboratorsHandler = {
       promises: {
       promises: {
-        removeUserFromProject: sinon.stub().resolves()
+        removeUserFromProject: sinon.stub().resolves(),
+        setCollaboratorPrivilegeLevel: sinon.stub().resolves()
       }
       }
     }
     }
     this.CollaboratorsGetter = {
     this.CollaboratorsGetter = {
@@ -35,7 +39,7 @@ describe('CollaboratorsController', function() {
       }
       }
     }
     }
     this.AuthenticationController = {
     this.AuthenticationController = {
-      getLoggedInUserId: sinon.stub().returns(this.user_id)
+      getLoggedInUserId: sinon.stub().returns(this.userId)
     }
     }
     this.logger = {
     this.logger = {
       err: sinon.stub(),
       err: sinon.stub(),
@@ -54,6 +58,8 @@ describe('CollaboratorsController', function() {
         '../Tags/TagsHandler': this.TagsHandler,
         '../Tags/TagsHandler': this.TagsHandler,
         '../Authentication/AuthenticationController': this
         '../Authentication/AuthenticationController': this
           .AuthenticationController,
           .AuthenticationController,
+        '../Errors/Errors': Errors,
+        '@overleaf/o-error/http': HttpErrors,
         'logger-sharelatex': this.logger
         'logger-sharelatex': this.logger
       }
       }
     })
     })
@@ -62,8 +68,8 @@ describe('CollaboratorsController', function() {
   describe('removeUserFromProject', function() {
   describe('removeUserFromProject', function() {
     beforeEach(function(done) {
     beforeEach(function(done) {
       this.req.params = {
       this.req.params = {
-        Project_id: this.project_id,
-        user_id: this.user_id
+        Project_id: this.projectId,
+        user_id: this.userId
       }
       }
       this.res.sendStatus = sinon.spy(() => {
       this.res.sendStatus = sinon.spy(() => {
         done()
         done()
@@ -74,14 +80,14 @@ describe('CollaboratorsController', function() {
     it('should from the user from the project', function() {
     it('should from the user from the project', function() {
       expect(
       expect(
         this.CollaboratorsHandler.promises.removeUserFromProject
         this.CollaboratorsHandler.promises.removeUserFromProject
-      ).to.have.been.calledWith(this.project_id, this.user_id)
+      ).to.have.been.calledWith(this.projectId, this.userId)
     })
     })
 
 
     it('should emit a userRemovedFromProject event to the proejct', function() {
     it('should emit a userRemovedFromProject event to the proejct', function() {
       expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
       expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
-        this.project_id,
+        this.projectId,
         'userRemovedFromProject',
         'userRemovedFromProject',
-        this.user_id
+        this.userId
       )
       )
     })
     })
 
 
@@ -91,7 +97,7 @@ describe('CollaboratorsController', function() {
 
 
     it('should have called emitToRoom', function() {
     it('should have called emitToRoom', function() {
       expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
       expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
-        this.project_id,
+        this.projectId,
         'project:membership:changed'
         'project:membership:changed'
       )
       )
     })
     })
@@ -99,7 +105,7 @@ describe('CollaboratorsController', function() {
 
 
   describe('removeSelfFromProject', function() {
   describe('removeSelfFromProject', function() {
     beforeEach(function(done) {
     beforeEach(function(done) {
-      this.req.params = { Project_id: this.project_id }
+      this.req.params = { Project_id: this.projectId }
       this.res.sendStatus = sinon.spy(() => {
       this.res.sendStatus = sinon.spy(() => {
         done()
         done()
       })
       })
@@ -109,21 +115,21 @@ describe('CollaboratorsController', function() {
     it('should remove the logged in user from the project', function() {
     it('should remove the logged in user from the project', function() {
       expect(
       expect(
         this.CollaboratorsHandler.promises.removeUserFromProject
         this.CollaboratorsHandler.promises.removeUserFromProject
-      ).to.have.been.calledWith(this.project_id, this.user_id)
+      ).to.have.been.calledWith(this.projectId, this.userId)
     })
     })
 
 
     it('should emit a userRemovedFromProject event to the proejct', function() {
     it('should emit a userRemovedFromProject event to the proejct', function() {
       expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
       expect(this.EditorRealTimeController.emitToRoom).to.have.been.calledWith(
-        this.project_id,
+        this.projectId,
         'userRemovedFromProject',
         'userRemovedFromProject',
-        this.user_id
+        this.userId
       )
       )
     })
     })
 
 
     it('should remove the project from all tags', function() {
     it('should remove the project from all tags', function() {
       expect(
       expect(
         this.TagsHandler.promises.removeProjectFromAllTags
         this.TagsHandler.promises.removeProjectFromAllTags
-      ).to.have.been.calledWith(this.user_id, this.project_id)
+      ).to.have.been.calledWith(this.userId, this.projectId)
     })
     })
 
 
     it('should return a success code', function() {
     it('should return a success code', function() {
@@ -133,7 +139,7 @@ describe('CollaboratorsController', function() {
 
 
   describe('getAllMembers', function() {
   describe('getAllMembers', function() {
     beforeEach(function(done) {
     beforeEach(function(done) {
-      this.req.params = { Project_id: this.project_id }
+      this.req.params = { Project_id: this.projectId }
       this.res.json = sinon.spy(() => {
       this.res.json = sinon.spy(() => {
         done()
         done()
       })
       })
@@ -187,4 +193,39 @@ describe('CollaboratorsController', function() {
       })
       })
     })
     })
   })
   })
+
+  describe('setCollaboratorInfo', function() {
+    beforeEach(function() {
+      this.req.params = {
+        Project_id: this.projectId,
+        user_id: this.userId
+      }
+      this.req.body = { privilegeLevel: 'readOnly' }
+    })
+
+    it('should set the collaborator privilege level', function(done) {
+      this.res.sendStatus = status => {
+        expect(status).to.equal(204)
+        expect(
+          this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel
+        ).to.have.been.calledWith(this.projectId, this.userId, 'readOnly')
+        done()
+      }
+      this.CollaboratorsController.setCollaboratorInfo(this.req, this.res)
+    })
+
+    it('should return a 404 when the project or collaborator is not found', function(done) {
+      this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel.rejects(
+        new Errors.NotFoundError()
+      )
+      this.CollaboratorsController.setCollaboratorInfo(
+        this.req,
+        this.res,
+        err => {
+          expect(err).to.be.instanceof(HttpErrors.NotFoundError)
+          done()
+        }
+      )
+    })
+  })
 })
 })

+ 65 - 2
services/web/test/unit/src/Collaborators/CollaboratorsHandlerTests.js

@@ -21,8 +21,8 @@ describe('CollaboratorsHandler', function() {
       warn: sinon.stub(),
       warn: sinon.stub(),
       err: sinon.stub()
       err: sinon.stub()
     }
     }
-    this.userId = 'mock-user-id'
-    this.addingUserId = 'adding-user-id'
+    this.userId = ObjectId()
+    this.addingUserId = ObjectId()
     this.project = {
     this.project = {
       _id: ObjectId()
       _id: ObjectId()
     }
     }
@@ -346,4 +346,67 @@ describe('CollaboratorsHandler', function() {
       })
       })
     })
     })
   })
   })
+
+  describe('setCollaboratorPrivilegeLevel', function() {
+    it('sets a collaborator to read-only', async function() {
+      this.ProjectMock.expects('updateOne')
+        .withArgs(
+          {
+            _id: this.projectId,
+            $or: [
+              { collaberator_refs: this.userId },
+              { readOnly_refs: this.userId }
+            ]
+          },
+          {
+            $pull: { collaberator_refs: this.userId },
+            $addToSet: { readOnly_refs: this.userId }
+          }
+        )
+        .chain('exec')
+        .resolves({ n: 1 })
+      await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
+        this.projectId,
+        this.userId,
+        'readOnly'
+      )
+    })
+
+    it('sets a collaborator to read-write', async function() {
+      this.ProjectMock.expects('updateOne')
+        .withArgs(
+          {
+            _id: this.projectId,
+            $or: [
+              { collaberator_refs: this.userId },
+              { readOnly_refs: this.userId }
+            ]
+          },
+          {
+            $addToSet: { collaberator_refs: this.userId },
+            $pull: { readOnly_refs: this.userId }
+          }
+        )
+        .chain('exec')
+        .resolves({ n: 1 })
+      await this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
+        this.projectId,
+        this.userId,
+        'readAndWrite'
+      )
+    })
+
+    it('throws a NotFoundError if the project or collaborator does not exist', async function() {
+      this.ProjectMock.expects('updateOne')
+        .chain('exec')
+        .resolves({ n: 0 })
+      await expect(
+        this.CollaboratorsHandler.promises.setCollaboratorPrivilegeLevel(
+          this.projectId,
+          this.userId,
+          'readAndWrite'
+        )
+      ).to.be.rejectedWith(Errors.NotFoundError)
+    })
+  })
 })
 })