Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
696c547238 | ||
| 38165bdb9a | |||
|
|
6139dca072 | ||
| 68bfaa50e6 | |||
|
|
9c42621f74 | ||
| 1b98282202 | |||
|
|
b6731b220c | ||
| 3507d455e8 | |||
|
|
92b2adf8e8 | ||
| d6c7452256 | |||
|
|
d812b681dd | ||
| b4306a6092 |
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
147
src/config/rateLimiters.ts
Normal 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
|
||||
113
src/routes/admin.routes.test.ts
Normal file
113
src/routes/admin.routes.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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')],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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
13
src/utils/rateLimit.ts
Normal 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';
|
||||
};
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user