Compare commits

...

16 Commits

Author SHA1 Message Date
Gitea Actions
3c8316f4f7 ci: Bump version to 0.9.36 [skip ci] 2026-01-06 09:03:20 +05:00
2564df1c64 get rid of localhost in tests - not a qualified URL - we'll see
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 33m19s
2026-01-05 20:02:44 -08:00
Gitea Actions
696c547238 ci: Bump version to 0.9.35 [skip ci] 2026-01-06 08:11:42 +05:00
38165bdb9a get rid of localhost in tests - not a qualified URL - we'll see
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m14s
2026-01-05 19:10:46 -08:00
Gitea Actions
6139dca072 ci: Bump version to 0.9.34 [skip ci] 2026-01-06 06:33:46 +05:00
68bfaa50e6 more baseurl work - hopefully that does it for now
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 26m5s
2026-01-05 17:33:00 -08:00
Gitea Actions
9c42621f74 ci: Bump version to 0.9.33 [skip ci] 2026-01-06 04:34:48 +05:00
1b98282202 more rate limiting
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 30m19s
2026-01-05 15:31:01 -08:00
Gitea Actions
b6731b220c ci: Bump version to 0.9.32 [skip ci] 2026-01-06 04:13:42 +05:00
3507d455e8 more rate limiting
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Has been cancelled
2026-01-05 15:13:10 -08:00
Gitea Actions
92b2adf8e8 ci: Bump version to 0.9.31 [skip ci] 2026-01-06 04:07:21 +05:00
d6c7452256 more rate limiting
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
2026-01-05 15:06:55 -08:00
Gitea Actions
d812b681dd ci: Bump version to 0.9.30 [skip ci] 2026-01-06 03:54:42 +05:00
b4306a6092 more rate limiting
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 50s
2026-01-05 14:53:49 -08:00
Gitea Actions
57fdd159d5 ci: Bump version to 0.9.29 [skip ci] 2026-01-06 01:08:45 +05:00
4a747ca042 even even more and more test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 23m46s
2026-01-05 12:08:18 -08:00
70 changed files with 1215 additions and 209 deletions

View File

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

View File

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

4
package-lock.json generated
View File

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

View File

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

View File

