Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ void DOGFOOD;
const { MeetingUI } = jest.requireActual(
'../../../../sample-apps/react-native/dogfood/src/components/MeetingUI',
) as { MeetingUI: React.ComponentType<any> };
const { LobbyE2EEContext } = jest.requireActual(
'../../../../sample-apps/react-native/dogfood/src/contexts/LobbyE2EEContext',
) as { LobbyE2EEContext: React.Context<any> };
const { EncryptionManager } = jest.requireMock(
'@stream-io/video-react-native-sdk',
) as { EncryptionManager: any };
Expand All @@ -129,19 +132,27 @@ const navigation = () => ({
goBack: jest.fn(),
});

/** The meeting's lobby key, as the meeting screen provides it. */
const e2eeControls = {
encryptionKey: 'a-passphrase',
updateEncryptionKey: jest.fn(),
};

const meeting = (nav: any) => (
<LobbyE2EEContext.Provider value={e2eeControls}>
<MeetingUI callId="dogfood" navigation={nav} route={{} as any} />
</LobbyE2EEContext.Provider>
);

const renderMeeting = (nav = navigation()) => {
const view = render(
<MeetingUI callId="dogfood" navigation={nav as any} route={{} as any} />,
);
const view = render(meeting(nav));
return { view, nav };
};

/** Swaps in a replacement Call, the way a changed callId or client does. */
const replaceCall = (view: any, nav: any, next: any) => {
mockCall = next;
view.rerender(
<MeetingUI callId="dogfood" navigation={nav as any} route={{} as any} />,
);
view.rerender(meeting(nav));
};

