Compare commits

...

8 Commits

Author SHA1 Message Date
Gitea Actions
b6731b220c ci: Bump version to 0.9.32 [skip ci] 2026-01-06 04:13:42 +05:00
3507d455e8 more rate limiting
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2026-01-05 15:13:10 -08:00
Gitea Actions
92b2adf8e8 ci: Bump version to 0.9.31 [skip ci] 2026-01-06 04:07:21 +05:00
d6c7452256 more rate limiting
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
2026-01-05 15:06:55 -08:00
Gitea Actions
d812b681dd ci: Bump version to 0.9.30 [skip ci] 2026-01-06 03:54:42 +05:00
b4306a6092 more rate limiting
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 50s
2026-01-05 14:53:49 -08:00
Gitea Actions
57fdd159d5 ci: Bump version to 0.9.29 [skip ci] 2026-01-06 01:08:45 +05:00
4a747ca042 even even more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 23m46s
2026-01-05 12:08:18 -08:00
37 changed files with 1081 additions and 96 deletions

View File

@@ -0,0 +1,41 @@
# ADR-027: Standardized Naming Convention for AI and Database Types
**Date**: 2026-01-05
**Status**: Accepted
## Context
The application codebase primarily follows the standard TypeScript convention of `camelCase` for variable and property names. However, the PostgreSQL database uses `snake_case` for column names. Additionally, the AI prompts are designed to extract data that maps directly to these database columns.
Attempting to enforce `camelCase` strictly across the entire stack created friction and ambiguity, particularly in the background processing pipeline where data moves from the AI model directly to the database. Developers were unsure whether to transform keys immediately upon receipt (adding overhead) or keep them as-is.
## Decision
We will adopt a hybrid naming convention strategy to explicitly distinguish between internal application state and external/persisted data formats.
1. **Database and AI Types (`snake_case`)**:
Interfaces, Type definitions, and Zod schemas that represent raw database rows or direct AI responses **MUST** use `snake_case`.
- *Examples*: `AiFlyerDataSchema`, `ExtractedFlyerItemSchema`, `FlyerInsert`.
- *Reasoning*: This avoids unnecessary mapping layers when inserting data into the database or parsing AI output. It serves as a visual cue that the data is "raw", "external", or destined for persistence.
2. **Internal Application Logic (`camelCase`)**:
Variables, function arguments, and processed data structures used within the application logic (Service layer, UI components, utility functions) **MUST** use `camelCase`.
- *Reasoning*: This adheres to standard JavaScript/TypeScript practices and maintains consistency with the rest of the ecosystem (React, etc.).
3. **Boundary Handling**:
- For background jobs that primarily move data from AI to DB, preserving `snake_case` is preferred to minimize transformation logic.
- For API responses sent to the frontend, data should generally be transformed to `camelCase` unless it is a direct dump of a database entity for a specific administrative view.
## Consequences
### Positive
- **Visual Distinction**: It is immediately obvious whether a variable holds raw data (`price_in_cents`) or processed application state (`priceInCents`).
- **Efficiency**: Reduces boilerplate code for mapping keys (e.g., `price_in_cents: data.priceInCents`) when performing bulk inserts or updates.
- **Simplicity**: AI prompts can request JSON keys that match the database schema 1:1, reducing the risk of mapping errors.
### Negative
- **Context Switching**: Developers must be mindful of the casing context.
- **Linter Configuration**: May require specific overrides or `// eslint-disable-next-line` comments if the linter is configured to strictly enforce `camelCase` everywhere.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.9.28",
"version": "0.9.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.9.28",
"version": "0.9.32",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.9.28",
"version": "0.9.32",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

147
src/config/rateLimiters.ts Normal file
View File

@@ -0,0 +1,147 @@
// src/config/rateLimiters.ts
import rateLimit from 'express-rate-limit';
import { shouldSkipRateLimit } from '../utils/rateLimit';
const standardConfig = {
standardHeaders: true,
legacyHeaders: false,
skip: shouldSkipRateLimit,
};
// --- AUTHENTICATION ---
export const loginLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: 'Too many login attempts from this IP, please try again after 15 minutes.',
});
export const registerLimiter = rateLimit({
...standardConfig,
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: 'Too many accounts created from this IP, please try again after an hour.',
});
export const forgotPasswordLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
});
export const resetPasswordLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
});
export const refreshTokenLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: 'Too many token refresh attempts from this IP, please try again after 15 minutes.',
});
export const logoutLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many logout attempts from this IP, please try again after 15 minutes.',
});
// --- GENERAL PUBLIC & USER ---
export const publicReadLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: 'Too many requests from this IP, please try again later.',
});
export const userReadLimiter = publicReadLimiter; // Alias for consistency
export const userUpdateLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: 'Too many update requests from this IP, please try again after 15 minutes.',
});
export const reactionToggleLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 150,
message: 'Too many reaction requests from this IP, please try again later.',
});
export const trackingLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200,
message: 'Too many tracking requests from this IP, please try again later.',
});
// --- SENSITIVE / COSTLY ---
export const userSensitiveUpdateLimiter = rateLimit({
...standardConfig,
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: 'Too many sensitive requests from this IP, please try again after an hour.',
});
export const adminTriggerLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 30,
message: 'Too many administrative triggers from this IP, please try again later.',
});
export const aiGenerationLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: 'Too many AI generation requests from this IP, please try again after 15 minutes.',
});
export const suggestionLimiter = aiGenerationLimiter; // Alias
export const geocodeLimiter = rateLimit({
...standardConfig,
windowMs: 60 * 60 * 1000, // 1 hour
max: 100,
message: 'Too many geocoding requests from this IP, please try again later.',
});
export const priceHistoryLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50,
message: 'Too many price history requests from this IP, please try again later.',
});
// --- UPLOADS / BATCH ---
export const adminUploadLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: 'Too many file uploads from this IP, please try again after 15 minutes.',
});
export const userUploadLimiter = adminUploadLimiter; // Alias
export const aiUploadLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many file uploads from this IP, please try again after 15 minutes.',
});
export const batchLimiter = rateLimit({
...standardConfig,
windowMs: 15 * 60 * 1000, // 15 minutes
max: 50,
message: 'Too many batch requests from this IP, please try again later.',
});
export const budgetUpdateLimiter = batchLimiter; // Alias

View File

