Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions __tests__/components/screens/AddAnotherWallet.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { fireEvent } from "@testing-library/react-native";
import AddAnotherWalletScreen from "components/screens/AddAnotherWallet";
import { MANAGE_WALLETS_ROUTES } from "config/routes";
import { renderWithProviders } from "helpers/testUtils";
import React from "react";

const navigation = { navigate: jest.fn() };

const renderScreen = () =>
renderWithProviders(
<AddAnotherWalletScreen
navigation={navigation as never}
route={{} as never}
/>,
);

describe("AddAnotherWalletScreen", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it.each([
["add-wallet-create", MANAGE_WALLETS_ROUTES.CREATE_WALLET_SCREEN],
[
"add-wallet-import-phrase",
MANAGE_WALLETS_ROUTES.IMPORT_RECOVERY_PHRASE_SCREEN,
],
[
"add-wallet-import-secret-key",
MANAGE_WALLETS_ROUTES.IMPORT_SECRET_KEY_SCREEN,
],
])("%s opens %s", (testID, route) => {
const { getByTestId } = renderScreen();

fireEvent.press(getByTestId(testID));

expect(navigation.navigate).toHaveBeenCalledWith(route);
});

it("offers only the ways mobile supports", () => {
const { getByText, queryByText } = renderScreen();

expect(getByText("Add account")).toBeTruthy();
expect(getByText("Import Recovery Phrase")).toBeTruthy();
expect(queryByText("Connect Hardware Wallet")).toBeNull();
});
});
97 changes: 97 additions & 0 deletions __tests__/components/screens/BackupRecoveryPhraseScreen.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { fireEvent } from "@testing-library/react-native";
import BackupRecoveryPhraseScreen from "components/screens/BackupRecoveryPhraseScreen";
import { MANAGE_WALLETS_ROUTES } from "config/routes";
import { renderWithProviders } from "helpers/testUtils";
import React from "react";

const PHRASE =
"gloom student label strategy tattoo promote brand mushroom problem divert carbon erode";
const WORDS = PHRASE.split(" ");

jest.mock("@react-native-community/blur", () => ({ BlurView: "BlurView" }));

const mockCopyToClipboard = jest.fn();
jest.mock("hooks/useSecureClipboard", () => ({
useSecureClipboard: () => ({ copyToClipboard: mockCopyToClipboard }),
}));

const PENDING_ID = "pending-phrase-7";

let mockPendingPhrase: string | null = PHRASE;
const mockGetPending = jest.fn();
const mockClearPending = jest.fn();
jest.mock("ducks/auth", () => ({
getPendingRecoveryPhrase: (id: string) => {
mockGetPending(id);
return mockPendingPhrase;
},
clearPendingRecoveryPhrase: (id?: string) => mockClearPending(id),
// CopyPhraseButton's module (the onboarding screen) reads the store.
useAuthenticationStore: (
selector: (state: { isLoading: boolean }) => unknown,
) => selector({ isLoading: false }),
}));

const navigation = { navigate: jest.fn(), goBack: jest.fn() };

const renderScreen = () =>
renderWithProviders(
<BackupRecoveryPhraseScreen
navigation={navigation as never}
route={
{
params: { pendingPhraseId: PENDING_ID, accountName: "Travel" },
} as never
}
/>,
);

