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
25 changes: 22 additions & 3 deletions src/app/api/messages/send/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 });
}
});
});
83 changes: 83 additions & 0 deletions src/app/api/messages/send/route.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading