Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 45 additions & 28 deletions src/app/api/bookings/[id]/messages/attachments/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)
})

Expand All @@ -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)
})

Expand All @@ -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')
Expand All @@ -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')
Expand All @@ -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')
})
Expand All @@ -244,17 +244,17 @@ 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)
})

it('returns 413 when Content-Length header exceeds 10 MB', async () => {
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)
})

Expand All @@ -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)
})

Expand All @@ -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')
Expand All @@ -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)
})

Expand All @@ -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)
})
})
8 changes: 8 additions & 0 deletions src/app/api/bookings/[id]/messages/attachments/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 18 additions & 1 deletion src/app/api/bookings/[id]/messages/read/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
})
})
8 changes: 8 additions & 0 deletions src/app/api/bookings/[id]/messages/read/route.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
// -----------------------------------------------------------
Expand All @@ -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) {
Expand Down
27 changes: 26 additions & 1 deletion src/app/api/bookings/[id]/messages/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
})
})

// -----------------------------------------------------------
Expand Down Expand Up @@ -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')
})
})
12 changes: 12 additions & 0 deletions src/app/api/bookings/[id]/messages/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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}`)
Expand Down Expand Up @@ -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}`)
Expand Down