Handle invalid PIN input in the settings flow. (#1972)

This commit is contained in:
Doug
2023-10-27 14:08:06 +01:00
committed by GitHub
parent acfc564490
commit 84a16091b2
19 changed files with 209 additions and 39 deletions
@@ -447,6 +447,8 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationCoordinatorDelegate,
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false)) stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: false))
case .clearCache: case .clearCache:
stateMachine.processEvent(.clearCache) stateMachine.processEvent(.clearCache)
case .forceLogout:
stateMachine.processEvent(.signOut(isSoft: false, disableAppLock: true))
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -19,7 +19,10 @@ import SwiftState
import SwiftUI import SwiftUI
enum AppLockSetupFlowCoordinatorAction: Equatable { enum AppLockSetupFlowCoordinatorAction: Equatable {
/// The flow is complete.
case complete case complete
/// The user failed to remember their existing PIN.
case forceLogout
} }
/// Coordinates the display of any screens used to configure the App Lock feature. /// Coordinates the display of any screens used to configure the App Lock feature.
@@ -49,8 +52,10 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
case biometricsPrompt case biometricsPrompt
/// The settings screen. /// The settings screen.
case settings case settings
/// The flow is finished. /// The flow is finished. This is a final state.
case complete case complete
/// The user is being signed out. This is a final state.
case loggingOut
} }
/// Events that can be triggered on the flow state machine /// Events that can be triggered on the flow state machine
@@ -63,8 +68,12 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
case biometricsSet case biometricsSet
/// The user wants to change their PIN. /// The user wants to change their PIN.
case changePIN case changePIN
/// The user wants to dismiss the flow. /// The user has disabled the app lock feature.
case dismiss case appLockDisabled
/// The user wants to cancel the flow.
case cancel
/// The user failed to remember their existing PIN.
case forceLogout
} }
private let stateMachine: StateMachine<State, Event> private let stateMachine: StateMachine<State, Event>
@@ -108,17 +117,23 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
return appLockService.isEnabled ? .unlock : .createPIN return appLockService.isEnabled ? .unlock : .createPIN
case (.pinEntered, .unlock): case (.pinEntered, .unlock):
return .settings return .settings
case (.cancel, .unlock):
return .complete
case (.forceLogout, .unlock):
return .loggingOut
case (.pinEntered, .createPIN): case (.pinEntered, .createPIN):
if presentingFlow == .authentication { if presentingFlow == .authentication {
return appLockService.biometryType != .none ? .biometricsPrompt : .complete return appLockService.biometryType != .none ? .biometricsPrompt : .complete
} else { } else {
return appLockService.biometricUnlockEnabled || appLockService.biometryType == .none ? .settings : .biometricsPrompt return appLockService.biometricUnlockEnabled || appLockService.biometryType == .none ? .settings : .biometricsPrompt
} }
case (.cancel, .createPIN):
return .complete
case (.biometricsSet, .biometricsPrompt): case (.biometricsSet, .biometricsPrompt):
return presentingFlow == .settings ? .settings : .complete return presentingFlow == .settings ? .settings : .complete
case (.changePIN, .settings): case (.changePIN, .settings):
return .createPIN return .createPIN
case (.dismiss, _): case (.appLockDisabled, .settings):
return .complete return .complete
default: default:
return nil return nil
@@ -149,6 +164,8 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
showCreatePIN() showCreatePIN()
case (_, .complete): case (_, .complete):
complete(from: context.fromState) complete(from: context.fromState)
case (.unlock, .loggingOut):
actionsSubject.send(.forceLogout)
default: default:
fatalError("Unhandled transition.") fatalError("Unhandled transition.")
} }
@@ -170,10 +187,12 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
coordinator.actions.sink { [weak self] action in coordinator.actions.sink { [weak self] action in
guard let self else { return } guard let self else { return }
switch action { switch action {
case .cancel:
stateMachine.tryEvent(.dismiss)
case .complete: case .complete:
stateMachine.tryEvent(.pinEntered) stateMachine.tryEvent(.pinEntered)
case .cancel:
stateMachine.tryEvent(.cancel)
case .forceLogout:
fatalError("Creating a PIN can't force a logout.")
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -211,10 +230,12 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
coordinator.actions.sink { [weak self] action in coordinator.actions.sink { [weak self] action in
guard let self else { return } guard let self else { return }
switch action { switch action {
case .cancel:
stateMachine.tryEvent(.dismiss)
case .complete: case .complete:
stateMachine.tryEvent(.pinEntered) stateMachine.tryEvent(.pinEntered)
case .cancel:
stateMachine.tryEvent(.cancel)
case .forceLogout:
stateMachine.tryEvent(.forceLogout)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -231,7 +252,7 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
case .changePINCode: case .changePINCode:
stateMachine.tryEvent(.changePIN) stateMachine.tryEvent(.changePIN)
case .appLockDisabled: case .appLockDisabled:
stateMachine.tryEvent(.dismiss) stateMachine.tryEvent(.appLockDisabled)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -245,7 +266,7 @@ class AppLockSetupFlowCoordinator: FlowCoordinatorProtocol {
/// Tear down the flow for completion. /// Tear down the flow for completion.
private func complete(from state: State) { private func complete(from state: State) {
switch state { switch state {
case .initial, .complete: fatalError() case .initial, .complete, .loggingOut: fatalError()
case .unlock: case .unlock:
navigationStackCoordinator.setSheetCoordinator(nil) navigationStackCoordinator.setSheetCoordinator(nil)
actionsSubject.send(.complete) actionsSubject.send(.complete)
@@ -22,6 +22,8 @@ enum SettingsFlowCoordinatorAction {
case dismissedSettings case dismissedSettings
case runLogoutFlow case runLogoutFlow
case clearCache case clearCache
/// Logout without a confirmation. The user forgot their PIN.
case forceLogout
} }
struct SettingsFlowCoordinatorParameters { struct SettingsFlowCoordinatorParameters {
@@ -107,6 +109,8 @@ class SettingsFlowCoordinator: FlowCoordinatorProtocol {
actionsSubject.send(.clearCache) actionsSubject.send(.clearCache)
case .secureBackup: case .secureBackup:
presentSecureBackupScreen(animated: true) presentSecureBackupScreen(animated: true)
case .forceLogout:
actionsSubject.send(.forceLogout)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -20,6 +20,8 @@ import SwiftUI
enum UserSessionFlowCoordinatorAction { enum UserSessionFlowCoordinatorAction {
case logout case logout
case clearCache case clearCache
/// Logout without a confirmation. The user forgot their PIN.
case forceLogout
} }
class UserSessionFlowCoordinator: FlowCoordinatorProtocol { class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
@@ -116,6 +118,8 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
runLogoutFlow() runLogoutFlow()
case .clearCache: case .clearCache:
actionsSubject.send(.clearCache) actionsSubject.send(.clearCache)
case .forceLogout:
actionsSubject.send(.forceLogout)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -24,7 +24,8 @@ enum AppLockScreenViewModelAction {
} }
struct AppLockScreenViewState: BindableState { struct AppLockScreenViewState: BindableState {
private let maximumAttempts = 3 /// The number of attempts allowed to unlock the app.
let maximumAttempts = 3
/// The number of times the user attempted to enter their PIN. /// The number of times the user attempted to enter their PIN.
var numberOfPINAttempts = 0 var numberOfPINAttempts = 0
@@ -78,7 +78,7 @@ class AppLockScreenViewModel: AppLockScreenViewModelType, AppLockScreenViewModel
} }
private func showForceLogoutAlertIfNeeded() { private func showForceLogoutAlertIfNeeded() {
if state.numberOfPINAttempts >= 3 { if state.numberOfPINAttempts >= state.maximumAttempts {
state.bindings.alertInfo = .init(id: .forcedLogout, state.bindings.alertInfo = .init(id: .forcedLogout,
title: L10n.screenAppLockSignoutAlertTitle, title: L10n.screenAppLockSignoutAlertTitle,
message: L10n.screenAppLockSignoutAlertMessage, message: L10n.screenAppLockSignoutAlertMessage,
@@ -27,10 +27,12 @@ struct AppLockSetupPINScreenCoordinatorParameters {
} }
enum AppLockSetupPINScreenCoordinatorAction { enum AppLockSetupPINScreenCoordinatorAction {
/// The user cancelled PIN entry.
case cancel
/// The user succeeded PIN entry. /// The user succeeded PIN entry.
case complete case complete
/// The user cancelled PIN entry.
case cancel
/// The user failed to remember their PIN to unlock.
case forceLogout
} }
final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol { final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
@@ -62,6 +64,8 @@ final class AppLockSetupPINScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.complete) actionsSubject.send(.complete)
case .cancel: case .cancel:
actionsSubject.send(.cancel) actionsSubject.send(.cancel)
case .forceLogout:
actionsSubject.send(.forceLogout)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -21,6 +21,8 @@ enum AppLockSetupPINScreenViewModelAction {
case complete case complete
/// The user cancelled PIN entry. /// The user cancelled PIN entry.
case cancel case cancel
/// The user failed to remember their PIN to unlock.
case forceLogout
} }
enum AppLockSetupPINScreenMode { enum AppLockSetupPINScreenMode {
@@ -33,10 +35,19 @@ enum AppLockSetupPINScreenMode {
} }
struct AppLockSetupPINScreenViewState: BindableState { struct AppLockSetupPINScreenViewState: BindableState {
/// The number of attempts allowed to enter the PIN.
let maximumAttempts = 3
/// The current mode that the screen is in. /// The current mode that the screen is in.
var mode: AppLockSetupPINScreenMode var mode: AppLockSetupPINScreenMode
/// Whether the screen is mandatory or can be cancelled. /// Whether the screen is mandatory or can be cancelled.
let isMandatory: Bool let isMandatory: Bool
/// The number of attempts the user has made in the `confirm` mode.
var numberOfConfirmAttempts = 0
/// The number of attempts the user has made in the `unlock` mode.
var numberOfUnlockAttempts = 0
/// The user failed to unlock the app (or forgot their PIN) and the log out is in progress.
var isLoggingOut = false
var title: String { var title: String {
switch mode { switch mode {
@@ -46,10 +57,14 @@ struct AppLockSetupPINScreenViewState: BindableState {
} }
} }
var subtitle: String? { /// Whether the subtitle is in a warning state or not.
switch mode { var isSubtitleWarning: Bool { mode == .unlock && numberOfUnlockAttempts > 0 }
case .create, .confirm: return L10n.screenAppLockSetupPinContext(InfoPlistReader.main.bundleDisplayName) var subtitle: String {
case .unlock: return nil guard mode == .unlock else { return L10n.screenAppLockSetupPinContext(InfoPlistReader.main.bundleDisplayName) }
if !isSubtitleWarning {
return L10n.screenAppLockSubtitle(maximumAttempts)
} else {
return L10n.screenAppLockSubtitleWrongPin(maximumAttempts - numberOfUnlockAttempts)
} }
} }
@@ -68,6 +83,8 @@ enum AppLockSetupPINScreenAlertType {
case pinMismatch case pinMismatch
/// An error occurred setting the PIN code in the App Lock service. /// An error occurred setting the PIN code in the App Lock service.
case failedToSetPIN case failedToSetPIN
/// The user failed to unlock the app (or forgot their PIN).
case forceLogout
} }
enum AppLockSetupPINScreenViewAction { enum AppLockSetupPINScreenViewAction {
@@ -32,7 +32,12 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
init(initialMode: AppLockSetupPINScreenMode, isMandatory: Bool, appLockService: AppLockServiceProtocol) { init(initialMode: AppLockSetupPINScreenMode, isMandatory: Bool, appLockService: AppLockServiceProtocol) {
self.appLockService = appLockService self.appLockService = appLockService
super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, isMandatory: isMandatory, bindings: .init(pinCode: ""))) super.init(initialViewState: AppLockSetupPINScreenViewState(mode: initialMode, isMandatory: isMandatory, bindings: .init(pinCode: "")))
appLockService.numberOfPINAttempts
.weakAssign(to: \.state.numberOfUnlockAttempts, on: self)
.store(in: &cancellables)
} }
// MARK: - Public // MARK: - Public
@@ -74,6 +79,7 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
newPIN = pinCode newPIN = pinCode
state.mode = .confirm state.mode = .confirm
state.bindings.pinCode = "" state.bindings.pinCode = ""
state.numberOfConfirmAttempts = 0
} }
/// Handles a PIN input from the confirm mode. Stores the pin if it matches. /// Handles a PIN input from the confirm mode. Stores the pin if it matches.
@@ -102,9 +108,10 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
/// Handles a PIN input for the unlock mode. /// Handles a PIN input for the unlock mode.
private func unlock() { private func unlock() {
guard appLockService.unlock(with: state.bindings.pinCode) else { guard appLockService.unlock(with: state.bindings.pinCode) else {
// show an error
// https://www.figma.com/file/0MMNu7cTOzLOlWb7ctTkv3?node-id=13067:107631&mode=dev#591068578
state.bindings.pinCode = "" state.bindings.pinCode = ""
if state.numberOfUnlockAttempts >= state.maximumAttempts {
handleError(.forceLogout)
}
return return
} }
@@ -119,12 +126,29 @@ class AppLockSetupPINScreenViewModel: AppLockSetupPINScreenViewModelType, AppLoc
message: L10n.screenAppLockSetupPinBlacklistedDialogContent, message: L10n.screenAppLockSetupPinBlacklistedDialogContent,
primaryButton: .init(title: L10n.actionOk) { self.state.bindings.pinCode = "" }) primaryButton: .init(title: L10n.actionOk) { self.state.bindings.pinCode = "" })
case .pinMismatch: case .pinMismatch:
state.numberOfConfirmAttempts += 1
state.bindings.alertInfo = .init(id: error, state.bindings.alertInfo = .init(id: error,
title: L10n.screenAppLockSetupPinMismatchDialogTitle, title: L10n.screenAppLockSetupPinMismatchDialogTitle,
message: L10n.screenAppLockSetupPinMismatchDialogContent, message: L10n.screenAppLockSetupPinMismatchDialogContent,
primaryButton: .init(title: L10n.actionTryAgain) { self.state.bindings.pinCode = "" }) primaryButton: .init(title: L10n.actionTryAgain) { self.restartCreateIfNeeded() })
case .failedToSetPIN: case .failedToSetPIN:
state.bindings.alertInfo = .init(id: error) state.bindings.alertInfo = .init(id: error)
case .forceLogout:
state.isLoggingOut = true // Disable the screen before showing the alert.
state.bindings.alertInfo = .init(id: error,
title: L10n.screenAppLockSignoutAlertTitle,
message: L10n.screenAppLockSignoutAlertMessage,
primaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.forceLogout) })
}
}
private func restartCreateIfNeeded() {
state.bindings.pinCode = ""
if state.numberOfConfirmAttempts >= state.maximumAttempts {
newPIN = ""
state.mode = .create
state.numberOfConfirmAttempts = 0
} }
} }
} }
@@ -23,9 +23,21 @@ struct AppLockSetupPINScreen: View {
@FocusState private var textFieldFocus @FocusState private var textFieldFocus
var stackSpacing: CGFloat {
context.viewState.mode == .unlock ? 36 : 40
}
var subtitleColor: Color {
context.viewState.isSubtitleWarning ? .compound.textCriticalPrimary : .compound.textSecondary
}
var interactiveDismissDisabled: Bool {
context.viewState.isMandatory || context.viewState.isLoggingOut
}
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(spacing: 48) { VStack(spacing: stackSpacing) {
header header
PINTextField(pinCode: $context.pinCode, PINTextField(pinCode: $context.pinCode,
@@ -44,7 +56,8 @@ struct AppLockSetupPINScreen: View {
.toolbar { toolbar } .toolbar { toolbar }
.toolbar(.visible, for: .navigationBar) .toolbar(.visible, for: .navigationBar)
.navigationBarBackButtonHidden() .navigationBarBackButtonHidden()
.interactiveDismissDisabled(context.viewState.isMandatory) .interactiveDismissDisabled(interactiveDismissDisabled)
.disabled(context.viewState.isLoggingOut)
.alert(item: $context.alertInfo) .alert(item: $context.alertInfo)
.onAppear { textFieldFocus = true } .onAppear { textFieldFocus = true }
} }
@@ -60,12 +73,10 @@ struct AppLockSetupPINScreen: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(.compound.textPrimary) .foregroundColor(.compound.textPrimary)
if let subtitle = context.viewState.subtitle { Text(context.viewState.subtitle)
Text(subtitle)
.font(.compound.bodyMD) .font(.compound.bodyMD)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(.compound.textSecondary) .foregroundColor(subtitleColor)
}
} }
} }
@@ -84,8 +95,9 @@ struct AppLockSetupPINScreen: View {
// MARK: - Previews // MARK: - Previews
struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview { struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
static let service = AppLockService(keychainController: KeychainControllerMock(), static let service = AppLockServiceMock.mock()
appSettings: ServiceLocator.shared.settings) static let failedService = AppLockServiceMock.mock(numberOfPINAttempts: 1)
static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create, static let createViewModel = AppLockSetupPINScreenViewModel(initialMode: .create,
isMandatory: false, isMandatory: false,
appLockService: service) appLockService: service)
@@ -95,6 +107,9 @@ struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, static let unlockViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock,
isMandatory: false, isMandatory: false,
appLockService: service) appLockService: service)
static let unlockFailedViewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock,
isMandatory: false,
appLockService: failedService)
static var previews: some View { static var previews: some View {
NavigationStack { NavigationStack {
@@ -111,5 +126,10 @@ struct AppLockSetupPINScreen_Previews: PreviewProvider, TestablePreview {
AppLockSetupPINScreen(context: unlockViewModel.context) AppLockSetupPINScreen(context: unlockViewModel.context)
} }
.previewDisplayName("Unlock") .previewDisplayName("Unlock")
NavigationStack {
AppLockSetupPINScreen(context: unlockFailedViewModel.context)
}
.previewDisplayName("Unlock Failed")
} }
} }
@@ -264,6 +264,8 @@ class AuthenticationCoordinator: CoordinatorProtocol {
} else { } else {
delegate?.authenticationCoordinator(self, didLoginWithSession: userSession) delegate?.authenticationCoordinator(self, didLoginWithSession: userSession)
} }
case .forceLogout:
fatalError("The PIN creation flow should not fail.")
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -33,6 +33,8 @@ enum SettingsScreenCoordinatorAction {
case logout case logout
case clearCache case clearCache
case secureBackup case secureBackup
/// Logout without a confirmation. The user forgot their PIN.
case forceLogout
} }
final class SettingsScreenCoordinator: CoordinatorProtocol { final class SettingsScreenCoordinator: CoordinatorProtocol {
@@ -163,6 +165,8 @@ final class SettingsScreenCoordinator: CoordinatorProtocol {
case .complete: case .complete:
// The flow coordinator tidies up the stack, no need to do anything. // The flow coordinator tidies up the stack, no need to do anything.
appLockSetupFlowCoordinator = nil appLockSetupFlowCoordinator = nil
case .forceLogout:
actionsSubject.send(.forceLogout)
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
@@ -79,11 +79,11 @@ protocol AppLockServiceProtocol: AnyObject {
extension AppLockServiceProtocol { } extension AppLockServiceProtocol { }
extension AppLockServiceMock { extension AppLockServiceMock {
static func mock(pinCode: String? = "2023", isMandatory: Bool = false, biometryType: LABiometryType = .faceID) -> AppLockServiceMock { static func mock(pinCode: String? = "2023", isMandatory: Bool = false, biometryType: LABiometryType = .faceID, numberOfPINAttempts: Int = 0) -> AppLockServiceMock {
let mock = AppLockServiceMock() let mock = AppLockServiceMock()
mock.isEnabled = pinCode != nil mock.isEnabled = pinCode != nil
mock.isMandatory = isMandatory mock.isMandatory = isMandatory
mock.numberOfPINAttempts = PassthroughSubject<Int, Never>().eraseToAnyPublisher() mock.numberOfPINAttempts = CurrentValueSubject<Int, Never>(numberOfPINAttempts).eraseToAnyPublisher()
mock.underlyingBiometryType = biometryType mock.underlyingBiometryType = biometryType
mock.underlyingBiometricUnlockEnabled = biometryType != .none mock.underlyingBiometricUnlockEnabled = biometryType != .none
mock.unlockWithClosure = { $0 == pinCode } mock.unlockWithClosure = { $0 == pinCode }
@@ -37,34 +37,45 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
} }
func testCreatePIN() async throws { func testCreatePIN() async throws {
// Given the screen in create mode.
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService) viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.") XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
// When entering an new PIN.
let createDeferred = deferFulfillment(context.$viewState, message: "A valid PIN needs confirming.") { $0.mode == .confirm } let createDeferred = deferFulfillment(context.$viewState, message: "A valid PIN needs confirming.") { $0.mode == .confirm }
context.pinCode = "2023" context.pinCode = "2023"
context.send(viewAction: .submitPINCode) context.send(viewAction: .submitPINCode)
try await createDeferred.fulfill() try await createDeferred.fulfill()
// Then the screen should transition to the confirm mode.
XCTAssertEqual(context.viewState.mode, .confirm, "The mode should transition to confirmation.") XCTAssertEqual(context.viewState.mode, .confirm, "The mode should transition to confirmation.")
// When re-entering that PIN.
let confirmDeferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete } let confirmDeferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
context.pinCode = "2023" context.pinCode = "2023"
context.send(viewAction: .submitPINCode) context.send(viewAction: .submitPINCode)
// Then the screen should signal it is complete.
try await confirmDeferred.fulfill() try await confirmDeferred.fulfill()
} }
func testCreateWeakPIN() async throws { func testCreateWeakPIN() async throws {
// Given the screen in create mode.
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService) viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.") XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.") XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.")
// When entering a weak PIN on the blocklist.
context.pinCode = "0000" context.pinCode = "0000"
context.send(viewAction: .submitPINCode) context.send(viewAction: .submitPINCode)
// Then the PIN should be rejected and the user alerted.
XCTAssertEqual(context.alertInfo?.id, .weakPIN, "The weak PIN should be rejected.") XCTAssertEqual(context.alertInfo?.id, .weakPIN, "The weak PIN should be rejected.")
XCTAssertEqual(context.viewState.mode, .create, "The mode shouldn't transition after an invalid PIN code.") XCTAssertEqual(context.viewState.mode, .create, "The mode shouldn't transition after an invalid PIN code.")
} }
func testCreatePINMismatch() async throws { func testCreatePINMismatch() async throws {
// Given the confirm mode after entering a new PIN.
viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService) viewModel = AppLockSetupPINScreenViewModel(initialMode: .create, isMandatory: false, appLockService: appLockService)
XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.") XCTAssertEqual(context.viewState.mode, .create, "The mode should start as creation.")
XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.") XCTAssertNil(context.alertInfo, "There shouldn't be an alert to begin with.")
@@ -74,24 +85,76 @@ class AppLockSetupPINScreenViewModelTests: XCTestCase {
context.send(viewAction: .submitPINCode) context.send(viewAction: .submitPINCode)
try await createDeferred.fulfill() try await createDeferred.fulfill()
XCTAssertEqual(context.viewState.mode, .confirm, "The mode should transition to confirmation.") XCTAssertEqual(context.viewState.mode, .confirm, "The mode should transition to confirmation.")
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 0, "The mode should start with zero attempts.")
XCTAssertNil(context.alertInfo, "There shouldn't be an alert after a valid initial PIN.") XCTAssertNil(context.alertInfo, "There shouldn't be an alert after a valid initial PIN.")
// When entering the new PIN incorrectly
context.pinCode = "2024" context.pinCode = "2024"
context.send(viewAction: .submitPINCode) context.send(viewAction: .submitPINCode)
// Then the user should be alerted.
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 1, "The mismatch should be counted.")
XCTAssertEqual(context.alertInfo?.id, .pinMismatch, "A PIN mismatch should be rejected.") XCTAssertEqual(context.alertInfo?.id, .pinMismatch, "A PIN mismatch should be rejected.")
// When repeating this twice more.
context.pinCode = "2024"
context.send(viewAction: .submitPINCode)
context.pinCode = "2024"
context.send(viewAction: .submitPINCode)
XCTAssertEqual(context.viewState.numberOfConfirmAttempts, 3, "All the mismatches should be counted.")
XCTAssertEqual(context.alertInfo?.id, .pinMismatch, "A PIN mismatch should be rejected.")
// Then tapping the alert button should reset back to create mode.
context.alertInfo?.primaryButton.action?()
XCTAssertEqual(context.viewState.mode, .create, "The mode should revert back to creation.")
} }
func testUnlock() async throws { func testUnlock() async throws {
// Given the screen in unlock mode.
viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, isMandatory: false, appLockService: appLockService) viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, isMandatory: false, appLockService: appLockService)
let pinCode = "2023" let pinCode = "2023"
keychainController.pinCodeReturnValue = pinCode keychainController.pinCodeReturnValue = pinCode
keychainController.containsPINCodeReturnValue = true keychainController.containsPINCodeReturnValue = true
keychainController.containsPINCodeBiometricStateReturnValue = false keychainController.containsPINCodeBiometricStateReturnValue = false
// When entering the configured PIN.
let deferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete } let deferred = deferFulfillment(viewModel.actions, message: "The screen should be finished.") { $0 == .complete }
context.pinCode = pinCode context.pinCode = pinCode
context.send(viewAction: .submitPINCode) context.send(viewAction: .submitPINCode)
// Then the screen should signal it is complete.
try await deferred.fulfill() try await deferred.fulfill()
} }
func testUnlockFailed() async throws {
// Given the screen in unlock mode.
viewModel = AppLockSetupPINScreenViewModel(initialMode: .unlock, isMandatory: false, appLockService: appLockService)
keychainController.pinCodeReturnValue = "2023"
keychainController.containsPINCodeReturnValue = true
keychainController.containsPINCodeBiometricStateReturnValue = false
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 0, "The screen should start with zero attempts.")
XCTAssertFalse(context.viewState.isSubtitleWarning, "The subtitle should start without a warning.")
XCTAssertFalse(context.viewState.isLoggingOut, "The view should not start disabled.")
// When entering a different PIN.
context.pinCode = "2024"
context.send(viewAction: .submitPINCode)
// Then the PIN should be rejected and the user notified.
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 1, "An invalid attempt should be counted.")
XCTAssertTrue(context.viewState.isSubtitleWarning, "The subtitle should then show a warning.")
XCTAssertFalse(context.viewState.isLoggingOut, "The view should still work.")
// When entering the same incorrect PIN twice more
context.pinCode = "2024"
context.send(viewAction: .submitPINCode)
context.pinCode = "2024"
context.send(viewAction: .submitPINCode)
// Then the user should be alerted that they're being signed out.
XCTAssertEqual(context.viewState.numberOfUnlockAttempts, 3, "All invalid attempts should be counted.")
XCTAssertTrue(context.viewState.isSubtitleWarning, "The subtitle should continue showing a warning.")
XCTAssertEqual(context.alertInfo?.id, .forceLogout, "An alert should be shown about a force logout.")
XCTAssertTrue(context.viewState.isLoggingOut, "The view should become disabled.")
}
} }
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3f1fa2ac91c3c5966db4e7cf34e8f4d724547d583147ac23bd392c536b0357bb oid sha256:d5ed9229cb5607a9cefca50dbee4d3ad91cf2a563c6088d388c7907184454ace
size 105733 size 105595
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a3de8520c297d0c53f500a5cbe1322d50ae1f76d7c4678ab7c7e1d243c6a8657 oid sha256:09722ed927d4708b3c6053c6e8d528f3484a908ee445569bf3e38c5f1007ff1a
size 106000 size 105859
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4c01d9a9aea76ed7793e772441d4556b76092550c40a1d4c0205d4fc5d71577f
size 85973
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:641c74d77ed0b43113fda06653a3a7eda92906a81a682cbd6b781aabcce51ab0 oid sha256:f2c675912f550b5e05abd322db817dde5a1360eaae0d678b0bc8ac5c93f75a97
size 76727 size 84354
+1
View File
@@ -0,0 +1 @@
Handle invalid PIN input in the settings flow.