Compare commits

...

6 Commits

Author SHA1 Message Date
Marcos Rodriguez
4cec9ab9c5
Update PromptPasswordConfirmationStack.tsx 2026-06-03 20:04:07 -05:00
Marcos Rodriguez Vélez
0cd2cc40a6
Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-03 20:00:24 -05:00
Marcos Rodriguez
6486e9a598
Update en.json 2026-06-02 16:59:33 -05:00
Marcos Rodriguez
aa8365aef1
FIX: lint 2026-06-02 14:11:39 -05:00
Marcos Rodriguez
8d08d8c062
DEL: comments 2026-06-02 14:06:23 -05:00
Marcos Rodriguez
77b4bc83f2
REF: Prompt Modal with useReducer and tests 2026-06-02 14:02:28 -05:00
6 changed files with 513 additions and 135 deletions

View File

@ -309,6 +309,7 @@
"open_link_in_explorer": "Open link in explorer",
"password": "Password",
"password_explain": "Enter the password you will use to unlock your storage.",
"passwords_do_not_match": "Passwords do not match.",
"plausible_deniability": "Plausible Deniability",
"privacy": "Privacy",
"privacy_read_clipboard": "Read Clipboard",

View File

@ -2,9 +2,9 @@ import React from 'react';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import navigationStyle, { CloseButtonPosition } from '../components/navigationStyle';
import { useTheme } from '../components/themes';
import loc from '../loc';
import PromptPasswordConfirmationSheet from '../screen/PromptPasswordConfirmationSheet';
import { PromptPasswordConfirmationStackParamList } from './PromptPasswordConfirmationStackParamList';
import loc from '../loc';
const Stack = createNativeStackNavigator<PromptPasswordConfirmationStackParamList>();

View File

