diff --git a/src/middleware/validate.js b/src/middleware/validate.js new file mode 100644 index 0000000..4e5f0e4 --- /dev/null +++ b/src/middleware/validate.js @@ -0,0 +1,35 @@ +'use strict'; + +const AppError = require('../errors/AppError'); + +function flattenZodIssues(error) { + return error.issues.reduce((fields, issue) => { + const path = issue.path.length > 0 ? issue.path.join('.') : '_root'; + fields[path] = fields[path] || []; + fields[path].push(issue.message); + return fields; + }, {}); +} + +function validate(schema, source = 'body') { + return (req, _res, next) => { + const result = schema.safeParse(req[source] ?? {}); + if (!result.success) { + return next(new AppError('VALIDATION_ERROR', 'Validation failed', 400, { + fields: flattenZodIssues(result.error), + })); + } + + req.validated = { + ...(req.validated || {}), + [source]: result.data, + }; + req[source] = result.data; + return next(); + }; +} + +module.exports = { + flattenZodIssues, + validate, +}; diff --git a/src/routes/airdrops.js b/src/routes/airdrops.js index 1253cd5..f5c1472 100644 --- a/src/routes/airdrops.js +++ b/src/routes/airdrops.js @@ -7,6 +7,30 @@ const config = require('../config'); const airdropsService = require('../services/airdrops'); const logger = require('../logger'); const AppError = require('../errors/AppError'); +const { flattenZodIssues, validate } = require('../middleware/validate'); +const { + airdropCreateBodySchema, + airdropRecipientsBodySchema, + airdropUpdateBodySchema, + paginationQuerySchema, + recipientsSchema, + routeIdParamsSchema, +} = require('../validation/schemas'); + +const router = express.Router(); +const upload = multer(); +const validateRouteIdParams = validate(routeIdParamsSchema, 'params'); +const validatePaginationQuery = validate(paginationQuerySchema, 'query'); +const validateRecipientBody = validate(airdropRecipientsBodySchema); + +function validateWithCurrentLedger(schemaFactory) { + return async (req, res, next) => { + try { + const currentLedger = await airdropsService.getCurrentLedger(); + return validate(schemaFactory(currentLedger))(req, res, next); + } catch (err) { + logger.error('Airdrop validation error', { error: err.message }); + return next(err); const buildRateLimit = require('../middleware/rateLimit'); const { StrKey } = require('stellar-sdk'); @@ -83,26 +107,17 @@ function validateAirdropCreate(body, currentLedger) { if (recipientSet.has(r.address)) { return `recipient ${i}: duplicate address ${r.address}`; } - recipientSet.add(r.address); - if (typeof r.amount !== 'number' || r.amount <= 0) { - return `recipient ${i}: amount must be a positive number`; - } - sum += r.amount; - } - - if (recipients.length > 0 && sum !== total_amount) { - return `sum of recipient amounts (${sum}) must equal total_amount (${total_amount})`; - } - - return null; + }; } -function validateAirdropUpdate(body, currentLedger) { - const { expiry_ledger } = body; - if (expiry_ledger !== undefined && (typeof expiry_ledger !== 'number' || expiry_ledger <= currentLedger)) { - return `expiry_ledger must be greater than current ledger (${currentLedger})`; +function parseRecipients(recipients, next) { + const result = recipientsSchema.safeParse(recipients); + if (!result.success) { + return next(new AppError('VALIDATION_ERROR', 'Validation failed', 400, { + fields: flattenZodIssues(result.error), + })); } - return null; + return result.data; } async function parseCSV(buffer) { @@ -132,15 +147,10 @@ async function parseCSV(buffer) { return results; } +router.post('/airdrops', validateWithCurrentLedger(airdropCreateBodySchema), async (req, res, next) => { router.post('/airdrops', createAirdropLimit, async (req, res, next) => { try { - const currentLedger = await airdropsService.getCurrentLedger(); - const validationError = validateAirdropCreate(req.body, currentLedger); - if (validationError) { - return next(new AppError('VALIDATION_ERROR', validationError, 400)); - } - - const airdrop = await airdropsService.create(req.body); + const airdrop = await airdropsService.create(req.validated.body); return res.status(201).json(airdrop); } catch (err) { logger.error('Create airdrop error', { error: err.message }); @@ -148,10 +158,9 @@ router.post('/airdrops', createAirdropLimit, async (req, res, next) => { } }); -router.get('/airdrops', async (req, res, next) => { +router.get('/airdrops', validatePaginationQuery, async (req, res, next) => { try { - const page = parseInt(req.query.page, 10) || 1; - const limit = parseInt(req.query.limit, 10) || 20; + const { page, limit } = req.validated.query; const result = await airdropsService.list(page, limit); return res.json(result); } catch (err) { @@ -160,7 +169,7 @@ router.get('/airdrops', async (req, res, next) => { } }); -router.get('/airdrops/:id', async (req, res, next) => { +router.get('/airdrops/:id', validateRouteIdParams, async (req, res, next) => { try { const airdrop = await airdropsService.get(req.params.id); if (!airdrop) { @@ -173,15 +182,9 @@ router.get('/airdrops/:id', async (req, res, next) => { } }); -router.patch('/airdrops/:id', async (req, res, next) => { +router.patch('/airdrops/:id', validateRouteIdParams, validateWithCurrentLedger(airdropUpdateBodySchema), async (req, res, next) => { try { - const currentLedger = await airdropsService.getCurrentLedger(); - const validationError = validateAirdropUpdate(req.body, currentLedger); - if (validationError) { - return next(new AppError('VALIDATION_ERROR', validationError, 400)); - } - - const airdrop = await airdropsService.update(req.params.id, req.body); + const airdrop = await airdropsService.update(req.params.id, req.validated.body); if (!airdrop) { return next(new AppError('NOT_FOUND', 'Airdrop not found', 404)); } @@ -192,7 +195,7 @@ router.patch('/airdrops/:id', async (req, res, next) => { } }); -router.delete('/airdrops/:id', async (req, res, next) => { +router.delete('/airdrops/:id', validateRouteIdParams, async (req, res, next) => { try { const deleted = await airdropsService.remove(req.params.id); if (!deleted) { @@ -205,7 +208,7 @@ router.delete('/airdrops/:id', async (req, res, next) => { } }); -router.post('/airdrops/:id/cancel', async (req, res, next) => { +router.post('/airdrops/:id/cancel', validateRouteIdParams, async (req, res, next) => { try { const airdrop = await airdropsService.cancel(req.params.id); if (!airdrop) { @@ -218,6 +221,7 @@ router.post('/airdrops/:id/cancel', async (req, res, next) => { } }); +router.post('/airdrops/:id/recipients', validateRouteIdParams, upload.single('file'), validateRecipientBody, async (req, res, next) => { router.post('/airdrops/:id/recipients', addRecipientsLimit, uploadRecipientsFile, async (req, res, next) => { try { const airdrop = await airdropsService.get(req.params.id); @@ -228,8 +232,10 @@ router.post('/airdrops/:id/recipients', addRecipientsLimit, uploadRecipientsFile let recipients = []; if (req.file) { recipients = await parseCSV(req.file.buffer); - } else if (req.body.recipients) { - recipients = Array.isArray(req.body.recipients) ? req.body.recipients : JSON.parse(req.body.recipients); + recipients = parseRecipients(recipients, next); + if (!recipients) return undefined; + } else if (req.validated.body.recipients) { + recipients = req.validated.body.recipients; } else { return next(new AppError('VALIDATION_ERROR', 'recipients or file is required', 400)); } @@ -263,15 +269,14 @@ router.post('/airdrops/:id/recipients', addRecipientsLimit, uploadRecipientsFile } }); -router.get('/airdrops/:id/recipients', async (req, res, next) => { +router.get('/airdrops/:id/recipients', validateRouteIdParams, validatePaginationQuery, async (req, res, next) => { try { const airdrop = await airdropsService.get(req.params.id); if (!airdrop) { return next(new AppError('NOT_FOUND', 'Airdrop not found', 404)); } - const page = parseInt(req.query.page, 10) || 1; - const limit = parseInt(req.query.limit, 10) || 20; + const { page, limit } = req.validated.query; const result = await airdropsService.listRecipients(req.params.id, page, limit); return res.json(result); } catch (err) { diff --git a/src/routes/alerts.js b/src/routes/alerts.js index 4453722..8f483d8 100644 --- a/src/routes/alerts.js +++ b/src/routes/alerts.js @@ -1,52 +1,18 @@ const express = require('express'); +const { validate } = require('../middleware/validate'); const alertsService = require('../services/alerts'); const logger = require('../logger'); const AppError = require('../errors/AppError'); +const { alertCreateBodySchema, paginationQuerySchema, routeIdParamsSchema } = require('../validation/schemas'); const router = express.Router(); - -const VALID_TYPES = ['above', 'below', 'change_pct']; +const validateRouteIdParams = validate(routeIdParamsSchema, 'params'); const { parsePagination, paginateResponse } = require('../utils/paginate'); -function isValidUrl(str) { +router.post('/alerts', validate(alertCreateBodySchema), async (req, res, next) => { try { - const u = new URL(str); - return u.protocol === 'http:' || u.protocol === 'https:'; - } catch { - return false; - } -} - -function validateCreateBody(body) { - const { asset, type, threshold_usd, webhook_url, webhook_secret } = body; - - if (!asset || typeof asset !== 'string' || !/^[A-Z0-9]{1,12}$/i.test(asset)) { - return 'asset must be 1-12 alphanumeric characters'; - } - if (!VALID_TYPES.includes(type)) { - return `type must be one of: ${VALID_TYPES.join(', ')}`; - } - if (typeof threshold_usd !== 'number' || threshold_usd <= 0) { - return 'threshold_usd must be a positive number'; - } - if (!webhook_url || !isValidUrl(webhook_url)) { - return 'webhook_url must be a valid URL'; - } - if (!webhook_secret || typeof webhook_secret !== 'string' || webhook_secret.length < 8) { - return 'webhook_secret must be at least 8 characters'; - } - return null; -} - -router.post('/alerts', async (req, res, next) => { - try { - const validationError = validateCreateBody(req.body); - if (validationError) { - return next(new AppError('VALIDATION_ERROR', validationError, 400)); - } - - const alert = await alertsService.create(req.body); + const alert = await alertsService.create(req.validated.body); return res.status(201).json(alert); } catch (err) { logger.error('Create alert error', { error: err.message }); @@ -54,7 +20,7 @@ router.post('/alerts', async (req, res, next) => { } }); -router.get('/alerts', async (req, res, next) => { +router.get('/alerts', validate(paginationQuerySchema, 'query'), async (req, res, next) => { try { const pagination = parsePagination(req.query); const result = await alertsService.listPaginated(pagination); @@ -70,7 +36,7 @@ router.get('/alerts', async (req, res, next) => { } }); -router.delete('/alerts/:id', async (req, res, next) => { +router.delete('/alerts/:id', validateRouteIdParams, async (req, res, next) => { try { const deleted = await alertsService.remove(req.params.id); if (!deleted) { diff --git a/src/routes/keys.js b/src/routes/keys.js index 12c72c6..bdfedfb 100644 --- a/src/routes/keys.js +++ b/src/routes/keys.js @@ -1,24 +1,16 @@ const express = require('express'); const { requireApiKey } = require('../middleware/auth'); +const { validate } = require('../middleware/validate'); const apiKeys = require('../services/apiKeys'); const logger = require('../logger'); const AppError = require('../errors/AppError'); +const { keyCreateBodySchema, routeIdParamsSchema } = require('../validation/schemas'); const router = express.Router(); +const validateRouteIdParams = validate(routeIdParamsSchema, 'params'); router.use('/keys', requireApiKey({ scopes: ['admin'] })); -function validateScopes(scopes) { - if (scopes === undefined) return null; - if (!Array.isArray(scopes) || scopes.length === 0) { - return 'scopes must be a non-empty array of strings'; - } - if (scopes.some((scope) => typeof scope !== 'string' || !scope.trim())) { - return 'scopes must be a non-empty array of strings'; - } - return null; -} - router.get('/keys', async (_req, res, next) => { try { const keys = await apiKeys.listKeys(); @@ -29,22 +21,13 @@ router.get('/keys', async (_req, res, next) => { } }); -router.post('/keys', async (req, res, next) => { +router.post('/keys', validate(keyCreateBodySchema), async (req, res, next) => { try { - const { label, scopes } = req.body || {}; - const normalizedLabel = typeof label === 'string' ? label.trim() : ''; - if (!normalizedLabel || normalizedLabel.length > 80) { - return next(new AppError('VALIDATION_ERROR', 'label must be a non-empty string up to 80 characters', 400)); - } - - const scopeError = validateScopes(scopes); - if (scopeError) { - return next(new AppError('VALIDATION_ERROR', scopeError, 400)); - } + const { label, scopes } = req.validated.body; const created = await apiKeys.createKey({ - label: normalizedLabel, - scopes: scopes ? scopes.map((scope) => scope.trim()) : ['default'], + label, + scopes: scopes || ['default'], }); return res.status(201).json(created); } catch (err) { @@ -53,7 +36,7 @@ router.post('/keys', async (req, res, next) => { } }); -router.delete('/keys/:id', async (req, res, next) => { +router.delete('/keys/:id', validateRouteIdParams, async (req, res, next) => { try { const deleted = await apiKeys.revokeKey(req.params.id); if (!deleted) { diff --git a/src/routes/prices.js b/src/routes/prices.js index ec2b7b1..7914843 100644 --- a/src/routes/prices.js +++ b/src/routes/prices.js @@ -1,12 +1,16 @@ const express = require('express'); const config = require('../config'); const { requireApiKey } = require('../middleware/auth'); +const { validate } = require('../middleware/validate'); const buildRateLimit = require('../middleware/rateLimit'); const priceOracle = require('../services/priceOracle'); const AppError = require('../errors/AppError'); +const { priceParamsSchema, priceQuerySchema } = require('../validation/schemas'); const router = express.Router(); +const validatePriceParams = validate(priceParamsSchema, 'params'); +const validatePriceQuery = validate(priceQuerySchema, 'query'); const priceLimit = buildRateLimit({ windowSeconds: config.priceRateLimit.windowSeconds, max: config.priceRateLimit.max, @@ -21,35 +25,10 @@ function validateAssetCode(assetCode) { return /^[A-Z0-9]+$/.test(assetCode); } -function validateIssuer(issuer) { - if (!issuer) return true; - return /^G[A-Z0-9]{55}$/.test(issuer); -} - -function validatePriceRequest(assetCode, issuer) { - if (!validateAssetCode(assetCode)) { - throw new AppError('VALIDATION_ERROR', 'Asset code must be 1-12 uppercase alphanumeric characters', 400, { - field: 'assetCode', - received: assetCode, - constraint: 'regex', - }); - } - - if (!validateIssuer(issuer)) { - throw new AppError('VALIDATION_ERROR', 'Issuer must be a valid Stellar address (G...)', 400, { - field: 'issuer', - received: issuer, - constraint: 'stellar_public_key', - }); - } -} - -router.get('/prices/:asset_code', async (req, res, next) => { +router.get('/prices/:asset_code', validatePriceParams, validatePriceQuery, async (req, res, next) => { try { - const { asset_code } = req.params; + const { asset_code: normalizedCode } = req.validated.params; const { issuer } = req.query; - const normalizedCode = asset_code.toUpperCase(); - validatePriceRequest(normalizedCode, issuer); const priceData = await priceOracle.getPrice(normalizedCode, issuer || null); @@ -63,12 +42,10 @@ router.get('/prices/:asset_code', async (req, res, next) => { } }); -router.get('/prices/:asset_code/refresh', requireApiKey(), async (req, res, next) => { +router.get('/prices/:asset_code/refresh', requireApiKey(), validatePriceParams, validatePriceQuery, async (req, res, next) => { try { - const { asset_code } = req.params; + const { asset_code: normalizedCode } = req.validated.params; const { issuer } = req.query; - const normalizedCode = asset_code.toUpperCase(); - validatePriceRequest(normalizedCode, issuer); const priceData = await priceOracle.fetchFreshPrice(normalizedCode, issuer || null); if (priceData.price_usd === null) { diff --git a/src/routes/webhooks.js b/src/routes/webhooks.js index c7b8040..6c60511 100644 --- a/src/routes/webhooks.js +++ b/src/routes/webhooks.js @@ -2,15 +2,22 @@ const express = require('express'); const config = require('../config'); +const { validate } = require('../middleware/validate'); const webhookRepo = require('../repositories/webhookRepository'); const deliveryRepo = require('../repositories/deliveryRepository'); const dispatcher = require('../services/webhookDispatcher'); const signatureService = require('../services/webhookSignature'); -const events = require('../services/webhookEvents'); const buildRateLimit = require('../middleware/rateLimit'); const AppError = require('../errors/AppError'); +const { + routeIdParamsSchema, + webhookCreateBodySchema, + webhookDeliveriesQuerySchema, + webhookPatchBodySchema, +} = require('../validation/schemas'); const router = express.Router(); +const validateRouteIdParams = validate(routeIdParamsSchema, 'params'); const manageLimit = buildRateLimit({ windowSeconds: config.webhooks.rateLimit.windowSeconds, @@ -26,25 +33,6 @@ const testLimit = buildRateLimit({ router.use('/webhooks', manageLimit); -function isValidUrl(str) { - try { - const u = new URL(str); - return u.protocol === 'http:' || u.protocol === 'https:'; - } catch { - return false; - } -} - -function validateCreate(body) { - if (!body || typeof body !== 'object') return 'body must be an object'; - const { url, events: subscribedEvents, secret, description } = body; - if (!url || !isValidUrl(url)) return 'url must be a valid http(s) URL'; - if (!events.isValidSubscription(subscribedEvents)) return `events must be a non-empty array of: ${events.ALL_EVENTS.join(', ')} or "*"`; - if (secret !== undefined && (typeof secret !== 'string' || secret.length < 16)) return 'secret must be a string of at least 16 characters'; - if (description !== undefined && typeof description !== 'string') return 'description must be a string'; - return null; -} - function publicView(webhook) { if (!webhook) return null; return { @@ -59,17 +47,15 @@ function publicView(webhook) { }; } -router.post('/webhooks', async (req, res, next) => { +router.post('/webhooks', validate(webhookCreateBodySchema), async (req, res, next) => { try { - const validationError = validateCreate(req.body); - if (validationError) return next(new AppError('VALIDATION_ERROR', validationError, 400)); - - const secret = req.body.secret || signatureService.generateSecret(); + const body = req.validated.body; + const secret = body.secret || signatureService.generateSecret(); const webhook = await webhookRepo.create({ - url: req.body.url, - events: req.body.events, + url: body.url, + events: body.events, secret, - description: req.body.description, + description: body.description, }); return res.status(201).json({ @@ -91,7 +77,7 @@ router.get('/webhooks', async (_req, res, next) => { } }); -router.get('/webhooks/:id', async (req, res, next) => { +router.get('/webhooks/:id', validateRouteIdParams, async (req, res, next) => { try { const webhook = await webhookRepo.findById(req.params.id); if (!webhook) return next(new AppError('NOT_FOUND', 'Webhook not found', 404)); @@ -101,26 +87,9 @@ router.get('/webhooks/:id', async (req, res, next) => { } }); -router.patch('/webhooks/:id', async (req, res, next) => { +router.patch('/webhooks/:id', validateRouteIdParams, validate(webhookPatchBodySchema), async (req, res, next) => { try { - const patch = {}; - if (req.body.url !== undefined) { - if (!isValidUrl(req.body.url)) return next(new AppError('VALIDATION_ERROR', 'url must be a valid http(s) URL', 400)); - patch.url = req.body.url; - } - if (req.body.events !== undefined) { - if (!events.isValidSubscription(req.body.events)) return next(new AppError('VALIDATION_ERROR', 'events invalid', 400)); - patch.events = req.body.events; - } - if (req.body.active !== undefined) { - if (typeof req.body.active !== 'boolean') return next(new AppError('VALIDATION_ERROR', 'active must be boolean', 400)); - patch.active = req.body.active; - } - if (req.body.description !== undefined) { - if (typeof req.body.description !== 'string') return next(new AppError('VALIDATION_ERROR', 'description must be a string', 400)); - patch.description = req.body.description; - } - + const patch = req.validated.body; const updated = await webhookRepo.update(req.params.id, patch); if (!updated) return next(new AppError('NOT_FOUND', 'Webhook not found', 404)); return res.json(publicView(updated)); @@ -129,7 +98,7 @@ router.patch('/webhooks/:id', async (req, res, next) => { } }); -router.delete('/webhooks/:id', async (req, res, next) => { +router.delete('/webhooks/:id', validateRouteIdParams, async (req, res, next) => { try { const deleted = await webhookRepo.remove(req.params.id); if (!deleted) return next(new AppError('NOT_FOUND', 'Webhook not found', 404)); @@ -139,7 +108,7 @@ router.delete('/webhooks/:id', async (req, res, next) => { } }); -router.post('/webhooks/:id/test', testLimit, async (req, res, next) => { +router.post('/webhooks/:id/test', validateRouteIdParams, testLimit, async (req, res, next) => { try { const delivery = await dispatcher.sendTest(req.params.id); if (!delivery) return next(new AppError('NOT_FOUND', 'Webhook not found', 404)); @@ -155,11 +124,11 @@ router.post('/webhooks/:id/test', testLimit, async (req, res, next) => { } }); -router.get('/webhooks/:id/deliveries', async (req, res, next) => { +router.get('/webhooks/:id/deliveries', validateRouteIdParams, validate(webhookDeliveriesQuerySchema, 'query'), async (req, res, next) => { try { const webhook = await webhookRepo.findById(req.params.id); if (!webhook) return next(new AppError('NOT_FOUND', 'Webhook not found', 404)); - const limit = Math.min(parseInt(req.query.limit, 10) || 50, 100); + const { limit } = req.validated.query; const deliveries = await deliveryRepo.listByWebhook(req.params.id, limit); return res.json({ deliveries }); } catch (err) { diff --git a/src/validation/schemas.js b/src/validation/schemas.js new file mode 100644 index 0000000..afc89e6 --- /dev/null +++ b/src/validation/schemas.js @@ -0,0 +1,195 @@ +'use strict'; + +const { z } = require('zod'); +const webhookEvents = require('../services/webhookEvents'); + +const stellarPublicKeySchema = z + .string() + .regex(/^G[A-Z0-9]{55}$/, 'Must be a valid Stellar public key'); + +const assetCodeSchema = z + .string() + .trim() + .min(1, 'Asset code is required') + .max(12, 'Asset code must be 12 characters or fewer') + .regex(/^[A-Za-z0-9]+$/, 'Asset code must be alphanumeric') + .transform((value) => value.toUpperCase()); + +const optionalIssuerSchema = z.preprocess( + (value) => (value === '' ? undefined : value), + stellarPublicKeySchema.optional() +); + +const paginationQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + +const routeIdParamsSchema = z.object({ + id: z + .string() + .trim() + .min(1) + .max(128) + .regex(/^[A-Za-z0-9_-]+$/, 'ID can contain only letters, numbers, underscores, and hyphens'), +}); + +const httpUrlSchema = z + .string() + .trim() + .refine((value) => { + try { + const url = new URL(value); + return ['http:', 'https:'].includes(url.protocol); + } catch { + return false; + } + }, { + message: 'Must be an http(s) URL', + }); + +const priceParamsSchema = z.object({ + asset_code: assetCodeSchema, +}); + +const priceQuerySchema = z.object({ + issuer: optionalIssuerSchema, +}); + +const keyCreateBodySchema = z.object({ + label: z.string().trim().min(1).max(80), + scopes: z + .array(z.string().trim().min(1)) + .nonempty() + .optional(), +}); + +const alertCreateBodySchema = z.object({ + asset: assetCodeSchema, + type: z.enum(['above', 'below', 'change_pct']), + threshold_usd: z.number().positive(), + webhook_url: httpUrlSchema, + webhook_secret: z.string().min(8), + repeat: z.boolean().optional(), +}); + +const webhookSubscriptionSchema = z + .array(z.string()) + .nonempty() + .refine((value) => webhookEvents.isValidSubscription(value), { + message: `Must contain ${webhookEvents.WILDCARD} or known events`, + }); + +const webhookCreateBodySchema = z.object({ + url: httpUrlSchema, + events: webhookSubscriptionSchema, + secret: z.string().min(16).optional(), + description: z.string().optional(), +}); + +const webhookPatchBodySchema = z.object({ + url: httpUrlSchema.optional(), + events: webhookSubscriptionSchema.optional(), + secret: z.string().min(16).optional(), + active: z.boolean().optional(), + description: z.string().optional(), +}); + +const webhookDeliveriesQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).default(50), +}); + +const recipientSchema = z.object({ + address: stellarPublicKeySchema, + amount: z.number().positive(), +}); + +const recipientsSchema = z + .array(recipientSchema) + .max(10000, 'recipients cannot exceed 10,000') + .superRefine((recipients, ctx) => { + const seen = new Set(); + recipients.forEach((recipient, index) => { + if (seen.has(recipient.address)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [index, 'address'], + message: `recipient ${index}: duplicate address ${recipient.address}`, + }); + } + seen.add(recipient.address); + }); + }); + +function expiryLedgerSchema(currentLedger) { + return z + .number() + .int() + .gt(currentLedger, `expiry_ledger must be greater than current ledger (${currentLedger})`); +} + +function airdropCreateBodySchema(currentLedger) { + return z + .object({ + name: z.string().trim().min(1), + description: z.string().optional(), + asset: assetCodeSchema, + asset_issuer: stellarPublicKeySchema, + total_amount: z.number().positive(), + expiry_ledger: expiryLedgerSchema(currentLedger), + recipients: recipientsSchema.optional().default([]), + }) + .superRefine((body, ctx) => { + if (body.recipients.length === 0) return; + + const total = body.recipients.reduce((sum, recipient) => sum + recipient.amount, 0); + if (total !== body.total_amount) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['recipients'], + message: `sum of recipient amounts (${total}) must equal total_amount (${body.total_amount})`, + }); + } + }); +} + +function airdropUpdateBodySchema(currentLedger) { + return z.object({ + name: z.string().trim().min(1).optional(), + description: z.string().optional(), + expiry_ledger: expiryLedgerSchema(currentLedger).optional(), + }); +} + +const airdropRecipientsBodySchema = z.object({ + recipients: z.preprocess((value) => { + if (typeof value !== 'string') return value; + + try { + return JSON.parse(value); + } catch { + return value; + } + }, recipientsSchema.optional()), +}); + +module.exports = { + airdropCreateBodySchema, + airdropRecipientsBodySchema, + airdropUpdateBodySchema, + alertCreateBodySchema, + assetCodeSchema, + httpUrlSchema, + keyCreateBodySchema, + optionalIssuerSchema, + paginationQuerySchema, + priceParamsSchema, + priceQuerySchema, + recipientsSchema, + routeIdParamsSchema, + stellarPublicKeySchema, + webhookCreateBodySchema, + webhookDeliveriesQuerySchema, + webhookPatchBodySchema, + webhookSubscriptionSchema, +}; diff --git a/test/airdrops.test.js b/test/airdrops.test.js index 7740a25..932de22 100644 --- a/test/airdrops.test.js +++ b/test/airdrops.test.js @@ -209,7 +209,13 @@ describe('POST /api/v1/airdrops', () => { recipients: [{ address: validAddress1, amount: 50 }], }); expect(response.status).toBe(400); - expect(response.body.error.message).toContain('sum of recipient amounts'); + expect(response.body.error).toMatchObject({ + code: 'VALIDATION_ERROR', + message: 'Validation failed', + }); + expect(response.body.error.details.fields.recipients).toEqual( + expect.arrayContaining([expect.stringContaining('sum of recipient amounts')]) + ); }); test('rate limits repeated airdrop creation attempts', async () => { diff --git a/test/auth.test.js b/test/auth.test.js index d9f2424..80346d0 100644 --- a/test/auth.test.js +++ b/test/auth.test.js @@ -245,7 +245,8 @@ describe('API key management routes', () => { expect(res.status).toBe(400); expect(res.body.error).toMatchObject({ code: 'VALIDATION_ERROR', - message: 'label must be a non-empty string up to 80 characters', + message: 'Validation failed', }); + expect(res.body.error.details.fields.label).toBeDefined(); }); }); diff --git a/test/prices.test.js b/test/prices.test.js index ba88dfc..e9604c7 100644 --- a/test/prices.test.js +++ b/test/prices.test.js @@ -352,8 +352,11 @@ describe('GET /api/v1/prices/:asset_code/refresh', () => { expect(res.status).toBe(400); expect(res.body.error).toMatchObject({ code: 'VALIDATION_ERROR', - message: 'Asset code must be 1-12 uppercase alphanumeric characters', + message: 'Validation failed', }); + expect(res.body.error.details.fields.asset_code).toEqual( + expect.arrayContaining(['Asset code must be alphanumeric']) + ); expect(mockGetPrice).not.toHaveBeenCalled(); }); @@ -365,8 +368,11 @@ describe('GET /api/v1/prices/:asset_code/refresh', () => { expect(res.status).toBe(400); expect(res.body.error).toMatchObject({ code: 'VALIDATION_ERROR', - message: 'Issuer must be a valid Stellar address (G...)', + message: 'Validation failed', }); + expect(res.body.error.details.fields.issuer).toEqual( + expect.arrayContaining(['Must be a valid Stellar public key']) + ); expect(mockGetPrice).not.toHaveBeenCalled(); }); diff --git a/test/validate.test.js b/test/validate.test.js new file mode 100644 index 0000000..eae4354 --- /dev/null +++ b/test/validate.test.js @@ -0,0 +1,57 @@ +'use strict'; + +const express = require('express'); +const request = require('supertest'); +const { z } = require('zod'); + +const { validate } = require('../src/middleware/validate'); +const { errorHandler } = require('../src/middleware/errorHandler'); + +function buildApp(schema, source = 'body') { + const app = express(); + app.use(express.json()); + app.post('/validate/:id?', validate(schema, source), (req, res) => { + res.json({ validated: req.validated[source] }); + }); + app.use(errorHandler); + return app; +} + +describe('validate middleware', () => { + test('stores parsed body data on req.validated', async () => { + const app = buildApp(z.object({ + count: z.coerce.number().int().min(1), + })); + + const res = await request(app).post('/validate').send({ count: '3' }); + + expect(res.status).toBe(200); + expect(res.body.validated).toEqual({ count: 3 }); + }); + + test('returns a validation AppError with flattened field details', async () => { + const app = buildApp(z.object({ + count: z.coerce.number().int().min(1), + })); + + const res = await request(app).post('/validate').send({ count: 0 }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatchObject({ + code: 'VALIDATION_ERROR', + message: 'Validation failed', + }); + expect(res.body.error.details.fields.count).toEqual(expect.any(Array)); + }); + + test('validates route params before the handler runs', async () => { + const app = buildApp(z.object({ + id: z.string().regex(/^ok_[a-z]+$/), + }), 'params'); + + const res = await request(app).post('/validate/bad-id').send({}); + + expect(res.status).toBe(400); + expect(res.body.error.details.fields.id).toEqual(expect.any(Array)); + }); +});