Parcourir la source

Merge pull request #28367 from overleaf/ar-migrate-routermjs-to-zod

[web] migrate router.mjs to zod

GitOrigin-RevId: d3fc21a11351f3e2deb5011cd1beeb86286a300b
Andrew Rumble il y a 11 mois
Parent
commit
7f908915aa

+ 27 - 5
services/web/app/src/Features/Compile/CompileController.js

@@ -11,6 +11,7 @@ const Settings = require('@overleaf/settings')
 const Errors = require('../Errors/Errors')
 const SessionManager = require('../Authentication/SessionManager')
 const { RateLimiter } = require('../../infrastructure/RateLimiter')
+const { z, zz, validateReq } = require('../../infrastructure/Validation')
 const ClsiCookieManager = require('./ClsiCookieManager')(
   Settings.apis.clsi?.backendGroupName
 )
@@ -138,6 +139,25 @@ async function _syncTeX(req, res, direction, validatedOptions) {
   }
 }
 
+const deleteAuxFilesSchema = z.object({
+  params: z.object({
+    Project_id: zz.objectId(),
+  }),
+  query: z.object({
+    clsiserverid: z.string().optional(),
+  }),
+})
+
+const wordCountSchema = z.object({
+  params: z.object({
+    Project_id: zz.objectId(),
+  }),
+  query: z.object({
+    clsiserverid: z.string().optional(),
+    file: z.string().optional(),
+  }),
+})
+
 const _CompileController = {
   async compile(req, res) {
     res.setTimeout(COMPILE_TIMEOUT_MS)
@@ -384,8 +404,9 @@ const _CompileController = {
   },
 
   async deleteAuxFiles(req, res) {
-    const projectId = req.params.Project_id
-    const { clsiserverid } = req.query
+    const { params, query } = validateReq(req, deleteAuxFilesSchema)
+    const projectId = params.Project_id
+    const { clsiserverid } = query
     const userId = await CompileController._getUserIdForCompile(req)
     await CompileManager.promises.deleteAuxFiles(
       projectId,
@@ -650,9 +671,10 @@ const _CompileController = {
   },
 
   async wordCount(req, res) {
-    const projectId = req.params.Project_id
-    const file = req.query.file || false
-    const { clsiserverid } = req.query
+    const { params, query } = validateReq(req, wordCountSchema)
+    const projectId = params.Project_id
+    const file = query.file || false
+    const { clsiserverid } = query
     const userId = CompileController._getUserIdForCompile(req)
 
     const body = await CompileManager.promises.wordCount(

+ 49 - 31
services/web/app/src/Features/Project/ProjectController.mjs

@@ -45,6 +45,7 @@ import TagsHandler from '../Tags/TagsHandler.js'
 import TutorialHandler from '../Tutorial/TutorialHandler.js'
 import UserUpdater from '../User/UserUpdater.js'
 import Modules from '../../infrastructure/Modules.js'
+import { z, zz, validateReq } from '../../infrastructure/Validation.js'
 import UserGetter from '../User/UserGetter.js'
 import { isStandaloneAiAddOnPlanCode } from '../Subscription/AiHelper.js'
 import SubscriptionController from '../Subscription/SubscriptionController.js'
@@ -55,6 +56,34 @@ const { ObjectId } = mongodb
  * @import { GetProjectsRequest, GetProjectsResponse, Project } from "./types"
  */
 
+const updateProjectAdminSettingsSchema = z.object({
+  params: z.object({
+    Project_id: zz.coercedObjectId(ObjectId),
+  }),
+  body: z.object({
+    publicAccessLevel: z
+      .enum(
+        [PublicAccessLevels.PRIVATE, PublicAccessLevels.TOKEN_BASED],
+        'unexpected access level'
+      )
+      .optional(),
+  }),
+})
+
+const updateProjectSettingsSchema = z.object({
+  params: z.object({
+    Project_id: zz.coercedObjectId(),
+  }),
+  body: z.object({
+    compiler: z.string().optional(),
+    imageName: z.string().optional(),
+    mainBibliographyDocId: zz.objectId().optional(),
+    name: z.string().optional(),
+    rootDocId: zz.objectId().optional(),
+    spellCheckLanguage: z.string().optional(),
+  }),
+})
+
 const _ProjectController = {
   _isInPercentageRollout(rolloutName, objectId, percentage) {
     if (Settings.bypassPercentageRollouts === true) {
@@ -67,38 +96,36 @@ const _ProjectController = {
   },
 
   async updateProjectSettings(req, res) {
-    const projectId = req.params.Project_id
+    const { params, body } = validateReq(req, updateProjectSettingsSchema)
+    const projectId = params.Project_id
 
-    if (req.body.compiler != null) {
-      await EditorController.promises.setCompiler(projectId, req.body.compiler)
+    if (body.compiler != null) {
+      await EditorController.promises.setCompiler(projectId, body.compiler)
     }
 
-    if (req.body.imageName != null) {
-      await EditorController.promises.setImageName(
-        projectId,
-        req.body.imageName
-      )
+    if (body.imageName != null) {
+      await EditorController.promises.setImageName(projectId, body.imageName)
     }
 
-    if (req.body.name != null) {
-      await EditorController.promises.renameProject(projectId, req.body.name)
+    if (body.name != null) {
+      await EditorController.promises.renameProject(projectId, body.name)
     }
 
-    if (req.body.spellCheckLanguage != null) {
+    if (body.spellCheckLanguage != null) {
       await EditorController.promises.setSpellCheckLanguage(
         projectId,
-        req.body.spellCheckLanguage
+        body.spellCheckLanguage
       )
     }
 
-    if (req.body.rootDocId != null) {
-      await EditorController.promises.setRootDoc(projectId, req.body.rootDocId)
+    if (body.rootDocId != null) {
+      await EditorController.promises.setRootDoc(projectId, body.rootDocId)
     }
 
-    if (req.body.mainBibliographyDocId != null) {
+    if (body.mainBibliographyDocId != null) {
       await EditorController.promises.setMainBibliographyDoc(
         projectId,
-        req.body.mainBibliographyDocId
+        body.mainBibliographyDocId
       )
     }
 
@@ -106,26 +133,17 @@ const _ProjectController = {
   },
 
   async updateProjectAdminSettings(req, res) {
-    const projectId = req.params.Project_id
+    const { params, body } = validateReq(req, updateProjectAdminSettingsSchema)
+    const projectId = params.Project_id
     const user = SessionManager.getSessionUser(req.session)
     if (!Features.hasFeature('link-sharing')) {
       return res.sendStatus(403) // return Forbidden if link sharing is not enabled
     }
-    const publicAccessLevel = req.body.publicAccessLevel
-    const publicAccessLevels = [
-      PublicAccessLevels.READ_ONLY,
-      PublicAccessLevels.READ_AND_WRITE,
-      PublicAccessLevels.PRIVATE,
-      PublicAccessLevels.TOKEN_BASED,
-    ]
-
-    if (
-      req.body.publicAccessLevel != null &&
-      publicAccessLevels.includes(publicAccessLevel)
-    ) {
+
+    if (body.publicAccessLevel != null) {
       await EditorController.promises.setPublicAccessLevel(
         projectId,
-        req.body.publicAccessLevel
+        body.publicAccessLevel
       )
 
       await ProjectAuditLogHandler.promises.addEntry(
@@ -133,7 +151,7 @@ const _ProjectController = {
         'toggle-access-level',
         user._id,
         req.ip,
-        { publicAccessLevel: req.body.publicAccessLevel, status: 'OK' }
+        { publicAccessLevel: body.publicAccessLevel, status: 'OK' }
       )
       res.sendStatus(204)
     } else {

+ 19 - 2
services/web/app/src/Features/Spelling/SpellingController.mjs

@@ -1,9 +1,25 @@
+// @ts-check
+
 import SessionManager from '../Authentication/SessionManager.js'
 import LearnedWordsManager from './LearnedWordsManager.js'
+import { z, validateReq } from '../../infrastructure/Validation.js'
+
+const learnSchema = z.object({
+  body: z.object({
+    word: z.string().min(1),
+  }),
+})
+
+const unlearnSchema = z.object({
+  body: z.object({
+    word: z.string().min(1),
+  }),
+})
 
 export default {
   learn(req, res, next) {
-    const { word } = req.body
+    const { body } = validateReq(req, learnSchema)
+    const { word } = body
     const userId = SessionManager.getLoggedInUserId(req.session)
     LearnedWordsManager.learnWord(userId, word, err => {
       if (err) return next(err)
@@ -12,7 +28,8 @@ export default {
   },
 
   unlearn(req, res, next) {
-    const { word } = req.body
+    const { body } = validateReq(req, unlearnSchema)
+    const { word } = body
     const userId = SessionManager.getLoggedInUserId(req.session)
     LearnedWordsManager.unlearnWord(userId, word, err => {
       if (err) return next(err)

+ 60 - 10
services/web/app/src/Features/Tags/TagsController.mjs

@@ -1,6 +1,7 @@
 import TagsHandler from './TagsHandler.js'
 import SessionManager from '../Authentication/SessionManager.js'
 import Errors from '../Errors/Errors.js'
+import { z, validateReq } from '../../infrastructure/Validation.js'
 import { expressify } from '@overleaf/promise-utils'
 
 async function _getTags(userId, _req, res) {
@@ -21,9 +22,17 @@ async function getAllTags(req, res) {
   await _getTags(userId, req, res)
 }
 
+const createTagSchema = z.object({
+  body: z.object({
+    name: z.string(),
+    color: z.string().optional(),
+  }),
+})
+
 async function createTag(req, res) {
+  const { body } = validateReq(req, createTagSchema)
+  const { name, color } = body
   const userId = SessionManager.getLoggedInUserId(req.session)
-  const { name, color } = req.body
   const tag = await TagsHandler.promises.createTag(userId, name, color)
   res.json(tag)
 }
@@ -35,10 +44,20 @@ async function addProjectToTag(req, res) {
   res.status(204).end()
 }
 
+const addProjectsToTagSchema = z.object({
+  params: z.object({
+    tagId: z.string(),
+  }),
+  body: z.object({
+    projectIds: z.string().array(),
+  }),
+})
+
 async function addProjectsToTag(req, res) {
+  const { params, body } = validateReq(req, addProjectsToTagSchema)
+  const { tagId } = params
+  const { projectIds } = body
   const userId = SessionManager.getLoggedInUserId(req.session)
-  const { tagId } = req.params
-  const { projectIds } = req.body
   await TagsHandler.promises.addProjectsToTag(userId, tagId, projectIds)
   res.status(204).end()
 }
@@ -50,10 +69,20 @@ async function removeProjectFromTag(req, res, next) {
   res.status(204).end()
 }
 
+const removeProjectsFromTagSchema = z.object({
+  params: z.object({
+    tagId: z.string(),
+  }),
+  body: z.object({
+    projectIds: z.string().array(),
+  }),
+})
+
 async function removeProjectsFromTag(req, res, next) {
+  const { params, body } = validateReq(req, removeProjectsFromTagSchema)
+  const { tagId } = params
+  const { projectIds } = body
   const userId = SessionManager.getLoggedInUserId(req.session)
-  const { tagId } = req.params
-  const { projectIds } = req.body
   await TagsHandler.promises.removeProjectsFromTag(userId, tagId, projectIds)
   res.status(204).end()
 }
@@ -65,10 +94,20 @@ async function deleteTag(req, res) {
   res.status(204).end()
 }
 
+const renameTagSchema = z.object({
+  params: z.object({
+    tagId: z.string(),
+  }),
+  body: z.object({
+    name: z.string(),
+  }),
+})
+
 async function renameTag(req, res) {
+  const { params, body } = validateReq(req, renameTagSchema)
   const userId = SessionManager.getLoggedInUserId(req.session)
-  const { tagId } = req.params
-  const name = req.body?.name
+  const { tagId } = params
+  const name = body.name
   if (!name) {
     return res.status(400).end()
   }
@@ -76,11 +115,22 @@ async function renameTag(req, res) {
   res.status(204).end()
 }
 
+const editTagSchema = z.object({
+  params: z.object({
+    tagId: z.string(),
+  }),
+  body: z.object({
+    name: z.string(),
+    color: z.string().optional(),
+  }),
+})
+
 async function editTag(req, res) {
+  const { params, body } = validateReq(req, editTagSchema)
+  const { tagId } = params
+  const name = body.name
+  const color = body.color
   const userId = SessionManager.getLoggedInUserId(req.session)
-  const { tagId } = req.params
-  const name = req.body?.name
-  const color = req.body?.color
   if (!name) {
     return res.status(400).end()
   }

+ 51 - 40
services/web/app/src/Features/User/UserController.mjs

@@ -8,6 +8,7 @@ import metrics from '@overleaf/metrics'
 import AuthenticationManager from '../Authentication/AuthenticationManager.js'
 import SessionManager from '../Authentication/SessionManager.js'
 import Features from '../../infrastructure/Features.js'
+import { z, validateReq } from '../../infrastructure/Validation.js'
 import UserAuditLogHandler from './UserAuditLogHandler.js'
 import UserSessionsManager from './UserSessionsManager.js'
 import UserUpdater from './UserUpdater.js'
@@ -328,7 +329,18 @@ async function unsubscribe(req, res, next) {
   })
 }
 
+const updateUserSettingsSchema = z.object({
+  body: z
+    .object({
+      first_name: z.string().max(255).nullish(),
+      last_name: z.string().max(255).nullish(),
+    })
+    .passthrough(),
+  // TODO: complete the schema and remove the passthrough
+})
+
 async function updateUserSettings(req, res, next) {
+  const { body } = validateReq(req, updateUserSettingsSchema)
   const userId = SessionManager.getLoggedInUserId(req.session)
   req.logger.addFields({ userId })
 
@@ -337,68 +349,67 @@ async function updateUserSettings(req, res, next) {
     throw new OError('problem updating user settings', { userId })
   }
 
-  if (req.body.first_name != null) {
-    user.first_name = req.body.first_name.trim()
+  if (body.first_name != null) {
+    user.first_name = body.first_name.trim()
   }
-  if (req.body.last_name != null) {
-    user.last_name = req.body.last_name.trim()
+  if (body.last_name != null) {
+    user.last_name = body.last_name.trim()
   }
-  if (req.body.role != null) {
-    user.role = req.body.role.trim()
+  if (body.role != null) {
+    user.role = body.role.trim()
   }
-  if (req.body.institution != null) {
-    user.institution = req.body.institution.trim()
+  if (body.institution != null) {
+    user.institution = body.institution.trim()
   }
-  if (req.body.mode != null) {
-    user.ace.mode = req.body.mode
+  if (body.mode != null) {
+    user.ace.mode = body.mode
   }
-  if (req.body.editorTheme != null) {
-    user.ace.theme = req.body.editorTheme
+  if (body.editorTheme != null) {
+    user.ace.theme = body.editorTheme
   }
-  if (req.body.overallTheme != null) {
-    user.ace.overallTheme = req.body.overallTheme
+  if (body.overallTheme != null) {
+    user.ace.overallTheme = body.overallTheme
   }
-  if (req.body.fontSize != null) {
-    user.ace.fontSize = req.body.fontSize
+  if (body.fontSize != null) {
+    user.ace.fontSize = body.fontSize
   }
-  if (req.body.autoComplete != null) {
-    user.ace.autoComplete = req.body.autoComplete
+  if (body.autoComplete != null) {
+    user.ace.autoComplete = body.autoComplete
   }
-  if (req.body.autoPairDelimiters != null) {
-    user.ace.autoPairDelimiters = req.body.autoPairDelimiters
+  if (body.autoPairDelimiters != null) {
+    user.ace.autoPairDelimiters = body.autoPairDelimiters
   }
-  if (req.body.spellCheckLanguage != null) {
-    user.ace.spellCheckLanguage = req.body.spellCheckLanguage
+  if (body.spellCheckLanguage != null) {
+    user.ace.spellCheckLanguage = body.spellCheckLanguage
   }
-  if (req.body.pdfViewer != null) {
-    user.ace.pdfViewer = req.body.pdfViewer
+  if (body.pdfViewer != null) {
+    user.ace.pdfViewer = body.pdfViewer
   }
-  if (req.body.syntaxValidation != null) {
-    user.ace.syntaxValidation = req.body.syntaxValidation
+  if (body.syntaxValidation != null) {
+    user.ace.syntaxValidation = body.syntaxValidation
   }
-  if (req.body.fontFamily != null) {
-    user.ace.fontFamily = req.body.fontFamily
+  if (body.fontFamily != null) {
+    user.ace.fontFamily = body.fontFamily
   }
-  if (req.body.lineHeight != null) {
-    user.ace.lineHeight = req.body.lineHeight
+  if (body.lineHeight != null) {
+    user.ace.lineHeight = body.lineHeight
   }
-  if (req.body.mathPreview != null) {
-    user.ace.mathPreview = req.body.mathPreview
+  if (body.mathPreview != null) {
+    user.ace.mathPreview = body.mathPreview
   }
-  if (req.body.breadcrumbs != null) {
-    user.ace.breadcrumbs = Boolean(req.body.breadcrumbs)
+  if (body.breadcrumbs != null) {
+    user.ace.breadcrumbs = Boolean(body.breadcrumbs)
   }
-  if (req.body.referencesSearchMode != null) {
-    const mode =
-      req.body.referencesSearchMode === 'simple' ? 'simple' : 'advanced'
+  if (body.referencesSearchMode != null) {
+    const mode = body.referencesSearchMode === 'simple' ? 'simple' : 'advanced'
     user.ace.referencesSearchMode = mode
   }
-  if (req.body.enableNewEditor != null) {
-    user.ace.enableNewEditor = Boolean(req.body.enableNewEditor)
+  if (body.enableNewEditor != null) {
+    user.ace.enableNewEditor = Boolean(body.enableNewEditor)
   }
   await user.save()
 
-  const newEmail = req.body.email?.trim().toLowerCase()
+  const newEmail = body.email?.trim().toLowerCase()
   if (
     newEmail == null ||
     newEmail === user.email ||

+ 0 - 54
services/web/app/src/router.mjs

@@ -58,12 +58,10 @@ import SystemMessageController from './Features/SystemMessages/SystemMessageCont
 import AnalyticsRegistrationSourceMiddleware from './Features/Analytics/AnalyticsRegistrationSourceMiddleware.js'
 import AnalyticsUTMTrackingMiddleware from './Features/Analytics/AnalyticsUTMTrackingMiddleware.mjs'
 import CaptchaMiddleware from './Features/Captcha/CaptchaMiddleware.mjs'
-import { Joi, validate } from './infrastructure/Validation.js'
 import UnsupportedBrowserMiddleware from './infrastructure/UnsupportedBrowserMiddleware.js'
 import logger from '@overleaf/logger'
 import _ from 'lodash'
 import { plainTextResponse } from './infrastructure/Response.js'
-import PublicAccessLevels from './Features/Authorization/PublicAccessLevels.js'
 import SocketDiagnostics from './Features/SocketDiagnostics/SocketDiagnostics.mjs'
 import ClsiCacheController from './Features/Compile/ClsiCacheController.mjs'
 import AsyncLocalStorage from './infrastructure/AsyncLocalStorage.js'
@@ -305,12 +303,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
   webRouter.post(
     '/user/settings',
     AuthenticationController.requireLogin(),
-    validate({
-      body: Joi.object({
-        first_name: Joi.string().allow(null, '').max(255),
-        last_name: Joi.string().allow(null, '').max(255),
-      }).unknown(),
-    }),
     UserController.updateUserSettings
   )
   webRouter.post(
@@ -565,13 +557,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
   )
   webRouter.post(
     '/project/:Project_id/settings',
-    validate({
-      body: Joi.object({
-        publicAccessLevel: Joi.string()
-          .valid(PublicAccessLevels.PRIVATE, PublicAccessLevels.TOKEN_BASED)
-          .optional(),
-      }),
-    }),
     AuthorizationMiddleware.ensureUserCanWriteProjectSettings,
     ProjectController.updateProjectSettings
   )
@@ -659,7 +644,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
 
   webRouter.delete(
     '/project/:Project_id/output',
-    validate({ query: { clsiserverid: Joi.string() } }),
     AuthorizationMiddleware.ensureUserCanReadProject,
     CompileController.deleteAuxFiles
   )
@@ -675,7 +659,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
   )
   webRouter.get(
     '/project/:Project_id/wordcount',
-    validate({ query: { clsiserverid: Joi.string() } }),
     AuthorizationMiddleware.ensureUserCanReadProject,
     CompileController.wordCount
   )
@@ -817,35 +800,18 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
     '/tag',
     AuthenticationController.requireLogin(),
     RateLimiterMiddleware.rateLimit(rateLimiters.createTag),
-    validate({
-      body: Joi.object({
-        name: Joi.string().required(),
-        color: Joi.string(),
-      }),
-    }),
     TagsController.createTag
   )
   webRouter.post(
     '/tag/:tagId/rename',
     AuthenticationController.requireLogin(),
     RateLimiterMiddleware.rateLimit(rateLimiters.renameTag),
-    validate({
-      body: Joi.object({
-        name: Joi.string().required(),
-      }),
-    }),
     TagsController.renameTag
   )
   webRouter.post(
     '/tag/:tagId/edit',
     AuthenticationController.requireLogin(),
     RateLimiterMiddleware.rateLimit(rateLimiters.renameTag),
-    validate({
-      body: Joi.object({
-        name: Joi.string().required(),
-        color: Joi.string(),
-      }),
-    }),
     TagsController.editTag
   )
   webRouter.delete(
@@ -864,11 +830,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
     '/tag/:tagId/projects',
     AuthenticationController.requireLogin(),
     RateLimiterMiddleware.rateLimit(rateLimiters.addProjectsToTag),
-    validate({
-      body: Joi.object({
-        projectIds: Joi.array().items(Joi.string()).required(),
-      }),
-    }),
     TagsController.addProjectsToTag
   )
   webRouter.delete(
@@ -881,11 +842,6 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
     '/tag/:tagId/projects/remove',
     AuthenticationController.requireLogin(),
     RateLimiterMiddleware.rateLimit(rateLimiters.removeProjectsFromTag),
-    validate({
-      body: Joi.object({
-        projectIds: Joi.array().items(Joi.string()).required(),
-      }),
-    }),
     TagsController.removeProjectsFromTag
   )
 
@@ -996,22 +952,12 @@ async function initialize(webRouter, privateApiRouter, publicApiRouter) {
 
   webRouter.post(
     '/spelling/learn',
-    validate({
-      body: Joi.object({
-        word: Joi.string().required(),
-      }),
-    }),
     AuthenticationController.requireLogin(),
     SpellingController.learn
   )
 
   webRouter.post(
     '/spelling/unlearn',
-    validate({
-      body: Joi.object({
-        word: Joi.string().required(),
-      }),
-    }),
     AuthenticationController.requireLogin(),
     SpellingController.unlearn
   )

+ 47 - 0
services/web/test/acceptance/src/ProjectCRUDTests.mjs

@@ -165,6 +165,53 @@ describe('Project CRUD', function () {
       expectObjectIdArrayEqual(trashedProject.trashed, [])
     })
   })
+
+  describe('ProjectAdminSettings', async function () {
+    it('publicAccessLevel can be set to private', async function () {
+      const { response } = await this.user.doRequest('POST', {
+        url: `/project/${this.projectId}/settings/admin`,
+        json: {
+          publicAccessLevel: 'private',
+        },
+      })
+      expect(response.statusCode).to.equal(204)
+      const project = await Project.findById(this.projectId).exec()
+      expect(project.publicAccesLevel).to.equal('private')
+    })
+    it('publicAccessLevel can be set to tokenBased', async function () {
+      await this.user.makePrivate(this.projectId)
+      const { response } = await this.user.doRequest('POST', {
+        url: `/project/${this.projectId}/settings/admin`,
+        json: {
+          publicAccessLevel: 'tokenBased',
+        },
+      })
+      expect(response.statusCode).to.equal(204)
+      const project = await Project.findById(this.projectId).exec()
+      expect(project.publicAccesLevel).to.equal('tokenBased')
+    })
+    it('returns a 400 when publicAccessLevel is set an unsupported access level', async function () {
+      await this.user.makePrivate(this.projectId)
+      const { response, body } = await this.user.doRequest('POST', {
+        url: `/project/${this.projectId}/settings/admin`,
+        json: {
+          publicAccessLevel: 'readOnly',
+        },
+      })
+      expect(response.statusCode).to.equal(400)
+      expect(body).to.include('Unexpected access level')
+      const project = await Project.findById(this.projectId).exec()
+      expect(project.publicAccesLevel).to.equal('private')
+    })
+    it('returns a 500 when no publicAccessLevel is provided', async function () {
+      const { response, body } = await this.user.doRequest('POST', {
+        url: `/project/${this.projectId}/settings/admin`,
+        json: {},
+      })
+      expect(response.statusCode).to.equal(500)
+      expect(body).to.equal('Internal Server Error')
+    })
+  })
 })
 
 function expectObjectIdArrayEqual(objectIdArray, stringArray) {

+ 10 - 1
services/web/test/unit/src/Compile/CompileControllerTests.js

@@ -97,6 +97,15 @@ describe('CompileController', function () {
             done() {}
           },
         }),
+        // TODO: remove this once we remove Joi/Celebrate
+        celebrate: (this.celebrate = {
+          celebrate: sinon.stub(),
+          errors: sinon.stub(),
+          Joi: {
+            any: sinon.stub(),
+            extend: sinon.stub(),
+          },
+        }),
         './CompileManager': this.CompileManager,
         '../User/UserGetter': this.UserGetter,
         './ClsiManager': this.ClsiManager,
@@ -118,7 +127,7 @@ describe('CompileController', function () {
         },
       },
     })
-    this.projectId = 'project-id'
+    this.projectId = 'abc123def456abc123def456'
     this.build_id = '18fbe9e7564-30dcb2f71250c690'
     this.next = sinon.stub()
     this.req = new MockRequest()

+ 26 - 9
services/web/test/unit/src/Project/ProjectController.test.mjs

@@ -505,6 +505,10 @@ describe('ProjectController', function () {
   })
 
   describe('updateProjectSettings', function () {
+    beforeEach(function (ctx) {
+      ctx.req.params.Project_id = ctx.project_id.toString()
+    })
+
     it('should update the name', async function (ctx) {
       await new Promise(resolve => {
         ctx.EditorController.promises.renameProject = sinon.stub().resolves()
@@ -570,7 +574,9 @@ describe('ProjectController', function () {
     it('should update the root doc', async function (ctx) {
       await new Promise(resolve => {
         ctx.EditorController.promises.setRootDoc = sinon.stub().resolves()
-        ctx.req.body = { rootDocId: (ctx.rootDocId = 'root-doc-id') }
+        ctx.req.body = {
+          rootDocId: (ctx.rootDocId = 'abc123def456abc123def456'),
+        }
         ctx.res.sendStatus = code => {
           ctx.EditorController.promises.setRootDoc
             .calledWith(ctx.project_id, ctx.rootDocId)
@@ -590,13 +596,14 @@ describe('ProjectController', function () {
       ctx.EditorController.promises.setPublicAccessLevel = sinon
         .stub()
         .resolves()
+      ctx.req.params.Project_id = ctx.project_id.toString()
       ctx.req.body = {
-        publicAccessLevel: 'readOnly',
+        publicAccessLevel: 'tokenBased',
       }
       await new Promise(resolve => {
         ctx.res.sendStatus = code => {
           ctx.EditorController.promises.setPublicAccessLevel
-            .calledWith(ctx.project_id, 'readOnly')
+            .calledWith(ctx.project_id, 'tokenBased')
             .should.equal(true)
           code.should.equal(204)
           resolve()
@@ -611,8 +618,9 @@ describe('ProjectController', function () {
         .stub()
         .resolves()
       ctx.req.body = {
-        publicAccessLevel: 'readOnly',
+        publicAccessLevel: 'tokenBased',
       }
+      ctx.req.params.Project_id = ctx.project_id.toString()
       await new Promise(resolve => {
         ctx.res.sendStatus = code => {
           ctx.ProjectAuditLogHandler.promises.addEntry
@@ -622,7 +630,7 @@ describe('ProjectController', function () {
               ctx.user._id,
               ctx.req.ip,
               {
-                publicAccessLevel: 'readOnly',
+                publicAccessLevel: 'tokenBased',
                 status: 'OK',
               }
             )
@@ -638,8 +646,9 @@ describe('ProjectController', function () {
       ctx.EditorController.promises.setPublicAccessLevel = sinon
         .stub()
         .resolves()
+      ctx.req.params.Project_id = ctx.project_id.toString()
       ctx.req.body = {
-        publicAccessLevel: 'readOnly',
+        publicAccessLevel: 'tokenBased',
       }
       await new Promise(resolve => {
         ctx.res.sendStatus = code => {
@@ -740,6 +749,7 @@ describe('ProjectController', function () {
     beforeEach(function (ctx) {
       ctx.newProjectName = 'my supper great new project'
       ctx.req.body.newProjectName = ctx.newProjectName
+      ctx.req.params.Project_id = ctx.project_id.toString()
     })
 
     it('should call the editor controller', async function (ctx) {
@@ -747,9 +757,16 @@ describe('ProjectController', function () {
         ctx.EditorController.promises.renameProject.resolves()
         ctx.res.sendStatus = code => {
           code.should.equal(200)
-          ctx.EditorController.promises.renameProject
-            .calledWith(ctx.project_id, ctx.newProjectName)
-            .should.equal(true)
+
+          expect(ctx.EditorController.promises.renameProject).to.have.been
+            .called
+          expect(
+            ctx.EditorController.promises.renameProject.args[0][0].toString()
+          ).to.equal(ctx.project_id.toString())
+          expect(
+            ctx.EditorController.promises.renameProject.args[0][1]
+          ).to.equal(ctx.newProjectName)
+
           resolve()
         }
         ctx.ProjectController.renameProject(ctx.req, ctx.res)

+ 7 - 15
services/web/test/unit/src/Tags/TagsController.test.mjs

@@ -1,5 +1,6 @@
-import { assert, vi } from 'vitest'
+import { assert, beforeEach, describe, it, vi } from 'vitest'
 import sinon from 'sinon'
+import { ZodError } from 'zod'
 
 const modulePath = '../../../../app/src/Features/Tags/TagsController.mjs'
 
@@ -198,20 +199,11 @@ describe('TagsController', function () {
       })
     })
 
-    it('without a name', async function (ctx) {
-      await new Promise(resolve => {
-        ctx.req.body = { name: undefined }
-        ctx.TagsController.renameTag(ctx.req, {
-          status: code => {
-            assert.equal(code, 400)
-            sinon.assert.notCalled(ctx.TagsHandler.promises.renameTag)
-            resolve()
-            return {
-              end: () => {},
-            }
-          },
-        })
-      })
+    it('without a name', function (ctx) {
+      ctx.req.body = { name: undefined }
+      ctx.TagsController.renameTag(ctx.req, ctx.res).should.be.rejectedWith(
+        ZodError
+      )
     })
   })