@ -0,0 +1,47 @@
import { MODAL_TYPES, ModalType } from './PromptPasswordConfirmationSheet.types';
export const ACTIONS = {
SET_PASSWORD: 'SET_PASSWORD',
SET_CONFIRM_PASSWORD: 'SET_CONFIRM_PASSWORD',
SET_LOADING: 'SET_LOADING',
HIDE_EXPLANATION: 'HIDE_EXPLANATION',
RESET: 'RESET',
} as const;
export type State = {
password: string;
confirmPassword: string;
isLoading: boolean;
showExplanation: boolean;
};
export type Action =
| { type: typeof ACTIONS.SET_PASSWORD; payload: string }
| { type: typeof ACTIONS.SET_CONFIRM_PASSWORD; payload: string }
| { type: typeof ACTIONS.SET_LOADING; payload: boolean }
| { type: typeof ACTIONS.HIDE_EXPLANATION }
| { type: typeof ACTIONS.RESET; payload: ModalType };
export const initialState = (modalType: ModalType): State => ({
password: '',
confirmPassword: '',
isLoading: false,
showExplanation: modalType === MODAL_TYPES.CREATE_PASSWORD,
});
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case ACTIONS.SET_PASSWORD:
return { ...state, password: action.payload };
case ACTIONS.SET_CONFIRM_PASSWORD:
return { ...state, confirmPassword: action.payload };
case ACTIONS.SET_LOADING:
return { ...state, isLoading: action.payload };
case ACTIONS.HIDE_EXPLANATION:
return { ...state, showExplanation: false };
case ACTIONS.RESET:
return initialState(action.payload);
default:
return state;
}
};

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import { RouteProp, StackActions, useNavigation, useRoute } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Animated, Easing, Keyboard, StyleSheet, Text, TextInput, View, TextStyle, ViewStyle } from 'react-native';
@ -10,6 +10,7 @@ import { useTheme } from '../components/themes';
import loc from '../loc';
import { DetailViewStackParamList } from '../navigation/DetailViewStackParamList';
import { MODAL_TYPES } from './PromptPasswordConfirmationSheet.types';
import { ACTIONS, initialState, reducer } from './PromptPasswordConfirmationSheet.reducer';
import { useStorage } from '../hooks/context/useStorage';
import presentAlert from '../components/Alert';
@ -20,15 +21,28 @@ type DynamicStyles = {
feeModalLabel: TextStyle;
};
const SHAKE_KEYFRAMES = [10, -10, 5, -5, 0];
const runShake = (value: Animated.Value) => {
Animated.sequence(
SHAKE_KEYFRAMES.map(toValue =>
Animated.timing(value, {
toValue,
duration: 100,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
),
).start();
};
const PromptPasswordConfirmationSheet = () => {
const navigation = useNavigation<NativeStackNavigationProp<DetailViewStackParamList>>();
const route = useRoute<RouteProp<DetailViewStackParamList, 'PromptPasswordConfirmationSheet'>>();
const { modalType = MODAL_TYPES.ENTER_PASSWORD, returnTo } = route.params ?? {};
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showExplanation, setShowExplanation] = useState(modalType === MODAL_TYPES.CREATE_PASSWORD);
const [state, dispatch] = useReducer(reducer, modalType, initialState);
const { password, confirmPassword, isLoading, showExplanation } = state;
const shakeAnimation = useRef(new Animated.Value(0)).current;
const explanationOpacity = useRef(new Animated.Value(1)).current;
@ -36,167 +50,136 @@ const PromptPasswordConfirmationSheet = () => {
const { colors } = useTheme();
const { encryptStorage, decryptStorage, saveToDisk, cachedPassword, isPasswordInUse, createFakeStorage, resetWallets } = useStorage();
const isCreatePassword = modalType === MODAL_TYPES.CREATE_PASSWORD;
const isCreateFake = modalType === MODAL_TYPES.CREATE_FAKE_STORAGE;
const isCreateFlow = isCreatePassword || isCreateFake;
const showPasswordForm = !isCreatePassword || !showExplanation;
const stylesHook = useMemo<DynamicStyles>(
() => ({
modalContent: {
backgroundColor: colors.elevated,
},
modalContent: { backgroundColor: colors.elevated },
input: {
backgroundColor: colors.inputBackgroundColor,
borderColor: colors.formBorder,
color: colors.foregroundColor,
width: '100%',
},
feeModalCustomText: {
color: colors.buttonAlternativeTextColor,
},
feeModalLabel: {
color: colors.successColor,
},
feeModalCustomText: { color: colors.buttonAlternativeTextColor },
feeModalLabel: { color: colors.successColor },
}),
[colors],
);
const resetState = useCallback(() => {
setPassword('');
setConfirmPassword('');
setIsLoading(false);
useEffect(() => {
dispatch({ type: ACTIONS.RESET, payload: modalType });
shakeAnimation.setValue(0);
explanationOpacity.setValue(1);
setShowExplanation(modalType === MODAL_TYPES.CREATE_PASSWORD);
}, [modalType, shakeAnimation, explanationOpacity]);
useEffect(() => {
resetState();
}, [modalType, resetState]);
const performShake = (shakeAnimRef: Animated.Value) => {
Animated.sequence([
Animated.timing(shakeAnimRef, {
toValue: 10,
duration: 100,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(shakeAnimRef, {
toValue: -10,
duration: 100,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(shakeAnimRef, {
toValue: 5,
duration: 100,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(shakeAnimRef, {
toValue: -5,
duration: 100,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
Animated.timing(shakeAnimRef, {
toValue: 0,
duration: 100,
easing: Easing.inOut(Easing.ease),
useNativeDriver: true,
}),
]).start();
};
const handleShakeAnimation = () => {
const failWithShake = useCallback(() => {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
performShake(shakeAnimation);
};
runShake(shakeAnimation);
}, [shakeAnimation]);
const handleSubmit = async () => {
Keyboard.dismiss();
setIsLoading(true);
const isCreateFlow = modalType === MODAL_TYPES.CREATE_PASSWORD || modalType === MODAL_TYPES.CREATE_FAKE_STORAGE;
if (isCreateFlow) {
if (!password || password !== confirmPassword) {
setIsLoading(false);
return handleShakeAnimation();
}
} else if (!password) {
setIsLoading(false);
return handleShakeAnimation();
}
const runAction = async () => {
if (returnTo === 'EncryptStorage') {
if (modalType === MODAL_TYPES.CREATE_PASSWORD) {
await encryptStorage(password);
await saveToDisk();
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
navigation.goBack();
return true;
}
if (modalType === MODAL_TYPES.ENTER_PASSWORD) {
await decryptStorage(password);
await saveToDisk();
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
const action = StackActions.popToTop();
navigation.dispatch(action);
return true;
}
}
if (returnTo === 'PlausibleDeniability' && modalType === MODAL_TYPES.CREATE_FAKE_STORAGE) {
const isProvidedPasswordInUse = password === cachedPassword || (await isPasswordInUse(password));
if (isProvidedPasswordInUse) {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
presentAlert({ message: loc.plausibledeniability.password_should_not_match });
return false;
}
await createFakeStorage(password);
resetWallets();
const runAction = useCallback(async (): Promise<boolean> => {
if (returnTo === 'EncryptStorage') {
if (isCreatePassword) {
await encryptStorage(password);
await saveToDisk();
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
const popToTop = StackActions.popToTop();
navigation.dispatch(popToTop);
navigation.goBack();
return true;
}
if (modalType === MODAL_TYPES.ENTER_PASSWORD) {
await decryptStorage(password);
await saveToDisk();
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
navigation.dispatch(StackActions.popToTop());
return true;
}
}
return false;
};
if (returnTo === 'PlausibleDeniability' && isCreateFake) {
const inUse = password === cachedPassword || (await isPasswordInUse(password));
if (inUse) {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
presentAlert({ message: loc.plausibledeniability.password_should_not_match });
return false;
}
await createFakeStorage(password);
resetWallets();
triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess);
navigation.dispatch(StackActions.popToTop());
return true;
}
return false;
}, [
returnTo,
modalType,
isCreatePassword,
isCreateFake,
password,
encryptStorage,
decryptStorage,
saveToDisk,
navigation,
cachedPassword,
isPasswordInUse,
createFakeStorage,
resetWallets,
]);
const handleSubmit = useCallback(async () => {
Keyboard.dismiss();
if (isCreateFlow && password && confirmPassword && password !== confirmPassword) {
failWithShake();
presentAlert({ message: loc.settings.passwords_do_not_match });
return;
}
const invalid = isCreateFlow ? !password || password !== confirmPassword : !password;
if (invalid) {
failWithShake();
return;
}
dispatch({ type: ACTIONS.SET_LOADING, payload: true });
try {
const success = await runAction();
if (!success) {
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
}
if (!success) triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
} catch (error) {
presentAlert({ message: (error as Error).message });
triggerHapticFeedback(HapticFeedbackTypes.NotificationError);
} finally {
setIsLoading(false);
dispatch({ type: ACTIONS.SET_LOADING, payload: false });
}
};
}, [isCreateFlow, password, confirmPassword, failWithShake, runAction]);
const handleTransitionToCreatePassword = () => {
const handleTransitionToCreatePassword = useCallback(() => {
Animated.timing(explanationOpacity, {
toValue: 0,
duration: 240,
useNativeDriver: true,
}).start(() => {
setShowExplanation(false);
dispatch({ type: ACTIONS.HIDE_EXPLANATION });
explanationOpacity.setValue(1);
});
};
}, [explanationOpacity]);
const animatedViewStyle: Animated.WithAnimatedObject<any> = {
width: '100%',
};
const headerText = isCreatePassword
? loc.settings.password_explain
: isCreateFake
? `${loc.settings.password_explain} ${loc.plausibledeniability.create_password_explanation}`
: loc._.enter_password;
return (
<SafeAreaView style={[styles.modalContent, stylesHook.modalContent]} edges={['bottom', 'left', 'right']}>
<View style={styles.flex}>
<View style={styles.padding} />
<Animated.View style={[animatedViewStyle, styles.minHeight]}>
{modalType === MODAL_TYPES.CREATE_PASSWORD && showExplanation && (
<Animated.View style={[styles.fullWidth, styles.minHeight]}>
{isCreatePassword && showExplanation && (
<Animated.View style={{ opacity: explanationOpacity }}>
<Text style={[styles.textLabel, stylesHook.feeModalLabel]}>{loc.settings.encrypt_storage_explanation_headline}</Text>
<Animated.View>
@ -210,15 +193,10 @@ const PromptPasswordConfirmationSheet = () => {
<View style={styles.feeModalFooter} />
</Animated.View>
)}
{(modalType === MODAL_TYPES.ENTER_PASSWORD ||
((modalType === MODAL_TYPES.CREATE_PASSWORD || modalType === MODAL_TYPES.CREATE_FAKE_STORAGE) && !showExplanation)) && (
{showPasswordForm && (
<>
<Text adjustsFontSizeToFit style={[styles.textLabel, stylesHook.feeModalLabel]}>
{modalType === MODAL_TYPES.CREATE_PASSWORD
? loc.settings.password_explain
: modalType === MODAL_TYPES.CREATE_FAKE_STORAGE
? `${loc.settings.password_explain} ${loc.plausibledeniability.create_password_explanation}`
: loc._.enter_password}
{headerText}
</Text>
<View style={styles.inputContainer}>
<Animated.View style={{ transform: [{ translateX: shakeAnimation }] }}>
@ -230,14 +208,14 @@ const PromptPasswordConfirmationSheet = () => {
autoCapitalize="none"
autoComplete="off"
autoCorrect={false}
onChangeText={setPassword}
onChangeText={text => dispatch({ type: ACTIONS.SET_PASSWORD, payload: text })}
style={[styles.input, stylesHook.input]}
clearTextOnFocus
clearButtonMode="while-editing"
autoFocus
/>
</Animated.View>
{(modalType === MODAL_TYPES.CREATE_PASSWORD || modalType === MODAL_TYPES.CREATE_FAKE_STORAGE) && (
{isCreateFlow && (
<Animated.View style={{ transform: [{ translateX: shakeAnimation }] }}>
<TextInput
testID="ConfirmPasswordInput"
@ -249,7 +227,7 @@ const PromptPasswordConfirmationSheet = () => {
autoComplete="off"
autoCapitalize="none"
clearButtonMode="while-editing"
onChangeText={setConfirmPassword}
onChangeText={text => dispatch({ type: ACTIONS.SET_CONFIRM_PASSWORD, payload: text })}
style={[styles.input, stylesHook.input]}
/>
</Animated.View>
@ -260,7 +238,7 @@ const PromptPasswordConfirmationSheet = () => {
</Animated.View>
<View style={styles.footerContainer}>
{showExplanation && modalType === MODAL_TYPES.CREATE_PASSWORD ? (
{showExplanation && isCreatePassword ? (
<Animated.View style={[{ opacity: explanationOpacity }, styles.feeModalFooterSpacing]}>
<SecondButton
title={loc.settings.i_understand}
@ -276,7 +254,7 @@ const PromptPasswordConfirmationSheet = () => {
onPress={handleSubmit}
testID="OKButton"
loading={isLoading}
disabled={isLoading || !password || (modalType === MODAL_TYPES.CREATE_PASSWORD && !confirmPassword)}
disabled={isLoading || !password || (isCreatePassword && !confirmPassword)}
/>
</View>
)}
@ -297,6 +275,9 @@ const styles = StyleSheet.create({
flex: {
flex: 1,
},
fullWidth: {
width: '100%',
},
padding: {
paddingTop: 6,
},

View File

@ -0,0 +1,273 @@
import React from 'react';
import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
import { Animated } from 'react-native';
import { StackActions } from '@react-navigation/native';
import PromptPasswordConfirmationSheet from '../../screen/PromptPasswordConfirmationSheet';
import { MODAL_TYPES } from '../../screen/PromptPasswordConfirmationSheet.types';
jest.spyOn(Animated, 'timing').mockImplementation(
() =>
({
start: (cb?: (result: { finished: boolean }) => void) => cb?.({ finished: true }),
stop: () => {},
reset: () => {},
}) as any,
);
jest.spyOn(Animated, 'sequence').mockImplementation(
() =>
({
start: (cb?: (result: { finished: boolean }) => void) => cb?.({ finished: true }),
stop: () => {},
reset: () => {},
}) as any,
);
const mockGoBack = jest.fn();
const mockDispatch = jest.fn();
const mockUseRoute = jest.fn();
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
return {
...actual,
useNavigation: () => ({ goBack: mockGoBack, dispatch: mockDispatch }),
useRoute: () => mockUseRoute(),
};
});
const mockEncryptStorage = jest.fn();
const mockDecryptStorage = jest.fn();
const mockSaveToDisk = jest.fn();
const mockCreateFakeStorage = jest.fn();
const mockResetWallets = jest.fn();
const mockIsPasswordInUse = jest.fn();
jest.mock('../../hooks/context/useStorage', () => ({
useStorage: () => ({
encryptStorage: mockEncryptStorage,
decryptStorage: mockDecryptStorage,
saveToDisk: mockSaveToDisk,
cachedPassword: 'cached-pw',
isPasswordInUse: mockIsPasswordInUse,
createFakeStorage: mockCreateFakeStorage,
resetWallets: mockResetWallets,
}),
}));
const mockAlert = jest.fn();
jest.mock('../../components/Alert', () => ({
__esModule: true,
default: (...args: unknown[]) => mockAlert(...args),
}));
jest.mock('../../components/themes', () => ({
useTheme: () => ({
colors: {
elevated: '#fff',
inputBackgroundColor: '#eee',
formBorder: '#ccc',
foregroundColor: '#000',
buttonAlternativeTextColor: '#333',
successColor: '#0a0',
},
}),
}));
jest.mock('../../components/SecondButton', () => {
const { Pressable, Text } = require('react-native');
const ReactLocal = require('react');
return {
SecondButton: ({ title, onPress, testID, disabled }: any) =>
ReactLocal.createElement(
Pressable,
{ testID, onPress, disabled, accessibilityState: { disabled } },
ReactLocal.createElement(Text, null, title),
),
};
});
const mockHaptic = jest.fn();
jest.mock('../../blue_modules/hapticFeedback', () => ({
__esModule: true,
default: (...args: unknown[]) => mockHaptic(...args),
HapticFeedbackTypes: {
NotificationError: 'NotificationError',
NotificationSuccess: 'NotificationSuccess',
},
}));
const setRoute = (params: Record<string, unknown>) => {
mockUseRoute.mockReturnValue({ params, key: 'k', name: 'PromptPasswordConfirmationSheet' });
};
const resetAllMocks = () => {
mockGoBack.mockReset();
mockDispatch.mockReset();
mockEncryptStorage.mockReset().mockResolvedValue(undefined);
mockDecryptStorage.mockReset().mockResolvedValue(undefined);
mockSaveToDisk.mockReset().mockResolvedValue(undefined);
mockCreateFakeStorage.mockReset().mockResolvedValue(undefined);
mockResetWallets.mockReset();
mockIsPasswordInUse.mockReset().mockResolvedValue(false);
mockAlert.mockReset();
mockHaptic.mockReset();
};
describe('PromptPasswordConfirmationSheet (render)', () => {
beforeEach(resetAllMocks);
afterAll(() => {
jest.restoreAllMocks();
});
it('renders the enter-password form by default', () => {
setRoute({ modalType: MODAL_TYPES.ENTER_PASSWORD, returnTo: 'EncryptStorage' });
const { getByTestId, queryByTestId } = render(<PromptPasswordConfirmationSheet />);
expect(getByTestId('PasswordInput')).toBeTruthy();
expect(queryByTestId('ConfirmPasswordInput')).toBeNull();
expect(getByTestId('OKButton')).toBeTruthy();
});
it('shows the explanation step for CREATE_PASSWORD and transitions to the form on I Understand', () => {
setRoute({ modalType: MODAL_TYPES.CREATE_PASSWORD, returnTo: 'EncryptStorage' });
const { getByTestId, queryByTestId } = render(<PromptPasswordConfirmationSheet />);
expect(getByTestId('IUnderstandButton')).toBeTruthy();
expect(queryByTestId('PasswordInput')).toBeNull();
act(() => {
fireEvent.press(getByTestId('IUnderstandButton'));
});
expect(getByTestId('PasswordInput')).toBeTruthy();
expect(getByTestId('ConfirmPasswordInput')).toBeTruthy();
});
it('disables the OK button while the password is empty', () => {
setRoute({ modalType: MODAL_TYPES.ENTER_PASSWORD, returnTo: 'EncryptStorage' });
const { getByTestId } = render(<PromptPasswordConfirmationSheet />);
const okButton = getByTestId('OKButton');
expect(okButton.props.accessibilityState?.disabled).toBe(true);
fireEvent.changeText(getByTestId('PasswordInput'), 'something');
expect(getByTestId('OKButton').props.accessibilityState?.disabled).toBe(false);
});
it('decrypts and pops to top on successful ENTER_PASSWORD submit', async () => {
setRoute({ modalType: MODAL_TYPES.ENTER_PASSWORD, returnTo: 'EncryptStorage' });
const { getByTestId } = render(<PromptPasswordConfirmationSheet />);
fireEvent.changeText(getByTestId('PasswordInput'), 'hunter2');
await act(async () => {
fireEvent.press(getByTestId('OKButton'));
});
expect(mockDecryptStorage).toHaveBeenCalledWith('hunter2');
expect(mockSaveToDisk).toHaveBeenCalled();
expect(mockHaptic).toHaveBeenCalledWith('NotificationSuccess');
expect(mockDispatch).toHaveBeenCalledWith(StackActions.popToTop());
});
it('encrypts and goes back on successful CREATE_PASSWORD submit', async () => {
setRoute({ modalType: MODAL_TYPES.CREATE_PASSWORD, returnTo: 'EncryptStorage' });
const { getByTestId } = render(<PromptPasswordConfirmationSheet />);
act(() => {
fireEvent.press(getByTestId('IUnderstandButton'));
});
fireEvent.changeText(getByTestId('PasswordInput'), 'matching');
fireEvent.changeText(getByTestId('ConfirmPasswordInput'), 'matching');
await act(async () => {
fireEvent.press(getByTestId('OKButton'));
});
expect(mockEncryptStorage).toHaveBeenCalledWith('matching');
expect(mockSaveToDisk).toHaveBeenCalled();
expect(mockGoBack).toHaveBeenCalled();
});
it('shakes and alerts when CREATE_PASSWORD confirmation does not match', async () => {
setRoute({ modalType: MODAL_TYPES.CREATE_PASSWORD, returnTo: 'EncryptStorage' });
const { getByTestId } = render(<PromptPasswordConfirmationSheet />);
act(() => {
fireEvent.press(getByTestId('IUnderstandButton'));
});
fireEvent.changeText(getByTestId('PasswordInput'), 'abc');
fireEvent.changeText(getByTestId('ConfirmPasswordInput'), 'xyz');
await act(async () => {
fireEvent.press(getByTestId('OKButton'));
});
expect(mockEncryptStorage).not.toHaveBeenCalled();
expect(mockHaptic).toHaveBeenCalledWith('NotificationError');
expect(mockAlert).toHaveBeenCalledWith({ message: 'Passwords do not match.' });
});
it('rejects CREATE_FAKE_STORAGE when password is already in use', async () => {
setRoute({ modalType: MODAL_TYPES.CREATE_FAKE_STORAGE, returnTo: 'PlausibleDeniability' });
mockIsPasswordInUse.mockResolvedValue(true);
const { getByTestId } = render(<PromptPasswordConfirmationSheet />);
fireEvent.changeText(getByTestId('PasswordInput'), 'newpw');
fireEvent.changeText(getByTestId('ConfirmPasswordInput'), 'newpw');
await act(async () => {
fireEvent.press(getByTestId('OKButton'));
});
await waitFor(() => expect(mockAlert).toHaveBeenCalled());
expect(mockCreateFakeStorage).not.toHaveBeenCalled();
expect(mockResetWallets).not.toHaveBeenCalled();
});
it('rejects CREATE_FAKE_STORAGE when password equals cachedPassword without querying isPasswordInUse', async () => {
setRoute({ modalType: MODAL_TYPES.CREATE_FAKE_STORAGE, returnTo: 'PlausibleDeniability' });
const { getByTestId } = render(<PromptPasswordConfirmationSheet />);
fireEvent.changeText(getByTestId('PasswordInput'), 'cached-pw');
fireEvent.changeText(getByTestId('ConfirmPasswordInput'), 'cached-pw');
await act(async () => {
fireEvent.press(getByTestId('OKButton'));
});
await waitFor(() => expect(mockAlert).toHaveBeenCalled());
expect(mockIsPasswordInUse).not.toHaveBeenCalled();
expect(mockCreateFakeStorage).not.toHaveBeenCalled();
});
it('creates fake storage and pops to top on success', async () => {
setRoute({ modalType: MODAL_TYPES.CREATE_FAKE_STORAGE, returnTo: 'PlausibleDeniability' });
const { getByTestId } = render(<PromptPasswordConfirmationSheet />);
fireEvent.changeText(getByTestId('PasswordInput'), 'fresh');
fireEvent.changeText(getByTestId('ConfirmPasswordInput'), 'fresh');
await act(async () => {
fireEvent.press(getByTestId('OKButton'));
});
expect(mockCreateFakeStorage).toHaveBeenCalledWith('fresh');
expect(mockResetWallets).toHaveBeenCalled();
expect(mockDispatch).toHaveBeenCalledWith(StackActions.popToTop());
});
it('surfaces thrown errors via presentAlert', async () => {
setRoute({ modalType: MODAL_TYPES.ENTER_PASSWORD, returnTo: 'EncryptStorage' });
mockDecryptStorage.mockRejectedValue(new Error('boom'));
const { getByTestId } = render(<PromptPasswordConfirmationSheet />);
fireEvent.changeText(getByTestId('PasswordInput'), 'whatever');
await act(async () => {
fireEvent.press(getByTestId('OKButton'));
});
expect(mockAlert).toHaveBeenCalledWith({ message: 'boom' });
expect(mockHaptic).toHaveBeenCalledWith('NotificationError');
});
});

View File

@ -0,0 +1,76 @@
import { ACTIONS, initialState, reducer } from '../../screen/PromptPasswordConfirmationSheet.reducer';
import { MODAL_TYPES } from '../../screen/PromptPasswordConfirmationSheet.types';
describe('PromptPasswordConfirmationSheet reducer', () => {
describe('initialState', () => {
it('shows explanation only for CREATE_PASSWORD', () => {
expect(initialState(MODAL_TYPES.CREATE_PASSWORD).showExplanation).toBe(true);
expect(initialState(MODAL_TYPES.ENTER_PASSWORD).showExplanation).toBe(false);
expect(initialState(MODAL_TYPES.CREATE_FAKE_STORAGE).showExplanation).toBe(false);
});
it('starts with empty fields and not loading', () => {
const state = initialState(MODAL_TYPES.ENTER_PASSWORD);
expect(state.password).toBe('');
expect(state.confirmPassword).toBe('');
expect(state.isLoading).toBe(false);
});
});
describe('reducer', () => {
const base = initialState(MODAL_TYPES.CREATE_PASSWORD);
it('handles SET_PASSWORD', () => {
const next = reducer(base, { type: ACTIONS.SET_PASSWORD, payload: 'hunter2' });
expect(next.password).toBe('hunter2');
expect(next.confirmPassword).toBe('');
});
it('handles SET_CONFIRM_PASSWORD', () => {
const next = reducer(base, { type: ACTIONS.SET_CONFIRM_PASSWORD, payload: 'hunter2' });
expect(next.confirmPassword).toBe('hunter2');
expect(next.password).toBe('');
});
it('handles SET_LOADING', () => {
const next = reducer(base, { type: ACTIONS.SET_LOADING, payload: true });
expect(next.isLoading).toBe(true);
expect(reducer(next, { type: ACTIONS.SET_LOADING, payload: false }).isLoading).toBe(false);
});
it('handles HIDE_EXPLANATION', () => {
expect(base.showExplanation).toBe(true);
const next = reducer(base, { type: ACTIONS.HIDE_EXPLANATION });
expect(next.showExplanation).toBe(false);
});
it('handles RESET back to initialState for the given modalType', () => {
const dirty = reducer(base, { type: ACTIONS.SET_PASSWORD, payload: 'abc' });
const reset = reducer(dirty, { type: ACTIONS.RESET, payload: MODAL_TYPES.ENTER_PASSWORD });
expect(reset).toEqual(initialState(MODAL_TYPES.ENTER_PASSWORD));
});
it('returns same state for unknown action', () => {
// @ts-expect-error exercising the default branch with an unknown action type
expect(reducer(base, { type: 'NOPE' })).toBe(base);
});
it('does not mutate the previous state', () => {
const snapshot = { ...base };
reducer(base, { type: ACTIONS.SET_PASSWORD, payload: 'x' });
expect(base).toEqual(snapshot);
});
});
describe('ACTIONS', () => {
it('exposes named action type constants', () => {
expect(ACTIONS).toEqual({
SET_PASSWORD: 'SET_PASSWORD',
SET_CONFIRM_PASSWORD: 'SET_CONFIRM_PASSWORD',
SET_LOADING: 'SET_LOADING',
HIDE_EXPLANATION: 'HIDE_EXPLANATION',
RESET: 'RESET',
});
});
});
});