From 27170d90ecac17747f30fd8c42e312fc0743da25 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Mon, 29 Jun 2026 07:46:53 +0200 Subject: [PATCH] improved queries validation in the backend --- backend/src/factories/router.factory.js | 16 +- backend/src/helpers.js | 7 + backend/src/middlewares/validate-request.js | 58 +++ backend/src/routes/auth.js | 40 +- backend/src/routes/file.js | 102 +++-- backend/src/routes/projects.js | 17 +- backend/src/routes/publish.js | 5 +- backend/src/routes/tour_pages.js | 29 +- backend/src/routes/users.js | 5 + .../notifications/errors/validation.js | 4 +- backend/src/validators/request-schemas.js | 357 ++++++++++++++++++ backend/tests/request-validation.test.js | 129 +++++++ 12 files changed, 674 insertions(+), 95 deletions(-) create mode 100644 backend/src/middlewares/validate-request.js create mode 100644 backend/src/validators/request-schemas.js create mode 100644 backend/tests/request-validation.test.js diff --git a/backend/src/factories/router.factory.js b/backend/src/factories/router.factory.js index 259f23e..c5db8a2 100644 --- a/backend/src/factories/router.factory.js +++ b/backend/src/factories/router.factory.js @@ -8,6 +8,8 @@ const { const { checkCrudPermissions } = require('../middlewares/check-permissions'); const { parse } = require('json2csv'); const { logger } = require('../utils/logger'); +const { validateRequest } = require('../middlewares/validate-request'); +const { crud: crudSchemas } = require('../validators/request-schemas'); const DEFAULT_LIST_LIMIT = 50; const MAX_LIST_LIMIT = 1000; @@ -54,10 +56,13 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { const router = express.Router(); const permissionEntity = options.permissionEntity || entityName; + const validation = options.validation || {}; + const schemaFor = (name) => validation[name] || crudSchemas[name]; router.use(checkCrudPermissions(permissionEntity)); router.post( '/', + validateRequest(schemaFor('create')), wrapAsync(async (req, res) => { const referer = req.headers.referer || @@ -87,6 +92,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { router.put( '/:id', + validateRequest(schemaFor('update')), wrapAsync(async (req, res) => { assertRouteIdMatchesBody(req); await Service.update(req.body.data, req.params.id, req.currentUser); @@ -96,6 +102,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { router.delete( '/:id', + validateRequest(schemaFor('remove')), wrapAsync(async (req, res) => { await Service.remove(req.params.id, req.currentUser); res.status(200).send(true); @@ -104,6 +111,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { router.post( '/deleteByIds', + validateRequest(schemaFor('deleteByIds')), wrapAsync(async (req, res) => { await Service.deleteByIds(req.body.data, req.currentUser); res.status(200).send(true); @@ -112,6 +120,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { router.get( '/', + validateRequest(schemaFor('list')), wrapAsync(async (req, res) => { const filetype = req.query.filetype; const currentUser = req.currentUser; @@ -144,6 +153,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { router.get( '/count', + validateRequest(schemaFor('count')), wrapAsync(async (req, res) => { const currentUser = req.currentUser; const runtimeContext = req.runtimeContext; @@ -158,6 +168,7 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { router.get( '/autocomplete', + validateRequest(schemaFor('autocomplete')), wrapAsync(async (req, res) => { const limit = clampLimit(req.query.limit, { defaultLimit: 20, @@ -174,11 +185,8 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) { router.get( '/:id', + validateRequest(schemaFor('findOne')), wrapAsync(async (req, res) => { - if (!isUuidV4(req.params.id)) { - return res.status(400).send(`Invalid ${entityName} id`); - } - const runtimeContext = req.runtimeContext; const payload = await DBApi.findBy( { id: req.params.id }, diff --git a/backend/src/helpers.js b/backend/src/helpers.js index 475d403..24dc29e 100644 --- a/backend/src/helpers.js +++ b/backend/src/helpers.js @@ -13,6 +13,13 @@ module.exports = class Helpers { static commonErrorHandler(error, req, res, _next) { const statusCode = error.code || error.status; + if (error.isRequestValidation) { + return res.status(400).send({ + error: error.message, + details: error.details || [], + }); + } + if ([400, 401, 403, 404, 409, 422].includes(statusCode)) { return res.status(statusCode).send(error.message); } diff --git a/backend/src/middlewares/validate-request.js b/backend/src/middlewares/validate-request.js new file mode 100644 index 0000000..07a58d9 --- /dev/null +++ b/backend/src/middlewares/validate-request.js @@ -0,0 +1,58 @@ +const Joi = require('joi'); +const ValidationError = require('../services/notifications/errors/validation'); + +const VALID_REQUEST_PARTS = ['params', 'query', 'body']; + +function formatDetails(details = [], part) { + return details.map((detail) => ({ + path: [part, ...detail.path].join('.'), + message: detail.message, + type: detail.type, + })); +} + +function validateRequest(schemas) { + if (!schemas || typeof schemas !== 'object') { + throw new Error('validateRequest requires a schema map'); + } + + for (const part of Object.keys(schemas)) { + if (!VALID_REQUEST_PARTS.includes(part)) { + throw new Error(`Unsupported request validation part: ${part}`); + } + + if (!Joi.isSchema(schemas[part])) { + throw new Error( + `Request validation schema for ${part} must be a Joi schema`, + ); + } + } + + return function requestValidationMiddleware(req, _res, next) { + for (const part of VALID_REQUEST_PARTS) { + const schema = schemas[part]; + if (!schema) continue; + + const { value, error } = schema.validate(req[part], { + abortEarly: false, + convert: true, + stripUnknown: true, + }); + + if (error) { + return next( + new ValidationError('Invalid request', { + isRequestValidation: true, + details: formatDetails(error.details, part), + }), + ); + } + + req[part] = value; + } + + return next(); + }; +} + +module.exports = { validateRequest }; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 067c37a..f535f4c 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -11,6 +11,8 @@ const { authLimiter: signinLimiter, passwordResetLimiter, } = require('../middlewares/rateLimiter'); +const { validateRequest } = require('../middlewares/validate-request'); +const { auth: authSchemas } = require('../validators/request-schemas'); const router = express.Router(); @@ -108,6 +110,7 @@ function getRequestHost(req) { router.post( '/signin/local', signinLimiter, + validateRequest(authSchemas.signinLocal), wrapAsync(async (req, res) => { const payload = await AuthService.signin( req.body.email, @@ -157,6 +160,7 @@ router.get( router.put( '/password-reset', + validateRequest(authSchemas.passwordReset), wrapAsync(async (req, res) => { const payload = await AuthService.passwordReset( req.body.token, @@ -170,6 +174,7 @@ router.put( router.put( '/password-update', passport.authenticate('jwt', { session: false }), + validateRequest(authSchemas.passwordUpdate), wrapAsync(async (req, res) => { const payload = await AuthService.passwordUpdate( req.body.currentPassword, @@ -197,6 +202,7 @@ router.post( router.post( '/send-password-reset-email', passwordResetLimiter, + validateRequest(authSchemas.sendPasswordResetEmail), wrapAsync(async (req, res) => { const host = getRequestHost(req); await AuthService.sendPasswordResetEmail(req.body.email, 'register', host); @@ -208,6 +214,7 @@ router.post( router.put( '/profile', passport.authenticate('jwt', { session: false }), + validateRequest(authSchemas.profile), wrapAsync(async (req, res) => { if (!req.currentUser || !req.currentUser.id) { throw new ForbiddenError(); @@ -221,6 +228,7 @@ router.put( router.put( '/verify-email', + validateRequest(authSchemas.verifyEmail), wrapAsync(async (req, res) => { const payload = await AuthService.verifyEmail( req.body.token, @@ -236,12 +244,16 @@ router.get('/email-configured', (req, res) => { res.status(200).send(payload); }); -router.get('/signin/google', (req, res, next) => { - passport.authenticate('google', { - scope: ['profile', 'email'], - state: req.query.app, - })(req, res, next); -}); +router.get( + '/signin/google', + validateRequest(authSchemas.socialSignin), + (req, res, next) => { + passport.authenticate('google', { + scope: ['profile', 'email'], + state: req.query.app, + })(req, res, next); + }, +); router.get( '/signin/google/callback', @@ -255,12 +267,16 @@ router.get( }, ); -router.get('/signin/microsoft', (req, res, next) => { - passport.authenticate('microsoft', { - scope: ['https://graph.microsoft.com/user.read openid'], - state: req.query.app, - })(req, res, next); -}); +router.get( + '/signin/microsoft', + validateRequest(authSchemas.socialSignin), + (req, res, next) => { + passport.authenticate('microsoft', { + scope: ['https://graph.microsoft.com/user.read openid'], + state: req.query.app, + })(req, res, next); + }, +); router.get( '/signin/microsoft/callback', diff --git a/backend/src/routes/file.js b/backend/src/routes/file.js index 656fc9f..7dcf074 100644 --- a/backend/src/routes/file.js +++ b/backend/src/routes/file.js @@ -4,6 +4,9 @@ const bodyParser = require('body-parser'); const services = require('../services/file/'); const { isValidPath, createErrorResponse } = require('../services/file'); const { logger } = require('../utils/logger'); +const { commonErrorHandler } = require('../helpers'); +const { validateRequest } = require('../middlewares/validate-request'); +const { file: fileSchemas } = require('../validators/request-schemas'); const router = express.Router(); @@ -22,72 +25,49 @@ router.get('/download', (req, res) => { }); // POST /api/file/presign - Generate presigned URLs for multiple assets -router.post('/presign', jsonParser, async (req, res) => { - const log = req.log || logger; - const { urls } = req.body || {}; +router.post( + '/presign', + jsonParser, + validateRequest(fileSchemas.presign), + async (req, res) => { + const log = req.log || logger; + const { urls } = req.body || {}; - if (!Array.isArray(urls) || urls.length === 0) { - return res - .status(400) - .json(createErrorResponse('urls array required', 'MISSING_URLS')); - } - - if (urls.length > 50) { - return res - .status(400) - .json( - createErrorResponse('Maximum 50 URLs per request', 'TOO_MANY_URLS'), + // Validate paths for security (no traversal, no protocols) + const unsafeUrls = urls.filter((url) => !isValidPath(url)); + if (unsafeUrls.length > 0) { + log.warn({ unsafeUrls }, 'Presign request with invalid paths rejected'); + return res.status(400).json( + createErrorResponse('Invalid file paths detected', 'INVALID_PATH', { + invalidPaths: unsafeUrls, + }), ); - } + } - // Validate that all URLs are non-empty strings - const invalidUrls = urls.filter( - (url) => typeof url !== 'string' || !url.trim(), - ); - if (invalidUrls.length > 0) { - return res - .status(400) - .json( - createErrorResponse( - 'All URLs must be non-empty strings', - 'INVALID_URL_FORMAT', - ), + try { + const presignedUrls = await services.generatePresignedUrls(urls); + res.json({ presignedUrls }); + } catch (error) { + log.error( + { err: error, urlCount: urls.length }, + 'Failed to generate presigned URLs', ); - } - - // Validate paths for security (no traversal, no protocols) - const unsafeUrls = urls.filter((url) => !isValidPath(url)); - if (unsafeUrls.length > 0) { - log.warn({ unsafeUrls }, 'Presign request with invalid paths rejected'); - return res.status(400).json( - createErrorResponse('Invalid file paths detected', 'INVALID_PATH', { - invalidPaths: unsafeUrls, - }), - ); - } - - try { - const presignedUrls = await services.generatePresignedUrls(urls); - res.json({ presignedUrls }); - } catch (error) { - log.error( - { err: error, urlCount: urls.length }, - 'Failed to generate presigned URLs', - ); - res - .status(500) - .json( - createErrorResponse( - 'Failed to generate presigned URLs', - 'PRESIGN_ERROR', - ), - ); - } -}); + res + .status(500) + .json( + createErrorResponse( + 'Failed to generate presigned URLs', + 'PRESIGN_ERROR', + ), + ); + } + }, +); router.post( '/upload/:table/:field', passport.authenticate('jwt', { session: false }), + validateRequest(fileSchemas.upload), (req, res) => { const fileName = `${req.params.table}/${req.params.field}`; @@ -99,6 +79,7 @@ router.post( '/upload-sessions/init', passport.authenticate('jwt', { session: false }), jsonParser, + validateRequest(fileSchemas.initUploadSession), (req, res) => { services.initUploadSession(req, res); }, @@ -107,6 +88,7 @@ router.post( router.get( '/upload-sessions/:sessionId', passport.authenticate('jwt', { session: false }), + validateRequest(fileSchemas.session), (req, res) => { services.getUploadSession(req, res); }, @@ -116,6 +98,7 @@ router.get( router.put( '/upload-sessions/:sessionId/chunks/:chunkIndex', passport.authenticate('jwt', { session: false }), + validateRequest(fileSchemas.chunk), (req, res) => { services.uploadChunk(req, res); }, @@ -125,9 +108,12 @@ router.put( router.post( '/upload-sessions/:sessionId/finalize', passport.authenticate('jwt', { session: false }), + validateRequest(fileSchemas.session), (req, res) => { services.finalizeUploadSession(req, res); }, ); +router.use('/', commonErrorHandler); + module.exports = router; diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js index d0fefc0..8f32a6d 100644 --- a/backend/src/routes/projects.js +++ b/backend/src/routes/projects.js @@ -1,7 +1,9 @@ -const { createEntityRouter, isUuidV4 } = require('../factories/router.factory'); +const { createEntityRouter } = require('../factories/router.factory'); const ProjectsService = require('../services/projects'); const ProjectsDBApi = require('../db/api/projects'); -const { wrapAsync } = require('../helpers'); +const { wrapAsync, commonErrorHandler } = require('../helpers'); +const { validateRequest } = require('../middlewares/validate-request'); +const { projects: projectSchemas } = require('../validators/request-schemas'); // Create base router with factory (includes all standard CRUD endpoints) const router = createEntityRouter('projects', ProjectsService, ProjectsDBApi, { @@ -15,16 +17,17 @@ const router = createEntityRouter('projects', ProjectsService, ProjectsDBApi, { 'favicon_url', 'og_image_url', ], + validation: { + create: projectSchemas.create, + update: projectSchemas.update, + }, }); // Custom endpoint: Clone project router.post( '/:id/clone', + validateRequest(projectSchemas.clone), wrapAsync(async (req, res) => { - if (!isUuidV4(req.params.id)) { - return res.status(400).send('Invalid project id'); - } - const payload = await ProjectsService.cloneFromProject( req.params.id, req.currentUser, @@ -33,4 +36,6 @@ router.post( }), ); +router.use('/', commonErrorHandler); + module.exports = router; diff --git a/backend/src/routes/publish.js b/backend/src/routes/publish.js index bdd2cc4..300fe09 100644 --- a/backend/src/routes/publish.js +++ b/backend/src/routes/publish.js @@ -2,6 +2,8 @@ const express = require('express'); const PublishService = require('../services/publish'); const wrapAsync = require('../helpers').wrapAsync; const { checkCrudPermissions } = require('../middlewares/check-permissions'); +const { validateRequest } = require('../middlewares/validate-request'); +const { publish: publishSchemas } = require('../validators/request-schemas'); const router = express.Router(); @@ -18,7 +20,7 @@ const publishHandler = wrapAsync(async (req, res) => { res.status(200).send(result); }); -router.post('/', publishHandler); +router.post('/', validateRequest(publishSchemas.publish), publishHandler); /** * @swagger @@ -49,6 +51,7 @@ router.post('/', publishHandler); */ router.post( '/save-to-stage', + validateRequest(publishSchemas.saveToStage), wrapAsync(async (req, res) => { const { projectId } = req.body; const result = await PublishService.saveToStage(projectId, req.currentUser); diff --git a/backend/src/routes/tour_pages.js b/backend/src/routes/tour_pages.js index 902ca34..598ddde 100644 --- a/backend/src/routes/tour_pages.js +++ b/backend/src/routes/tour_pages.js @@ -4,12 +4,16 @@ const Tour_pagesDBApi = require('../db/api/tour_pages'); const { wrapAsync, commonErrorHandler, - isUuidV4, assertRouteIdMatchesBody, } = require('../helpers'); const { checkCrudPermissions } = require('../middlewares/check-permissions'); const { parse } = require('json2csv'); const { logger } = require('../utils/logger'); +const { validateRequest } = require('../middlewares/validate-request'); +const { + crud: crudSchemas, + tourPages: tourPageSchemas, +} = require('../validators/request-schemas'); /** * @swagger @@ -165,6 +169,7 @@ router.use(checkCrudPermissions('tour_pages')); // POST - Create router.post( '/', + validateRequest(tourPageSchemas.create), wrapAsync(async (req, res) => { const referer = req.headers.referer || @@ -196,6 +201,7 @@ router.post( // POST - Reorder pages within a project/environment router.post( '/reorder', + validateRequest(tourPageSchemas.reorder), wrapAsync(async (req, res) => { const payload = await Tour_pagesService.reorder( req.body.data, @@ -208,11 +214,8 @@ router.post( // POST - Duplicate a dev page within a project router.post( '/:id/duplicate', + validateRequest(tourPageSchemas.duplicate), wrapAsync(async (req, res) => { - if (!isUuidV4(req.params.id)) { - return res.status(400).send('Invalid tour_pages id'); - } - const payload = await Tour_pagesService.duplicatePage( req.params.id, req.body.data, @@ -225,6 +228,7 @@ router.post( // PUT - Update router.put( '/:id', + validateRequest(tourPageSchemas.update), wrapAsync(async (req, res) => { assertRouteIdMatchesBody(req); await Tour_pagesService.update( @@ -239,6 +243,7 @@ router.put( // DELETE - Remove router.delete( '/:id', + validateRequest(crudSchemas.remove), wrapAsync(async (req, res) => { await Tour_pagesService.remove(req.params.id, req.currentUser); res.status(200).send(true); @@ -248,6 +253,7 @@ router.delete( // POST - Delete by IDs router.post( '/deleteByIds', + validateRequest(crudSchemas.deleteByIds), wrapAsync(async (req, res) => { await Tour_pagesService.deleteByIds(req.body.data, req.currentUser); res.status(200).send(true); @@ -257,6 +263,7 @@ router.post( // GET - List all (with reverseVideoUrl population) router.get( '/', + validateRequest(crudSchemas.list), wrapAsync(async (req, res) => { const filetype = req.query.filetype; const currentUser = req.currentUser; @@ -297,13 +304,10 @@ router.get( */ router.post( '/reverse-video-status', + validateRequest(tourPageSchemas.reverseVideoStatus), wrapAsync(async (req, res) => { const { storageKeys } = req.body; - if (!Array.isArray(storageKeys)) { - return res.status(400).send({ error: 'storageKeys must be an array' }); - } - const status = await Tour_pagesService.checkReverseVideoStatus(storageKeys); res.status(200).send(status); }), @@ -312,6 +316,7 @@ router.post( // GET - Count router.get( '/count', + validateRequest(crudSchemas.count), wrapAsync(async (req, res) => { const currentUser = req.currentUser; const runtimeContext = req.runtimeContext; @@ -327,6 +332,7 @@ router.get( // GET - Autocomplete router.get( '/autocomplete', + validateRequest(crudSchemas.autocomplete), wrapAsync(async (req, res) => { const payload = await Tour_pagesDBApi.findAllAutocomplete( req.query.query, @@ -340,11 +346,8 @@ router.get( // GET - Single item (with reverseVideoUrl population) router.get( '/:id', + validateRequest(crudSchemas.findOne), wrapAsync(async (req, res) => { - if (!isUuidV4(req.params.id)) { - return res.status(400).send('Invalid tour_pages id'); - } - const runtimeContext = req.runtimeContext; let payload = await Tour_pagesDBApi.findBy( { id: req.params.id }, diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 0c105fe..9c6e177 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -2,11 +2,16 @@ const { createEntityRouter } = require('../factories/router.factory'); const UsersService = require('../services/users'); const UsersDBApi = require('../db/api/users'); const { wrapAsync } = require('../helpers'); +const { users: userSchemas } = require('../validators/request-schemas'); // Create base router with factory (includes all standard CRUD endpoints) const router = createEntityRouter('users', UsersService, UsersDBApi, { permissionEntity: 'users', csvFields: ['id', 'firstName', 'lastName', 'phoneNumber', 'email'], + validation: { + create: userSchemas.create, + update: userSchemas.update, + }, }); // Override GET /:id to remove password from response diff --git a/backend/src/services/notifications/errors/validation.js b/backend/src/services/notifications/errors/validation.js index fc65457..0ef973b 100644 --- a/backend/src/services/notifications/errors/validation.js +++ b/backend/src/services/notifications/errors/validation.js @@ -1,7 +1,7 @@ const { getNotification } = require('../helpers'); module.exports = class ValidationError extends Error { - constructor(messageCode) { + constructor(messageCode, options = {}) { // getNotification returns the translated message if key exists, // or the key itself as the message if not found // This allows both notification keys and plain string messages @@ -11,5 +11,7 @@ module.exports = class ValidationError extends Error { super(message); this.code = 400; + this.details = options.details; + this.isRequestValidation = Boolean(options.isRequestValidation); } }; diff --git a/backend/src/validators/request-schemas.js b/backend/src/validators/request-schemas.js new file mode 100644 index 0000000..ace3f34 --- /dev/null +++ b/backend/src/validators/request-schemas.js @@ -0,0 +1,357 @@ +const Joi = require('joi'); + +const uuid = Joi.string().guid({ version: ['uuidv4'] }); +const idParam = Joi.object({ id: uuid.required() }).required(); +const emptyQuery = Joi.object().max(0); +const entityId = Joi.alternatives().try( + uuid, + Joi.object({ + id: uuid.optional(), + value: uuid.optional(), + }) + .or('id', 'value') + .unknown(true), +); + +const sortDirection = Joi.string().valid('ASC', 'DESC', 'asc', 'desc'); + +const listQuery = Joi.object({ + limit: Joi.number().integer().min(1).max(1000), + page: Joi.number().integer().min(1), + field: Joi.string() + .trim() + .max(128) + .pattern(/^[A-Za-z0-9_.$]+$/), + sort: sortDirection, + filetype: Joi.string().valid('csv'), +}) + .unknown(true) + .required(); + +const countQuery = listQuery; + +const autocompleteQuery = Joi.object({ + query: Joi.string().allow('').max(255), + limit: Joi.number().integer().min(1).max(50), + offset: Joi.number().integer().min(0), +}) + .unknown(false) + .required(); + +const dataEnvelope = Joi.object({ + data: Joi.object().required().unknown(true), +}) + .unknown(false) + .required(); + +const updateDataEnvelope = Joi.object({ + id: uuid.optional(), + data: Joi.object({ + id: uuid.optional(), + }) + .required() + .unknown(true), +}) + .unknown(false) + .required(); + +const deleteByIdsBody = Joi.object({ + data: Joi.array().items(uuid.required()).min(1).required(), +}) + .unknown(false) + .required(); + +const optionalSlug = Joi.string() + .trim() + .max(255) + .pattern(/^[a-z0-9_-]+$/i); + +const fileUrl = Joi.string().trim().min(1).max(4096); + +const userData = Joi.object({ + id: uuid.optional(), + firstName: Joi.string().allow('', null).max(255), + lastName: Joi.string().allow('', null).max(255), + phoneNumber: Joi.string().allow('', null).max(255), + email: Joi.string().trim().email().allow(null), + disabled: Joi.boolean(), + password: Joi.string().allow('', null).max(1024), + emailVerified: Joi.boolean(), + emailVerificationToken: Joi.string().allow('', null).max(1024), + emailVerificationTokenExpiresAt: Joi.date().allow(null), + passwordResetToken: Joi.string().allow('', null).max(1024), + passwordResetTokenExpiresAt: Joi.date().allow(null), + provider: Joi.string().allow('', null).max(255), + importHash: Joi.string().allow('', null).max(255), + app_role: entityId.allow(null), + custom_permissions: Joi.array().items(entityId), + allowed_private_production_project_ids: Joi.array().items(entityId), + avatar: Joi.any(), +}).unknown(true); + +const userCreateData = userData.keys({ + email: Joi.string().trim().email().required(), +}); + +const projectData = Joi.object({ + id: uuid.optional(), + name: Joi.string().trim().min(1).max(255), + slug: optionalSlug, + description: Joi.string().allow('', null), + logo_url: Joi.string().allow('', null).max(4096), + favicon_url: Joi.string().allow('', null).max(4096), + og_image_url: Joi.string().allow('', null).max(4096), + design_width: Joi.number().integer().min(1).max(20000).allow(null), + design_height: Joi.number().integer().min(1).max(20000).allow(null), + production_presentation_visibility: Joi.string().valid('public', 'private'), + importHash: Joi.string().allow('', null).max(255), +}).unknown(true); + +const projectCreateData = projectData.keys({ + name: Joi.string().trim().min(1).max(255).required(), + slug: optionalSlug.required(), +}); + +const uiSchema = Joi.alternatives().try( + Joi.object({ elements: Joi.array().default([]) }).unknown(true), + Joi.string().allow('', null), + Joi.allow(null), +); + +const tourPageData = Joi.object({ + id: uuid.optional(), + project: uuid.optional(), + projectId: uuid.optional(), + project_id: uuid.optional(), + environment: Joi.string().valid('dev', 'stage', 'production'), + source_key: Joi.string().allow('', null).max(4096), + name: Joi.string().trim().min(1).max(255), + slug: optionalSlug, + sort_order: Joi.number().integer().min(0), + background_image_url: Joi.string().allow('', null).max(4096), + background_video_url: Joi.string().allow('', null).max(4096), + background_embed_url: Joi.string().allow('', null).max(8192), + background_audio_url: Joi.string().allow('', null).max(4096), + background_audio_autoplay: Joi.boolean(), + background_audio_loop: Joi.boolean(), + background_audio_start_time: Joi.number().min(0).allow(null), + background_audio_end_time: Joi.number().min(0).allow(null), + background_loop: Joi.boolean(), + background_video_autoplay: Joi.boolean(), + background_video_loop: Joi.boolean(), + background_video_muted: Joi.boolean(), + background_video_start_time: Joi.number().min(0).allow(null), + background_video_end_time: Joi.number().min(0).allow(null), + design_width: Joi.number().integer().min(1).max(20000).allow(null), + design_height: Joi.number().integer().min(1).max(20000).allow(null), + requires_auth: Joi.boolean(), + ui_schema_json: uiSchema, + global_ui_controls_settings_json: Joi.alternatives().try( + Joi.object().unknown(true), + Joi.string().allow('', null), + Joi.allow(null), + ), + importHash: Joi.string().allow('', null).max(255), +}).unknown(true); + +const tourPageCreateData = tourPageData + .keys({ + name: Joi.string().trim().min(1).max(255).required(), + slug: optionalSlug.required(), + }) + .or('project', 'projectId', 'project_id'); + +function envelopeForData(dataSchema, { requireData = true } = {}) { + return Joi.object({ + id: uuid.optional(), + data: requireData ? dataSchema.required() : dataSchema, + }) + .unknown(false) + .required(); +} + +module.exports = { + auth: { + signinLocal: { + body: Joi.object({ + email: Joi.string().trim().email().required(), + password: Joi.string().required(), + }) + .unknown(false) + .required(), + }, + passwordReset: { + body: Joi.object({ + token: Joi.string().trim().required(), + password: Joi.string().required(), + }) + .unknown(false) + .required(), + }, + passwordUpdate: { + body: Joi.object({ + currentPassword: Joi.string().required(), + newPassword: Joi.string().required(), + }) + .unknown(false) + .required(), + }, + sendPasswordResetEmail: { + body: Joi.object({ + email: Joi.string().trim().email().required(), + }) + .unknown(false) + .required(), + }, + profile: { + body: Joi.object({ + profile: Joi.object().required().unknown(true), + }) + .unknown(false) + .required(), + }, + verifyEmail: { + body: Joi.object({ + token: Joi.string().trim().required(), + }) + .unknown(false) + .required(), + }, + socialSignin: { + query: Joi.object({ + app: Joi.string().allow('').max(255), + }) + .unknown(false) + .required(), + }, + }, + crud: { + create: { body: dataEnvelope }, + update: { params: idParam, body: updateDataEnvelope }, + remove: { params: idParam }, + deleteByIds: { body: deleteByIdsBody }, + list: { query: listQuery }, + count: { query: countQuery }, + autocomplete: { query: autocompleteQuery }, + findOne: { params: idParam }, + }, + file: { + presign: { + body: Joi.object({ + urls: Joi.array().items(fileUrl.required()).min(1).max(50).required(), + }) + .unknown(false) + .required(), + }, + upload: { + params: Joi.object({ + table: Joi.string() + .trim() + .max(128) + .pattern(/^[A-Za-z0-9_-]+$/) + .required(), + field: Joi.string() + .trim() + .max(128) + .pattern(/^[A-Za-z0-9_-]+$/) + .required(), + }).required(), + }, + initUploadSession: { + body: Joi.object({ + folder: Joi.string().trim().min(1).max(512).required(), + filename: Joi.string().trim().min(1).max(255).required(), + totalChunks: Joi.number().integer().min(1).max(10000).required(), + size: Joi.number().min(0).required(), + contentType: Joi.string().trim().allow('').max(255), + }) + .unknown(false) + .required(), + }, + session: { + params: Joi.object({ + sessionId: uuid.required(), + }).required(), + }, + chunk: { + params: Joi.object({ + sessionId: uuid.required(), + chunkIndex: Joi.number().integer().min(0).max(9999).required(), + }).required(), + }, + }, + publish: { + publish: { + body: Joi.object({ + projectId: uuid.required(), + title: Joi.string().allow('', null).max(255), + description: Joi.string().allow('', null).max(2000), + }) + .unknown(false) + .required(), + }, + saveToStage: { + body: Joi.object({ + projectId: uuid.required(), + }) + .unknown(false) + .required(), + }, + }, + projects: { + create: { body: envelopeForData(projectCreateData) }, + update: { params: idParam, body: envelopeForData(projectData) }, + clone: { params: idParam }, + }, + tourPages: { + create: { body: envelopeForData(tourPageCreateData) }, + update: { params: idParam, body: envelopeForData(tourPageData) }, + reorder: { + body: Joi.object({ + data: Joi.object({ + project: uuid.optional(), + projectId: uuid.optional(), + environment: Joi.string().valid('dev').default('dev'), + orderedPageIds: Joi.array().items(uuid.required()).min(1).required(), + }) + .or('project', 'projectId') + .required() + .unknown(false), + }) + .unknown(false) + .required(), + }, + duplicate: { + params: idParam, + body: Joi.object({ + data: Joi.object({ + project: uuid.optional(), + projectId: uuid.optional(), + environment: Joi.string().valid('dev').default('dev'), + name: Joi.string().trim().allow('').max(255), + slug: optionalSlug, + }) + .unknown(false) + .default({}), + }) + .unknown(false) + .required(), + }, + reverseVideoStatus: { + body: Joi.object({ + storageKeys: Joi.array() + .items(fileUrl.required()) + .min(1) + .max(200) + .required(), + }) + .unknown(false) + .required(), + }, + }, + users: { + create: { body: envelopeForData(userCreateData) }, + update: { params: idParam, body: envelopeForData(userData) }, + }, + emptyQuery, +}; diff --git a/backend/tests/request-validation.test.js b/backend/tests/request-validation.test.js new file mode 100644 index 0000000..dbb11b7 --- /dev/null +++ b/backend/tests/request-validation.test.js @@ -0,0 +1,129 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Joi = require('joi'); + +const { validateRequest } = require('../src/middlewares/validate-request'); +const { commonErrorHandler } = require('../src/helpers'); +const { + crud, + projects, + tourPages, + users, +} = require('../src/validators/request-schemas'); + +function runMiddleware(middleware, req) { + return new Promise((resolve) => { + middleware(req, {}, (error) => resolve(error || null)); + }); +} + +test('validateRequest applies converted sanitized values to request parts', async () => { + const req = { + query: { + limit: '25', + page: '2', + field: 'createdAt', + sort: 'DESC', + name: 'Lobby', + }, + }; + + const error = await runMiddleware(validateRequest(crud.list), req); + + assert.equal(error, null); + assert.equal(req.query.limit, 25); + assert.equal(req.query.page, 2); + assert.equal(req.query.name, 'Lobby'); +}); + +test('validateRequest returns structured request validation error details', async () => { + const req = { + body: { + data: ['not-object'], + }, + }; + + const error = await runMiddleware(validateRequest(crud.create), req); + + assert.equal(error.code, 400); + assert.equal(error.isRequestValidation, true); + assert.equal(error.details[0].path, 'body.data'); +}); + +test('commonErrorHandler sends request validation errors as JSON', () => { + const error = { + code: 400, + message: 'Invalid request', + isRequestValidation: true, + details: [{ path: 'body.email', message: 'email is required' }], + }; + const res = { + statusCode: null, + payload: null, + status(code) { + this.statusCode = code; + return this; + }, + send(payload) { + this.payload = payload; + return this; + }, + }; + + commonErrorHandler(error, {}, res, () => {}); + + assert.equal(res.statusCode, 400); + assert.deepEqual(res.payload, { + error: 'Invalid request', + details: [{ path: 'body.email', message: 'email is required' }], + }); +}); + +test('user update schema preserves omitted permissions fields', async () => { + const req = { + params: { id: '094121b9-c567-469a-b256-ba221b7fd5d6' }, + body: { + data: { + firstName: 'Admin', + }, + }, + }; + + const error = await runMiddleware(validateRequest(users.update), req); + + assert.equal(error, null); + assert.equal( + Object.prototype.hasOwnProperty.call(req.body.data, 'custom_permissions'), + false, + ); + assert.equal( + Object.prototype.hasOwnProperty.call( + req.body.data, + 'allowed_private_production_project_ids', + ), + false, + ); +}); + +test('create schemas reject missing fields required before service layer', async () => { + const userError = await runMiddleware(validateRequest(users.create), { + body: { data: { firstName: 'No Email' } }, + }); + const projectError = await runMiddleware(validateRequest(projects.create), { + body: { data: { name: 'No Slug' } }, + }); + const tourPageError = await runMiddleware(validateRequest(tourPages.create), { + body: { data: { name: 'No Project', slug: 'no-project' } }, + }); + + assert.equal(userError.isRequestValidation, true); + assert.equal(projectError.isRequestValidation, true); + assert.equal(tourPageError.isRequestValidation, true); +}); + +test('validateRequest rejects invalid schema maps during route setup', () => { + assert.throws( + () => validateRequest({ body: Joi.object(), cookies: Joi.object() }), + /Unsupported request validation part/, + ); +});