fix(api): enforce gdm call membership and ring recipient validation

This commit is contained in:
Hampus Kraft 2026-02-20 20:09:44 +00:00
parent 68ae760fa8
commit fcc8463cd8
No known key found for this signature in database
GPG Key ID: 6090864C465A454D
3 changed files with 219 additions and 2 deletions

View File

@ -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: UserID;
}): void {
if (channelType !== ChannelTypes.GROUP_DM) {
return;
}
if (!channelRecipientIds.has(userId)) {
throw new UnknownChannelError();
}
}
private validateExplicitRingRecipients({
channelRecipientIds,
userId,
recipients,
}: {
channelRecipientIds: Set<UserID>;
userId: UserID;
recipients: Array<UserID>;
}): 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,

View File

@ -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<MinimalChannelResponse> {}
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<ErrorResponse>(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<PrivateChannelsResponse>(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', () => {

View File

@ -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<ErrorResponse>(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', () => {