From d0cca8121c34ef76df8f686ac4c2235e23e697fd Mon Sep 17 00:00:00 2001 From: Lukas Korba Date: Tue, 12 Apr 2022 13:12:07 +0200 Subject: [PATCH] Initialization refactored & covered by tests --- secant.xcodeproj/project.pbxproj | 12 ++ secant/Features/App/App.swift | 140 ++++++++++++------- secantTests/AppReducer/AppReducerTests.swift | 118 ++++++++++++++++ 3 files changed, 220 insertions(+), 50 deletions(-) create mode 100644 secantTests/AppReducer/AppReducerTests.swift diff --git a/secant.xcodeproj/project.pbxproj b/secant.xcodeproj/project.pbxproj index 7f273abb..cb6f7ae1 100644 --- a/secant.xcodeproj/project.pbxproj +++ b/secant.xcodeproj/project.pbxproj @@ -102,6 +102,7 @@ 9E80B47227E4B34B008FF493 /* UserPreferencesStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */; }; 9EAFEB84280597B700199FC9 /* WrappedSecItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAFEB83280597B700199FC9 /* WrappedSecItem.swift */; }; 9EAFEB862805A23100199FC9 /* WrappedSecItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAFEB852805A23100199FC9 /* WrappedSecItemTests.swift */; }; + 9EAFEB822805793200199FC9 /* AppReducerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EAFEB812805793200199FC9 /* AppReducerTests.swift */; }; 9EBEF87A27CE369800B4F343 /* RecoveryPhraseTestPreambleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EBEF87927CE369800B4F343 /* RecoveryPhraseTestPreambleView.swift */; }; 9ECAE56827FC713C0089A0EF /* DatabaseFiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ECAE56727FC713C0089A0EF /* DatabaseFiles.swift */; }; 9EF8135C27ECC25E0075AF48 /* WalletStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EF8135A27ECC25E0075AF48 /* WalletStorageTests.swift */; }; @@ -256,6 +257,7 @@ 9E80B47127E4B34B008FF493 /* UserPreferencesStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPreferencesStorage.swift; sourceTree = ""; }; 9EAFEB83280597B700199FC9 /* WrappedSecItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedSecItem.swift; sourceTree = ""; }; 9EAFEB852805A23100199FC9 /* WrappedSecItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WrappedSecItemTests.swift; sourceTree = ""; }; + 9EAFEB812805793200199FC9 /* AppReducerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReducerTests.swift; sourceTree = ""; }; 9EBEF87927CE369800B4F343 /* RecoveryPhraseTestPreambleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RecoveryPhraseTestPreambleView.swift; sourceTree = ""; }; 9ECAE56727FC713C0089A0EF /* DatabaseFiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseFiles.swift; sourceTree = ""; }; 9EF8135A27ECC25E0075AF48 /* WalletStorageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletStorageTests.swift; sourceTree = ""; }; @@ -451,6 +453,7 @@ 0D4E7A1926B364180058B01E /* secantTests */ = { isa = PBXGroup; children = ( + 9EAFEB802805791400199FC9 /* AppReducer */, 9EF8135927ECC25E0075AF48 /* Util */, 0DFE93E4272CB6D0000FCCA5 /* RecoveryPhraseValidationTests */, 0DFE93DD272C6D4B000FCCA5 /* BackupFlowTests */, @@ -754,6 +757,14 @@ path = Views; sourceTree = ""; }; + 9EAFEB802805791400199FC9 /* AppReducer */ = { + isa = PBXGroup; + children = ( + 9EAFEB812805793200199FC9 /* AppReducerTests.swift */, + ); + path = AppReducer; + sourceTree = ""; + }; 9EBEF87827CE365D00B4F343 /* Preamble */ = { isa = PBXGroup; children = ( @@ -1242,6 +1253,7 @@ 6654C7442715A4AC00901167 /* OnboardingStoreTests.swift in Sources */, 9EAFEB862805A23100199FC9 /* WrappedSecItemTests.swift in Sources */, 0D1C1AA327611EFD0004AF6A /* RecoveryPhraseDisplayReducerTests.swift in Sources */, + 9EAFEB822805793200199FC9 /* AppReducerTests.swift in Sources */, 0D4E7A1B26B364180058B01E /* secantTests.swift in Sources */, 0DFE93E6272CB6F7000FCCA5 /* RecoveryPhraseValidationTests.swift in Sources */, 9EF8135C27ECC25E0075AF48 /* WalletStorageTests.swift in Sources */, diff --git a/secant/Features/App/App.swift b/secant/Features/App/App.swift index a1866353..12e1219e 100644 --- a/secant/Features/App/App.swift +++ b/secant/Features/App/App.swift @@ -30,6 +30,7 @@ enum AppAction: Equatable { case onboarding(OnboardingAction) case phraseDisplay(RecoveryPhraseDisplayAction) case phraseValidation(RecoveryPhraseValidationAction) + case respondToWalletInitializationState(InitializationState) case updateRoute(AppState.Route) case welcome(WelcomeAction) } @@ -78,59 +79,33 @@ extension AppReducer { private static let appReducer = AppReducer { state, action, environment in switch action { - case .createNewWallet: - let randomPhraseWords: [String] - do { - let randomPhrase = try environment.mnemonicSeedPhraseProvider.randomMnemonic() - randomPhraseWords = try environment.mnemonicSeedPhraseProvider.asWords(randomPhrase) - // TODO: - Get birthday from the integrated SDK, issue 228 (https://github.com/zcash/secant-ios-wallet/issues/228) - let birthday = BlockHeight(12345678) - - try environment.walletStorage.importWallet(randomPhrase, birthday, .english, false) - } catch { - // TODO: - merge with issue 201 (https://github.com/zcash/secant-ios-wallet/issues/201) and its Error States - return .none - } + case .appDelegate(.didFinishLaunching): + /// We need to fetch data from keychain, in order to be 100% sure the kecyhain can be read we delay the check a bit + return Effect(value: .checkWalletInitialization) + .delay(for: 0.02, scheduler: environment.scheduler) + .eraseToEffect() - let recoveryPhrase = RecoveryPhrase(words: randomPhraseWords) - state.phraseDisplayState.phrase = recoveryPhrase - state.phraseValidationState = RecoveryPhraseValidationState.random(phrase: recoveryPhrase) - - return Effect(value: .phraseValidation(.displayBackedUpPhrase)) - - /// Checking presense of stored wallet in the keychain and presense of database files in documents directory. + /// Evaluate the wallet's state based on keychain keys and database files presence case .checkWalletInitialization: - var keysPresent = false - do { - // TODO: replace the hardcoded network with the environmental value, issue 239 (https://github.com/zcash/secant-ios-wallet/issues/239) - keysPresent = try environment.walletStorage.areKeysPresent() - let databaseFilesPresent = try environment.databaseFiles.areDbFilesPresentFor("mainnet") - - switch (keysPresent, databaseFilesPresent) { - case (false, false): - state.appInitializationState = .uninitialized - case (false, true): - state.appInitializationState = .keysMissing - // TODO: error we need to handle, issue #221 (https://github.com/zcash/secant-ios-wallet/issues/221) - case (true, false), (true, true): - return Effect(value: .initializeApp) - } - } catch DatabaseFiles.DatabaseFilesError.filesPresentCheck { - if keysPresent { - /// This state is not an error as long as wallet keys are present. The process to initialize the missing files is handled by `.initializeApp` action. - state.appInitializationState = .filesMissing - return Effect(value: .initializeApp) - } else { - state.appInitializationState = .uninitialized - } - } catch WalletStorage.WalletStorageError.uninitializedWallet { - state.appInitializationState = .uninitialized - } catch { - state.appInitializationState = .failed - // TODO: error we need to handle, issue #221 (https://github.com/zcash/secant-ios-wallet/issues/221) - } + let walletState = walletInitializationState(environment) + return Effect(value: .respondToWalletInitializationState(walletState)) - if state.appInitializationState == .uninitialized { + /// Respond to all possible states of the wallet and initiate appropriate side effects including errors handling + case .respondToWalletInitializationState(let walletState): + switch walletState { + case .failed: + // TODO: error we need to handle, issue #221 (https://github.com/zcash/secant-ios-wallet/issues/221) + state.appInitializationState = .failed + case .initialized: + return Effect(value: .initializeApp) + case .keysMissing: + // TODO: error we need to handle, issue #221 (https://github.com/zcash/secant-ios-wallet/issues/221) + state.appInitializationState = .keysMissing + case .filesMissing: + state.appInitializationState = .filesMissing + return Effect(value: .initializeApp) + case .uninitialized: + state.appInitializationState = .uninitialized return Effect(value: .updateRoute(.onboarding)) .delay(for: 3, scheduler: environment.scheduler) .eraseToEffect() @@ -179,6 +154,30 @@ extension AppReducer { .eraseToEffect() .cancellable(id: ListenerId(), cancelInFlight: true) + case .createNewWallet: + do { + // get the random english mnemonic + let randomPhrase = try environment.mnemonicSeedPhraseProvider.randomMnemonic() + let randomPhraseWords = try environment.mnemonicSeedPhraseProvider.asWords(randomPhrase) + // TODO: - Get birthday from the integrated SDK, issue 228 (https://github.com/zcash/secant-ios-wallet/issues/228) + // get the latest block height + let birthday = BlockHeight(12345678) + + // store the wallet to the keychain + try environment.walletStorage.importWallet(randomPhrase, birthday, .english, false) + + // start the backup phrase validation test + let recoveryPhrase = RecoveryPhrase(words: randomPhraseWords) + state.phraseDisplayState.phrase = recoveryPhrase + state.phraseValidationState = RecoveryPhraseValidationState.random(phrase: recoveryPhrase) + + return Effect(value: .phraseValidation(.displayBackedUpPhrase)) + } catch { + // TODO: - merge with issue 201 (https://github.com/zcash/secant-ios-wallet/issues/201) and its Error States + } + + return .none + case .phraseValidation(.succeed): do { try environment.walletStorage.markUserPassedPhraseBackupTest() @@ -278,6 +277,47 @@ extension AppReducer { ) } +// MARK: - AppReducer Helper Functions + +extension AppReducer { + static func walletInitializationState(_ environment: AppEnvironment) -> InitializationState { + var keysPresent = false + do { + keysPresent = try environment.walletStorage.areKeysPresent() + // TODO: replace the hardcoded network with the environmental value, issue 239 (https://github.com/zcash/secant-ios-wallet/issues/239) + let databaseFilesPresent = try environment.databaseFiles.areDbFilesPresentFor("mainnet") + + switch (keysPresent, databaseFilesPresent) { + case (false, false): + return .uninitialized + case (false, true): + return .keysMissing + case (true, false): + return .filesMissing + case (true, true): + return .initialized + } + } catch DatabaseFiles.DatabaseFilesError.filesPresentCheck { + if keysPresent { + return .filesMissing + } + } catch WalletStorage.WalletStorageError.uninitializedWallet { + do { + // TODO: replace the hardcoded network with the environmental value, issue 239 (https://github.com/zcash/secant-ios-wallet/issues/239) + _ = try environment.databaseFiles.areDbFilesPresentFor("mainnet") + + return .keysMissing + } catch { + return .uninitialized + } + } catch { + return .failed + } + + return .uninitialized + } +} + // MARK: - AppStore typealias AppStore = Store diff --git a/secantTests/AppReducer/AppReducerTests.swift b/secantTests/AppReducer/AppReducerTests.swift new file mode 100644 index 00000000..c0c6da8d --- /dev/null +++ b/secantTests/AppReducer/AppReducerTests.swift @@ -0,0 +1,118 @@ +// +// AppReducerTests.swift +// secantTests +// +// Created by Lukáš Korba on 12.04.2022. +// + +import XCTest +@testable import secant_testnet +import ComposableArchitecture + +class AppReducerTests: XCTestCase { + static let testScheduler = DispatchQueue.test + + let testEnvironment = AppEnvironment( + databaseFiles: .throwing, + scheduler: testScheduler.eraseToAnyScheduler(), + mnemonicSeedPhraseProvider: .mock, + walletStorage: .throwing + ) + + func testWalletInitializationState_Uninitialized() throws { + let uninitializedEnvironment = AppEnvironment( + databaseFiles: .throwing, + scheduler: DispatchQueue.test.eraseToAnyScheduler(), + mnemonicSeedPhraseProvider: .mock, + walletStorage: .throwing + ) + + let walletState = AppReducer.walletInitializationState(uninitializedEnvironment) + + XCTAssertEqual(walletState, .uninitialized) + } + + func testWalletInitializationState_KeysMissing() throws { + let wfmMock = WrappedFileManager( + url: { _, _, _, _ in URL(fileURLWithPath: "") }, + fileExists: { _ in return true }, + removeItem: { _ in } + ) + + let keysMissingEnvironment = AppEnvironment( + databaseFiles: .live(databaseFiles: DatabaseFiles(fileManager: wfmMock)), + scheduler: Self.testScheduler.eraseToAnyScheduler(), + mnemonicSeedPhraseProvider: .mock, + walletStorage: .throwing + ) + + let walletState = AppReducer.walletInitializationState(keysMissingEnvironment) + + XCTAssertEqual(walletState, .keysMissing) + } + + // TODO: - Implement testWalletInitializationState_FilesMissing when WalletStorage mock is available, issue 231 (https://github.com/zcash/secant-ios-wallet/issues/231) + + // TODO: - Implement testWalletInitializationState_Initialized when WalletStorage mock is available, issue 231 (https://github.com/zcash/secant-ios-wallet/issues/231) + + func testRespondToWalletInitializationState_Uninitialized() throws { + let store = TestStore( + initialState: .placeholder, + reducer: AppReducer.default, + environment: testEnvironment + ) + + store.send(.respondToWalletInitializationState(.uninitialized)) + + Self.testScheduler.advance(by: 3) + + store.receive(.updateRoute(.onboarding)) { + $0.route = .onboarding + $0.appInitializationState = .uninitialized + } + } + + func testRespondToWalletInitializationState_KeysMissing() throws { + let store = TestStore( + initialState: .placeholder, + reducer: AppReducer.default, + environment: testEnvironment + ) + + store.send(.respondToWalletInitializationState(.keysMissing)) { state in + state.appInitializationState = .keysMissing + } + } + + func testRespondToWalletInitializationState_FilesMissing() throws { + let store = TestStore( + initialState: .placeholder, + reducer: AppReducer.default, + environment: testEnvironment + ) + + store.send(.respondToWalletInitializationState(.filesMissing)) { state in + state.appInitializationState = .filesMissing + } + + store.receive(.initializeApp) { state in + // failed is expected because environment is throwing errors + state.appInitializationState = .failed + } + } + + func testRespondToWalletInitializationState_Initialized() throws { + let store = TestStore( + initialState: .placeholder, + reducer: AppReducer.default, + environment: testEnvironment + ) + + store.send(.respondToWalletInitializationState(.initialized)) + + store.receive(.initializeApp) { state in + // failed is expected because environment is throwing errors + state.appInitializationState = .failed + } + } +}