diff --git a/.github/workflows/backend-cicd.yml b/.github/workflows/backend-cicd.yml
index 6395981..22c0a10 100644
--- a/.github/workflows/backend-cicd.yml
+++ b/.github/workflows/backend-cicd.yml
@@ -40,6 +40,9 @@ jobs:
# Step 4: Run unit tests and generate a coverage report
- name: Run Tests with Coverage
run: npm run test:coverage
+ env:
+ JWT_SECRET: ci_test_jwt_secret_for_github_actions_min_32_chars
+ NODE_ENV: test
# Step 5: Upload the coverage report as an artifact
- name: Upload Coverage Report
diff --git a/README.md b/README.md
index 12ec641..3146244 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,10 @@
# 🌍 myWorld Travel API - Backend
+
+[](https://github.com/fraidakis/software-engineering-2-backend/actions)
[](https://github.com/fraidakis/software-engineering-2-backend)
-[](https://nodejs.org/)
-[](./coverage/index.html)
-[](./tests)
+[](https://nodejs.org/)
+[](./coverage/index.html)
[](https://github.com/fraidakis/software-engineering-2-backend)
A production-ready RESTful API with HATEOAS support for personalized travel experiences. Discover attractions, get recommendations, navigate, and experience the world with **myWorld**.
diff --git a/app.js b/app.js
index 4f91411..0c6b197 100644
--- a/app.js
+++ b/app.js
@@ -5,15 +5,18 @@
import express from 'express';
import cors from 'cors';
+import helmet from 'helmet';
import swaggerUi from 'swagger-ui-express';
import yaml from 'js-yaml';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
+import mongoose from 'mongoose';
// Import middleware
import errorHandler from './middleware/errorHandler.js';
import { requestLogger } from './middleware/logger.js';
+import requestId from './middleware/requestId.js';
// Import centralized routes
import routes from './routes/index.js';
@@ -42,10 +45,10 @@ if (process.env.NODE_ENV === 'production' && !process.env.CORS_ORIGIN) {
const corsOptions = {
origin: (origin, callback) => {
- const allowedOrigins = process.env.CORS_ORIGIN
+ const allowedOrigins = process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',')
: ['*'];
-
+
if (allowedOrigins.includes('*')) {
return callback(null, true);
}
@@ -58,7 +61,9 @@ const corsOptions = {
};
// Middleware
+app.use(helmet()); // Security headers
app.use(cors(corsOptions));
+app.use(requestId); // Request ID for tracing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(requestLogger);
@@ -113,10 +118,19 @@ app.get('/', (_req, res) => {
// Health check endpoint
app.get('/health', (_req, res) => {
+ // Determine database status
+ let dbStatus = 'in-memory';
+ if (process.env.USE_MONGODB === 'true') {
+ const readyState = mongoose.connection.readyState;
+ dbStatus = readyState === 1 ? 'connected' : readyState === 2 ? 'connecting' : 'disconnected';
+ }
+
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
- uptime: process.uptime()
+ uptime: process.uptime(),
+ database: dbStatus,
+ version: API_VERSION
});
});
diff --git a/middleware/rateLimiter.js b/middleware/rateLimiter.js
new file mode 100644
index 0000000..ac28f8a
--- /dev/null
+++ b/middleware/rateLimiter.js
@@ -0,0 +1,47 @@
+/**
+ * Rate Limiting Middleware
+ * Protects auth endpoints from brute-force attacks
+ */
+
+import rateLimit from 'express-rate-limit';
+
+const isTest = process.env.NODE_ENV === 'test';
+
+/**
+ * Strict rate limiter for authentication endpoints
+ * Allows 10 requests per 15 minutes per IP
+ * Disabled in test environment to avoid test failures
+ */
+export const authLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000, // 15 minutes
+ max: isTest ? 0 : 10, // 0 = unlimited in test mode
+ message: {
+ success: false,
+ error: 'TOO_MANY_REQUESTS',
+ message: 'Too many authentication attempts, please try again later'
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
+ skipSuccessfulRequests: true, // Don't count successful logins against the limit
+ skip: () => isTest // Skip rate limiting entirely in test environment
+});
+
+/**
+ * General API rate limiter
+ * Allows 100 requests per minute per IP
+ * Disabled in test environment
+ */
+export const apiLimiter = rateLimit({
+ windowMs: 60 * 1000, // 1 minute
+ max: isTest ? 0 : 100, // 0 = unlimited in test mode
+ message: {
+ success: false,
+ error: 'TOO_MANY_REQUESTS',
+ message: 'Too many requests, please slow down'
+ },
+ standardHeaders: true,
+ legacyHeaders: false,
+ skip: () => isTest // Skip rate limiting entirely in test environment
+});
+
+export default { authLimiter, apiLimiter };
diff --git a/middleware/requestId.js b/middleware/requestId.js
new file mode 100644
index 0000000..db262e2
--- /dev/null
+++ b/middleware/requestId.js
@@ -0,0 +1,20 @@
+/**
+ * Request ID Middleware
+ * Generates unique request IDs for tracing and log correlation
+ */
+
+import { randomUUID } from 'crypto';
+
+/**
+ * Attaches a unique request ID to each incoming request
+ * ID is available as req.id and returned in X-Request-ID response header
+ * If client provides X-Request-ID header, it will be reused for tracing
+ */
+export const requestId = (req, res, next) => {
+ const id = req.headers['x-request-id'] || randomUUID();
+ req.id = id;
+ res.setHeader('X-Request-ID', id);
+ next();
+};
+
+export default requestId;
diff --git a/models/Place.js b/models/Place.js
index 9026e74..32eab8f 100644
--- a/models/Place.js
+++ b/models/Place.js
@@ -7,22 +7,33 @@ import mongoose from 'mongoose';
const placeSchema = new mongoose.Schema({
placeId: { type: Number, required: true, unique: true },
- name: { type: String, required: true },
+ name: { type: String, required: true, trim: true },
category: { type: String, required: true },
- description: String,
- address: String,
- city: String,
- country: String,
+ description: { type: String, trim: true },
+ address: { type: String, trim: true },
+ city: { type: String, trim: true },
+ country: { type: String, trim: true },
location: {
latitude: Number,
longitude: Number
},
website: String,
- rating: { type: Number, default: 0 },
- createdAt: { type: Date, default: Date.now }
+ rating: { type: Number, default: 0, min: 0, max: 5 }
+}, {
+ timestamps: true
});
// Index for better geospatial queries
placeSchema.index({ 'location.latitude': 1, 'location.longitude': 1 });
+// Index for category-based filtering
+placeSchema.index({ category: 1 });
+
+// Index for city-based queries
+placeSchema.index({ city: 1 });
+
+// Text index for search functionality
+placeSchema.index({ name: 'text', description: 'text', city: 'text' });
+
export default mongoose.model('Place', placeSchema);
+
diff --git a/models/User.js b/models/User.js
index 200cb51..6a96e67 100644
--- a/models/User.js
+++ b/models/User.js
@@ -7,9 +7,15 @@ import mongoose from 'mongoose';
const userSchema = new mongoose.Schema({
userId: { type: Number, required: true, unique: true },
- name: { type: String, required: true },
- email: { type: String, required: true, unique: true },
- phone: String,
+ name: { type: String, required: true, trim: true },
+ email: {
+ type: String,
+ required: true,
+ unique: true,
+ lowercase: true,
+ trim: true
+ },
+ phone: { type: String, trim: true },
dateOfBirth: String,
password: { type: String, required: true },
role: { type: String, enum: ['user', 'admin'], default: 'user' },
@@ -21,8 +27,14 @@ const userSchema = new mongoose.Schema({
latitude: Number,
longitude: Number
},
- activeProfile: Number,
- createdAt: { type: Date, default: Date.now }
+ activeProfile: Number
+}, {
+ timestamps: true // Adds createdAt and updatedAt automatically
});
+// Indexes for better query performance
+// Note: email already has unique index from unique: true
+userSchema.index({ role: 1 });
+
export default mongoose.model('User', userSchema);
+
diff --git a/package-lock.json b/package-lock.json
index 01980b7..98c6003 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,11 +10,12 @@
"license": "Apache-2.0",
"dependencies": {
"bcryptjs": "^2.4.3",
- "body-parser": "^1.20.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
+ "express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
+ "helmet": "^8.0.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.19.3",
@@ -2963,6 +2964,21 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
"node_modules/express-validator": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.3.0.tgz",
@@ -3444,6 +3460,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/helmet": {
+ "version": "8.1.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+ "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
diff --git a/package.json b/package.json
index fc07ece..a77c168 100644
--- a/package.json
+++ b/package.json
@@ -33,11 +33,12 @@
"homepage": "https://github.com/fraidakis/software-engineering-2-backend#readme",
"dependencies": {
"bcryptjs": "^2.4.3",
- "body-parser": "^1.20.2",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
+ "express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
+ "helmet": "^8.0.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.2",
"mongoose": "^8.19.3",
@@ -53,4 +54,4 @@
"nodemon": "^3.0.1",
"supertest": "^6.3.3"
}
-}
+}
\ No newline at end of file
diff --git a/routes/authRoutes.js b/routes/authRoutes.js
index 14ce832..0333f03 100644
--- a/routes/authRoutes.js
+++ b/routes/authRoutes.js
@@ -1,11 +1,49 @@
+/**
+ * Authentication Routes
+ * Handles login and signup with rate limiting and validation
+ */
+
import express from 'express';
-const router = express.Router();
+import { body } from 'express-validator';
+import { validate } from '../middleware/validation.js';
+import { authLimiter } from '../middleware/rateLimiter.js';
import authController from '../controllers/authController.js';
+const router = express.Router();
+
+// Validation chains
+const loginValidation = [
+ body('email')
+ .trim()
+ .isEmail()
+ .withMessage('Valid email is required'),
+ body('password')
+ .notEmpty()
+ .withMessage('Password is required')
+];
+
+const signupValidation = [
+ body('name')
+ .trim()
+ .notEmpty()
+ .withMessage('Name is required')
+ .isLength({ max: 100 })
+ .withMessage('Name must be 100 characters or less'),
+ body('email')
+ .trim()
+ .isEmail()
+ .withMessage('Valid email is required')
+ .normalizeEmail(),
+ body('password')
+ .isLength({ min: 6 })
+ .withMessage('Password must be at least 6 characters')
+];
+
// POST /auth/login - User login
-router.post('/login', authController.login);
+router.post('/login', authLimiter, loginValidation, validate, authController.login);
-// POST /auth/signup - User signup
-router.post('/signup', authController.signup);
+// POST /auth/signup - User signup
+router.post('/signup', authLimiter, signupValidation, validate, authController.signup);
export default router;
+
diff --git a/server.js b/server.js
index e02e3f3..3691eb0 100644
--- a/server.js
+++ b/server.js
@@ -20,11 +20,13 @@ const isProduction = process.env.NODE_ENV === 'production';
// Ensure a default JWT secret is available when not provided via .env
const DEFAULT_JWT_SECRET = 'myworld_secret_key_2025_change_in_production';
if (!process.env.JWT_SECRET) {
- process.env.JWT_SECRET = DEFAULT_JWT_SECRET;
- if (!isProduction) {
- console.warn('\n⚠️ JWT_SECRET not set in environment; using default secret.');
- console.warn(' Change this value in production by setting JWT_SECRET in .env\n');
+ if (isProduction) {
+ console.error('\n❌ FATAL: JWT_SECRET must be set in production environment\n');
+ process.exit(1);
}
+ process.env.JWT_SECRET = DEFAULT_JWT_SECRET;
+ console.warn('\n⚠️ JWT_SECRET not set in environment; using default secret.');
+ console.warn(' Change this value in production by setting JWT_SECRET in .env\n');
}
// Database connection module (only loaded if using MongoDB)
diff --git a/tests/helpers/testUtils.js b/tests/helpers/testUtils.js
index 9518d74..9e050fb 100644
--- a/tests/helpers/testUtils.js
+++ b/tests/helpers/testUtils.js
@@ -67,15 +67,15 @@ export const defaultAdminUser = {
*/
export const createTestUser = async (overrides = {}) => {
const userData = { ...defaultTestUser, ...overrides };
-
+
// Hash the password if provided
if (userData.password) {
userData.password = await bcrypt.hash(userData.password, 10);
}
-
+
// Create user via database
const user = await db.createUser(userData);
-
+
return user;
};
@@ -111,6 +111,28 @@ export const generateTestJWT = (user) => {
);
};
+/**
+ * Generate an expired JWT token for testing
+ * Useful for testing token expiry handling
+ *
+ * @param {Object} user - User object containing userId, email, and role
+ * @returns {string} Expired JWT token
+ *
+ * Example:
+ * const expiredToken = generateExpiredToken({ userId: 1, email: 'test@example.com', role: 'user' });
+ */
+export const generateExpiredToken = (user) => {
+ return jwt.sign(
+ {
+ userId: user.userId,
+ email: user.email,
+ role: user.role
+ },
+ process.env.JWT_SECRET,
+ { expiresIn: '-1h' } // Already expired
+ );
+};
+
/**
* Create an authenticated request with Authorization header
* Returns a Supertest request object with the Authorization header set
@@ -147,7 +169,7 @@ export const authRequest = (token) => {
export const createAuthenticatedUser = async (overrides = {}) => {
const user = await createTestUser(overrides);
const token = generateTestJWT(user);
-
+
return { user, token };
};
@@ -160,7 +182,7 @@ export const createAuthenticatedUser = async (overrides = {}) => {
export const createAuthenticatedAdmin = async (overrides = {}) => {
const admin = await createTestAdmin(overrides);
const token = generateTestJWT(admin);
-
+
return { user: admin, token };
};
@@ -185,7 +207,7 @@ export const createTestPlace = async (overrides = {}) => {
address: '123 Test Street',
...overrides
};
-
+
// Use your actual database method to create a place
// Example: await db.createPlace(placeData);
// For now, returning the data structure
@@ -213,7 +235,7 @@ export const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
export const extractCookies = (response) => {
const cookies = {};
const setCookieHeader = response.headers['set-cookie'];
-
+
if (setCookieHeader) {
setCookieHeader.forEach(cookie => {
const [nameValue] = cookie.split(';');
@@ -221,7 +243,7 @@ export const extractCookies = (response) => {
cookies[name.trim()] = value;
});
}
-
+
return cookies;
};
@@ -235,20 +257,20 @@ export const fixtures = {
email: 'newuser@example.com',
password: 'ValidPassword123!'
},
-
+
// Invalid user registration data
invalidUserRegistration: {
name: '',
email: 'invalid-email',
password: '123' // Too short
},
-
+
// Valid login credentials (matches defaultTestUser)
validLoginCredentials: {
email: 'test@example.com',
password: 'TestPassword123!'
},
-
+
// Invalid login credentials
invalidLoginCredentials: {
email: 'wrong@example.com',
@@ -267,7 +289,7 @@ export const verifyHATEOASLinks = (response, expectedLinks) => {
if (!response.links) {
return false;
}
-
+
return expectedLinks.every(linkName => {
const link = response.links[linkName];
return link && link.href && link.method;
@@ -283,6 +305,7 @@ export default {
createAuthenticatedAdmin,
createTestPlace,
generateTestJWT,
+ generateExpiredToken,
wait,
extractCookies,
fixtures,
diff --git a/tests/integration/security.test.js b/tests/integration/security.test.js
new file mode 100644
index 0000000..0e46225
--- /dev/null
+++ b/tests/integration/security.test.js
@@ -0,0 +1,408 @@
+/**
+ * Security Integration Tests
+ * Tests for JWT handling, authorization bypass prevention, and input validation
+ *
+ * Coverage:
+ * - JWT token expiry handling
+ * - Invalid token rejection
+ * - Authorization bypass prevention
+ * - Input validation and sanitization
+ */
+
+import {
+ api,
+ createAuthenticatedUser,
+ createAuthenticatedAdmin,
+ generateExpiredToken,
+ authRequest
+} from '../helpers/testUtils.js';
+import db from '../../config/db.js';
+
+describe('Security Tests', () => {
+ describe('JWT Token Handling', () => {
+ describe('Token Expiry', () => {
+ it('should reject requests with expired tokens', async () => {
+ // Arrange - Create user and generate expired token
+ const { user } = await createAuthenticatedUser({ email: 'expired@test.com' });
+ const expiredToken = generateExpiredToken(user);
+
+ // Act
+ const response = await api
+ .get(`/users/${user.userId}/profile`)
+ .set('Authorization', `Bearer ${expiredToken}`);
+
+ // Assert
+ expect(response.status).toBe(401);
+ expect(response.body.error).toBe('TOKEN_EXPIRED');
+ expect(response.body.message).toMatch(/expired/i);
+ });
+ });
+
+ describe('Invalid Tokens', () => {
+ it('should reject requests with malformed tokens', async () => {
+ // Act
+ const response = await api
+ .get('/users/1/profile')
+ .set('Authorization', 'Bearer invalid.token.here');
+
+ // Assert
+ expect(response.status).toBe(401);
+ expect(response.body.error).toBe('INVALID_TOKEN');
+ });
+
+ it('should reject requests without Bearer prefix', async () => {
+ // Arrange
+ const { token } = await createAuthenticatedUser({ email: 'nobearer@test.com' });
+
+ // Act - Send token without 'Bearer ' prefix
+ const response = await api
+ .get('/users/1/profile')
+ .set('Authorization', token);
+
+ // Assert
+ expect(response.status).toBe(401);
+ expect(response.body.error).toBe('AUTHENTICATION_REQUIRED');
+ });
+
+ it('should reject requests with empty Authorization header', async () => {
+ // Act
+ const response = await api
+ .get('/users/1/profile')
+ .set('Authorization', '');
+
+ // Assert
+ expect(response.status).toBe(401);
+ });
+
+ it('should reject requests with only Bearer (no token)', async () => {
+ // Act
+ const response = await api
+ .get('/users/1/profile')
+ .set('Authorization', 'Bearer ');
+
+ // Assert
+ expect(response.status).toBe(401);
+ });
+ });
+ });
+
+ describe('Authorization Bypass Prevention', () => {
+ describe('User Resource Access', () => {
+ it('should prevent user from accessing another user profile', async () => {
+ // Arrange - Create two users
+ const { user: user1, token: token1 } = await createAuthenticatedUser({
+ email: 'user1profile@test.com'
+ });
+ const { user: user2 } = await createAuthenticatedUser({
+ email: 'user2profile@test.com'
+ });
+
+ // Act - User1 tries to access User2's profile
+ const response = await authRequest(token1).get(`/users/${user2.userId}/profile`);
+
+ // Assert
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('ACCESS_DENIED');
+ expect(response.body.message).toMatch(/your own resources/i);
+ });
+
+ it('should prevent user from modifying another user favourites', async () => {
+ // Arrange - Create two users and a place
+ const { user: user1, token: token1 } = await createAuthenticatedUser({
+ email: 'user1fav@test.com'
+ });
+ const { user: user2 } = await createAuthenticatedUser({
+ email: 'user2fav@test.com'
+ });
+
+ // Create a test place
+ const place = await db.createPlace({
+ name: 'Fav Test Place',
+ category: 'RESTAURANT',
+ description: 'Test place for favourites',
+ location: { latitude: 40.64, longitude: 22.94 }
+ });
+
+ // Act - User1 tries to add favourite to User2's list
+ const response = await api
+ .post(`/users/${user2.userId}/favourite-places`)
+ .set('Authorization', `Bearer ${token1}`)
+ .send({ placeId: place.placeId });
+
+ // Assert
+ expect(response.status).toBe(403);
+ });
+
+ it('should prevent user from modifying another user settings', async () => {
+ // Arrange
+ const { user: user1, token: token1 } = await createAuthenticatedUser({
+ email: 'user1settings@test.com'
+ });
+ const { user: user2 } = await createAuthenticatedUser({
+ email: 'user2settings@test.com'
+ });
+
+ // Act - User1 tries to update User2's settings
+ const response = await api
+ .put(`/users/${user2.userId}/settings`)
+ .set('Authorization', `Bearer ${token1}`)
+ .send({ emailNotifications: false });
+
+ // Assert
+ expect(response.status).toBe(403);
+ });
+
+ it('should prevent user from accessing another user preference profiles', async () => {
+ // Arrange
+ const { user: user1, token: token1 } = await createAuthenticatedUser({
+ email: 'user1prefs@test.com'
+ });
+ const { user: user2 } = await createAuthenticatedUser({
+ email: 'user2prefs@test.com'
+ });
+
+ // Act - User1 tries to get User2's preference profiles
+ const response = await api
+ .get(`/users/${user2.userId}/preference-profiles`)
+ .set('Authorization', `Bearer ${token1}`);
+
+ // Assert
+ expect(response.status).toBe(403);
+ });
+ });
+
+ describe('Admin Endpoint Protection', () => {
+ it('should reject admin endpoints for regular users', async () => {
+ // Arrange - Create regular user
+ const { user, token } = await createAuthenticatedUser({
+ email: 'regularuser@test.com'
+ });
+
+ // Create a place to have valid report target
+ await db.createPlace({
+ name: 'Admin Test Place',
+ category: 'MUSEUM',
+ description: 'Test',
+ location: { latitude: 40.64, longitude: 22.94 }
+ });
+
+ // Act - Regular user tries to access admin endpoint
+ const response = await api
+ .get(`/admin/${user.userId}/places/1/reports`)
+ .set('Authorization', `Bearer ${token}`);
+
+ // Assert
+ expect(response.status).toBe(403);
+ expect(response.body.error).toBe('ACCESS_DENIED');
+ expect(response.body.message).toMatch(/admin|administrator/i);
+ });
+
+ it('should allow admin access to admin endpoints', async () => {
+ // Arrange - Create admin user
+ const { token } = await createAuthenticatedAdmin({
+ email: 'adminaccess@test.com'
+ });
+
+ // Create a place
+ const place = await db.createPlace({
+ name: 'Admin Access Test Place',
+ category: 'MUSEUM',
+ description: 'Test',
+ location: { latitude: 40.64, longitude: 22.94 }
+ });
+
+ // Act - Admin accesses admin endpoint
+ const response = await api
+ .get(`/admin/1/places/${place.placeId}/reports`)
+ .set('Authorization', `Bearer ${token}`);
+
+ // Assert - Should succeed (200) or return empty reports
+ expect([200, 404]).toContain(response.status);
+ });
+ });
+ });
+
+ describe('Input Validation', () => {
+ describe('XSS Prevention', () => {
+ it('should sanitize XSS attempts in review comments', async () => {
+ // Arrange
+ const { user, token } = await createAuthenticatedUser({
+ email: 'xsstest@test.com'
+ });
+
+ // Create a place for the review
+ const place = await db.createPlace({
+ name: 'XSS Test Place',
+ category: 'RESTAURANT',
+ description: 'Test',
+ location: { latitude: 40.64, longitude: 22.94 }
+ });
+
+ // Act - Submit review with XSS payload
+ const response = await api
+ .post(`/places/${place.placeId}/reviews`)
+ .set('Authorization', `Bearer ${token}`)
+ .send({
+ rating: 5,
+ comment: 'Great place!'
+ });
+
+ // Assert - Should succeed but sanitize the HTML
+ if (response.status === 201) {
+ expect(response.body.data.review.comment).not.toContain('