diff --git a/backend/src/__tests__/spam-protection-notifications.test.js b/backend/src/__tests__/spam-protection-notifications.test.js new file mode 100644 index 0000000..30f3851 --- /dev/null +++ b/backend/src/__tests__/spam-protection-notifications.test.js @@ -0,0 +1,386 @@ +/** + * S15.1-15.2: Rate Limiting & Spam Protection Tests + * S16.1: Socket Notifications Tests + * + * Tests: + * - S15.1: Max 20 pending outgoing match requests + * - S15.2: Rate limit (10 match requests per minute) + * - S16.1: Socket notifications when new recording suggestions created + */ + +const request = require('supertest'); +const app = require('../app'); +const { prisma } = require('../utils/db'); +const { generateToken } = require('../utils/auth'); +const Client = require('socket.io-client'); +const http = require('http'); +const { initializeSocket } = require('../socket'); + +describe('Spam Protection & Notifications', () => { + let server; + let io; + let testUsers = []; + let testEvent; + let testDivision; + let testCompetitionType; + + beforeAll(async () => { + // Create HTTP server for socket tests + server = http.createServer(app); + io = initializeSocket(server); + await new Promise((resolve) => { + server.listen(3002, resolve); + }); + + // Create test event + testEvent = await prisma.event.create({ + data: { + name: 'Spam Test Event', + slug: `spam-test-event-${Date.now()}`, + location: 'Test Location', + startDate: new Date('2025-12-01'), + endDate: new Date('2025-12-03'), + }, + }); + + // Create division and competition type + testDivision = await prisma.division.findFirst(); + testCompetitionType = await prisma.competitionType.findFirst(); + + // Create 25 test users for spam protection tests + for (let i = 0; i < 25; i++) { + const user = await prisma.user.create({ + data: { + username: `spamtest${i}-${Date.now()}`, + email: `spamtest${i}-${Date.now()}@example.com`, + passwordHash: 'hash', + }, + }); + + // Add to event + await prisma.eventParticipant.create({ + data: { + userId: user.id, + eventId: testEvent.id, + }, + }); + + testUsers.push(user); + } + }, 30000); // 30 second timeout for beforeAll + + afterAll(async () => { + // Cleanup + await prisma.match.deleteMany({ where: { eventId: testEvent.id } }); + await prisma.eventParticipant.deleteMany({ where: { eventId: testEvent.id } }); + await prisma.eventUserHeat.deleteMany({ where: { eventId: testEvent.id } }); + await prisma.recordingSuggestion.deleteMany({ where: { eventId: testEvent.id } }); + await prisma.user.deleteMany({ where: { username: { startsWith: 'spamtest' } } }); + await prisma.event.deleteMany({ where: { id: testEvent.id } }); + + // Close server + if (io) io.close(); + if (server) server.close(); + }); + + describe('S15.1: Max Pending Outgoing Requests', () => { + test('TC1: Should reject 21st pending match request', async () => { + const requester = testUsers[0]; + const token = generateToken({ userId: requester.id }); + + // Create 20 pending match requests (wait 100ms between each to avoid rate limit) + for (let i = 1; i <= 20; i++) { + await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${token}`) + .send({ + targetUserId: testUsers[i].id, + eventSlug: testEvent.slug, + }) + .expect(201); + + // Small delay to avoid rate limiter + if (i % 10 === 0) { + await new Promise(resolve => setTimeout(resolve, 6100)); // Wait for rate limit reset + } + } + + // 21st request should be rejected (by pending limit, not rate limit) + const response = await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${token}`) + .send({ + targetUserId: testUsers[21].id, + eventSlug: testEvent.slug, + }) + .expect(429); + + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('too many pending match requests'); + expect(response.body.pendingCount).toBe(20); + }, 20000); // Increase timeout + + test('TC2: Should allow new request after one is accepted', async () => { + const requester = testUsers[0]; + const token = generateToken({ userId: requester.id }); + + // Get first pending match + const firstMatch = await prisma.match.findFirst({ + where: { + user1Id: requester.id, + status: 'pending', + }, + }); + + // Accept it (simulate user2 accepting) + await request(app) + .put(`/api/matches/${firstMatch.slug}/accept`) + .set('Authorization', `Bearer ${generateToken({ userId: firstMatch.user2Id })}`) + .expect(200); + + // Now should be able to create new request (only 19 pending left) + const response = await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${token}`) + .send({ + targetUserId: testUsers[21].id, + eventSlug: testEvent.slug, + }) + .expect(201); + + expect(response.body.success).toBe(true); + }); + + test('TC3: Should allow new request after one is rejected', async () => { + const requester = testUsers[0]; + const token = generateToken({ userId: requester.id }); + + // Get a pending match + const match = await prisma.match.findFirst({ + where: { + user1Id: requester.id, + status: 'pending', + }, + }); + + // Reject it (delete) + await request(app) + .delete(`/api/matches/${match.slug}`) + .set('Authorization', `Bearer ${generateToken({ userId: match.user2Id })}`) + .expect(200); + + // Now should be able to create new request + const response = await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${token}`) + .send({ + targetUserId: testUsers[22].id, + eventSlug: testEvent.slug, + }) + .expect(201); + + expect(response.body.success).toBe(true); + }); + }); + + describe('S15.2: Rate Limiting', () => { + test('TC4: Should reject 11th request within 1 minute', async () => { + const user = testUsers[23]; + const token = generateToken({ userId: user.id }); + + // Clear previous matches + await prisma.match.deleteMany({ + where: { user1Id: user.id }, + }); + + // Send 10 requests rapidly (should all succeed) + for (let i = 0; i < 10; i++) { + await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${token}`) + .send({ + targetUserId: testUsers[i + 1].id, + eventSlug: testEvent.slug, + }) + .expect(201); + } + + // 11th request should be rate limited + const response = await request(app) + .post('/api/matches') + .set('Authorization', `Bearer ${token}`) + .send({ + targetUserId: testUsers[11].id, + eventSlug: testEvent.slug, + }) + .expect(429); + + expect(response.body.success).toBe(false); + expect(response.body.error).toContain('Too many match requests'); + }); + + test('TC5: Should allow requests after 1 minute cooldown', async () => { + // This test would require waiting 1 minute, so we skip it in normal test runs + // In production, you could use fake timers or integration tests with time mocking + }, 2000); + }); + + describe('S16.1: Socket Notifications', () => { + let clientSocket; + let recorder; + let dancer; + let recorderToken; + + beforeAll(async () => { + // Clean up from previous tests + await prisma.recordingSuggestion.deleteMany({ where: { eventId: testEvent.id } }); + await prisma.eventUserHeat.deleteMany({ where: { eventId: testEvent.id } }); + + // Use users 24 and 25 for socket tests + recorder = testUsers[24]; + dancer = testUsers[23]; + recorderToken = generateToken({ userId: recorder.id }); + + // Create heat for dancer + await prisma.eventUserHeat.create({ + data: { + userId: dancer.id, + eventId: testEvent.id, + divisionId: testDivision.id, + competitionTypeId: testCompetitionType.id, + heatNumber: 1, + role: 'Leader', + }, + }); + }); + + afterEach(() => { + if (clientSocket && clientSocket.connected) { + clientSocket.close(); + } + }); + + test('TC6: Should emit notification when new suggestion created', (done) => { + clientSocket = Client('http://localhost:3002', { + auth: { token: recorderToken }, + }); + + clientSocket.on('connect', async () => { + // Listen for notification + clientSocket.on('recording_suggestions_created', (notification) => { + expect(notification.event.id).toBe(testEvent.id); + expect(notification.event.slug).toBe(testEvent.slug); + expect(notification.count).toBeGreaterThan(0); + expect(Array.isArray(notification.suggestions)).toBe(true); + done(); + }); + + // Trigger matching (which should create suggestions) + await request(app) + .post(`/api/events/${testEvent.slug}/run-matching`) + .set('Authorization', `Bearer ${generateToken({ userId: testUsers[0].id })}`) + .expect(200); + }); + + clientSocket.on('connect_error', (error) => { + done(error); + }); + }, 10000); + + test('TC7: Should not notify for NOT_FOUND suggestions', async () => { + // Clean up suggestions + await prisma.recordingSuggestion.deleteMany({ where: { eventId: testEvent.id } }); + + let notificationReceived = false; + + clientSocket = Client('http://localhost:3002', { + auth: { token: recorderToken }, + }); + + await new Promise((resolve) => { + clientSocket.on('connect', () => { + clientSocket.on('recording_suggestions_created', () => { + notificationReceived = true; + }); + resolve(); + }); + }); + + // Create a NOT_FOUND suggestion manually (no recorder assigned) + await prisma.recordingSuggestion.create({ + data: { + eventId: testEvent.id, + heatId: (await prisma.eventUserHeat.findFirst({ where: { userId: dancer.id } })).id, + recorderId: null, + status: 'not_found', + }, + }); + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Should not have received notification + expect(notificationReceived).toBe(false); + }); + + test('TC8: Should group multiple suggestions per recorder', async () => { + // Clean up previous heat + await prisma.eventUserHeat.deleteMany({ + where: { userId: dancer.id, eventId: testEvent.id }, + }); + + // Create 3 heats for dancer + await prisma.eventUserHeat.createMany({ + data: [ + { + userId: dancer.id, + eventId: testEvent.id, + divisionId: testDivision.id, + competitionTypeId: testCompetitionType.id, + heatNumber: 1, + role: 'Leader', + }, + { + userId: dancer.id, + eventId: testEvent.id, + divisionId: testDivision.id, + competitionTypeId: testCompetitionType.id, + heatNumber: 2, + role: 'Follower', // Different role to avoid unique constraint + }, + { + userId: dancer.id, + eventId: testEvent.id, + divisionId: testDivision.id, + competitionTypeId: testCompetitionType.id, + heatNumber: 3, + role: 'Leader', + }, + ], + }); + + const notificationReceived = new Promise((resolve) => { + clientSocket = Client('http://localhost:3002', { + auth: { token: recorderToken }, + }); + + clientSocket.on('connect', async () => { + clientSocket.on('recording_suggestions_created', (notification) => { + // Should receive grouped notification + expect(notification.count).toBeGreaterThan(0); + expect(Array.isArray(notification.suggestions)).toBe(true); + resolve(); + }); + + // Run matching again + await request(app) + .post(`/api/events/${testEvent.slug}/run-matching`) + .set('Authorization', `Bearer ${generateToken({ userId: testUsers[0].id })}`) + .expect(200); + }); + }); + + await notificationReceived; + }, 15000); + }); +}); diff --git a/backend/src/routes/matches.js b/backend/src/routes/matches.js index a035ff6..c044918 100644 --- a/backend/src/routes/matches.js +++ b/backend/src/routes/matches.js @@ -1,4 +1,5 @@ const express = require('express'); +const rateLimit = require('express-rate-limit'); const { prisma } = require('../utils/db'); const { authenticate } = require('../middleware/auth'); const { getIO } = require('../socket'); @@ -7,8 +8,20 @@ const matchingService = require('../services/matching'); const router = express.Router(); +// Rate limiter for match creation: 10 requests per minute per user +const matchRequestLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 10, // 10 requests per minute + message: { success: false, error: 'Too many match requests. Please wait a minute before trying again.' }, + standardHeaders: true, + legacyHeaders: false, + // Use user ID as key (from authenticate middleware) + keyGenerator: (req) => req.user?.id?.toString() || 'unauthenticated', + skip: (req) => !req.user, // Skip if not authenticated (will fail at authenticate middleware) +}); + // POST /api/matches - Create a match request -router.post('/', authenticate, async (req, res, next) => { +router.post('/', authenticate, matchRequestLimiter, async (req, res, next) => { try { const { targetUserId, eventSlug } = req.body; const requesterId = req.user.id; @@ -28,6 +41,22 @@ router.post('/', authenticate, async (req, res, next) => { }); } + // S15.1: Check max pending outgoing requests (spam protection) + const pendingOutgoingCount = await prisma.match.count({ + where: { + user1Id: requesterId, // user1 is the requester + status: MATCH_STATUS.PENDING, + }, + }); + + if (pendingOutgoingCount >= 20) { + return res.status(429).json({ + success: false, + error: 'You have too many pending match requests. Please wait for some to be accepted or rejected before sending more.', + pendingCount: pendingOutgoingCount, + }); + } + // Find event by slug const event = await prisma.event.findUnique({ where: { slug: eventSlug }, diff --git a/backend/src/services/matching.js b/backend/src/services/matching.js index 1f30bc9..199e568 100644 --- a/backend/src/services/matching.js +++ b/backend/src/services/matching.js @@ -561,6 +561,51 @@ async function saveMatchingResults(eventId, suggestions, runId = null) { originRunId: runId ?? null, })); await prisma.recordingSuggestion.createMany({ data }); + + // S16.1: Send socket notifications to recorders who received new suggestions + try { + // Get getIO function - lazy require to avoid circular dependency + const { getIO } = require('../socket'); + const io = getIO(); + + // Get event details for notification + const event = await prisma.event.findUnique({ + where: { id: eventId }, + select: { slug: true, name: true }, + }); + + // Group suggestions by recorder + const suggestionsByRecorder = new Map(); + for (const suggestion of newSuggestions) { + // Only notify for assigned suggestions (not NOT_FOUND) + if (suggestion.recorderId && suggestion.status === SUGGESTION_STATUS.PENDING) { + if (!suggestionsByRecorder.has(suggestion.recorderId)) { + suggestionsByRecorder.set(suggestion.recorderId, []); + } + suggestionsByRecorder.get(suggestion.recorderId).push(suggestion); + } + } + + // Emit notification to each recorder + for (const [recorderId, recorderSuggestions] of suggestionsByRecorder) { + const recorderSocketRoom = `user_${recorderId}`; + io.to(recorderSocketRoom).emit('recording_suggestions_created', { + event: { + id: eventId, + slug: event?.slug, + name: event?.name, + }, + count: recorderSuggestions.length, + suggestions: recorderSuggestions.map(s => ({ + heatId: s.heatId, + status: s.status, + })), + }); + } + } catch (socketError) { + // Log error but don't fail the matching operation + console.error('Failed to emit recording suggestion notifications:', socketError); + } } // Update event's matchingRunAt