SELF-483: Enable backup recovery prompts (#834)

* Guard recovery prompts

* refactor(app): gate recovery prompts with allow list (#1251)

* fix typing

* fix header

* fix app loading

* fix tests

* Limit recovery prompts to home allowlist (#1460)

* fix test

* fix typing pipeline

* format and fix linting and tests

* tests pass

* fix tests

* split up testing

* save wip

* save button fix

* fix count

* fix modal width

* remove consologging

* remove depcrecated login count

* linting

* lint

* early return
This commit is contained in:
Justin Hernandez
2025-12-05 21:34:50 -08:00
committed by GitHub
parent 89b16486ee
commit 202d0f8122
21 changed files with 1112 additions and 561 deletions

View File

@@ -2,32 +2,22 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { useNavigation } from '@react-navigation/native';
import { act, renderHook } from '@testing-library/react-native';
import { useModal } from '@/hooks/useModal';
import { getModalCallbacks } from '@/utils/modalCallbackRegistry';
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
}));
const mockNavigate = jest.fn();
const mockGoBack = jest.fn();
const mockGetState = jest.fn(() => ({
routes: [{ name: 'Home' }, { name: 'Modal' }],
}));
describe('useModal', () => {
beforeEach(() => {
(useNavigation as jest.Mock).mockReturnValue({
navigate: mockNavigate,
goBack: mockGoBack,
getState: mockGetState,
// Reset all mocks including the global navigationRef
jest.clearAllMocks();
// Set up the navigation ref mock with proper methods
global.mockNavigationRef.isReady.mockReturnValue(true);
global.mockNavigationRef.getState.mockReturnValue({
routes: [{ name: 'Home' }, { name: 'Modal' }],
index: 1,
});
mockNavigate.mockClear();
mockGoBack.mockClear();
mockGetState.mockClear();
});
it('should navigate to Modal with callbackId and handle dismissal', () => {
@@ -45,8 +35,10 @@ describe('useModal', () => {
act(() => result.current.showModal());
expect(mockNavigate).toHaveBeenCalledTimes(1);
const params = mockNavigate.mock.calls[0][1];
expect(global.mockNavigationRef.navigate).toHaveBeenCalledTimes(1);
const [screenName, params] =
global.mockNavigationRef.navigate.mock.calls[0];
expect(screenName).toBe('Modal');
expect(params).toMatchObject({
titleText: 'Title',
bodyText: 'Body',
@@ -58,7 +50,7 @@ describe('useModal', () => {
act(() => result.current.dismissModal());
expect(mockGoBack).toHaveBeenCalled();
expect(global.mockNavigationRef.goBack).toHaveBeenCalled();
expect(onModalDismiss).toHaveBeenCalled();
expect(getModalCallbacks(id)).toBeUndefined();
});

View File

@@ -2,6 +2,7 @@
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { AppState } from 'react-native';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useModal } from '@/hooks/useModal';
@@ -9,56 +10,175 @@ import useRecoveryPrompts from '@/hooks/useRecoveryPrompts';
import { usePassport } from '@/providers/passportDataProvider';
import { useSettingStore } from '@/stores/settingStore';
const navigationStateListeners: Array<() => void> = [];
let isNavigationReady = true;
// Use global appStateListeners from jest.setup.js mock
const appStateListeners = global.mockAppStateListeners || [];
jest.mock('@/hooks/useModal');
jest.mock('@/providers/passportDataProvider');
jest.mock('@/navigation', () => ({
navigationRef: {
isReady: jest.fn(() => true),
navigate: jest.fn(),
},
navigationRef: global.mockNavigationRef,
}));
// Use global react-native mock from jest.setup.js - no need to mock here
const showModal = jest.fn();
(useModal as jest.Mock).mockReturnValue({ showModal, visible: false });
const getAllDocuments = jest.fn();
(usePassport as jest.Mock).mockReturnValue({ getAllDocuments });
const getAppState = (): {
currentState: string;
addEventListener: jest.Mock;
} =>
AppState as unknown as {
currentState: string;
addEventListener: jest.Mock;
};
describe('useRecoveryPrompts', () => {
beforeEach(() => {
showModal.mockClear();
getAllDocuments.mockResolvedValue({ doc1: {} as any });
jest.clearAllMocks();
navigationStateListeners.length = 0;
appStateListeners.length = 0;
isNavigationReady = true;
// Setup the global navigation ref mock
global.mockNavigationRef.isReady.mockImplementation(
() => isNavigationReady,
);
global.mockNavigationRef.getCurrentRoute.mockReturnValue({ name: 'Home' });
global.mockNavigationRef.addListener.mockImplementation(
(_: string, callback: () => void) => {
navigationStateListeners.push(callback);
return () => {
const index = navigationStateListeners.indexOf(callback);
if (index >= 0) {
navigationStateListeners.splice(index, 1);
}
};
},
);
(useModal as jest.Mock).mockReturnValue({ showModal, visible: false });
getAllDocuments.mockResolvedValue({
doc1: {
data: {} as any,
metadata: { isRegistered: true } as any,
},
});
const mockAppState = getAppState();
mockAppState.currentState = 'active';
act(() => {
useSettingStore.setState({
loginCount: 0,
homeScreenViewCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});
});
it('shows modal on first login', async () => {
act(() => {
useSettingStore.setState({ loginCount: 1 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
it('does not show modal before the fifth home view', async () => {
for (const count of [1, 2, 3, 4]) {
showModal.mockClear();
act(() => {
useSettingStore.setState({ homeScreenViewCount: count });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
}
});
it('does not show modal when login count is 4', async () => {
it('waits for navigation readiness before prompting', async () => {
isNavigationReady = false;
global.mockNavigationRef.isReady.mockImplementation(
() => isNavigationReady,
);
act(() => {
useSettingStore.setState({ loginCount: 4 });
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
isNavigationReady = true;
navigationStateListeners.forEach(listener => listener());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});
it('shows modal on eighth login', async () => {
it('respects custom allow list overrides', async () => {
act(() => {
useSettingStore.setState({ loginCount: 8 });
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts({ allowedRoutes: ['Settings'] }));
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
showModal.mockClear();
global.mockNavigationRef.getCurrentRoute.mockReturnValue({
name: 'Settings',
});
renderHook(() => useRecoveryPrompts({ allowedRoutes: ['Settings'] }));
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});
it('prompts when returning from background on eligible route', async () => {
// This test verifies that the hook registers an app state listener
// and that the prompt logic can be triggered multiple times for different view counts
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
const { rerender, unmount } = renderHook(() => useRecoveryPrompts());
// Wait for initial prompt
await waitFor(() => {
expect(showModal).toHaveBeenCalledTimes(1);
});
// Clear and test with a different login count that should trigger again
showModal.mockClear();
act(() => {
useSettingStore.setState({ homeScreenViewCount: 10 }); // next multiple of 5
});
rerender();
// Wait for second prompt with new login count
await waitFor(() => {
expect(showModal).toHaveBeenCalledTimes(1);
});
unmount();
});
it('does not show modal for non-multiple-of-five view counts', async () => {
for (const count of [6, 7, 8, 9]) {
showModal.mockClear();
act(() => {
useSettingStore.setState({ homeScreenViewCount: count });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
}
});
it('shows modal on fifth home view', async () => {
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -68,7 +188,10 @@ describe('useRecoveryPrompts', () => {
it('does not show modal if backup already enabled', async () => {
act(() => {
useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true });
useSettingStore.setState({
homeScreenViewCount: 5,
cloudBackupEnabled: true,
});
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -76,12 +199,8 @@ describe('useRecoveryPrompts', () => {
});
});
it('does not show modal when navigation is not ready', async () => {
const navigationRef = require('@/navigation').navigationRef;
navigationRef.isReady.mockReturnValueOnce(false);
act(() => {
useSettingStore.setState({ loginCount: 1 });
});
it('does not show modal if already visible', async () => {
(useModal as jest.Mock).mockReturnValueOnce({ showModal, visible: true });
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
@@ -91,7 +210,7 @@ describe('useRecoveryPrompts', () => {
it('does not show modal when recovery phrase has been viewed', async () => {
act(() => {
useSettingStore.setState({
loginCount: 1,
homeScreenViewCount: 5,
hasViewedRecoveryPhrase: true,
});
});
@@ -104,7 +223,7 @@ describe('useRecoveryPrompts', () => {
it('does not show modal when no documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({});
act(() => {
useSettingStore.setState({ loginCount: 1 });
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -112,11 +231,51 @@ describe('useRecoveryPrompts', () => {
});
});
it('shows modal for other valid login counts', async () => {
for (const count of [2, 3, 13, 18]) {
it('does not show modal when only unregistered documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({
doc1: {
data: {} as any,
metadata: { isRegistered: false } as any,
},
doc2: {
data: {} as any,
metadata: { isRegistered: undefined } as any,
},
});
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
});
it('shows modal when registered documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({
doc1: {
data: {} as any,
metadata: { isRegistered: false } as any,
},
doc2: {
data: {} as any,
metadata: { isRegistered: true } as any,
},
});
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
});
});
it('shows modal for other valid view counts (multiples of five)', async () => {
for (const count of [5, 10, 15]) {
showModal.mockClear();
act(() => {
useSettingStore.setState({ loginCount: count });
useSettingStore.setState({ homeScreenViewCount: count });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
@@ -125,6 +284,32 @@ describe('useRecoveryPrompts', () => {
}
});
it('does not show modal again for same login count when state changes', async () => {
act(() => {
useSettingStore.setState({ homeScreenViewCount: 5 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalledTimes(1);
});
showModal.mockClear();
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
});
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: false });
});
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
});
});
it('returns correct visible state', () => {
const { result } = renderHook(() => useRecoveryPrompts());
expect(result.current.visible).toBe(false);
@@ -134,8 +319,9 @@ describe('useRecoveryPrompts', () => {
renderHook(() => useRecoveryPrompts());
expect(useModal).toHaveBeenCalledWith({
titleText: 'Protect your account',
bodyText:
bodyText: expect.stringContaining(
'Enable cloud backup or save your recovery phrase so you can recover your account.',
),
buttonText: 'Back up now',
onButtonPress: expect.any(Function),
onModalDismiss: expect.any(Function),

View File

@@ -0,0 +1,107 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import React from 'react';
import { render } from '@testing-library/react-native';
jest.mock('@/hooks/useRecoveryPrompts', () => jest.fn());
jest.mock('@selfxyz/mobile-sdk-alpha', () => ({
useSelfClient: jest.fn(() => ({})),
}));
jest.mock('@/navigation/deeplinks', () => ({
setupUniversalLinkListenerInNavigation: jest.fn(() => jest.fn()),
}));
jest.mock('@/services/analytics', () => ({
__esModule: true,
default: jest.fn(() => ({
trackEvent: jest.fn(),
trackScreenView: jest.fn(),
flush: jest.fn(),
})),
}));
describe('navigation', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should have the correct navigation screens', () => {
// Unmock @/navigation for this test to get the real navigationScreens
jest.unmock('@/navigation');
jest.isolateModules(() => {
const navigationScreens = require('@/navigation').navigationScreens;
const listOfScreens = Object.keys(navigationScreens).sort();
expect(listOfScreens).toEqual([
'AadhaarUpload',
'AadhaarUploadError',
'AadhaarUploadSuccess',
'AccountRecovery',
'AccountRecoveryChoice',
'AccountVerifiedSuccess',
'CloudBackupSettings',
'ComingSoon',
'ConfirmBelonging',
'CountryPicker',
'CreateMock',
'DeferredLinkingInfo',
'DevFeatureFlags',
'DevHapticFeedback',
'DevLoadingScreen',
'DevPrivateKey',
'DevSettings',
'Disclaimer',
'DocumentCamera',
'DocumentCameraTrouble',
'DocumentDataInfo',
'DocumentDataNotFound',
'DocumentNFCMethodSelection',
'DocumentNFCScan',
'DocumentNFCTrouble',
'DocumentOnboarding',
'Gratification',
'Home',
'IDPicker',
'IdDetails',
'Loading',
'ManageDocuments',
'MockDataDeepLink',
'Modal',
'Points',
'PointsInfo',
'ProofHistory',
'ProofHistoryDetail',
'ProofRequestStatus',
'Prove',
'QRCodeTrouble',
'QRCodeViewFinder',
'RecoverWithPhrase',
'Referral',
'SaveRecoveryPhrase',
'Settings',
'ShowRecoveryPhrase',
'Splash',
'WebView',
]);
});
});
it('wires recovery prompts hook into navigation', () => {
// Temporarily restore the React mock and unmock @/navigation for this test
jest.unmock('@/navigation');
const useRecoveryPrompts =
require('@/hooks/useRecoveryPrompts') as jest.Mock;
// Since we're testing the wiring and not the actual rendering,
// we can just check if the module exports the default component
// and verify the hook is called when the component is imported
const navigation = require('@/navigation');
expect(navigation.default).toBeDefined();
// Render the component to trigger the hooks
const NavigationWithTracking = navigation.default;
render(<NavigationWithTracking />);
expect(useRecoveryPrompts).toHaveBeenCalledWith();
});
});

View File

@@ -1,157 +0,0 @@
// SPDX-FileCopyrightText: 2025 Social Connect Labs, Inc.
// SPDX-License-Identifier: BUSL-1.1
// NOTE: Converts to Apache-2.0 on 2029-06-11 per LICENSE.
import { act } from '@testing-library/react-native';
import { useSettingStore } from '@/stores/settingStore';
describe('settingStore', () => {
beforeEach(() => {
act(() => {
useSettingStore.setState({
loginCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});
});
it('increments login count', () => {
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(1);
});
it('increments login count multiple times', () => {
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(3);
});
it('increments login count from non-zero initial value', () => {
act(() => {
useSettingStore.setState({ loginCount: 5 });
});
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(6);
});
it('resets login count when recovery phrase viewed', () => {
act(() => {
useSettingStore.setState({ loginCount: 2 });
});
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when setting recovery phrase viewed to false', () => {
act(() => {
useSettingStore.setState({
loginCount: 3,
hasViewedRecoveryPhrase: true,
});
});
useSettingStore.getState().setHasViewedRecoveryPhrase(false);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
});
it('resets login count when enabling cloud backup', () => {
act(() => {
useSettingStore.setState({ loginCount: 3, cloudBackupEnabled: false });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when disabling cloud backup', () => {
act(() => {
useSettingStore.setState({ loginCount: 4, cloudBackupEnabled: true });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(4);
});
it('handles sequential actions that reset login count', () => {
// Increment login count
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(2);
// Toggle cloud backup (should reset to 0)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
// Increment again
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(1);
// Set recovery phrase viewed (should reset to 0)
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('does not reset login count when setting recovery phrase viewed to true when already true', () => {
act(() => {
useSettingStore.setState({
loginCount: 5,
hasViewedRecoveryPhrase: true,
});
});
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(5);
});
it('handles complex sequence of mixed operations', () => {
// Start with some increments
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(3);
// Disable cloud backup (should not reset)
act(() => {
useSettingStore.setState({ cloudBackupEnabled: true });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
// Set recovery phrase viewed to false (should not reset)
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
});
useSettingStore.getState().setHasViewedRecoveryPhrase(false);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
// Enable cloud backup (should reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
it('maintains login count when toggling cloud backup from true to false then back to true', () => {
// Start with cloud backup enabled and some login count
act(() => {
useSettingStore.setState({ loginCount: 2, cloudBackupEnabled: true });
});
// Toggle to disable (should not reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(2);
// Toggle to enable (should reset)
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(0);
});
});