feat: add integration and setup tests and complete code review fixes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: ["<rootDir>/tests/setup.js"],
|
||||
testTimeout: 30000,
|
||||
verbose: true,
|
||||
forceExit: true,
|
||||
clearMocks: true,
|
||||
resetMocks: true,
|
||||
restoreMocks: true,
|
||||
};
|
||||
+14
-1
@@ -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",
|
||||
|
||||
+231
@@ -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 <command>", "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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user