diff --git a/README.md b/README.md index 26c0356..92de6e9 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,10 @@ The following environment variables can be configured in your `.env` file: | `PDF_OUTPUT_DIR` | `tickets` | Directory for generated PDF tickets | | `PDF_CLEANUP_MAX_AGE_HOURS` | `24` | Maximum age for PDF cleanup | | `TEST_URL` | `http://localhost:3049` | Base URL for load testing | +| `ALLOWED_ORIGINS` | `localhost:3000,3049` | CORS allowed origins | +| `RATE_LIMIT_ENABLED` | `true` | Enable rate limiting | +| `SECURITY_HEADERS_ENABLED` | `true` | Enable security headers | +| `REDIS_SCAN_BATCH_SIZE` | `100` | Redis SCAN batch size for performance | ### Quick Start @@ -169,6 +173,26 @@ node tests/load-test.js --event 2 --connections 1000 --duration 10 # Test fallback store functionality npm run test:fallback +# Test security features +npm run test:security + +# Run comprehensive test suite +npm test + +# Run specific test categories +npm run test:unit # Unit tests only +npm run test:integration # Integration tests only +npm run test:performance # Performance tests only + +# Run critical duplicate prevention tests +npm run test:duplicate-prevention + +# Run with coverage report +npm run test:coverage + +# Run tests in watch mode (development) +npm run test:watch + ### Monitoring & Metrics #### Application Metrics @@ -226,6 +250,88 @@ docker-compose up -d --build - **Logging & Metrics:** Proper logging of operations and a functional metrics endpoint suitable for Prometheus scraping. - **Design Rationale:** The design document (`design.md`) should clearly articulate your architectural decisions, potential bottlenecks, and design solutions. +## Testing Suite + +The project includes a comprehensive testing framework to ensure reliability and prevent critical issues: + +### Test Categories + +- **Unit Tests** (`tests/unit/`): Test individual components in isolation +- **Integration Tests** (`tests/integration/`): Test component interactions and API endpoints +- **Performance Tests** (`tests/performance/`): Verify system behavior under high load + +### Critical Test Coverage + +- **Duplicate Prevention**: Automated verification that no ticket is sold more than once +- **High Concurrency**: Tests with 100+ concurrent requests to ensure data integrity +- **Fallback Mode**: Comprehensive testing of Redis failure scenarios +- **API Endpoints**: Full coverage of all REST endpoints with edge case handling +- **Security Features**: Validation of rate limiting, input validation, and security headers + +### Running Tests + +```bash +# Run all tests +npm test + +# Run specific test categories +npm run test:unit # Unit tests only +npm run test:integration # Integration tests only +npm run test:performance # Performance tests only + +# Run critical duplicate prevention tests +npm run test:duplicate-prevention + +# Generate coverage report +npm run test:coverage + +# Run tests in watch mode (development) +npm run test:watch + +# Use the test runner script for easier test execution +node run-tests.js all # Run all tests +node run-tests.js validate # Run core requirement validation +node run-tests.js duplicate # Run duplicate prevention tests only +node run-tests.js quick # Run quick test suite +``` + +### Test Requirements + +- **No Duplicate Tickets**: Core requirement verified by automated tests +- **High Concurrency**: System tested with 100+ concurrent requests +- **Data Consistency**: Redis and fallback store synchronization verified +- **Performance**: Response times and memory usage monitored under load +- **Security**: All security features validated with comprehensive tests + +## Security Features + +The system includes comprehensive security measures to protect against common threats: + +### Rate Limiting + +- **General API**: 100 requests per 15 minutes +- **Purchase Endpoints**: 10 requests per minute +- **Admin Endpoints**: 20 requests per 5 minutes + +### Input Validation + +- **Event IDs**: Must be positive integers +- **Purchase IDs**: Must be valid UUIDs +- **Request Parameters**: Validated and sanitized + +### Security Headers + +- **Content Security Policy**: Prevents XSS attacks +- **HSTS**: Enforces HTTPS connections +- **XSS Protection**: Additional XSS prevention +- **Frame Guard**: Prevents clickjacking + +### Request Security + +- **Size Limits**: Maximum 1MB request size +- **CORS Protection**: Configurable allowed origins +- **Security Logging**: Suspicious request monitoring + ## Troubleshooting ### Common Issues diff --git a/design.md b/design.md index 77bc28b..9ba8d32 100644 --- a/design.md +++ b/design.md @@ -285,9 +285,11 @@ local globalKey = KEYS[3] -- global:stats ### Input Validation -- **Event ID Validation**: Numeric constraints -- **Request Rate Limiting**: DDoS protection +- **Event ID Validation**: Numeric constraints with range checking +- **Purchase ID Validation**: UUID format validation +- **Request Rate Limiting**: Multi-tier DDoS protection - **Parameter Sanitization**: Injection prevention +- **Request Size Limits**: Prevents large payload attacks ### Container Security @@ -301,6 +303,14 @@ local globalKey = KEYS[3] -- global:stats - **Audit Logging**: Purchase tracking - **Secure Defaults**: Production-ready configuration +### Security Headers & Middleware + +- **Helmet.js**: Comprehensive security headers +- **Content Security Policy**: XSS prevention +- **HSTS**: HTTPS enforcement +- **Frame Guard**: Clickjacking protection +- **Security Logging**: Suspicious request monitoring + ## Deployment Strategy ### Development Environment diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..562d605 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,19 @@ +module.exports = { + testEnvironment: "node", + testMatch: ["**/tests/**/*.test.js", "**/*.test.js"], + collectCoverageFrom: [ + "src/**/*.js", + "server.js", + "!src/**/*.test.js", + "!**/node_modules/**", + ], + coverageDirectory: "coverage", + coverageReporters: ["text", "lcov", "html"], + setupFilesAfterEnv: ["/tests/setup.js"], + testTimeout: 30000, + verbose: true, + forceExit: true, + clearMocks: true, + resetMocks: true, + restoreMocks: true, +}; diff --git a/package.json b/package.json index 681596d..348ae26 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,17 @@ "dev": "nodemon server.js", "seed": "node seed.js", "test": "jest", + "test:unit": "jest tests/unit", + "test:integration": "jest tests/integration", + "test:performance": "jest tests/performance", + "test:coverage": "jest --coverage", + "test:watch": "jest --watch", "test:load": "node tests/load-test.js", "test:fallback": "node test-fallback.js", + "test:security": "node test-security.js", + "test:duplicate-prevention": "jest tests/integration/duplicate-prevention.test.js", + "test:api": "jest tests/integration/api-endpoints.test.js", + "test:load-performance": "jest tests/performance/load-testing.test.js", "docker:up": "docker-compose up -d", "docker:down": "docker-compose down" }, @@ -23,7 +32,11 @@ "winston": "^3.11.0", "prom-client": "^15.1.0", "uuid": "^9.0.1", - "dotenv": "^16.3.1" + "dotenv": "^16.3.1", + "express-rate-limit": "^7.1.5", + "helmet": "^7.1.0", + "express-validator": "^7.0.1", + "axios": "^1.6.0" }, "devDependencies": { "jest": "^29.7.0", diff --git a/run-tests.js b/run-tests.js new file mode 100644 index 0000000..d63bc3f --- /dev/null +++ b/run-tests.js @@ -0,0 +1,231 @@ +#!/usr/bin/env node + +const { spawn } = require("child_process"); +const path = require("path"); + +// ANSI color codes for better output +const colors = { + reset: "\x1b[0m", + bright: "\x1b[1m", + red: "\x1b[31m", + green: "\x1b[32m", + yellow: "\x1b[33m", + blue: "\x1b[34m", + magenta: "\x1b[35m", + cyan: "\x1b[36m", +}; + +function log(message, color = "reset") { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function logHeader(message) { + console.log("\n" + "=".repeat(60)); + log(` ${message}`, "bright"); + console.log("=".repeat(60)); +} + +function runCommand(command, args = [], description = "") { + return new Promise((resolve, reject) => { + if (description) { + log(`\nšŸš€ ${description}`, "cyan"); + log(` Command: ${command} ${args.join(" ")}`, "yellow"); + } + + const child = spawn(command, args, { + stdio: "inherit", + shell: true, + }); + + child.on("close", (code) => { + if (code === 0) { + if (description) { + log(`āœ… ${description} completed successfully`, "green"); + } + resolve(); + } else { + if (description) { + log(`āŒ ${description} failed with code ${code}`, "red"); + } + reject(new Error(`Command failed with code ${code}`)); + } + }); + + child.on("error", (error) => { + log(`āŒ Error running ${description}: ${error.message}`, "red"); + reject(error); + }); + }); +} + +async function runTestSuite() { + const args = process.argv.slice(2); + const command = args[0]; + + logHeader("Ticket Microservice Test Runner"); + log("Comprehensive testing suite for the ticket microservice", "blue"); + + try { + switch (command) { + case "all": + logHeader("Running Complete Test Suite"); + await runCommand("npm", ["test"], "Complete test suite"); + break; + + case "unit": + logHeader("Running Unit Tests"); + await runCommand("npm", ["run", "test:unit"], "Unit tests"); + break; + + case "integration": + logHeader("Running Integration Tests"); + await runCommand( + "npm", + ["run", "test:integration"], + "Integration tests" + ); + break; + + case "performance": + logHeader("Running Performance Tests"); + await runCommand( + "npm", + ["run", "test:performance"], + "Performance tests" + ); + break; + + case "duplicate": + logHeader("Running Critical Duplicate Prevention Tests"); + await runCommand( + "npm", + ["run", "test:duplicate-prevention"], + "Duplicate prevention tests" + ); + break; + + case "api": + logHeader("Running API Endpoint Tests"); + await runCommand("npm", ["run", "test:api"], "API endpoint tests"); + break; + + case "security": + logHeader("Running Security Tests"); + await runCommand("npm", ["run", "test:security"], "Security tests"); + break; + + case "fallback": + logHeader("Running Fallback Store Tests"); + await runCommand( + "npm", + ["run", "test:fallback"], + "Fallback store tests" + ); + break; + + case "coverage": + logHeader("Running Tests with Coverage Report"); + await runCommand("npm", ["run", "test:coverage"], "Coverage tests"); + break; + + case "load": + logHeader("Running Load Tests"); + await runCommand("npm", ["run", "test:load"], "Load tests"); + break; + + case "quick": + logHeader("Running Quick Test Suite (Critical Paths Only)"); + log("Running duplicate prevention tests...", "yellow"); + await runCommand( + "npm", + ["run", "test:duplicate-prevention"], + "Duplicate prevention tests" + ); + log("Running API endpoint tests...", "yellow"); + await runCommand("npm", ["run", "test:api"], "API endpoint tests"); + log("Running security tests...", "yellow"); + await runCommand("npm", ["run", "test:security"], "Security tests"); + break; + + case "validate": + logHeader("Running Validation Tests (Core Requirements)"); + log("1. Duplicate Prevention Tests", "cyan"); + await runCommand( + "npm", + ["run", "test:duplicate-prevention"], + "Duplicate prevention validation" + ); + log("2. High Concurrency Tests", "cyan"); + await runCommand( + "npm", + ["run", "test:load-performance"], + "High concurrency validation" + ); + log("3. API Endpoint Tests", "cyan"); + await runCommand("npm", ["run", "test:api"], "API endpoint validation"); + log("4. Security Tests", "cyan"); + await runCommand( + "npm", + ["run", "test:security"], + "Security validation" + ); + break; + + default: + logHeader("Available Test Commands"); + log("Usage: node run-tests.js ", "bright"); + console.log(""); + log("Commands:", "bright"); + log(" all - Run complete test suite", "green"); + log(" unit - Run unit tests only", "green"); + log(" integration- Run integration tests only", "green"); + log(" performance- Run performance tests only", "green"); + log(" duplicate - Run duplicate prevention tests", "green"); + log(" api - Run API endpoint tests", "green"); + log(" security - Run security tests", "green"); + log(" fallback - Run fallback store tests", "green"); + log(" coverage - Run tests with coverage report", "green"); + log(" load - Run load tests", "green"); + log(" quick - Run quick test suite (critical paths)", "green"); + log(" validate - Run validation tests (core requirements)", "green"); + console.log(""); + log("Examples:", "bright"); + log(" node run-tests.js all", "yellow"); + log(" node run-tests.js duplicate", "yellow"); + log(" node run-tests.js validate", "yellow"); + console.log(""); + log( + "Note: Make sure Redis is running and the application is properly configured.", + "cyan" + ); + break; + } + + if (command && command !== "help") { + logHeader("Test Suite Completed Successfully"); + log("šŸŽ‰ All tests passed! The system is working correctly.", "green"); + } + } catch (error) { + logHeader("Test Suite Failed"); + log(`āŒ Error: ${error.message}`, "red"); + log("Please check the test output above for details.", "yellow"); + process.exit(1); + } +} + +// Handle process termination +process.on("SIGINT", () => { + log("\n\nāš ļø Test execution interrupted by user", "yellow"); + process.exit(0); +}); + +process.on("SIGTERM", () => { + log("\n\nāš ļø Test execution terminated", "yellow"); + process.exit(0); +}); + +// Run the test suite +runTestSuite().catch((error) => { + log(`\nšŸ’„ Fatal error: ${error.message}`, "red"); + process.exit(1); +}); diff --git a/server.js b/server.js index 43b5b94..c2f079d 100644 --- a/server.js +++ b/server.js @@ -8,13 +8,18 @@ const fallbackStore = require("./src/utils/fallback-store"); const logger = require("./src/utils/logger"); const pdfGenerator = require("./src/utils/pdf-generator"); const metrics = require("./src/utils/metrics"); +const security = require("./src/utils/security"); const app = express(); const port = process.env.PORT || 3049; // Middleware app.use(express.json()); -app.use(express.static("public")); + +// Security middleware +app.use(security.securityHeaders); +app.use(security.requestSizeLimit); +app.use(security.securityLogging); // Request logging middleware app.use((req, res, next) => { @@ -29,6 +34,9 @@ app.use((req, res, next) => { // Prometheus metrics middleware app.use(metrics.metricsMiddleware); +// Apply general rate limiting to all routes +app.use(security.generalLimiter); + // Health check endpoint app.get("/health", async (req, res) => { const redisHealthy = redisClient.isHealthy(); @@ -75,7 +83,7 @@ app.get("/events", async (req, res) => { }); // Get specific event stats -app.get("/events/:eventId", async (req, res) => { +app.get("/events/:eventId", security.validateEventId, async (req, res) => { try { const eventId = req.params.eventId; let eventStats; @@ -108,109 +116,256 @@ app.get("/events/:eventId", async (req, res) => { }); // Purchase ticket endpoint (multi-event) -app.post("/buy/:eventId", async (req, res) => { - const startTime = Date.now(); - const eventId = req.params.eventId; - const purchaseId = uuidv4(); - const timestamp = new Date().toISOString(); +app.post( + "/buy/:eventId", + security.purchaseLimiter, + security.validateEventId, + async (req, res) => { + const startTime = Date.now(); + const eventId = req.params.eventId; + const purchaseId = uuidv4(); + const timestamp = new Date().toISOString(); - try { - let result; + try { + let result; - // Try Redis first - if (redisClient.isHealthy()) { + // Try Redis first + if (redisClient.isHealthy()) { + try { + const luaResult = await redisClient.purchaseTicket( + eventId, + purchaseId, + timestamp + ); + + if (luaResult[0]) { + // Success - generate PDF ticket + try { + // Get event details for PDF + const eventStats = await redisClient.getEventStats(eventId); + + const pdfStartTime = Date.now(); + const pdfResult = await pdfGenerator.generateTicketPDF({ + ticketId: luaResult[0], + eventId, + purchaseId, + eventName: eventStats?.name || `Event ${eventId}`, + eventDescription: + eventStats?.description || "Event description not available", + timestamp, + soldCount: luaResult[2], + }); + + // Record PDF generation metrics + const pdfDuration = (Date.now() - pdfStartTime) / 1000; + metrics.recordPDFGeneration( + pdfResult.success ? "success" : "failed", + pdfDuration + ); + + result = { + success: true, + ticket: luaResult[0], + purchaseId, + eventId, + soldCount: luaResult[2], + message: "Ticket purchased successfully!", + usingFallback: false, + pdf: { + generated: pdfResult.success, + filename: pdfResult.filename, + downloadUrl: `/tickets/${purchaseId}`, + }, + }; + + // Record metrics for successful purchase + metrics.recordTicketSale(eventId, "success"); + metrics.updateTicketMetrics( + eventId, + luaResult[2], + luaResult[3] || 0 + ); + + logger.logPurchase(eventId, luaResult[0], purchaseId, true); + logger.info(`PDF ticket generated for purchase ${purchaseId}`); + } catch (pdfError) { + logger.error("PDF generation failed:", pdfError); + + // Still return success for ticket purchase, but note PDF failure + result = { + success: true, + ticket: luaResult[0], + purchaseId, + eventId, + soldCount: luaResult[2], + message: + "Ticket purchased successfully! (PDF generation failed)", + usingFallback: false, + pdf: { + generated: false, + error: "PDF generation failed", + }, + }; + + // Record metrics for successful purchase (even with PDF failure) + metrics.recordTicketSale(eventId, "success"); + metrics.updateTicketMetrics( + eventId, + luaResult[2], + luaResult[3] || 0 + ); + + logger.logPurchase(eventId, luaResult[0], purchaseId, true); + } + } else { + // Failed - handle specific error + const errorCode = luaResult[1]; + let statusCode = 400; + let message = "Purchase failed"; + + switch (errorCode) { + case "EVENT_NOT_FOUND": + statusCode = 404; + message = "Event not found"; + break; + case "NO_TICKETS_AVAILABLE": + statusCode = 409; + message = "No tickets available for this event"; + break; + } + + // Record metrics for failed purchase + metrics.recordTicketSale(eventId, "failed"); + + logger.logPurchase(eventId, null, purchaseId, false, errorCode); + return res.status(statusCode).json({ + success: false, + message, + errorCode, + eventId, + purchaseId, + }); + } + } catch (redisError) { + logger.error( + "Redis purchase failed, attempting fallback:", + redisError + ); + // Activate fallback if not already active + if (!fallbackStore.isActive) { + fallbackStore.activate("Redis purchase operation failed"); + // Try to sync with Redis data if possible + setTimeout(() => { + if (fallbackStore.isActive && fallbackStore.events.size === 0) { + fallbackStore.attemptReseed(); + } + }, 100); + } + throw redisError; // Will be caught by outer try-catch for fallback + } + } else { + throw new Error("Redis not available"); + } + + const responseTime = Date.now() - startTime; + result.responseTime = `${responseTime}ms`; + + res.json(result); + } catch (error) { + // Fallback to in-memory store try { - const luaResult = await redisClient.purchaseTicket( + if (!fallbackStore.isActive) { + fallbackStore.activate("Redis connection failed during purchase"); + // Try to sync with Redis data if possible + setTimeout(() => { + if (fallbackStore.isActive && fallbackStore.events.size === 0) { + fallbackStore.attemptReseed(); + } + }, 100); + } + + const fallbackResult = fallbackStore.purchaseTicket( eventId, - purchaseId, - timestamp + purchaseId ); - if (luaResult[0]) { - // Success - generate PDF ticket + if (fallbackResult.success) { + // Generate PDF for fallback purchase try { - // Get event details for PDF - const eventStats = await redisClient.getEventStats(eventId); + const eventStats = fallbackStore.getEventStats(eventId); - const pdfStartTime = Date.now(); const pdfResult = await pdfGenerator.generateTicketPDF({ - ticketId: luaResult[0], + ticketId: fallbackResult.ticket, eventId, purchaseId, eventName: eventStats?.name || `Event ${eventId}`, eventDescription: eventStats?.description || "Event description not available", timestamp, - soldCount: luaResult[2], + soldCount: fallbackResult.soldCount, }); - // Record PDF generation metrics - const pdfDuration = (Date.now() - pdfStartTime) / 1000; - metrics.recordPDFGeneration( - pdfResult.success ? "success" : "failed", - pdfDuration - ); - - result = { + const responseTime = Date.now() - startTime; + res.json({ success: true, - ticket: luaResult[0], + ticket: fallbackResult.ticket, purchaseId, eventId, - soldCount: luaResult[2], - message: "Ticket purchased successfully!", - usingFallback: false, + soldCount: fallbackResult.soldCount, + message: "Ticket purchased successfully (fallback mode)!", + usingFallback: true, + responseTime: `${responseTime}ms`, pdf: { generated: pdfResult.success, filename: pdfResult.filename, downloadUrl: `/tickets/${purchaseId}`, }, - }; + }); - // Record metrics for successful purchase - metrics.recordTicketSale(eventId, "success"); + // Record metrics for successful fallback purchase + metrics.recordTicketSale(eventId, "success_fallback"); metrics.updateTicketMetrics( eventId, - luaResult[2], - luaResult[3] || 0 + fallbackResult.soldCount, + fallbackResult.remainingTickets || 0 ); - logger.logPurchase(eventId, luaResult[0], purchaseId, true); - logger.info(`PDF ticket generated for purchase ${purchaseId}`); + logger.info( + `PDF ticket generated for fallback purchase ${purchaseId}` + ); } catch (pdfError) { - logger.error("PDF generation failed:", pdfError); + logger.error("PDF generation failed in fallback mode:", pdfError); - // Still return success for ticket purchase, but note PDF failure - result = { + const responseTime = Date.now() - startTime; + res.json({ success: true, - ticket: luaResult[0], + ticket: fallbackResult.ticket, purchaseId, eventId, - soldCount: luaResult[2], - message: "Ticket purchased successfully! (PDF generation failed)", - usingFallback: false, + soldCount: fallbackResult.soldCount, + message: + "Ticket purchased successfully (fallback mode, PDF generation failed)!", + usingFallback: true, + responseTime: `${responseTime}ms`, pdf: { generated: false, error: "PDF generation failed", }, - }; + }); - // Record metrics for successful purchase (even with PDF failure) - metrics.recordTicketSale(eventId, "success"); + // Record metrics for successful fallback purchase (even with PDF failure) + metrics.recordTicketSale(eventId, "success_fallback"); metrics.updateTicketMetrics( eventId, - luaResult[2], - luaResult[3] || 0 + fallbackResult.soldCount, + fallbackResult.remainingTickets || 0 ); - - logger.logPurchase(eventId, luaResult[0], purchaseId, true); } } else { - // Failed - handle specific error - const errorCode = luaResult[1]; let statusCode = 400; let message = "Purchase failed"; - switch (errorCode) { + switch (fallbackResult.error) { case "EVENT_NOT_FOUND": statusCode = 404; message = "Event not found"; @@ -221,212 +376,84 @@ app.post("/buy/:eventId", async (req, res) => { break; } - // Record metrics for failed purchase - metrics.recordTicketSale(eventId, "failed"); + // Record metrics for failed fallback purchase + metrics.recordTicketSale(eventId, "failed_fallback"); - logger.logPurchase(eventId, null, purchaseId, false, errorCode); - return res.status(statusCode).json({ + logger.logPurchase( + eventId, + null, + purchaseId, + false, + fallbackResult.error + ); + res.status(statusCode).json({ success: false, message, - errorCode, + errorCode: fallbackResult.error, eventId, purchaseId, - }); - } - } catch (redisError) { - logger.error("Redis purchase failed, attempting fallback:", redisError); - // Activate fallback if not already active - if (!fallbackStore.isActive) { - fallbackStore.activate("Redis purchase operation failed"); - // Try to sync with Redis data if possible - setTimeout(() => { - if (fallbackStore.isActive && fallbackStore.events.size === 0) { - fallbackStore.attemptReseed(); - } - }, 100); - } - throw redisError; // Will be caught by outer try-catch for fallback - } - } else { - throw new Error("Redis not available"); - } - - const responseTime = Date.now() - startTime; - result.responseTime = `${responseTime}ms`; - - res.json(result); - } catch (error) { - // Fallback to in-memory store - try { - if (!fallbackStore.isActive) { - fallbackStore.activate("Redis connection failed during purchase"); - // Try to sync with Redis data if possible - setTimeout(() => { - if (fallbackStore.isActive && fallbackStore.events.size === 0) { - fallbackStore.attemptReseed(); - } - }, 100); - } - - const fallbackResult = fallbackStore.purchaseTicket(eventId, purchaseId); - - if (fallbackResult.success) { - // Generate PDF for fallback purchase - try { - const eventStats = fallbackStore.getEventStats(eventId); - - const pdfResult = await pdfGenerator.generateTicketPDF({ - ticketId: fallbackResult.ticket, - eventId, - purchaseId, - eventName: eventStats?.name || `Event ${eventId}`, - eventDescription: - eventStats?.description || "Event description not available", - timestamp, - soldCount: fallbackResult.soldCount, - }); - - const responseTime = Date.now() - startTime; - res.json({ - success: true, - ticket: fallbackResult.ticket, - purchaseId, - eventId, - soldCount: fallbackResult.soldCount, - message: "Ticket purchased successfully (fallback mode)!", usingFallback: true, - responseTime: `${responseTime}ms`, - pdf: { - generated: pdfResult.success, - filename: pdfResult.filename, - downloadUrl: `/tickets/${purchaseId}`, - }, }); - - // Record metrics for successful fallback purchase - metrics.recordTicketSale(eventId, "success_fallback"); - metrics.updateTicketMetrics( - eventId, - fallbackResult.soldCount, - fallbackResult.remainingTickets || 0 - ); - - logger.info( - `PDF ticket generated for fallback purchase ${purchaseId}` - ); - } catch (pdfError) { - logger.error("PDF generation failed in fallback mode:", pdfError); - - const responseTime = Date.now() - startTime; - res.json({ - success: true, - ticket: fallbackResult.ticket, - purchaseId, - eventId, - soldCount: fallbackResult.soldCount, - message: - "Ticket purchased successfully (fallback mode, PDF generation failed)!", - usingFallback: true, - responseTime: `${responseTime}ms`, - pdf: { - generated: false, - error: "PDF generation failed", - }, - }); - - // Record metrics for successful fallback purchase (even with PDF failure) - metrics.recordTicketSale(eventId, "success_fallback"); - metrics.updateTicketMetrics( - eventId, - fallbackResult.soldCount, - fallbackResult.remainingTickets || 0 - ); } - } else { - let statusCode = 400; - let message = "Purchase failed"; + } catch (fallbackError) { + logger.error("Both Redis and fallback failed:", fallbackError); - switch (fallbackResult.error) { - case "EVENT_NOT_FOUND": - statusCode = 404; - message = "Event not found"; - break; - case "NO_TICKETS_AVAILABLE": - statusCode = 409; - message = "No tickets available for this event"; - break; - } + // Record metrics for system failure + metrics.recordTicketSale(eventId, "system_error"); - // Record metrics for failed fallback purchase - metrics.recordTicketSale(eventId, "failed_fallback"); + logger.logPurchase(eventId, null, purchaseId, false, fallbackError); - logger.logPurchase( - eventId, - null, - purchaseId, - false, - fallbackResult.error - ); - res.status(statusCode).json({ + res.status(500).json({ success: false, - message, - errorCode: fallbackResult.error, + message: "System temporarily unavailable", eventId, purchaseId, - usingFallback: true, }); } - } catch (fallbackError) { - logger.error("Both Redis and fallback failed:", fallbackError); - - // Record metrics for system failure - metrics.recordTicketSale(eventId, "system_error"); - - logger.logPurchase(eventId, null, purchaseId, false, fallbackError); - - res.status(500).json({ - success: false, - message: "System temporarily unavailable", - eventId, - purchaseId, - }); } } -}); +); // Download ticket PDF endpoint -app.get("/tickets/:purchaseId", async (req, res) => { - try { - const purchaseId = req.params.purchaseId; +app.get( + "/tickets/:purchaseId", + security.validatePurchaseId, + async (req, res) => { + try { + const purchaseId = req.params.purchaseId; - if (!pdfGenerator.ticketExists(purchaseId)) { - return res.status(404).json({ + if (!pdfGenerator.ticketExists(purchaseId)) { + return res.status(404).json({ + success: false, + message: "Ticket not found", + }); + } + + const filepath = pdfGenerator.getTicketPath(purchaseId); + const filename = `ticket-${purchaseId}.pdf`; + + res.setHeader("Content-Type", "application/pdf"); + res.setHeader( + "Content-Disposition", + `attachment; filename="${filename}"` + ); + + const fileStream = require("fs").createReadStream(filepath); + fileStream.pipe(res); + + logger.info(`PDF ticket downloaded: ${purchaseId}`); + } catch (error) { + logger.error("Error downloading ticket:", error); + res.status(500).json({ success: false, - message: "Ticket not found", + message: "Failed to download ticket", }); } - - const filepath = pdfGenerator.getTicketPath(purchaseId); - const filename = `ticket-${purchaseId}.pdf`; - - res.setHeader("Content-Type", "application/pdf"); - res.setHeader("Content-Disposition", `attachment; filename="${filename}"`); - - const fileStream = require("fs").createReadStream(filepath); - fileStream.pipe(res); - - logger.info(`PDF ticket downloaded: ${purchaseId}`); - } catch (error) { - logger.error("Error downloading ticket:", error); - res.status(500).json({ - success: false, - message: "Failed to download ticket", - }); } -}); +); // PDF management endpoint -app.get("/admin/pdf-stats", async (req, res) => { +app.get("/admin/pdf-stats", security.adminLimiter, async (req, res) => { try { const stats = pdfGenerator.getStats(); res.json({ @@ -443,27 +470,32 @@ app.get("/admin/pdf-stats", async (req, res) => { }); // Cleanup old tickets endpoint -app.post("/admin/cleanup-tickets", async (req, res) => { - try { - const maxAgeHours = req.body.maxAgeHours || 24; - const deletedCount = await pdfGenerator.cleanupOldTickets(maxAgeHours); +app.post( + "/admin/cleanup-tickets", + security.adminLimiter, + security.validateCleanupRequest, + async (req, res) => { + try { + const maxAgeHours = req.body.maxAgeHours || 24; + const deletedCount = await pdfGenerator.cleanupOldTickets(maxAgeHours); - res.json({ - success: true, - message: `Cleaned up ${deletedCount} old tickets`, - deletedCount, - }); - } catch (error) { - logger.error("Error cleaning up tickets:", error); - res.status(500).json({ - success: false, - message: "Failed to cleanup tickets", - }); + res.json({ + success: true, + message: `Cleaned up ${deletedCount} old tickets`, + deletedCount, + }); + } catch (error) { + logger.error("Error cleaning up tickets:", error); + res.status(500).json({ + success: false, + message: "Failed to cleanup tickets", + }); + } } -}); +); // Seed fallback store endpoint -app.post("/admin/seed-fallback", async (req, res) => { +app.post("/admin/seed-fallback", security.adminLimiter, async (req, res) => { try { if (redisClient.isHealthy()) { // Activate fallback store temporarily for seeding diff --git a/src/utils/redis-client.js b/src/utils/redis-client.js index ab23cd1..350a455 100644 --- a/src/utils/redis-client.js +++ b/src/utils/redis-client.js @@ -135,16 +135,58 @@ class RedisClient { } try { - const eventKeys = await this.client.keys("event:*:meta"); + // Use SCAN instead of KEYS to avoid blocking Redis const events = []; + let cursor = 0; - for (const key of eventKeys) { - const eventId = key.match(/event:(\d+):meta/)[1]; - const stats = await this.getEventStats(eventId); - if (stats) { - events.push(stats); + do { + const result = await this.client.scan(cursor, { + MATCH: "event:*:meta", + COUNT: 100, // Process in batches + }); + + cursor = result.cursor; + + // Process batch of keys + if (result.keys.length > 0) { + // Use pipeline to batch multiple operations + const pipeline = this.client.multi(); + + for (const key of result.keys) { + pipeline.hGetAll(key); + } + + const batchResults = await pipeline.exec(); + + // Process results and get ticket counts + for (let i = 0; i < result.keys.length; i++) { + const key = result.keys[i]; + const metadata = batchResults[i]; + + if (metadata && metadata.eventId) { + const eventId = metadata.eventId; + const ticketKey = `event:${eventId}:tickets`; + + // Get remaining tickets count efficiently + const remainingTickets = await this.client.lLen(ticketKey); + + events.push({ + eventId: eventId, + name: metadata.name, + description: metadata.description, + totalTickets: parseInt(metadata.totalTickets), + soldTickets: parseInt(metadata.soldTickets), + remainingTickets: remainingTickets, + createdAt: metadata.createdAt, + lastSoldAt: metadata.lastSoldAt || "never", + }); + } + } } - } + } while (cursor !== 0); + + // Sort events by ID for consistent ordering + events.sort((a, b) => parseInt(a.eventId) - parseInt(b.eventId)); return events; } catch (error) { diff --git a/src/utils/security.js b/src/utils/security.js new file mode 100644 index 0000000..20f4dc7 --- /dev/null +++ b/src/utils/security.js @@ -0,0 +1,203 @@ +const rateLimit = require("express-rate-limit"); +const helmet = require("helmet"); +const { body, param, validationResult } = require("express-validator"); + +// Rate limiting configuration +const createRateLimiter = (windowMs, max, message) => { + return rateLimit({ + windowMs, + max, + message: { + success: false, + message: message || "Too many requests, please try again later.", + retryAfter: Math.ceil(windowMs / 1000), + }, + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + res.status(429).json({ + success: false, + message: message || "Too many requests, please try again later.", + retryAfter: Math.ceil(windowMs / 1000), + }); + }, + }); +}; + +// General API rate limiting (100 requests per 15 minutes) +const generalLimiter = createRateLimiter( + 15 * 60 * 1000, // 15 minutes + 100, + "API rate limit exceeded. Please try again later." +); + +// Purchase endpoint rate limiting (10 requests per minute) +const purchaseLimiter = createRateLimiter( + 60 * 1000, // 1 minute + 10, + "Too many purchase attempts. Please wait before trying again." +); + +// Admin endpoints rate limiting (20 requests per 5 minutes) +const adminLimiter = createRateLimiter( + 5 * 60 * 1000, // 5 minutes + 20, + "Too many admin requests. Please wait before trying again." +); + +// Input validation middleware +const validateEventId = [ + param("eventId") + .isInt({ min: 1 }) + .withMessage("Event ID must be a positive integer") + .toInt(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: "Invalid event ID", + errors: errors.array(), + }); + } + next(); + }, +]; + +const validatePurchaseId = [ + param("purchaseId").isUUID(4).withMessage("Purchase ID must be a valid UUID"), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: "Invalid purchase ID", + errors: errors.array(), + }); + } + next(); + }, +]; + +const validateCleanupRequest = [ + body("maxAgeHours") + .optional() + .isInt({ min: 1, max: 8760 }) // 1 hour to 1 year + .withMessage("Max age must be between 1 and 8760 hours") + .toInt(), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + success: false, + message: "Invalid cleanup parameters", + errors: errors.array(), + }); + } + next(); + }, +]; + +// Security headers middleware +const securityHeaders = helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:"], + }, + }, + hsts: { + maxAge: 31536000, // 1 year + includeSubDomains: true, + preload: true, + }, + noSniff: true, + xssFilter: true, + frameguard: { action: "deny" }, +}); + +// Request size limiting +const requestSizeLimit = (req, res, next) => { + const contentLength = parseInt(req.headers["content-length"] || "0"); + const maxSize = 1024 * 1024; // 1MB + + if (contentLength > maxSize) { + return res.status(413).json({ + success: false, + message: "Request entity too large. Maximum size is 1MB.", + }); + } + + next(); +}; + +// CORS configuration +const corsOptions = { + origin: process.env.ALLOWED_ORIGINS + ? process.env.ALLOWED_ORIGINS.split(",") + : ["http://localhost:3000", "http://localhost:3049"], + methods: ["GET", "POST", "PUT", "DELETE"], + allowedHeaders: ["Content-Type", "Authorization"], + credentials: true, + maxAge: 86400, // 24 hours +}; + +// IP address extraction (for rate limiting) +const getClientIP = (req) => { + return ( + req.ip || + req.connection.remoteAddress || + req.socket.remoteAddress || + req.connection.socket?.remoteAddress || + "unknown" + ); +}; + +// Request logging for security monitoring +const securityLogging = (req, res, next) => { + const clientIP = getClientIP(req); + const userAgent = req.get("User-Agent") || "unknown"; + + // Log suspicious requests + if ( + req.path.includes("admin") || + req.path.includes("..") || + req.path.includes("//") + ) { + console.warn( + `Security Warning: Suspicious request from ${clientIP} to ${req.path}`, + { + ip: clientIP, + userAgent, + path: req.path, + method: req.method, + timestamp: new Date().toISOString(), + } + ); + } + + next(); +}; + +module.exports = { + // Rate limiters + generalLimiter, + purchaseLimiter, + adminLimiter, + + // Input validation + validateEventId, + validatePurchaseId, + validateCleanupRequest, + + // Security middleware + securityHeaders, + requestSizeLimit, + corsOptions, + + // Utilities + getClientIP, + securityLogging, +}; diff --git a/test-security.js b/test-security.js new file mode 100644 index 0000000..4d83184 --- /dev/null +++ b/test-security.js @@ -0,0 +1,120 @@ +const axios = require("axios"); + +const BASE_URL = process.env.TEST_URL || "http://localhost:3049"; + +async function testSecurityFeatures() { + console.log("šŸ”’ Testing Security Features\n"); + + try { + // Test 1: Rate Limiting + console.log("1. Testing Rate Limiting..."); + const promises = []; + for (let i = 0; i < 15; i++) { + promises.push(axios.get(`${BASE_URL}/health`)); + } + + try { + await Promise.all(promises); + console.log(" āŒ Rate limiting not working (all requests succeeded)"); + } catch (error) { + if (error.response?.status === 429) { + console.log(" āœ… Rate limiting working correctly"); + } else { + console.log(" āŒ Unexpected error:", error.message); + } + } + + // Test 2: Input Validation - Invalid Event ID + console.log("\n2. Testing Input Validation..."); + try { + await axios.get(`${BASE_URL}/events/invalid`); + console.log(" āŒ Invalid event ID accepted"); + } catch (error) { + if (error.response?.status === 400) { + console.log(" āœ… Invalid event ID properly rejected"); + } else { + console.log(" āŒ Unexpected error:", error.message); + } + } + + // Test 3: Input Validation - Invalid Purchase ID + try { + await axios.get(`${BASE_URL}/tickets/invalid-uuid`); + console.log(" āŒ Invalid purchase ID accepted"); + } catch (error) { + if (error.response?.status === 400) { + console.log(" āœ… Invalid purchase ID properly rejected"); + } else { + console.log(" āŒ Unexpected error:", error.message); + } + } + + // Test 4: Security Headers + console.log("\n3. Testing Security Headers..."); + try { + const response = await axios.get(`${BASE_URL}/health`); + const headers = response.headers; + + const securityHeaders = { + "X-Content-Type-Options": headers["x-content-type-options"], + "X-Frame-Options": headers["x-frame-options"], + "X-XSS-Protection": headers["x-xss-protection"], + "Strict-Transport-Security": headers["strict-transport-security"], + }; + + console.log(" Security Headers:"); + Object.entries(securityHeaders).forEach(([header, value]) => { + if (value) { + console.log(` āœ… ${header}: ${value}`); + } else { + console.log(` āŒ ${header}: Missing`); + } + }); + } catch (error) { + console.log(" āŒ Error checking security headers:", error.message); + } + + // Test 5: Admin Rate Limiting + console.log("\n4. Testing Admin Rate Limiting..."); + const adminPromises = []; + for (let i = 0; i < 25; i++) { + adminPromises.push(axios.get(`${BASE_URL}/admin/pdf-stats`)); + } + + try { + await Promise.all(adminPromises); + console.log(" āŒ Admin rate limiting not working"); + } catch (error) { + if (error.response?.status === 429) { + console.log(" āœ… Admin rate limiting working correctly"); + } else { + console.log(" āŒ Unexpected error:", error.message); + } + } + + // Test 6: Purchase Rate Limiting + console.log("\n5. Testing Purchase Rate Limiting..."); + const purchasePromises = []; + for (let i = 0; i < 15; i++) { + purchasePromises.push(axios.post(`${BASE_URL}/buy/1`)); + } + + try { + await Promise.all(purchasePromises); + console.log(" āŒ Purchase rate limiting not working"); + } catch (error) { + if (error.response?.status === 429) { + console.log(" āœ… Purchase rate limiting working correctly"); + } else { + console.log(" āŒ Unexpected error:", error.message); + } + } + + console.log("\nāœ… Security Tests Completed!"); + } catch (error) { + console.error("āŒ Test failed:", error.message); + } +} + +// Run the security tests +testSecurityFeatures(); diff --git a/tests/integration/api-endpoints.test.js b/tests/integration/api-endpoints.test.js new file mode 100644 index 0000000..1eb7858 --- /dev/null +++ b/tests/integration/api-endpoints.test.js @@ -0,0 +1,374 @@ +const request = require("supertest"); +const app = require("../../server"); +const redisClient = require("../../src/utils/redis-client"); +const fallbackStore = require("../../src/utils/fallback-store"); + +describe("API Endpoints - Integration Tests", () => { + let server; + let testEventId = "888"; // Use a unique event ID for testing + + beforeAll(async () => { + // Start the server + server = app.listen(0); // Use random port + + // Wait for server to be ready + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Ensure Redis is connected + if (!redisClient.isConnected) { + await redisClient.connect(); + } + }); + + afterAll(async () => { + // Clean up test data + try { + if (redisClient.isConnected) { + const testEventKey = `event:${testEventId}:meta`; + const testTicketsKey = `event:${testEventId}:tickets`; + await redisClient.client.del(testEventKey); + await redisClient.client.del(testTicketsKey); + } + } catch (error) { + console.warn("Failed to cleanup test data:", error.message); + } + + // Close server + if (server) { + await new Promise((resolve) => server.close(resolve)); + } + + // Disconnect Redis + if (redisClient.isConnected) { + await redisClient.disconnect(); + } + }); + + beforeEach(async () => { + // Reset fallback store + fallbackStore.deactivate(); + fallbackStore.events.clear(); + fallbackStore.globalStats = { + totalEvents: 0, + totalTickets: 0, + totalSold: 0, + lastSeeded: null, + }; + + // Create test event with 5 tickets in Redis + if (redisClient.isConnected) { + const testEventKey = `event:${testEventId}:meta`; + const testTicketsKey = `event:${testEventId}:tickets`; + + // Create event metadata + await redisClient.client.hSet(testEventKey, { + eventId: testEventId, + name: "Test Event for API Testing", + description: "Test event to verify API endpoints", + totalTickets: "5", + soldTickets: "0", + createdAt: new Date().toISOString(), + lastSoldAt: "never", + }); + + // Create 5 test tickets + const testTickets = Array.from( + { length: 5 }, + (_, i) => `api-test-ticket-${i + 1}` + ); + await redisClient.client.lPush(testTicketsKey, testTickets); + } + }); + + describe("Health Check Endpoint", () => { + test("GET /health should return system status", async () => { + const response = await request(server).get("/health").expect(200); + + expect(response.body).toHaveProperty("status", "ok"); + expect(response.body).toHaveProperty("timestamp"); + expect(response.body).toHaveProperty("redis"); + expect(response.body).toHaveProperty("uptime"); + expect(response.body.redis).toHaveProperty("connected"); + expect(response.body.redis).toHaveProperty("fallbackActive"); + }); + }); + + describe("Events Endpoints", () => { + test("GET /events should return all events", async () => { + const response = await request(server).get("/events").expect(200); + + expect(response.body).toHaveProperty("success", true); + expect(response.body).toHaveProperty("events"); + expect(response.body).toHaveProperty("usingFallback"); + expect(Array.isArray(response.body.events)).toBe(true); + }); + + test("GET /events/:eventId should return specific event", async () => { + const response = await request(server) + .get(`/events/${testEventId}`) + .expect(200); + + expect(response.body).toHaveProperty("success", true); + expect(response.body).toHaveProperty("event"); + expect(response.body.event).toHaveProperty("eventId", testEventId); + expect(response.body.event).toHaveProperty("name"); + expect(response.body.event).toHaveProperty("totalTickets", 5); + expect(response.body.event).toHaveProperty("remainingTickets", 5); + }); + + test("GET /events/:eventId should return 404 for non-existent event", async () => { + const response = await request(server).get("/events/99999").expect(404); + + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty("message", "Event not found"); + }); + + test("GET /events/:eventId should validate event ID format", async () => { + const response = await request(server).get("/events/invalid").expect(400); + + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty("message", "Invalid event ID"); + }); + }); + + describe("Ticket Purchase Endpoint", () => { + test("POST /buy/:eventId should purchase ticket successfully", async () => { + const response = await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json") + .expect(200); + + expect(response.body).toHaveProperty("success", true); + expect(response.body).toHaveProperty("ticket"); + expect(response.body).toHaveProperty("purchaseId"); + expect(response.body).toHaveProperty("eventId", testEventId); + expect(response.body).toHaveProperty("soldCount", 1); + expect(response.body).toHaveProperty("usingFallback", false); + expect(response.body).toHaveProperty("pdf"); + expect(response.body.pdf).toHaveProperty("generated"); + }); + + test("POST /buy/:eventId should fail for non-existent event", async () => { + const response = await request(server) + .post("/buy/99999") + .set("Content-Type", "application/json") + .expect(404); + + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty("message", "Event not found"); + }); + + test("POST /buy/:eventId should fail when no tickets available", async () => { + // Purchase all available tickets first + for (let i = 0; i < 5; i++) { + await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json") + .expect(200); + } + + // Try to purchase one more + const response = await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json") + .expect(409); + + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty( + "message", + "No tickets available for this event" + ); + }); + + test("POST /buy/:eventId should validate event ID format", async () => { + const response = await request(server) + .post("/buy/invalid") + .set("Content-Type", "application/json") + .expect(400); + + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty("message", "Invalid event ID"); + }); + }); + + describe("Ticket Download Endpoint", () => { + let purchaseId; + + beforeEach(async () => { + // Purchase a ticket first + const response = await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json"); + + purchaseId = response.body.purchaseId; + }); + + test("GET /tickets/:purchaseId should download ticket PDF", async () => { + const response = await request(server) + .get(`/tickets/${purchaseId}`) + .expect(200); + + expect(response.headers["content-type"]).toBe("application/pdf"); + expect(response.headers["content-disposition"]).toContain( + `filename="ticket-${purchaseId}.pdf"` + ); + expect(response.body).toBeDefined(); + }); + + test("GET /tickets/:purchaseId should return 404 for non-existent ticket", async () => { + const response = await request(server) + .get("/tickets/non-existent-uuid") + .expect(404); + + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty("message", "Ticket not found"); + }); + + test("GET /tickets/:purchaseId should validate purchase ID format", async () => { + const response = await request(server) + .get("/tickets/invalid-uuid") + .expect(400); + + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty("message", "Invalid purchase ID"); + }); + }); + + describe("Admin Endpoints", () => { + test("GET /admin/pdf-stats should return PDF statistics", async () => { + const response = await request(server) + .get("/admin/pdf-stats") + .expect(200); + + expect(response.body).toHaveProperty("success", true); + expect(response.body).toHaveProperty("stats"); + expect(response.body.stats).toHaveProperty("totalFiles"); + expect(response.body.stats).toHaveProperty("totalSize"); + }); + + test("POST /admin/cleanup-tickets should cleanup old tickets", async () => { + const response = await request(server) + .post("/admin/cleanup-tickets") + .set("Content-Type", "application/json") + .send({ maxAgeHours: 24 }) + .expect(200); + + expect(response.body).toHaveProperty("success", true); + expect(response.body).toHaveProperty("message"); + expect(response.body).toHaveProperty("deletedCount"); + }); + + test("POST /admin/cleanup-tickets should validate maxAgeHours parameter", async () => { + const response = await request(server) + .post("/admin/cleanup-tickets") + .set("Content-Type", "application/json") + .send({ maxAgeHours: -1 }) + .expect(400); + + expect(response.body).toHaveProperty("success", false); + expect(response.body).toHaveProperty( + "message", + "Invalid cleanup parameters" + ); + }); + + test("POST /admin/seed-fallback should seed fallback store", async () => { + const response = await request(server) + .post("/admin/seed-fallback") + .set("Content-Type", "application/json") + .expect(200); + + expect(response.body).toHaveProperty("success", true); + expect(response.body).toHaveProperty("message"); + expect(response.body).toHaveProperty("eventsCount"); + expect(response.body).toHaveProperty("totalTickets"); + expect(response.body).toHaveProperty("totalSold"); + }); + }); + + describe("Metrics Endpoint", () => { + test("GET /metrics should return system metrics", async () => { + const response = await request(server).get("/metrics").expect(200); + + expect(response.body).toHaveProperty("timestamp"); + expect(response.body).toHaveProperty("global"); + expect(response.body).toHaveProperty("events"); + expect(response.body).toHaveProperty("system"); + expect(response.body).toHaveProperty("pdf"); + + expect(response.body.system).toHaveProperty("usingFallback"); + expect(response.body.system).toHaveProperty("redisConnected"); + expect(response.body.system).toHaveProperty("uptime"); + expect(response.body.system).toHaveProperty("memoryUsage"); + }); + }); + + describe("Fallback Mode Operation", () => { + test("should operate in fallback mode when Redis is unavailable", async () => { + // Disconnect Redis to simulate failure + if (redisClient.isConnected) { + await redisClient.disconnect(); + } + + // Seed fallback store + const metadata = { + eventId: testEventId, + name: "Test Event for Fallback Testing", + description: "Test event in fallback mode", + totalTickets: 3, + soldTickets: 0, + createdAt: new Date().toISOString(), + lastSoldAt: "never", + }; + + const testTickets = [ + "fallback-ticket-1", + "fallback-ticket-2", + "fallback-ticket-3", + ]; + fallbackStore.seedEvent(testEventId, testTickets, metadata); + fallbackStore.activate("Test fallback mode"); + + // Test events endpoint in fallback mode + const eventsResponse = await request(server).get("/events").expect(200); + + expect(eventsResponse.body.usingFallback).toBe(true); + + // Test ticket purchase in fallback mode + const purchaseResponse = await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json") + .expect(200); + + expect(purchaseResponse.body.success).toBe(true); + expect(purchaseResponse.body.usingFallback).toBe(true); + expect(purchaseResponse.body.ticket).toBeDefined(); + + // Verify ticket was removed from fallback store + const event = fallbackStore.events.get(testEventId); + expect(event.tickets).toHaveLength(2); + }); + }); + + describe("Error Handling", () => { + test("should handle malformed JSON requests gracefully", async () => { + const response = await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json") + .send("invalid json") + .expect(400); + + expect(response.body).toHaveProperty("success", false); + }); + + test("should handle missing required parameters", async () => { + const response = await request(server) + .post("/admin/cleanup-tickets") + .set("Content-Type", "application/json") + .send({}) + .expect(200); // Should use default value + + expect(response.body).toHaveProperty("success", true); + }); + }); +}); diff --git a/tests/integration/duplicate-prevention.test.js b/tests/integration/duplicate-prevention.test.js new file mode 100644 index 0000000..aebbade --- /dev/null +++ b/tests/integration/duplicate-prevention.test.js @@ -0,0 +1,368 @@ +const request = require("supertest"); +const app = require("../../server"); +const redisClient = require("../../src/utils/redis-client"); +const fallbackStore = require("../../src/utils/fallback-store"); + +describe("Duplicate Ticket Prevention - Integration Tests", () => { + let server; + let testEventId = "999"; // Use a unique event ID for testing + + beforeAll(async () => { + // Start the server + server = app.listen(0); // Use random port + + // Wait for server to be ready + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Ensure Redis is connected + if (!redisClient.isConnected) { + await redisClient.connect(); + } + }); + + afterAll(async () => { + // Clean up test data + try { + if (redisClient.isConnected) { + const testEventKey = `event:${testEventId}:meta`; + const testTicketsKey = `event:${testEventId}:tickets`; + await redisClient.client.del(testEventKey); + await redisClient.client.del(testTicketsKey); + } + } catch (error) { + console.warn("Failed to cleanup test data:", error.message); + } + + // Close server + if (server) { + await new Promise((resolve) => server.close(resolve)); + } + + // Disconnect Redis + if (redisClient.isConnected) { + await redisClient.disconnect(); + } + }); + + beforeEach(async () => { + // Reset fallback store + fallbackStore.deactivate(); + fallbackStore.events.clear(); + fallbackStore.globalStats = { + totalEvents: 0, + totalTickets: 0, + totalSold: 0, + lastSeeded: null, + }; + + // Create test event with 10 tickets in Redis + if (redisClient.isConnected) { + const testEventKey = `event:${testEventId}:meta`; + const testTicketsKey = `event:${testEventId}:tickets`; + + // Create event metadata + await redisClient.client.hSet(testEventKey, { + eventId: testEventId, + name: "Test Event for Duplicate Prevention", + description: "Test event to verify no duplicate tickets", + totalTickets: "10", + soldTickets: "0", + createdAt: new Date().toISOString(), + lastSoldAt: "never", + }); + + // Create 10 test tickets + const testTickets = Array.from( + { length: 10 }, + (_, i) => `test-ticket-${i + 1}` + ); + await redisClient.client.lPush(testTicketsKey, testTickets); + + // Update global stats + await redisClient.client.hSet("global:stats", { + totalEvents: "1", + totalTickets: "10", + totalSold: "0", + lastSeeded: new Date().toISOString(), + }); + } + }); + + describe("Redis Ticket Purchase - Duplicate Prevention", () => { + test("should prevent duplicate ticket sales under normal conditions", async () => { + if (!redisClient.isConnected) { + console.warn("Skipping Redis test - Redis not connected"); + return; + } + + const purchaseIds = []; + const soldTickets = new Set(); + let successCount = 0; + let failureCount = 0; + + // Attempt to purchase 15 tickets (more than available) + for (let i = 0; i < 15; i++) { + const purchaseId = `test-purchase-${Date.now()}-${i}`; + purchaseIds.push(purchaseId); + + try { + const response = await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json"); + + if (response.status === 200 && response.body.success) { + successCount++; + const ticket = response.body.ticket; + + // Verify ticket is unique + expect(soldTickets.has(ticket)).toBe(false); + soldTickets.add(ticket); + + expect(response.body.usingFallback).toBe(false); + } else { + failureCount++; + expect(response.status).toBe(409); // No tickets available + expect(response.body.message).toContain("No tickets available"); + } + } catch (error) { + failureCount++; + } + } + + // Should have sold exactly 10 tickets (no duplicates) + expect(successCount).toBe(10); + expect(failureCount).toBe(5); + expect(soldTickets.size).toBe(10); + + // Verify no tickets remain + const remainingTickets = await redisClient.client.lLen( + `event:${testEventId}:tickets` + ); + expect(remainingTickets).toBe(0); + }); + + test("should prevent duplicate tickets under concurrent load", async () => { + if (!redisClient.isConnected) { + console.warn("Skipping Redis test - Redis not connected"); + return; + } + + // Reset test event with 5 tickets + const testTicketsKey = `event:${testEventId}:tickets`; + await redisClient.client.del(testTicketsKey); + const testTickets = Array.from( + { length: 5 }, + (_, i) => `concurrent-ticket-${i + 1}` + ); + await redisClient.client.lPush(testTicketsKey, testTickets); + + const soldTickets = new Set(); + const purchasePromises = []; + + // Create 10 concurrent purchase requests + for (let i = 0; i < 10; i++) { + const purchaseId = `concurrent-purchase-${Date.now()}-${i}`; + + const promise = request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json") + .then((response) => ({ success: true, response, purchaseId })) + .catch((error) => ({ success: false, error, purchaseId })); + + purchasePromises.push(promise); + } + + // Wait for all requests to complete + const results = await Promise.all(purchasePromises); + + let successCount = 0; + let failureCount = 0; + + results.forEach((result) => { + if ( + result.success && + result.response.status === 200 && + result.response.body.success + ) { + successCount++; + const ticket = result.response.body.ticket; + + // Verify ticket is unique + expect(soldTickets.has(ticket)).toBe(false); + soldTickets.add(ticket); + } else { + failureCount++; + } + }); + + // Should have sold exactly 5 tickets (no duplicates) + expect(successCount).toBe(5); + expect(failureCount).toBe(5); + expect(soldTickets.size).toBe(5); + + // Verify no tickets remain + const remainingTickets = await redisClient.client.lLen(testTicketsKey); + expect(remainingTickets).toBe(0); + }); + }); + + describe("Fallback Store - Duplicate Prevention", () => { + test("should prevent duplicate tickets in fallback mode", async () => { + // Seed fallback store with test event + const metadata = { + eventId: testEventId, + name: "Test Event for Duplicate Prevention", + description: "Test event to verify no duplicate tickets", + totalTickets: 5, + soldTickets: 0, + createdAt: new Date().toISOString(), + lastSoldAt: "never", + }; + + const testTickets = Array.from( + { length: 5 }, + (_, i) => `fallback-ticket-${i + 1}` + ); + fallbackStore.seedEvent(testEventId, testTickets, metadata); + fallbackStore.activate("Test activation"); + + const soldTickets = new Set(); + let successCount = 0; + let failureCount = 0; + + // Attempt to purchase 8 tickets (more than available) + for (let i = 0; i < 8; i++) { + const purchaseId = `fallback-purchase-${Date.now()}-${i}`; + + try { + const response = await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json"); + + if (response.status === 200 && response.body.success) { + successCount++; + const ticket = response.body.ticket; + + // Verify ticket is unique + expect(soldTickets.has(ticket)).toBe(false); + soldTickets.add(ticket); + + expect(response.body.usingFallback).toBe(true); + } else { + failureCount++; + expect(response.status).toBe(409); // No tickets available + expect(response.body.message).toContain("No tickets available"); + } + } catch (error) { + failureCount++; + } + } + + // Should have sold exactly 5 tickets (no duplicates) + expect(successCount).toBe(5); + expect(failureCount).toBe(3); + expect(soldTickets.size).toBe(5); + + // Verify no tickets remain in fallback store + const event = fallbackStore.events.get(testEventId); + expect(event.tickets).toHaveLength(0); + }); + }); + + describe("Mixed Mode - Redis + Fallback", () => { + test("should prevent duplicates when switching between Redis and fallback", async () => { + if (!redisClient.isConnected) { + console.warn("Skipping Redis test - Redis not connected"); + return; + } + + // Start with Redis + const soldTickets = new Set(); + + // Purchase 3 tickets from Redis + for (let i = 0; i < 3; i++) { + const purchaseId = `mixed-purchase-redis-${Date.now()}-${i}`; + + const response = await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json"); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.usingFallback).toBe(false); + + const ticket = response.body.ticket; + expect(soldTickets.has(ticket)).toBe(false); + soldTickets.add(ticket); + } + + // Simulate Redis failure by disconnecting + await redisClient.disconnect(); + + // Purchase remaining tickets from fallback + for (let i = 0; i < 7; i++) { + const purchaseId = `mixed-purchase-fallback-${Date.now()}-${i}`; + + const response = await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json"); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.usingFallback).toBe(true); + + const ticket = response.body.ticket; + expect(soldTickets.has(ticket)).toBe(false); + soldTickets.add(ticket); + } + + // Verify total unique tickets sold + expect(soldTickets.size).toBe(10); + + // Try to purchase one more (should fail) + const response = await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json"); + + expect(response.status).toBe(409); + expect(response.body.success).toBe(false); + expect(response.body.message).toContain("No tickets available"); + }); + }); + + describe("Data Integrity Verification", () => { + test("should maintain consistent ticket counts across Redis and fallback", async () => { + if (!redisClient.isConnected) { + console.warn("Skipping Redis test - Redis not connected"); + return; + } + + // Purchase 3 tickets + for (let i = 0; i < 3; i++) { + const purchaseId = `integrity-purchase-${Date.now()}-${i}`; + + const response = await request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json"); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + } + + // Check Redis stats + const redisStats = await redisClient.getEventStats(testEventId); + expect(redisStats.remainingTickets).toBe(7); + expect(redisStats.soldTickets).toBe(3); + + // Check fallback store stats (should be synced) + fallbackStore.activate("Test activation"); + const fallbackStats = fallbackStore.getEventStats(testEventId); + expect(fallbackStats.remainingTickets).toBe(7); + expect(fallbackStats.soldTickets).toBe(3); + + // Verify global stats + const globalStats = await redisClient.getGlobalStats(); + expect(globalStats.totalSold).toBe(3); + }); + }); +}); diff --git a/tests/performance/load-testing.test.js b/tests/performance/load-testing.test.js new file mode 100644 index 0000000..5ac994f --- /dev/null +++ b/tests/performance/load-testing.test.js @@ -0,0 +1,416 @@ +const request = require("supertest"); +const app = require("../../server"); +const redisClient = require("../../src/utils/redis-client"); +const fallbackStore = require("../../src/utils/fallback-store"); + +describe("Performance and Load Testing", () => { + let server; + let testEventId = "777"; // Use a unique event ID for testing + const CONCURRENT_REQUESTS = 100; // Test with 100 concurrent requests + const TICKET_COUNT = 50; // Start with 50 tickets + + beforeAll(async () => { + // Start the server + server = app.listen(0); // Use random port + + // Wait for server to be ready + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Ensure Redis is connected + if (!redisClient.isConnected) { + await redisClient.connect(); + } + }); + + afterAll(async () => { + // Clean up test data + try { + if (redisClient.isConnected) { + const testEventKey = `event:${testEventId}:meta`; + const testTicketsKey = `event:${testEventId}:tickets`; + await redisClient.client.del(testEventKey); + await redisClient.client.del(testTicketsKey); + } + } catch (error) { + console.warn("Failed to cleanup test data:", error.message); + } + + // Close server + if (server) { + await new Promise((resolve) => server.close(resolve)); + } + + // Disconnect Redis + if (redisClient.isConnected) { + await redisClient.disconnect(); + } + }); + + beforeEach(async () => { + // Reset fallback store + fallbackStore.deactivate(); + fallbackStore.events.clear(); + fallbackStore.globalStats = { + totalEvents: 0, + totalTickets: 0, + totalSold: 0, + lastSeeded: null, + }; + + // Create test event with tickets in Redis + if (redisClient.isConnected) { + const testEventKey = `event:${testEventId}:meta`; + const testTicketsKey = `event:${testEventId}:tickets`; + + // Create event metadata + await redisClient.client.hSet(testEventKey, { + eventId: testEventId, + name: "Performance Test Event", + description: "Test event for load testing", + totalTickets: TICKET_COUNT.toString(), + soldTickets: "0", + createdAt: new Date().toISOString(), + lastSoldAt: "never", + }); + + // Create test tickets + const testTickets = Array.from( + { length: TICKET_COUNT }, + (_, i) => `perf-ticket-${i + 1}` + ); + await redisClient.client.lPush(testTicketsKey, testTickets); + } + }); + + describe("High Concurrency Ticket Purchase", () => { + test("should handle 100 concurrent requests without duplicate tickets", async () => { + if (!redisClient.isConnected) { + console.warn("Skipping Redis test - Redis not connected"); + return; + } + + const soldTickets = new Set(); + const purchasePromises = []; + const startTime = Date.now(); + + // Create concurrent purchase requests + for (let i = 0; i < CONCURRENT_REQUESTS; i++) { + const promise = request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json") + .then((response) => ({ success: true, response, index: i })) + .catch((error) => ({ success: false, error, index: i })); + + purchasePromises.push(promise); + } + + // Wait for all requests to complete + const results = await Promise.all(purchasePromises); + const endTime = Date.now(); + const totalTime = endTime - startTime; + + let successCount = 0; + let failureCount = 0; + let duplicateCount = 0; + + results.forEach((result) => { + if ( + result.success && + result.response.status === 200 && + result.response.body.success + ) { + successCount++; + const ticket = result.response.body.ticket; + + // Check for duplicates + if (soldTickets.has(ticket)) { + duplicateCount++; + } else { + soldTickets.add(ticket); + } + } else { + failureCount++; + } + }); + + // Performance metrics + const avgResponseTime = totalTime / CONCURRENT_REQUESTS; + const requestsPerSecond = (CONCURRENT_REQUESTS / totalTime) * 1000; + + console.log(`\nšŸ“Š Performance Test Results:`); + console.log(` Total Time: ${totalTime}ms`); + console.log(` Average Response Time: ${avgResponseTime.toFixed(2)}ms`); + console.log(` Requests per Second: ${requestsPerSecond.toFixed(2)}`); + console.log(` Success Count: ${successCount}`); + console.log(` Failure Count: ${failureCount}`); + console.log(` Duplicate Tickets: ${duplicateCount}`); + + // Critical assertions + expect(duplicateCount).toBe(0); // No duplicate tickets allowed + expect(successCount).toBe(TICKET_COUNT); // Should sell exactly available tickets + expect(failureCount).toBe(CONCURRENT_REQUESTS - TICKET_COUNT); // Remaining should fail + expect(soldTickets.size).toBe(TICKET_COUNT); // All sold tickets should be unique + + // Performance requirements + expect(avgResponseTime).toBeLessThan(1000); // Should respond within 1 second on average + expect(requestsPerSecond).toBeGreaterThan(10); // Should handle at least 10 RPS + }); + + test("should maintain data consistency under high load", async () => { + if (!redisClient.isConnected) { + console.warn("Skipping Redis test - Redis not connected"); + return; + } + + // Purchase 20 tickets under load + const purchasePromises = []; + for (let i = 0; i < 20; i++) { + const promise = request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json"); + + purchasePromises.push(promise); + } + + await Promise.all(purchasePromises); + + // Verify Redis consistency + const remainingTickets = await redisClient.client.lLen( + `event:${testEventId}:tickets` + ); + const eventStats = await redisClient.getEventStats(testEventId); + + expect(remainingTickets).toBe(TICKET_COUNT - 20); + expect(eventStats.soldTickets).toBe(20); + expect(eventStats.remainingTickets).toBe(TICKET_COUNT - 20); + + // Verify no tickets remain after exhausting supply + const finalPurchasePromises = []; + for (let i = 0; i < TICKET_COUNT; i++) { + const promise = request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json"); + + finalPurchasePromises.push(promise); + } + + const finalResults = await Promise.all(finalPurchasePromises); + const finalSuccessCount = finalResults.filter( + (r) => + r.response && r.response.status === 200 && r.response.body.success + ).length; + + expect(finalSuccessCount).toBe(TICKET_COUNT - 20); // Should only sell remaining tickets + }); + }); + + describe("Fallback Store Performance", () => { + test("should handle high concurrency in fallback mode", async () => { + // Disconnect Redis to simulate failure + if (redisClient.isConnected) { + await redisClient.disconnect(); + } + + // Seed fallback store with test event + const metadata = { + eventId: testEventId, + name: "Fallback Performance Test Event", + description: "Test event for fallback performance", + totalTickets: 30, + soldTickets: 0, + createdAt: new Date().toISOString(), + lastSoldAt: "never", + }; + + const testTickets = Array.from( + { length: 30 }, + (_, i) => `fallback-perf-ticket-${i + 1}` + ); + fallbackStore.seedEvent(testEventId, testTickets, metadata); + fallbackStore.activate("Performance test fallback mode"); + + const soldTickets = new Set(); + const purchasePromises = []; + const startTime = Date.now(); + + // Create 50 concurrent purchase requests (more than available) + for (let i = 0; i < 50; i++) { + const promise = request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json") + .then((response) => ({ success: true, response, index: i })) + .catch((error) => ({ success: false, error, index: i })); + + purchasePromises.push(promise); + } + + const results = await Promise.all(purchasePromises); + const endTime = Date.now(); + const totalTime = endTime - startTime; + + let successCount = 0; + let failureCount = 0; + let duplicateCount = 0; + + results.forEach((result) => { + if ( + result.success && + result.response.status === 200 && + result.response.body.success + ) { + successCount++; + const ticket = result.response.body.ticket; + + if (soldTickets.has(ticket)) { + duplicateCount++; + } else { + soldTickets.add(ticket); + } + } else { + failureCount++; + } + }); + + console.log(`\nšŸ“Š Fallback Performance Test Results:`); + console.log(` Total Time: ${totalTime}ms`); + console.log(` Success Count: ${successCount}`); + console.log(` Failure Count: ${failureCount}`); + console.log(` Duplicate Tickets: ${duplicateCount}`); + + // Critical assertions for fallback mode + expect(duplicateCount).toBe(0); // No duplicate tickets + expect(successCount).toBe(30); // Should sell exactly available tickets + expect(failureCount).toBe(20); // Remaining should fail + expect(soldTickets.size).toBe(30); // All sold tickets should be unique + + // Verify fallback store consistency + const event = fallbackStore.events.get(testEventId); + expect(event.tickets).toHaveLength(0); // No tickets should remain + expect(event.soldTickets).toBe(30); // All tickets should be marked as sold + }); + }); + + describe("Memory and Resource Usage", () => { + test("should maintain stable memory usage under load", async () => { + if (!redisClient.isConnected) { + console.warn("Skipping Redis test - Redis not connected"); + return; + } + + const initialMemory = process.memoryUsage(); + console.log(`\nšŸ’¾ Initial Memory Usage:`); + console.log(` RSS: ${(initialMemory.rss / 1024 / 1024).toFixed(2)} MB`); + console.log( + ` Heap Used: ${(initialMemory.heapUsed / 1024 / 1024).toFixed(2)} MB` + ); + + // Perform multiple rounds of purchases + for (let round = 0; round < 3; round++) { + // Reset test event + const testTicketsKey = `event:${testEventId}:tickets`; + await redisClient.client.del(testTicketsKey); + const testTickets = Array.from( + { length: 20 }, + (_, i) => `round-${round}-ticket-${i + 1}` + ); + await redisClient.client.lPush(testTicketsKey, testTickets); + + // Purchase all tickets + const purchasePromises = []; + for (let i = 0; i < 20; i++) { + const promise = request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json"); + + purchasePromises.push(promise); + } + + await Promise.all(purchasePromises); + } + + const finalMemory = process.memoryUsage(); + console.log(`\nšŸ’¾ Final Memory Usage:`); + console.log(` RSS: ${(finalMemory.rss / 1024 / 1024).toFixed(2)} MB`); + console.log( + ` Heap Used: ${(finalMemory.heapUsed / 1024 / 1024).toFixed(2)} MB` + ); + + const memoryIncrease = finalMemory.heapUsed - initialMemory.heapUsed; + const memoryIncreaseMB = memoryIncrease / 1024 / 1024; + + console.log(`\nšŸ“ˆ Memory Change: ${memoryIncreaseMB.toFixed(2)} MB`); + + // Memory should not increase excessively (less than 50MB increase) + expect(memoryIncreaseMB).toBeLessThan(50); + }); + }); + + describe("Response Time Consistency", () => { + test("should maintain consistent response times under varying load", async () => { + if (!redisClient.isConnected) { + console.warn("Skipping Redis test - Redis not connected"); + return; + } + + const responseTimes = []; + const loadLevels = [10, 25, 50, 75, 100]; + + for (const loadLevel of loadLevels) { + // Reset test event + const testTicketsKey = `event:${testEventId}:tickets`; + await redisClient.client.del(testTicketsKey); + const testTickets = Array.from( + { length: loadLevel }, + (_, i) => `load-${loadLevel}-ticket-${i + 1}` + ); + await redisClient.client.lPush(testTicketsKey, testTickets); + + const startTime = Date.now(); + const purchasePromises = []; + + for (let i = 0; i < loadLevel; i++) { + const promise = request(server) + .post(`/buy/${testEventId}`) + .set("Content-Type", "application/json"); + + purchasePromises.push(promise); + } + + await Promise.all(purchasePromises); + const endTime = Date.now(); + const responseTime = endTime - startTime; + + responseTimes.push({ + loadLevel, + responseTime, + avgResponseTime: responseTime / loadLevel, + }); + + console.log(`\n⚔ Load Level ${loadLevel}:`); + console.log(` Total Time: ${responseTime}ms`); + console.log( + ` Average Response: ${(responseTime / loadLevel).toFixed(2)}ms` + ); + } + + // Calculate consistency metrics + const avgResponseTimes = responseTimes.map((r) => r.avgResponseTime); + const minResponseTime = Math.min(...avgResponseTimes); + const maxResponseTime = Math.max(...avgResponseTimes); + const responseTimeVariance = maxResponseTime - minResponseTime; + + console.log(`\nšŸ“Š Response Time Consistency:`); + console.log(` Min Avg Response: ${minResponseTime.toFixed(2)}ms`); + console.log(` Max Avg Response: ${maxResponseTime.toFixed(2)}ms`); + console.log(` Variance: ${responseTimeVariance.toFixed(2)}ms`); + + // Response times should be reasonably consistent (variance < 200ms) + expect(responseTimeVariance).toBeLessThan(200); + + // All response times should be reasonable (< 2 seconds average) + avgResponseTimes.forEach((time) => { + expect(time).toBeLessThan(2000); + }); + }); + }); +}); diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..0fd3d58 --- /dev/null +++ b/tests/setup.js @@ -0,0 +1,48 @@ +// Test setup and configuration +process.env.NODE_ENV = "test"; +process.env.PORT = "0"; // Use random port for tests +process.env.REDIS_URL = "redis://localhost:6379"; +process.env.LOG_LEVEL = "error"; // Reduce log noise during tests +process.env.PDF_OUTPUT_DIR = "test-tickets"; + +// Global test utilities +global.testUtils = { + // Generate unique test IDs + generateTestId: () => + `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + + // Wait for a specified time + wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + + // Generate test event data + createTestEvent: (eventId = 1, ticketCount = 100) => ({ + eventId: eventId.toString(), + name: `Test Event ${eventId}`, + description: `Test event description ${eventId}`, + totalTickets: ticketCount, + soldTickets: 0, + remainingTickets: ticketCount, + createdAt: new Date().toISOString(), + lastSoldAt: "never", + }), + + // Generate test ticket data + createTestTicket: (eventId = 1, ticketId = "test-ticket-1") => ({ + ticketId, + eventId: eventId.toString(), + purchaseId: `test-purchase-${Date.now()}`, + timestamp: new Date().toISOString(), + }), +}; + +// Mock console methods to reduce noise during tests +global.console = { + ...console, + log: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), +}; + +// Increase timeout for integration tests +jest.setTimeout(30000); diff --git a/tests/unit/fallback-store.test.js b/tests/unit/fallback-store.test.js new file mode 100644 index 0000000..4d75ea4 --- /dev/null +++ b/tests/unit/fallback-store.test.js @@ -0,0 +1,240 @@ +const fallbackStore = require("../../src/utils/fallback-store"); + +describe("Fallback Store", () => { + beforeEach(() => { + // Reset fallback store state before each test + fallbackStore.deactivate(); + fallbackStore.events.clear(); + fallbackStore.globalStats = { + totalEvents: 0, + totalTickets: 0, + totalSold: 0, + lastSeeded: null, + }; + }); + + describe("Initialization", () => { + test("should start in inactive state", () => { + expect(fallbackStore.isActive).toBe(false); + expect(fallbackStore.events.size).toBe(0); + }); + + test("should have empty global stats", () => { + const stats = fallbackStore.getGlobalStats(); + expect(stats.totalEvents).toBe(0); + expect(stats.totalTickets).toBe(0); + expect(stats.totalSold).toBe(0); + }); + }); + + describe("Activation/Deactivation", () => { + test("should activate and deactivate correctly", () => { + fallbackStore.activate("Test activation"); + expect(fallbackStore.isActive).toBe(true); + expect(fallbackStore.activationReason).toBe("Test activation"); + + fallbackStore.deactivate(); + expect(fallbackStore.isActive).toBe(false); + expect(fallbackStore.activationReason).toBe(null); + }); + + test("should log activation and deactivation", () => { + const consoleSpy = jest.spyOn(console, "warn"); + + fallbackStore.activate("Test"); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Fallback store activated") + ); + + fallbackStore.deactivate(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Fallback store deactivated") + ); + }); + }); + + describe("Event Seeding", () => { + test("should seed events correctly", () => { + const eventId = "1"; + const tickets = ["ticket1", "ticket2", "ticket3"]; + const metadata = { + eventId: "1", + name: "Test Event", + description: "Test Description", + totalTickets: 3, + soldTickets: 0, + createdAt: "2024-01-01T00:00:00.000Z", + lastSoldAt: "never", + }; + + fallbackStore.seedEvent(eventId, tickets, metadata); + + expect(fallbackStore.events.has(eventId)).toBe(true); + const event = fallbackStore.events.get(eventId); + expect(event.tickets).toEqual(tickets); + expect(event.metadata).toEqual(metadata); + expect(fallbackStore.globalStats.totalEvents).toBe(1); + expect(fallbackStore.globalStats.totalTickets).toBe(3); + }); + + test("should update global stats when seeding multiple events", () => { + const event1 = { eventId: "1", totalTickets: 5 }; + const event2 = { eventId: "2", totalTickets: 3 }; + + fallbackStore.seedEvent("1", ["t1", "t2", "t3", "t4", "t5"], event1); + fallbackStore.seedEvent("2", ["t6", "t7", "t8"], event2); + + expect(fallbackStore.globalStats.totalEvents).toBe(2); + expect(fallbackStore.globalStats.totalTickets).toBe(8); + }); + }); + + describe("Ticket Purchase", () => { + beforeEach(() => { + // Seed a test event + const metadata = { + eventId: "1", + name: "Test Event", + description: "Test Description", + totalTickets: 3, + soldTickets: 0, + createdAt: "2024-01-01T00:00:00.000Z", + lastSoldAt: "never", + }; + fallbackStore.seedEvent("1", ["ticket1", "ticket2", "ticket3"], metadata); + }); + + test("should purchase ticket successfully", () => { + const purchaseId = "test-purchase-123"; + const result = fallbackStore.purchaseTicket("1", purchaseId); + + expect(result.success).toBe(true); + expect(result.ticket).toBeDefined(); + expect(result.soldCount).toBe(1); + expect(result.remainingTickets).toBe(2); + + // Verify ticket was removed + const event = fallbackStore.events.get("1"); + expect(event.tickets).toHaveLength(2); + expect(event.tickets).not.toContain(result.ticket); + }); + + test("should prevent duplicate ticket sales", () => { + const purchaseId1 = "test-purchase-1"; + const purchaseId2 = "test-purchase-2"; + + // First purchase should succeed + const result1 = fallbackStore.purchaseTicket("1", purchaseId1); + expect(result1.success).toBe(true); + + // Second purchase should succeed (different purchase ID) + const result2 = fallbackStore.purchaseTicket("1", purchaseId2); + expect(result2.success).toBe(true); + + // Verify different tickets were sold + expect(result1.ticket).not.toBe(result2.ticket); + expect(result1.ticket).toBeDefined(); + expect(result2.ticket).toBeDefined(); + }); + + test("should fail when no tickets available", () => { + // Purchase all available tickets + fallbackStore.purchaseTicket("1", "purchase1"); + fallbackStore.purchaseTicket("1", "purchase2"); + fallbackStore.purchaseTicket("1", "purchase3"); + + // Try to purchase when no tickets left + const result = fallbackStore.purchaseTicket("1", "purchase4"); + expect(result.success).toBe(false); + expect(result.error).toBe("NO_TICKETS_AVAILABLE"); + }); + + test("should fail for non-existent event", () => { + const result = fallbackStore.purchaseTicket("999", "test-purchase"); + expect(result.success).toBe(false); + expect(result.error).toBe("EVENT_NOT_FOUND"); + }); + }); + + describe("Event Statistics", () => { + beforeEach(() => { + const metadata = { + eventId: "1", + name: "Test Event", + description: "Test Description", + totalTickets: 5, + soldTickets: 0, + createdAt: "2024-01-01T00:00:00.000Z", + lastSoldAt: "never", + }; + fallbackStore.seedEvent("1", ["t1", "t2", "t3", "t4", "t5"], metadata); + }); + + test("should return correct event stats", () => { + const stats = fallbackStore.getEventStats("1"); + expect(stats).toBeDefined(); + expect(stats.eventId).toBe("1"); + expect(stats.name).toBe("Test Event"); + expect(stats.totalTickets).toBe(5); + expect(stats.remainingTickets).toBe(5); + expect(stats.soldTickets).toBe(0); + }); + + test("should return null for non-existent event", () => { + const stats = fallbackStore.getEventStats("999"); + expect(stats).toBeNull(); + }); + + test("should update stats after ticket purchase", () => { + fallbackStore.purchaseTicket("1", "test-purchase"); + + const stats = fallbackStore.getEventStats("1"); + expect(stats.remainingTickets).toBe(4); + expect(stats.soldTickets).toBe(1); + }); + }); + + describe("Global Statistics", () => { + test("should return correct global stats", () => { + const stats = fallbackStore.getGlobalStats(); + expect(stats.totalEvents).toBe(0); + expect(stats.totalTickets).toBe(0); + expect(stats.totalSold).toBe(0); + expect(stats.lastSeeded).toBeNull(); + }); + + test("should update global stats after seeding", () => { + const metadata = { + eventId: "1", + name: "Test Event", + totalTickets: 3, + soldTickets: 0, + createdAt: "2024-01-01T00:00:00.000Z", + lastSoldAt: "never", + }; + fallbackStore.seedEvent("1", ["t1", "t2", "t3"], metadata); + + const stats = fallbackStore.getGlobalStats(); + expect(stats.totalEvents).toBe(1); + expect(stats.totalTickets).toBe(3); + }); + }); + + describe("Status Information", () => { + test("should return correct status", () => { + const status = fallbackStore.getStatus(); + expect(status.active).toBe(false); + expect(status.eventsCount).toBe(0); + expect(status.totalTickets).toBe(0); + expect(status.totalSold).toBe(0); + expect(status.activationReason).toBeNull(); + }); + + test("should return correct status when active", () => { + fallbackStore.activate("Test reason"); + const status = fallbackStore.getStatus(); + expect(status.active).toBe(true); + expect(status.activationReason).toBe("Test reason"); + }); + }); +}); diff --git a/tests/unit/security.test.js b/tests/unit/security.test.js new file mode 100644 index 0000000..933ab85 --- /dev/null +++ b/tests/unit/security.test.js @@ -0,0 +1,281 @@ +const security = require("../../src/utils/security"); + +describe("Security Middleware", () => { + let mockReq; + let mockRes; + let mockNext; + + beforeEach(() => { + mockReq = { + method: "GET", + path: "/test", + headers: {}, + params: {}, + body: {}, + ip: "127.0.0.1", + connection: { remoteAddress: "127.0.0.1" }, + socket: { remoteAddress: "127.0.0.1" }, + get: jest.fn(), + }; + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + setHeader: jest.fn(), + }; + mockNext = jest.fn(); + }); + + describe("Rate Limiters", () => { + test("should create general rate limiter", () => { + expect(security.generalLimiter).toBeDefined(); + expect(typeof security.generalLimiter).toBe("function"); + }); + + test("should create purchase rate limiter", () => { + expect(security.purchaseLimiter).toBeDefined(); + expect(typeof security.purchaseLimiter).toBe("function"); + }); + + test("should create admin rate limiter", () => { + expect(security.adminLimiter).toBeDefined(); + expect(typeof security.adminLimiter).toBe("function"); + }); + }); + + describe("Input Validation", () => { + describe("validateEventId", () => { + test("should pass valid event ID", () => { + mockReq.params.eventId = "123"; + + security.validateEventId[0](mockReq, mockRes, mockNext); + security.validateEventId[1](mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockReq.params.eventId).toBe(123); // Should be converted to number + }); + + test("should reject invalid event ID", () => { + mockReq.params.eventId = "invalid"; + + security.validateEventId[0](mockReq, mockRes, mockNext); + security.validateEventId[1](mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + success: false, + message: "Invalid event ID", + errors: expect.any(Array), + }); + }); + + test("should reject negative event ID", () => { + mockReq.params.eventId = "-1"; + + security.validateEventId[0](mockReq, mockRes, mockNext); + security.validateEventId[1](mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(400); + }); + + test("should reject zero event ID", () => { + mockReq.params.eventId = "0"; + + security.validateEventId[0](mockReq, mockRes, mockNext); + security.validateEventId[1](mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(400); + }); + }); + + describe("validatePurchaseId", () => { + test("should pass valid UUID", () => { + mockReq.params.purchaseId = "123e4567-e89b-12d3-a456-426614174000"; + + security.validatePurchaseId[0](mockReq, mockRes, mockNext); + security.validatePurchaseId[1](mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + + test("should reject invalid UUID", () => { + mockReq.params.purchaseId = "invalid-uuid"; + + security.validatePurchaseId[0](mockReq, mockRes, mockNext); + security.validatePurchaseId[1](mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + success: false, + message: "Invalid purchase ID", + errors: expect.any(Array), + }); + }); + }); + + describe("validateCleanupRequest", () => { + test("should pass valid maxAgeHours", () => { + mockReq.body.maxAgeHours = "48"; + + security.validateCleanupRequest[0](mockReq, mockRes, mockNext); + security.validateCleanupRequest[1](mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + expect(mockReq.body.maxAgeHours).toBe(48); // Should be converted to number + }); + + test("should pass without maxAgeHours", () => { + delete mockReq.body.maxAgeHours; + + security.validateCleanupRequest[0](mockReq, mockRes, mockNext); + security.validateCleanupRequest[1](mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + + test("should reject invalid maxAgeHours", () => { + mockReq.body.maxAgeHours = "99999"; // Too high + + security.validateCleanupRequest[0](mockReq, mockRes, mockNext); + security.validateCleanupRequest[1](mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(400); + }); + + test("should reject negative maxAgeHours", () => { + mockReq.body.maxAgeHours = "-1"; + + security.validateCleanupRequest[0](mockReq, mockRes, mockNext); + security.validateCleanupRequest[1](mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(400); + }); + }); + }); + + describe("Security Headers", () => { + test("should be defined", () => { + expect(security.securityHeaders).toBeDefined(); + expect(typeof security.securityHeaders).toBe("function"); + }); + }); + + describe("Request Size Limit", () => { + test("should pass requests within size limit", () => { + mockReq.headers["content-length"] = "1024"; // 1KB + + security.requestSizeLimit(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + + test("should reject requests exceeding size limit", () => { + mockReq.headers["content-length"] = "2097152"; // 2MB + + security.requestSizeLimit(mockReq, mockRes, mockNext); + + expect(mockRes.status).toHaveBeenCalledWith(413); + expect(mockRes.json).toHaveBeenCalledWith({ + success: false, + message: "Request entity too large. Maximum size is 1MB.", + }); + }); + + test("should handle missing content-length", () => { + delete mockReq.headers["content-length"]; + + security.requestSizeLimit(mockReq, mockRes, mockNext); + + expect(mockNext).toHaveBeenCalled(); + }); + }); + + describe("CORS Options", () => { + test("should have correct structure", () => { + expect(security.corsOptions).toBeDefined(); + expect(security.corsOptions.origin).toBeDefined(); + expect(security.corsOptions.methods).toBeDefined(); + expect(security.corsOptions.allowedHeaders).toBeDefined(); + expect(security.corsOptions.credentials).toBeDefined(); + expect(security.corsOptions.maxAge).toBeDefined(); + }); + + test("should have default origins", () => { + expect(security.corsOptions.origin).toContain("http://localhost:3000"); + expect(security.corsOptions.origin).toContain("http://localhost:3049"); + }); + }); + + describe("IP Address Extraction", () => { + test("should extract IP from req.ip", () => { + mockReq.ip = "192.168.1.1"; + const ip = security.getClientIP(mockReq); + expect(ip).toBe("192.168.1.1"); + }); + + test("should fallback to connection.remoteAddress", () => { + delete mockReq.ip; + mockReq.connection.remoteAddress = "192.168.1.2"; + const ip = security.getClientIP(mockReq); + expect(ip).toBe("192.168.1.2"); + }); + + test("should fallback to socket.remoteAddress", () => { + delete mockReq.ip; + delete mockReq.connection.remoteAddress; + mockReq.socket.remoteAddress = "192.168.1.3"; + const ip = security.getClientIP(mockReq); + expect(ip).toBe("192.168.1.3"); + }); + + test("should return unknown if no IP found", () => { + delete mockReq.ip; + delete mockReq.connection.remoteAddress; + delete mockReq.socket.remoteAddress; + const ip = security.getClientIP(mockReq); + expect(ip).toBe("unknown"); + }); + }); + + describe("Security Logging", () => { + test("should log suspicious admin requests", () => { + const consoleSpy = jest.spyOn(console, "warn"); + mockReq.path = "/admin/test"; + + security.securityLogging(mockReq, mockRes, mockNext); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Security Warning"), + expect.objectContaining({ + ip: "127.0.0.1", + path: "/admin/test", + method: "GET", + }) + ); + expect(mockNext).toHaveBeenCalled(); + }); + + test("should log path traversal attempts", () => { + const consoleSpy = jest.spyOn(console, "warn"); + mockReq.path = "/test/../admin"; + + security.securityLogging(mockReq, mockRes, mockNext); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining("Security Warning"), + expect.objectContaining({ + path: "/test/../admin", + }) + ); + }); + + test("should not log normal requests", () => { + const consoleSpy = jest.spyOn(console, "warn"); + mockReq.path = "/events/1"; + + security.securityLogging(mockReq, mockRes, mockNext); + + expect(consoleSpy).not.toHaveBeenCalled(); + expect(mockNext).toHaveBeenCalled(); + }); + }); +});