Skip to content

Commit e37b0b4

Browse files
fix(conversations): authorize SSE room joins (#223)
1 parent 8186f6d commit e37b0b4

2 files changed

Lines changed: 51 additions & 1 deletion

File tree

‎src/app/api/conversations/join/route.js‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,18 @@ export const POST = withAuth(async ({ request, locals }) => {
3636

3737
const userId = userData.id;
3838

39+
const { data: participant, error: participantError } = await supabase
40+
.from('conversation_participants')
41+
.select('id')
42+
.eq('conversation_id', conversationId)
43+
.eq('user_id', userId)
44+
.is('left_at', null)
45+
.single();
46+
47+
if (participantError || !participant) {
48+
return NextResponse.json({ error: 'Access denied to conversation' }, { status: 403 });
49+
}
50+
3951
// Join SSE room for real-time updates
4052
sseManager.joinRoom(userId, conversationId);
4153

‎src/app/api/conversations/join/route.test.js‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ const mocks = vi.hoisted(() => ({
55
from: vi.fn()
66
},
77
mockJoinRoom: vi.fn(),
8-
userEq: vi.fn()
8+
userEq: vi.fn(),
9+
participantEq: vi.fn(),
10+
participantIs: vi.fn(),
11+
participantSingle: vi.fn()
912
}));
1013

1114
vi.mock('@/lib/api/middleware/auth.js', () => ({
@@ -39,6 +42,18 @@ function createUserQuery() {
3942
return query;
4043
}
4144

45+
function createParticipantQuery() {
46+
const query = {
47+
select: vi.fn(() => query),
48+
eq: mocks.participantEq,
49+
is: mocks.participantIs,
50+
single: mocks.participantSingle
51+
};
52+
mocks.participantEq.mockReturnValue(query);
53+
mocks.participantIs.mockReturnValue(query);
54+
return query;
55+
}
56+
4257
function createRequest(conversationId) {
4358
return {
4459
json: vi.fn().mockResolvedValue({ conversationId })
@@ -49,8 +64,13 @@ describe('POST /api/conversations/join', () => {
4964
beforeEach(() => {
5065
vi.resetModules();
5166
vi.clearAllMocks();
67+
mocks.participantSingle.mockResolvedValue({
68+
data: { id: 'participant-id' },
69+
error: null
70+
});
5271
mocks.mockSupabase.from.mockImplementation((table) => {
5372
if (table === 'users') return createUserQuery();
73+
if (table === 'conversation_participants') return createParticipantQuery();
5474
throw new Error(`Unexpected table: ${table}`);
5575
});
5676
});
@@ -72,6 +92,24 @@ describe('POST /api/conversations/join', () => {
7292

7393
expect(response.status).toBe(200);
7494
expect(mocks.userEq).toHaveBeenCalledWith('auth_user_id', 'auth-user-id');
95+
expect(mocks.participantEq).toHaveBeenCalledWith('conversation_id', 'conversation-1');
96+
expect(mocks.participantEq).toHaveBeenCalledWith('user_id', 'internal-user-id');
97+
expect(mocks.participantIs).toHaveBeenCalledWith('left_at', null);
7598
expect(mocks.mockJoinRoom).toHaveBeenCalledWith('internal-user-id', 'conversation-1');
7699
});
100+
101+
it('rejects users who are not active conversation participants', async () => {
102+
mocks.participantSingle.mockResolvedValue({
103+
data: null,
104+
error: { message: 'No rows found' }
105+
});
106+
107+
const { POST } = await import('./route.js');
108+
const response = await POST(createRequest('conversation-1'));
109+
const body = await response.json();
110+
111+
expect(response.status).toBe(403);
112+
expect(body).toEqual({ error: 'Access denied to conversation' });
113+
expect(mocks.mockJoinRoom).not.toHaveBeenCalled();
114+
});
77115
});

0 commit comments

Comments
 (0)