Skip to content

Commit c6d65ca

Browse files
Handle malformed message send payloads (#80)
1 parent 2f361aa commit c6d65ca

2 files changed

Lines changed: 105 additions & 3 deletions

File tree

src/app/api/messages/send/route.js

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,36 @@ import { getServiceRoleClient } from '@/lib/supabase/service-role.js';
1111

1212
export const POST = withAuth(async ({ request, locals }) => {
1313
try {
14-
const { conversationId, encryptedContents, messageType = 'text', replyToId, metadata } = await request.json();
14+
let body;
15+
try {
16+
body = await request.json();
17+
} catch {
18+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
19+
}
20+
21+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
22+
return NextResponse.json({ error: 'Request body must be a JSON object' }, { status: 400 });
23+
}
24+
25+
const { conversationId, encryptedContents, messageType = 'text', replyToId, metadata } = body;
1526

1627
if (!conversationId || !encryptedContents) {
1728
return NextResponse.json({ error: 'Conversation ID and encrypted contents are required' }, { status: 400 });
1829
}
1930

2031
// Validate encryptedContents is an object with user_id -> encrypted_content mappings
21-
if (typeof encryptedContents !== 'object' || Object.keys(encryptedContents).length === 0) {
32+
if (
33+
typeof encryptedContents !== 'object' ||
34+
Array.isArray(encryptedContents) ||
35+
Object.keys(encryptedContents).length === 0
36+
) {
2237
return NextResponse.json({ error: 'encryptedContents must be an object with user_id -> encrypted_content mappings' }, { status: 400 });
2338
}
2439

40+
if (Object.values(encryptedContents).some((content) => typeof content !== 'string')) {
41+
return NextResponse.json({ error: 'encryptedContents values must be encrypted content strings' }, { status: 400 });
42+
}
43+
2544
const { supabase, user: authUser } = locals;
2645

2746
// Get internal user ID from auth user ID
@@ -146,4 +165,4 @@ export const POST = withAuth(async ({ request, locals }) => {
146165
console.error('Send message error:', error);
147166
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
148167
}
149-
});
168+
});
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
3+
const mocks = vi.hoisted(() => ({
4+
mockSupabase: {
5+
from: vi.fn()
6+
},
7+
mockBroadcastToRoom: vi.fn(),
8+
mockServiceRoleClient: {}
9+
}));
10+
11+
vi.mock('@/lib/api/middleware/auth.js', () => ({
12+
withAuth: (handler) => (request, context) =>
13+
handler({
14+
request,
15+
locals: {
16+
supabase: mocks.mockSupabase,
17+
user: { id: 'auth-user-id' }
18+
},
19+
context
20+
})
21+
}));
22+
23+
vi.mock('@/lib/api/sse-manager.js', () => ({
24+
sseManager: {
25+
broadcastToRoom: mocks.mockBroadcastToRoom
26+
}
27+
}));
28+
29+
vi.mock('@/lib/supabase/service-role.js', () => ({
30+
getServiceRoleClient: () => mocks.mockServiceRoleClient
31+
}));
32+
33+
describe('POST /api/messages/send validation', () => {
34+
it('returns 400 for malformed JSON instead of a generic 500', async () => {
35+
const { POST } = await import('./route.js');
36+
const request = {
37+
json: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token'))
38+
};
39+
40+
const response = await POST(request);
41+
const body = await response.json();
42+
43+
expect(response.status).toBe(400);
44+
expect(body.error).toBe('Invalid JSON body');
45+
expect(mocks.mockSupabase.from).not.toHaveBeenCalled();
46+
});
47+
48+
it('rejects encryptedContents arrays before database work', async () => {
49+
const { POST } = await import('./route.js');
50+
const request = {
51+
json: vi.fn().mockResolvedValue({
52+
conversationId: 'conversation-1',
53+
encryptedContents: ['not-a-user-map']
54+
})
55+
};
56+
57+
const response = await POST(request);
58+
const body = await response.json();
59+
60+
expect(response.status).toBe(400);
61+
expect(body.error).toBe('encryptedContents must be an object with user_id -> encrypted_content mappings');
62+
expect(mocks.mockSupabase.from).not.toHaveBeenCalled();
63+
});
64+
65+
it('rejects non-string encrypted content values before database work', async () => {
66+
const { POST } = await import('./route.js');
67+
const request = {
68+
json: vi.fn().mockResolvedValue({
69+
conversationId: 'conversation-1',
70+
encryptedContents: {
71+
'user-1': { ciphertext: 'abc' }
72+
}
73+
})
74+
};
75+
76+
const response = await POST(request);
77+
const body = await response.json();
78+
79+
expect(response.status).toBe(400);
80+
expect(body.error).toBe('encryptedContents values must be encrypted content strings');
81+
expect(mocks.mockSupabase.from).not.toHaveBeenCalled();
82+
});
83+
});

0 commit comments

Comments
 (0)