describe("BackupRecoveryPhraseScreen", () => {
beforeEach(() => {
jest.clearAllMocks();
mockPendingPhrase = PHRASE;
});

it("keeps the held phrase off the screen until the user reveals it", () => {
const { getByText, queryByText } = renderScreen();

expect(mockGetPending).toHaveBeenCalledWith(PENDING_ID);
expect(getByText("Back Up Your New Recovery Phrase")).toBeTruthy();
WORDS.forEach((word) => expect(queryByText(word)).toBeNull());
});

it("reveals the phrase, copies it securely, and goes on to the password step", () => {
const { getByTestId, getByText } = renderScreen();

fireEvent.press(getByTestId("backup-recovery-phrase-reveal"));
WORDS.forEach((word) => expect(getByText(word)).toBeTruthy());

fireEvent.press(getByTestId("copy-recovery-phrase-button"));
expect(mockCopyToClipboard).toHaveBeenCalledWith(PHRASE, {
hideNotification: true,
});

fireEvent.press(getByText("Create"));
// The phrase stays in the store, out of the params.
expect(navigation.navigate).toHaveBeenCalledWith(
MANAGE_WALLETS_ROUTES.VERIFY_PASSWORD_SCREEN,
{ kind: "newPhrase", pendingPhraseId: PENDING_ID, accountName: "Travel" },
);
});

it("drops its own generation when the screen goes away", () => {
const { unmount } = renderScreen();

unmount();

expect(mockClearPending).toHaveBeenCalledTimes(1);
expect(mockClearPending).toHaveBeenCalledWith(PENDING_ID);
});

it("goes back when its generation is no longer held", () => {
mockPendingPhrase = null;
renderScreen();

expect(navigation.goBack).toHaveBeenCalledTimes(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { act, fireEvent, waitFor, within } from "@testing-library/react-native";
import { BiometricsOnboardingScreen } from "components/screens/BiometricsEnableScreen/BiometricsEnableScreen";
import { BiometricsSource } from "config/constants";
import { useWalletSetupOverlayStore } from "ducks/walletSetupOverlay";
import {
SETTING_UP_MIN_MS,
useWalletSetupOverlayStore,
} from "ducks/walletSetupOverlay";
import { renderWithProviders } from "helpers/testUtils";
import React from "react";
import { BIOMETRY_TYPE } from "react-native-keychain";
Expand All @@ -18,20 +21,29 @@ jest.mock("services/storage/storageFactory", () => ({

const mockSignUp = jest.fn();
const mockImportWallet = jest.fn();
const mockImportWalletFromSecretKey = jest.fn();
const mockEnableBiometrics = jest.fn();
const mockStoreBiometricPassword = jest.fn();
jest.mock("ducks/auth", () => ({
useAuthenticationStore: () => ({
isLoading: false,
signUp: mockSignUp,
importWallet: mockImportWallet,
importWalletFromSecretKey: mockImportWalletFromSecretKey,
storeBiometricPassword: mockStoreBiometricPassword,
enableBiometrics: mockEnableBiometrics,
}),
}));

const mockClearLoginData = jest.fn();
const SECRET_KEY = "SBUV3MRWKNS6AYKZ6E6MOUVF2OYMON3MIUASWL3JLY5E3ISDJFELYBRZ";
let mockLoginSecret: {
mnemonicPhrase: string | null;
secretKey: string | null;
} = { mnemonicPhrase: "abandon ability able", secretKey: null };
jest.mock("ducks/loginData", () => ({
useLoginDataStore: () => ({
mnemonicPhrase: "abandon ability able",
...mockLoginSecret,
password: "password1",
clearLoginData: mockClearLoginData,
}),
Expand Down Expand Up @@ -80,10 +92,24 @@ const deferred = <T,>() => {
};

describe("BiometricsOnboardingScreen", () => {
afterEach(async () => {
await act(async () => {
await jest.runOnlyPendingTimersAsync();
});
jest.useRealTimers();
});

beforeEach(() => {
jest.clearAllMocks();
mockEnableBiometrics.mockReset();
// Wallet setup holds its title for a minimum time; fake timers keep
// that wait inside each test.
jest.useFakeTimers();
mockBiometryType = BIOMETRY_TYPE.FACE_ID;
mockLoginSecret = {
mnemonicPhrase: "abandon ability able",
secretKey: null,
};
useWalletSetupOverlayStore.setState(
useWalletSetupOverlayStore.getInitialState(),
true,
Expand Down Expand Up @@ -171,6 +197,24 @@ describe("BiometricsOnboardingScreen", () => {
expect(mockSignUp).not.toHaveBeenCalled();
});

it("imports from the secret key when Import Wallet kept one", async () => {
mockLoginSecret = { mnemonicPhrase: null, secretKey: SECRET_KEY };
mockImportWalletFromSecretKey.mockResolvedValue(true);
const { getByText } = renderScreen(BiometricsSource.IMPORT_WALLET);

fireEvent.press(getByText("Skip for now"));

await waitFor(() =>
expect(mockImportWalletFromSecretKey).toHaveBeenCalledWith({
secretKey: SECRET_KEY,
password: "password1",
}),
);
expect(mockImportWallet).not.toHaveBeenCalled();
expect(mockSignUp).not.toHaveBeenCalled();
await waitFor(() => expect(mockClearLoginData).toHaveBeenCalledTimes(1));
});

// Enable sets the wallet up with the password the biometric check hands
// back. Only a setup that succeeds may turn biometrics on and drop the
// phrase and password from memory.
Expand Down Expand Up @@ -234,6 +278,67 @@ describe("BiometricsOnboardingScreen", () => {
expect(mockClearLoginData).toHaveBeenCalledTimes(1);
});

it("imports the secret key with the password the check returns", async () => {
mockLoginSecret = { mnemonicPhrase: null, secretKey: SECRET_KEY };
passBiometricCheck("biometric-password");
mockImportWalletFromSecretKey.mockResolvedValue(true);
const { getByText } = renderScreen(BiometricsSource.IMPORT_WALLET);

fireEvent.press(getByText("Enable Face ID"));

await waitFor(() =>
expect(mockSetIsBiometricsEnabled).toHaveBeenCalledWith(true),
);
expect(mockImportWalletFromSecretKey).toHaveBeenCalledWith({
secretKey: SECRET_KEY,
password: "biometric-password",
});
expect(mockImportWallet).not.toHaveBeenCalled();
});

// A failed setup removes the biometric password, which the check reads,
// so each attempt saves it again from login data first.
it.each([BiometricsSource.ONBOARDING, BiometricsSource.IMPORT_WALLET])(
"saves the password before the biometric check (%s)",
async (source) => {
passBiometricCheck("biometric-password");
mockSignUp.mockResolvedValue(true);
mockImportWallet.mockResolvedValue(true);
const { getByText } = renderScreen(source);

fireEvent.press(getByText("Enable Face ID"));

await waitFor(() =>
expect(mockSetIsBiometricsEnabled).toHaveBeenCalledWith(true),
);
expect(mockStoreBiometricPassword).toHaveBeenCalledWith("password1");
expect(
mockStoreBiometricPassword.mock.invocationCallOrder[0],
).toBeLessThan(mockEnableBiometrics.mock.invocationCallOrder[0]);
},
);

it("saves the password again on a retry after a failed setup", async () => {
passBiometricCheck("biometric-password");
mockSignUp.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
const { getByText } = renderScreen();

fireEvent.press(getByText("Enable Face ID"));
await waitFor(() =>
expect(mockNotifySetupFailed).toHaveBeenCalledTimes(1),
);
await act(async () => {
await jest.runOnlyPendingTimersAsync();
});

fireEvent.press(getByText("Enable Face ID"));
await waitFor(() =>
expect(mockSetIsBiometricsEnabled).toHaveBeenCalledWith(true),
);
expect(mockStoreBiometricPassword).toHaveBeenCalledTimes(2);
expect(mockSignUp).toHaveBeenCalledTimes(2);
});

it("leaves biometrics off and keeps the phrase when setup fails", async () => {
passBiometricCheck("biometric-password");
mockSignUp.mockResolvedValue(false);
Expand Down Expand Up @@ -294,7 +399,7 @@ describe("BiometricsOnboardingScreen", () => {
expect(overlay().flow).toBe("create");

await settle(() => signUp.resolve(true));
expect(overlay().phase).toBe("ready");
await waitFor(() => expect(overlay().phase).toBe("ready"));
});

it("shows Importing your wallet when Skip imports", async () => {
Expand Down Expand Up @@ -377,6 +482,8 @@ describe("BiometricsOnboardingScreen", () => {
unmount();

await settle(() => signUp.resolve(true));
// The setting-up title's minimum time.
await act(() => jest.advanceTimersByTimeAsync(SETTING_UP_MIN_MS));

expect(overlay().phase).toBe("ready");
});
Expand All @@ -392,6 +499,8 @@ describe("BiometricsOnboardingScreen", () => {

expect(mockEnableBiometrics).toHaveBeenCalledTimes(1);
expect(overlay().phase).toBe("hidden");
// The wallet and its biometric password already exist after onboarding.
expect(mockStoreBiometricPassword).not.toHaveBeenCalled();
});
});
});
39 changes: 38 additions & 1 deletion __tests__/components/screens/ChoosePasswordScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,11 @@ const withKeyboardUp = () => {
describe("ChoosePasswordScreen (Set Password)", () => {
beforeEach(() => {
jest.clearAllMocks();
useLoginDataStore.setState({ password: null, mnemonicPhrase: null });
useLoginDataStore.setState({
password: null,
mnemonicPhrase: null,
secretKey: null,
});
});
afterEach(() => jest.restoreAllMocks());

Expand Down Expand Up @@ -343,6 +347,39 @@ describe("ChoosePasswordScreen (Set Password)", () => {
expect(className).not.toContain("flex-1");
});

it("names the phrase in the acknowledgement", () => {
const { getByText } = renderScreen(true);

expect(
getByText(
"I understand imported recovery phrase can't be recovered and must be stored securely.",
),
).toBeTruthy();
});

it("names the secret key in the acknowledgement and imports it", async () => {
useLoginDataStore.setState({
mnemonicPhrase: null,
secretKey: "SBUV3MRWKNS6AYKZ6E6MOUVF2OYMON3MIUASWL3JLY5E3ISDJFELYBRZ",
});
const screen = renderScreen(true);

expect(
screen.getByText(
"I understand an imported secret key can't be recovered and must be stored securely.",
),
).toBeTruthy();

fillPasswords(screen);
fireEvent.press(screen.getByTestId("import-acknowledgement-checkbox"));
await pressContinue(screen);

expect(mockGoBack).not.toHaveBeenCalled();
expect(mockFinishWalletSetup).toHaveBeenCalledWith(
BiometricsSource.IMPORT_WALLET,
);
});

it("goes back to Import Wallet if the phrase is gone", async () => {
useLoginDataStore.setState({ mnemonicPhrase: null });
const screen = renderScreen(true);
Expand Down
Loading
Loading