// // Copyright 2025 Element Creations Ltd. // Copyright 2023-2025 New Vector Ltd. // // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. // Please see LICENSE files in the repository root for full details. // @testable import ElementX import Foundation import Testing @MainActor @Suite final class AppLockServiceTests { private var keychainController: KeychainController private var appSettings: AppSettings private var service: AppLockService init() { AppSettings.resetAllSettings() appSettings = AppSettings() keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier) keychainController.resetSecrets() service = AppLockService(keychainController: keychainController, appSettings: appSettings) service.disable() } deinit { AppSettings.resetAllSettings() } // MARK: - PIN Code @Test func validPINCode() { // Given a service that hasn't been enabled. #expect(!service.isEnabled, "The service shouldn't be enabled to begin with.") // When setting a PIN code. let pinCode = "2023" // Highly secure PIN that is rotated every 12 months. guard case .success = service.setupPINCode(pinCode) else { Issue.record("The PIN should be valid.") return } // Then service should be enabled and only the provided PIN should work to unlock the app. #expect(service.isEnabled, "The service should become enabled when setting a PIN.") #expect(service.unlock(with: pinCode), "The provided PIN code should work.") #expect(!service.unlock(with: "2024"), "No other PIN code should work.") #expect(!service.unlock(with: "1234"), "No other PIN code should work.") #expect(!service.unlock(with: "9999"), "No other PIN code should work.") } @Test func weakPINCode() { // Given a service that hasn't been enabled. #expect(!service.isEnabled, "The service shouldn't be enabled to begin with.") // When setting a PIN code that is in the block list. let pinCode = appSettings.appLockPINCodeBlockList[0] let result = service.setupPINCode(pinCode) // Then the setup should fail and the service be left as disabled. guard case let .failure(error) = result else { Issue.record("The call should have failed.") return } #expect(error == .weakPIN, "The PIN should be rejected as weak.") #expect(!service.isEnabled, "The service should remain disabled.") } @Test func shortPINCode() { // Given a service that hasn't been enabled. #expect(!service.isEnabled, "The service shouldn't be enabled to begin with.") // When setting a PIN code that is too short let pinCode = "123" let result = service.setupPINCode(pinCode) // Then the setup should fail and the service be left as disabled. guard case let .failure(error) = result else { Issue.record("The call should have failed.") return } #expect(error == .invalidPIN, "The PIN should be rejected as invalid.") #expect(!service.isEnabled, "The service should remain disabled.") } @Test func nonNumericPINCode() { // Given a service that hasn't been enabled. #expect(!service.isEnabled, "The service shouldn't be enabled to begin with.") // When setting a PIN code that is too short let pinCode = "abcd" let result = service.setupPINCode(pinCode) // Then the setup should fail and the service be left as disabled. guard case let .failure(error) = result else { Issue.record("The call should have failed.") return } #expect(error == .invalidPIN, "The PIN should be rejected as invalid.") #expect(!service.isEnabled, "The service should remain disabled.") } @Test func changePINCode() { // Given a service that is already enabled with a PIN. let pinCode = "2023" let newPINCode = "2024" guard case .success = service.setupPINCode(pinCode) else { Issue.record("The PIN should be valid.") return } #expect(service.isEnabled, "The service should be enabled.") #expect(service.unlock(with: pinCode), "The initial PIN should work.") #expect(!service.unlock(with: newPINCode), "The PIN we're about to set should not work.") // When updating the PIN code. guard case .success = service.setupPINCode(newPINCode) else { Issue.record("The PIN should be valid.") return } // Then the old code should not be accepted. #expect(service.isEnabled, "The service should remain enabled.") #expect(service.unlock(with: newPINCode), "The new PIN should work.") #expect(!service.unlock(with: pinCode), "The original PIN should be rejected.") } @Test func invalidChangePINCode() { // Given a service that is already enabled with a PIN. let pinCode = "2023" let invalidPIN = appSettings.appLockPINCodeBlockList[0] guard case .success = service.setupPINCode(pinCode) else { Issue.record("The PIN should be valid.") return } #expect(service.isEnabled, "The service should be enabled.") #expect(service.unlock(with: pinCode), "The initial PIN should work.") #expect(!service.unlock(with: invalidPIN), "The PIN we're about to set should not work.") // When updating the PIN code that is in the block list. let result = service.setupPINCode(invalidPIN) // Then it should fail and nothing should change. guard case let .failure(error) = result else { Issue.record("The call should have failed.") return } #expect(error == .weakPIN, "The PIN should be rejected as weak.") #expect(service.isEnabled, "The service should remain enabled.") #expect(!service.unlock(with: invalidPIN), "The rejected PIN shouldn't work.") #expect(service.unlock(with: pinCode), "The original PIN should continue to work.") } @Test func disablePINCode() { // Given a service that is already enabled with a PIN. let pinCode = "2023" guard case .success = service.setupPINCode(pinCode) else { Issue.record("The PIN should be valid.") return } #expect(service.isEnabled, "The service should be enabled.") #expect(service.unlock(with: pinCode), "The initial PIN should work.") // When disabling the PIN code. service.disable() // Then the PIN code should be removed. #expect(!service.isEnabled, "The service should no longer be enabled.") #expect(!service.unlock(with: pinCode), "The initial PIN shouldn't work any more.") } // MARK: - Biometric Unlock @Test func enableBiometricUnlock() async { // Given a service with the PIN code already set. let context = LAContextMock() context.biometryTypeValue = .touchID context.evaluatedPolicyDomainStateValue = Data("👆".utf8) service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context) guard case .success = service.setupPINCode("2023") else { Issue.record("The PIN should be valid.") return } #expect(service.isEnabled, "The service should be enabled.") #expect(service.biometryType == .touchID, "The biometry type should be in sync with the mock.") #expect(!service.biometricUnlockEnabled, "Biometric unlock should not be enabled.") #expect(!service.biometricUnlockTrusted, "Biometric unlock should not be trusted.") // When enabling biometric unlock. guard case .success = service.enableBiometricUnlock() else { Issue.record("The biometric lock should enable.") return } context.evaluatePolicyReturnValue = true // Then the service should be unlockable with biometrics. #expect(service.biometryType == .touchID, "The biometry type should not change.") #expect(service.biometricUnlockEnabled, "Biometric unlock should now be enabled.") #expect(service.biometricUnlockTrusted, "Biometric unlock should now be trusted.") guard await service.unlockWithBiometrics() == .unlocked else { Issue.record("The biometric unlock should work.") return } } @Test func biometricUnlockTrust() { // Given a service with the PIN code already set. let context = LAContextMock() context.biometryTypeValue = .touchID context.evaluatedPolicyDomainStateValue = Data("👆".utf8) service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context) let pinCode = "2023" guard case .success = service.setupPINCode(pinCode) else { Issue.record("The PIN should be valid.") return } guard case .success = service.enableBiometricUnlock() else { Issue.record("The biometric lock should enable.") return } #expect(service.isEnabled, "The service should be enabled.") #expect(service.biometryType == .touchID, "The biometry type should be in sync with the mock.") #expect(service.biometricUnlockEnabled, "Biometric unlock should be enabled.") #expect(service.biometricUnlockTrusted, "Biometric unlock should be trusted.") // When the user changes biometric data. context.evaluatedPolicyDomainStateValue = Data("👈".utf8) // Then biometric lock should remain enabled but untrusted. #expect(service.isEnabled, "The service should remain enabled.") #expect(service.biometryType == .touchID, "The biometry type should not change.") #expect(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.") #expect(!service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.") // When the user confirms their PIN code. #expect(service.unlock(with: pinCode), "The PIN code should be accepted") // Then the biometric lock should once again be trusted. #expect(service.isEnabled, "The service should remain enabled.") #expect(service.biometryType == .touchID, "The biometry type should not change.") #expect(service.biometricUnlockEnabled, "Biometric unlock should remain enabled.") #expect(service.biometricUnlockTrusted, "Biometric unlock should once again be trusted.") } @Test func disableBiometricUnlock() { // Given a service with the PIN code already set. let context = LAContextMock() context.biometryTypeValue = .touchID context.evaluatedPolicyDomainStateValue = Data("👆".utf8) service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context) guard case .success = service.setupPINCode("2023") else { Issue.record("The PIN should be valid.") return } guard case .success = service.enableBiometricUnlock() else { Issue.record("The biometric lock should enable.") return } #expect(service.isEnabled, "The service should be enabled.") #expect(service.biometryType == .touchID, "The biometry type should be in sync with the mock.") #expect(service.biometricUnlockEnabled, "Biometric unlock should be enabled.") #expect(service.biometricUnlockTrusted, "Biometric unlock should be trusted.") // When disabling biometric unlock. service.disableBiometricUnlock() // Then only PIN unlock should remain enabled. #expect(service.isEnabled, "The service should remain enabled.") #expect(service.biometryType == .touchID, "The biometry type should not change.") #expect(!service.biometricUnlockEnabled, "Biometric unlock should become disabled.") #expect(!service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.") } @Test func disablePINWithBiometricUnlock() { // Given a service with the PIN code already set. let context = LAContextMock() context.biometryTypeValue = .touchID context.evaluatedPolicyDomainStateValue = Data("👆".utf8) service = AppLockService(keychainController: keychainController, appSettings: appSettings, context: context) guard case .success = service.setupPINCode("2023") else { Issue.record("The PIN should be valid.") return } guard case .success = service.enableBiometricUnlock() else { Issue.record("The biometric lock should enable.") return } #expect(service.isEnabled, "The service should be enabled.") #expect(service.biometricUnlockEnabled, "Biometric unlock should be enabled.") #expect(service.biometricUnlockTrusted, "Biometric unlock should be trusted.") // When disabling the PIN lock. service.disable() // Then both PIN and biometric unlock should be disabled. #expect(!service.isEnabled, "The service should remain enabled.") #expect(!service.biometricUnlockEnabled, "Biometric unlock should become disabled.") #expect(!service.biometricUnlockTrusted, "Biometric unlock should no longer be trusted.") } // MARK: - Attempt failures @Test func resetAttemptsOnUnlock() { // Given a service that is enabled and has failed unlock attempts. let pinCode = "2023" guard case .success = service.setupPINCode(pinCode) else { Issue.record("The PIN should be valid.") return } appSettings.appLockNumberOfPINAttempts = 2 #expect(appSettings.appLockNumberOfPINAttempts == 2, "The initial conditions should be stored.") #expect(service.isEnabled, "The service should be enabled.") // When unlocking the service #expect(service.unlock(with: pinCode), "The PIN should work.") // Then the attempts counts should both be reset. #expect(appSettings.appLockNumberOfPINAttempts == 0, "The PIN attempts should be reset.") } @Test func resetAttemptsOnDisable() { // Given a service that is enabled and has failed unlock attempts. let pinCode = "2023" guard case .success = service.setupPINCode(pinCode) else { Issue.record("The PIN should be valid.") return } appSettings.appLockNumberOfPINAttempts = 2 #expect(appSettings.appLockNumberOfPINAttempts == 2, "The initial conditions should be stored.") #expect(service.isEnabled, "The service should be enabled.") // When disabling the service service.disable() #expect(!service.isEnabled, "The service should be disabled.") // Then the attempts counts should both be reset. #expect(appSettings.appLockNumberOfPINAttempts == 0, "The PIN attempts should be reset.") } }