improved queries validation in the backend

This commit is contained in:
Dmitri 2026-06-29 07:46:53 +02:00
parent 7778d34925
commit 27170d90ec
12 changed files with 674 additions and 95 deletions

View File

@ -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 },

View File

@ -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);
}

View File

@ -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 };

View File

@ -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',

View File

@ -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;

View File

@ -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;

View File

@ -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);

View File

@ -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 },

View File

@ -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

View File

@ -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);
}
};

View File

@ -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,
};

View File

@ -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/,
);
});