diff --git a/src/middlewares/creator-param.middleware.test.ts b/src/middlewares/creator-param.middleware.test.ts new file mode 100644 index 0000000..8e583e5 --- /dev/null +++ b/src/middlewares/creator-param.middleware.test.ts @@ -0,0 +1,151 @@ +// src/middlewares/creator-param.middleware.test.ts +import { Request, Response, NextFunction } from 'express'; +import { validateCreatorParam } from './creator-param.middleware'; + +function makeReq(params: Record = {}): Request { + return { params } as unknown as Request; +} + +function makeRes(): Partial { + const res: Partial = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +describe('validateCreatorParam middleware', () => { + describe('valid params — calls next()', () => { + it('passes a standard alphanumeric handle', () => { + const next = makeNext(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'alice123' }), + makeRes() as Response, + next as NextFunction + ); + expect(next).toHaveBeenCalledWith(); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('passes a UUID-style id param', () => { + const next = makeNext(); + validateCreatorParam('id')( + makeReq({ id: 'abc123-def456' }), + makeRes() as Response, + next as NextFunction + ); + expect(next).toHaveBeenCalledWith(); + }); + + it('passes a handle with underscores and hyphens', () => { + const next = makeNext(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'jazz_king-99' }), + makeRes() as Response, + next as NextFunction + ); + expect(next).toHaveBeenCalledWith(); + }); + + it('passes a single character param', () => { + const next = makeNext(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'a' }), + makeRes() as Response, + next as NextFunction + ); + expect(next).toHaveBeenCalledWith(); + }); + }); + + describe('invalid params — returns 400 and does not call next()', () => { + it('rejects a missing param key', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({}), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects a param with special characters', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'bad param!' }), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects a param exceeding 128 characters', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'a'.repeat(129) }), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects a param with path traversal characters', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: '../admin' }), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('rejects a param with spaces', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'hello world' }), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('error response includes the param field name', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('creatorId')( + makeReq({ creatorId: 'bad!' }), + res as Response, + next as NextFunction + ); + const body = (res.json as jest.Mock).mock.calls[0][0]; + expect(body.error.details[0].field).toBe('creatorId'); + }); + + it('works with a different param name (id)', () => { + const next = makeNext(); + const res = makeRes(); + validateCreatorParam('id')( + makeReq({ id: 'bad@id' }), + res as Response, + next as NextFunction + ); + expect(next).not.toHaveBeenCalled(); + const body = (res.json as jest.Mock).mock.calls[0][0]; + expect(body.error.details[0].field).toBe('id'); + }); + }); +}); \ No newline at end of file diff --git a/src/middlewares/creator-param.middleware.ts b/src/middlewares/creator-param.middleware.ts new file mode 100644 index 0000000..0f9fe1d --- /dev/null +++ b/src/middlewares/creator-param.middleware.ts @@ -0,0 +1,52 @@ +// src/middlewares/creator-param.middleware.ts +import { Request, Response, NextFunction } from 'express'; +import { sendValidationError } from '../utils/api-response.utils'; + +/** + * Allowed characters for a creator param: + * - Alphanumeric, hyphens, underscores (handles and UUIDs) + * - 1–128 characters + */ +const CREATOR_PARAM_PATTERN = /^[a-zA-Z0-9_-]{1,128}$/; + +/** + * Middleware factory that validates a named creator route param before + * the handler runs. + * + * - Rejects missing, non-string, or malformed values with a 400 response. + * - Calls next() for valid params so the handler receives a clean input. + * - Reusable across any creator route that carries a creator identifier param. + * + * @param paramName - The route param key to validate (e.g. 'creatorId' or 'id') + * + * @example + * router.get('/:creatorId/profile', validateCreatorParam('creatorId'), handler); + * router.get('/:id/stats', validateCreatorParam('id'), handler); + */ +export function validateCreatorParam(paramName: string) { + return (req: Request, res: Response, next: NextFunction): void => { + const value = req.params[paramName]; + + if (!value || typeof value !== 'string') { + sendValidationError(res, 'Invalid creator parameter', [ + { + field: paramName, + message: `Route parameter '${paramName}' is required`, + }, + ]); + return; + } + + if (!CREATOR_PARAM_PATTERN.test(value)) { + sendValidationError(res, 'Invalid creator parameter', [ + { + field: paramName, + message: `Route parameter '${paramName}' contains invalid characters or exceeds maximum length`, + }, + ]); + return; + } + + next(); + }; +} \ No newline at end of file diff --git a/src/modules/creator/creator.routes.ts b/src/modules/creator/creator.routes.ts index 9f7f690..aec27b2 100644 --- a/src/modules/creator/creator.routes.ts +++ b/src/modules/creator/creator.routes.ts @@ -10,6 +10,7 @@ import { cacheControl } from '../../middlewares/cache-control.middleware'; import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants'; import { CREATOR_PUBLIC_ROUTE_NAMES } from '../../constants/creator-public-routes.constants'; import { requireCreatorProfileOwnership } from '../../middlewares/wallet-ownership.middleware'; +import { validateCreatorParam } from '../../middlewares/creator-param.middleware'; const router = Router(); @@ -48,6 +49,7 @@ router.all(CREATORS_ROOT, (_req, res) => { */ router.get( '/:creatorId/profile', + validateCreatorParam('creatorId'), cacheControl( CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_PROFILE] ), @@ -62,6 +64,7 @@ router.get( */ router.put( '/:creatorId/profile', + validateCreatorParam('creatorId'), requireCreatorProfileOwnership('creatorId'), upsertCreatorProfileHandler ); diff --git a/src/modules/creators/creators.routes.ts b/src/modules/creators/creators.routes.ts index d121179..6fde2be 100644 --- a/src/modules/creators/creators.routes.ts +++ b/src/modules/creators/creators.routes.ts @@ -6,6 +6,7 @@ import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-publ import { CREATOR_PUBLIC_ROUTE_NAMES } from '../../constants/creator-public-routes.constants'; import { createCreatorReadMetricsMiddleware } from '../../utils/creator-read-metrics.utils'; import { normalizeTrailingSlash } from '../../middlewares/trailing-slash-normalizer.middleware'; +import { validateCreatorParam } from '../../middlewares/creator-param.middleware'; const creatorsRouter = Router(); @@ -39,6 +40,7 @@ creatorsRouter.all('/', (_req, res) => { */ creatorsRouter.get( '/:id/stats', + validateCreatorParam('id'), createCreatorReadMetricsMiddleware('detail'), cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_STATS]), httpGetCreatorStats @@ -57,6 +59,7 @@ creatorsRouter.all('/:id/stats', (_req, res) => { */ creatorsRouter.get( '/:id/holders', + validateCreatorParam('id'), createCreatorReadMetricsMiddleware('holders'), cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_HOLDERS]), httpGetCreatorHolders