From fcc8463cd8c5fe4eb0548f1c1180cd1b3eef6560 Mon Sep 17 00:00:00 2001 From: Hampus Kraft Date: Fri, 20 Feb 2026 20:09:44 +0000 Subject: [PATCH] fix(api): enforce gdm call membership and ring recipient validation --- .../api/src/channel/services/CallService.tsx | 62 +++++++++++++ .../src/channel/tests/CallEndpoints.test.tsx | 93 ++++++++++++++++++- .../src/voice/tests/VoiceCallRinging.test.tsx | 66 ++++++++++++- 3 files changed, 219 insertions(+), 2 deletions(-) diff --git a/packages/api/src/channel/services/CallService.tsx b/packages/api/src/channel/services/CallService.tsx index b91d0e6f..938da526 100644 --- a/packages/api/src/channel/services/CallService.tsx +++ b/packages/api/src/channel/services/CallService.tsx @@ -184,6 +184,18 @@ export class CallService { throw new InvalidChannelTypeForCallError(); } + this.ensureRequesterIsGroupDmRecipient({ + channelType: channel.type, + channelRecipientIds: channel.recipientIds, + userId, + }); + + this.validateExplicitRingRecipients({ + channelRecipientIds: channel.recipientIds, + userId, + recipients: ringing, + }); + const recipientIds = Array.from(channel.recipientIds); const channelRecipients = recipientIds.length > 0 ? await this.userRepository.listUsers(recipientIds) : []; @@ -323,6 +335,20 @@ export class CallService { throw new InvalidChannelTypeForCallError(); } + this.ensureRequesterIsGroupDmRecipient({ + channelType: channel.type, + channelRecipientIds: channel.recipientIds, + userId, + }); + + if (recipients !== undefined) { + this.validateExplicitRingRecipients({ + channelRecipientIds: channel.recipientIds, + userId, + recipients, + }); + } + if (channel.type === ChannelTypes.DM) { const channelRecipients = await this.userRepository.listUsers(Array.from(channel.recipientIds)); await this.dmPermissionValidator.validate({recipients: channelRecipients, userId}); @@ -528,6 +554,42 @@ export class CallService { } } + private ensureRequesterIsGroupDmRecipient({ + channelType, + channelRecipientIds, + userId, + }: { + channelType: number; + channelRecipientIds: Set; + userId: UserID; + }): void { + if (channelType !== ChannelTypes.GROUP_DM) { + return; + } + + if (!channelRecipientIds.has(userId)) { + throw new UnknownChannelError(); + } + } + + private validateExplicitRingRecipients({ + channelRecipientIds, + userId, + recipients, + }: { + channelRecipientIds: Set; + userId: UserID; + recipients: Array; + }): void { + const allowedRecipientIds = new Set(Array.from(channelRecipientIds).filter((id) => id !== userId)); + + for (const recipientId of recipients) { + if (!allowedRecipientIds.has(recipientId)) { + throw InputValidationError.fromCode('recipients', ValidationErrorCodes.USER_NOT_IN_CHANNEL); + } + } + } + async updateCallMessageEnded({ channelId, messageId, diff --git a/packages/api/src/channel/tests/CallEndpoints.test.tsx b/packages/api/src/channel/tests/CallEndpoints.test.tsx index 396f0893..15bd2a70 100644 --- a/packages/api/src/channel/tests/CallEndpoints.test.tsx +++ b/packages/api/src/channel/tests/CallEndpoints.test.tsx @@ -18,12 +18,25 @@ */ import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils'; -import {createDmChannel, createGuild} from '@fluxer/api/src/channel/tests/ChannelTestUtils'; +import { + createDmChannel, + createFriendship, + createGroupDmChannel, + createGuild, + type MinimalChannelResponse, +} from '@fluxer/api/src/channel/tests/ChannelTestUtils'; import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness'; import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder'; import type {GuildInviteMetadataResponse} from '@fluxer/schema/src/domains/invite/InviteSchemas'; import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; +interface ErrorResponse { + code: string; + errors?: Array<{code?: string}>; +} + +interface PrivateChannelsResponse extends Array {} + describe('Call Endpoints', () => { let harness: ApiTestHarness; @@ -55,6 +68,20 @@ describe('Call Endpoints', () => { return {user1, user2}; } + async function setupGroupDmUsers() { + const owner = await createTestAccount(harness); + const memberOne = await createTestAccount(harness); + const memberTwo = await createTestAccount(harness); + const outsider = await createTestAccount(harness); + + await createFriendship(harness, owner, memberOne); + await createFriendship(harness, owner, memberTwo); + + const groupDm = await createGroupDmChannel(harness, owner.token, [memberOne.userId, memberTwo.userId]); + + return {owner, memberOne, memberTwo, outsider, groupDm}; + } + describe('GET /channels/:channel_id/call', () => { it('returns call eligibility for DM channel when voice is disabled', async () => { const {user1, user2} = await setupUsersWithMutualGuild(); @@ -144,6 +171,70 @@ describe('Call Endpoints', () => { .expect(404) .execute(); }); + + it('non-member cannot create or start a group DM call', async () => { + const {outsider, groupDm} = await setupGroupDmUsers(); + + await createBuilder(harness, outsider.token) + .post(`/channels/${groupDm.id}/call/ring`) + .body({}) + .expect(404, 'UNKNOWN_CHANNEL') + .execute(); + }); + + it('non-member cannot ring an existing group DM call', async () => { + const {owner, memberOne, outsider, groupDm} = await setupGroupDmUsers(); + + await createBuilder(harness, owner.token) + .post(`/channels/${groupDm.id}/call/ring`) + .body({recipients: [memberOne.userId]}) + .expect(204) + .execute(); + + await createBuilder(harness, outsider.token) + .post(`/channels/${groupDm.id}/call/ring`) + .body({recipients: [memberOne.userId]}) + .expect(404, 'UNKNOWN_CHANNEL') + .execute(); + }); + + it('rejects group DM ring recipients outside the channel recipient set', async () => { + const {owner, memberOne, outsider, groupDm} = await setupGroupDmUsers(); + + const error = await createBuilder(harness, owner.token) + .post(`/channels/${groupDm.id}/call/ring`) + .body({recipients: [memberOne.userId, outsider.userId]}) + .expect(400, 'INVALID_FORM_BODY') + .execute(); + + expect(error.errors?.[0]?.code).toBe('USER_NOT_IN_CHANNEL'); + + const outsiderChannels = await createBuilder(harness, outsider.token) + .get('/users/@me/channels') + .execute(); + + expect(outsiderChannels.some((channel) => channel.id === groupDm.id)).toBe(false); + }); + + it('group DM member can ring a valid recipient subset', async () => { + const {memberOne, owner, groupDm} = await setupGroupDmUsers(); + + await createBuilder(harness, memberOne.token) + .post(`/channels/${groupDm.id}/call/ring`) + .body({recipients: [owner.userId]}) + .expect(204) + .execute(); + }); + + it('group DM member can ring default recipients when recipients are omitted', async () => { + const {memberTwo, groupDm} = await setupGroupDmUsers(); + + await createBuilder(harness, memberTwo.token) + .post(`/channels/${groupDm.id}/call/ring`) + .body({}) + .expect(204) + .execute(); + }); }); describe('POST /channels/:channel_id/call/stop-ringing', () => { diff --git a/packages/api/src/voice/tests/VoiceCallRinging.test.tsx b/packages/api/src/voice/tests/VoiceCallRinging.test.tsx index beee5374..42f31a79 100644 --- a/packages/api/src/voice/tests/VoiceCallRinging.test.tsx +++ b/packages/api/src/voice/tests/VoiceCallRinging.test.tsx @@ -23,6 +23,7 @@ import { createChannelInvite, createDmChannel, createFriendship, + createGroupDmChannel, createGuild, getChannel, } from '@fluxer/api/src/channel/tests/ChannelTestUtils'; @@ -30,7 +31,12 @@ import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUti import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness'; import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants'; import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder'; -import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest'; +import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest'; + +interface ErrorResponse { + code: string; + errors?: Array<{code?: string}>; +} describe('Voice Call Ringing', () => { let harness: ApiTestHarness; @@ -61,6 +67,20 @@ describe('Voice Call Ringing', () => { return {user1, user2, guild}; } + async function setupGroupDmUsers() { + const owner = await createTestAccount(harness); + const memberOne = await createTestAccount(harness); + const memberTwo = await createTestAccount(harness); + const outsider = await createTestAccount(harness); + + await createFriendship(harness, owner, memberOne); + await createFriendship(harness, owner, memberTwo); + + const groupDm = await createGroupDmChannel(harness, owner.token, [memberOne.userId, memberTwo.userId]); + + return {owner, memberOne, memberTwo, outsider, groupDm}; + } + describe('Ring call', () => { it('rings call recipients in DM channel', async () => { const {user1, user2} = await setupUsersWithMutualGuild(); @@ -127,6 +147,50 @@ describe('Voice Call Ringing', () => { .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_CHANNEL_TYPE_FOR_CALL') .execute(); }); + + it('non-member cannot ring group DM call', async () => { + const {owner, memberOne, outsider, groupDm} = await setupGroupDmUsers(); + + await createBuilder(harness, owner.token) + .post(`/channels/${groupDm.id}/call/ring`) + .body({recipients: [memberOne.userId]}) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + + await createBuilder(harness, outsider.token) + .post(`/channels/${groupDm.id}/call/ring`) + .body({recipients: [memberOne.userId]}) + .expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL') + .execute(); + }); + + it('rejects non-recipient ids in group DM ring payload', async () => { + const {owner, memberOne, outsider, groupDm} = await setupGroupDmUsers(); + + const error = await createBuilder(harness, owner.token) + .post(`/channels/${groupDm.id}/call/ring`) + .body({recipients: [memberOne.userId, outsider.userId]}) + .expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY') + .execute(); + + expect(error.errors?.[0]?.code).toBe('USER_NOT_IN_CHANNEL'); + }); + + it('group DM members can ring valid subset and default recipients', async () => { + const {owner, memberOne, memberTwo, groupDm} = await setupGroupDmUsers(); + + await createBuilder(harness, memberOne.token) + .post(`/channels/${groupDm.id}/call/ring`) + .body({recipients: [owner.userId]}) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + + await createBuilder(harness, memberTwo.token) + .post(`/channels/${groupDm.id}/call/ring`) + .body({}) + .expect(HTTP_STATUS.NO_CONTENT) + .execute(); + }); }); describe('Stop ringing', () => {