@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import { createTestApp } from '../tests/utils/createTestApp';
import { createMockUserProfile } from '../tests/utils/mockFactories';
// Mock dependencies required by admin.routes.ts
vi.mock('../services/db/index.db', () => ({
adminRepo: {},
flyerRepo: {},
recipeRepo: {},
userRepo: {},
personalizationRepo: {},
notificationRepo: {},
}));
vi.mock('../services/backgroundJobService', () => ({
backgroundJobService: {
runDailyDealCheck: vi.fn(),
triggerAnalyticsReport: vi.fn(),
triggerWeeklyAnalyticsReport: vi.fn(),
},
}));
vi.mock('../services/queueService.server', () => ({
flyerQueue: { add: vi.fn(), getJob: vi.fn() },
emailQueue: { add: vi.fn(), getJob: vi.fn() },
analyticsQueue: { add: vi.fn(), getJob: vi.fn() },
cleanupQueue: { add: vi.fn(), getJob: vi.fn() },
weeklyAnalyticsQueue: { add: vi.fn(), getJob: vi.fn() },
}));
vi.mock('../services/geocodingService.server', () => ({
geocodingService: { clearGeocodeCache: vi.fn() },
}));
vi.mock('../services/logger.server', async () => ({
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
vi.mock('@bull-board/api');
vi.mock('@bull-board/api/bullMQAdapter');
vi.mock('@bull-board/express', () => ({
ExpressAdapter: class {
setBasePath() {}
getRouter() { return (req: any, res: any, next: any) => next(); }
},
}));
vi.mock('node:fs/promises');
// Mock Passport to allow admin access
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(() => (req: any, res: any, next: any) => {
req.user = createMockUserProfile({ role: 'admin' });
next();
}),
},
isAdmin: (req: any, res: any, next: any) => next(),
}));
import adminRouter from './admin.routes';
describe('Admin Routes Rate Limiting', () => {
const app = createTestApp({ router: adminRouter, basePath: '/api/admin' });
beforeEach(() => {
vi.clearAllMocks();
});
describe('Trigger Rate Limiting', () => {
it('should block requests to /trigger/daily-deal-check after exceeding limit', async () => {
const limit = 30; // Matches adminTriggerLimiter config
// Make requests up to the limit
for (let i = 0; i < limit; i++) {
await supertest(app)
.post('/api/admin/trigger/daily-deal-check')
.set('X-Test-Rate-Limit-Enable', 'true');
}
// The next request should be blocked
const response = await supertest(app)
.post('/api/admin/trigger/daily-deal-check')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(429);
expect(response.text).toContain('Too many administrative triggers');
});
});
describe('Upload Rate Limiting', () => {
it('should block requests to /brands/:id/logo after exceeding limit', async () => {
const limit = 20; // Matches adminUploadLimiter config
const brandId = 1;
// Make requests up to the limit
// Note: We don't need to attach a file to test the rate limiter, as it runs before multer
for (let i = 0; i < limit; i++) {
await supertest(app)
.post(`/api/admin/brands/${brandId}/logo`)
.set('X-Test-Rate-Limit-Enable', 'true');
}
const response = await supertest(app)
.post(`/api/admin/brands/${brandId}/logo`)
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(429);
expect(response.text).toContain('Too many file uploads');
});
});
});

View File

