-
Notifications
You must be signed in to change notification settings - Fork 1.4k
fix(VoIP): guard push token registration behind authentication #7285
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
diegolmello
wants to merge
8
commits into
feat.voip-lib-new
Choose a base branch
from
fix/voip-pr6918-05-authenticated-push-registration
base: feat.voip-lib-new
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
cd4ab06
fix(VoIP): guard push token registration behind authentication
diegolmello e9a9a4d
fix(voip): satisfy lint on push token registration changes
diegolmello 2261c52
fix(voip): cast NativeVoipModule mock for TS strict mode
diegolmello 222f5dc
fix(voip): use namespace import for live binding to pendingToken
diegolmello e9ca489
fix(voip): move imports above const destructure
diegolmello ab78b44
fix(voip): mock sdk.current to satisfy guard in registerPushToken
diegolmello 9ead497
fix(voip): address review feedback on push token registration
diegolmello 2dd97ae
fix(voip): cast namespace import for module-level state reset
diegolmello File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import type * as ExpoNotifications from 'expo-notifications'; | ||
|
|
||
| // Mock the dependencies before importing push.ts | ||
| jest.mock('expo-notifications'); | ||
| jest.mock('../store/auxStore', () => ({ | ||
| store: { | ||
| getState: jest.fn() | ||
| } | ||
| })); | ||
| jest.mock('../services/restApi', () => ({ | ||
| registerPushToken: jest.fn().mockResolvedValue(undefined) | ||
| })); | ||
|
|
||
| // Use jest.isolateModules to get a fresh copy of push.ts per test, | ||
| // resetting the module-level `configured = false` flag each time. | ||
| let pushNotificationConfigure: (onNotification: jest.Mock) => Promise<any>; | ||
| let Notifications: typeof ExpoNotifications; | ||
| let reduxStore: { getState: jest.Mock }; | ||
| let registerPushToken: jest.Mock; | ||
|
|
||
| const baseState = { | ||
| login: { isAuthenticated: false }, | ||
| server: { version: '8.0.0', server: 'https://open.rocket.chat' }, | ||
| app: { background: false } | ||
| }; | ||
|
|
||
| // Helper to set up a fresh push module within isolateModules | ||
| const setupPushModule = () => { | ||
| jest.isolateModules(() => { | ||
| jest.doMock('expo-notifications', () => ({ | ||
| __esModule: true, | ||
| getDevicePushTokenAsync: jest.fn(() => Promise.resolve({ data: 'mock-token' })), | ||
| getPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'granted' })), | ||
| requestPermissionsAsync: jest.fn(() => Promise.resolve({ status: 'granted' })), | ||
| setBadgeCountAsync: jest.fn(() => Promise.resolve(true)), | ||
| dismissAllNotificationsAsync: jest.fn(() => Promise.resolve()), | ||
| setNotificationHandler: jest.fn(), | ||
| setNotificationCategoryAsync: jest.fn(() => Promise.resolve()), | ||
| addNotificationReceivedListener: jest.fn(() => ({ remove: jest.fn() })), | ||
| addNotificationResponseReceivedListener: jest.fn(() => ({ remove: jest.fn() })), | ||
| addPushTokenListener: jest.fn(() => ({ remove: jest.fn() })), | ||
| getLastNotificationResponse: jest.fn(() => null), | ||
| DEFAULT_ACTION_IDENTIFIER: 'expo.modules.notifications.actions.DEFAULT' | ||
| })); | ||
| jest.doMock('../store/auxStore', () => ({ | ||
| store: { | ||
| getState: jest.fn(() => baseState) | ||
| } | ||
| })); | ||
| jest.doMock('../services/restApi', () => ({ | ||
| registerPushToken: jest.fn().mockResolvedValue(undefined) | ||
| })); | ||
|
|
||
| Notifications = require('expo-notifications'); | ||
| reduxStore = require('../store/auxStore').store; | ||
| registerPushToken = require('../services/restApi').registerPushToken; | ||
| const pushModule = require('./push'); | ||
| pushNotificationConfigure = pushModule.pushNotificationConfigure; | ||
| }); | ||
| }; | ||
|
|
||
| describe('push.ts auth guard', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe('Notifications.addPushTokenListener callback', () => { | ||
| it('does NOT call registerPushToken when user is not authenticated', async () => { | ||
| setupPushModule(); | ||
| (reduxStore.getState as jest.Mock).mockReturnValue(baseState); | ||
|
|
||
| await pushNotificationConfigure(jest.fn()); | ||
| registerPushToken.mockClear(); | ||
|
|
||
| const registeredCallbacks = (Notifications.addPushTokenListener as jest.Mock).mock.calls; | ||
| expect(registeredCallbacks.length).toBe(1); | ||
|
|
||
| const listener = registeredCallbacks[0][0]; | ||
| await listener({ data: 'refreshed-token' }); | ||
|
|
||
| // Auth guard should have blocked the call | ||
| expect(registerPushToken).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
|
|
||
| describe('Notifications.addPushTokenListener callback (authenticated)', () => { | ||
| it('calls registerPushToken when user IS authenticated', async () => { | ||
| setupPushModule(); | ||
| (reduxStore.getState as jest.Mock).mockReturnValue({ | ||
| ...baseState, | ||
| login: { isAuthenticated: true } | ||
| }); | ||
|
|
||
| await pushNotificationConfigure(jest.fn()); | ||
| registerPushToken.mockClear(); | ||
|
|
||
| const registeredCallbacks = (Notifications.addPushTokenListener as jest.Mock).mock.calls; | ||
| expect(registeredCallbacks.length).toBe(1); | ||
|
|
||
| const listener = registeredCallbacks[0][0]; | ||
| await listener({ data: 'refreshed-token' }); | ||
|
|
||
| expect(registerPushToken).toHaveBeenCalledTimes(1); | ||
| }); | ||
| }); | ||
|
|
||
| describe('initial registerForPushNotifications().then(...) branch', () => { | ||
| it('does NOT call registerPushToken when user is not authenticated (initial token acquisition)', async () => { | ||
| setupPushModule(); | ||
| (reduxStore.getState as jest.Mock).mockReturnValue(baseState); | ||
|
|
||
| // The .then() callback fires after registerForPushNotifications() resolves; | ||
| // at that moment isAuthenticated is false, so the guard returns early. | ||
| await pushNotificationConfigure(jest.fn()); | ||
|
|
||
| expect(registerPushToken).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,136 @@ | ||
| import { store as reduxStore } from '../store/auxStore'; | ||
| import sdk from './sdk'; | ||
| import * as restApi from './restApi'; | ||
| import NativeVoipModule from '../native/NativeVoip'; | ||
| import { getDeviceToken } from '../notifications'; | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| const { registerPushToken } = restApi; | ||
|
|
||
| jest.mock('../store/auxStore', () => ({ | ||
| store: { | ||
| getState: jest.fn() | ||
| } | ||
| })); | ||
|
|
||
| jest.mock('./sdk', () => ({ | ||
| __esModule: true, | ||
| default: { | ||
| current: {}, | ||
| methodCallWrapper: jest.fn().mockResolvedValue(undefined), | ||
| post: jest.fn().mockResolvedValue({ success: true }) | ||
| } | ||
| })); | ||
|
|
||
| jest.mock('react-native-device-info', () => { | ||
| const mockFn = jest.fn(() => 'test-value'); | ||
| return { | ||
| __esModule: true, | ||
| default: { | ||
| getUniqueId: mockFn, | ||
| getSystemVersion: mockFn, | ||
| getVersion: mockFn, | ||
| getBuildNumber: mockFn, | ||
| hasNotch: mockFn, | ||
| getReadableVersion: mockFn, | ||
| getBundleId: mockFn, | ||
| getModel: mockFn, | ||
| isTablet: mockFn | ||
| }, | ||
| getUniqueId: mockFn, | ||
| getSystemVersion: mockFn, | ||
| getVersion: mockFn, | ||
| getBuildNumber: mockFn, | ||
| hasNotch: mockFn, | ||
| getReadableVersion: mockFn, | ||
| getBundleId: mockFn, | ||
| getModel: mockFn, | ||
| isTablet: mockFn | ||
| }; | ||
| }); | ||
|
|
||
| jest.mock('../native/NativeVoip', () => ({ | ||
| __esModule: true, | ||
| default: { | ||
| getLastVoipToken: jest.fn(() => '') | ||
| } | ||
| })); | ||
|
|
||
| jest.mock('../notifications', () => ({ | ||
| getDeviceToken: jest.fn(() => '') | ||
| })); | ||
|
|
||
| const baseState = { | ||
| server: { version: '8.0.0', server: 'https://open.rocket.chat' }, | ||
| login: { user: { id: 'uid1', token: 'tok1' } } | ||
| }; | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| (restApi as { pendingToken: string }).pendingToken = ''; | ||
| (restApi as { pendingVoipToken: string }).pendingVoipToken = ''; | ||
| (reduxStore.getState as jest.Mock).mockReturnValue(baseState); | ||
| (sdk.post as jest.Mock).mockResolvedValue({ success: true }); | ||
| (getDeviceToken as jest.Mock).mockReturnValue(''); | ||
| (NativeVoipModule.getLastVoipToken as jest.Mock).mockReturnValue(''); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| describe('registerPushToken - pendingToken cache', () => { | ||
| describe('when sdk.post rejects with 401', () => { | ||
| it('sets pendingToken so it can be replayed after auth', async () => { | ||
| (getDeviceToken as jest.Mock).mockReturnValue('auth-test-token'); | ||
| (sdk.post as jest.Mock).mockRejectedValue({ status: 401 }); | ||
|
|
||
| await registerPushToken(); | ||
|
|
||
| expect(restApi.pendingToken).toBe('auth-test-token'); | ||
| expect(restApi.pendingVoipToken).toBe(''); | ||
| }); | ||
| }); | ||
|
|
||
| describe('when sdk.post rejects with 403', () => { | ||
| it('sets pendingToken so it can be replayed after auth', async () => { | ||
| (getDeviceToken as jest.Mock).mockReturnValue('auth-test-token-403'); | ||
| (sdk.post as jest.Mock).mockRejectedValue({ status: 403 }); | ||
|
|
||
| await registerPushToken(); | ||
|
|
||
| expect(restApi.pendingToken).toBe('auth-test-token-403'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('when sdk.post succeeds', () => { | ||
| it('clears pendingToken', async () => { | ||
| // First, simulate a failed registration to set pendingToken | ||
| (getDeviceToken as jest.Mock).mockReturnValue('first-token'); | ||
| (sdk.post as jest.Mock).mockRejectedValue({ status: 401 }); | ||
| await registerPushToken(); | ||
| expect(restApi.pendingToken).toBe('first-token'); | ||
|
|
||
| // Now simulate a successful call — pendingToken should be cleared | ||
| (sdk.post as jest.Mock).mockResolvedValue({ success: true }); | ||
| await registerPushToken(); | ||
|
|
||
| expect(restApi.pendingToken).toBe(''); | ||
| expect(restApi.pendingVoipToken).toBe(''); | ||
| }); | ||
| }); | ||
|
|
||
| describe('short-circuit guard with pendingToken', () => { | ||
| it('does NOT short-circuit when pendingToken is set', async () => { | ||
| (getDeviceToken as jest.Mock).mockReturnValue('my-token'); | ||
|
|
||
| // First call: 401 sets pendingToken | ||
| (sdk.post as jest.Mock).mockRejectedValue({ status: 401 }); | ||
| await registerPushToken(); | ||
| expect(restApi.pendingToken).toBe('my-token'); | ||
|
|
||
| // Second call: same token, but pendingToken is set — should NOT short-circuit | ||
| // because the short-circuit condition is: | ||
| // token === lastToken && voipToken === lastVoipToken && !pendingToken | ||
| // Since !pendingToken is false (pendingToken is truthy), it proceeds | ||
| (sdk.post as jest.Mock).mockResolvedValue({ success: true }); | ||
| await registerPushToken(); | ||
| expect(sdk.post).toHaveBeenCalledTimes(2); // 401 call + success call | ||
| }); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.