Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions src/middlewares/creator-param.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}): Request {
return { params } as unknown as Request;
}

function makeRes(): Partial<Response> {
const res: Partial<Response> = {};
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');
});
});
});
52 changes: 52 additions & 0 deletions src/middlewares/creator-param.middleware.ts
Original file line number Diff line number Diff line change
@@ -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();
};
}
3 changes: 3 additions & 0 deletions src/modules/creator/creator.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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]
),
Expand All @@ -62,6 +64,7 @@ router.get(
*/
router.put(
'/:creatorId/profile',
validateCreatorParam('creatorId'),
requireCreatorProfileOwnership('creatorId'),
upsertCreatorProfileHandler
);
Expand Down
3 changes: 3 additions & 0 deletions src/modules/creators/creators.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading