Compare commits

...

12 Commits

Author SHA1 Message Date
Gitea Actions
696c547238 ci: Bump version to 0.9.35 [skip ci] 2026-01-06 08:11:42 +05:00
38165bdb9a get rid of localhost in tests - not a qualified URL - we'll see
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m14s
2026-01-05 19:10:46 -08:00
Gitea Actions
6139dca072 ci: Bump version to 0.9.34 [skip ci] 2026-01-06 06:33:46 +05:00
68bfaa50e6 more baseurl work - hopefully that does it for now
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m5s
2026-01-05 17:33:00 -08:00
Gitea Actions
9c42621f74 ci: Bump version to 0.9.33 [skip ci] 2026-01-06 04:34:48 +05:00
1b98282202 more rate limiting
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 30m19s
2026-01-05 15:31:01 -08:00
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
52 changed files with 1105 additions and 131 deletions

View File

@@ -113,7 +113,7 @@ jobs:
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
# --- Integration test specific variables ---
FRONTEND_URL: 'http://localhost:3000'
FRONTEND_URL: 'http://example.com'
VITE_API_BASE_URL: 'http://localhost:3001/api'
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY }}
@@ -389,7 +389,7 @@ jobs:
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD_TEST }}
# Application Secrets
FRONTEND_URL: 'https://flyer-crawler-test.projectium.com'
FRONTEND_URL: 'http://example.com'
JWT_SECRET: ${{ secrets.JWT_SECRET }}
GEMINI_API_KEY: ${{ secrets.VITE_GOOGLE_GENAI_API_KEY_TEST }}
GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.9.29",
"version": "0.9.35",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.9.29",
"version": "0.9.35",
"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.29",
"version": "0.9.35",
"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();
@@ -27,6 +28,7 @@ const uploadAndProcessSchema = z.object({
.length(64, 'Checksum must be 64 characters long.')
.regex(/^[a-f0-9]+$/, 'Checksum must be a valid hexadecimal string.'),
),
baseUrl: z.string().url().optional(),
}),
});
@@ -165,6 +167,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.
@@ -196,6 +199,7 @@ router.post(
userProfile,
req.ip ?? 'unknown',
req.log,
body.baseUrl,
);
// Respond immediately to the client with 202 Accepted
@@ -221,6 +225,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 +276,7 @@ router.get(
*/
router.post(
'/flyers/process',
aiUploadLimiter,
optionalAuth,
uploadToDisk.single('flyerImage'),
async (req, res, next: NextFunction) => {
@@ -306,6 +312,7 @@ router.post(
*/
router.post(
'/check-flyer',
aiUploadLimiter,
optionalAuth,
uploadToDisk.single('image'),
async (req, res, next: NextFunction) => {
@@ -325,6 +332,7 @@ router.post(
router.post(
'/extract-address',
aiUploadLimiter,
optionalAuth,
uploadToDisk.single('image'),
async (req, res, next: NextFunction) => {
@@ -344,6 +352,7 @@ router.post(
router.post(
'/extract-logo',
aiUploadLimiter,
optionalAuth,
uploadToDisk.array('images'),
async (req, res, next: NextFunction) => {
@@ -363,6 +372,7 @@ router.post(
router.post(
'/quick-insights',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(insightsSchema),
async (req, res, next: NextFunction) => {
@@ -379,6 +389,7 @@ router.post(
router.post(
'/deep-dive',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(insightsSchema),
async (req, res, next: NextFunction) => {
@@ -395,6 +406,7 @@ router.post(
router.post(
'/search-web',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(searchWebSchema),
async (req, res, next: NextFunction) => {
@@ -409,6 +421,7 @@ router.post(
router.post(
'/compare-prices',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(comparePricesSchema),
async (req, res, next: NextFunction) => {
@@ -427,6 +440,7 @@ router.post(
router.post(
'/plan-trip',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(planTripSchema),
async (req, res, next: NextFunction) => {
@@ -446,6 +460,7 @@ router.post(
router.post(
'/generate-image',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(generateImageSchema),
(req: Request, res: Response) => {
@@ -458,6 +473,7 @@ router.post(
router.post(
'/generate-speech',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }),
validateRequest(generateSpeechSchema),
(req: Request, res: Response) => {
@@ -474,6 +490,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('ratelimit-limit');
expect(parseInt(response.headers['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('ratelimit-limit');
expect(parseInt(response.headers['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('ratelimit-limit');
expect(parseInt(response.headers['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('ratelimit-limit');
expect(parseInt(response.headers['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('ratelimit-limit');
expect(parseInt(response.headers['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('ratelimit-limit');
expect(parseInt(response.headers['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('ratelimit-limit');
expect(parseInt(response.headers['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('ratelimit-limit');
expect(parseInt(response.headers['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('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('ratelimit-limit');
expect(parseInt(response.headers['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('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('ratelimit-limit');
expect(parseInt(response.headers['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 AI generation 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('ratelimit-limit');
expect(parseInt(response.headers['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('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('ratelimit-limit');
expect(response.headers).toHaveProperty('ratelimit-remaining');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(limit);
expect(parseInt(response.headers['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

@@ -1030,7 +1030,7 @@ describe('User Routes (/api/users)', () => {
it('should upload an avatar and update the user profile', async () => {
const mockUpdatedProfile = createMockUserProfile({
...mockUserProfile,
avatar_url: 'http://localhost:3001/uploads/avatars/new-avatar.png',
avatar_url: 'http://example.com/uploads/avatars/new-avatar.png',
});
vi.mocked(userService.updateUserAvatar).mockResolvedValue(mockUpdatedProfile);
@@ -1042,7 +1042,7 @@ describe('User Routes (/api/users)', () => {
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200);
expect(response.body.avatar_url).toContain('http://localhost:3001/uploads/avatars/');
expect(response.body.avatar_url).toContain('http://example.com/uploads/avatars/');
expect(userService.updateUserAvatar).toHaveBeenCalledWith(
mockUserProfile.user.user_id,
expect.any(Object),
@@ -1235,5 +1235,96 @@ describe('User Routes (/api/users)', () => {
expect(logger.error).toHaveBeenCalled();
});
}); // End of Recipe Routes
describe('Rate Limiting', () => {
beforeAll(() => {
vi.useFakeTimers();
});
beforeEach(() => {
// Advance time to ensure rate limits are reset between tests
vi.advanceTimersByTime(2 * 60 * 60 * 1000);
});
afterAll(() => {
vi.useRealTimers();
});
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('ratelimit-limit');
expect(parseInt(response.headers['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('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(20);
});
it('should apply userSensitiveUpdateLimiter to DELETE /account and block after limit', async () => {
// Explicitly advance time to ensure the rate limiter window has reset from previous tests
vi.advanceTimersByTime(60 * 60 * 1000 + 5000);
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

@@ -116,7 +116,7 @@ interface MockFlyer {
updated_at: string;
}
const baseUrl = 'http://localhost:3001';
const baseUrl = 'http://example.com';
describe('AI Service (Server)', () => {
// Create mock dependencies that will be injected into the service
@@ -1015,7 +1015,7 @@ describe('AI Service (Server)', () => {
userId: 'user123',
submitterIp: '127.0.0.1',
userProfileAddress: '123 St, City, Country', // Partial address match based on filter(Boolean)
baseUrl: 'http://localhost:3000',
baseUrl: 'http://example.com',
});
expect(result.id).toBe('job123');
});
@@ -1037,7 +1037,7 @@ describe('AI Service (Server)', () => {
expect.objectContaining({
userId: undefined,
userProfileAddress: undefined,
baseUrl: 'http://localhost:3000',
baseUrl: 'http://example.com',
}),
);
});

View File

@@ -753,6 +753,7 @@ async enqueueFlyerProcessing(
userProfile: UserProfile | undefined,
submitterIp: string,
logger: Logger,
baseUrlOverride?: string,
): Promise<Job> {
// 1. Check for duplicate flyer
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
@@ -779,7 +780,7 @@ async enqueueFlyerProcessing(
.join(', ');
}
const baseUrl = getBaseUrl(logger);
const baseUrl = baseUrlOverride || getBaseUrl(logger);
// --- START DEBUGGING ---
// Add a fail-fast check to ensure the baseUrl is a valid URL before enqueuing.
// This will make the test fail at the upload step if the URL is the problem,

View File

@@ -59,7 +59,7 @@ describe('AuthService', () => {
// Set environment variables before any modules are imported
vi.stubEnv('JWT_SECRET', 'test-secret');
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000');
vi.stubEnv('FRONTEND_URL', 'http://example.com');
// Mock all dependencies before dynamically importing the service
// Core modules like bcrypt, jsonwebtoken, and crypto are now mocked globally in tests-setup-unit.ts

View File

@@ -132,8 +132,8 @@ describe('Flyer DB Service', () => {
it('should execute an INSERT query and return the new flyer', async () => {
const flyerData: FlyerDbInsert = {
file_name: 'test.jpg',
image_url: 'http://localhost:3001/images/test.jpg',
icon_url: 'http://localhost:3001/images/icons/test.jpg',
image_url: 'http://example.com/images/test.jpg',
icon_url: 'http://example.com/images/icons/test.jpg',
checksum: 'checksum123',
store_id: 1,
valid_from: '2024-01-01',
@@ -155,8 +155,8 @@ describe('Flyer DB Service', () => {
expect.stringContaining('INSERT INTO flyers'),
[
'test.jpg',
'http://localhost:3001/images/test.jpg',
'http://localhost:3001/images/icons/test.jpg',
'http://example.com/images/test.jpg',
'http://example.com/images/icons/test.jpg',
'checksum123',
1,
'2024-01-01',

View File

@@ -21,7 +21,7 @@ const createMockJobData = (data: Partial<FlyerJobData>): FlyerJobData => ({
filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg',
checksum: 'checksum-123',
baseUrl: 'http://localhost:3000',
baseUrl: 'http://example.com',
...data,
});

View File

@@ -5,6 +5,7 @@ import { logger as mockLogger } from './logger.server';
import { generateFlyerIcon } from '../utils/imageProcessor';
import type { AiProcessorResult } from './flyerAiProcessor.server';
import type { FlyerItemInsert } from '../types';
import { getBaseUrl } from '../utils/serverUtils';
// Mock the dependencies
vi.mock('../utils/imageProcessor', () => ({
@@ -15,6 +16,10 @@ vi.mock('./logger.server', () => ({
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
}));
vi.mock('../utils/serverUtils', () => ({
getBaseUrl: vi.fn(),
}));
describe('FlyerDataTransformer', () => {
let transformer: FlyerDataTransformer;
@@ -23,12 +28,13 @@ describe('FlyerDataTransformer', () => {
transformer = new FlyerDataTransformer();
// Stub environment variables to ensure consistency and predictability.
// Prioritize FRONTEND_URL to match the updated service logic.
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000');
vi.stubEnv('FRONTEND_URL', 'http://example.com');
vi.stubEnv('BASE_URL', ''); // Ensure this is not used to confirm priority logic
vi.stubEnv('PORT', ''); // Ensure this is not used
// Provide a default mock implementation for generateFlyerIcon
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-page-1.webp');
vi.mocked(getBaseUrl).mockReturnValue('http://example.com');
});
it('should transform AI data into database-ready format with a user ID', async () => {
@@ -244,7 +250,7 @@ describe('FlyerDataTransformer', () => {
);
});
it('should use fallback baseUrl if none is provided and log a warning', async () => {
it('should use fallback baseUrl from getBaseUrl if none is provided', async () => {
// Arrange
const aiResult: AiProcessorResult = {
data: {
@@ -258,9 +264,8 @@ describe('FlyerDataTransformer', () => {
};
const baseUrl = undefined; // Explicitly pass undefined for this test
// The fallback logic uses process.env.PORT || 3000.
// The beforeEach sets PORT to '', so it should fallback to 3000.
const expectedFallbackUrl = 'http://localhost:3000';
const expectedFallbackUrl = 'http://fallback-url.com';
vi.mocked(getBaseUrl).mockReturnValue(expectedFallbackUrl);
// Act
const { flyerData } = await transformer.transform(
@@ -275,10 +280,8 @@ describe('FlyerDataTransformer', () => {
);
// Assert
// 1. Check that a warning was logged
expect(mockLogger.warn).toHaveBeenCalledWith(
`Base URL not provided in job data. Falling back to default local URL: ${expectedFallbackUrl}`,
);
// 1. Check that getBaseUrl was called
expect(getBaseUrl).toHaveBeenCalledWith(mockLogger);
// 2. Check that the URLs were constructed with the fallback
expect(flyerData.image_url).toBe(`${expectedFallbackUrl}/flyer-images/flyer-page-1.jpg`);

View File

@@ -7,6 +7,7 @@ import type { AiProcessorResult } from './flyerAiProcessor.server'; // Keep this
import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema
import { TransformationError } from './processingErrors';
import { parsePriceToCents } from '../utils/priceParser';
import { getBaseUrl } from '../utils/serverUtils';
/**
* This class is responsible for transforming the validated data from the AI service
@@ -62,13 +63,7 @@ export class FlyerDataTransformer {
logger: Logger,
): { imageUrl: string; iconUrl: string } {
logger.debug({ imageFileName, iconFileName, baseUrl }, 'Building URLs');
let finalBaseUrl = baseUrl;
if (!finalBaseUrl) {
const port = process.env.PORT || 3000;
finalBaseUrl = `http://localhost:${port}`;
logger.warn(`Base URL not provided in job data. Falling back to default local URL: ${finalBaseUrl}`);
}
finalBaseUrl = finalBaseUrl.endsWith('/') ? finalBaseUrl.slice(0, -1) : finalBaseUrl;
const finalBaseUrl = baseUrl || getBaseUrl(logger);
const imageUrl = `${finalBaseUrl}/flyer-images/${imageFileName}`;
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
logger.debug({ imageUrl, iconUrl }, 'Constructed URLs');

View File

@@ -189,7 +189,7 @@ describe('FlyerProcessingService', () => {
filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg',
checksum: 'checksum-123',
baseUrl: 'http://localhost:3000',
baseUrl: 'http://example.com',
...data,
},
updateProgress: vi.fn(),
@@ -241,7 +241,7 @@ describe('FlyerProcessingService', () => {
'checksum-123', // checksum
undefined, // userId
expect.any(Object), // logger
'http://localhost:3000', // baseUrl
'http://example.com', // baseUrl
);
// 5. DB transaction was initiated
@@ -695,8 +695,8 @@ describe('FlyerProcessingService', () => {
it('should derive paths from DB and delete files if job paths are empty', async () => {
const job = createMockCleanupJob({ flyerId: 1, paths: [] }); // Empty paths
const mockFlyer = createMockFlyer({
image_url: 'http://localhost:3000/flyer-images/flyer-abc.jpg',
icon_url: 'http://localhost:3000/flyer-images/icons/icon-flyer-abc.webp',
image_url: 'http://example.com/flyer-images/flyer-abc.jpg',
icon_url: 'http://example.com/flyer-images/icons/icon-flyer-abc.webp',
});
// Mock DB call to return a flyer
vi.mocked(mockedDb.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer);

View File

@@ -7,6 +7,7 @@ import { ValidationError, NotFoundError } from './db/errors.db';
import { DatabaseError } from './processingErrors';
import type { Job } from 'bullmq';
import type { TokenCleanupJobData } from '../types/job-data';
import { getTestBaseUrl } from '../tests/utils/testHelpers';
// Un-mock the service under test to ensure we are testing the real implementation,
// not the global mock from `tests/setup/tests-setup-unit.ts`.
@@ -240,12 +241,12 @@ describe('UserService', () => {
describe('updateUserAvatar', () => {
it('should construct avatar URL and update profile', async () => {
const { logger } = await import('./logger.server');
const testBaseUrl = 'http://localhost:3001';
const testBaseUrl = getTestBaseUrl();
vi.stubEnv('FRONTEND_URL', testBaseUrl);
const userId = 'user-123';
const file = { filename: 'avatar.jpg' } as Express.Multer.File;
const expectedUrl = `${testBaseUrl}/uploads/avatars/avatar.jpg`;
const expectedUrl = `${testBaseUrl}/uploads/avatars/${file.filename}`;
mocks.mockUpdateUserProfile.mockResolvedValue({} as any);

View File

@@ -4,7 +4,7 @@ import supertest from 'supertest';
import app from '../../../server';
import { getPool } from '../../services/db/connection.db';
import type { UserProfile } from '../../types';
import { createAndLoginUser } from '../utils/testHelpers';
import { createAndLoginUser, TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
/**
@@ -164,7 +164,7 @@ describe('Admin API Routes Integration Tests', () => {
beforeEach(async () => {
const flyerRes = await getPool().query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
VALUES ($1, 'admin-test.jpg', 'https://example.com/flyer-images/asdmin-test.jpg', 'https://example.com/flyer-images/icons/admin-test.jpg', 1, $2) RETURNING flyer_id`,
VALUES ($1, 'admin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/asdmin-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/admin-test.jpg', 1, $2) RETURNING flyer_id`,
// The checksum must be a unique 64-character string to satisfy the DB constraint.
// We generate a dynamic string and pad it to 64 characters.
[testStoreId, `checksum-${Date.now()}-${Math.random()}`.padEnd(64, '0')],

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

@@ -9,7 +9,7 @@ import { getPool } from '../../services/db/connection.db';
import { generateFileChecksum } from '../../utils/checksum';
import { logger } from '../../services/logger.server';
import type { UserProfile, ExtractedFlyerItem } from '../../types';
import { createAndLoginUser } from '../utils/testHelpers';
import { createAndLoginUser, getTestBaseUrl } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { cleanupFiles } from '../utils/cleanupFiles';
@@ -57,7 +57,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
beforeAll(async () => {
// FIX: Stub FRONTEND_URL to ensure valid absolute URLs (http://...) are generated
// for the database, satisfying the 'url_check' constraint.
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000');
vi.stubEnv('FRONTEND_URL', 'http://example.com');
});
// FIX: Reset mocks before each test to ensure isolation.
@@ -130,7 +130,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
.field('checksum', checksum)
// Pass the baseUrl directly in the form data to ensure the worker receives it,
// bypassing issues with vi.stubEnv in multi-threaded test environments.
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.attach('flyerFile', uniqueContent, uniqueFileName);
if (token) {
uploadReq.set('Authorization', `Bearer ${token}`);
@@ -248,7 +248,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${token}`)
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.field('checksum', checksum)
.attach('flyerFile', imageWithExifBuffer, uniqueFileName);
@@ -333,7 +333,7 @@ describe('Flyer Processing Background Job Integration Test', () => {
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.set('Authorization', `Bearer ${token}`)
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.field('checksum', checksum)
.attach('flyerFile', imageWithMetadataBuffer, uniqueFileName);
@@ -399,7 +399,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);
@@ -451,7 +451,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);
@@ -505,7 +505,7 @@ it(
// Act 1: Upload the file to start the background job.
const uploadResponse = await request
.post('/api/ai/upload-and-process')
.field('baseUrl', 'http://localhost:3000')
.field('baseUrl', getTestBaseUrl())
.field('checksum', checksum)
.attach('flyerFile', uniqueContent, uniqueFileName);

View File

@@ -5,6 +5,7 @@ import { getPool } from '../../services/db/connection.db';
import app from '../../../server';
import type { Flyer, FlyerItem } from '../../types';
import { cleanupDb } from '../utils/cleanup';
import { TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
/**
* @vitest-environment node
@@ -27,7 +28,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
const flyerRes = await getPool().query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
VALUES ($1, 'integration-test.jpg', 'https://example.com/flyer-images/integration-test.jpg', 'https://example.com/flyer-images/icons/integration-test.jpg', 1, $2) RETURNING flyer_id`,
VALUES ($1, 'integration-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/integration-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/integration-test.jpg', 1, $2) RETURNING flyer_id`,
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
);
createdFlyerId = flyerRes.rows[0].flyer_id;

View File

@@ -5,7 +5,7 @@ import app from '../../../server';
import path from 'path';
import fs from 'node:fs/promises';
import { getPool } from '../../services/db/connection.db';
import { createAndLoginUser } from '../utils/testHelpers';
import { createAndLoginUser, getTestBaseUrl } from '../utils/testHelpers';
import { generateFileChecksum } from '../../utils/checksum';
import * as db from '../../services/db/index.db';
import { cleanupDb } from '../utils/cleanup';
@@ -69,7 +69,7 @@ describe('Gamification Flow Integration Test', () => {
// Stub environment variables for URL generation in the background worker.
// This needs to be in beforeAll to ensure it's set before any code that might use it is imported.
vi.stubEnv('FRONTEND_URL', 'http://localhost:3001');
vi.stubEnv('FRONTEND_URL', 'http://example.com');
// Setup default mock response for the AI service's extractCoreDataFromFlyerImage method.
mockExtractCoreData.mockResolvedValue({
@@ -253,7 +253,8 @@ describe('Gamification Flow Integration Test', () => {
// 8. Assert that the URLs are fully qualified.
expect(savedFlyer.image_url).to.equal(newFlyer.image_url);
expect(savedFlyer.icon_url).to.equal(newFlyer.icon_url);
expect(newFlyer.image_url).toContain('http://localhost:3001/flyer-images/');
const expectedBaseUrl = getTestBaseUrl();
expect(newFlyer.image_url).toContain(`${expectedBaseUrl}/flyer-images/`);
});
});
});

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import supertest from 'supertest';
import app from '../../../server';
import { getPool } from '../../services/db/connection.db';
import { TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
/**
* @vitest-environment node
@@ -35,21 +36,21 @@ describe('Price History API Integration Test (/api/price-history)', () => {
// 3. Create two flyers with different dates
const flyerRes1 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-1.jpg', 'https://example.com/flyer-images/price-test-1.jpg', 'https://example.com/flyer-images/icons/price-test-1.jpg', 1, $2, '2025-01-01') RETURNING flyer_id`,
VALUES ($1, 'price-test-1.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/price-test-1.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/price-test-1.jpg', 1, $2, '2025-01-01') RETURNING flyer_id`,
[storeId, `${Date.now().toString(16)}1`.padEnd(64, '0')],
);
flyerId1 = flyerRes1.rows[0].flyer_id;
const flyerRes2 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-2.jpg', 'https://example.com/flyer-images/price-test-2.jpg', 'https://example.com/flyer-images/icons/price-test-2.jpg', 1, $2, '2025-01-08') RETURNING flyer_id`,
VALUES ($1, 'price-test-2.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/price-test-2.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/price-test-2.jpg', 1, $2, '2025-01-08') RETURNING flyer_id`,
[storeId, `${Date.now().toString(16)}2`.padEnd(64, '0')],
);
flyerId2 = flyerRes2.rows[0].flyer_id; // This was a duplicate, fixed.
const flyerRes3 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from)
VALUES ($1, 'price-test-3.jpg', 'https://example.com/flyer-images/price-test-3.jpg', 'https://example.com/flyer-images/icons/price-test-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`,
VALUES ($1, 'price-test-3.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/price-test-3.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/price-test-3.jpg', 1, $2, '2025-01-15') RETURNING flyer_id`,
[storeId, `${Date.now().toString(16)}3`.padEnd(64, '0')],
);
flyerId3 = flyerRes3.rows[0].flyer_id;

View File

@@ -14,7 +14,7 @@ import type {
import { getPool } from '../../services/db/connection.db';
import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll';
import { createAndLoginUser } from '../utils/testHelpers';
import { createAndLoginUser, TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
/**
* @vitest-environment node
@@ -64,7 +64,7 @@ describe('Public API Routes Integration Tests', () => {
testStoreId = storeRes.rows[0].store_id;
const flyerRes = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum)
VALUES ($1, 'public-routes-test.jpg', 'https://example.com/flyer-images/public-routes-test.jpg', 'https://example.com/flyer-images/icons/public-routes-test.jpg', 1, $2) RETURNING *`,
VALUES ($1, 'public-routes-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/public-routes-test.jpg', '${TEST_EXAMPLE_DOMAIN}/flyer-images/icons/public-routes-test.jpg', 1, $2) RETURNING *`,
[testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
);
testFlyer = flyerRes.rows[0];
@@ -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

@@ -178,7 +178,7 @@ export const createMockFlyer = (
store_id: overrides.store_id ?? overrides.store?.store_id,
});
const baseUrl = 'http://localhost:3001'; // A reasonable default for tests
const baseUrl = 'http://example.com'; // A reasonable default for tests
// Determine the final file_name to generate dependent properties from.
const fileName = overrides.file_name ?? `flyer-${flyerId}.jpg`;

View File

@@ -5,6 +5,12 @@ import type { UserProfile } from '../../types';
import supertest from 'supertest';
export const TEST_PASSWORD = 'a-much-stronger-password-for-testing-!@#$';
export const TEST_EXAMPLE_DOMAIN = 'https://example.com';
export const getTestBaseUrl = (): string => {
const url = process.env.FRONTEND_URL || `http://example.com`;
return url.endsWith('/') ? url.slice(0, -1) : url;
};
interface CreateUserOptions {
email?: string;

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';
};

View File

@@ -56,29 +56,21 @@ describe('serverUtils', () => {
expect(mockLogger.warn).not.toHaveBeenCalled();
});
it('should fall back to localhost with default port 3000 if no URL is provided', () => {
it('should fall back to example.com with default port 3000 if no URL is provided', () => {
delete process.env.FRONTEND_URL;
delete process.env.BASE_URL;
delete process.env.PORT;
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('http://localhost:3000');
expect(baseUrl).toBe('http://example.com:3000');
expect(mockLogger.warn).not.toHaveBeenCalled();
});
it('should fall back to localhost with the specified PORT if no URL is provided', () => {
delete process.env.FRONTEND_URL;
delete process.env.BASE_URL;
process.env.PORT = '8888';
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('http://localhost:8888');
});
it('should log a warning and fall back if FRONTEND_URL is invalid (does not start with http)', () => {
process.env.FRONTEND_URL = 'invalid.url.com';
const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('http://localhost:3000');
expect(baseUrl).toBe('http://example.com');
expect(mockLogger.warn).toHaveBeenCalledWith(
"[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('invalid.url.com'). Falling back to default local URL: http://localhost:3000",
"[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('invalid.url.com'). Falling back to default local URL: http://example.com",
);
});
});

View File

@@ -14,7 +14,7 @@ export function getBaseUrl(logger: Logger): string {
let baseUrl = (process.env.FRONTEND_URL || process.env.BASE_URL || '').trim();
if (!baseUrl || !baseUrl.startsWith('http')) {
const port = process.env.PORT || 3000;
const fallbackUrl = `http://localhost:${port}`;
const fallbackUrl = `http://example.com:${port}`;
if (baseUrl) {
logger.warn(
`[getBaseUrl] FRONTEND_URL/BASE_URL is invalid or incomplete ('${baseUrl}'). Falling back to default local URL: ${fallbackUrl}`,