// // Copyright 2025 Element Creations Ltd. // Copyright 2022-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 KeychainAccess import Testing struct KeychainControllerTests { var keychain: KeychainController init() { keychain = KeychainController(service: .tests, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier) keychain.removeAllRestorationTokens() keychain.resetSecrets() } @Test func addRestorationToken() { // Given an empty keychain. #expect(keychain.restorationTokens().isEmpty, "The keychain should be empty to begin with.") // When adding an restoration token. let username = "@test:example.com" let restorationToken = RestorationToken(session: .init(accessToken: "accessToken", refreshToken: "refreshToken", userId: "userId", deviceId: "deviceId", homeserverUrl: "homeserverUrl", oidcData: "oidcData", slidingSyncVersion: .native), sessionDirectories: .init(), passphrase: "passphrase", pusherNotificationClientIdentifier: "pusherClientID") keychain.setRestorationToken(restorationToken, forUsername: username) // Then the restoration token should be stored in the keychain. #expect(keychain.restorationTokenForUsername(username) == restorationToken, "The retrieved restoration token should match the value that was stored.") } @Test func removingRestorationToken() { // Given a keychain with a stored restoration token. let username = "@test:example.com" let restorationToken = RestorationToken(session: .init(accessToken: "accessToken", refreshToken: "refreshToken", userId: "userId", deviceId: "deviceId", homeserverUrl: "homeserverUrl", oidcData: "oidcData", slidingSyncVersion: .native), sessionDirectories: .init(), passphrase: "passphrase", pusherNotificationClientIdentifier: "pusherClientID") keychain.setRestorationToken(restorationToken, forUsername: username) #expect(keychain.restorationTokens().count == 1, "The keychain should have 1 restoration token.") #expect(keychain.restorationTokenForUsername(username) == restorationToken, "The initial restoration token should match the value that was stored.") // When deleting the restoration token. keychain.removeRestorationTokenForUsername(username) // Then the keychain should be empty. #expect(keychain.restorationTokens().isEmpty, "The keychain should be empty after deleting the token.") #expect(keychain.restorationTokenForUsername(username) == nil, "There restoration token should not be returned after removal.") } @Test func removingAllRestorationTokens() { // Given a keychain with 5 stored restoration tokens. for index in 0..<5 { let restorationToken = RestorationToken(session: .init(accessToken: "accessToken", refreshToken: "refreshToken", userId: "userId", deviceId: "deviceId", homeserverUrl: "homeserverUrl", oidcData: "oidcData", slidingSyncVersion: .native), sessionDirectories: .init(), passphrase: "passphrase", pusherNotificationClientIdentifier: "pusherClientID") keychain.setRestorationToken(restorationToken, forUsername: "@test\(index):example.com") } #expect(keychain.restorationTokens().count == 5, "The keychain should have 5 restoration tokens.") // When deleting all of the restoration tokens. keychain.removeAllRestorationTokens() // Then the keychain should be empty. #expect(keychain.restorationTokens().isEmpty, "The keychain should be empty after deleting the token.") } @Test func removingSingleRestorationTokens() { // Given a keychain with 5 stored restoration tokens. for index in 0..<5 { let restorationToken = RestorationToken(session: .init(accessToken: "accessToken", refreshToken: "refreshToken", userId: "userId", deviceId: "deviceId", homeserverUrl: "homeserverUrl", oidcData: "oidcData", slidingSyncVersion: .native), sessionDirectories: .init(), passphrase: "passphrase", pusherNotificationClientIdentifier: "pusherClientID") keychain.setRestorationToken(restorationToken, forUsername: "@test\(index):example.com") } #expect(keychain.restorationTokens().count == 5, "The keychain should have 5 restoration tokens.") // When deleting one of the restoration tokens. keychain.removeRestorationTokenForUsername("@test2:example.com") // Then the other 4 items should remain untouched. #expect(keychain.restorationTokens().count == 4, "The keychain have 4 remaining restoration tokens.") #expect(keychain.restorationTokenForUsername("@test0:example.com") != nil, "The restoration token should not have been deleted.") #expect(keychain.restorationTokenForUsername("@test1:example.com") != nil, "The restoration token should not have been deleted.") #expect(keychain.restorationTokenForUsername("@test2:example.com") == nil, "The restoration token should have been deleted.") #expect(keychain.restorationTokenForUsername("@test3:example.com") != nil, "The restoration token should not have been deleted.") #expect(keychain.restorationTokenForUsername("@test4:example.com") != nil, "The restoration token should not have been deleted.") } @Test func unsupportedRestorationToken() throws { // Given a keychain with an unsupported restoration token with a sliding sync proxy URL value. let underlyingKeychain = Keychain(service: KeychainControllerService.tests.restorationTokenID, accessGroup: InfoPlistReader.main.keychainAccessGroupIdentifier) // Note: We assert with this underlying keychain's keys as keychain.restorationTokens() triggers the deletion that we're testing. #expect(underlyingKeychain.allKeys().isEmpty, "The keychain should be empty to begin with.") let unsupportedToken = RestorationTokenV4(session: SessionV1(accessToken: "1234", refreshToken: nil, userId: "@test:example.com", deviceId: "D3V1C3", homeserverUrl: "https://matrix.example.com", oidcData: nil, slidingSyncVersion: .proxy(url: "https://sync.example.com")), sessionDirectory: .sessionsBaseDirectory.appending(component: UUID().uuidString), passphrase: "passphrase", pusherNotificationClientIdentifier: "pusherClientID") let tokenData = try JSONEncoder().encode(unsupportedToken) try underlyingKeychain.set(tokenData, key: "@test:example.com") #expect(underlyingKeychain.allKeys().count == 1) // When attempting to retrieve the unsupported token. let retrievedToken = keychain.restorationTokenForUsername("@test:example.com") // Then nothing should be returned and the restoration token should be automatically removed. #expect(retrievedToken == nil, "The token should not be decoded.") #expect(underlyingKeychain.allKeys().isEmpty, "The keychain should be empty again.") } @Test func addPINCode() throws { // Given a keychain without a PIN code set. #expect(try !keychain.containsPINCode(), "A new keychain shouldn't contain a PIN code.") #expect(keychain.pinCode() == nil, "A new keychain shouldn't return a PIN code.") // When setting a PIN code. try keychain.setPINCode("0000") // Then the PIN code should be stored. #expect(try keychain.containsPINCode(), "The keychain should contain the PIN code.") #expect(keychain.pinCode() == "0000", "The stored PIN code should match what was set.") } @Test func updatePINCode() throws { // Given a keychain with a PIN code already set. try keychain.setPINCode("0000") #expect(try keychain.containsPINCode(), "The keychain should contain the PIN code.") #expect(keychain.pinCode() == "0000", "The stored PIN code should match what was set.") // When setting a different PIN code. try keychain.setPINCode("1234") // Then the PIN code should be updated. #expect(try keychain.containsPINCode(), "The keychain should still contain the PIN code.") #expect(keychain.pinCode() == "1234", "The stored PIN code should match the new value.") } @Test func removePINCode() throws { // Given a keychain with a PIN code already set. try keychain.setPINCode("0000") #expect(try keychain.containsPINCode(), "The keychain should contain the PIN code.") #expect(keychain.pinCode() == "0000", "The stored PIN code should match what was set.") // When removing the PIN code. keychain.removePINCode() // Then the PIN code should no longer be stored. #expect(try !keychain.containsPINCode(), "The keychain should no longer contain the PIN code.") #expect(keychain.pinCode() == nil, "There shouldn't be a stored PIN code after removing it.") } @Test func addPINCodeBiometricState() throws { // Given a keychain without any biometric state. #expect(!keychain.containsPINCodeBiometricState(), "A new keychain shouldn't contain biometric state.") #expect(keychain.pinCodeBiometricState() == nil, "A new keychain shouldn't return biometric state.") // When setting the state. let data = Data("Face ID".utf8) try keychain.setPINCodeBiometricState(data) // Then the state should be stored. #expect(keychain.containsPINCodeBiometricState(), "The keychain should contain the biometric state.") #expect(keychain.pinCodeBiometricState() == data, "The stored biometric state should match what was set.") } @Test func updatePINCodeBiometricState() throws { // Given a keychain that contains PIN code biometric state. let data = Data("😃".utf8) try keychain.setPINCodeBiometricState(data) #expect(keychain.containsPINCodeBiometricState(), "The keychain should contain the biometric state.") #expect(keychain.pinCodeBiometricState() == data, "The stored biometric state should match what was set.") // When setting different state. let newData = Data("😎".utf8) try keychain.setPINCodeBiometricState(newData) // Then the state should be updated. #expect(keychain.containsPINCodeBiometricState(), "The keychain should still contain biometric state.") #expect(keychain.pinCodeBiometricState() != data, "The stored biometric state shouldn't match the old value.") #expect(keychain.pinCodeBiometricState() == newData, "The stored biometric state should match the new value.") } @Test func removePINCodeBiometricState() throws { // Given a keychain that contains PIN code biometric state. let data = Data("Face ID".utf8) try keychain.setPINCodeBiometricState(data) #expect(keychain.containsPINCodeBiometricState(), "The keychain should contain the biometric state.") #expect(keychain.pinCodeBiometricState() == data, "The stored biometric state should match what was set.") // When removing the state. keychain.removePINCodeBiometricState() // Then the state should no longer be stored. #expect(!keychain.containsPINCodeBiometricState(), "The keychain should no longer contain the biometric state.") #expect(keychain.pinCodeBiometricState() == nil, "There shouldn't be any stored biometric state after removing it.") } }