Compare commits
6 Commits
master
...
promptredu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4cec9ab9c5 | ||
|
|
0cd2cc40a6 | ||
|
|
6486e9a598 | ||
|
|
aa8365aef1 | ||
|
|
8d08d8c062 | ||
|
|
77b4bc83f2 |
@ -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",
|
||||
|
||||
@ -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>();
|
||||
|
||||
|
||||
47
screen/PromptPasswordConfirmationSheet.reducer.ts
Normal file
47
screen/PromptPasswordConfirmationSheet.reducer.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
|
||||
273
tests/unit/PromptPasswordConfirmationSheet.test.tsx
Normal file
273
tests/unit/PromptPasswordConfirmationSheet.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
76
tests/unit/prompt-password-confirmation-reducer.test.ts
Normal file
76
tests/unit/prompt-password-confirmation-reducer.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user