Skip to content
Merged
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
17 changes: 17 additions & 0 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,23 @@ export const envSchema = z
.default('https://soroban-testnet.stellar.org'),
STELLAR_AUTH_SECRET: optionalNonEmptyString,

// Shared secret that lets trusted internal services bypass per-wallet
// rate limits (e.g. the buy endpoint's sliding window limiter).
// Unset by default — no requests bypass rate limiting until configured.
INTERNAL_SERVICE_KEY: optionalNonEmptyString,

// Volume leaderboard (GET /api/v1/creators/leaderboard/volume, #785).
LEADERBOARD_VOLUME_WINDOW_DAYS: z.coerce
.number()
.int()
.positive()
.default(7),
LEADERBOARD_VOLUME_CACHE_TTL_SECONDS: z.coerce
.number()
.int()
.positive()
.default(300),

// Ownership snapshot cleanup job
OWNERSHIP_SNAPSHOT_TABLE_NAME: z
.string()
Expand Down
86 changes: 86 additions & 0 deletions src/middlewares/validate-body.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { z } from 'zod';
import { validateBody } from './validate-body.middleware';

function makeRes(): any {
const res: any = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.setHeader = jest.fn().mockReturnValue(res);
return res;
}

const schema = z.object({
name: z.string().min(1),
age: z.number().int().positive(),
});

describe('validateBody', () => {
it('calls next() and replaces req.body with the parsed data on success', () => {
const req: any = { body: { name: 'Ada', age: 30 } };
const res = makeRes();
const next = jest.fn();

validateBody(schema)(req, res, next);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
expect(req.body).toEqual({ name: 'Ada', age: 30 });
});

it('strips unknown fields before reaching the controller', () => {
const req: any = {
body: { name: 'Ada', age: 30, isAdmin: true, extra: 'nope' },
};
const res = makeRes();
const next = jest.fn();

validateBody(schema)(req, res, next);

expect(next).toHaveBeenCalled();
expect(req.body).toEqual({ name: 'Ada', age: 30 });
expect(req.body).not.toHaveProperty('isAdmin');
expect(req.body).not.toHaveProperty('extra');
});

it('returns 422 with per-field details when a required field is missing', () => {
const req: any = { body: { age: 30 } };
const res = makeRes();
const next = jest.fn();

validateBody(schema)(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(422);
const body = res.json.mock.calls[0][0];
expect(body.success).toBe(false);
expect(body.error.code).toBe('VALIDATION_ERROR');
expect(body.error.details).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: 'name' }),
])
);
});

it('returns 422 with the field name and message when a type is wrong', () => {
const req: any = { body: { name: 'Ada', age: 'not-a-number' } };
const res = makeRes();
const next = jest.fn();

validateBody(schema)(req, res, next);

expect(res.status).toHaveBeenCalledWith(422);
const body = res.json.mock.calls[0][0];
expect(body.error.details[0].field).toBe('age');
expect(body.error.details[0].message).toEqual(expect.any(String));
});

it('passes valid bodies through unchanged (no extraneous mutation)', () => {
const req: any = { body: { name: 'Grace', age: 42 } };
const res = makeRes();
const next = jest.fn();

validateBody(schema)(req, res, next);

expect(req.body).toEqual({ name: 'Grace', age: 42 });
});
});
44 changes: 44 additions & 0 deletions src/middlewares/validate-body.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// src/middlewares/validate-body.middleware.ts
// Centralized Zod request body validation.
//
// Mount `validateBody(schema)` ahead of a route handler to validate
// `req.body` before it reaches business logic. Unknown fields are stripped
// (the default behavior of `z.object()`), and invalid payloads short-circuit
// with a structured 422 response instead of reaching the controller.

import type { Request, Response, NextFunction } from 'express';
import type { ZodTypeAny } from 'zod';
import {
sendError,
zodIssuesToDetails,
ErrorCode,
} from '../utils/api-response.utils';

/**
* Builds middleware that validates `req.body` against `schema` via
* `safeParse`, replacing `req.body` with the parsed (and unknown-field
* stripped) result on success.
*
* On failure, responds 422 with `{ error: { code: VALIDATION_ERROR, details } }`
* where `details` lists every invalid field and its message — the handler is
* never invoked.
*/
export function validateBody(schema: ZodTypeAny) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body);

if (!result.success) {
sendError(
res,
422,
ErrorCode.VALIDATION_ERROR,
'Invalid request body',
zodIssuesToDetails(result.error.issues)
);
return;
}

req.body = result.data;
next();
};
}
209 changes: 209 additions & 0 deletions src/middlewares/wallet-rate-limit.middleware.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
// Unit tests for the per-wallet sliding-window rate limiter (#779).

const mockEnvConfig: { INTERNAL_SERVICE_KEY?: string } = {
INTERNAL_SERVICE_KEY: undefined,
};

jest.mock('../config', () => ({
envConfig: mockEnvConfig,
}));

jest.mock('../utils/logger.utils', () => ({
logger: {
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
info: jest.fn(),
},
}));

type PipelineCommand = [string, ...unknown[]];

