Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/fuzzy-terms-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@rocket.chat/media-calls': minor
'@rocket.chat/model-typings': minor
'@rocket.chat/ui-voip': minor
'@rocket.chat/models': minor
'@rocket.chat/i18n': minor
'@rocket.chat/core-typings': minor
'@rocket.chat/rest-typings': minor
'@rocket.chat/meteor': minor
---

Adds name and avatar resolution for external voice calls
41 changes: 23 additions & 18 deletions ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,39 +439,44 @@ export class IncomingSipCall extends BaseSipCall {
throw new SipError(SipErrorCodes.NOT_FOUND);
}

private static async getRocketChatCallerFromInvite(req: SrfRequest): Promise<MediaCallContact | null> {
logger.debug({
msg: 'IncomingSipCall.getRocketChatCallerFromInvite',
callingNumber: req.callingNumber,
calledNumber: req.calledNumber,
});
private static getDisplayNameFromInvite(req: SrfRequest): string | undefined {
const removeQuotes = (str?: string): string | undefined => str?.replace(/^"|"$/g, '').trim();

if (req.callingNumber && typeof req.callingNumber === 'string') {
const userContact = await mediaCallDirector.cast.getContactForExtensionNumber(req.callingNumber, { preferredType: 'sip' });
if (userContact) {
return userContact;
if (req.has('X-RocketChat-Caller-Name')) {
const headerValue = req.get('X-RocketChat-Caller-Name');
if (headerValue) {
return headerValue;
}
}

return null;
if (req.has('p-asserted-identity')) {
const pAssertedIdentity = removeQuotes(req.getParsedHeader('p-asserted-identity')?.name);
if (pAssertedIdentity) {
return pAssertedIdentity;
}
}

if (req.has('from')) {
const fromHeader = removeQuotes(req.getParsedHeader('from')?.name);
if (fromHeader) {
return fromHeader;
}
}

return undefined;
}
Comment thread
aleksandernsilva marked this conversation as resolved.

private static async getCallerContactFromInvite(sessionId: string, req: SrfRequest): Promise<MediaCallSignedContact<'sip'>> {
logger.debug({ msg: 'IncomingSipCall.getCallerContactFromInvite' });
const callerBase = await this.getRocketChatCallerFromInvite(req);

const displayNameFromHeader = req.has('X-RocketChat-Caller-Name') && req.get('X-RocketChat-Caller-Name');
const displayName = this.getDisplayNameFromInvite(req);
const usernameFromHeader = req.has('X-RocketChat-Caller-Username') && req.get('X-RocketChat-Caller-Username');

const displayName = displayNameFromHeader || callerBase?.displayName || req.from;
const username = usernameFromHeader || callerBase?.username || req.callingNumber;

const sipExtension = req.callingNumber;

const defaultContactInfo: MediaCallContactInformation = {
username,
sipExtension,
displayName: displayName || sipExtension,
...(usernameFromHeader && { username: usernameFromHeader }),
Comment on lines +472 to +479
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium: SIP Caller Identity Spoofing via Unverified Headers

The IncomingSipCall.getCallerContactFromInvite method trusts SIP headers (X-RocketChat-Caller-Name, X-RocketChat-Caller-Username, p-asserted-identity, from) to construct the caller's identity (MediaCallContactInformation). These headers are provided by the external SIP peer and are not cryptographically verified. An attacker can spoof the caller's display name and username, which are then used throughout the application to represent the caller, potentially leading to social engineering or impersonation attacks within the Rocket.Chat interface.

Steps to Reproduce
  1. Configure a SIP client to send an INVITE request to the Rocket.Chat SIP server.
  2. Add custom headers 'X-RocketChat-Caller-Name' and 'X-RocketChat-Caller-Username' to the INVITE request with arbitrary values (e.g., 'CEO', 'admin').
  3. Observe that the Rocket.Chat UI displays the caller as the spoofed identity.
# Not applicable for SIP. Use the bash script PoC.
# This is a conceptual PoC.
# An attacker sends a SIP INVITE with spoofed headers.
# The server will process these headers and display the spoofed identity to the callee.

cat <<EOF | nc <SIP_SERVER_IP> 5060
INVITE sip:target@example.com SIP/2.0
Via: SIP/2.0/UDP attacker.com;branch=z9hG4bK123
From: "Spoofed User" <sip:attacker@attacker.com>;tag=123
To: <sip:target@example.com>
Call-ID: 123@attacker.com
CSeq: 1 INVITE
X-RocketChat-Caller-Name: "CEO"
X-RocketChat-Caller-Username: "admin"
Content-Type: application/sdp
Content-Length: 0
EOF
# Not applicable for SIP. Use the bash script PoC.

PoC Url: N/A

Fix with AI

Open in Cursor Open in Claude

Fix the following security vulnerability found by Hacktron.

File: ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts
Lines: 472-479
Severity: medium

Vulnerability: SIP Caller Identity Spoofing via Unverified Headers

Description:
The IncomingSipCall.getCallerContactFromInvite method trusts SIP headers (X-RocketChat-Caller-Name, X-RocketChat-Caller-Username, p-asserted-identity, from) to construct the caller's identity (MediaCallContactInformation). These headers are provided by the external SIP peer and are not cryptographically verified. An attacker can spoof the caller's display name and username, which are then used throughout the application to represent the caller, potentially leading to social engineering or impersonation attacks within the Rocket.Chat interface.

Proof of Concept:
**Steps to Reproduce**

1. Configure a SIP client to send an INVITE request to the Rocket.Chat SIP server.
2. Add custom headers 'X-RocketChat-Caller-Name' and 'X-RocketChat-Caller-Username' to the INVITE request with arbitrary values (e.g., 'CEO', 'admin').
3. Observe that the Rocket.Chat UI displays the caller as the spoofed identity.

```python
# Not applicable for SIP. Use the bash script PoC.
```

```bash
# This is a conceptual PoC.
# An attacker sends a SIP INVITE with spoofed headers.
# The server will process these headers and display the spoofed identity to the callee.

cat <<EOF | nc <SIP_SERVER_IP> 5060
INVITE sip:target@example.com SIP/2.0
Via: SIP/2.0/UDP attacker.com;branch=z9hG4bK123
From: "Spoofed User" <sip:attacker@attacker.com>;tag=123
To: <sip:target@example.com>
Call-ID: 123@attacker.com
CSeq: 1 INVITE
X-RocketChat-Caller-Name: "CEO"
X-RocketChat-Caller-Username: "admin"
Content-Type: application/sdp
Content-Length: 0
EOF
```

```bash
# Not applicable for SIP. Use the bash script PoC.
```


PoC Url: N/A

Affected Code:
const displayName = this.getDisplayNameFromInvite(req);
const usernameFromHeader = req.has('X-RocketChat-Caller-Username') && req.get('X-RocketChat-Caller-Username');
const sipExtension = req.callingNumber;

const defaultContactInfo: MediaCallContactInformation = {
    sipExtension,
    displayName: displayName || sipExtension,
    ...(usernameFromHeader && { username: usernameFromHeader }),
};

const contact = await mediaCallDirector.cast.getContactForExtensionNumber(sipExtension, { requiredType: 'sip' }, defaultContactInfo);

Fix this vulnerability. Only change what's necessary - don't modify unrelated code.

Triage: Reply !fp <reason> (false positive), !valid (confirmed), or !accepted_risk <reason>. Reason is optional but improves future scans — e.g. !fp internal endpoint, not user-facing. Any other reply is saved as a triage note.

View finding in Hacktron

Comment thread
pierre-lehnen-rc marked this conversation as resolved.
};

const contact = await mediaCallDirector.cast.getContactForExtensionNumber(sipExtension, { requiredType: 'sip' }, defaultContactInfo);
Expand Down
18 changes: 18 additions & 0 deletions packages/ui-voip/src/components/PeerInfo/ExternalUser.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import InternalUser from './InternalUser';
import PhoneNumber from './PhoneNumber';

export type ExternalUserProps = {
number: string;
displayName?: string;
avatarUrl?: string;
};

const ExternalUser = ({ number, displayName, avatarUrl }: ExternalUserProps) => {
if (displayName) {
return <InternalUser displayName={displayName} avatarUrl={avatarUrl} callerId={number} />;
}

return <PhoneNumber number={number} />;
Comment thread
pierre-lehnen-rc marked this conversation as resolved.
};

export default ExternalUser;
2 changes: 1 addition & 1 deletion packages/ui-voip/src/components/PeerInfo/InternalUser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Avatar, Box, Icon, StatusBullet } from '@rocket.chat/fuselage';

import type { Slot } from './useInfoSlots';

type InternalUserProps = {
export type InternalUserProps = {
displayName: string;
status?: UserStatus;
avatarUrl?: string;
Expand Down
27 changes: 26 additions & 1 deletion packages/ui-voip/src/components/PeerInfo/PeerInfo.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,5 +62,30 @@ export const InternalUserWithRemoteStatus: StoryFn<typeof PeerInfo> = () => {
};

export const ExternalUser: StoryFn<typeof PeerInfo> = () => {
return <PeerInfo number='1234567890' />;
return <PeerInfo external number='1234567890' />;
};

export const ExternalUserWithDisplayName: StoryFn<typeof PeerInfo> = () => {
return <PeerInfo external number='1234567890' displayName='Jane Doe' />;
};

export const ExternalUserWithDisplayNameAndAvatar: StoryFn<typeof PeerInfo> = () => {
return (
<PeerInfo
external
number='1234567890'
displayName='Jane Doe'
avatarUrl='data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC
4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
IyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAA
AAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAg
MREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6L
xqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVr
jbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRk
eX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef
6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkB
SuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlP
UH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z'
/>
);
};
15 changes: 8 additions & 7 deletions packages/ui-voip/src/components/PeerInfo/PeerInfo.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { ComponentProps } from 'react';
import { ExternalUser, InternalUser } from '.';
import type { ExternalUserProps } from './ExternalUser';
import type { InternalUserProps } from './InternalUser';

import { InternalUser, PhoneNumber } from '.';

export type PeerInfoProps = ComponentProps<typeof InternalUser> | ComponentProps<typeof PhoneNumber>;
export type PeerInfoProps = (InternalUserProps & { external?: false }) | (ExternalUserProps & { external: true });

const PeerInfo = (props: PeerInfoProps) => {
if ('displayName' in props) {
return <InternalUser {...props} />;
if (props.external) {
return <ExternalUser {...props} />;
}
return <PhoneNumber {...props} />;

return <InternalUser {...props} />;
};

export default PeerInfo;
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,107 @@ exports[`renders ExternalUser without crashing 1`] = `
</body>
`;

exports[`renders ExternalUserWithDisplayName without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-omrq7i"
id="rcx-media-call-widget-caller-info"
>
<div
class="rcx-box rcx-box--full rcx-css-1rtu0k9"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-user rcx-icon rcx-css-4pvxx3"
>
</i>
</div>
<div
class="rcx-box rcx-box--full rcx-css-jgzlgu"
>
<div
class="rcx-box rcx-box--full rcx-css-1jbbtow"
>
<span
class="rcx-box rcx-box--full rcx-css-ilfe77"
>
<div
class="rcx-box rcx-box--full rcx-css-nc7kyx"
>
Jane Doe
</div>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1m7ucy6"
>
1234567890
</div>
</div>
</div>
</div>
</body>
`;

exports[`renders ExternalUserWithDisplayNameAndAvatar without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-css-omrq7i"
id="rcx-media-call-widget-caller-info"
>
<div
class="rcx-box rcx-box--full rcx-css-1rtu0k9"
>
<figure
class="rcx-box rcx-box--full rcx-avatar rcx-avatar--x20"
>
<img
alt=""
class="rcx-avatar__element rcx-avatar__element--x20"
src="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC
4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMj
IyMjIyMjIyMjIyMjIyMjL/wAARCAAoACgDASIAAhEBAxEB/8QAGwAAAgIDAQAAAAAAAAAAAAAAAAcEBgIDBQj/xAAuEAACAQQAAwcEAQUAAA
AAAAABAgMABAUREiExBhMUIkFRYQcWcYGhFTJSgpH/xAAYAQADAQEAAAAAAAAAAAAAAAACAwQBAP/EAB4RAAIBBQEBAQAAAAAAAAAAAAABAg
MREiExE0HR/9oADAMBAAIRAxEAPwBuXuIkhBuMe5ib/AHQP49q4L3mLitryTLTSpOiHQI5k/HzXa/qbFOEudVTu1dumWvcTaNCZYZ7vU6g6L
xqjOU/24dfs1Ouh9FnkMpd3Reeyx83hAxZZEhkdV9/MBrX71WGPvJcqrJBGveKATtuXXqNU0pu02bTHXD/AGvJAluyxxRd6F4x00o+NdKoVr
jbzJdvVe1t5cVLc2ck8qjnohgpPtz2v7G6JtPQ2VJwjlcw+37mchpnK6GtIuv5NFWeTsLNPvxWTvpfjvOEfwKKzEVkSct2vscS/BIzSN0YRk
eX81UpPqO8masJETu7OOccY4dswYFQeftv096XV5knuJGdm2T1+agvMXj8jEaHX905QihabvcbuS7X566mLWLwSY8PuRnk/u4eZ0deTl71Ef
6hY+0yM88TzeNZY4luYwpVYyduOfrvhPTnr0pXSX9y5mCsyJMdyxxvwq599em+taItqCSNc90ChvZRUruUcT0JiO18Elpk7t8v41LWzacxkB
SuvjQ/FFJayjDWrCTepAQ2vUH0oo/Jk3ovpwJJeVCP5CN+lFFaaMqy+nAyuChvrTI2kN9JAsi2ZOy4IBHMnkSCP+iqBexSWdxLazoUljJVlP
UH2oorkV10pRc7b1zXb/hZOzuJvM86QWEXeELxOzHSIPcmiiiunVlF2RNTpRkrs//Z"
/>
</figure>
</div>
<div
class="rcx-box rcx-box--full rcx-css-jgzlgu"
>
<div
class="rcx-box rcx-box--full rcx-css-1jbbtow"
>
<span
class="rcx-box rcx-box--full rcx-css-ilfe77"
>
<div
class="rcx-box rcx-box--full rcx-css-nc7kyx"
>
Jane Doe
</div>
</span>
</div>
<div
class="rcx-box rcx-box--full rcx-css-1m7ucy6"
>
1234567890
</div>
</div>
</div>
</div>
</body>
`;

exports[`renders InternalUser without crashing 1`] = `
<body>
<div>
Expand Down
1 change: 1 addition & 0 deletions packages/ui-voip/src/components/PeerInfo/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { default as InternalUser } from './InternalUser';
export { default as ExternalUser } from './ExternalUser';
export { default as PhoneNumber } from './PhoneNumber';
export { default as PeerInfo } from './PeerInfo';
export { type PeerInfoProps } from './PeerInfo';
4 changes: 4 additions & 0 deletions packages/ui-voip/src/context/definitions.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { UserStatus } from '@rocket.chat/core-typings';
import type { CallFeature } from '@rocket.chat/media-signaling';

export type InternalPeerInfo = {
external: false;
displayName: string;
userId: string;
username?: string;
Expand All @@ -11,7 +12,10 @@ export type InternalPeerInfo = {
};

export type ExternalPeerInfo = {
external: true;
number: string;
displayName?: string;
avatarUrl?: string;
};

export type ConnectionState = 'CONNECTED' | 'CONNECTING' | 'RECONNECTING';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('usePeekMediaSessionPeerInfo', () => {
wrapper: createWrapper(instance),
});

expect(result.current).toEqual({ number: '+5511999999999' });
expect(result.current).toEqual({ external: true, number: '+5511999999999' });
});

it('returns internal peer info for user contact', () => {
Expand All @@ -98,6 +98,7 @@ describe('usePeekMediaSessionPeerInfo', () => {
});

expect(result.current).toEqual({
external: false,
displayName: 'John Doe',
userId: 'userId123',
username: 'johndoe',
Expand Down Expand Up @@ -131,7 +132,7 @@ describe('usePeekMediaSessionPeerInfo', () => {
wrapper: createWrapper(instance),
});

expect(result.current).toEqual({ number: '+5511999999999' });
expect(result.current).toEqual({ external: true, number: '+5511999999999' });

act(() => {
instanceState = null;
Expand All @@ -155,6 +156,7 @@ describe('usePeekMediaSessionPeerInfo', () => {
});

expect(result.current).toEqual({
external: false,
displayName: 'Jane Smith',
userId: 'userId456',
username: undefined,
Expand Down
15 changes: 8 additions & 7 deletions packages/ui-voip/src/context/usePeerAutocomplete.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ describe('hook', () => {

it('should return value when peerInfo has userId', () => {
mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1' };
const peerInfo: PeerInfo = { external: false, userId: 'user1', displayName: 'User 1' };

const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
wrapper: appRoot(),
Expand All @@ -130,7 +130,7 @@ describe('hook', () => {

it('should return undefined value when peerInfo has no userId', () => {
mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { number: '123456' };
const peerInfo: PeerInfo = { external: true, number: '123456' };

const { result } = renderHook(() => usePeerAutocomplete(mockOnSelectPeer, peerInfo), {
wrapper: appRoot(),
Expand Down Expand Up @@ -165,7 +165,7 @@ describe('hook', () => {
result.current.onChangeValue('rcx-first-option-123456');
});

expect(mockOnSelectPeer).toHaveBeenCalledWith({ number: '123456' });
expect(mockOnSelectPeer).toHaveBeenCalledWith({ external: true, number: '123456' });
});

it('should call onSelectPeer with full peer info when value matches option', async () => {
Expand All @@ -187,6 +187,7 @@ describe('hook', () => {
});

expect(mockOnSelectPeer).toHaveBeenCalledWith({
external: false,
userId: 'user1',
displayName: 'User 1',
avatarUrl: 'avatar.jpg',
Expand Down Expand Up @@ -247,7 +248,7 @@ describe('hook', () => {
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;

mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };
const peerInfo: PeerInfo = { external: false, userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };

mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.AWAY, statusText: '' });

Expand All @@ -268,7 +269,7 @@ describe('hook', () => {
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;

mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };
const peerInfo: PeerInfo = { external: false, userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };

mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.ONLINE, statusText: '' });

Expand Down Expand Up @@ -302,7 +303,7 @@ describe('hook', () => {
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;

mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { number: '123456' };
const peerInfo: PeerInfo = { external: true, number: '123456' };

mockUseUserPresence.mockReturnValue({ _id: 'user1', status: UserStatus.ONLINE, statusText: '' });

Expand All @@ -320,7 +321,7 @@ describe('hook', () => {
const mockUseUserPresence = useUserPresence as jest.MockedFunction<typeof useUserPresence>;

mockGetAutocompleteOptions.mockResolvedValue([]);
const peerInfo: PeerInfo = { userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };
const peerInfo: PeerInfo = { external: false, userId: 'user1', displayName: 'User 1', status: UserStatus.ONLINE };

mockUseUserPresence.mockReturnValue(undefined);

Expand Down
Loading
Loading