diff --git a/src/app/api/typing/route.test.js b/src/app/api/typing/route.test.js new file mode 100644 index 0000000..f090a1b --- /dev/null +++ b/src/app/api/typing/route.test.js @@ -0,0 +1,148 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + mockSupabase: { + from: vi.fn() + }, + mockBroadcastToRoom: vi.fn(), + participantResult: { + data: { id: 'participant-row' }, + error: null + } +})); + +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 + } +})); + +function createQuery(result) { + const query = { + select: vi.fn(() => query), + eq: vi.fn(() => query), + is: vi.fn(() => query), + single: vi.fn().mockResolvedValue(result) + }; + + return query; +} + +function setupSupabase() { + mocks.mockSupabase.from.mockImplementation((table) => { + if (table === 'users') { + return createQuery({ + data: { + id: 'internal-user-id', + username: 'cream', + display_name: 'Cream' + }, + error: null + }); + } + + if (table === 'conversation_participants') { + return createQuery(mocks.participantResult); + } + + throw new Error(`Unexpected table: ${table}`); + }); +} + +describe('typing API conversation access', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.participantResult = { + data: { id: 'participant-row' }, + error: null + }; + setupSupabase(); + }); + + it('rejects typing start for conversations the user cannot access', async () => { + mocks.participantResult = { + data: null, + error: { message: 'not found' } + }; + + const { POST } = await import('./start/route.js'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ conversationId: 'conversation-1' }) + }); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body.error).toBe('Access denied to conversation'); + expect(mocks.mockBroadcastToRoom).not.toHaveBeenCalled(); + }); + + it('rejects typing stop for conversations the user cannot access', async () => { + mocks.participantResult = { + data: null, + error: { message: 'not found' } + }; + + const { POST } = await import('./stop/route.js'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ conversationId: 'conversation-1' }) + }); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body.error).toBe('Access denied to conversation'); + expect(mocks.mockBroadcastToRoom).not.toHaveBeenCalled(); + }); + + it('broadcasts typing start after membership is verified', async () => { + const { POST } = await import('./start/route.js'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ conversationId: 'conversation-1' }) + }); + + expect(response.status).toBe(200); + expect(mocks.mockSupabase.from).toHaveBeenCalledWith('conversation_participants'); + expect(mocks.mockBroadcastToRoom).toHaveBeenCalledWith( + 'conversation-1', + expect.any(String), + expect.objectContaining({ + userId: 'internal-user-id', + conversationId: 'conversation-1', + isTyping: true + }), + 'internal-user-id' + ); + }); + + it('broadcasts typing stop after membership is verified', async () => { + const { POST } = await import('./stop/route.js'); + const response = await POST({ + json: vi.fn().mockResolvedValue({ conversationId: 'conversation-1' }) + }); + + expect(response.status).toBe(200); + expect(mocks.mockSupabase.from).toHaveBeenCalledWith('conversation_participants'); + expect(mocks.mockBroadcastToRoom).toHaveBeenCalledWith( + 'conversation-1', + expect.any(String), + expect.objectContaining({ + userId: 'internal-user-id', + conversationId: 'conversation-1', + isTyping: false + }), + 'internal-user-id' + ); + }); +}); diff --git a/src/app/api/typing/start/route.js b/src/app/api/typing/start/route.js index bf43ba5..7487fe6 100644 --- a/src/app/api/typing/start/route.js +++ b/src/app/api/typing/start/route.js @@ -32,6 +32,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 }); + } + // Broadcast typing indicator to conversation sseManager.broadcastToRoom(conversationId, MESSAGE_TYPES.USER_TYPING, { userId, @@ -46,4 +58,4 @@ export const POST = withAuth(async ({ request, locals }) => { console.error('Start typing error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -}); \ No newline at end of file +}); diff --git a/src/app/api/typing/stop/route.js b/src/app/api/typing/stop/route.js index 1dd97b9..05ea45c 100644 --- a/src/app/api/typing/stop/route.js +++ b/src/app/api/typing/stop/route.js @@ -32,6 +32,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 }); + } + // Broadcast typing stopped to conversation sseManager.broadcastToRoom(conversationId, MESSAGE_TYPES.USER_TYPING, { userId, @@ -44,4 +56,4 @@ export const POST = withAuth(async ({ request, locals }) => { console.error('Stop typing error:', error); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } -}); \ No newline at end of file +});