@@ -35,6 +35,7 @@ import { monitoringService } from '../services/monitoringService.server';
import { userService } from '../services/userService';
import { cleanupUploadedFile } from '../utils/fileUtils';
import { brandService } from '../services/brandService';
import { adminTriggerLimiter, adminUploadLimiter } from '../config/rateLimiters';
const updateCorrectionSchema = numericIdParam('id').extend({
body: z.object({
@@ -242,6 +243,7 @@ router.put(
router.post(
'/brands/:id/logo',
adminUploadLimiter,
validateRequest(numericIdParam('id')),
brandLogoUpload.single('logoImage'),
requireFileUpload('logoImage'),
@@ -421,6 +423,7 @@ router.delete(
*/
router.post(
'/trigger/daily-deal-check',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -449,6 +452,7 @@ router.post(
*/
router.post(
'/trigger/analytics-report',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -474,6 +478,7 @@ router.post(
*/
router.post(
'/flyers/:flyerId/cleanup',
adminTriggerLimiter,
validateRequest(numericIdParam('flyerId')),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -502,6 +507,7 @@ router.post(
*/
router.post(
'/trigger/failing-job',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -528,6 +534,7 @@ router.post(
*/
router.post(
'/system/clear-geocode-cache',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -580,6 +587,7 @@ router.get('/queues/status', validateRequest(emptySchema), async (req: Request,
*/
router.post(
'/jobs/:queueName/:jobId/retry',
adminTriggerLimiter,
validateRequest(jobRetrySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -606,6 +614,7 @@ router.post(
*/
router.post(
'/trigger/weekly-analytics',
adminTriggerLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile; // This was a duplicate, fixed.

View File

@@ -14,6 +14,7 @@ import { validateRequest } from '../middleware/validation.middleware';
import { requiredString } from '../utils/zodUtils';
import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils';
import { monitoringService } from '../services/monitoringService.server';
import { aiUploadLimiter, aiGenerationLimiter } from '../config/rateLimiters';
const router = Router();
@@ -165,6 +166,7 @@ router.use((req: Request, res: Response, next: NextFunction) => {
*/
router.post(
'/upload-and-process',
aiUploadLimiter,
optionalAuth,
uploadToDisk.single('flyerFile'),
// Validation is now handled inside the route to ensure file cleanup on failure.
@@ -221,6 +223,7 @@ router.post(
*/
router.post(
'/upload-legacy',
aiUploadLimiter,
passport.authenticate('jwt', { session: false }),
uploadToDisk.single('flyerFile'),
async (req: Request, res: Response, next: NextFunction) => {
@@ -271,6 +274,7 @@ router.get(
*/
router.post(
'/flyers/process',
aiUploadLimiter,
optionalAuth,
uploadToDisk.single('flyerImage'),
async (req, res, next: NextFunction) => {
@@ -306,6 +310,7 @@ router.post(
*/
router.post(
'/check-flyer',
aiUploadLimiter,
optionalAuth,
uploadToDisk.single('image'),
async (req, res, next: NextFunction) => {
@@ -325,6 +330,7 @@ router.post(
router.post(
'/extract-address',
aiUploadLimiter,
optionalAuth,
uploadToDisk.single('image'),
async (req, res, next: NextFunction) => {
@@ -344,6 +350,7 @@ router.post(
router.post(
'/extract-logo',
aiUploadLimiter,
optionalAuth,
uploadToDisk.array('images'),
async (req, res, next: NextFunction) => {
@@ -363,6 +370,7 @@ router.post(
router.post(
'/quick-insights',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(insightsSchema),
async (req, res, next: NextFunction) => {
@@ -379,6 +387,7 @@ router.post(
router.post(
'/deep-dive',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(insightsSchema),
async (req, res, next: NextFunction) => {
@@ -395,6 +404,7 @@ router.post(
router.post(
'/search-web',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(searchWebSchema),
async (req, res, next: NextFunction) => {
@@ -409,6 +419,7 @@ router.post(
router.post(
'/compare-prices',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(comparePricesSchema),
async (req, res, next: NextFunction) => {
@@ -427,6 +438,7 @@ router.post(
router.post(
'/plan-trip',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(planTripSchema),
async (req, res, next: NextFunction) => {
@@ -446,6 +458,7 @@ router.post(
router.post(
'/generate-image',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(generateImageSchema),
(req: Request, res: Response) => {
@@ -458,6 +471,7 @@ router.post(
router.post(
'/generate-speech',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(generateSpeechSchema),
(req: Request, res: Response) => {
@@ -474,6 +488,7 @@ router.post(
*/
router.post(
'/rescan-area',
aiUploadLimiter,
passport.authenticate('jwt', { session: false }),
uploadToDisk.single('image'),
validateRequest(rescanAreaSchema),

View File

@@ -708,5 +708,203 @@ describe('Rate Limiting on /forgot-password', () => {
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many password reset attempts');
});
it('should NOT block requests when the opt-in header is not sent (default test behavior)', async () => {
// Arrange
const maxRequests = 12; // Limit is 10
const newPassword = 'a-Very-Strong-Password-123!';
const token = 'some-token-for-skip-limit-test';
mockedAuthService.updatePassword.mockResolvedValue(null);
// Act: Make more calls than the limit.
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/reset-password')
.send({ token, newPassword });
expect(response.status).toBe(400);
}
});
});
describe('Rate Limiting on /register', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 5; // Limit is 5 per hour
const newUser = {
email: 'rate-limit-reg@test.com',
password: 'StrongPassword123!',
full_name: 'Rate Limit User',
};
// Mock success to ensure we are hitting the limiter and not failing early
mockedAuthService.registerAndLoginUser.mockResolvedValue({
newUserProfile: createMockUserProfile({ user: { email: newUser.email } }),
accessToken: 'token',
refreshToken: 'refresh',
});
// Act: Make maxRequests calls
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/register')
.set('X-Test-Rate-Limit-Enable', 'true')
.send(newUser);
expect(response.status).not.toBe(429);
}
// Act: Make one more call
const blockedResponse = await supertest(app)
.post('/api/auth/register')
.set('X-Test-Rate-Limit-Enable', 'true')
.send(newUser);
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many accounts created');
});
it('should NOT block requests when the opt-in header is not sent', async () => {
const maxRequests = 7;
const newUser = {
email: 'no-limit-reg@test.com',
password: 'StrongPassword123!',
full_name: 'No Limit User',
};
mockedAuthService.registerAndLoginUser.mockResolvedValue({
newUserProfile: createMockUserProfile({ user: { email: newUser.email } }),
accessToken: 'token',
refreshToken: 'refresh',
});
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app).post('/api/auth/register').send(newUser);
expect(response.status).not.toBe(429);
}
});
});
describe('Rate Limiting on /login', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 5; // Limit is 5 per 15 mins
const credentials = { email: 'rate-limit-login@test.com', password: 'password123' };
mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
accessToken: 'token',
refreshToken: 'refresh',
});
// Act
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/login')
.set('X-Test-Rate-Limit-Enable', 'true')
.send(credentials);
expect(response.status).not.toBe(429);
}
const blockedResponse = await supertest(app)
.post('/api/auth/login')
.set('X-Test-Rate-Limit-Enable', 'true')
.send(credentials);
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many login attempts');
});
it('should NOT block requests when the opt-in header is not sent', async () => {
const maxRequests = 7;
const credentials = { email: 'no-limit-login@test.com', password: 'password123' };
mockedAuthService.handleSuccessfulLogin.mockResolvedValue({
accessToken: 'token',
refreshToken: 'refresh',
});
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app).post('/api/auth/login').send(credentials);
expect(response.status).not.toBe(429);
}
});
});
describe('Rate Limiting on /refresh-token', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 20; // Limit is 20 per 15 mins
mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-token' });
// Act: Make maxRequests calls
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/refresh-token')
.set('Cookie', 'refreshToken=valid-token')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).not.toBe(429);
}
// Act: Make one more call
const blockedResponse = await supertest(app)
.post('/api/auth/refresh-token')
.set('Cookie', 'refreshToken=valid-token')
.set('X-Test-Rate-Limit-Enable', 'true');
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many token refresh attempts');
});
it('should NOT block requests when the opt-in header is not sent', async () => {
const maxRequests = 22;
mockedAuthService.refreshAccessToken.mockResolvedValue({ accessToken: 'new-token' });
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/refresh-token')
.set('Cookie', 'refreshToken=valid-token');
expect(response.status).not.toBe(429);
}
});
});
describe('Rate Limiting on /logout', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 10; // Limit is 10 per 15 mins
mockedAuthService.logout.mockResolvedValue(undefined);
// Act
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/logout')
.set('Cookie', 'refreshToken=valid-token')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).not.toBe(429);
}
const blockedResponse = await supertest(app)
.post('/api/auth/logout')
.set('Cookie', 'refreshToken=valid-token')
.set('X-Test-Rate-Limit-Enable', 'true');
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many logout attempts');
});
it('should NOT block requests when the opt-in header is not sent', async () => {
const maxRequests = 12;
mockedAuthService.logout.mockResolvedValue(undefined);
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(app)
.post('/api/auth/logout')
.set('Cookie', 'refreshToken=valid-token');
expect(response.status).not.toBe(429);
}
});
});
});

View File

@@ -1,7 +1,6 @@
// src/routes/auth.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import rateLimit from 'express-rate-limit';
import passport from './passport.routes';
import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks
import { logger } from '../services/logger.server';
@@ -9,39 +8,18 @@ import { validateRequest } from '../middleware/validation.middleware';
import type { UserProfile } from '../types';
import { validatePasswordStrength } from '../utils/authUtils';
import { requiredString } from '../utils/zodUtils';
import {
loginLimiter,
registerLimiter,
forgotPasswordLimiter,
resetPasswordLimiter,
refreshTokenLimiter,
logoutLimiter,
} from '../config/rateLimiters';
import { authService } from '../services/authService';
const router = Router();
// Conditionally disable rate limiting for the test environment
const isTestEnv = process.env.NODE_ENV === 'test';
// --- Rate Limiting Configuration ---
const forgotPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: 'Too many password reset requests from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
// Skip in test env unless a specific header is present.
// This allows E2E tests to run unblocked, while specific integration
// tests for the limiter can opt-in by sending the header.
skip: (req) => {
if (!isTestEnv) return false; // Never skip in non-test environments.
// In test env, skip UNLESS the opt-in header is present.
return req.headers['x-test-rate-limit-enable'] !== 'true';
},
});
const resetPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
message: 'Too many password reset attempts from this IP, please try again after 15 minutes.',
standardHeaders: true,
legacyHeaders: false,
skip: () => isTestEnv, // Skip this middleware if in test environment
});
// --- Reusable Schemas ---
const passwordSchema = z
@@ -95,6 +73,7 @@ const resetPasswordSchema = z.object({
// Registration Route
router.post(
'/register',
registerLimiter,
validateRequest(registerSchema),
async (req: Request, res: Response, next: NextFunction) => {
type RegisterRequest = z.infer<typeof registerSchema>;
@@ -134,6 +113,7 @@ router.post(
// Login Route
router.post(
'/login',
loginLimiter,
validateRequest(loginSchema),
(req: Request, res: Response, next: NextFunction) => {
passport.authenticate(
@@ -238,7 +218,7 @@ router.post(
);
// New Route to refresh the access token
router.post('/refresh-token', async (req: Request, res: Response, next: NextFunction) => {
router.post('/refresh-token', refreshTokenLimiter, async (req: Request, res: Response, next: NextFunction) => {
const { refreshToken } = req.cookies;
if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not found.' });
@@ -261,7 +241,7 @@ router.post('/refresh-token', async (req: Request, res: Response, next: NextFunc
* It clears the refresh token from the database and instructs the client to
* expire the `refreshToken` cookie.
*/
router.post('/logout', async (req: Request, res: Response) => {
router.post('/logout', logoutLimiter, async (req: Request, res: Response) => {
const { refreshToken } = req.cookies;
if (refreshToken) {
// Invalidate the token in the database so it cannot be used again.

View File

@@ -6,6 +6,7 @@ import { budgetRepo } from '../services/db/index.db';
import type { UserProfile } from '../types';
import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, numericIdParam } from '../utils/zodUtils';
import { budgetUpdateLimiter } from '../config/rateLimiters';
const router = express.Router();
@@ -37,6 +38,9 @@ const spendingAnalysisSchema = z.object({
// Middleware to ensure user is authenticated for all budget routes
router.use(passport.authenticate('jwt', { session: false }));
// Apply rate limiting to all subsequent budget routes
router.use(budgetUpdateLimiter);
/**
* GET /api/budgets - Get all budgets for the authenticated user.
*/

View File

@@ -103,4 +103,18 @@ describe('Deals Routes (/api/users/deals)', () => {
);
});
});
describe('Rate Limiting', () => {
it('should apply userReadLimiter to GET /best-watched-prices', async () => {
vi.mocked(dealsRepo.findBestPricesForWatchedItems).mockResolvedValue([]);
const response = await supertest(authenticatedApp)
.get('/api/users/deals/best-watched-prices')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
});
});
});

View File

@@ -5,6 +5,7 @@ import passport from './passport.routes';
import { dealsRepo } from '../services/db/deals.db';
import type { UserProfile } from '../types';
import { validateRequest } from '../middleware/validation.middleware';
import { userReadLimiter } from '../config/rateLimiters';
const router = express.Router();
@@ -27,6 +28,7 @@ router.use(passport.authenticate('jwt', { session: false }));
*/
router.get(
'/best-watched-prices',
userReadLimiter,
validateRequest(bestWatchedPricesSchema),
async (req: Request, res: Response, next: NextFunction) => {
const userProfile = req.user as UserProfile;

View File

@@ -310,4 +310,55 @@ describe('Flyer Routes (/api/flyers)', () => {
);
});
});
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /', async () => {
vi.mocked(db.flyerRepo.getFlyers).mockResolvedValue([]);
const response = await supertest(app)
.get('/api/flyers')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
});
it('should apply batchLimiter to POST /items/batch-fetch', async () => {
vi.mocked(db.flyerRepo.getFlyerItemsForFlyers).mockResolvedValue([]);
const response = await supertest(app)
.post('/api/flyers/items/batch-fetch')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ flyerIds: [1] });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(50);
});
it('should apply batchLimiter to POST /items/batch-count', async () => {
vi.mocked(db.flyerRepo.countFlyerItemsForFlyers).mockResolvedValue(0);
const response = await supertest(app)
.post('/api/flyers/items/batch-count')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ flyerIds: [1] });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(50);
});
it('should apply trackingLimiter to POST /items/:itemId/track', async () => {
// Mock fire-and-forget promise
vi.mocked(db.flyerRepo.trackFlyerItemInteraction).mockResolvedValue(undefined);
const response = await supertest(app)
.post('/api/flyers/items/1/track')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ type: 'view' });
expect(response.status).toBe(202);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(200);
});
});
});

View File

@@ -4,6 +4,11 @@ import * as db from '../services/db/index.db';
import { z } from 'zod';
import { validateRequest } from '../middleware/validation.middleware';
import { optionalNumeric } from '../utils/zodUtils';
import {
publicReadLimiter,
batchLimiter,
trackingLimiter,
} from '../config/rateLimiters';
const router = Router();
@@ -48,7 +53,7 @@ const trackItemSchema = z.object({
/**
* GET /api/flyers - Get a paginated list of all flyers.
*/
router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
router.get('/', publicReadLimiter, validateRequest(getFlyersSchema), async (req, res, next): Promise<void> => {
try {
// The `validateRequest` middleware ensures `req.query` is valid.
// We parse it here to apply Zod's coercions (string to number) and defaults.
@@ -65,7 +70,7 @@ router.get('/', validateRequest(getFlyersSchema), async (req, res, next): Promis
/**
* GET /api/flyers/:id - Get a single flyer by its ID.
*/
router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
router.get('/:id', publicReadLimiter, validateRequest(flyerIdParamSchema), async (req, res, next): Promise<void> => {
try {
// Explicitly parse to get the coerced number type for `id`.
const { id } = flyerIdParamSchema.shape.params.parse(req.params);
@@ -82,6 +87,7 @@ router.get('/:id', validateRequest(flyerIdParamSchema), async (req, res, next):
*/
router.get(
'/:id/items',
publicReadLimiter,
validateRequest(flyerIdParamSchema),
async (req, res, next): Promise<void> => {
type GetFlyerByIdRequest = z.infer<typeof flyerIdParamSchema>;
@@ -103,6 +109,7 @@ router.get(
type BatchFetchRequest = z.infer<typeof batchFetchSchema>;
router.post(
'/items/batch-fetch',
batchLimiter,
validateRequest(batchFetchSchema),
async (req, res, next): Promise<void> => {
const { body } = req as unknown as BatchFetchRequest;
@@ -124,6 +131,7 @@ router.post(
type BatchCountRequest = z.infer<typeof batchCountSchema>;
router.post(
'/items/batch-count',
batchLimiter,
validateRequest(batchCountSchema),
async (req, res, next): Promise<void> => {
const { body } = req as unknown as BatchCountRequest;
@@ -142,7 +150,7 @@ router.post(
/**
* POST /api/flyers/items/:itemId/track - Tracks a user interaction with a flyer item.
*/
router.post('/items/:itemId/track', validateRequest(trackItemSchema), (req, res, next): void => {
router.post('/items/:itemId/track', trackingLimiter, validateRequest(trackItemSchema), (req, res, next): void => {
try {
// Explicitly parse to get coerced types.
const { params, body } = trackItemSchema.parse({ params: req.params, body: req.body });

View File

@@ -336,4 +336,50 @@ describe('Gamification Routes (/api/achievements)', () => {
expect(response.body.errors[0].message).toMatch(/less than or equal to 50|Too big/i);
});
});
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /', async () => {
vi.mocked(db.gamificationRepo.getAllAchievements).mockResolvedValue([]);
const response = await supertest(unauthenticatedApp)
.get('/api/achievements')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
});
it('should apply userReadLimiter to GET /me', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockUserProfile;
next();
});
vi.mocked(db.gamificationRepo.getUserAchievements).mockResolvedValue([]);
const response = await supertest(authenticatedApp)
.get('/api/achievements/me')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
});
it('should apply adminTriggerLimiter to POST /award', async () => {
mockedAuthMiddleware.mockImplementation((req: Request, res: Response, next: NextFunction) => {
req.user = mockAdminProfile;
next();
});
mockedIsAdmin.mockImplementation((req: Request, res: Response, next: NextFunction) => next());
vi.mocked(db.gamificationRepo.awardAchievement).mockResolvedValue(undefined);
const response = await supertest(adminApp)
.post('/api/achievements/award')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ userId: 'some-user', achievementName: 'some-achievement' });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(30);
});
});
});

View File

@@ -7,6 +7,11 @@ import { logger } from '../services/logger.server';
import { UserProfile } from '../types';
import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, optionalNumeric } from '../utils/zodUtils';
import {
publicReadLimiter,
userReadLimiter,
adminTriggerLimiter,
} from '../config/rateLimiters';
const router = express.Router();
const adminGamificationRouter = express.Router(); // Create a new router for admin-only routes.
@@ -34,7 +39,7 @@ const awardAchievementSchema = z.object({
* GET /api/achievements - Get the master list of all available achievements.
* This is a public endpoint.
*/
router.get('/', async (req, res, next: NextFunction) => {
router.get('/', publicReadLimiter, async (req, res, next: NextFunction) => {
try {
const achievements = await gamificationService.getAllAchievements(req.log);
res.json(achievements);
@@ -50,6 +55,7 @@ router.get('/', async (req, res, next: NextFunction) => {
*/
router.get(
'/leaderboard',
publicReadLimiter,
validateRequest(leaderboardSchema),
async (req, res, next: NextFunction): Promise<void> => {
try {
@@ -74,6 +80,7 @@ router.get(
router.get(
'/me',
passport.authenticate('jwt', { session: false }),
userReadLimiter,
async (req, res, next: NextFunction): Promise<void> => {
const userProfile = req.user as UserProfile;
try {
@@ -103,6 +110,7 @@ adminGamificationRouter.use(passport.authenticate('jwt', { session: false }), is
*/
adminGamificationRouter.post(
'/award',
adminTriggerLimiter,
validateRequest(awardAchievementSchema),
async (req, res, next: NextFunction): Promise<void> => {
// Infer type and cast request object as per ADR-003

View File

@@ -106,4 +106,16 @@ describe('Personalization Routes (/api/personalization)', () => {
);
});
});
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /master-items', async () => {
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
const response = await supertest(app)
.get('/api/personalization/master-items')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
});
});
});

View File

@@ -3,6 +3,7 @@ import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import { publicReadLimiter } from '../config/rateLimiters';
const router = Router();
@@ -16,6 +17,7 @@ const emptySchema = z.object({});
*/
router.get(
'/master-items',
publicReadLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -39,6 +41,7 @@ router.get(
*/
router.get(
'/dietary-restrictions',
publicReadLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -59,6 +62,7 @@ router.get(
*/
router.get(
'/appliances',
publicReadLimiter,
validateRequest(emptySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {

View File

@@ -1,8 +1,10 @@
// src/routes/price.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger';
import { createMockUserProfile } from '../tests/utils/mockFactories';
// Mock the price repository
vi.mock('../services/db/price.db', () => ({
@@ -17,12 +19,29 @@ vi.mock('../services/logger.server', async () => ({
logger: (await import('../tests/utils/mockLogger')).mockLogger,
}));
// Mock the passport middleware
vi.mock('./passport.routes', () => ({
default: {
authenticate: vi.fn(
(_strategy, _options) => (req: Request, res: Response, next: NextFunction) => {
// If req.user is not set by the test setup, simulate unauthenticated access.
if (!req.user) {
return res.status(401).json({ message: 'Unauthorized' });
}
// If req.user is set, proceed as an authenticated user.
next();
},
),
},
}));
// Import the router AFTER other setup.
import priceRouter from './price.routes';
import { priceRepo } from '../services/db/price.db';
describe('Price Routes (/api/price-history)', () => {
const app = createTestApp({ router: priceRouter, basePath: '/api/price-history' });
const mockUser = createMockUserProfile({ user: { user_id: 'price-user-123' } });
const app = createTestApp({ router: priceRouter, basePath: '/api/price-history', authenticatedUser: mockUser });
beforeEach(() => {
vi.clearAllMocks();
});
@@ -130,4 +149,18 @@ describe('Price Routes (/api/price-history)', () => {
expect(response.body.errors[1].message).toBe('Invalid input: expected number, received NaN');
});
});
describe('Rate Limiting', () => {
it('should apply priceHistoryLimiter to POST /', async () => {
vi.mocked(priceRepo.getPriceHistory).mockResolvedValue([]);
const response = await supertest(app)
.post('/api/price-history')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ masterItemIds: [1, 2] });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(50);
});
});
});

View File

@@ -1,9 +1,11 @@
// src/routes/price.routes.ts
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import passport from './passport.routes';
import { validateRequest } from '../middleware/validation.middleware';
import { priceRepo } from '../services/db/price.db';
import { optionalNumeric } from '../utils/zodUtils';
import { priceHistoryLimiter } from '../config/rateLimiters';
const router = Router();
@@ -26,21 +28,27 @@ type PriceHistoryRequest = z.infer<typeof priceHistorySchema>;
* POST /api/price-history - Fetches historical price data for a given list of master item IDs.
* This endpoint retrieves price points over time for specified master grocery items.
*/
router.post('/', validateRequest(priceHistorySchema), async (req: Request, res: Response, next: NextFunction) => {
// Cast 'req' to the inferred type for full type safety.
const {
body: { masterItemIds, limit, offset },
} = req as unknown as PriceHistoryRequest;
req.log.info(
{ itemCount: masterItemIds.length, limit, offset },
'[API /price-history] Received request for historical price data.',
);
try {
const priceHistory = await priceRepo.getPriceHistory(masterItemIds, req.log, limit, offset);
res.status(200).json(priceHistory);
} catch (error) {
next(error);
}
});
router.post(
'/',
passport.authenticate('jwt', { session: false }),
priceHistoryLimiter,
validateRequest(priceHistorySchema),
async (req: Request, res: Response, next: NextFunction) => {
// Cast 'req' to the inferred type for full type safety.
const {
body: { masterItemIds, limit, offset },
} = req as unknown as PriceHistoryRequest;
req.log.info(
{ itemCount: masterItemIds.length, limit, offset },
'[API /price-history] Received request for historical price data.',
);
try {
const priceHistory = await priceRepo.getPriceHistory(masterItemIds, req.log, limit, offset);
res.status(200).json(priceHistory);
} catch (error) {
next(error);
}
},
);
export default router;

View File

@@ -208,4 +208,36 @@ describe('Reaction Routes (/api/reactions)', () => {
);
});
});
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /', async () => {
const app = createTestApp({ router: reactionsRouter, basePath: '/api/reactions' });
vi.mocked(reactionRepo.getReactions).mockResolvedValue([]);
const response = await supertest(app)
.get('/api/reactions')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
});
it('should apply userUpdateLimiter to POST /toggle', async () => {
const mockUser = createMockUserProfile({ user: { user_id: 'user-123' } });
const app = createTestApp({
router: reactionsRouter,
basePath: '/api/reactions',
authenticatedUser: mockUser,
});
vi.mocked(reactionRepo.toggleReaction).mockResolvedValue(null);
const response = await supertest(app)
.post('/api/reactions/toggle')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ entity_type: 'recipe', entity_id: '1', reaction_type: 'like' });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(150);
});
});
});

View File

@@ -5,6 +5,7 @@ import { validateRequest } from '../middleware/validation.middleware';
import passport from './passport.routes';
import { requiredString } from '../utils/zodUtils';
import { UserProfile } from '../types';
import { publicReadLimiter, reactionToggleLimiter } from '../config/rateLimiters';
const router = Router();
@@ -42,6 +43,7 @@ const getReactionSummarySchema = z.object({
*/
router.get(
'/',
publicReadLimiter,
validateRequest(getReactionsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -62,6 +64,7 @@ router.get(
*/
router.get(
'/summary',
publicReadLimiter,
validateRequest(getReactionSummarySchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
@@ -81,6 +84,7 @@ router.get(
*/
router.post(
'/toggle',
reactionToggleLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(toggleReactionSchema),
async (req: Request, res: Response, next: NextFunction) => {

View File

@@ -318,4 +318,65 @@ describe('Recipe Routes (/api/recipes)', () => {
);
});
});
describe('Rate Limiting on /suggest', () => {
const mockUser = createMockUserProfile({ user: { user_id: 'rate-limit-user' } });
const authApp = createTestApp({
router: recipeRouter,
basePath: '/api/recipes',
authenticatedUser: mockUser,
});
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
// Arrange
const maxRequests = 20; // Limit is 20 per 15 mins
const ingredients = ['chicken', 'rice'];
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue('A tasty suggestion');
// Act: Make maxRequests calls
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ ingredients });
expect(response.status).not.toBe(429);
}
// Act: Make one more call
const blockedResponse = await supertest(authApp)
.post('/api/recipes/suggest')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ ingredients });
// Assert
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many recipe suggestion requests');
});
it('should NOT block requests when the opt-in header is not sent', async () => {
const maxRequests = 22;
const ingredients = ['beef', 'potatoes'];
vi.mocked(aiService.generateRecipeSuggestion).mockResolvedValue('Another suggestion');
for (let i = 0; i < maxRequests; i++) {
const response = await supertest(authApp)
.post('/api/recipes/suggest')
.send({ ingredients });
expect(response.status).not.toBe(429);
}
});
});
describe('Rate Limiting on Public Routes', () => {
it('should apply publicReadLimiter to GET /:recipeId', async () => {
vi.mocked(db.recipeRepo.getRecipeById).mockResolvedValue(createMockRecipe({}));
const response = await supertest(app)
.get('/api/recipes/1')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
});
});
});

View File

@@ -6,6 +6,7 @@ import { aiService } from '../services/aiService.server';
import passport from './passport.routes';
import { validateRequest } from '../middleware/validation.middleware';
import { requiredString, numericIdParam, optionalNumeric } from '../utils/zodUtils';
import { publicReadLimiter, suggestionLimiter } from '../config/rateLimiters';
const router = Router();
@@ -41,6 +42,7 @@ const suggestRecipeSchema = z.object({
*/
router.get(
'/by-sale-percentage',
publicReadLimiter,
validateRequest(bySalePercentageSchema),
async (req, res, next) => {
try {
@@ -60,6 +62,7 @@ router.get(
*/
router.get(
'/by-sale-ingredients',
publicReadLimiter,
validateRequest(bySaleIngredientsSchema),
async (req, res, next) => {
try {
@@ -82,6 +85,7 @@ router.get(
*/
router.get(
'/by-ingredient-and-tag',
publicReadLimiter,
validateRequest(byIngredientAndTagSchema),
async (req, res, next) => {
try {
@@ -102,7 +106,7 @@ router.get(
/**
* GET /api/recipes/:recipeId/comments - Get all comments for a specific recipe.
*/
router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (req, res, next) => {
router.get('/:recipeId/comments', publicReadLimiter, validateRequest(recipeIdParamsSchema), async (req, res, next) => {
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
@@ -117,7 +121,7 @@ router.get('/:recipeId/comments', validateRequest(recipeIdParamsSchema), async (
/**
* GET /api/recipes/:recipeId - Get a single recipe by its ID, including ingredients and tags.
*/
router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req, res, next) => {
router.get('/:recipeId', publicReadLimiter, validateRequest(recipeIdParamsSchema), async (req, res, next) => {
try {
// Explicitly parse req.params to coerce recipeId to a number
const { params } = recipeIdParamsSchema.parse({ params: req.params });
@@ -135,6 +139,7 @@ router.get('/:recipeId', validateRequest(recipeIdParamsSchema), async (req, res,
*/
router.post(
'/suggest',
suggestionLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(suggestRecipeSchema),
async (req, res, next) => {

View File

@@ -66,4 +66,16 @@ describe('Stats Routes (/api/stats)', () => {
expect(response.body.errors.length).toBe(2);
});
});
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /most-frequent-sales', async () => {
vi.mocked(db.adminRepo.getMostFrequentSaleItems).mockResolvedValue([]);
const response = await supertest(app)
.get('/api/stats/most-frequent-sales')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
});
});
});

View File

@@ -4,6 +4,7 @@ import { z } from 'zod';
import * as db from '../services/db/index.db';
import { validateRequest } from '../middleware/validation.middleware';
import { optionalNumeric } from '../utils/zodUtils';
import { publicReadLimiter } from '../config/rateLimiters';
const router = Router();
@@ -25,6 +26,7 @@ const mostFrequentSalesSchema = z.object({
*/
router.get(
'/most-frequent-sales',
publicReadLimiter,
validateRequest(mostFrequentSalesSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {

View File

@@ -156,4 +156,25 @@ describe('System Routes (/api/system)', () => {
expect(response.body.errors[0].message).toMatch(/An address string is required|Required/i);
});
});
describe('Rate Limiting on /geocode', () => {
it('should block requests after exceeding the limit when the opt-in header is sent', async () => {
const limit = 100; // Matches geocodeLimiter config
const address = '123 Test St';
vi.mocked(geocodingService.geocodeAddress).mockResolvedValue({ lat: 0, lng: 0 });
// We only need to verify it blocks eventually.
// Instead of running 100 requests, we check for the headers which confirm the middleware is active.
const response = await supertest(app)
.post('/api/system/geocode')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ address });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(response.headers).toHaveProperty('x-ratelimit-remaining');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(limit);
expect(parseInt(response.headers['x-ratelimit-remaining'])).toBeLessThan(limit);
});
});
});

View File

@@ -6,6 +6,7 @@ import { validateRequest } from '../middleware/validation.middleware';
import { z } from 'zod';
import { requiredString } from '../utils/zodUtils';
import { systemService } from '../services/systemService';
import { geocodeLimiter } from '../config/rateLimiters';
const router = Router();
@@ -41,6 +42,7 @@ router.get(
*/
router.post(
'/geocode',
geocodeLimiter,
validateRequest(geocodeSchema),
async (req: Request, res: Response, next: NextFunction) => {
// Infer type and cast request object as per ADR-003

View File

@@ -1235,5 +1235,80 @@ describe('User Routes (/api/users)', () => {
expect(logger.error).toHaveBeenCalled();
});
}); // End of Recipe Routes
describe('Rate Limiting', () => {
it('should apply userUpdateLimiter to PUT /profile', async () => {
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(mockUserProfile);
const response = await supertest(app)
.put('/api/users/profile')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ full_name: 'Rate Limit Test' });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(100);
});
it('should apply userSensitiveUpdateLimiter to PUT /profile/password and block after limit', async () => {
const limit = 5;
vi.mocked(userService.updateUserPassword).mockResolvedValue(undefined);
// Consume the limit
for (let i = 0; i < limit; i++) {
const response = await supertest(app)
.put('/api/users/profile/password')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ newPassword: 'StrongPassword123!' });
expect(response.status).toBe(200);
}
// Next request should be blocked
const response = await supertest(app)
.put('/api/users/profile/password')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ newPassword: 'StrongPassword123!' });
expect(response.status).toBe(429);
expect(response.text).toContain('Too many sensitive requests');
});
it('should apply userUploadLimiter to POST /profile/avatar', async () => {
vi.mocked(userService.updateUserAvatar).mockResolvedValue(mockUserProfile);
const dummyImagePath = 'test-avatar.png';
const response = await supertest(app)
.post('/api/users/profile/avatar')
.set('X-Test-Rate-Limit-Enable', 'true')
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(20);
});
});
it('should apply userSensitiveUpdateLimiter to DELETE /account and block after limit', async () => {
const limit = 5;
vi.mocked(userService.deleteUserAccount).mockResolvedValue(undefined);
// Consume the limit
for (let i = 0; i < limit; i++) {
const response = await supertest(app)
.delete('/api/users/account')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ password: 'correct-password' });
expect(response.status).toBe(200);
}
// Next request should be blocked
const response = await supertest(app)
.delete('/api/users/account')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ password: 'correct-password' });
expect(response.status).toBe(429);
expect(response.text).toContain('Too many sensitive requests');
});
});
});

View File

@@ -21,6 +21,11 @@ import {
} from '../utils/zodUtils';
import * as db from '../services/db/index.db';
import { cleanupUploadedFile } from '../utils/fileUtils';
import {
userUpdateLimiter,
userSensitiveUpdateLimiter,
userUploadLimiter,
} from '../config/rateLimiters';
const router = express.Router();
@@ -95,6 +100,7 @@ const avatarUpload = createUploadMiddleware({
*/
router.post(
'/profile/avatar',
userUploadLimiter,
avatarUpload.single('avatar'),
async (req: Request, res: Response, next: NextFunction) => {
// The try-catch block was already correct here.
@@ -215,6 +221,7 @@ router.get('/profile', validateRequest(emptySchema), async (req, res, next: Next
type UpdateProfileRequest = z.infer<typeof updateProfileSchema>;
router.put(
'/profile',
userUpdateLimiter,
validateRequest(updateProfileSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile - ENTER`);
@@ -241,6 +248,7 @@ router.put(
type UpdatePasswordRequest = z.infer<typeof updatePasswordSchema>;
router.put(
'/profile/password',
userSensitiveUpdateLimiter,
validateRequest(updatePasswordSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile/password - ENTER`);
@@ -264,6 +272,7 @@ router.put(
type DeleteAccountRequest = z.infer<typeof deleteAccountSchema>;
router.delete(
'/account',
userSensitiveUpdateLimiter,
validateRequest(deleteAccountSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/account - ENTER`);
@@ -302,6 +311,7 @@ router.get('/watched-items', validateRequest(emptySchema), async (req, res, next
type AddWatchedItemRequest = z.infer<typeof addWatchedItemSchema>;
router.post(
'/watched-items',
userUpdateLimiter,
validateRequest(addWatchedItemSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/watched-items - ENTER`);
@@ -333,6 +343,7 @@ const watchedItemIdSchema = numericIdParam('masterItemId');
type DeleteWatchedItemRequest = z.infer<typeof watchedItemIdSchema>;
router.delete(
'/watched-items/:masterItemId',
userUpdateLimiter,
validateRequest(watchedItemIdSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/watched-items/:masterItemId - ENTER`);
@@ -407,6 +418,7 @@ router.get(
type CreateShoppingListRequest = z.infer<typeof createShoppingListSchema>;
router.post(
'/shopping-lists',
userUpdateLimiter,
validateRequest(createShoppingListSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists - ENTER`);
@@ -435,6 +447,7 @@ router.post(
*/
router.delete(
'/shopping-lists/:listId',
userUpdateLimiter,
validateRequest(shoppingListIdSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/:listId - ENTER`);
@@ -475,6 +488,7 @@ const addShoppingListItemSchema = shoppingListIdSchema.extend({
type AddShoppingListItemRequest = z.infer<typeof addShoppingListItemSchema>;
router.post(
'/shopping-lists/:listId/items',
userUpdateLimiter,
validateRequest(addShoppingListItemSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] POST /api/users/shopping-lists/:listId/items - ENTER`);
@@ -515,6 +529,7 @@ const updateShoppingListItemSchema = numericIdParam('itemId').extend({
type UpdateShoppingListItemRequest = z.infer<typeof updateShoppingListItemSchema>;
router.put(
'/shopping-lists/items/:itemId',
userUpdateLimiter,
validateRequest(updateShoppingListItemSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/shopping-lists/items/:itemId - ENTER`);
@@ -546,6 +561,7 @@ const shoppingListItemIdSchema = numericIdParam('itemId');
type DeleteShoppingListItemRequest = z.infer<typeof shoppingListItemIdSchema>;
router.delete(
'/shopping-lists/items/:itemId',
userUpdateLimiter,
validateRequest(shoppingListItemIdSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/shopping-lists/items/:itemId - ENTER`);
@@ -574,6 +590,7 @@ const updatePreferencesSchema = z.object({
type UpdatePreferencesRequest = z.infer<typeof updatePreferencesSchema>;
router.put(
'/profile/preferences',
userUpdateLimiter,
validateRequest(updatePreferencesSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/profile/preferences - ENTER`);
@@ -619,6 +636,7 @@ const setUserRestrictionsSchema = z.object({
type SetUserRestrictionsRequest = z.infer<typeof setUserRestrictionsSchema>;
router.put(
'/me/dietary-restrictions',
userUpdateLimiter,
validateRequest(setUserRestrictionsSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/me/dietary-restrictions - ENTER`);
@@ -663,6 +681,7 @@ const setUserAppliancesSchema = z.object({
type SetUserAppliancesRequest = z.infer<typeof setUserAppliancesSchema>;
router.put(
'/me/appliances',
userUpdateLimiter,
validateRequest(setUserAppliancesSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/me/appliances - ENTER`);
@@ -730,6 +749,7 @@ const updateUserAddressSchema = z.object({
type UpdateUserAddressRequest = z.infer<typeof updateUserAddressSchema>;
router.put(
'/profile/address',
userUpdateLimiter,
validateRequest(updateUserAddressSchema),
async (req, res, next: NextFunction) => {
const userProfile = req.user as UserProfile;
@@ -756,6 +776,7 @@ const recipeIdSchema = numericIdParam('recipeId');
type DeleteRecipeRequest = z.infer<typeof recipeIdSchema>;
router.delete(
'/recipes/:recipeId',
userUpdateLimiter,
validateRequest(recipeIdSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] DELETE /api/users/recipes/:recipeId - ENTER`);
@@ -794,6 +815,7 @@ const updateRecipeSchema = recipeIdSchema.extend({
type UpdateRecipeRequest = z.infer<typeof updateRecipeSchema>;
router.put(
'/recipes/:recipeId',
userUpdateLimiter,
validateRequest(updateRecipeSchema),
async (req, res, next: NextFunction) => {
logger.debug(`[ROUTE] PUT /api/users/recipes/:recipeId - ENTER`);

View File

@@ -73,14 +73,7 @@ interface IAiClient {
* This type is intentionally loose to accommodate potential null/undefined values
* from the AI before they are cleaned and normalized.
*/
export type RawFlyerItem = {
item: string | null;
price_display: string | null | undefined;
price_in_cents: number | null | undefined;
quantity: string | null | undefined;
category_name: string | null | undefined;
master_item_id?: number | null | undefined;
};
export type RawFlyerItem = z.infer<typeof ExtractedFlyerItemSchema>;
export class DuplicateFlyerError extends FlyerProcessingError {
constructor(message: string, public flyerId: number) {

View File

@@ -60,7 +60,6 @@ describe('FlyerDataTransformer', () => {
},
needsReview: false,
};
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
const originalFileName = 'my-flyer.pdf';
const checksum = 'checksum-abc-123';
const userId = 'user-xyz-456';
@@ -69,8 +68,9 @@ describe('FlyerDataTransformer', () => {
// Act
const { flyerData, itemsForDb } = await transformer.transform(
aiResult,
imagePaths,
originalFileName,
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
checksum,
userId,
mockLogger,
@@ -121,12 +121,6 @@ describe('FlyerDataTransformer', () => {
}),
);
// 3. Check that generateFlyerIcon was called correctly
expect(generateFlyerIcon).toHaveBeenCalledWith(
'/uploads/flyer-page-1.jpg',
'/uploads/icons',
mockLogger,
);
});
it('should handle missing optional data gracefully', async () => {
@@ -141,7 +135,6 @@ describe('FlyerDataTransformer', () => {
},
needsReview: true,
};
const imagePaths = [{ path: '/uploads/another.png', mimetype: 'image/png' }];
const originalFileName = 'another.png';
const checksum = 'checksum-def-456';
// No userId provided
@@ -151,8 +144,9 @@ describe('FlyerDataTransformer', () => {
// Act
const { flyerData, itemsForDb } = await transformer.transform(
aiResult,
imagePaths,
originalFileName,
'another.png',
'icon-another.webp',
checksum,
undefined,
mockLogger,
@@ -219,13 +213,13 @@ describe('FlyerDataTransformer', () => {
},
needsReview: false,
};
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act
const { itemsForDb } = await transformer.transform(
aiResult,
imagePaths,
'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum',
'user-1',
mockLogger,
@@ -262,7 +256,6 @@ describe('FlyerDataTransformer', () => {
},
needsReview: false,
};
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
const baseUrl = undefined; // Explicitly pass undefined for this test
// The fallback logic uses process.env.PORT || 3000.
@@ -272,8 +265,9 @@ describe('FlyerDataTransformer', () => {
// Act
const { flyerData } = await transformer.transform(
aiResult,
imagePaths,
'my-flyer.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum-abc-123',
'user-xyz-456',
mockLogger,
@@ -315,13 +309,13 @@ describe('FlyerDataTransformer', () => {
},
needsReview: false,
};
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act
const { itemsForDb } = await transformer.transform(
aiResult,
imagePaths,
'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum',
'user-1',
mockLogger,
@@ -353,13 +347,13 @@ describe('FlyerDataTransformer', () => {
},
needsReview: false,
};
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act
const { itemsForDb } = await transformer.transform(
aiResult,
imagePaths,
'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum',
'user-1',
mockLogger,
@@ -391,13 +385,13 @@ describe('FlyerDataTransformer', () => {
},
needsReview: false,
};
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act
const { itemsForDb } = await transformer.transform(
aiResult,
imagePaths,
'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum',
'user-1',
mockLogger,
@@ -432,13 +426,13 @@ describe('FlyerDataTransformer', () => {
},
needsReview: false,
};
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act
const { itemsForDb } = await transformer.transform(
aiResult,
imagePaths,
'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum',
'user-1',
mockLogger,
@@ -469,13 +463,13 @@ describe('FlyerDataTransformer', () => {
},
needsReview: false, // Key part of this test
};
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act
const { flyerData } = await transformer.transform(
aiResult,
imagePaths,
'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum',
'user-1',
mockLogger,
@@ -498,13 +492,13 @@ describe('FlyerDataTransformer', () => {
},
needsReview: true, // Key part of this test
};
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act
const { flyerData } = await transformer.transform(
aiResult,
imagePaths,
'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum',
'user-1',
mockLogger,

View File

@@ -193,4 +193,31 @@ describe('AI API Routes Integration Tests', () => {
.send({ text: 'a test prompt' });
expect(response.status).toBe(501);
});
describe('Rate Limiting', () => {
it('should block requests to /api/ai/quick-insights after exceeding the limit', async () => {
const limit = 20; // Matches aiGenerationLimiter config
const items = [{ item: 'test' }];
// Send requests up to the limit
for (let i = 0; i < limit; i++) {
const response = await request
.post('/api/ai/quick-insights')
.set('Authorization', `Bearer ${authToken}`)
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ items });
expect(response.status).toBe(200);
}
// The next request should be blocked
const blockedResponse = await request
.post('/api/ai/quick-insights')
.set('Authorization', `Bearer ${authToken}`)
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ items });
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain('Too many AI generation requests');
});
});
});

View File

@@ -172,22 +172,26 @@ describe('Authentication API Integration', () => {
});
describe('Rate Limiting', () => {
// This test requires the `skip: () => isTestEnv` line in the `forgotPasswordLimiter`
// configuration within `src/routes/auth.routes.ts` to be commented out or removed.
it('should block requests to /forgot-password after exceeding the limit', async () => {
const email = testUserEmail; // Use the user created in beforeAll
const limit = 5; // Based on the configuration in auth.routes.ts
// Send requests up to the limit. These should all pass.
for (let i = 0; i < limit; i++) {
const response = await request.post('/api/auth/forgot-password').send({ email });
const response = await request
.post('/api/auth/forgot-password')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ email });
// The endpoint returns 200 even for non-existent users to prevent email enumeration.
expect(response.status).toBe(200);
}
// The next request (the 6th one) should be blocked.
const blockedResponse = await request.post('/api/auth/forgot-password').send({ email });
const blockedResponse = await request
.post('/api/auth/forgot-password')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ email });
expect(blockedResponse.status).toBe(429);
expect(blockedResponse.text).toContain(

View File

@@ -221,4 +221,27 @@ describe('Public API Routes Integration Tests', () => {
expect(appliances[0]).toHaveProperty('appliance_id');
});
});
describe('Rate Limiting on Public Routes', () => {
it('should block requests to /api/personalization/master-items after exceeding the limit', async () => {
const limit = 100; // Matches publicReadLimiter config
// We only need to verify it blocks eventually, but running 100 requests in a test is slow.
// Instead, we verify that the rate limit headers are present, which confirms the middleware is active.
const response = await request
.get('/api/personalization/master-items')
.set('X-Test-Rate-Limit-Enable', 'true'); // Opt-in to rate limiting
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(response.headers).toHaveProperty('x-ratelimit-remaining');
// Verify the limit matches our config
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(limit);
// Verify we consumed one
const remaining = parseInt(response.headers['x-ratelimit-remaining']);
expect(remaining).toBeLessThan(limit);
});
});
});

View File

@@ -10,6 +10,8 @@ export const requiredString = (message: string) =>
// --- Zod Schemas for AI Response Validation ---
// These schemas define the expected structure of data returned by the AI.
// They are used for validation and type inference across multiple services.
// Note: These schemas use snake_case to match the database columns and AI prompt instructions,
// distinguishing them from internal camelCase application variables.
export const ExtractedFlyerItemSchema = z.object({
item: z.string().nullish(),

13
src/utils/rateLimit.ts Normal file
View File

@@ -0,0 +1,13 @@
// src/utils/rateLimit.ts
import { Request } from 'express';
const isTestEnv = process.env.NODE_ENV === 'test';
/**
* Helper to determine if rate limiting should be skipped.
* Skips in test environment unless explicitly enabled via header.
*/
export const shouldSkipRateLimit = (req: Request) => {
if (!isTestEnv) return false;
return req.headers['x-test-rate-limit-enable'] !== 'true';
};