diff --git a/src/app/api/conversations/join/route.js b/src/app/api/conversations/join/route.js index 2f71ee5..570a127 100644 --- a/src/app/api/conversations/join/route.js +++ b/src/app/api/conversations/join/route.js @@ -36,6 +36,18 @@ export const POST = withAuth(async ({ request, locals }) => { const userId = userData.id; + const { data: participant, error: participantError } = await supabase + .from('conversation_participants') + .select('id') + .eq('conversation_id', conversationId) + .eq('user_id', userId) + .is('left_at', null) + .single(); + + if (participantError || !participant) { + return NextResponse.json({ error: 'Access denied to conversation' }, { status: 403 }); + } + // Join SSE room for real-time updates sseManager.joinRoom(userId, conversationId); diff --git a/src/app/api/conversations/join/route.test.js b/src/app/api/conversations/join/route.test.js index 3f6ac00..e5fdf51 100644 --- a/src/app/api/conversations/join/route.test.js +++ b/src/app/api/conversations/join/route.test.js @@ -5,7 +5,10 @@ const mocks = vi.hoisted(() => ({ from: vi.fn() }, mockJoinRoom: vi.fn(), - userEq: vi.fn() + userEq: vi.fn(), + participantEq: vi.fn(), + participantIs: vi.fn(), + participantSingle: vi.fn() })); vi.mock('@/lib/api/middleware/auth.js', () => ({ @@ -39,6 +42,18 @@ function createUserQuery() { return query; } +function createParticipantQuery() { + const query = { + select: vi.fn(() => query), + eq: mocks.participantEq, + is: mocks.participantIs, + single: mocks.participantSingle + }; + mocks.participantEq.mockReturnValue(query); + mocks.participantIs.mockReturnValue(query); + return query; +} + function createRequest(conversationId) { return { json: vi.fn().mockResolvedValue({ conversationId }) @@ -49,8 +64,13 @@ describe('POST /api/conversations/join', () => { beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); + mocks.participantSingle.mockResolvedValue({ + data: { id: 'participant-id' }, + error: null + }); mocks.mockSupabase.from.mockImplementation((table) => { if (table === 'users') return createUserQuery(); + if (table === 'conversation_participants') return createParticipantQuery(); throw new Error(`Unexpected table: ${table}`); }); }); @@ -72,6 +92,24 @@ describe('POST /api/conversations/join', () => { expect(response.status).toBe(200); expect(mocks.userEq).toHaveBeenCalledWith('auth_user_id', 'auth-user-id'); + expect(mocks.participantEq).toHaveBeenCalledWith('conversation_id', 'conversation-1'); + expect(mocks.participantEq).toHaveBeenCalledWith('user_id', 'internal-user-id'); + expect(mocks.participantIs).toHaveBeenCalledWith('left_at', null); expect(mocks.mockJoinRoom).toHaveBeenCalledWith('internal-user-id', 'conversation-1'); }); + + it('rejects users who are not active conversation participants', async () => { + mocks.participantSingle.mockResolvedValue({ + data: null, + error: { message: 'No rows found' } + }); + + const { POST } = await import('./route.js'); + const response = await POST(createRequest('conversation-1')); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body).toEqual({ error: 'Access denied to conversation' }); + expect(mocks.mockJoinRoom).not.toHaveBeenCalled(); + }); });