diff --git a/src/app/api/messages/send/route.js b/src/app/api/messages/send/route.js index 9b5120f..bc9f38a 100644 --- a/src/app/api/messages/send/route.js +++ b/src/app/api/messages/send/route.js @@ -11,17 +11,36 @@ import { getServiceRoleClient } from '@/lib/supabase/service-role.js'; export const POST = withAuth(async ({ request, locals }) => { try { - const { conversationId, encryptedContents, messageType = 'text', replyToId, metadata } = await request.json(); + let body; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return NextResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 }); + } + + const { conversationId, encryptedContents, messageType = 'text', replyToId, metadata } = body; if (!conversationId || !encryptedContents) { return NextResponse.json({ error: 'Conversation ID and encrypted contents are required' }, { status: 400 }); } // Validate encryptedContents is an object with user_id -> encrypted_content mappings - if (typeof encryptedContents !== 'object' || Object.keys(encryptedContents).length === 0) { + if ( + typeof encryptedContents !== 'object' || + Array.isArray(encryptedContents) || + Object.keys(encryptedContents).length === 0 + ) { return NextResponse.json({ error: 'encryptedContents must be an object with user_id -> encrypted_content mappings' }, { status: 400 }); } + if (Object.values(encryptedContents).some((content) => typeof content !== 'string')) { + return NextResponse.json({ error: 'encryptedContents values must be encrypted content strings' }, { status: 400 }); + } + const { supabase, user: authUser } = locals; // Get internal user ID from auth user ID @@ -146,4 +165,4 @@ export const POST = withAuth(async ({ request, locals }) => { console.error('Send message error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -}); \ No newline at end of file +}); diff --git a/src/app/api/messages/send/route.test.js b/src/app/api/messages/send/route.test.js new file mode 100644 index 0000000..c7a0437 --- /dev/null +++ b/src/app/api/messages/send/route.test.js @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + mockSupabase: { + from: vi.fn() + }, + mockBroadcastToRoom: vi.fn(), + mockServiceRoleClient: {} +})); + +vi.mock('@/lib/api/middleware/auth.js', () => ({ + withAuth: (handler) => (request, context) => + handler({ + request, + locals: { + supabase: mocks.mockSupabase, + user: { id: 'auth-user-id' } + }, + context + }) +})); + +vi.mock('@/lib/api/sse-manager.js', () => ({ + sseManager: { + broadcastToRoom: mocks.mockBroadcastToRoom + } +})); + +vi.mock('@/lib/supabase/service-role.js', () => ({ + getServiceRoleClient: () => mocks.mockServiceRoleClient +})); + +describe('POST /api/messages/send validation', () => { + it('returns 400 for malformed JSON instead of a generic 500', async () => { + const { POST } = await import('./route.js'); + const request = { + json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token')) + }; + + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe('Invalid JSON body'); + expect(mocks.mockSupabase.from).not.toHaveBeenCalled(); + }); + + it('rejects encryptedContents arrays before database work', async () => { + const { POST } = await import('./route.js'); + const request = { + json: vi.fn().mockResolvedValue({ + conversationId: 'conversation-1', + encryptedContents: ['not-a-user-map'] + }) + }; + + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe('encryptedContents must be an object with user_id -> encrypted_content mappings'); + expect(mocks.mockSupabase.from).not.toHaveBeenCalled(); + }); + + it('rejects non-string encrypted content values before database work', async () => { + const { POST } = await import('./route.js'); + const request = { + json: vi.fn().mockResolvedValue({ + conversationId: 'conversation-1', + encryptedContents: { + 'user-1': { ciphertext: 'abc' } + } + }) + }; + + const response = await POST(request); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error).toBe('encryptedContents values must be encrypted content strings'); + expect(mocks.mockSupabase.from).not.toHaveBeenCalled(); + }); +});