function buildFakeRedis(initialCount = 0) {
const commands: PipelineCommand[] = [];
let currentCount = initialCount;

const pipeline = {
zremrangebyscore: (...args: unknown[]) => {
commands.push(['zremrangebyscore', ...args]);
return pipeline;
},
zadd: (...args: unknown[]) => {
commands.push(['zadd', ...args]);
currentCount += 1;
return pipeline;
},
zcard: (...args: unknown[]) => {
commands.push(['zcard', ...args]);
return pipeline;
},
pexpire: (...args: unknown[]) => {
commands.push(['pexpire', ...args]);
return pipeline;
},
exec: jest.fn(async () => [
[null, 0], // zremrangebyscore
[null, 1], // zadd
[null, currentCount], // zcard
[null, 1], // pexpire
]),
};

return {
pipeline: jest.fn(() => pipeline),
__setCount: (n: number) => {
currentCount = n;
},
};
}

jest.mock('../utils/redis.utils', () => ({
getRedis: jest.fn(),
}));

import { getRedis } from '../utils/redis.utils';
import { walletRateLimit } from './wallet-rate-limit.middleware';
import type { StellarSignedRequest } from './stellar-signature.middleware';

const mockGetRedis = getRedis as jest.Mock;

function makeReq(walletAddress?: string, headers: Record<string, string> = {}) {
return {
walletAddress,
headers,
path: '/api/v1/creators/creator-1/buy',
} as unknown as StellarSignedRequest;
}

function makeRes(): any {
const res: any = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.set = jest.fn().mockReturnValue(res);
return res;
}

describe('walletRateLimit', () => {
beforeEach(() => {
mockEnvConfig.INTERNAL_SERVICE_KEY = undefined;
jest.clearAllMocks();
});

it('allows the request when the wallet is under the limit', async () => {
const fakeRedis = buildFakeRedis(3);
mockGetRedis.mockReturnValue(fakeRedis);
const middleware = walletRateLimit({
windowMs: 10_000,
max: 5,
keyPrefix: 'rl:test:',
});

const req = makeReq('GBUYER');
const res = makeRes();
const next = jest.fn();
await middleware(req, res, next);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});

it('returns 429 with Retry-After when the wallet exceeds the limit', async () => {
const fakeRedis = buildFakeRedis(6);
mockGetRedis.mockReturnValue(fakeRedis);
const middleware = walletRateLimit({
windowMs: 10_000,
max: 5,
keyPrefix: 'rl:test:',
});

const req = makeReq('GBUYER');
const res = makeRes();
const next = jest.fn();
await middleware(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(429);
expect(res.set).toHaveBeenCalledWith('Retry-After', '10');
const body = res.json.mock.calls[0][0];
expect(body.type).toBe('RATE_LIMIT_EXCEEDED');
});

it('passes through unlimited when no wallet address is set (unauthenticated)', async () => {
const fakeRedis = buildFakeRedis(999);
mockGetRedis.mockReturnValue(fakeRedis);
const middleware = walletRateLimit({
windowMs: 10_000,
max: 5,
keyPrefix: 'rl:test:',
});

const req = makeReq(undefined);
const res = makeRes();
const next = jest.fn();
await middleware(req, res, next);

expect(next).toHaveBeenCalled();
expect(fakeRedis.pipeline).not.toHaveBeenCalled();
});

it('bypasses the limit for internal service calls with a matching key', async () => {
mockEnvConfig.INTERNAL_SERVICE_KEY = 'super-secret';
const fakeRedis = buildFakeRedis(999);
mockGetRedis.mockReturnValue(fakeRedis);
const middleware = walletRateLimit({
windowMs: 10_000,
max: 5,
keyPrefix: 'rl:test:',
});

const req = makeReq('GBUYER', { 'x-internal-service-key': 'super-secret' });
const res = makeRes();
const next = jest.fn();
await middleware(req, res, next);

expect(next).toHaveBeenCalled();
expect(fakeRedis.pipeline).not.toHaveBeenCalled();
});

it('does not bypass the limit when the internal service key header is wrong', async () => {
mockEnvConfig.INTERNAL_SERVICE_KEY = 'super-secret';
const fakeRedis = buildFakeRedis(6);
mockGetRedis.mockReturnValue(fakeRedis);
const middleware = walletRateLimit({
windowMs: 10_000,
max: 5,
keyPrefix: 'rl:test:',
});

const req = makeReq('GBUYER', { 'x-internal-service-key': 'wrong' });
const res = makeRes();
const next = jest.fn();
await middleware(req, res, next);

expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(429);
});

it('fails open (allows the request) when Redis throws', async () => {
mockGetRedis.mockReturnValue({
pipeline: () => ({
zremrangebyscore: () => {
throw new Error('redis down');
},
}),
});
const middleware = walletRateLimit({
windowMs: 10_000,
max: 5,
keyPrefix: 'rl:test:',
});

const req = makeReq('GBUYER');
const res = makeRes();
const next = jest.fn();
await middleware(req, res, next);

expect(next).toHaveBeenCalled();
expect(res.status).not.toHaveBeenCalled();
});
});
Loading
Loading