Initial service implementation for using a PIN code. (#1912)

* Initial service implementation for using a PIN code.

* Tweak Danger for commit size

600-800 lines is perfectly normal for our PRs, up it to 1000.
This commit is contained in:
Doug
2023-10-19 10:42:12 +01:00
committed by GitHub
parent fbcf037240
commit 99c28784a9
14 changed files with 730 additions and 35 deletions
@@ -21,19 +21,22 @@ import XCTest
@MainActor
class AppLockScreenViewModelTests: XCTestCase {
var appLockService: AppLockService!
var keychainController: KeychainControllerMock!
var viewModel: AppLockScreenViewModelProtocol!
var context: AppLockScreenViewModelType.Context { viewModel.context }
override func setUp() {
AppSettings.reset()
appLockService = AppLockService(keychainController: KeychainControllerMock(), appSettings: AppSettings())
keychainController = KeychainControllerMock()
appLockService = AppLockService(keychainController: keychainController, appSettings: AppSettings())
viewModel = AppLockScreenViewModel(appLockService: appLockService)
}
func testUnlock() async throws {
// Given a valid PIN code.
let pinCode = "0000"
let pinCode = "2023"
keychainController.pinCodeReturnValue = pinCode
// When entering it on the lock screen.
let deferred = deferFulfillment(viewModel.actions) { $0 == .appUnlocked }
@@ -0,0 +1,178 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
@testable import ElementX
@MainActor
class AppLockServiceTests: XCTestCase {
var appSettings: AppSettings!
var service: AppLockService!
override func setUp() {
AppSettings.reset()
appSettings = AppSettings()
appSettings.appLockFlowEnabled = true
let keychainController = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
service = AppLockService(keychainController: keychainController, appSettings: appSettings)
service.disable()
}
override func tearDown() {
AppSettings.reset()
}
func testValidPINCode() {
// Given a service that hasn't been enabled.
XCTAssertFalse(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 {
XCTFail("The PIN should be valid.")
return
}
// Then service should be enabled and only the provided PIN should work to unlock the app.
XCTAssertTrue(service.isEnabled, "The service should become enabled when setting a PIN.")
XCTAssertTrue(service.unlock(with: pinCode), "The provided PIN code should work.")
XCTAssertFalse(service.unlock(with: "2024"), "No other PIN code should work.")
XCTAssertFalse(service.unlock(with: "1234"), "No other PIN code should work.")
XCTAssertFalse(service.unlock(with: "9999"), "No other PIN code should work.")
}
func testWeakPINCode() {
// Given a service that hasn't been enabled.
XCTAssertFalse(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 {
XCTFail("The call should have failed.")
return
}
XCTAssertEqual(error, .weakPIN, "The PIN should be rejected as weak.")
XCTAssertFalse(service.isEnabled, "The service should remain disabled.")
}
func testShortPINCode() {
// Given a service that hasn't been enabled.
XCTAssertFalse(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 {
XCTFail("The call should have failed.")
return
}
XCTAssertEqual(error, .invalidPIN, "The PIN should be rejected as invalid.")
XCTAssertFalse(service.isEnabled, "The service should remain disabled.")
}
func testNonNumericPINCode() {
// Given a service that hasn't been enabled.
XCTAssertFalse(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 {
XCTFail("The call should have failed.")
return
}
XCTAssertEqual(error, .invalidPIN, "The PIN should be rejected as invalid.")
XCTAssertFalse(service.isEnabled, "The service should remain disabled.")
}
func testChangePINCode() {
// Given a service that is already enabled with a PIN.
let pinCode = "2023"
let newPINCode = "2024"
guard case .success = service.setupPINCode(pinCode) else {
XCTFail("The PIN should be valid.")
return
}
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
XCTAssertTrue(service.unlock(with: pinCode), "The initial PIN should work.")
XCTAssertFalse(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 {
XCTFail("The PIN should be valid.")
return
}
// Then the old code should not be accepted.
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
XCTAssertTrue(service.unlock(with: newPINCode), "The new PIN should work.")
XCTAssertFalse(service.unlock(with: pinCode), "The original PIN should be rejected.")
}
func testInvalidChangePINCode() {
// 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 {
XCTFail("The PIN should be valid.")
return
}
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
XCTAssertTrue(service.unlock(with: pinCode), "The initial PIN should work.")
XCTAssertFalse(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 {
XCTFail("The call should have failed.")
return
}
XCTAssertEqual(error, .weakPIN, "The PIN should be rejected as weak.")
XCTAssertTrue(service.isEnabled, "The service should remain enabled.")
XCTAssertFalse(service.unlock(with: invalidPIN), "The rejected PIN shouldn't work.")
XCTAssertTrue(service.unlock(with: pinCode), "The original PIN should continue to work.")
}
func testDisablePINCode() {
// Given a service that is already enabled with a PIN.
let pinCode = "2023"
guard case .success = service.setupPINCode(pinCode) else {
XCTFail("The PIN should be valid.")
return
}
XCTAssertTrue(service.isEnabled, "The service should be enabled.")
XCTAssertTrue(service.unlock(with: pinCode), "The initial PIN should work.")
// When disabling the PIN code.
service.disable()
// Then the PIN code should be removed.
XCTAssertFalse(service.isEnabled, "The service should no longer be enabled.")
XCTAssertFalse(service.unlock(with: pinCode), "The initial PIN shouldn't work any more.")
}
}
@@ -0,0 +1,154 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
@testable import ElementX
class AppLockTimerTests: XCTestCase {
var timer: AppLockTimer!
let now = Date.now
var gracePeriod: TimeInterval { timer.gracePeriod }
var halfGracePeriod: TimeInterval { gracePeriod / 2 }
var gracePeriodX2: TimeInterval { gracePeriod * 2 }
var gracePeriodX10: TimeInterval { gracePeriod * 10 }
override func tearDown() {
timer = nil
}
func testTimerLockedOnStartup() {
setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now),
"The app should be locked on a fresh launch.")
setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + 1),
"The app should be locked after a fresh launch.")
setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + halfGracePeriod),
"The app should be locked after a fresh launch.")
setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriod),
"The app should be locked after a fresh launch.")
setupTimer(unlocked: false)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriodX10),
"The app should be locked after a fresh launch.")
}
func testTimerBeforeFirstUnlock() {
setupTimer(unlocked: false, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now),
"The app should always remain locked after backgrounding when locked.")
setupTimer(unlocked: false, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + 1),
"The app should always remain locked after backgrounding when locked.")
setupTimer(unlocked: false, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + halfGracePeriod),
"The app should always remain locked after backgrounding when locked.")
setupTimer(unlocked: false, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriod),
"The app should always remain locked after backgrounding when locked.")
setupTimer(unlocked: false, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriodX10),
"The app should always remain locked after backgrounding when locked.")
}
func testTimerWhenUnlocked() {
setupTimer(unlocked: true, backgroundedAt: now)
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: now + 1),
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
setupTimer(unlocked: true, backgroundedAt: now)
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: now + halfGracePeriod),
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
setupTimer(unlocked: true, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriod),
"The app should become locked when it was unlocked and backgrounded for more than the grace period.")
setupTimer(unlocked: true, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now + gracePeriodX10),
"The app should become locked when it was unlocked and backgrounded for more than the grace period.")
}
func testTimerRepeatingWithinGracePeriod() {
setupTimer(unlocked: true, backgroundedAt: now)
var nextCheck = now + halfGracePeriod
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck),
"The app should remain unlocked when it was unlocked and backgrounded for less then the grace period.")
timer.applicationDidEnterBackground(date: nextCheck)
nextCheck = now + gracePeriod
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck),
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
timer.applicationDidEnterBackground(date: nextCheck)
nextCheck = now + gracePeriod + halfGracePeriod
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck),
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
timer.applicationDidEnterBackground(date: nextCheck)
nextCheck = now + gracePeriodX2
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: nextCheck),
"The app should remain unlocked when repeating the backgrounded and foreground within the grace period.")
timer.applicationDidEnterBackground(date: nextCheck)
nextCheck = now + gracePeriodX10
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: nextCheck),
"The app should become locked however when finally staying backgrounded for longer than the grace period.")
}
func testTimerWithLongForeground() {
setupTimer(unlocked: true)
let backgroundDate = now + gracePeriodX10
timer.applicationDidEnterBackground(date: backgroundDate)
XCTAssertFalse(timer.computeLockState(willEnterForegroundAt: backgroundDate + 1),
"The grace period should be measured from the time the app was backgrounded, and not when it was unlocked.")
}
func testChangingTimeLocksApp() {
setupTimer(unlocked: true, backgroundedAt: now)
XCTAssertTrue(timer.computeLockState(willEnterForegroundAt: now - 1),
"The the device's clock is changed to before the app was backgrounded, the device should remain locked.")
}
/// Sets up the timer for testing.
/// - Parameters:
/// - unlocked: Whether the timer should consider itself unlocked or not.
/// - backgroundedDate: If not nil, the timer will consider the app to have been backgrounded at the specified date.
private func setupTimer(unlocked: Bool, backgroundedAt backgroundedDate: Date? = nil) {
timer = AppLockTimer(gracePeriod: 180)
if unlocked {
timer.registerUnlock()
}
if let backgroundedDate {
timer.applicationDidEnterBackground(date: backgroundedDate)
}
}
}
@@ -24,6 +24,7 @@ class KeychainControllerTests: XCTestCase {
keychain = KeychainController(service: .tests,
accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier)
keychain.removeAllRestorationTokens()
keychain.resetSecrets()
}
func testAddRestorationToken() {
@@ -113,4 +114,45 @@ class KeychainControllerTests: XCTestCase {
XCTAssertNotNil(keychain.restorationTokenForUsername("@test3:example.com"), "The restoration token should not have been deleted.")
XCTAssertNotNil(keychain.restorationTokenForUsername("@test4:example.com"), "The restoration token should not have been deleted.")
}
func testAddPINCode() throws {
// Given a keychain without a PIN code set.
try XCTAssertFalse(keychain.containsPINCode(), "A new keychain shouldn't contain a PIN code.")
XCTAssertNil(keychain.pinCode(), "A new keychain shouldn't return a PIN code.")
// When setting a PIN code.
try keychain.setPINCode("0000")
// The the PIN code should be stored.
try XCTAssertTrue(keychain.containsPINCode(), "The keychain should contain the PIN code.")
XCTAssertEqual(keychain.pinCode(), "0000", "The stored PIN code should match what was set.")
}
func testUpdatePINCode() throws {
// Given a keychain with a PIN code already set.
try keychain.setPINCode("0000")
try XCTAssertTrue(keychain.containsPINCode(), "The keychain should contain the PIN code.")
XCTAssertEqual(keychain.pinCode(), "0000", "The stored PIN code should match what was set.")
// When setting a different PIN code.
try keychain.setPINCode("1234")
// The the PIN code should be updated.
try XCTAssertTrue(keychain.containsPINCode(), "The keychain should still contain the PIN code.")
XCTAssertEqual(keychain.pinCode(), "1234", "The stored PIN code should match the new value.")
}
func testRemovePINCode() throws {
// Given a keychain with a PIN code already set.
try keychain.setPINCode("0000")
try XCTAssertTrue(keychain.containsPINCode(), "The keychain should contain the PIN code.")
XCTAssertEqual(keychain.pinCode(), "0000", "The stored PIN code should match what was set.")
// When removing the PIN code.
keychain.removePINCode()
// The the PIN code should no longer be stored.
try XCTAssertFalse(keychain.containsPINCode(), "The keychain should no longer contain the PIN code.")
XCTAssertNil(keychain.pinCode(), "There shouldn't be a stored PIN code after removing it.")
}
}