@@ -628,7 +628,7 @@ describe('App Component', () => {
app: { app: {
version: '2.0.0', version: '2.0.0',
commitMessage: 'A new version!', commitMessage: 'A new version!',
commitUrl: 'http://example.com/commit/2.0.0', commitUrl: 'https://example.com/commit/2.0.0',
}, },
}, },
})); }));
@@ -638,7 +638,7 @@ describe('App Component', () => {
renderApp(); renderApp();
const versionLink = screen.getByText(`Version: 2.0.0`); const versionLink = screen.getByText(`Version: 2.0.0`);
expect(versionLink).toBeInTheDocument(); expect(versionLink).toBeInTheDocument();
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0'); expect(versionLink).toHaveAttribute('href', 'https://example.com/commit/2.0.0');
}); });
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => { it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {

View File

@@ -19,7 +19,7 @@ const mockedNotifyError = notifyError as Mocked<typeof notifyError>;
const defaultProps = { const defaultProps = {
isOpen: true, isOpen: true,
onClose: vi.fn(), onClose: vi.fn(),
imageUrl: 'http://example.com/flyer.jpg', imageUrl: 'https://example.com/flyer.jpg',
onDataExtracted: vi.fn(), onDataExtracted: vi.fn(),
}; };

View File

@@ -25,7 +25,7 @@ const mockLeaderboardData: LeaderboardUser[] = [
createMockLeaderboardUser({ createMockLeaderboardUser({
user_id: 'user-2', user_id: 'user-2',
full_name: 'Bob', full_name: 'Bob',
avatar_url: 'http://example.com/bob.jpg', avatar_url: 'https://example.com/bob.jpg',
points: 950, points: 950,
rank: '2', rank: '2',
}), }),
@@ -95,7 +95,7 @@ describe('Leaderboard', () => {
// Check for correct avatar URLs // Check for correct avatar URLs
const bobAvatar = screen.getByAltText('Bob') as HTMLImageElement; const bobAvatar = screen.getByAltText('Bob') as HTMLImageElement;
expect(bobAvatar.src).toBe('http://example.com/bob.jpg'); expect(bobAvatar.src).toBe('https://example.com/bob.jpg');
const aliceAvatar = screen.getByAltText('Alice') as HTMLImageElement; const aliceAvatar = screen.getByAltText('Alice') as HTMLImageElement;
expect(aliceAvatar.src).toContain('api.dicebear.com'); // Check for fallback avatar expect(aliceAvatar.src).toContain('api.dicebear.com'); // Check for fallback avatar

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

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

View File

@@ -160,9 +160,9 @@ describe('AnalysisPanel', () => {
results: { WEB_SEARCH: 'Search results text.' }, results: { WEB_SEARCH: 'Search results text.' },
sources: { sources: {
WEB_SEARCH: [ WEB_SEARCH: [
{ title: 'Valid Source', uri: 'http://example.com/source1' }, { title: 'Valid Source', uri: 'https://example.com/source1' },
{ title: 'Source without URI', uri: null }, { title: 'Source without URI', uri: null },
{ title: 'Another Valid Source', uri: 'http://example.com/source2' }, { title: 'Another Valid Source', uri: 'https://example.com/source2' },
], ],
}, },
loadingAnalysis: null, loadingAnalysis: null,
@@ -178,7 +178,7 @@ describe('AnalysisPanel', () => {
expect(screen.getByText('Sources:')).toBeInTheDocument(); expect(screen.getByText('Sources:')).toBeInTheDocument();
const source1 = screen.getByText('Valid Source'); const source1 = screen.getByText('Valid Source');
expect(source1).toBeInTheDocument(); expect(source1).toBeInTheDocument();
expect(source1.closest('a')).toHaveAttribute('href', 'http://example.com/source1'); expect(source1.closest('a')).toHaveAttribute('href', 'https://example.com/source1');
expect(screen.queryByText('Source without URI')).not.toBeInTheDocument(); expect(screen.queryByText('Source without URI')).not.toBeInTheDocument();
expect(screen.getByText('Another Valid Source')).toBeInTheDocument(); expect(screen.getByText('Another Valid Source')).toBeInTheDocument();
}); });
@@ -278,13 +278,13 @@ describe('AnalysisPanel', () => {
loadingAnalysis: null, loadingAnalysis: null,
error: null, error: null,
runAnalysis: mockRunAnalysis, runAnalysis: mockRunAnalysis,
generatedImageUrl: 'http://example.com/meal.jpg', generatedImageUrl: 'https://example.com/meal.jpg',
generateImage: mockGenerateImage, generateImage: mockGenerateImage,
}); });
rerender(<AnalysisPanel selectedFlyer={mockFlyer} />); rerender(<AnalysisPanel selectedFlyer={mockFlyer} />);
const image = screen.getByAltText('AI generated meal plan'); const image = screen.getByAltText('AI generated meal plan');
expect(image).toBeInTheDocument(); expect(image).toBeInTheDocument();
expect(image).toHaveAttribute('src', 'http://example.com/meal.jpg'); expect(image).toHaveAttribute('src', 'https://example.com/meal.jpg');
}); });
it('should not show sources for non-search analysis types', () => { it('should not show sources for non-search analysis types', () => {

View File

@@ -8,13 +8,13 @@ import { createMockStore } from '../../tests/utils/mockFactories';
const mockStore = createMockStore({ const mockStore = createMockStore({
store_id: 1, store_id: 1,
name: 'SuperMart', name: 'SuperMart',
logo_url: 'http://example.com/logo.png', logo_url: 'https://example.com/logo.png',
}); });
const mockOnOpenCorrectionTool = vi.fn(); const mockOnOpenCorrectionTool = vi.fn();
const defaultProps = { const defaultProps = {
imageUrl: 'http://example.com/flyer.jpg', imageUrl: 'https://example.com/flyer.jpg',
store: mockStore, store: mockStore,
validFrom: '2023-10-26', validFrom: '2023-10-26',
validTo: '2023-11-01', validTo: '2023-11-01',

View File

@@ -19,7 +19,7 @@ const mockFlyers: Flyer[] = [
flyer_id: 1, flyer_id: 1,
file_name: 'metro_flyer_oct_1.pdf', file_name: 'metro_flyer_oct_1.pdf',
item_count: 50, item_count: 50,
image_url: 'http://example.com/flyer1.jpg', image_url: 'https://example.com/flyer1.jpg',
store: { store_id: 101, name: 'Metro' }, store: { store_id: 101, name: 'Metro' },
valid_from: '2023-10-05', valid_from: '2023-10-05',
valid_to: '2023-10-11', valid_to: '2023-10-11',
@@ -29,7 +29,7 @@ const mockFlyers: Flyer[] = [
flyer_id: 2, flyer_id: 2,
file_name: 'walmart_flyer.pdf', file_name: 'walmart_flyer.pdf',
item_count: 75, item_count: 75,
image_url: 'http://example.com/flyer2.jpg', image_url: 'https://example.com/flyer2.jpg',
store: { store_id: 102, name: 'Walmart' }, store: { store_id: 102, name: 'Walmart' },
valid_from: '2023-10-06', valid_from: '2023-10-06',
valid_to: '2023-10-06', // Same day valid_to: '2023-10-06', // Same day
@@ -40,8 +40,8 @@ const mockFlyers: Flyer[] = [
flyer_id: 3, flyer_id: 3,
file_name: 'no-store-flyer.pdf', file_name: 'no-store-flyer.pdf',
item_count: 10, item_count: 10,
image_url: 'http://example.com/flyer3.jpg', image_url: 'https://example.com/flyer3.jpg',
icon_url: 'http://example.com/icon3.png', icon_url: 'https://example.com/icon3.png',
valid_from: '2023-10-07', valid_from: '2023-10-07',
valid_to: '2023-10-08', valid_to: '2023-10-08',
store_address: '456 Side St, Ottawa', store_address: '456 Side St, Ottawa',
@@ -53,7 +53,7 @@ const mockFlyers: Flyer[] = [
flyer_id: 4, flyer_id: 4,
file_name: 'bad-date-flyer.pdf', file_name: 'bad-date-flyer.pdf',
item_count: 5, item_count: 5,
image_url: 'http://example.com/flyer4.jpg', image_url: 'https://example.com/flyer4.jpg',
store: { store_id: 103, name: 'Date Store' }, store: { store_id: 103, name: 'Date Store' },
created_at: 'invalid-date', created_at: 'invalid-date',
valid_from: 'invalid-from', valid_from: 'invalid-from',
@@ -163,7 +163,7 @@ describe('FlyerList', () => {
const flyerWithIcon = screen.getByText('Unknown Store').closest('li'); // Flyer ID 3 const flyerWithIcon = screen.getByText('Unknown Store').closest('li'); // Flyer ID 3
const iconImage = flyerWithIcon?.querySelector('img'); const iconImage = flyerWithIcon?.querySelector('img');
expect(iconImage).toBeInTheDocument(); expect(iconImage).toBeInTheDocument();
expect(iconImage).toHaveAttribute('src', 'http://example.com/icon3.png'); expect(iconImage).toHaveAttribute('src', 'https://example.com/icon3.png');
}); });
it('should render a document icon when icon_url is not present', () => { it('should render a document icon when icon_url is not present', () => {

View File

@@ -15,8 +15,8 @@ describe('useFlyerItems Hook', () => {
const mockFlyer = createMockFlyer({ const mockFlyer = createMockFlyer({
flyer_id: 123, flyer_id: 123,
file_name: 'test-flyer.jpg', file_name: 'test-flyer.jpg',
image_url: 'http://example.com/test.jpg', image_url: 'https://example.com/test.jpg',
icon_url: 'http://example.com/icon.jpg', icon_url: 'https://example.com/icon.jpg',
checksum: 'abc', checksum: 'abc',
valid_from: '2024-01-01', valid_from: '2024-01-01',
valid_to: '2024-01-07', valid_to: '2024-01-07',

View File

@@ -72,7 +72,7 @@ describe('useFlyers Hook and FlyersProvider', () => {
createMockFlyer({ createMockFlyer({
flyer_id: 1, flyer_id: 1,
file_name: 'flyer1.jpg', file_name: 'flyer1.jpg',
image_url: 'http://example.com/flyer1.jpg', image_url: 'https://example.com/flyer1.jpg',
item_count: 5, item_count: 5,
created_at: '2024-01-01', created_at: '2024-01-01',
}), }),

View File

@@ -79,7 +79,7 @@ describe('HomePage Component', () => {
describe('when a flyer is selected', () => { describe('when a flyer is selected', () => {
const mockFlyer: Flyer = createMockFlyer({ const mockFlyer: Flyer = createMockFlyer({
flyer_id: 1, flyer_id: 1,
image_url: 'http://example.com/flyer.jpg', image_url: 'https://example.com/flyer.jpg',
}); });
it('should render FlyerDisplay but not data tables if there are no flyer items', () => { it('should render FlyerDisplay but not data tables if there are no flyer items', () => {

View File

@@ -26,7 +26,7 @@ const mockedApiClient = vi.mocked(apiClient);
const mockProfile: UserProfile = createMockUserProfile({ const mockProfile: UserProfile = createMockUserProfile({
user: createMockUser({ user_id: 'user-123', email: 'test@example.com' }), user: createMockUser({ user_id: 'user-123', email: 'test@example.com' }),
full_name: 'Test User', full_name: 'Test User',
avatar_url: 'http://example.com/avatar.jpg', avatar_url: 'https://example.com/avatar.jpg',
points: 150, points: 150,
role: 'user', role: 'user',
}); });
@@ -359,7 +359,7 @@ describe('UserProfilePage', () => {
}); });
it('should upload a new avatar and update the image source', async () => { it('should upload a new avatar and update the image source', async () => {
const updatedProfile = { ...mockProfile, avatar_url: 'http://example.com/new-avatar.png' }; const updatedProfile = { ...mockProfile, avatar_url: 'https://example.com/new-avatar.png' };
// Log when the mock is called // Log when the mock is called
mockedApiClient.uploadAvatar.mockImplementation((file) => { mockedApiClient.uploadAvatar.mockImplementation((file) => {

View File

@@ -30,7 +30,7 @@ const mockLogs: ActivityLogItem[] = [
user_id: 'user-123', user_id: 'user-123',
action: 'flyer_processed', action: 'flyer_processed',
display_text: 'Processed a new flyer for Walmart.', display_text: 'Processed a new flyer for Walmart.',
user_avatar_url: 'http://example.com/avatar.png', user_avatar_url: 'https://example.com/avatar.png',
user_full_name: 'Test User', user_full_name: 'Test User',
details: { flyer_id: 1, store_name: 'Walmart' }, details: { flyer_id: 1, store_name: 'Walmart' },
}), }),
@@ -63,7 +63,7 @@ const mockLogs: ActivityLogItem[] = [
action: 'recipe_favorited', action: 'recipe_favorited',
display_text: 'User favorited a recipe', display_text: 'User favorited a recipe',
user_full_name: 'Pizza Lover', user_full_name: 'Pizza Lover',
user_avatar_url: 'http://example.com/pizza.png', user_avatar_url: 'https://example.com/pizza.png',
details: { recipe_name: 'Best Pizza' }, details: { recipe_name: 'Best Pizza' },
}), }),
createMockActivityLogItem({ createMockActivityLogItem({
@@ -136,7 +136,7 @@ describe('ActivityLog', () => {
// Check for avatar // Check for avatar
const avatar = screen.getByAltText('Test User'); const avatar = screen.getByAltText('Test User');
expect(avatar).toBeInTheDocument(); expect(avatar).toBeInTheDocument();
expect(avatar).toHaveAttribute('src', 'http://example.com/avatar.png'); expect(avatar).toHaveAttribute('src', 'https://example.com/avatar.png');
// Check for fallback avatar (Newbie User has no avatar) // Check for fallback avatar (Newbie User has no avatar)
// The fallback is an SVG inside a span. We can check for the span's class or the SVG. // The fallback is an SVG inside a span. We can check for the span's class or the SVG.

View File

@@ -59,14 +59,14 @@ describe('FlyerReviewPage', () => {
file_name: 'flyer1.jpg', file_name: 'flyer1.jpg',
created_at: '2023-01-01T00:00:00Z', created_at: '2023-01-01T00:00:00Z',
store: { name: 'Store A' }, store: { name: 'Store A' },
icon_url: 'http://example.com/icon1.jpg', icon_url: 'https://example.com/icon1.jpg',
}, },
{ {
flyer_id: 2, flyer_id: 2,
file_name: 'flyer2.jpg', file_name: 'flyer2.jpg',
created_at: '2023-01-02T00:00:00Z', created_at: '2023-01-02T00:00:00Z',
store: { name: 'Store B' }, store: { name: 'Store B' },
icon_url: 'http://example.com/icon2.jpg', icon_url: 'https://example.com/icon2.jpg',
}, },
{ {
flyer_id: 3, flyer_id: 3,

View File

@@ -19,7 +19,7 @@ const mockBrands = [
brand_id: 2, brand_id: 2,
name: 'Compliments', name: 'Compliments',
store_name: 'Sobeys', store_name: 'Sobeys',
logo_url: 'http://example.com/compliments.png', logo_url: 'https://example.com/compliments.png',
}), }),
]; ];
@@ -92,7 +92,7 @@ describe('AdminBrandManager', () => {
); );
mockedApiClient.uploadBrandLogo.mockImplementation( mockedApiClient.uploadBrandLogo.mockImplementation(
async () => async () =>
new Response(JSON.stringify({ logoUrl: 'http://example.com/new-logo.png' }), { new Response(JSON.stringify({ logoUrl: 'https://example.com/new-logo.png' }), {
status: 200, status: 200,
}), }),
); );
@@ -120,7 +120,7 @@ describe('AdminBrandManager', () => {
// Check if the UI updates with the new logo // Check if the UI updates with the new logo
expect(screen.getByAltText('No Frills logo')).toHaveAttribute( expect(screen.getByAltText('No Frills logo')).toHaveAttribute(
'src', 'src',
'http://example.com/new-logo.png', 'https://example.com/new-logo.png',
); );
console.log('TEST SUCCESS: All assertions for successful upload passed.'); console.log('TEST SUCCESS: All assertions for successful upload passed.');
}); });
@@ -350,7 +350,7 @@ describe('AdminBrandManager', () => {
// Brand 2 should still have original logo // Brand 2 should still have original logo
expect(screen.getByAltText('Compliments logo')).toHaveAttribute( expect(screen.getByAltText('Compliments logo')).toHaveAttribute(
'src', 'src',
'http://example.com/compliments.png', 'https://example.com/compliments.png',
); );
}); });
}); });

View File

@@ -35,7 +35,7 @@ const authenticatedUser = createMockUser({ user_id: 'auth-user-123', email: 'tes
const mockAddressId = 123; const mockAddressId = 123;
const authenticatedProfile = createMockUserProfile({ const authenticatedProfile = createMockUserProfile({
full_name: 'Test User', full_name: 'Test User',
avatar_url: 'http://example.com/avatar.png', avatar_url: 'https://example.com/avatar.png',
role: 'user', role: 'user',
points: 100, points: 100,
preferences: { preferences: {

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import { validateRequest } from '../middleware/validation.middleware';
import { requiredString } from '../utils/zodUtils'; import { requiredString } from '../utils/zodUtils';
import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils'; import { cleanupUploadedFile, cleanupUploadedFiles } from '../utils/fileUtils';
import { monitoringService } from '../services/monitoringService.server'; import { monitoringService } from '../services/monitoringService.server';
import { aiUploadLimiter, aiGenerationLimiter } from '../config/rateLimiters';
const router = Router(); const router = Router();
@@ -27,6 +28,7 @@ const uploadAndProcessSchema = z.object({
.length(64, 'Checksum must be 64 characters long.') .length(64, 'Checksum must be 64 characters long.')
.regex(/^[a-f0-9]+$/, 'Checksum must be a valid hexadecimal string.'), .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( router.post(
'/upload-and-process', '/upload-and-process',
aiUploadLimiter,
optionalAuth, optionalAuth,
uploadToDisk.single('flyerFile'), uploadToDisk.single('flyerFile'),
// Validation is now handled inside the route to ensure file cleanup on failure. // Validation is now handled inside the route to ensure file cleanup on failure.
@@ -196,6 +199,7 @@ router.post(
userProfile, userProfile,
req.ip ?? 'unknown', req.ip ?? 'unknown',
req.log, req.log,
body.baseUrl,
); );
// Respond immediately to the client with 202 Accepted // Respond immediately to the client with 202 Accepted
@@ -221,6 +225,7 @@ router.post(
*/ */
router.post( router.post(
'/upload-legacy', '/upload-legacy',
aiUploadLimiter,
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),
uploadToDisk.single('flyerFile'), uploadToDisk.single('flyerFile'),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@@ -271,6 +276,7 @@ router.get(
*/ */
router.post( router.post(
'/flyers/process', '/flyers/process',
aiUploadLimiter,
optionalAuth, optionalAuth,
uploadToDisk.single('flyerImage'), uploadToDisk.single('flyerImage'),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
@@ -306,6 +312,7 @@ router.post(
*/ */
router.post( router.post(
'/check-flyer', '/check-flyer',
aiUploadLimiter,
optionalAuth, optionalAuth,
uploadToDisk.single('image'), uploadToDisk.single('image'),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
@@ -325,6 +332,7 @@ router.post(
router.post( router.post(
'/extract-address', '/extract-address',
aiUploadLimiter,
optionalAuth, optionalAuth,
uploadToDisk.single('image'), uploadToDisk.single('image'),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
@@ -344,6 +352,7 @@ router.post(
router.post( router.post(
'/extract-logo', '/extract-logo',
aiUploadLimiter,
optionalAuth, optionalAuth,
uploadToDisk.array('images'), uploadToDisk.array('images'),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
@@ -363,6 +372,7 @@ router.post(
router.post( router.post(
'/quick-insights', '/quick-insights',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),
validateRequest(insightsSchema), validateRequest(insightsSchema),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
@@ -379,6 +389,7 @@ router.post(
router.post( router.post(
'/deep-dive', '/deep-dive',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),
validateRequest(insightsSchema), validateRequest(insightsSchema),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
@@ -395,6 +406,7 @@ router.post(
router.post( router.post(
'/search-web', '/search-web',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),
validateRequest(searchWebSchema), validateRequest(searchWebSchema),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
@@ -409,6 +421,7 @@ router.post(
router.post( router.post(
'/compare-prices', '/compare-prices',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),
validateRequest(comparePricesSchema), validateRequest(comparePricesSchema),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
@@ -427,6 +440,7 @@ router.post(
router.post( router.post(
'/plan-trip', '/plan-trip',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),
validateRequest(planTripSchema), validateRequest(planTripSchema),
async (req, res, next: NextFunction) => { async (req, res, next: NextFunction) => {
@@ -446,6 +460,7 @@ router.post(
router.post( router.post(
'/generate-image', '/generate-image',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),
validateRequest(generateImageSchema), validateRequest(generateImageSchema),
(req: Request, res: Response) => { (req: Request, res: Response) => {
@@ -458,6 +473,7 @@ router.post(
router.post( router.post(
'/generate-speech', '/generate-speech',
aiGenerationLimiter,
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),
validateRequest(generateSpeechSchema), validateRequest(generateSpeechSchema),
(req: Request, res: Response) => { (req: Request, res: Response) => {
@@ -474,6 +490,7 @@ router.post(
*/ */
router.post( router.post(
'/rescan-area', '/rescan-area',
aiUploadLimiter,
passport.authenticate('jwt', { session: false }), passport.authenticate('jwt', { session: false }),
uploadToDisk.single('image'), uploadToDisk.single('image'),
validateRequest(rescanAreaSchema), validateRequest(rescanAreaSchema),

View File

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

View File

@@ -1,7 +1,6 @@
// src/routes/auth.routes.ts // src/routes/auth.routes.ts
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import rateLimit from 'express-rate-limit';
import passport from './passport.routes'; import passport from './passport.routes';
import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks import { UniqueConstraintError } from '../services/db/errors.db'; // Import actual class for instanceof checks
import { logger } from '../services/logger.server'; import { logger } from '../services/logger.server';
@@ -9,39 +8,18 @@ import { validateRequest } from '../middleware/validation.middleware';
import type { UserProfile } from '../types'; import type { UserProfile } from '../types';
import { validatePasswordStrength } from '../utils/authUtils'; import { validatePasswordStrength } from '../utils/authUtils';
import { requiredString } from '../utils/zodUtils'; import { requiredString } from '../utils/zodUtils';
import {
loginLimiter,
registerLimiter,
forgotPasswordLimiter,
resetPasswordLimiter,
refreshTokenLimiter,
logoutLimiter,
} from '../config/rateLimiters';
import { authService } from '../services/authService'; import { authService } from '../services/authService';
const router = Router(); 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 --- // --- Reusable Schemas ---
const passwordSchema = z const passwordSchema = z
@@ -95,6 +73,7 @@ const resetPasswordSchema = z.object({
// Registration Route // Registration Route
router.post( router.post(
'/register', '/register',
registerLimiter,
validateRequest(registerSchema), validateRequest(registerSchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
type RegisterRequest = z.infer<typeof registerSchema>; type RegisterRequest = z.infer<typeof registerSchema>;
@@ -134,6 +113,7 @@ router.post(
// Login Route // Login Route
router.post( router.post(
'/login', '/login',
loginLimiter,
validateRequest(loginSchema), validateRequest(loginSchema),
(req: Request, res: Response, next: NextFunction) => { (req: Request, res: Response, next: NextFunction) => {
passport.authenticate( passport.authenticate(
@@ -238,7 +218,7 @@ router.post(
); );
// New Route to refresh the access token // 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; const { refreshToken } = req.cookies;
if (!refreshToken) { if (!refreshToken) {
return res.status(401).json({ message: 'Refresh token not found.' }); 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 * It clears the refresh token from the database and instructs the client to
* expire the `refreshToken` cookie. * 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; const { refreshToken } = req.cookies;
if (refreshToken) { if (refreshToken) {
// Invalidate the token in the database so it cannot be used again. // Invalidate the token in the database so it cannot be used again.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,7 +40,7 @@ describe('Personalization Routes (/api/personalization)', () => {
const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })]; const mockItems = [createMockMasterGroceryItem({ master_grocery_item_id: 1, name: 'Milk' })];
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue(mockItems); vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue(mockItems);
const response = await supertest(app).get('/api/personalization/master-items'); const response = await supertest(app).get('/api/personalization/master-items').set('x-test-rate-limit-enable', 'true');
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual(mockItems); expect(response.body).toEqual(mockItems);
@@ -49,7 +49,7 @@ describe('Personalization Routes (/api/personalization)', () => {
it('should return 500 if the database call fails', async () => { it('should return 500 if the database call fails', async () => {
const dbError = new Error('DB Error'); const dbError = new Error('DB Error');
vi.mocked(db.personalizationRepo.getAllMasterItems).mockRejectedValue(dbError); vi.mocked(db.personalizationRepo.getAllMasterItems).mockRejectedValue(dbError);
const response = await supertest(app).get('/api/personalization/master-items'); const response = await supertest(app).get('/api/personalization/master-items').set('x-test-rate-limit-enable', 'true');
expect(response.status).toBe(500); expect(response.status).toBe(500);
expect(response.body.message).toBe('DB Error'); expect(response.body.message).toBe('DB Error');
expect(mockLogger.error).toHaveBeenCalledWith( expect(mockLogger.error).toHaveBeenCalledWith(
@@ -106,4 +106,16 @@ describe('Personalization Routes (/api/personalization)', () => {
); );
}); });
}); });
describe('Rate Limiting', () => {
it('should apply publicReadLimiter to GET /master-items', async () => {
vi.mocked(db.personalizationRepo.getAllMasterItems).mockResolvedValue([]);
const response = await supertest(app)
.get('/api/personalization/master-items')
.set('X-Test-Rate-Limit-Enable', 'true');
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
});
});
}); });

View File

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

View File

@@ -1,8 +1,10 @@
// src/routes/price.routes.test.ts // src/routes/price.routes.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import type { Request, Response, NextFunction } from 'express';
import { createTestApp } from '../tests/utils/createTestApp'; import { createTestApp } from '../tests/utils/createTestApp';
import { mockLogger } from '../tests/utils/mockLogger'; import { mockLogger } from '../tests/utils/mockLogger';
import { createMockUserProfile } from '../tests/utils/mockFactories';
// Mock the price repository // Mock the price repository
vi.mock('../services/db/price.db', () => ({ vi.mock('../services/db/price.db', () => ({
@@ -17,12 +19,29 @@ vi.mock('../services/logger.server', async () => ({
logger: (await import('../tests/utils/mockLogger')).mockLogger, 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 the router AFTER other setup.
import priceRouter from './price.routes'; import priceRouter from './price.routes';
import { priceRepo } from '../services/db/price.db'; import { priceRepo } from '../services/db/price.db';
describe('Price Routes (/api/price-history)', () => { 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(() => { beforeEach(() => {
vi.clearAllMocks(); 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'); expect(response.body.errors[1].message).toBe('Invalid input: expected number, received NaN');
}); });
}); });
describe('Rate Limiting', () => {
it('should apply priceHistoryLimiter to POST /', async () => {
vi.mocked(priceRepo.getPriceHistory).mockResolvedValue([]);
const response = await supertest(app)
.post('/api/price-history')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ masterItemIds: [1, 2] });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(50);
});
});
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1030,7 +1030,7 @@ describe('User Routes (/api/users)', () => {
it('should upload an avatar and update the user profile', async () => { it('should upload an avatar and update the user profile', async () => {
const mockUpdatedProfile = createMockUserProfile({ const mockUpdatedProfile = createMockUserProfile({
...mockUserProfile, ...mockUserProfile,
avatar_url: 'http://localhost:3001/uploads/avatars/new-avatar.png', avatar_url: 'https://example.com/uploads/avatars/new-avatar.png',
}); });
vi.mocked(userService.updateUserAvatar).mockResolvedValue(mockUpdatedProfile); vi.mocked(userService.updateUserAvatar).mockResolvedValue(mockUpdatedProfile);
@@ -1042,7 +1042,7 @@ describe('User Routes (/api/users)', () => {
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath); .attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body.avatar_url).toContain('http://localhost:3001/uploads/avatars/'); expect(response.body.avatar_url).toContain('https://example.com/uploads/avatars/');
expect(userService.updateUserAvatar).toHaveBeenCalledWith( expect(userService.updateUserAvatar).toHaveBeenCalledWith(
mockUserProfile.user.user_id, mockUserProfile.user.user_id,
expect.any(Object), expect.any(Object),
@@ -1235,5 +1235,96 @@ describe('User Routes (/api/users)', () => {
expect(logger.error).toHaveBeenCalled(); expect(logger.error).toHaveBeenCalled();
}); });
}); // End of Recipe Routes }); // End of Recipe Routes
describe('Rate Limiting', () => {
beforeAll(() => {
vi.useFakeTimers();
});
beforeEach(() => {
// Advance time to ensure rate limits are reset between tests
vi.advanceTimersByTime(2 * 60 * 60 * 1000);
});
afterAll(() => {
vi.useRealTimers();
});
it('should apply userUpdateLimiter to PUT /profile', async () => {
vi.mocked(db.userRepo.updateUserProfile).mockResolvedValue(mockUserProfile);
const response = await supertest(app)
.put('/api/users/profile')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ full_name: 'Rate Limit Test' });
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(100);
});
it('should apply userSensitiveUpdateLimiter to PUT /profile/password and block after limit', async () => {
const limit = 5;
vi.mocked(userService.updateUserPassword).mockResolvedValue(undefined);
// Consume the limit
for (let i = 0; i < limit; i++) {
const response = await supertest(app)
.put('/api/users/profile/password')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ newPassword: 'StrongPassword123!' });
expect(response.status).toBe(200);
}
// Next request should be blocked
const response = await supertest(app)
.put('/api/users/profile/password')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ newPassword: 'StrongPassword123!' });
expect(response.status).toBe(429);
expect(response.text).toContain('Too many sensitive requests');
});
it('should apply userUploadLimiter to POST /profile/avatar', async () => {
vi.mocked(userService.updateUserAvatar).mockResolvedValue(mockUserProfile);
const dummyImagePath = 'test-avatar.png';
const response = await supertest(app)
.post('/api/users/profile/avatar')
.set('X-Test-Rate-Limit-Enable', 'true')
.attach('avatar', Buffer.from('dummy-image-content'), dummyImagePath);
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('ratelimit-limit');
expect(parseInt(response.headers['ratelimit-limit'])).toBe(20);
});
it('should apply userSensitiveUpdateLimiter to DELETE /account and block after limit', async () => {
// Explicitly advance time to ensure the rate limiter window has reset from previous tests
vi.advanceTimersByTime(60 * 60 * 1000 + 5000);
const limit = 5;
vi.mocked(userService.deleteUserAccount).mockResolvedValue(undefined);
// Consume the limit
for (let i = 0; i < limit; i++) {
const response = await supertest(app)
.delete('/api/users/account')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ password: 'correct-password' });
expect(response.status).toBe(200);
}
// Next request should be blocked
const response = await supertest(app)
.delete('/api/users/account')
.set('X-Test-Rate-Limit-Enable', 'true')
.send({ password: 'correct-password' });
expect(response.status).toBe(429);
expect(response.text).toContain('Too many sensitive requests');
});
});
}); });
}); });

View File

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

View File

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

View File

@@ -73,14 +73,7 @@ interface IAiClient {
* This type is intentionally loose to accommodate potential null/undefined values * This type is intentionally loose to accommodate potential null/undefined values
* from the AI before they are cleaned and normalized. * from the AI before they are cleaned and normalized.
*/ */
export type RawFlyerItem = { export type RawFlyerItem = z.infer<typeof ExtractedFlyerItemSchema>;
item: string | null;
price_display: string | null | undefined;
price_in_cents: number | null | undefined;
quantity: string | null | undefined;
category_name: string | null | undefined;
master_item_id?: number | null | undefined;
};
export class DuplicateFlyerError extends FlyerProcessingError { export class DuplicateFlyerError extends FlyerProcessingError {
constructor(message: string, public flyerId: number) { constructor(message: string, public flyerId: number) {
@@ -760,6 +753,7 @@ async enqueueFlyerProcessing(
userProfile: UserProfile | undefined, userProfile: UserProfile | undefined,
submitterIp: string, submitterIp: string,
logger: Logger, logger: Logger,
baseUrlOverride?: string,
): Promise<Job> { ): Promise<Job> {
// 1. Check for duplicate flyer // 1. Check for duplicate flyer
const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger); const existingFlyer = await db.flyerRepo.findFlyerByChecksum(checksum, logger);
@@ -786,7 +780,7 @@ async enqueueFlyerProcessing(
.join(', '); .join(', ');
} }
const baseUrl = getBaseUrl(logger); const baseUrl = baseUrlOverride || getBaseUrl(logger);
// --- START DEBUGGING --- // --- START DEBUGGING ---
// Add a fail-fast check to ensure the baseUrl is a valid URL before enqueuing. // 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, // This will make the test fail at the upload step if the URL is the problem,

View File

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

View File

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

View File

@@ -596,7 +596,7 @@ describe('Shopping DB Service', () => {
const mockReceipt = { const mockReceipt = {
receipt_id: 1, receipt_id: 1,
user_id: 'user-1', user_id: 'user-1',
receipt_image_url: 'http://example.com/receipt.jpg', receipt_image_url: 'https://example.com/receipt.jpg',
status: 'pending', status: 'pending',
}; };
mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt] }); mockPoolInstance.query.mockResolvedValue({ rows: [mockReceipt] });

View File

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

View File

@@ -5,6 +5,7 @@ import { logger as mockLogger } from './logger.server';
import { generateFlyerIcon } from '../utils/imageProcessor'; import { generateFlyerIcon } from '../utils/imageProcessor';
import type { AiProcessorResult } from './flyerAiProcessor.server'; import type { AiProcessorResult } from './flyerAiProcessor.server';
import type { FlyerItemInsert } from '../types'; import type { FlyerItemInsert } from '../types';
import { getBaseUrl } from '../utils/serverUtils';
// Mock the dependencies // Mock the dependencies
vi.mock('../utils/imageProcessor', () => ({ 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() }, logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
})); }));
vi.mock('../utils/serverUtils', () => ({
getBaseUrl: vi.fn(),
}));
describe('FlyerDataTransformer', () => { describe('FlyerDataTransformer', () => {
let transformer: FlyerDataTransformer; let transformer: FlyerDataTransformer;
@@ -23,12 +28,13 @@ describe('FlyerDataTransformer', () => {
transformer = new FlyerDataTransformer(); transformer = new FlyerDataTransformer();
// Stub environment variables to ensure consistency and predictability. // Stub environment variables to ensure consistency and predictability.
// Prioritize FRONTEND_URL to match the updated service logic. // Prioritize FRONTEND_URL to match the updated service logic.
vi.stubEnv('FRONTEND_URL', 'http://localhost:3000'); vi.stubEnv('FRONTEND_URL', 'https://example.com');
vi.stubEnv('BASE_URL', ''); // Ensure this is not used to confirm priority logic vi.stubEnv('BASE_URL', ''); // Ensure this is not used to confirm priority logic
vi.stubEnv('PORT', ''); // Ensure this is not used vi.stubEnv('PORT', ''); // Ensure this is not used
// Provide a default mock implementation for generateFlyerIcon // Provide a default mock implementation for generateFlyerIcon
vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-page-1.webp'); vi.mocked(generateFlyerIcon).mockResolvedValue('icon-flyer-page-1.webp');
vi.mocked(getBaseUrl).mockReturnValue('https://example.com');
}); });
it('should transform AI data into database-ready format with a user ID', async () => { it('should transform AI data into database-ready format with a user ID', async () => {
@@ -60,7 +66,6 @@ describe('FlyerDataTransformer', () => {
}, },
needsReview: false, needsReview: false,
}; };
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
const originalFileName = 'my-flyer.pdf'; const originalFileName = 'my-flyer.pdf';
const checksum = 'checksum-abc-123'; const checksum = 'checksum-abc-123';
const userId = 'user-xyz-456'; const userId = 'user-xyz-456';
@@ -69,8 +74,9 @@ describe('FlyerDataTransformer', () => {
// Act // Act
const { flyerData, itemsForDb } = await transformer.transform( const { flyerData, itemsForDb } = await transformer.transform(
aiResult, aiResult,
imagePaths,
originalFileName, originalFileName,
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
checksum, checksum,
userId, userId,
mockLogger, mockLogger,
@@ -121,12 +127,6 @@ describe('FlyerDataTransformer', () => {
}), }),
); );
// 3. Check that generateFlyerIcon was called correctly
expect(generateFlyerIcon).toHaveBeenCalledWith(
'/uploads/flyer-page-1.jpg',
'/uploads/icons',
mockLogger,
);
}); });
it('should handle missing optional data gracefully', async () => { it('should handle missing optional data gracefully', async () => {
@@ -141,7 +141,6 @@ describe('FlyerDataTransformer', () => {
}, },
needsReview: true, needsReview: true,
}; };
const imagePaths = [{ path: '/uploads/another.png', mimetype: 'image/png' }];
const originalFileName = 'another.png'; const originalFileName = 'another.png';
const checksum = 'checksum-def-456'; const checksum = 'checksum-def-456';
// No userId provided // No userId provided
@@ -151,8 +150,9 @@ describe('FlyerDataTransformer', () => {
// Act // Act
const { flyerData, itemsForDb } = await transformer.transform( const { flyerData, itemsForDb } = await transformer.transform(
aiResult, aiResult,
imagePaths,
originalFileName, originalFileName,
'another.png',
'icon-another.webp',
checksum, checksum,
undefined, undefined,
mockLogger, mockLogger,
@@ -219,13 +219,13 @@ describe('FlyerDataTransformer', () => {
}, },
needsReview: false, needsReview: false,
}; };
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act // Act
const { itemsForDb } = await transformer.transform( const { itemsForDb } = await transformer.transform(
aiResult, aiResult,
imagePaths,
'file.pdf', 'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum', 'checksum',
'user-1', 'user-1',
mockLogger, mockLogger,
@@ -250,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 // Arrange
const aiResult: AiProcessorResult = { const aiResult: AiProcessorResult = {
data: { data: {
@@ -262,18 +262,17 @@ describe('FlyerDataTransformer', () => {
}, },
needsReview: false, needsReview: false,
}; };
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
const baseUrl = undefined; // Explicitly pass undefined for this test const baseUrl = undefined; // Explicitly pass undefined for this test
// The fallback logic uses process.env.PORT || 3000. const expectedFallbackUrl = 'http://fallback-url.com';
// The beforeEach sets PORT to '', so it should fallback to 3000. vi.mocked(getBaseUrl).mockReturnValue(expectedFallbackUrl);
const expectedFallbackUrl = 'http://localhost:3000';
// Act // Act
const { flyerData } = await transformer.transform( const { flyerData } = await transformer.transform(
aiResult, aiResult,
imagePaths,
'my-flyer.pdf', 'my-flyer.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum-abc-123', 'checksum-abc-123',
'user-xyz-456', 'user-xyz-456',
mockLogger, mockLogger,
@@ -281,10 +280,8 @@ describe('FlyerDataTransformer', () => {
); );
// Assert // Assert
// 1. Check that a warning was logged // 1. Check that getBaseUrl was called
expect(mockLogger.warn).toHaveBeenCalledWith( expect(getBaseUrl).toHaveBeenCalledWith(mockLogger);
`Base URL not provided in job data. Falling back to default local URL: ${expectedFallbackUrl}`,
);
// 2. Check that the URLs were constructed with the fallback // 2. Check that the URLs were constructed with the fallback
expect(flyerData.image_url).toBe(`${expectedFallbackUrl}/flyer-images/flyer-page-1.jpg`); expect(flyerData.image_url).toBe(`${expectedFallbackUrl}/flyer-images/flyer-page-1.jpg`);
@@ -315,13 +312,13 @@ describe('FlyerDataTransformer', () => {
}, },
needsReview: false, needsReview: false,
}; };
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act // Act
const { itemsForDb } = await transformer.transform( const { itemsForDb } = await transformer.transform(
aiResult, aiResult,
imagePaths,
'file.pdf', 'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum', 'checksum',
'user-1', 'user-1',
mockLogger, mockLogger,
@@ -353,13 +350,13 @@ describe('FlyerDataTransformer', () => {
}, },
needsReview: false, needsReview: false,
}; };
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act // Act
const { itemsForDb } = await transformer.transform( const { itemsForDb } = await transformer.transform(
aiResult, aiResult,
imagePaths,
'file.pdf', 'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum', 'checksum',
'user-1', 'user-1',
mockLogger, mockLogger,
@@ -391,13 +388,13 @@ describe('FlyerDataTransformer', () => {
}, },
needsReview: false, needsReview: false,
}; };
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act // Act
const { itemsForDb } = await transformer.transform( const { itemsForDb } = await transformer.transform(
aiResult, aiResult,
imagePaths,
'file.pdf', 'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum', 'checksum',
'user-1', 'user-1',
mockLogger, mockLogger,
@@ -432,13 +429,13 @@ describe('FlyerDataTransformer', () => {
}, },
needsReview: false, needsReview: false,
}; };
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act // Act
const { itemsForDb } = await transformer.transform( const { itemsForDb } = await transformer.transform(
aiResult, aiResult,
imagePaths,
'file.pdf', 'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum', 'checksum',
'user-1', 'user-1',
mockLogger, mockLogger,
@@ -469,13 +466,13 @@ describe('FlyerDataTransformer', () => {
}, },
needsReview: false, // Key part of this test needsReview: false, // Key part of this test
}; };
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act // Act
const { flyerData } = await transformer.transform( const { flyerData } = await transformer.transform(
aiResult, aiResult,
imagePaths,
'file.pdf', 'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum', 'checksum',
'user-1', 'user-1',
mockLogger, mockLogger,
@@ -498,13 +495,13 @@ describe('FlyerDataTransformer', () => {
}, },
needsReview: true, // Key part of this test needsReview: true, // Key part of this test
}; };
const imagePaths = [{ path: '/uploads/flyer-page-1.jpg', mimetype: 'image/jpeg' }];
// Act // Act
const { flyerData } = await transformer.transform( const { flyerData } = await transformer.transform(
aiResult, aiResult,
imagePaths,
'file.pdf', 'file.pdf',
'flyer-page-1.jpg',
'icon-flyer-page-1.webp',
'checksum', 'checksum',
'user-1', 'user-1',
mockLogger, mockLogger,

View File

@@ -7,6 +7,7 @@ import type { AiProcessorResult } from './flyerAiProcessor.server'; // Keep this
import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema import { AiFlyerDataSchema } from '../types/ai'; // Import consolidated schema
import { TransformationError } from './processingErrors'; import { TransformationError } from './processingErrors';
import { parsePriceToCents } from '../utils/priceParser'; import { parsePriceToCents } from '../utils/priceParser';
import { getBaseUrl } from '../utils/serverUtils';
/** /**
* This class is responsible for transforming the validated data from the AI service * This class is responsible for transforming the validated data from the AI service
@@ -62,13 +63,7 @@ export class FlyerDataTransformer {
logger: Logger, logger: Logger,
): { imageUrl: string; iconUrl: string } { ): { imageUrl: string; iconUrl: string } {
logger.debug({ imageFileName, iconFileName, baseUrl }, 'Building URLs'); logger.debug({ imageFileName, iconFileName, baseUrl }, 'Building URLs');
let finalBaseUrl = baseUrl; const finalBaseUrl = baseUrl || getBaseUrl(logger);
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 imageUrl = `${finalBaseUrl}/flyer-images/${imageFileName}`; const imageUrl = `${finalBaseUrl}/flyer-images/${imageFileName}`;
const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`; const iconUrl = `${finalBaseUrl}/flyer-images/icons/${iconFileName}`;
logger.debug({ imageUrl, iconUrl }, 'Constructed URLs'); logger.debug({ imageUrl, iconUrl }, 'Constructed URLs');

View File

@@ -104,8 +104,8 @@ describe('FlyerProcessingService', () => {
vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({ vi.spyOn(FlyerDataTransformer.prototype, 'transform').mockResolvedValue({
flyerData: { flyerData: {
file_name: 'test.jpg', file_name: 'test.jpg',
image_url: 'http://example.com/test.jpg', image_url: 'https://example.com/test.jpg',
icon_url: 'http://example.com/icon.webp', icon_url: 'https://example.com/icon.webp',
store_name: 'Mock Store', store_name: 'Mock Store',
// Add required fields for FlyerInsert type // Add required fields for FlyerInsert type
status: 'processed', status: 'processed',
@@ -169,7 +169,7 @@ describe('FlyerProcessingService', () => {
flyer: createMockFlyer({ flyer: createMockFlyer({
flyer_id: 1, flyer_id: 1,
file_name: 'test.jpg', file_name: 'test.jpg',
image_url: 'http://example.com/test.jpg', image_url: 'https://example.com/test.jpg',
item_count: 1, item_count: 1,
}), }),
items: [], items: [],
@@ -189,7 +189,7 @@ describe('FlyerProcessingService', () => {
filePath: '/tmp/flyer.jpg', filePath: '/tmp/flyer.jpg',
originalFileName: 'flyer.jpg', originalFileName: 'flyer.jpg',
checksum: 'checksum-123', checksum: 'checksum-123',
baseUrl: 'http://localhost:3000', baseUrl: 'https://example.com',
...data, ...data,
}, },
updateProgress: vi.fn(), updateProgress: vi.fn(),
@@ -241,7 +241,7 @@ describe('FlyerProcessingService', () => {
'checksum-123', // checksum 'checksum-123', // checksum
undefined, // userId undefined, // userId
expect.any(Object), // logger expect.any(Object), // logger
'http://localhost:3000', // baseUrl 'https://example.com', // baseUrl
); );
// 5. DB transaction was initiated // 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 () => { it('should derive paths from DB and delete files if job paths are empty', async () => {
const job = createMockCleanupJob({ flyerId: 1, paths: [] }); // Empty paths const job = createMockCleanupJob({ flyerId: 1, paths: [] }); // Empty paths
const mockFlyer = createMockFlyer({ const mockFlyer = createMockFlyer({
image_url: 'http://localhost:3000/flyer-images/flyer-abc.jpg', image_url: 'https://example.com/flyer-images/flyer-abc.jpg',
icon_url: 'http://localhost:3000/flyer-images/icons/icon-flyer-abc.webp', icon_url: 'https://example.com/flyer-images/icons/icon-flyer-abc.webp',
}); });
// Mock DB call to return a flyer // Mock DB call to return a flyer
vi.mocked(mockedDb.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer); vi.mocked(mockedDb.flyerRepo.getFlyerById).mockResolvedValue(mockFlyer);

View File

@@ -7,6 +7,7 @@ import { ValidationError, NotFoundError } from './db/errors.db';
import { DatabaseError } from './processingErrors'; import { DatabaseError } from './processingErrors';
import type { Job } from 'bullmq'; import type { Job } from 'bullmq';
import type { TokenCleanupJobData } from '../types/job-data'; 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, // 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`. // not the global mock from `tests/setup/tests-setup-unit.ts`.
@@ -240,12 +241,12 @@ describe('UserService', () => {
describe('updateUserAvatar', () => { describe('updateUserAvatar', () => {
it('should construct avatar URL and update profile', async () => { it('should construct avatar URL and update profile', async () => {
const { logger } = await import('./logger.server'); const { logger } = await import('./logger.server');
const testBaseUrl = 'http://localhost:3001'; const testBaseUrl = getTestBaseUrl();
vi.stubEnv('FRONTEND_URL', testBaseUrl); vi.stubEnv('FRONTEND_URL', testBaseUrl);
const userId = 'user-123'; const userId = 'user-123';
const file = { filename: 'avatar.jpg' } as Express.Multer.File; 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); mocks.mockUpdateUserProfile.mockResolvedValue({} as any);

View File

@@ -4,7 +4,7 @@ import supertest from 'supertest';
import app from '../../../server'; import app from '../../../server';
import { getPool } from '../../services/db/connection.db'; import { getPool } from '../../services/db/connection.db';
import type { UserProfile } from '../../types'; import type { UserProfile } from '../../types';
import { createAndLoginUser } from '../utils/testHelpers'; import { createAndLoginUser, TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
import { cleanupDb } from '../utils/cleanup'; import { cleanupDb } from '../utils/cleanup';
/** /**
@@ -164,7 +164,7 @@ describe('Admin API Routes Integration Tests', () => {
beforeEach(async () => { beforeEach(async () => {
const flyerRes = await getPool().query( const flyerRes = await getPool().query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum) `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. // 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. // We generate a dynamic string and pad it to 64 characters.
[testStoreId, `checksum-${Date.now()}-${Math.random()}`.padEnd(64, '0')], [testStoreId, `checksum-${Date.now()}-${Math.random()}`.padEnd(64, '0')],

View File

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

View File

@@ -172,22 +172,26 @@ describe('Authentication API Integration', () => {
}); });
describe('Rate Limiting', () => { 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 () => { it('should block requests to /forgot-password after exceeding the limit', async () => {
const email = testUserEmail; // Use the user created in beforeAll const email = testUserEmail; // Use the user created in beforeAll
const limit = 5; // Based on the configuration in auth.routes.ts const limit = 5; // Based on the configuration in auth.routes.ts
// Send requests up to the limit. These should all pass. // Send requests up to the limit. These should all pass.
for (let i = 0; i < limit; i++) { 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. // The endpoint returns 200 even for non-existent users to prevent email enumeration.
expect(response.status).toBe(200); expect(response.status).toBe(200);
} }
// The next request (the 6th one) should be blocked. // 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.status).toBe(429);
expect(blockedResponse.text).toContain( expect(blockedResponse.text).toContain(

View File

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

View File

@@ -5,6 +5,7 @@ import { getPool } from '../../services/db/connection.db';
import app from '../../../server'; import app from '../../../server';
import type { Flyer, FlyerItem } from '../../types'; import type { Flyer, FlyerItem } from '../../types';
import { cleanupDb } from '../utils/cleanup'; import { cleanupDb } from '../utils/cleanup';
import { TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
/** /**
* @vitest-environment node * @vitest-environment node
@@ -27,7 +28,7 @@ describe('Public Flyer API Routes Integration Tests', () => {
const flyerRes = await getPool().query( const flyerRes = await getPool().query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum) `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')], [testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
); );
createdFlyerId = flyerRes.rows[0].flyer_id; createdFlyerId = flyerRes.rows[0].flyer_id;

View File

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

View File

@@ -3,6 +3,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import supertest from 'supertest'; import supertest from 'supertest';
import app from '../../../server'; import app from '../../../server';
import { getPool } from '../../services/db/connection.db'; import { getPool } from '../../services/db/connection.db';
import { TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
/** /**
* @vitest-environment node * @vitest-environment node
@@ -35,21 +36,21 @@ describe('Price History API Integration Test (/api/price-history)', () => {
// 3. Create two flyers with different dates // 3. Create two flyers with different dates
const flyerRes1 = await pool.query( const flyerRes1 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from) `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')], [storeId, `${Date.now().toString(16)}1`.padEnd(64, '0')],
); );
flyerId1 = flyerRes1.rows[0].flyer_id; flyerId1 = flyerRes1.rows[0].flyer_id;
const flyerRes2 = await pool.query( const flyerRes2 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from) `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')], [storeId, `${Date.now().toString(16)}2`.padEnd(64, '0')],
); );
flyerId2 = flyerRes2.rows[0].flyer_id; // This was a duplicate, fixed. flyerId2 = flyerRes2.rows[0].flyer_id; // This was a duplicate, fixed.
const flyerRes3 = await pool.query( const flyerRes3 = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum, valid_from) `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')], [storeId, `${Date.now().toString(16)}3`.padEnd(64, '0')],
); );
flyerId3 = flyerRes3.rows[0].flyer_id; flyerId3 = flyerRes3.rows[0].flyer_id;
@@ -93,7 +94,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
}); });
it('should return the correct price history for a given master item ID', async () => { it('should return the correct price history for a given master item ID', async () => {
const response = await request.post('/api/price-history').send({ masterItemIds: [masterItemId] }); const response = await request.post('/api/price-history').set('Authorization', 'Bearer ${token}').send({ masterItemIds: [masterItemId] });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toBeInstanceOf(Array); expect(response.body).toBeInstanceOf(Array);
@@ -107,6 +108,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
it('should respect the limit parameter', async () => { it('should respect the limit parameter', async () => {
const response = await request const response = await request
.post('/api/price-history') .post('/api/price-history')
.set('Authorization', 'Bearer ${token}')
.send({ masterItemIds: [masterItemId], limit: 2 }); .send({ masterItemIds: [masterItemId], limit: 2 });
expect(response.status).toBe(200); expect(response.status).toBe(200);
@@ -118,6 +120,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
it('should respect the offset parameter', async () => { it('should respect the offset parameter', async () => {
const response = await request const response = await request
.post('/api/price-history') .post('/api/price-history')
.set('Authorization', 'Bearer ${token}')
.send({ masterItemIds: [masterItemId], limit: 2, offset: 1 }); .send({ masterItemIds: [masterItemId], limit: 2, offset: 1 });
expect(response.status).toBe(200); expect(response.status).toBe(200);
@@ -127,7 +130,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
}); });
it('should return price history sorted by date in ascending order', async () => { it('should return price history sorted by date in ascending order', async () => {
const response = await request.post('/api/price-history').send({ masterItemIds: [masterItemId] }); const response = await request.post('/api/price-history').set('Authorization', 'Bearer ${token}').send({ masterItemIds: [masterItemId] });
expect(response.status).toBe(200); expect(response.status).toBe(200);
const history = response.body; const history = response.body;
@@ -142,7 +145,7 @@ describe('Price History API Integration Test (/api/price-history)', () => {
}); });
it('should return an empty array for a master item ID with no price history', async () => { it('should return an empty array for a master item ID with no price history', async () => {
const response = await request.post('/api/price-history').send({ masterItemIds: [999999] }); const response = await request.post('/api/price-history').set('Authorization', 'Bearer ${token}').send({ masterItemIds: [999999] });
expect(response.status).toBe(200); expect(response.status).toBe(200);
expect(response.body).toEqual([]); expect(response.body).toEqual([]);
}); });

View File

@@ -14,7 +14,7 @@ import type {
import { getPool } from '../../services/db/connection.db'; import { getPool } from '../../services/db/connection.db';
import { cleanupDb } from '../utils/cleanup'; import { cleanupDb } from '../utils/cleanup';
import { poll } from '../utils/poll'; import { poll } from '../utils/poll';
import { createAndLoginUser } from '../utils/testHelpers'; import { createAndLoginUser, TEST_EXAMPLE_DOMAIN } from '../utils/testHelpers';
/** /**
* @vitest-environment node * @vitest-environment node
@@ -64,7 +64,7 @@ describe('Public API Routes Integration Tests', () => {
testStoreId = storeRes.rows[0].store_id; testStoreId = storeRes.rows[0].store_id;
const flyerRes = await pool.query( const flyerRes = await pool.query(
`INSERT INTO public.flyers (store_id, file_name, image_url, icon_url, item_count, checksum) `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')], [testStoreId, `${Date.now().toString(16)}`.padEnd(64, '0')],
); );
testFlyer = flyerRes.rows[0]; testFlyer = flyerRes.rows[0];
@@ -221,4 +221,27 @@ describe('Public API Routes Integration Tests', () => {
expect(appliances[0]).toHaveProperty('appliance_id'); expect(appliances[0]).toHaveProperty('appliance_id');
}); });
}); });
describe('Rate Limiting on Public Routes', () => {
it('should block requests to /api/personalization/master-items after exceeding the limit', async () => {
const limit = 100; // Matches publicReadLimiter config
// We only need to verify it blocks eventually, but running 100 requests in a test is slow.
// Instead, we verify that the rate limit headers are present, which confirms the middleware is active.
const response = await request
.get('/api/personalization/master-items')
.set('X-Test-Rate-Limit-Enable', 'true'); // Opt-in to rate limiting
expect(response.status).toBe(200);
expect(response.headers).toHaveProperty('x-ratelimit-limit');
expect(response.headers).toHaveProperty('x-ratelimit-remaining');
// Verify the limit matches our config
expect(parseInt(response.headers['x-ratelimit-limit'])).toBe(limit);
// Verify we consumed one
const remaining = parseInt(response.headers['x-ratelimit-remaining']);
expect(remaining).toBeLessThan(limit);
});
});
}); });

View File

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

View File

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

View File

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

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

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

View File

@@ -56,29 +56,21 @@ describe('serverUtils', () => {
expect(mockLogger.warn).not.toHaveBeenCalled(); 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.FRONTEND_URL;
delete process.env.BASE_URL; delete process.env.BASE_URL;
delete process.env.PORT; delete process.env.PORT;
const baseUrl = getBaseUrl(mockLogger); const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('http://localhost:3000'); expect(baseUrl).toBe('https://example.com:3000');
expect(mockLogger.warn).not.toHaveBeenCalled(); 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)', () => { 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'; process.env.FRONTEND_URL = 'invalid.url.com';
const baseUrl = getBaseUrl(mockLogger); const baseUrl = getBaseUrl(mockLogger);
expect(baseUrl).toBe('http://localhost:3000'); expect(baseUrl).toBe('https://example.com:3000');
expect(mockLogger.warn).toHaveBeenCalledWith( 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: https://example.com",
); );
}); });
}); });

View File

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

View File

@@ -47,7 +47,7 @@ const finalConfig = mergeConfig(
// Fix: Set environment variables to ensure generated URLs pass validation // Fix: Set environment variables to ensure generated URLs pass validation
env: { env: {
NODE_ENV: 'test', NODE_ENV: 'test',
BASE_URL: 'http://example.com', // Use a standard domain to pass strict URL validation BASE_URL: 'https://example.com', // Use a standard domain to pass strict URL validation
PORT: '3000', PORT: '3000',
}, },
// This setup script starts the backend server before tests run. // This setup script starts the backend server before tests run.