SEL-487: Prompt user to backup recovery phrase before registering (#715)

* feat: prompt backup before registration

* coderabbit feedback

* fix tests

* coderabbitai feedback and fix tests
This commit is contained in:
Justin Hernandez
2025-06-30 17:00:29 -07:00
committed by GitHub
parent cf2405254e
commit fe14ac655e
13 changed files with 502 additions and 45 deletions

View File

@@ -1,6 +1,6 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { renderHook, waitFor } from '@testing-library/react-native';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useModal } from '../../../src/hooks/useModal';
import useRecoveryPrompts from '../../../src/hooks/useRecoveryPrompts';
@@ -25,15 +25,19 @@ describe('useRecoveryPrompts', () => {
beforeEach(() => {
showModal.mockClear();
getAllDocuments.mockResolvedValue({ doc1: {} as any });
useSettingStore.setState({
loginCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
act(() => {
useSettingStore.setState({
loginCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});
});
it('shows modal on first login', async () => {
useSettingStore.setState({ loginCount: 1 });
act(() => {
useSettingStore.setState({ loginCount: 1 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
@@ -41,7 +45,9 @@ describe('useRecoveryPrompts', () => {
});
it('does not show modal when login count is 4', async () => {
useSettingStore.setState({ loginCount: 4 });
act(() => {
useSettingStore.setState({ loginCount: 4 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
@@ -49,7 +55,9 @@ describe('useRecoveryPrompts', () => {
});
it('shows modal on eighth login', async () => {
useSettingStore.setState({ loginCount: 8 });
act(() => {
useSettingStore.setState({ loginCount: 8 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();
@@ -57,7 +65,9 @@ describe('useRecoveryPrompts', () => {
});
it('does not show modal if backup already enabled', async () => {
useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true });
act(() => {
useSettingStore.setState({ loginCount: 1, cloudBackupEnabled: true });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
@@ -67,7 +77,9 @@ describe('useRecoveryPrompts', () => {
it('does not show modal when navigation is not ready', async () => {
const navigationRef = require('../../../src/navigation').navigationRef;
navigationRef.isReady.mockReturnValueOnce(false);
useSettingStore.setState({ loginCount: 1 });
act(() => {
useSettingStore.setState({ loginCount: 1 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
@@ -75,7 +87,12 @@ describe('useRecoveryPrompts', () => {
});
it('does not show modal when recovery phrase has been viewed', async () => {
useSettingStore.setState({ loginCount: 1, hasViewedRecoveryPhrase: true });
act(() => {
useSettingStore.setState({
loginCount: 1,
hasViewedRecoveryPhrase: true,
});
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
@@ -84,7 +101,9 @@ describe('useRecoveryPrompts', () => {
it('does not show modal when no documents exist', async () => {
getAllDocuments.mockResolvedValueOnce({});
useSettingStore.setState({ loginCount: 1 });
act(() => {
useSettingStore.setState({ loginCount: 1 });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).not.toHaveBeenCalled();
@@ -94,7 +113,9 @@ describe('useRecoveryPrompts', () => {
it('shows modal for other valid login counts', async () => {
for (const count of [2, 3, 13, 18]) {
showModal.mockClear();
useSettingStore.setState({ loginCount: count });
act(() => {
useSettingStore.setState({ loginCount: count });
});
renderHook(() => useRecoveryPrompts());
await waitFor(() => {
expect(showModal).toHaveBeenCalled();

View File

@@ -0,0 +1,264 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { render, waitFor } from '@testing-library/react-native';
import React from 'react';
import LoadingScreen from '../../../../src/screens/misc/LoadingScreen';
import { useProvingStore } from '../../../../src/utils/proving/provingMachine';
// Mock the proving store
jest.mock('../../../../src/utils/proving/provingMachine');
// Mock other dependencies
jest.mock('@react-navigation/native', () => ({
useIsFocused: () => true,
createNavigationContainerRef: jest.fn(() => ({
isReady: jest.fn(() => true),
navigate: jest.fn(),
getCurrentRoute: jest.fn(() => ({ name: 'TestScreen' })),
})),
createStaticNavigation: jest.fn(() => jest.fn()),
}));
jest.mock('@react-navigation/native-stack', () => ({
createNativeStackNavigator: jest.fn(() => ({})),
}));
jest.mock('react-native-gesture-handler', () => ({
GestureHandlerRootView: ({ children }: any) => children,
}));
jest.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({
top: 0,
bottom: 0,
left: 0,
right: 0,
}),
}));
jest.mock('lottie-react-native', () => ({
__esModule: true,
default: ({ children }: any) => children,
}));
jest.mock('tamagui', () => ({
YStack: ({ children }: any) => children,
Text: ({ children }: any) => children,
styled: jest.fn(
() =>
({ children }: any) =>
children,
),
}));
jest.mock('../../../../src/hooks/useHapticNavigation', () => ({
__esModule: true,
default: jest.fn(() => jest.fn()),
}));
jest.mock('../../../../src/utils/haptic', () => ({
loadingScreenProgress: jest.fn(),
}));
// Mock SVG imports
jest.mock(
'../../../../src/images/icons/close-warning.svg',
() => 'CloseWarningIcon',
);
jest.mock('../../../../src/utils/proving/loadingScreenStateText', () => ({
getLoadingScreenText: jest.fn().mockReturnValue({
actionText: 'Test Action',
estimatedTime: 'Test Time',
}),
}));
jest.mock('../../../../src/providers/passportDataProvider', () => ({
loadPassportDataAndSecret: jest.fn().mockResolvedValue(
JSON.stringify({
passportData: {
passportMetadata: {
signatureAlgorithm: 'RSA',
curveOrExponent: '65537',
},
},
}),
),
clearPassportData: jest.fn(),
}));
jest.mock('../../../../src/utils/notifications/notificationService', () => ({
setupNotifications: jest.fn().mockReturnValue(() => {}),
}));
jest.mock('../../../../src/utils/proving/validateDocument', () => ({
checkPassportSupported: jest
.fn()
.mockResolvedValue({ status: 'passport_supported' }),
}));
const mockUseProvingStore = useProvingStore as unknown as jest.MockedFunction<
typeof useProvingStore
>;
describe('LoadingScreen', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Circuit type handling', () => {
it('should handle DSC circuit type correctly', async () => {
// Mock proving store state for DSC flow
mockUseProvingStore.mockImplementation(selector => {
if (typeof selector === 'function') {
return selector({
currentState: 'proving',
fcmToken: 'test-token',
circuitType: 'dsc',
// Add other required state properties
} as any);
}
return {
currentState: 'proving',
fcmToken: 'test-token',
circuitType: 'dsc',
} as any;
});
// Mock getState to return DSC circuit type
(mockUseProvingStore as any).getState = jest.fn().mockReturnValue({
circuitType: 'dsc',
});
const {
getLoadingScreenText,
} = require('../../../../src/utils/proving/loadingScreenStateText');
render(<LoadingScreen route={{} as any} />);
// Verify that getLoadingScreenText was called with 'dsc' type
await waitFor(() => {
expect(getLoadingScreenText).toHaveBeenCalledWith(
'proving',
expect.any(Object),
'dsc',
);
});
});
it('should handle register circuit type correctly', async () => {
// Mock proving store state for register flow
mockUseProvingStore.mockImplementation(selector => {
if (typeof selector === 'function') {
return selector({
currentState: 'proving',
fcmToken: 'test-token',
circuitType: 'register',
} as any);
}
return {
currentState: 'proving',
fcmToken: 'test-token',
circuitType: 'register',
} as any;
});
// Mock getState to return register circuit type
(mockUseProvingStore as any).getState = jest.fn().mockReturnValue({
circuitType: 'register',
});
const {
getLoadingScreenText,
} = require('../../../../src/utils/proving/loadingScreenStateText');
render(<LoadingScreen route={{} as any} />);
// Verify that getLoadingScreenText was called with 'register' type
await waitFor(() => {
expect(getLoadingScreenText).toHaveBeenCalledWith(
'proving',
expect.any(Object),
'register',
);
});
});
it('should handle disclose circuit type correctly', async () => {
// Mock proving store state for disclose flow
mockUseProvingStore.mockImplementation(selector => {
if (typeof selector === 'function') {
return selector({
currentState: 'proving',
fcmToken: 'test-token',
circuitType: 'disclose',
} as any);
}
return {
currentState: 'proving',
fcmToken: 'test-token',
circuitType: 'disclose',
} as any;
});
// Mock getState to return disclose circuit type
(mockUseProvingStore as any).getState = jest.fn().mockReturnValue({
circuitType: 'disclose',
});
const {
getLoadingScreenText,
} = require('../../../../src/utils/proving/loadingScreenStateText');
render(<LoadingScreen route={{} as any} />);
// Verify that getLoadingScreenText was called with 'register' type (disclose uses register timing)
await waitFor(() => {
expect(getLoadingScreenText).toHaveBeenCalledWith(
'proving',
expect.any(Object),
'register',
);
});
});
it('should default to register type when circuit type is null', async () => {
// Mock proving store state with null circuit type
mockUseProvingStore.mockImplementation(selector => {
if (typeof selector === 'function') {
return selector({
currentState: 'proving',
fcmToken: 'test-token',
circuitType: null,
} as any);
}
return {
currentState: 'proving',
fcmToken: 'test-token',
circuitType: null,
} as any;
});
// Mock getState to return null circuit type
(mockUseProvingStore as any).getState = jest.fn().mockReturnValue({
circuitType: null,
});
const {
getLoadingScreenText,
} = require('../../../../src/utils/proving/loadingScreenStateText');
render(<LoadingScreen route={{} as any} />);
// Verify that getLoadingScreenText was called with 'register' type as default
await waitFor(() => {
expect(getLoadingScreenText).toHaveBeenCalledWith(
'proving',
expect.any(Object),
'register',
);
});
});
});
});

View File

@@ -1,13 +1,17 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { act } from '@testing-library/react-native';
import { useSettingStore } from '../../../src/stores/settingStore';
describe('settingStore', () => {
beforeEach(() => {
useSettingStore.setState({
loginCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
act(() => {
useSettingStore.setState({
loginCount: 0,
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});
});
@@ -24,34 +28,47 @@ describe('settingStore', () => {
});
it('increments login count from non-zero initial value', () => {
useSettingStore.setState({ loginCount: 5 });
act(() => {
useSettingStore.setState({ loginCount: 5 });
});
useSettingStore.getState().incrementLoginCount();
expect(useSettingStore.getState().loginCount).toBe(6);
});
it('resets login count when recovery phrase viewed', () => {
useSettingStore.setState({ loginCount: 2 });
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', () => {
useSettingStore.setState({ loginCount: 3, hasViewedRecoveryPhrase: true });
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', () => {
useSettingStore.setState({ loginCount: 3, cloudBackupEnabled: false });
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', () => {
useSettingStore.setState({ loginCount: 4, cloudBackupEnabled: true });
act(() => {
useSettingStore.setState({ loginCount: 4, cloudBackupEnabled: true });
});
useSettingStore.getState().toggleCloudBackupEnabled();
expect(useSettingStore.getState().cloudBackupEnabled).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(4);
@@ -79,7 +96,12 @@ describe('settingStore', () => {
});
it('does not reset login count when setting recovery phrase viewed to true when already true', () => {
useSettingStore.setState({ loginCount: 5, hasViewedRecoveryPhrase: true });
act(() => {
useSettingStore.setState({
loginCount: 5,
hasViewedRecoveryPhrase: true,
});
});
useSettingStore.getState().setHasViewedRecoveryPhrase(true);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(true);
expect(useSettingStore.getState().loginCount).toBe(5);
@@ -93,13 +115,17 @@ describe('settingStore', () => {
expect(useSettingStore.getState().loginCount).toBe(3);
// Disable cloud backup (should not reset)
useSettingStore.setState({ cloudBackupEnabled: true });
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)
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
});
useSettingStore.getState().setHasViewedRecoveryPhrase(false);
expect(useSettingStore.getState().hasViewedRecoveryPhrase).toBe(false);
expect(useSettingStore.getState().loginCount).toBe(3);
@@ -112,7 +138,9 @@ describe('settingStore', () => {
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
useSettingStore.setState({ loginCount: 2, cloudBackupEnabled: true });
act(() => {
useSettingStore.setState({ loginCount: 2, cloudBackupEnabled: true });
});
// Toggle to disable (should not reset)
useSettingStore.getState().toggleCloudBackupEnabled();

View File

@@ -1,20 +1,41 @@
// SPDX-License-Identifier: BUSL-1.1; Copyright (c) 2025 Social Connect Labs, Inc.; Licensed under BUSL-1.1 (see LICENSE); Apache-2.0 from 2029-06-11
import { act } from '@testing-library/react-native';
import { useSettingStore } from '../../../src/stores/settingStore';
import { getPostVerificationRoute } from '../../../src/utils/proving/provingMachine';
describe('getPostVerificationRoute', () => {
afterEach(() => {
useSettingStore.setState({ cloudBackupEnabled: false });
act(() => {
useSettingStore.setState({
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});
});
it('returns SaveRecoveryPhrase when cloud backup disabled', () => {
useSettingStore.setState({ cloudBackupEnabled: false });
it('returns SaveRecoveryPhrase when no backup and phrase not viewed', () => {
act(() => {
useSettingStore.setState({
cloudBackupEnabled: false,
hasViewedRecoveryPhrase: false,
});
});
expect(getPostVerificationRoute()).toBe('SaveRecoveryPhrase');
});
it('returns AccountVerifiedSuccess when cloud backup enabled', () => {
useSettingStore.setState({ cloudBackupEnabled: true });
act(() => {
useSettingStore.setState({ cloudBackupEnabled: true });
});
expect(getPostVerificationRoute()).toBe('AccountVerifiedSuccess');
});
it('returns AccountVerifiedSuccess when phrase already viewed', () => {
act(() => {
useSettingStore.setState({ hasViewedRecoveryPhrase: true });
});
expect(getPostVerificationRoute()).toBe('AccountVerifiedSuccess');
});
});

View File

@@ -95,6 +95,63 @@ describe('stateLoadingScreenText', () => {
// Should use RSA (4 SECONDS)
expect(result.estimatedTime).toBe('4 SECONDS');
});
it('should use DSC timing when type is specified as dsc', () => {
const result = getLoadingScreenText('proving', rsaMetadata, 'dsc');
// Should use RSA DSC (2 SECONDS) instead of register (4 SECONDS)
expect(result.estimatedTime).toBe('2 SECONDS');
});
it('should use register timing when type is specified as register', () => {
const result = getLoadingScreenText('proving', rsaMetadata, 'register');
// Should use RSA register (4 SECONDS)
expect(result.estimatedTime).toBe('4 SECONDS');
});
it('should default to register timing when no type is specified', () => {
const result = getLoadingScreenText('proving', rsaMetadata);
// Should default to register timing (4 SECONDS)
expect(result.estimatedTime).toBe('4 SECONDS');
});
});
describe('Circuit type specific timing estimates', () => {
const ecdsaMetadata: PassportMetadata = {
signatureAlgorithm: 'ECDSA',
curveOrExponent: 'secp256r1',
};
it('should provide correct DSC timing for ECDSA', () => {
const result = getLoadingScreenText('proving', ecdsaMetadata, 'dsc');
expect(result.estimatedTime).toBe('25 SECONDS');
});
it('should provide correct register timing for ECDSA', () => {
const result = getLoadingScreenText('proving', ecdsaMetadata, 'register');
expect(result.estimatedTime).toBe('50 SECONDS');
});
const rsaPssMetadata: PassportMetadata = {
signatureAlgorithm: 'RSAPSS',
curveOrExponent: '65537',
};
it('should provide correct DSC timing for RSA-PSS', () => {
const result = getLoadingScreenText('proving', rsaPssMetadata, 'dsc');
expect(result.estimatedTime).toBe('3 SECONDS');
});
it('should provide correct register timing for RSA-PSS', () => {
const result = getLoadingScreenText(
'proving',
rsaPssMetadata,
'register',
);
expect(result.estimatedTime).toBe('6 SECONDS');
});
});
describe('getProvingTimeEstimate', () => {