diff --git a/src/app/api/bookings/[id]/messages/attachments/route.test.ts b/src/app/api/bookings/[id]/messages/attachments/route.test.ts index 029bb7bc..b4dab713 100644 --- a/src/app/api/bookings/[id]/messages/attachments/route.test.ts +++ b/src/app/api/bookings/[id]/messages/attachments/route.test.ts @@ -96,7 +96,7 @@ function makeAuthUser(overrides = {}) { function makeBooking(overrides = {}) { return { - id: 'booking-1', + id: 'b0000000-0000-4000-b000-000000000001', customerId: 'customer-user-1', providerId: 'provider-1', providerUserId: 'provider-user-1', @@ -164,32 +164,32 @@ describe('POST /api/bookings/[id]/messages/attachments', () => { it('returns 401 when not authenticated', async () => { vi.mocked(getAuthUser).mockResolvedValue(null) - const req = makeFormDataRequest('booking-1') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(401) }) it('returns 404 when messaging feature is disabled', async () => { vi.mocked(getAuthUser).mockResolvedValue(makeAuthUser() as never) vi.mocked(isFeatureEnabled).mockResolvedValue(false) - const req = makeFormDataRequest('booking-1') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(404) }) it('returns 429 when rate limited', async () => { vi.mocked(getAuthUser).mockResolvedValue(makeAuthUser() as never) vi.mocked(rateLimiters.messageUpload).mockResolvedValue(false) - const req = makeFormDataRequest('booking-1') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(429) }) it('returns 404 when booking not found or not owned by user', async () => { vi.mocked(getAuthUser).mockResolvedValue(makeAuthUser() as never) vi.mocked(loadBookingForMessaging).mockResolvedValue(null) - const req = makeFormDataRequest('booking-1') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(404) }) @@ -201,7 +201,7 @@ describe('POST /api/bookings/[id]/messages/attachments', () => { method: 'POST', body: formData, }) - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(400) }) @@ -212,8 +212,8 @@ describe('POST /api/bookings/[id]/messages/attachments', () => { code: 'INVALID_TYPE', message: 'Filtypen stöds inte.', }) - const req = makeFormDataRequest('booking-1', 'data', 'application/pdf') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001', 'data', 'application/pdf') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(400) const data = await res.json() expect(data.error).toContain('Filtypen') @@ -222,8 +222,8 @@ describe('POST /api/bookings/[id]/messages/attachments', () => { it('returns 201 with message data on success', async () => { vi.mocked(getAuthUser).mockResolvedValue(makeAuthUser() as never) vi.mocked(loadBookingForMessaging).mockResolvedValue(makeBooking() as never) - const req = makeFormDataRequest('booking-1') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(201) const data = await res.json() expect(data.attachmentUrl).toBe('booking-1/msg-abc.jpg') @@ -234,8 +234,8 @@ describe('POST /api/bookings/[id]/messages/attachments', () => { vi.mocked(getAuthUser).mockResolvedValue(makeAuthUser() as never) vi.mocked(loadBookingForMessaging).mockResolvedValue(makeBooking() as never) vi.mocked(uploadMessageAttachment).mockRejectedValue(new Error('Upload failed')) - const req = makeFormDataRequest('booking-1') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(500) expect(mockRepo.deleteMessage).toHaveBeenCalledWith('msg-abc') }) @@ -244,8 +244,8 @@ describe('POST /api/bookings/[id]/messages/attachments', () => { vi.mocked(getAuthUser).mockResolvedValue(makeAuthUser({ userType: 'provider' }) as never) vi.mocked(loadBookingForMessaging).mockResolvedValue(makeBooking() as never) mockRepo.createMessage.mockResolvedValue(makeMessage({ senderType: 'PROVIDER' })) - const req = makeFormDataRequest('booking-1') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(201) }) @@ -253,8 +253,8 @@ describe('POST /api/bookings/[id]/messages/attachments', () => { vi.mocked(getAuthUser).mockResolvedValue(makeAuthUser() as never) vi.mocked(loadBookingForMessaging).mockResolvedValue(makeBooking() as never) const overLimit = 10 * 1024 * 1024 + 1 - const req = makeRequestWithContentLength('booking-1', overLimit) - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeRequestWithContentLength('b0000000-0000-4000-b000-000000000001', overLimit) + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(413) }) @@ -265,8 +265,8 @@ describe('POST /api/bookings/[id]/messages/attachments', () => { code: 'TOO_LARGE', message: 'Filen är för stor. Max 10 MB.', }) - const req = makeFormDataRequest('booking-1') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(413) }) @@ -277,8 +277,8 @@ describe('POST /api/bookings/[id]/messages/attachments', () => { code: 'MAGIC_BYTES_MISMATCH', message: 'Filinnehållet matchar inte det deklarerade formatet.', }) - const req = makeFormDataRequest('booking-1') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(400) const data = await res.json() expect(data.error).toContain('matchar inte') @@ -289,8 +289,8 @@ describe('POST /api/bookings/[id]/messages/attachments', () => { vi.mocked(rateLimiters.messageUpload).mockRejectedValue( new RateLimitServiceError('Redis unavailable') ) - const req = makeFormDataRequest('booking-1') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) expect(res.status).toBe(503) }) @@ -299,9 +299,26 @@ describe('POST /api/bookings/[id]/messages/attachments', () => { vi.mocked(loadBookingForMessaging).mockResolvedValue(makeBooking() as never) vi.mocked(uploadMessageAttachment).mockRejectedValue(new Error('Upload failed')) mockRepo.deleteMessage.mockRejectedValue(new Error('DB gone')) - const req = makeFormDataRequest('booking-1') - const res = await POST(req, { params: Promise.resolve({ id: 'booking-1' }) }) + const req = makeFormDataRequest('b0000000-0000-4000-b000-000000000001') + const res = await POST(req, { params: Promise.resolve({ id: 'b0000000-0000-4000-b000-000000000001' }) }) // Should still return 500, not crash expect(res.status).toBe(500) }) + + // 3B.2: bookingId UUID validation + it('returns 400 when bookingId is not a UUID', async () => { + vi.mocked(getAuthUser).mockResolvedValue(makeAuthUser() as never) + const req = makeFormDataRequest('not-a-uuid') + const res = await POST(req, { params: Promise.resolve({ id: 'not-a-uuid' }) }) + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Ogiltigt bookingId') + }) + + it('returns 400 when bookingId contains traversal', async () => { + vi.mocked(getAuthUser).mockResolvedValue(makeAuthUser() as never) + const req = makeFormDataRequest('../../etc/passwd') + const res = await POST(req, { params: Promise.resolve({ id: '../../etc/passwd' }) }) + expect(res.status).toBe(400) + }) }) diff --git a/src/app/api/bookings/[id]/messages/attachments/route.ts b/src/app/api/bookings/[id]/messages/attachments/route.ts index b7f6ffec..0e3f6de4 100644 --- a/src/app/api/bookings/[id]/messages/attachments/route.ts +++ b/src/app/api/bookings/[id]/messages/attachments/route.ts @@ -3,10 +3,13 @@ * POST /api/bookings/[id]/messages/attachments — upload an image attachment */ import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' import { getAuthUser } from '@/lib/auth-dual' import { rateLimiters, RateLimitServiceError } from '@/lib/rate-limit' import { isFeatureEnabled } from '@/lib/feature-flags' import { logger } from '@/lib/logger' + +const bookingIdSchema = z.string().uuid() import { loadBookingForMessaging } from '@/domain/conversation/loadBookingForMessaging' import { PrismaConversationRepository } from '@/infrastructure/persistence/conversation/PrismaConversationRepository' import { createMessageNotifier } from '@/domain/notification/MessageNotifierFactory' @@ -35,6 +38,11 @@ export async function POST( return NextResponse.json({ error: 'Ej tillgänglig' }, { status: 404 }) } + // 3B.2: reject non-UUID bookingId before DB/storage/service work + if (!bookingIdSchema.safeParse(bookingId).success) { + return NextResponse.json({ error: 'Ogiltigt bookingId' }, { status: 400 }) + } + // 3. Rate limiting (before body parse) try { const allowed = await rateLimiters.messageUpload(authUser.id) diff --git a/src/app/api/bookings/[id]/messages/read/route.test.ts b/src/app/api/bookings/[id]/messages/read/route.test.ts index 7845bf62..65a908e9 100644 --- a/src/app/api/bookings/[id]/messages/read/route.test.ts +++ b/src/app/api/bookings/[id]/messages/read/route.test.ts @@ -61,7 +61,7 @@ const mockIsFeatureEnabled = vi.mocked(isFeatureEnabled) // Fixtures // ----------------------------------------------------------- -const BOOKING_ID = 'booking-abc-123' +const BOOKING_ID = 'b0000000-0000-4000-b000-000000000001' const CUSTOMER_USER_ID = 'customer-user-111' const PROVIDER_USER_ID = 'provider-user-222' const PROVIDER_ID = 'provider-333' @@ -146,4 +146,21 @@ describe('PATCH /api/bookings/[id]/messages/read', () => { expect(data.marked).toBeGreaterThanOrEqual(0) expect(mockMarkMessagesAsRead).toHaveBeenCalledWith('conv-1', 'CUSTOMER') }) + + // 3B.2: bookingId UUID validation + it('returns 400 when bookingId is not a UUID', async () => { + const badParams = Promise.resolve({ id: 'not-a-uuid' }) + const req = new NextRequest('http://localhost/api/bookings/not-a-uuid/messages/read', { method: 'PATCH' }) + const res = await PATCH(req, { params: badParams }) + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Ogiltigt bookingId') + }) + + it('returns 400 when bookingId contains traversal', async () => { + const badParams = Promise.resolve({ id: '../../something' }) + const req = new NextRequest('http://localhost/api/bookings/whatever/messages/read', { method: 'PATCH' }) + const res = await PATCH(req, { params: badParams }) + expect(res.status).toBe(400) + }) }) diff --git a/src/app/api/bookings/[id]/messages/read/route.ts b/src/app/api/bookings/[id]/messages/read/route.ts index f8cef31a..3a1dc51b 100644 --- a/src/app/api/bookings/[id]/messages/read/route.ts +++ b/src/app/api/bookings/[id]/messages/read/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' import { getAuthUser } from '@/lib/auth-dual' import { rateLimiters } from '@/lib/rate-limit' import { isFeatureEnabled } from '@/lib/feature-flags' @@ -7,6 +8,8 @@ import { ConversationService } from '@/domain/conversation/ConversationService' import { loadBookingForMessaging } from '@/domain/conversation/loadBookingForMessaging' import { PrismaConversationRepository } from '@/infrastructure/persistence/conversation/PrismaConversationRepository' +const bookingIdSchema = z.string().uuid() + // ----------------------------------------------------------- // PATCH /api/bookings/[id]/messages/read -- mark as read // ----------------------------------------------------------- @@ -29,6 +32,11 @@ export async function PATCH( return NextResponse.json({ error: 'Ej tillgänglig' }, { status: 404 }) } + // 3B.2: reject non-UUID bookingId before DB/storage/service work + if (!bookingIdSchema.safeParse(bookingId).success) { + return NextResponse.json({ error: 'Ogiltigt bookingId' }, { status: 400 }) + } + // 3. Rate limiting per user const allowed = await rateLimiters.messageUser(`${authUser.id}:read:${bookingId}`) if (!allowed) { diff --git a/src/app/api/bookings/[id]/messages/route.test.ts b/src/app/api/bookings/[id]/messages/route.test.ts index 81551973..8a1b2427 100644 --- a/src/app/api/bookings/[id]/messages/route.test.ts +++ b/src/app/api/bookings/[id]/messages/route.test.ts @@ -66,7 +66,7 @@ const mockIsFeatureEnabled = vi.mocked(isFeatureEnabled) // Fixtures // ----------------------------------------------------------- -const BOOKING_ID = 'booking-abc-123' +const BOOKING_ID = 'b0000000-0000-4000-b000-000000000001' const CUSTOMER_USER_ID = 'customer-user-111' const PROVIDER_USER_ID = 'provider-user-222' const PROVIDER_ID = 'provider-333' @@ -179,6 +179,21 @@ describe('POST /api/bookings/[id]/messages', () => { const res = await POST(makeRequest({ content: 'Hej' }), { params }) expect(res.status).toBe(429) }) + + // 3B.2: bookingId UUID validation + it('returns 400 when bookingId is not a UUID', async () => { + const badParams = Promise.resolve({ id: 'not-a-uuid' }) + const res = await POST(makeRequest({ content: 'Hej' }), { params: badParams }) + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Ogiltigt bookingId') + }) + + it('returns 400 when bookingId contains traversal', async () => { + const badParams = Promise.resolve({ id: '../../etc/passwd' }) + const res = await POST(makeRequest({ content: 'Hej' }), { params: badParams }) + expect(res.status).toBe(400) + }) }) // ----------------------------------------------------------- @@ -256,4 +271,14 @@ describe('GET /api/bookings/[id]/messages', () => { const data = await res.json() expect(data.serviceName).toBe('Bokning') }) + + // 3B.2: bookingId UUID validation + it('returns 400 when bookingId is not a UUID', async () => { + const badParams = Promise.resolve({ id: 'not-a-uuid' }) + const req = new NextRequest('http://localhost/api/bookings/not-a-uuid/messages', { method: 'GET' }) + const res = await GET(req, { params: badParams }) + expect(res.status).toBe(400) + const data = await res.json() + expect(data.error).toBe('Ogiltigt bookingId') + }) }) diff --git a/src/app/api/bookings/[id]/messages/route.ts b/src/app/api/bookings/[id]/messages/route.ts index 0ef82573..9986ced2 100644 --- a/src/app/api/bookings/[id]/messages/route.ts +++ b/src/app/api/bookings/[id]/messages/route.ts @@ -15,6 +15,8 @@ const sendMessageSchema = z.object({ content: z.string().trim().min(1).max(2000), }).strict() +const bookingIdSchema = z.string().uuid() + const listQuerySchema = z.object({ cursor: z.string().optional(), limit: z.coerce.number().int().min(1).max(100).optional(), @@ -42,6 +44,11 @@ export async function POST( return NextResponse.json({ error: 'Ej tillgänglig' }, { status: 404 }) } + // 3B.2: reject non-UUID bookingId before DB/storage/service work + if (!bookingIdSchema.safeParse(bookingId).success) { + return NextResponse.json({ error: 'Ogiltigt bookingId' }, { status: 400 }) + } + // 3. Rate limiting per user (before body parse) try { const allowedUser = await rateLimiters.messageUser(`${authUser.id}:${bookingId}`) @@ -158,6 +165,11 @@ export async function GET( return NextResponse.json({ error: 'Ej tillgänglig' }, { status: 404 }) } + // 3B.2: reject non-UUID bookingId before DB/storage/service work + if (!bookingIdSchema.safeParse(bookingId).success) { + return NextResponse.json({ error: 'Ogiltigt bookingId' }, { status: 400 }) + } + // 3. Rate limiting per user try { const allowed = await rateLimiters.messageUser(`${authUser.id}:get:${bookingId}`)