/**
Expand Down
6 changes: 3 additions & 3 deletions sample-apps/react-native/dogfood/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { RootStackParamList } from './types';
import {
deeplinkCallId$,
deeplinkCall$,
useDeepLinkEffect,
} from './src/hooks/useDeepLinkEffect';
import {
Expand Down Expand Up @@ -136,8 +136,8 @@ const StackNavigator = () => {
}

useEffect(() => {
const subscription = deeplinkCallId$.subscribe((prontoCallId) => {
if (prontoCallId) {
const subscription = deeplinkCall$.subscribe((deeplinkCall) => {
if (deeplinkCall) {
setState({ appMode: 'Meeting' });
}
});
Expand Down
53 changes: 44 additions & 9 deletions sample-apps/react-native/dogfood/__tests__/e2ee.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
type Call,
EncryptionManager,
} from '@stream-io/video-react-native-sdk';
import { attachE2EEIfConfigured } from '../src/utils/e2ee';
import { attachE2EE, attachE2EEIfConfigured } from '../src/utils/e2ee';
import { LobbyE2EEContext } from '../src/contexts/LobbyE2EEContext';
import { mmkvStorage } from '../src/contexts/createStoreContext';
import { MeetingUI } from '../src/components/MeetingUI';

Expand Down Expand Up @@ -60,7 +61,10 @@ jest.mock('../src/components/CallErrorComponent', () => ({
const createCall = () => {
const call = {
currentUserId: undefined as string | undefined,
state: { callingState: 'idle' },
state: {
callingState: 'idle',
settings: undefined as { encryption: { mode: string } } | undefined,
},
setE2EEManager: jest.fn(),
join: jest.fn().mockResolvedValue(undefined),
leave: jest.fn(async () => {
Expand Down Expand Up @@ -105,15 +109,46 @@ it('remains a no-op without a configured key', async () => {
expect(EncryptionManager.create).not.toHaveBeenCalled();
});

it('shows the setup error and ends the meeting flow before joining', async () => {
it('does not read the ringing-call passphrase for a meeting', async () => {
mockCall.currentUserId = 'user';
await expect(
attachE2EE(mockCall as unknown as Call, undefined),
).resolves.toBeUndefined();
await expect(
attachE2EE(mockCall as unknown as Call, ' '),
).resolves.toBeUndefined();
expect(mmkvStorage.getString).not.toHaveBeenCalled();
expect(EncryptionManager.create).not.toHaveBeenCalled();
});

const renderMeeting = (encryptionKey: string) => {
type Props = React.ComponentProps<typeof MeetingUI>;
await renderAsync(
<MeetingUI
callId="test-call"
navigation={{} as Props['navigation']}
route={{} as Props['route']}
/>,
return renderAsync(
<LobbyE2EEContext.Provider
value={{ encryptionKey, updateEncryptionKey: jest.fn() }}
>
<MeetingUI
callId="test-call"
navigation={{} as Props['navigation']}
route={{} as Props['route']}
/>
</LobbyE2EEContext.Provider>,
);
};

it('joins an existing unencrypted call in the clear despite a brought key', async () => {
mockCall.currentUserId = 'user';
mockCall.state.settings = { encryption: { mode: 'disabled' } };
await renderMeeting('test-key');
await act(async () => mockJoin());

expect(EncryptionManager.create).not.toHaveBeenCalled();
expect(mockCall.setE2EEManager).not.toHaveBeenCalled();
expect(mockCall.join).toHaveBeenCalledWith({ create: true });
});

it('shows the setup error and ends the meeting flow before joining', async () => {
await renderMeeting('test-key');
await act(async () => mockJoin());

expect(mockCall.join).not.toHaveBeenCalled();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,14 @@ import {
import {
useDeepLinkEffect,
useEncryptedDeepLinkEffect,
deeplinkCallId$,
deeplinkCall$,
} from '../src/hooks/useDeepLinkEffect';

const mockClient = {
state: { calls: [] as { state: { callingState: CallingState } }[] },
};
let mockEnvironment = 'pronto';
let mockRoute = 'JoinMeetingScreen';
const mockSetState = jest.fn();

// This app-level test runs before SDK builds in CI. Mock only the SDK API it uses.
jest.mock(
Expand All @@ -34,7 +33,6 @@ jest.mock(
{ virtual: true },
);
jest.mock('../src/contexts/AppContext', () => ({
useAppGlobalStoreSetState: () => mockSetState,
useAppGlobalStoreValue: (selector: any) =>
selector({ appEnvironment: mockEnvironment }),
}));
Expand Down Expand Up @@ -66,9 +64,8 @@ beforeEach(() => {
mockEnvironment = 'pronto';
mockRoute = 'JoinMeetingScreen';
mockClient.state.calls = [];
mockSetState.mockReset();
removeListener.mockClear();
deeplinkCallId$.next(undefined);
deeplinkCall$.next(undefined);
jest.spyOn(Alert, 'alert').mockImplementation(() => {});
jest.spyOn(Linking, 'getInitialURL').mockResolvedValue(null);
jest.spyOn(Linking, 'addEventListener').mockImplementation((_, handler) => {
Expand All @@ -88,24 +85,22 @@ const listen = async () => {
};

it.each(encryptedLinks)(
'retains a $environment cold-start key through login and saves it before navigation',
'retains a $environment cold-start key through login and hands it to that meeting only',
async ({ environment, url }) => {
mockEnvironment = environment;
jest.mocked(Linking.getInitialURL).mockResolvedValue(url);
await listen();
expect(mockSetState).not.toHaveBeenCalled();
expect(deeplinkCallId$.value).toBeUndefined();
const subscription = deeplinkCallId$.subscribe((callId) => {
if (callId) {
expect(mockSetState).toHaveBeenCalledWith({ e2eeKeyInput: 'new-key' });
}
});
expect(deeplinkCall$.value).toBeUndefined();
const view = renderHook(useClientLinks);
expect(deeplinkCallId$.value).toBe('call-123');
expect(deeplinkCall$.value).toEqual({
callId: 'call-123',
encryptionKey: 'new-key',
});
// consumed by the join screen, and not replayed on the next login
deeplinkCall$.next(undefined);
view.unmount();
renderHook(useClientLinks);
expect(mockSetState).toHaveBeenCalledTimes(1);
subscription.unsubscribe();
expect(deeplinkCall$.value).toBeUndefined();
},
);

Expand All @@ -117,13 +112,15 @@ it.each(['https', 'streamvideo'])(
openURL(
`${scheme}://pronto.getstream.io/join/call-123?encryption_key=%20new%2Bkey%20`,
);
expect(mockSetState).toHaveBeenCalledWith({ e2eeKeyInput: 'new+key' });
expect(deeplinkCallId$.value).toBe('call-123');
expect(deeplinkCall$.value).toEqual({
callId: 'call-123',
encryptionKey: 'new+key',
});
},
);

it.each(encryptedLinks)(
'alerts outside $environment without saving or navigating, and does not retry on switching',
'alerts outside $environment without navigating, and does not retry on switching',
async ({ environment, url }) => {
await listen();
mockEnvironment = environment === 'pronto' ? 'demo' : 'pronto';
Expand All @@ -134,8 +131,7 @@ it.each(encryptedLinks)(
);
mockEnvironment = environment;
view.rerender({});
expect(mockSetState).not.toHaveBeenCalled();
expect(deeplinkCallId$.value).toBeUndefined();
expect(deeplinkCall$.value).toBeUndefined();
},
);

Expand All @@ -154,8 +150,7 @@ it.each([
expect(Alert.alert).toHaveBeenCalledWith(
'Leave the current call before opening this link',
);
expect(mockSetState).not.toHaveBeenCalled();
expect(deeplinkCallId$.value).toBeUndefined();
expect(deeplinkCall$.value).toBeUndefined();
},
);

Expand All @@ -169,8 +164,7 @@ it.each(['MeetingScreen', 'GuestMeetingScreen'])(
expect(Alert.alert).toHaveBeenCalledWith(
'Leave the current call before opening this link',
);
expect(mockSetState).not.toHaveBeenCalled();
expect(deeplinkCallId$.value).toBeUndefined();
expect(deeplinkCall$.value).toBeUndefined();
},
);

Expand All @@ -179,13 +173,12 @@ it.each([
'https://getstream.io/video/demos/join/call-123',
'https://pronto-staging.getstream.io/join/call-123',
'https://example.com/join/call-123',
])('preserves the saved key for an ordinary link: %s', async (url) => {
])('opens an ordinary link without a key: %s', async (url) => {
await listen();
renderHook(useClientLinks);
openURL(url);
expect(mockSetState).not.toHaveBeenCalled();
expect(Alert.alert).not.toHaveBeenCalled();
expect(deeplinkCallId$.value).toBe('call-123');
expect(deeplinkCall$.value).toEqual({ callId: 'call-123' });
});

it.each([
Expand All @@ -198,14 +191,12 @@ it.each([
async (url) => {
jest.mocked(Linking.getInitialURL).mockResolvedValue(url);
await listen();
expect(deeplinkCallId$.value).toBeUndefined();
expect(deeplinkCall$.value).toBeUndefined();
renderHook(useClientLinks);
expect(mockSetState).not.toHaveBeenCalled();
expect(deeplinkCallId$.value).toBeUndefined();
expect(deeplinkCall$.value).toBeUndefined();

openURL(url);
expect(mockSetState).not.toHaveBeenCalled();
expect(deeplinkCallId$.value).toBeUndefined();
expect(deeplinkCall$.value).toBeUndefined();
},
);

Expand All @@ -214,8 +205,7 @@ it('ignores malformed links and removes its listener on unmount', async () => {
renderHook(useClientLinks);
openURL('not a URL');
openURL('https://pronto.getstream.io/join/invalid!id?encryption_key=unused');
expect(mockSetState).not.toHaveBeenCalled();
expect(deeplinkCallId$.value).toBeUndefined();
expect(deeplinkCall$.value).toBeUndefined();
await cleanupAsync();
expect(removeListener).toHaveBeenCalledTimes(1);
});
15 changes: 15 additions & 0 deletions sample-apps/react-native/dogfood/__tests__/inviteLink.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getInviteUrl } from '../src/utils/inviteLink';

it.each([
['pronto', 'https://pronto.getstream.io/join/call-123'],
['pronto-staging', 'https://pronto-staging.getstream.io/join/call-123'],
['demo', 'https://getstream.io/video/demos/join/call-123'],
] as const)('links a %s call without a key', (environment, url) => {
expect(getInviteUrl(environment, 'call-123')).toBe(url);
});

it('carries the shared key, encoded, so the invitee can decrypt', () => {
expect(getInviteUrl('pronto', 'call-123', 'amber+otter canyon')).toBe(
'https://pronto.getstream.io/join/call-123?encryption_key=amber%2Botter%20canyon',
);
});
18 changes: 16 additions & 2 deletions sample-apps/react-native/dogfood/src/components/E2EEKeyInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@ import {
import { appTheme } from '../theme';
import { TextInput } from './TextInput';
import { isE2EESupported } from '../utils/e2ee';
import { useAppI18n } from '../hooks/useAppI18n';

/**
* Debug entry for the end-to-end encryption key.
* Entry for the ringing-call end-to-end encryption passphrase.
*
* Ringing calls are joined by the SDK (often from CallKit/Telecom, possibly
* with the app killed), so unlike meetings there is no lobby in which to enter a
* per-call key: caller and callee configure the same passphrase here up front.
*
* Keys are the app's business, not the SDK's, so the passphrase never leaves
* this app: it is stretched locally and installed as a shared key. Interop with
Expand All @@ -22,6 +27,7 @@ export const E2EEKeyInput = () => {
const stored = useAppGlobalStoreValue((store) => store.e2eeKeyInput) ?? '';
const [draft, setDraft] = useState(stored);
const styles = useStyles();
const { t } = useAppI18n();

useEffect(() => {
setDraft(stored);
Expand Down Expand Up @@ -49,13 +55,16 @@ export const E2EEKeyInput = () => {

return (
<View style={styles.container}>
<Text style={styles.label}>End-to-end encryption</Text>
<Text style={styles.label}>
{t('joinCall.e2eeKey.label', 'End-to-end encryption for ringing calls')}
</Text>
<TextInput
placeholder="Shared passphrase"
value={draft}
autoCapitalize="none"
autoCorrect={false}
onChangeText={onChangeText}
style={styles.input}
/>
<Text style={styles.status}>{status}</Text>
</View>
Expand All @@ -70,6 +79,11 @@ const useStyles = () => {
container: {
marginTop: appTheme.spacing.lg,
},
// The shared TextInput is `flex: 1` for row layouts; in this column it
// would collapse to zero height.
input: {
flex: 0,
},
label: {
color: theme.colors.textPrimary,
fontSize: 14,
Expand Down
Loading
Loading