Skip to content

Commit fc64ff4

Browse files
Validate typing conversation access (#84)
1 parent e40c64f commit fc64ff4

3 files changed

Lines changed: 174 additions & 2 deletions

File tree

‎src/app/api/typing/route.test.js‎

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
3+
const mocks = vi.hoisted(() => ({
4+
mockSupabase: {
5+
from: vi.fn()
6+
},
7+
mockBroadcastToRoom: vi.fn(),
8+
participantResult: {
9+
data: { id: 'participant-row' },
10+
error: null
11+
}
12+
}));
13+
14+
vi.mock('@/lib/api/middleware/auth.js', () => ({
15+
withAuth: (handler) => (request, context) =>
16+
handler({
17+
request,
18+
locals: {
19+
supabase: mocks.mockSupabase,
20+
user: { id: 'auth-user-id' }
21+
},
22+
context
23+
})
24+
}));
25+
26+
vi.mock('@/lib/api/sse-manager.js', () => ({
27+
sseManager: {
28+
broadcastToRoom: mocks.mockBroadcastToRoom
29+
}
30+
}));
31+
32+
function createQuery(result) {
33+
const query = {
34+
select: vi.fn(() => query),
35+
eq: vi.fn(() => query),
36+
is: vi.fn(() => query),
37+
single: vi.fn().mockResolvedValue(result)
38+
};
39+
40+
return query;
41+
}
42+
43+
function setupSupabase() {
44+
mocks.mockSupabase.from.mockImplementation((table) => {
45+
if (table === 'users') {
46+
return createQuery({
47+
data: {
48+
id: 'internal-user-id',
49+
username: 'cream',
50+
display_name: 'Cream'
51+
},
52+
error: null
53+
});
54+
}
55+
56+
if (table === 'conversation_participants') {
57+
return createQuery(mocks.participantResult);
58+
}
59+
60+
throw new Error(`Unexpected table: ${table}`);
61+
});
62+
}
63+
64+
describe('typing API conversation access', () => {
65+
beforeEach(() => {
66+
vi.resetModules();
67+
vi.clearAllMocks();
68+
mocks.participantResult = {
69+
data: { id: 'participant-row' },
70+
error: null
71+
};
72+
setupSupabase();
73+
});
74+
75+
it('rejects typing start for conversations the user cannot access', async () => {
76+
mocks.participantResult = {
77+
data: null,
78+
error: { message: 'not found' }
79+
};
80+
81+
const { POST } = await import('./start/route.js');
82+
const response = await POST({
83+
json: vi.fn().mockResolvedValue({ conversationId: 'conversation-1' })
84+
});
85+
const body = await response.json();
86+
87+
expect(response.status).toBe(403);
88+
expect(body.error).toBe('Access denied to conversation');
89+
expect(mocks.mockBroadcastToRoom).not.toHaveBeenCalled();
90+
});
91+
92+
it('rejects typing stop for conversations the user cannot access', async () => {
93+
mocks.participantResult = {
94+
data: null,
95+
error: { message: 'not found' }
96+
};
97+
98+
const { POST } = await import('./stop/route.js');
99+
const response = await POST({
100+
json: vi.fn().mockResolvedValue({ conversationId: 'conversation-1' })
101+
});
102+
const body = await response.json();
103+
104+
expect(response.status).toBe(403);
105+
expect(body.error).toBe('Access denied to conversation');
106+
expect(mocks.mockBroadcastToRoom).not.toHaveBeenCalled();
107+
});
108+
109+
it('broadcasts typing start after membership is verified', async () => {
110+
const { POST } = await import('./start/route.js');
111+
const response = await POST({
112+
json: vi.fn().mockResolvedValue({ conversationId: 'conversation-1' })
113+
});
114+
115+
expect(response.status).toBe(200);
116+
expect(mocks.mockSupabase.from).toHaveBeenCalledWith('conversation_participants');
117+
expect(mocks.mockBroadcastToRoom).toHaveBeenCalledWith(
118+
'conversation-1',
119+
expect.any(String),
120+
expect.objectContaining({
121+
userId: 'internal-user-id',
122+
conversationId: 'conversation-1',
123+
isTyping: true
124+
}),
125+
'internal-user-id'
126+
);
127+
});
128+
129+
it('broadcasts typing stop after membership is verified', async () => {
130+
const { POST } = await import('./stop/route.js');
131+
const response = await POST({
132+
json: vi.fn().mockResolvedValue({ conversationId: 'conversation-1' })
133+
});
134+
135+
expect(response.status).toBe(200);
136+
expect(mocks.mockSupabase.from).toHaveBeenCalledWith('conversation_participants');
137+
expect(mocks.mockBroadcastToRoom).toHaveBeenCalledWith(
138+
'conversation-1',
139+
expect.any(String),
140+
expect.objectContaining({
141+
userId: 'internal-user-id',
142+
conversationId: 'conversation-1',
143+
isTyping: false
144+
}),
145+
'internal-user-id'
146+
);
147+
});
148+
});

‎src/app/api/typing/start/route.js‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ export const POST = withAuth(async ({ request, locals }) => {
3232

3333
const userId = userData.id;
3434

35+
const { data: participant, error: participantError } = await supabase
36+
.from('conversation_participants')
37+
.select('id')
38+
.eq('conversation_id', conversationId)
39+
.eq('user_id', userId)
40+
.is('left_at', null)
41+
.single();
42+
43+
if (participantError || !participant) {
44+
return NextResponse.json({ error: 'Access denied to conversation' }, { status: 403 });
45+
}
46+
3547
// Broadcast typing indicator to conversation
3648
sseManager.broadcastToRoom(conversationId, MESSAGE_TYPES.USER_TYPING, {
3749
userId,
@@ -46,4 +58,4 @@ export const POST = withAuth(async ({ request, locals }) => {
4658
console.error('Start typing error:', error);
4759
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
4860
}
49-
});
61+
});

‎src/app/api/typing/stop/route.js‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,18 @@ export const POST = withAuth(async ({ request, locals }) => {
3232

3333
const userId = userData.id;
3434

35+
const { data: participant, error: participantError } = await supabase
36+
.from('conversation_participants')
37+
.select('id')
38+
.eq('conversation_id', conversationId)
39+
.eq('user_id', userId)
40+
.is('left_at', null)
41+
.single();
42+
43+
if (participantError || !participant) {
44+
return NextResponse.json({ error: 'Access denied to conversation' }, { status: 403 });
45+
}
46+
3547
// Broadcast typing stopped to conversation
3648
sseManager.broadcastToRoom(conversationId, MESSAGE_TYPES.USER_TYPING, {
3749
userId,
@@ -44,4 +56,4 @@ export const POST = withAuth(async ({ request, locals }) => {
4456
console.error('Stop typing error:', error);
4557
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
4658
}
47-
});
59+
});

0 commit comments

Comments
 (0)