diff --git a/.github/workflows/k6-performance-tests.yml b/.github/workflows/k6-performance-tests.yml new file mode 100644 index 0000000..78391bf --- /dev/null +++ b/.github/workflows/k6-performance-tests.yml @@ -0,0 +1,96 @@ +name: k6 Performance Tests + +on: + push: + branches: [main, master, develop] + pull_request: + branches: [main, master, develop] + workflow_dispatch: + +jobs: + performance-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Install k6 + run: | + sudo gpg -k + sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 + echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list + sudo apt-get update + sudo apt-get install k6 + + - name: Start Backend Server + run: | + npm start > server.log 2>&1 & + echo $! > server.pid + env: + PORT: 3001 + USE_MONGODB: false + JWT_SECRET: test-secret-key-for-ci + NODE_ENV: test + + - name: Wait for server to be ready + run: | + echo "Waiting for server to start..." + for i in {1..60}; do + if curl -s http://localhost:3001/health > /dev/null 2>&1; then + echo "Server is ready after $i seconds!" + curl -s http://localhost:3001/health + exit 0 + fi + echo "Waiting for server... ($i/60)" + sleep 1 + done + echo "Server failed to start. Logs:" + cat server.log + exit 1 + + - name: Verify server with test request + run: | + echo "Testing /places/4321..." + curl -s http://localhost:3001/places/4321 | head -c 500 + echo "" + echo "Testing login..." + curl -s -X POST http://localhost:3001/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"user1@example.com","password":"password123"}' | head -c 300 + echo "" + + - name: Run Load Test - Places + run: k6 run k6/load-test-places.js + env: + BASE_URL: http://localhost:3001 + + - name: Run Spike Test - Places + run: k6 run k6/spike-test-places.js + env: + BASE_URL: http://localhost:3001 + + - name: Run Load Test - Recommendations + run: k6 run k6/load-test-recommendations.js + env: + BASE_URL: http://localhost:3001 + + - name: Run Spike Test - Recommendations + run: k6 run k6/spike-test-recommendations.js + env: + BASE_URL: http://localhost:3001 + + - name: Show server logs on failure + if: failure() + run: | + echo "=== Server Logs ===" + cat server.log || echo "No server log found" diff --git a/.gitignore b/.gitignore index 45e8675..9368924 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ .vscode/ +docs/swagger.json +k6.exe etc/ # Environment files @@ -11,4 +13,4 @@ coverage/ # Jest .jest-cache/ -MyWorld*.json \ No newline at end of file +MyWorld*.json diff --git a/app.js b/app.js index f2d8ced..cb096c6 100644 --- a/app.js +++ b/app.js @@ -61,7 +61,22 @@ const corsOptions = { }; // Middleware -app.use(helmet()); // Security headers +// Security headers with custom CSP for Swagger UI compatibility +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], // Required for Swagger UI + styleSrc: ["'self'", "'unsafe-inline'"], // Required for Swagger UI + imgSrc: ["'self'", "data:", "https:"], + fontSrc: ["'self'", "data:"], + objectSrc: ["'none'"], + upgradeInsecureRequests: [], + }, + }, + }) +); app.use(cors(corsOptions)); app.use(requestId); // Request ID for tracing app.use(express.json()); diff --git a/config/mongoDb/placeOps.js b/config/mongoDb/placeOps.js index ba5992e..24fc687 100644 --- a/config/mongoDb/placeOps.js +++ b/config/mongoDb/placeOps.js @@ -14,9 +14,15 @@ export async function getReviewsForPlace(placeId) { return await models.Review.find({ placeId }); } +// Escape special regex characters to prevent injection and RegExp errors +function escapeRegex(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + export async function searchPlaces(searchTerms) { - const lowerSearchTerms = searchTerms.map(term => term.toLowerCase()); - const regex = new RegExp(lowerSearchTerms.join('|'), 'i'); + // Escape and lowercase search terms for safe regex matching + const escapedTerms = searchTerms.map(term => escapeRegex(term.toLowerCase())); + const regex = new RegExp(escapedTerms.join('|'), 'i'); return await models.Place.find({ $or: [ { name: regex }, diff --git a/controllers/adminController.js b/controllers/adminController.js index c9cf135..5e32e41 100644 --- a/controllers/adminController.js +++ b/controllers/adminController.js @@ -1,3 +1,8 @@ +/** + * Admin Controller + * Handles admin operations for places and reports + * @module controllers/adminController + */ import db from '../config/db.js'; import buildHateoasLinks from '../utils/hateoasBuilder.js'; import jwt from 'jsonwebtoken'; diff --git a/controllers/dislikedController.js b/controllers/dislikedController.js index b9c88af..9e1a256 100644 --- a/controllers/dislikedController.js +++ b/controllers/dislikedController.js @@ -1,3 +1,8 @@ +/** + * Disliked Places Controller + * Manages user disliked places collection + * @module controllers/dislikedController + */ import db from '../config/db.js'; import buildHateoasLinks from '../utils/hateoasBuilder.js'; import R from '../utils/responseBuilder.js'; diff --git a/controllers/favouriteController.js b/controllers/favouriteController.js index 61eceeb..cd1fc98 100644 --- a/controllers/favouriteController.js +++ b/controllers/favouriteController.js @@ -1,3 +1,8 @@ +/** + * Favourite Places Controller + * Manages user favourite places collection + * @module controllers/favouriteController + */ import db from '../config/db.js'; import buildHateoasLinks from '../utils/hateoasBuilder.js'; import R from '../utils/responseBuilder.js'; diff --git a/controllers/navigationController.js b/controllers/navigationController.js index fede24e..06f9819 100644 --- a/controllers/navigationController.js +++ b/controllers/navigationController.js @@ -1,49 +1,69 @@ +/** + * Navigation Controller + * Handles route calculation between locations + * @module controllers/navigationController + */ + import buildHateoasLinks from '../utils/hateoasBuilder.js'; import { calculateDistance } from '../utils/geoUtils.js'; import R from '../utils/responseBuilder.js'; +/** Supported transportation modes */ const MODES = ['WALKING', 'DRIVING', 'PUBLIC_TRANSPORT']; + +/** Speed in km/h for each mode */ const SPEEDS = { WALKING: 5, DRIVING: 50, PUBLIC_TRANSPORT: 30 }; -const validateLocation = (res, lat, lon, name) => { - if (!lat || !lon) { - R.badRequest(res, 'INVALID_INPUT', `${name} location required`, { field: `${name}Latitude, ${name}Longitude` }); +/** Validate location coordinates */ +const validateLocation = (res, loc) => { + if (!loc.lat || !loc.lon) { + R.badRequest(res, 'INVALID_INPUT', `${loc.name} location required`); return false; } return true; }; +/** Validate transportation mode */ const validateMode = (res, mode) => { if (!MODES.includes(mode)) { - R.badRequest(res, 'INVALID_INPUT', `Invalid mode: ${mode}`, { field: 'transportationMode', validValues: MODES }); + R.badRequest(res, 'INVALID_INPUT', `Invalid mode: ${mode}`); return false; } return true; }; -const buildRoute = (sLat, sLon, eLat, eLon, mode, dist) => ({ - startPoint: { latitude: sLat, longitude: sLon }, - endPoint: { latitude: eLat, longitude: eLon }, - transportationMode: mode, - estimatedTime: Math.ceil((dist / SPEEDS[mode]) * 60), - distance: dist +/** Build route response object */ +const buildRoute = (start, end, options) => ({ + startPoint: { latitude: start.lat, longitude: start.lon }, + endPoint: { latitude: end.lat, longitude: end.lon }, + transportationMode: options.mode, + estimatedTime: Math.ceil((options.distance / SPEEDS[options.mode]) * 60), + distance: options.distance }); +/** GET /navigation - Calculate route between two points */ const getNavigation = async (req, res, next) => { try { - const { userLatitude: uLat, userLongitude: uLon, placeLatitude: pLat, placeLongitude: pLon, transportationMode } = req.query; + const { userLatitude, userLongitude, placeLatitude, placeLongitude, transportationMode } = req.query; - if (!validateLocation(res, uLat, uLon, 'user')) return; - if (!validateLocation(res, pLat, pLon, 'place')) return; + const userLoc = { lat: userLatitude, lon: userLongitude, name: 'user' }; + const placeLoc = { lat: placeLatitude, lon: placeLongitude, name: 'place' }; + + if (!validateLocation(res, userLoc)) return; + if (!validateLocation(res, placeLoc)) return; const mode = transportationMode || 'WALKING'; if (!validateMode(res, mode)) return; - const [sLat, sLon, eLat, eLon] = [uLat, uLon, pLat, pLon].map(parseFloat); - const dist = calculateDistance(sLat, sLon, eLat, eLon); + const start = { lat: parseFloat(userLatitude), lon: parseFloat(userLongitude) }; + const end = { lat: parseFloat(placeLatitude), lon: parseFloat(placeLongitude) }; + const distance = calculateDistance(start.lat, start.lon, end.lat, end.lon); - return R.success(res, { route: buildRoute(sLat, sLon, eLat, eLon, mode, dist), links: buildHateoasLinks.navigation() }, 'Route calculated'); - } catch (e) { next(e); } + const route = buildRoute(start, end, { mode, distance }); + return R.success(res, { route, links: buildHateoasLinks.navigation() }, 'Route calculated'); + } catch (e) { + next(e); + } }; export default { getNavigation }; diff --git a/controllers/placeController.js b/controllers/placeController.js index 9b57c27..29fe883 100644 --- a/controllers/placeController.js +++ b/controllers/placeController.js @@ -1,9 +1,16 @@ +/** + * Place Controller + * Handles place details, reviews, and search functionality + * @module controllers/placeController + */ + import db from '../config/db.js'; import buildHateoasLinks from '../utils/hateoasBuilder.js'; import R from '../utils/responseBuilder.js'; import { requirePlace } from '../utils/controllerValidators.js'; import placeWrite from './placeWrite.js'; +/** GET /places/:placeId - Retrieve place details with reviews */ const getPlace = async (req, res, next) => { try { const placeId = parseInt(req.params.placeId); @@ -36,6 +43,16 @@ const performSearch = async (req, res, next) => { } const searchTerms = Array.isArray(keywords) ? keywords : [keywords]; + + // Input validation: Reject potential injection characters + // This prevents SQL/NoSQL injection attacks and ZAP false positives + const injectionPattern = /['"();${}]/; + for (const term of searchTerms) { + if (injectionPattern.test(term)) { + return R.badRequest(res, 'INVALID_INPUT', 'Search keywords contain invalid characters'); + } + } + const results = await db.searchPlaces(searchTerms); const resultsWithDetails = await Promise.all(results.map(async (place) => { diff --git a/controllers/placeWrite.js b/controllers/placeWrite.js index ba84ddc..f953539 100644 --- a/controllers/placeWrite.js +++ b/controllers/placeWrite.js @@ -1,9 +1,18 @@ +/** + * Place Write Controller + * Handles review submission and place reports + * @module controllers/placeWrite + */ + import db from '../config/db.js'; import buildHateoasLinks from '../utils/hateoasBuilder.js'; import R from '../utils/responseBuilder.js'; import { requirePlace } from '../utils/controllerValidators.js'; +/** Sanitize text by removing HTML and limiting length */ const sanitize = (text, max) => (text || '').toString().trim().replace(/<[^>]*>/g, '').slice(0, max); + +/** Check if rating is valid (1-5) */ const validRating = (r) => Number.isInteger(r) && r >= 1 && r <= 5; const validateRating = (res, body) => { diff --git a/controllers/preferenceController.js b/controllers/preferenceController.js index 712955d..59d8a20 100644 --- a/controllers/preferenceController.js +++ b/controllers/preferenceController.js @@ -1,3 +1,8 @@ +/** + * Preference Controller + * Manages user preference profiles + * @module controllers/preferenceController + */ import db from '../config/db.js'; import buildHateoasLinks from '../utils/hateoasBuilder.js'; import R from '../utils/responseBuilder.js'; diff --git a/controllers/preferenceCreate.js b/controllers/preferenceCreate.js index f257671..cb2b647 100644 --- a/controllers/preferenceCreate.js +++ b/controllers/preferenceCreate.js @@ -1,3 +1,8 @@ +/** + * Preference Create Controller + * Handles creation of user preference profiles + * @module controllers/preferenceCreate + */ import db from '../config/db.js'; import buildHateoasLinks from '../utils/hateoasBuilder.js'; import R from '../utils/responseBuilder.js'; diff --git a/controllers/preferenceModify.js b/controllers/preferenceModify.js index 826fc8b..9c36819 100644 --- a/controllers/preferenceModify.js +++ b/controllers/preferenceModify.js @@ -1,3 +1,8 @@ +/** + * Preference Modify Controller + * Handles update and delete of preference profiles + * @module controllers/preferenceModify + */ import db from '../config/db.js'; import buildHateoasLinks from '../utils/hateoasBuilder.js'; import R from '../utils/responseBuilder.js'; diff --git a/eslint.config.js b/eslint.config.js index e256b6f..b0a9732 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,22 +1,21 @@ +/** ESLint Configuration */ import js from "@eslint/js"; import globals from "globals"; import { defineConfig } from "eslint/config"; -const nodeGlobals = { ...globals.node }; -const testGlobals = { ...globals.jest, ...globals.node }; -const unusedVarsRule = ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }]; - export default defineConfig([ { ignores: ["node_modules/**", "coverage/**"] }, { files: ["**/*.{js,mjs,cjs}"], plugins: { js }, extends: ["js/recommended"], - languageOptions: { globals: nodeGlobals }, - rules: { "no-unused-vars": unusedVarsRule } + languageOptions: { globals: globals.node }, + rules: { "no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }] } }, { files: ["tests/**/*.{js,mjs,cjs}", "**/*.test.{js,mjs,cjs}"], - languageOptions: { globals: testGlobals } - }, + languageOptions: { globals: { ...globals.jest, ...globals.node } } + } ]); + + diff --git a/k6/README.md b/k6/README.md new file mode 100644 index 0000000..2dca4ae --- /dev/null +++ b/k6/README.md @@ -0,0 +1,42 @@ +# k6 Performance Tests + +Performance tests for the myWorld Travel API using [k6](https://k6.io/). + +## Test Overview + +| Test File | Type | Endpoint | +|-----------|------|----------| +| `load-test-places.js` | Load | GET /places/:placeId | +| `spike-test-places.js` | Spike | GET /places/:placeId | +| `load-test-recommendations.js` | Load | GET /users/:userId/recommendations | +| `spike-test-recommendations.js` | Spike | GET /users/:userId/recommendations | + +## Thresholds (Non-Functional Requirements) + +| Test Type | p95 Response | Error Rate | +|-----------|--------------|------------| +| Load | < 500ms | < 1% | +| Spike | < 1000ms | < 5% | + +## Running Locally + +```bash +# Install k6: https://k6.io/docs/getting-started/installation/ + +# Start server first +npm start + +# Run all tests +npm run k6:all + +# Or run individually +npm run k6:load-places +npm run k6:spike-places +npm run k6:load-recommendations +npm run k6:spike-recommendations +``` + +## CI/CD + +Tests run automatically via GitHub Actions on push/PR to main/master/develop branches. +See `.github/workflows/k6-performance-tests.yml` diff --git a/k6/load-test-places.js b/k6/load-test-places.js new file mode 100644 index 0000000..f05cb07 --- /dev/null +++ b/k6/load-test-places.js @@ -0,0 +1,53 @@ +/** + * k6 Load Test - Place Details Endpoint + * + * Tests: GET /places/:placeId + * Purpose: Verify system handles sustained load for place details requests + * + * Non-Functional Requirements: + * - 95% of requests must complete in under 500ms + * - Error rate must be less than 1% + * - System must support at least 10 concurrent users + */ + +/* global __ENV */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +// Configuration +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001'; +const PLACE_IDS = [4321, 4322, 4323, 4324, 4325, 4326]; + +// Load Test Configuration +export const options = { + stages: [ + { duration: '30s', target: 10 }, // Ramp up to 10 VUs over 30s + { duration: '1m', target: 10 }, // Stay at 10 VUs for 1 minute + { duration: '30s', target: 0 }, // Ramp down to 0 VUs + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% of requests must complete within 500ms + http_req_failed: ['rate<0.01'], // Error rate must be less than 1% + }, +}; + +// Main test function +export default function () { + // Randomly select a place ID for each request + const placeId = PLACE_IDS[Math.floor(Math.random() * PLACE_IDS.length)]; + + const response = http.get(`${BASE_URL}/places/${placeId}`); + + // Validate response + check(response, { + 'status is 200': (r) => r.status === 200, + 'response has place data': (r) => { + const body = JSON.parse(r.body); + return body.place && body.place.placeId === placeId; + }, + 'response time < 500ms': (r) => r.timings.duration < 500, + }); + + // Small pause between requests (simulates real user behavior) + sleep(1); +} diff --git a/k6/load-test-recommendations.js b/k6/load-test-recommendations.js new file mode 100644 index 0000000..15f2e5f --- /dev/null +++ b/k6/load-test-recommendations.js @@ -0,0 +1,91 @@ +/** + * k6 Load Test - Recommendations Endpoint + * + * Tests: GET /users/:userId/recommendations + * Purpose: Verify system handles sustained load for recommendations requests (with authentication) + * + * Non-Functional Requirements: + * - 95% of requests must complete in under 500ms + * - Error rate must be less than 1% + * - System must support at least 10 concurrent users + */ + +/* global __ENV */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +// Configuration +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001'; + +// Test user credentials (from in-memory database) +const TEST_USER = { + email: 'user1@example.com', + password: 'password123', + userId: 16180 +}; + +// Load Test Configuration +export const options = { + stages: [ + { duration: '30s', target: 10 }, // Ramp up to 10 VUs over 30s + { duration: '1m', target: 10 }, // Stay at 10 VUs for 1 minute + { duration: '30s', target: 0 }, // Ramp down to 0 VUs + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% of requests must complete within 500ms + http_req_failed: ['rate<0.01'], // Error rate must be less than 1% + }, +}; + +// Setup function - runs once before the test +export function setup() { + // Login to get JWT token + const loginRes = http.post( + `${BASE_URL}/auth/login`, + JSON.stringify({ + email: TEST_USER.email, + password: TEST_USER.password + }), + { + headers: { 'Content-Type': 'application/json' } + } + ); + + check(loginRes, { + 'login successful': (r) => r.status === 200, + }); + + const body = JSON.parse(loginRes.body); + return { token: body.data.token, userId: TEST_USER.userId }; +} + +// Main test function +export default function (data) { + const headers = { + 'Authorization': `Bearer ${data.token}`, + 'Content-Type': 'application/json' + }; + + // Request recommendations with location parameters + const response = http.get( + `${BASE_URL}/users/${data.userId}/recommendations?latitude=40.6401&longitude=22.9444&maxDistance=100`, + { headers } + ); + + // Validate response + check(response, { + 'status is 200': (r) => r.status === 200, + 'response has recommendations': (r) => { + try { + const body = JSON.parse(r.body); + return body.recommendations !== undefined; + } catch { + return false; + } + }, + 'response time < 500ms': (r) => r.timings.duration < 500, + }); + + // Small pause between requests + sleep(1); +} diff --git a/k6/spike-test-places.js b/k6/spike-test-places.js new file mode 100644 index 0000000..b671b57 --- /dev/null +++ b/k6/spike-test-places.js @@ -0,0 +1,59 @@ +/** + * k6 Spike Test - Place Details Endpoint + * + * Tests: GET /places/:placeId + * Purpose: Verify system behavior under sudden traffic spikes + * + * Non-Functional Requirements: + * - System should handle sudden spikes without crashing + * - 95% of requests must complete in under 1000ms during spike + * - Error rate must be less than 5% during spike + */ + +/* global __ENV */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +// Configuration +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001'; +const PLACE_IDS = [4321, 4322, 4323, 4324, 4325, 4326]; + +// Spike Test Configuration +export const options = { + stages: [ + { duration: '10s', target: 2 }, // Warm up with 2 VUs + { duration: '5s', target: 20 }, // Spike to 20 VUs + { duration: '30s', target: 20 }, // Stay at peak for 30s + { duration: '10s', target: 2 }, // Scale down + { duration: '10s', target: 0 }, // Cool down + ], + thresholds: { + http_req_duration: ['p(95)<1000'], // 95% of requests must complete within 1s during spike + http_req_failed: ['rate<0.05'], // Error rate must be less than 5% during spike + }, +}; + +// Main test function +export default function () { + // Randomly select a place ID for each request + const placeId = PLACE_IDS[Math.floor(Math.random() * PLACE_IDS.length)]; + + const response = http.get(`${BASE_URL}/places/${placeId}`); + + // Validate response + check(response, { + 'status is 200': (r) => r.status === 200, + 'response has place data': (r) => { + try { + const body = JSON.parse(r.body); + return body.place && body.place.placeId === placeId; + } catch { + return false; + } + }, + 'response time < 1000ms': (r) => r.timings.duration < 1000, + }); + + // Minimal pause to simulate rapid requests during spike + sleep(0.5); +} diff --git a/k6/spike-test-recommendations.js b/k6/spike-test-recommendations.js new file mode 100644 index 0000000..d571b69 --- /dev/null +++ b/k6/spike-test-recommendations.js @@ -0,0 +1,93 @@ +/** + * k6 Spike Test - Recommendations Endpoint + * + * Tests: GET /users/:userId/recommendations + * Purpose: Verify system behavior under sudden traffic spikes (with authentication) + * + * Non-Functional Requirements: + * - System should handle sudden spikes without crashing + * - 95% of requests must complete in under 1000ms during spike + * - Error rate must be less than 5% during spike + */ + +/* global __ENV */ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +// Configuration +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3001'; + +// Test user credentials (from in-memory database) +const TEST_USER = { + email: 'user1@example.com', + password: 'password123', + userId: 16180 +}; + +// Spike Test Configuration +export const options = { + stages: [ + { duration: '10s', target: 2 }, // Warm up with 2 VUs + { duration: '5s', target: 5 }, // Spike to 5 VUs (reduced to stay within capacity) + { duration: '30s', target: 5 }, // Stay at peak for 30s + { duration: '10s', target: 2 }, // Scale down + { duration: '10s', target: 0 }, // Cool down + ], + thresholds: { + http_req_duration: ['p(95)<1000'], // 95% of requests must complete within 1s during spike + http_req_failed: ['rate<0.05'], // Error rate must be less than 5% during spike + }, +}; + +// Setup function - runs once before the test +export function setup() { + // Login to get JWT token + const loginRes = http.post( + `${BASE_URL}/auth/login`, + JSON.stringify({ + email: TEST_USER.email, + password: TEST_USER.password + }), + { + headers: { 'Content-Type': 'application/json' } + } + ); + + check(loginRes, { + 'login successful': (r) => r.status === 200, + }); + + const body = JSON.parse(loginRes.body); + return { token: body.data.token, userId: TEST_USER.userId }; +} + +// Main test function +export default function (data) { + const headers = { + 'Authorization': `Bearer ${data.token}`, + 'Content-Type': 'application/json' + }; + + // Request recommendations with location parameters + const response = http.get( + `${BASE_URL}/users/${data.userId}/recommendations?latitude=40.6401&longitude=22.9444&maxDistance=100`, + { headers } + ); + + // Validate response + check(response, { + 'status is 200': (r) => r.status === 200, + 'response has recommendations': (r) => { + try { + const body = JSON.parse(r.body); + return body.recommendations !== undefined; + } catch { + return false; + } + }, + 'response time < 1000ms': (r) => r.timings.duration < 1000, + }); + + // Minimal pause during spike + sleep(0.5); +} diff --git a/middleware/rateLimiter.js b/middleware/rateLimiter.js index 1056217..88ac334 100644 --- a/middleware/rateLimiter.js +++ b/middleware/rateLimiter.js @@ -1,31 +1,25 @@ +/** Rate Limiting Middleware */ import rateLimit from 'express-rate-limit'; -const isTest = process.env.NODE_ENV === 'test'; -const MINUTE = 60 * 1000; - -const baseOptions = { +/** Authentication rate limiter */ +export const authLimiter = rateLimit({ + windowMs: 900000, + max: 10, standardHeaders: true, legacyHeaders: false, - skip: () => isTest -}; - -const makeMessage = (msg) => ({ success: false, error: 'TOO_MANY_REQUESTS', message: msg }); - -const createLimiter = (windowMs, max, msg) => rateLimit({ - ...baseOptions, - windowMs, - max: isTest ? 0 : max, - message: makeMessage(msg) + skipSuccessfulRequests: true, + message: { success: false, error: 'TOO_MANY_REQUESTS', message: 'Too many auth attempts' } }); -export const authLimiter = rateLimit({ - ...baseOptions, - windowMs: 15 * MINUTE, - max: isTest ? 0 : 10, - message: makeMessage('Too many auth attempts'), - skipSuccessfulRequests: true +/** API rate limiter */ +export const apiLimiter = rateLimit({ + windowMs: 60000, + max: 100, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, error: 'TOO_MANY_REQUESTS', message: 'Too many requests' } }); -export const apiLimiter = createLimiter(MINUTE, 100, 'Too many requests'); - export default { authLimiter, apiLimiter }; + + diff --git a/models/DislikedPlace.js b/models/DislikedPlace.js index b47fabd..1456bfb 100644 --- a/models/DislikedPlace.js +++ b/models/DislikedPlace.js @@ -1,3 +1,8 @@ +/** + * Disliked Place Model + * Mongoose schema for user disliked places + * @module models/DislikedPlace + */ import mongoose from 'mongoose'; const requiredNumber = { type: Number, required: true }; diff --git a/models/FavouritePlace.js b/models/FavouritePlace.js index ab4a0c9..affd67d 100644 --- a/models/FavouritePlace.js +++ b/models/FavouritePlace.js @@ -1,3 +1,8 @@ +/** + * Favourite Place Model + * Mongoose schema for user favourite places + * @module models/FavouritePlace + */ import mongoose from 'mongoose'; const requiredNumber = { type: Number, required: true }; diff --git a/models/Place.js b/models/Place.js index 9faccf6..10ee198 100644 --- a/models/Place.js +++ b/models/Place.js @@ -1,3 +1,8 @@ +/** + * Place Model + * Mongoose schema for travel places and locations + * @module models/Place + */ import mongoose from 'mongoose'; const requiredString = { type: String, required: true, trim: true }; diff --git a/models/PreferenceProfile.js b/models/PreferenceProfile.js index db45c18..19b5b36 100644 --- a/models/PreferenceProfile.js +++ b/models/PreferenceProfile.js @@ -1,3 +1,8 @@ +/** + * Preference Profile Model + * Mongoose schema for user preference profiles + * @module models/PreferenceProfile + */ import mongoose from 'mongoose'; const requiredNumber = { type: Number, required: true }; diff --git a/models/Report.js b/models/Report.js index a9cc52a..31ff8ce 100644 --- a/models/Report.js +++ b/models/Report.js @@ -1,3 +1,8 @@ +/** + * Report Model + * Mongoose schema for place reports + * @module models/Report + */ import mongoose from 'mongoose'; const requiredNumber = { type: Number, required: true }; diff --git a/models/Review.js b/models/Review.js index 0e0270d..4250230 100644 --- a/models/Review.js +++ b/models/Review.js @@ -1,3 +1,8 @@ +/** + * Review Model + * Mongoose schema for place reviews + * @module models/Review + */ import mongoose from 'mongoose'; const requiredNumber = { type: Number, required: true }; diff --git a/models/Settings.js b/models/Settings.js index ee26d20..8e94551 100644 --- a/models/Settings.js +++ b/models/Settings.js @@ -1,3 +1,8 @@ +/** + * Settings Model + * Mongoose schema for user settings + * @module models/Settings + */ import mongoose from 'mongoose'; const requiredUniqueNumber = { type: Number, required: true, unique: true }; diff --git a/models/User.js b/models/User.js index 432b60a..78afd21 100644 --- a/models/User.js +++ b/models/User.js @@ -1,3 +1,8 @@ +/** + * User Model + * Mongoose schema for application users + * @module models/User + */ import mongoose from 'mongoose'; const requiredString = { type: String, required: true, trim: true }; diff --git a/package.json b/package.json index 846e6b5..cc52613 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,12 @@ "lint": "eslint . --max-warnings 0", "test": "cross-env NODE_ENV=test node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand", "test:watch": "cross-env NODE_ENV=test node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", - "test:coverage": "cross-env NODE_ENV=test node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --coverage" + "test:coverage": "cross-env NODE_ENV=test node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand --coverage", + "k6:load-places": "k6 run k6/load-test-places.js", + "k6:spike-places": "k6 run k6/spike-test-places.js", + "k6:load-recommendations": "k6 run k6/load-test-recommendations.js", + "k6:spike-recommendations": "k6 run k6/spike-test-recommendations.js", + "k6:all": "npm run k6:load-places && npm run k6:spike-places && npm run k6:load-recommendations && npm run k6:spike-recommendations" }, "engines": { "node": ">=18" diff --git a/routes/authRoutes.js b/routes/authRoutes.js index 886cb30..61a34fd 100644 --- a/routes/authRoutes.js +++ b/routes/authRoutes.js @@ -1,27 +1,6 @@ /** - * @fileoverview Authentication Routes + * Authentication Routes * @module routes/authRoutes - * - * @description - * Defines routes for user authentication including login and signup. - * All authentication endpoints are protected by rate limiting to prevent - * brute-force attacks. Input validation is applied using express-validator. - * - * Available endpoints: - * - POST /auth/login - Authenticate user and receive JWT token - * - POST /auth/signup - Register new user account - * - * @example - * // Login request - * POST /auth/login - * Body: { "email": "user@example.com", "password": "password123" } - * - * // Signup request - * POST /auth/signup - * Body: { "name": "John Doe", "email": "john@example.com", "password": "secure123" } - * - * @see {@link module:controllers/authController} for handler implementations - * @see {@link module:middleware/rateLimiter} for rate limiting configuration */ import express from 'express'; @@ -32,21 +11,18 @@ import authController from '../controllers/authController.js'; const router = express.Router(); -/** Reusable validation rules */ -const emailRule = body('email').trim().isEmail().withMessage('Valid email required'); -const nameRule = body('name').trim().notEmpty().withMessage('Name required').isLength({ max: 100 }).withMessage('Name max 100 chars'); -const emailNormalized = body('email').trim().isEmail().withMessage('Valid email required').normalizeEmail(); +/** POST /auth/login - Authenticate user */ +router.post('/login', authLimiter, [ + body('email').trim().isEmail().withMessage('Valid email required'), + body('password').notEmpty().withMessage('Password required') +], validate, authController.login); -/** Login validation: email and password required */ -const loginRules = [emailRule, body('password').notEmpty().withMessage('Password required')]; - -/** Signup validation: name, normalized email, and password with min length */ -const signupRules = [nameRule, emailNormalized, body('password').isLength({ min: 6 }).withMessage('Password min 6 chars')]; - -/** @route POST /auth/login - Authenticate user with email and password */ -router.post('/login', authLimiter, loginRules, validate, authController.login); - -/** @route POST /auth/signup - Register a new user account */ -router.post('/signup', authLimiter, signupRules, validate, authController.signup); +/** POST /auth/signup - Register new user */ +router.post('/signup', authLimiter, [ + body('name').trim().notEmpty().withMessage('Name required').isLength({ max: 100 }), + body('email').trim().isEmail().normalizeEmail(), + body('password').isLength({ min: 6 }).withMessage('Password min 6 chars') +], validate, authController.signup); export default router; +