BIT-1916: Migrate tokens to keychain (#490)

This commit is contained in:
Matt Czech 2024-02-21 12:06:52 -06:00 committed by GitHub
parent b86aec872b
commit b01c5aa9e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 635 additions and 143 deletions

View File

@ -35,7 +35,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
guard !isTesting else { return true }
UNUserNotificationCenter.current().delegate = self
UIApplication.shared.registerForRemoteNotifications()
#if DEBUG
let errorReporter = OSLogErrorReporter()
@ -43,7 +42,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
let errorReporter = CrashlyticsErrorReporter()
#endif
let services = ServiceContainer(errorReporter: errorReporter, nfcReaderService: DefaultNFCReaderService())
let services = ServiceContainer(
application: UIApplication.shared,
errorReporter: errorReporter,
nfcReaderService: DefaultNFCReaderService()
)
let appModule = DefaultAppModule(services: services)
appProcessor = AppProcessor(appModule: appModule, services: services)
return true

View File

@ -0,0 +1,4 @@
import BitwardenShared
import UIKit
extension UIApplication: Application {}

View File

@ -204,6 +204,9 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
/// The service used by the application to manage the environment settings.
private let environmentService: EnvironmentService
/// The repository used to manages keychain items.
private let keychainRepository: KeychainRepository
/// The service used by the application to manage the policy.
private var policyService: PolicyService
@ -239,6 +242,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
/// - clientGenerators: The client used for generating passwords and passphrases.
/// - clientPlatform: The client used by the application to handle account fingerprint phrase generation.
/// - environmentService: The service used by the application to manage the environment settings.
/// - keychainRepository: The repository used to manages keychain items.
/// - policyService: The service used by the application to manage the policy.
/// - stateService: The object used by the application to retrieve information about this device.
/// - systemDevice: The object used by the application to retrieve information about this device.
@ -251,6 +255,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
clientGenerators: ClientGeneratorsProtocol,
clientPlatform: ClientPlatformProtocol,
environmentService: EnvironmentService,
keychainRepository: KeychainRepository,
policyService: PolicyService,
stateService: StateService,
systemDevice: SystemDevice
@ -262,6 +267,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
self.clientGenerators = clientGenerators
self.clientPlatform = clientPlatform
self.environmentService = environmentService
self.keychainRepository = keychainRepository
self.policyService = policyService
self.stateService = stateService
self.systemDevice = systemDevice
@ -613,11 +619,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
// Create the account.
let urls = await stateService.getPreAuthEnvironmentUrls()
let account = try Account(identityTokenResponseModel: identityTokenResponse, environmentUrls: urls)
await stateService.addAccount(account)
// Save the encryption keys.
let encryptionKeys = AccountEncryptionKeys(identityTokenResponseModel: identityTokenResponse)
try await stateService.setAccountEncryptionKeys(encryptionKeys)
try await saveAccount(account, identityTokenResponse: identityTokenResponse)
return identityTokenResponse
} catch let error as IdentityTokenRequestError {
@ -645,6 +647,30 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
}
}
/// Saves the user's account information.
///
/// - Parameters:
/// - account: The user's account.
/// - identityTokenResponse: The response from the identity token request.
///
private func saveAccount(_ account: Account, identityTokenResponse: IdentityTokenResponseModel) async throws {
await stateService.addAccount(account)
// Save the encryption keys.
let encryptionKeys = AccountEncryptionKeys(identityTokenResponseModel: identityTokenResponse)
try await stateService.setAccountEncryptionKeys(encryptionKeys)
// Save the account tokens.
try await keychainRepository.setAccessToken(
identityTokenResponse.accessToken,
userId: account.profile.userId
)
try await keychainRepository.setRefreshToken(
identityTokenResponse.refreshToken,
userId: account.profile.userId
)
}
/// Saves the user's master password hash.
///
/// - Parameter password: The user's master password to hash and save.

View File

@ -16,6 +16,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
var clientGenerators: MockClientGenerators!
var clientPlatform: MockClientPlatform!
var environmentService: MockEnvironmentService!
var keychainRepository: MockKeychainRepository!
var stateService: MockStateService!
var policyService: MockPolicyService!
var subject: DefaultAuthService!
@ -34,6 +35,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
clientGenerators = MockClientGenerators()
clientPlatform = MockClientPlatform()
environmentService = MockEnvironmentService()
keychainRepository = MockKeychainRepository()
policyService = MockPolicyService()
stateService = MockStateService()
systemDevice = MockSystemDevice()
@ -46,6 +48,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
clientGenerators: clientGenerators,
clientPlatform: clientPlatform,
environmentService: environmentService,
keychainRepository: keychainRepository,
policyService: policyService,
stateService: stateService,
systemDevice: systemDevice
@ -63,6 +66,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
clientGenerators = nil
clientPlatform = nil
environmentService = nil
keychainRepository = nil
stateService = nil
subject = nil
systemDevice = nil
@ -246,7 +250,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
}
/// `loginWithMasterPassword(_:username:captchaToken:)` logs in with the password.
func test_loginWithMasterPassword() async throws {
func test_loginWithMasterPassword() async throws { // swiftlint:disable:this function_body_length
// Set up the mock data.
client.results = [
.httpSuccess(testData: .preLoginSuccess),
@ -299,6 +303,14 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
stateService.masterPasswordHashes,
["13512467-9cfe-43b0-969f-07534084764b": "hashed password"]
)
try XCTAssertEqual(
keychainRepository.getValue(for: .accessToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
IdentityTokenResponseModel.fixture().accessToken
)
try XCTAssertEqual(
keychainRepository.getValue(for: .refreshToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
IdentityTokenResponseModel.fixture().refreshToken
)
}
/// `loginWithMasterPassword(_:username:captchaToken:)` logs in with the password updates AccountProfile's
@ -428,6 +440,14 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
),
]
)
try XCTAssertEqual(
keychainRepository.getValue(for: .accessToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
IdentityTokenResponseModel.fixture().accessToken
)
try XCTAssertEqual(
keychainRepository.getValue(for: .refreshToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
IdentityTokenResponseModel.fixture().refreshToken
)
XCTAssertEqual(account, .fixtureAccountLogin())
}
@ -493,6 +513,14 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
stateService.masterPasswordHashes,
["13512467-9cfe-43b0-969f-07534084764b": "hashed password"]
)
try XCTAssertEqual(
keychainRepository.getValue(for: .accessToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
IdentityTokenResponseModel.fixture().accessToken
)
try XCTAssertEqual(
keychainRepository.getValue(for: .refreshToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
IdentityTokenResponseModel.fixture().refreshToken
)
XCTAssertEqual(account, .fixtureAccountLogin())
}

View File

@ -3,21 +3,29 @@ import Foundation
// MARK: - KeychainItem
enum KeychainItem: Equatable {
/// The keychain item for a user's access token.
case accessToken(userId: String)
/// The keychain item for biometrics protected user auth key.
case biometrics(userId: String)
/// The keychain item for the neverLock user auth key.
case neverLock(userId: String)
/// The keychain item for a user's refresh token.
case refreshToken(userId: String)
/// The `SecAccessControlCreateFlags` protection level for this keychain item.
/// If `nil`, no extra protection is applied.
///
var protection: SecAccessControlCreateFlags? {
switch self {
case .accessToken,
.neverLock,
.refreshToken:
nil
case .biometrics:
.biometryCurrentSet
case .neverLock:
nil
}
}
@ -25,10 +33,14 @@ enum KeychainItem: Equatable {
///
var unformattedKey: String {
switch self {
case let .accessToken(userId):
"accessToken_\(userId)"
case let .biometrics(userId: id):
"biometric_key_" + id
case let .neverLock(userId: id):
"userKeyAutoUnlock_" + id
case let .refreshToken(userId):
"refreshToken_\(userId)"
}
}
}
@ -42,6 +54,20 @@ protocol KeychainRepository: AnyObject {
///
func deleteUserAuthKey(for item: KeychainItem) async throws
/// Gets the stored access token for a user from the keychain.
///
/// - Parameter userId: The user ID associated with the stored access token.
/// - Returns: The user's access token.
///
func getAccessToken(userId: String) async throws -> String
/// Gets the stored refresh token for a user from the keychain.
///
/// - Parameter userId: The user ID associated with the stored refresh token.
/// - Returns: The user's refresh token.
///
func getRefreshToken(userId: String) async throws -> String
/// Gets a user auth key value.
///
/// - Parameter item: The storage key of the user auth key.
@ -49,6 +75,22 @@ protocol KeychainRepository: AnyObject {
///
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String
/// Stores the access token for a user in the keychain.
///
/// - Parameters:
/// - value: The access token to store.
/// - userId: The user's ID, used to get back the token later on.
///
func setAccessToken(_ value: String, userId: String) async throws
/// Stores the refresh token for a user in the keychain.
///
/// - Parameters:
/// - value: The refresh token to store.
/// - userId: The user's ID, used to get back the token later on.
///
func setRefreshToken(_ value: String, userId: String) async throws
/// Sets a user auth key/value pair.
///
/// - Parameters:
@ -107,13 +149,7 @@ class DefaultKeychainRepository: KeychainRepository {
// MARK: Methods
func deleteUserAuthKey(for item: KeychainItem) async throws {
try await keychainService.delete(
query: keychainQueryValues(for: item)
)
}
/// Generates a formated storage key for a keychain item.
/// Generates a formatted storage key for a keychain item.
///
/// - Parameter item: The keychain item that needs a formatted key.
/// - Returns: A formatted storage key.
@ -123,7 +159,12 @@ class DefaultKeychainRepository: KeychainRepository {
return String(format: storageKeyFormat, appId, item.unformattedKey)
}
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String {
/// Gets the value associated with the keychain item from the keychain.
///
/// - Parameter item: The keychain item used to fetch the associated value.
/// - Returns: The fetched value associated with the keychain item.
///
func getValue(for item: KeychainItem) async throws -> String {
let foundItem = try await keychainService.search(
query: keychainQueryValues(
for: item,
@ -166,7 +207,7 @@ class DefaultKeychainRepository: KeychainRepository {
kSecClass: kSecClassGenericPassword,
]
// Add the addional key value pairs.
// Add the additional key value pairs.
additionalPairs.forEach { key, value in
result[key] = value
}
@ -174,7 +215,13 @@ class DefaultKeychainRepository: KeychainRepository {
return result as CFDictionary
}
func setUserAuthKey(for item: KeychainItem, value: String) async throws {
/// Sets a value associated with a keychain item in the keychain.
///
/// - Parameters:
/// - value: The value associated with the keychain item to set.
/// - item: The keychain item used to set the associated value.
///
func setValue(_ value: String, for item: KeychainItem) async throws {
let accessControl = try keychainService.accessControl(
for: item.protection ?? []
)
@ -196,3 +243,35 @@ class DefaultKeychainRepository: KeychainRepository {
)
}
}
extension DefaultKeychainRepository {
func deleteUserAuthKey(for item: KeychainItem) async throws {
try await keychainService.delete(
query: keychainQueryValues(for: item)
)
}
func getAccessToken(userId: String) async throws -> String {
try await getValue(for: .accessToken(userId: userId))
}
func getRefreshToken(userId: String) async throws -> String {
try await getValue(for: .refreshToken(userId: userId))
}
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String {
try await getValue(for: item)
}
func setAccessToken(_ value: String, userId: String) async throws {
try await setValue(value, for: .accessToken(userId: userId))
}
func setRefreshToken(_ value: String, userId: String) async throws {
try await setValue(value, for: .refreshToken(userId: userId))
}
func setUserAuthKey(for item: KeychainItem, value: String) async throws {
try await setValue(value, for: item)
}
}

View File

@ -4,7 +4,7 @@ import XCTest
// MARK: - KeychainRepositoryTests
final class KeychainRepositoryTests: BitwardenTestCase {
final class KeychainRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
var appSettingsStore: MockAppSettingsStore!
@ -105,6 +105,38 @@ final class KeychainRepositoryTests: BitwardenTestCase {
)
}
/// `getAccessToken(userId:)` returns the stored access token.
func test_getAccessToken() async throws {
keychainService.setSearchResultData(string: "ACCESS_TOKEN")
let accessToken = try await subject.getAccessToken(userId: "1")
XCTAssertEqual(accessToken, "ACCESS_TOKEN")
}
/// `getAccessToken(userId:)` throws an error if one occurs.
func test_getAccessToken_error() async {
let error = KeychainServiceError.keyNotFound(.accessToken(userId: "1"))
keychainService.searchResult = .failure(error)
await assertAsyncThrows(error: error) {
_ = try await subject.getAccessToken(userId: "1")
}
}
/// `getRefreshToken(userId:)` returns the stored refresh token.
func test_getRefreshToken() async throws {
keychainService.setSearchResultData(string: "REFRESH_TOKEN")
let accessToken = try await subject.getRefreshToken(userId: "1")
XCTAssertEqual(accessToken, "REFRESH_TOKEN")
}
/// `getRefreshToken(userId:)` throws an error if one occurs.
func test_getRefreshToken_error() async {
let error = KeychainServiceError.keyNotFound(.refreshToken(userId: "1"))
keychainService.searchResult = .failure(error)
await assertAsyncThrows(error: error) {
_ = try await subject.getRefreshToken(userId: "1")
}
}
/// `getUserAuthKeyValue(_:)` failures rethrow.
///
func test_getUserAuthKeyValue_error_searchError() async {
@ -208,6 +240,64 @@ final class KeychainRepositoryTests: BitwardenTestCase {
)
}
/// `setAccessToken(userId:)` stored the access token.
func test_setAccessToken() async throws {
keychainService.accessControlResult = .success(
SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[],
nil
)!
)
keychainService.setSearchResultData(string: "ACCESS_TOKEN")
try await subject.setAccessToken("ACCESS_TOKEN", userId: "1")
let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary
try XCTAssertEqual(
String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8),
"ACCESS_TOKEN"
)
}
/// `setAccessToken(userId:)` throws an error if one occurs.
func test_setAccessToken_error() async {
let error = KeychainServiceError.accessControlFailed(nil)
keychainService.addResult = .failure(error)
await assertAsyncThrows(error: error) {
_ = try await subject.setAccessToken("ACCESS_TOKEN", userId: "1")
}
}
/// `setRefreshToken(userId:)` stored the refresh token.
func test_setRefreshToken() async throws {
keychainService.accessControlResult = .success(
SecAccessControlCreateWithFlags(
nil,
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
[],
nil
)!
)
keychainService.setSearchResultData(string: "REFRESH_TOKEN")
try await subject.setRefreshToken("REFRESH_TOKEN", userId: "1")
let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary
try XCTAssertEqual(
String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8),
"REFRESH_TOKEN"
)
}
/// `setRefreshToken(userId:)` throws an error if one occurs.
func test_setRefreshToken_error() async {
let error = KeychainServiceError.accessControlFailed(nil)
keychainService.addResult = .failure(error)
await assertAsyncThrows(error: error) {
_ = try await subject.setRefreshToken("REFRESH_TOKEN", userId: "1")
}
}
/// `setUserAuthKey(_:)` failures rethrow.
///
func test_setUserAuthKey_error_accessControl() async {

View File

@ -10,12 +10,28 @@ class MockKeychainRepository: KeychainRepository {
var getResult: Result<String, Error>?
var setResult: Result<Void, Error> = .success(())
var getAccessTokenResult: Result<String, Error> = .success("ACCESS_TOKEN")
var getRefreshTokenResult: Result<String, Error> = .success("REFRESH_TOKEN")
var setAccessTokenResult: Result<Void, Error> = .success(())
var setRefreshTokenResult: Result<Void, Error> = .success(())
func deleteUserAuthKey(for item: KeychainItem) async throws {
try deleteResult.get()
let formattedKey = formattedKey(for: item)
mockStorage = mockStorage.filter { $0.key != formattedKey }
}
func getAccessToken(userId: String) async throws -> String {
try getAccessTokenResult.get()
}
func getRefreshToken(userId: String) async throws -> String {
try getRefreshTokenResult.get()
}
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String {
let formattedKey = formattedKey(for: item)
if let result = getResult {
@ -29,10 +45,28 @@ class MockKeychainRepository: KeychainRepository {
}
}
func getValue(for item: KeychainItem) throws -> String {
let formattedKey = formattedKey(for: item)
guard let value = mockStorage[formattedKey] else {
throw KeychainServiceError.keyNotFound(item)
}
return value
}
func formattedKey(for item: KeychainItem) -> String {
String(format: storageKeyFormat, appId, item.unformattedKey)
}
func setAccessToken(_ value: String, userId: String) async throws {
try setAccessTokenResult.get()
mockStorage[formattedKey(for: .accessToken(userId: userId))] = value
}
func setRefreshToken(_ value: String, userId: String) async throws {
try setRefreshTokenResult.get()
mockStorage[formattedKey(for: .refreshToken(userId: userId))] = value
}
func setUserAuthKey(for item: KeychainItem, value: String) async throws {
let formattedKey = formattedKey(for: item)
securityType = item.protection

View File

@ -38,3 +38,10 @@ extension MockKeychainService: KeychainService {
return try searchResult.get()
}
}
extension MockKeychainService {
func setSearchResultData(string: String) {
let dictionary = [kSecValueData as String: Data(string.utf8)]
searchResult = .success(dictionary as AnyObject)
}
}

View File

@ -1,6 +1,15 @@
/// Domain model for a user account.
///
public struct Account: Codable, Equatable, Hashable {
// MARK: Types
/// Key names used for encoding and decoding.
enum CodingKeys: String, CodingKey {
case profile
case settings
case _tokens = "tokens" // swiftlint:disable:this identifier_name
}
// MARK: Properties
/// The account's profile details.
@ -10,7 +19,11 @@ public struct Account: Codable, Equatable, Hashable {
var settings: AccountSettings
/// The account's API tokens.
var tokens: AccountTokens
///
/// Note: This is deprecated, but remains to support migration - the tokens have been moved to
/// the keychain.
///
var _tokens: AccountTokens? // swiftlint:disable:this identifier_name
}
extension Account {
@ -60,10 +73,7 @@ extension Account {
settings: AccountSettings(
environmentUrls: environmentUrls
),
tokens: AccountTokens(
accessToken: identityTokenResponseModel.accessToken,
refreshToken: identityTokenResponseModel.refreshToken
)
_tokens: nil // Tokens have been moved out of `State` to the keychain.
)
}
}

View File

@ -57,10 +57,7 @@ class AccountTests: BitwardenTestCase {
userId: "13512467-9cfe-43b0-969f-07534084764b"
),
settings: Account.AccountSettings(environmentUrls: nil),
tokens: Account.AccountTokens(
accessToken: accessToken,
refreshToken: "REFRESH_TOKEN"
)
_tokens: nil
)
)
}

View File

@ -6,12 +6,12 @@ extension Account {
static func fixture(
profile: AccountProfile = .fixture(),
settings: AccountSettings = .fixture(),
tokens: AccountTokens = .fixture()
tokens: AccountTokens? = nil
) -> Account {
Account(
profile: profile,
settings: settings,
tokens: tokens
_tokens: tokens
)
}
@ -32,9 +32,7 @@ extension Account {
settings: Account.AccountSettings(
environmentUrls: EnvironmentUrlData(base: URL(string: "https://vault.bitwarden.com")!)
),
tokens: Account.AccountTokens.fixture(
accessToken: IdentityTokenResponseModel.fixture().accessToken
)
tokens: nil
)
}
}
@ -84,15 +82,3 @@ extension Account.AccountSettings {
)
}
}
extension Account.AccountTokens {
static func fixture(
accessToken: String = "ACCESS_TOKEN",
refreshToken: String = "REFRESH_TOKEN"
) -> Account.AccountTokens {
Account.AccountTokens(
accessToken: accessToken,
refreshToken: refreshToken
)
}
}

View File

@ -0,0 +1,7 @@
/// A protocol for the application instance (i.e. `UIApplication`).
///
public protocol Application {
/// Registers the application to receive remote push notifications.
///
func registerForRemoteNotifications()
}

View File

@ -0,0 +1,90 @@
import OSLog
// MARK: - MigrationService
/// A protocol for a service which handles app data migrations.
///
protocol MigrationService: AnyObject {
/// Performs any necessary app data migrations.
///
func performMigrations() async
}
// MARK: - DefaultMigrationService
/// A default implementation of `MigrationService` which handles app data migrations.
///
class DefaultMigrationService {
// MARK: Properties
/// The service used by the application to persist app setting values.
let appSettingsStore: AppSettingsStore
/// The service used by the application to report non-fatal errors.
let errorReporter: ErrorReporter
/// The repository used to manage keychain items.
let keychainRepository: KeychainRepository
// MARK: Initialization
/// Initialize a `DefaultMigrationService`.
///
/// - Parameters:
/// - appSettingsStore: The service used by the application to persist app setting values.
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - keychainRepository: The repository used to manage keychain items.
///
init(
appSettingsStore: AppSettingsStore,
errorReporter: ErrorReporter,
keychainRepository: KeychainRepository
) {
self.appSettingsStore = appSettingsStore
self.errorReporter = errorReporter
self.keychainRepository = keychainRepository
}
// MARK: Private
/// Performs migration 1.
///
/// Notes:
/// - Migrates account tokens from UserDefaults to Keychain.
///
private func performMigration1() async throws {
guard var state = appSettingsStore.state else { return }
defer { appSettingsStore.state = state }
for (accountId, account) in state.accounts {
let tokens = account._tokens
state.accounts[accountId]?._tokens = nil
guard let tokens else { continue }
try await keychainRepository.setAccessToken(tokens.accessToken, userId: accountId)
try await keychainRepository.setRefreshToken(tokens.refreshToken, userId: accountId)
}
}
}
extension DefaultMigrationService: MigrationService {
func performMigrations() async {
var migrationVersion = appSettingsStore.migrationVersion
defer { appSettingsStore.migrationVersion = migrationVersion }
// The list of migrations that can be performed.
let migrations: [(version: Int, method: () async throws -> Void)] = [
(1, performMigration1),
]
do {
for migration in migrations where migrationVersion < migration.version {
try await migration.method()
migrationVersion = migration.version
Logger.application.info("Completed data migration \(migration.version)")
}
} catch {
errorReporter.log(error: error)
}
}
}

View File

@ -0,0 +1,108 @@
import XCTest
@testable import BitwardenShared
class MigrationServiceTests: BitwardenTestCase {
// MARK: Properties
var appSettingsStore: MockAppSettingsStore!
var errorReporter: MockErrorReporter!
var keychainRepository: MockKeychainRepository!
var subject: DefaultMigrationService!
// MARK: Setup & Teardown
override func setUp() {
super.setUp()
appSettingsStore = MockAppSettingsStore()
errorReporter = MockErrorReporter()
keychainRepository = MockKeychainRepository()
subject = DefaultMigrationService(
appSettingsStore: appSettingsStore,
errorReporter: errorReporter,
keychainRepository: keychainRepository
)
}
override func tearDown() {
super.tearDown()
appSettingsStore = nil
errorReporter = nil
keychainRepository = nil
subject = nil
}
// MARK: Tests
/// `performMigrations()` logs an error to the error reporter if one occurs.
func test_performMigrations_error() async throws {
appSettingsStore.migrationVersion = 0
appSettingsStore.state = .fixture(
accounts: [
"1": .fixture(
tokens: Account.AccountTokens(
accessToken: "ACCESS_TOKEN_1",
refreshToken: "REFRESH_TOKEN_1"
)
),
],
activeUserId: "1"
)
keychainRepository.setAccessTokenResult = .failure(KeychainServiceError.osStatusError(-1))
await subject.performMigrations()
XCTAssertEqual(appSettingsStore.migrationVersion, 0)
XCTAssertEqual(errorReporter.errors as? [KeychainServiceError], [KeychainServiceError.osStatusError(-1)])
}
/// `performMigrations()` performs migration 1 and moves the user's tokens to the keychain.
func test_performMigrations_1_withAccounts() async throws {
appSettingsStore.migrationVersion = 0
appSettingsStore.state = .fixture(
accounts: [
"1": .fixture(
tokens: Account.AccountTokens(
accessToken: "ACCESS_TOKEN_1",
refreshToken: "REFRESH_TOKEN_1"
)
),
"2": .fixture(
tokens: Account.AccountTokens(
accessToken: "ACCESS_TOKEN_2",
refreshToken: "REFRESH_TOKEN_2"
)
),
],
activeUserId: "1"
)
await subject.performMigrations()
XCTAssertEqual(appSettingsStore.migrationVersion, 1)
let account1 = try XCTUnwrap(appSettingsStore.state?.accounts["1"])
XCTAssertNil(account1._tokens)
let account2 = try XCTUnwrap(appSettingsStore.state?.accounts["2"])
XCTAssertNil(account2._tokens)
try XCTAssertEqual(keychainRepository.getValue(for: .accessToken(userId: "1")), "ACCESS_TOKEN_1")
try XCTAssertEqual(keychainRepository.getValue(for: .refreshToken(userId: "1")), "REFRESH_TOKEN_1")
try XCTAssertEqual(keychainRepository.getValue(for: .accessToken(userId: "2")), "ACCESS_TOKEN_2")
try XCTAssertEqual(keychainRepository.getValue(for: .refreshToken(userId: "2")), "REFRESH_TOKEN_2")
}
/// `performMigrations()` for migration 1 handles no existing accounts.
func test_performMigrations_1_withNoAccounts() async throws {
appSettingsStore.migrationVersion = 0
appSettingsStore.state = nil
await subject.performMigrations()
XCTAssertEqual(appSettingsStore.migrationVersion, 1)
XCTAssertNil(appSettingsStore.state)
}
}

View File

@ -23,6 +23,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// The service used by the application to manage the app's ID.
let appIdService: AppIdService
/// The application instance (i.e. `UIApplication`), if the app isn't running in an extension.
let application: Application?
/// The service used by the application to persist app setting values.
let appSettingsStore: AppSettingsStore
@ -32,7 +35,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// The service used by the application to handle authentication tasks.
let authService: AuthService
/// The repository to manage bioemtric unlock policies and access controls the user.
/// The repository to manage biometric unlock policies and access controls the user.
let biometricsRepository: BiometricsRepository
/// The service used to obtain device biometrics status & data.
@ -65,6 +68,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// The repository used to manage keychain items.
let keychainRepository: KeychainRepository
/// The serviced used to perform app data migrations.
let migrationService: MigrationService
/// The service used by the application to read NFC tags.
let nfcReaderService: NFCReaderService
@ -123,6 +129,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// - Parameters:
/// - apiService: The service used by the application to make API requests.
/// - appIdService: The service used by the application to manage the app's ID.
/// - application: The application instance.
/// - appSettingsStore: The service used by the application to persist app setting values.
/// - authRepository: The repository used by the application to manage auth data for the UI layer.
/// - authService: The service used by the application to handle authentication tasks.
@ -137,6 +144,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// - generatorRepository: The repository used by the application to manage generator data for the UI layer.
/// - keychainRepository: The repository used to manages keychain items.
/// - keychainService: The service used to access & store data on the device keychain.
/// - migrationService: The serviced used to perform app data migrations.
/// - nfcReaderService: The service used by the application to read NFC tags.
/// - notificationCenterService: The service used by the application to access the system's notification center.
/// - notificationService: The service used by the application to handle notifications.
@ -158,6 +166,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
init(
apiService: APIService,
appIdService: AppIdService,
application: Application?,
appSettingsStore: AppSettingsStore,
authRepository: AuthRepository,
authService: AuthService,
@ -172,6 +181,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
generatorRepository: GeneratorRepository,
keychainRepository: KeychainRepository,
keychainService: KeychainService,
migrationService: MigrationService,
nfcReaderService: NFCReaderService,
notificationCenterService: NotificationCenterService,
notificationService: NotificationService,
@ -192,6 +202,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
) {
self.apiService = apiService
self.appIdService = appIdService
self.application = application
self.appSettingsStore = appSettingsStore
self.authRepository = authRepository
self.authService = authService
@ -206,6 +217,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
self.generatorRepository = generatorRepository
self.keychainService = keychainService
self.keychainRepository = keychainRepository
self.migrationService = migrationService
self.nfcReaderService = nfcReaderService
self.notificationCenterService = notificationCenterService
self.notificationService = notificationService
@ -228,10 +240,12 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// A convenience initializer to initialize the `ServiceContainer` with the default services.
///
/// - Parameters:
/// - application: The application instance.
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - nfcReaderService: The service used by the application to read NFC tags.
///
public convenience init( // swiftlint:disable:this function_body_length
application: Application? = nil,
errorReporter: ErrorReporter,
nfcReaderService: NFCReaderService? = nil
) {
@ -263,7 +277,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
let environmentService = DefaultEnvironmentService(stateService: stateService)
let collectionService = DefaultCollectionService(collectionDataStore: dataStore, stateService: stateService)
let settingsService = DefaultSettingsService(settingsDataStore: dataStore, stateService: stateService)
let tokenService = DefaultTokenService(stateService: stateService)
let tokenService = DefaultTokenService(keychainRepository: keychainRepository, stateService: stateService)
let apiService = APIService(environmentService: environmentService, tokenService: tokenService)
let captchaService = DefaultCaptchaService(environmentService: environmentService, stateService: stateService)
let notificationCenterService = DefaultNotificationCenterService()
@ -350,6 +364,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
clientGenerators: clientService.clientGenerator(),
clientPlatform: clientService.clientPlatform(),
environmentService: environmentService,
keychainRepository: keychainRepository,
policyService: policyService,
stateService: stateService,
systemDevice: UIDevice.current
@ -369,6 +384,12 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
vaultTimeoutService: vaultTimeoutService
)
let migrationService = DefaultMigrationService(
appSettingsStore: appSettingsStore,
errorReporter: errorReporter,
keychainRepository: keychainRepository
)
let notificationService = DefaultNotificationService(
appIdService: appIdService,
authRepository: authRepository,
@ -426,6 +447,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
self.init(
apiService: apiService,
appIdService: appIdService,
application: application,
appSettingsStore: appSettingsStore,
authRepository: authRepository,
authService: authService,
@ -440,6 +462,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
generatorRepository: generatorRepository,
keychainRepository: keychainRepository,
keychainService: keychainService,
migrationService: migrationService,
nfcReaderService: nfcReaderService ?? NoopNFCReaderService(),
notificationCenterService: notificationCenterService,
notificationService: notificationService,

View File

@ -445,15 +445,6 @@ protocol StateService: AnyObject {
///
func setTimeoutAction(action: SessionTimeoutAction, userId: String?) async throws
/// Sets a new access and refresh token for an account.
///
/// - Parameters:
/// - accessToken: The account's updated access token.
/// - refreshToken: The account's updated refresh token.
/// - userId: The user ID of the account. Defaults to the active account if `nil`.
///
func setTokens(accessToken: String, refreshToken: String, userId: String?) async throws
/// Sets the user's two-factor token.
///
/// - Parameters:
@ -844,16 +835,6 @@ extension StateService {
try await setTimeoutAction(action: action, userId: nil)
}
/// Sets a new access and refresh token for the active account.
///
/// - Parameters:
/// - accessToken: The account's updated access token.
/// - refreshToken: The account's updated refresh token.
///
func setTokens(accessToken: String, refreshToken: String) async throws {
try await setTokens(accessToken: accessToken, refreshToken: refreshToken, userId: nil)
}
/// Sets the number of unsuccessful attempts to unlock the vault for the active account.
///
/// - Parameter attempts: The number of unsuccessful unlock attempts.
@ -1114,7 +1095,7 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
func getUnsuccessfulUnlockAttempts(userId: String?) async throws -> Int {
let userId = try userId ?? getActiveAccountUserId()
return appSettingsStore.unsuccessfulUnlockAttempts(userId: userId) ?? 0
return appSettingsStore.unsuccessfulUnlockAttempts(userId: userId)
}
func getUsernameGenerationOptions(userId: String?) async throws -> UsernameGenerationOptions? {
@ -1296,20 +1277,6 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
appSettingsStore.setTimeoutAction(key: action, userId: userId)
}
func setTokens(accessToken: String, refreshToken: String, userId: String?) async throws {
guard var state = appSettingsStore.state,
let userId = userId ?? state.activeUserId
else {
throw StateServiceError.noActiveAccount
}
state.accounts[userId]?.tokens = Account.AccountTokens(
accessToken: accessToken,
refreshToken: refreshToken
)
appSettingsStore.state = state
}
func setTwoFactorToken(_ token: String?, email: String) async {
appSettingsStore.setTwoFactorToken(token, email: email)
}

View File

@ -905,43 +905,6 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
)
}
/// `setTokens(accessToken:refreshToken)` throws an error if there isn't an active account.
func test_setAccountTokens_noAccount() async {
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
try await subject.setTokens(accessToken: "🔑", refreshToken: "🔒")
}
}
/// `setTokens(accessToken:refreshToken)` sets the tokens for a single account.
func test_setAccountTokens_singleAccount() async throws {
await subject.addAccount(.fixture())
try await subject.setTokens(accessToken: "🔑", refreshToken: "🔒")
let account = try XCTUnwrap(appSettingsStore.state?.accounts["1"])
XCTAssertEqual(
account,
Account.fixture(tokens: Account.AccountTokens(accessToken: "🔑", refreshToken: "🔒"))
)
}
/// `setTokens(accessToken:refreshToken)` sets the tokens for an account where there are multiple accounts.
func test_setAccountTokens_multipleAccount() async throws {
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
await subject.addAccount(.fixture(profile: .fixture(userId: "2")))
try await subject.setTokens(accessToken: "🔑", refreshToken: "🔒")
let account = try XCTUnwrap(appSettingsStore.state?.accounts["2"])
XCTAssertEqual(
account,
Account.fixture(
profile: .fixture(userId: "2"),
tokens: Account.AccountTokens(accessToken: "🔑", refreshToken: "🔒")
)
)
}
/// `setActiveAccount(userId: )` returns without action if there are no accounts
func test_setActiveAccount_noAccounts() async throws {
let storeState = await subject.appSettingsStore.state

View File

@ -31,6 +31,9 @@ protocol AppSettingsStore: AnyObject {
/// The login request information received from a push notification.
var loginRequest: LoginRequestNotification? { get set }
/// The app's last data migration version.
var migrationVersion: Int { get set }
/// The environment URLs used prior to user authentication.
var preAuthEnvironmentUrls: EnvironmentUrlData? { get set }
@ -373,7 +376,7 @@ protocol AppSettingsStore: AnyObject {
/// - Parameter userId: The user ID associated with the unsuccessful unlock attempts.
/// - Returns: The number of unsuccessful attempts to unlock the vault.
///
func unsuccessfulUnlockAttempts(userId: String) -> Int?
func unsuccessfulUnlockAttempts(userId: String) -> Int
/// Returns the session timeout date.
///
@ -435,7 +438,7 @@ class DefaultAppSettingsStore {
/// - Parameter key: The key used to store the value.
/// - Returns: The value associated with the given key.
///
private func fetch(for key: Keys) -> Int? {
private func fetch(for key: Keys) -> Int {
userDefaults.integer(forKey: key.storageKey)
}
@ -526,6 +529,7 @@ extension DefaultAppSettingsStore: AppSettingsStore {
case lastUserShouldConnectToWatch
case loginRequest
case masterPasswordHash(userId: String)
case migrationVersion
case notificationsLastRegistrationDate(userId: String)
case passwordGenerationOptions(userId: String)
case pinKeyEncryptedUserKey(userId: String)
@ -584,6 +588,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
key = "passwordlessLoginNotificationKey"
case let .masterPasswordHash(userId):
key = "keyHash_\(userId)"
case .migrationVersion:
key = "migrationVersion"
case let .notificationsLastRegistrationDate(userId):
key = "pushLastRegistrationDate_\(userId)"
case let .passwordGenerationOptions(userId):
@ -650,6 +656,11 @@ extension DefaultAppSettingsStore: AppSettingsStore {
set { store(newValue, for: .loginRequest) }
}
var migrationVersion: Int {
get { fetch(for: .migrationVersion) }
set { store(newValue, for: .migrationVersion) }
}
var preAuthEnvironmentUrls: EnvironmentUrlData? {
get { fetch(for: .preAuthEnvironmentUrls) }
set { store(newValue, for: .preAuthEnvironmentUrls) }
@ -852,7 +863,7 @@ extension DefaultAppSettingsStore: AppSettingsStore {
fetch(for: .vaultTimeout(userId: userId))
}
func unsuccessfulUnlockAttempts(userId: String) -> Int? {
func unsuccessfulUnlockAttempts(userId: String) -> Int {
fetch(for: .unsuccessfulUnlockAttempts(userId: userId))
}

View File

@ -416,6 +416,22 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_
)
}
/// `migrationVersion` returns `0` if there isn't a previously stored value.
func test_migrationVersion_isInitiallyZero() {
XCTAssertEqual(subject.migrationVersion, 0)
}
/// `migrationVersion` can be used to get and set the migration version.
func test_migrationVersion_withValue() throws {
subject.migrationVersion = 1
XCTAssertEqual(userDefaults.integer(forKey: "bwPreferencesStorage:migrationVersion"), 1)
XCTAssertEqual(subject.migrationVersion, 1)
subject.migrationVersion = 2
XCTAssertEqual(userDefaults.integer(forKey: "bwPreferencesStorage:migrationVersion"), 2)
XCTAssertEqual(subject.migrationVersion, 2)
}
/// `masterPasswordHash(userId:)` returns `nil` if there isn't a previously stored value.
func test_masterPasswordHash_isInitiallyNil() {
XCTAssertNil(subject.masterPasswordHash(userId: "-1"))

View File

@ -12,6 +12,7 @@ class MockAppSettingsStore: AppSettingsStore {
var disableWebIcons = false
var lastUserShouldConnectToWatch = false
var loginRequest: LoginRequestNotification?
var migrationVersion = 0
var preAuthEnvironmentUrls: EnvironmentUrlData?
var rememberedEmail: String?
var rememberedOrgIdentifier: String?
@ -211,8 +212,8 @@ class MockAppSettingsStore: AppSettingsStore {
timeoutAction[userId]
}
func unsuccessfulUnlockAttempts(userId: String) -> Int? {
unsuccessfulUnlockAttempts[userId]
func unsuccessfulUnlockAttempts(userId: String) -> Int {
unsuccessfulUnlockAttempts[userId] ?? 0
}
func usernameGenerationOptions(userId: String) -> UsernameGenerationOptions? {

View File

@ -0,0 +1,9 @@
@testable import BitwardenShared
class MockMigrationService: MigrationService {
var didPerformMigrations: Bool?
func performMigrations() async {
didPerformMigrations = true
}
}

View File

@ -5,6 +5,7 @@ import Networking
extension ServiceContainer {
static func withMocks(
application: Application? = nil,
appSettingsStore: AppSettingsStore = MockAppSettingsStore(),
authRepository: AuthRepository = MockAuthRepository(),
authService: AuthService = MockAuthService(),
@ -20,6 +21,7 @@ extension ServiceContainer {
httpClient: HTTPClient = MockHTTPClient(),
keychainRepository: KeychainRepository = MockKeychainRepository(),
keychainService: KeychainService = MockKeychainService(),
migrationService: MigrationService = MockMigrationService(),
nfcReaderService: NFCReaderService = MockNFCReaderService(),
notificationService: NotificationService = MockNotificationService(),
pasteboardService: PasteboardService = MockPasteboardService(),
@ -44,6 +46,7 @@ extension ServiceContainer {
environmentService: environmentService
),
appIdService: AppIdService(appSettingStore: appSettingsStore),
application: application,
appSettingsStore: appSettingsStore,
authRepository: authRepository,
authService: authService,
@ -58,6 +61,7 @@ extension ServiceContainer {
generatorRepository: generatorRepository,
keychainRepository: keychainRepository,
keychainService: keychainService,
migrationService: migrationService,
nfcReaderService: nfcReaderService,
notificationCenterService: notificationCenterService,
notificationService: notificationService,

View File

@ -29,6 +29,9 @@ protocol TokenService: AnyObject {
actor DefaultTokenService: TokenService {
// MARK: Properties
/// The repository used to manages keychain items.
let keychainRepository: KeychainRepository
/// The service that manages the account state.
let stateService: StateService
@ -36,23 +39,33 @@ actor DefaultTokenService: TokenService {
/// Initialize a `DefaultTokenService`.
///
/// - Parameter stateService: The service that manages the account state.
/// - Parameters
/// - keychainRepository: The repository used to manages keychain items.
/// - stateService: The service that manages the account state.
///
init(stateService: StateService) {
init(
keychainRepository: KeychainRepository,
stateService: StateService
) {
self.keychainRepository = keychainRepository
self.stateService = stateService
}
// MARK: Methods
func getAccessToken() async throws -> String {
try await stateService.getActiveAccount().tokens.accessToken
let userId = try await stateService.getActiveAccountId()
return try await keychainRepository.getAccessToken(userId: userId)
}
func getRefreshToken() async throws -> String {
try await stateService.getActiveAccount().tokens.refreshToken
let userId = try await stateService.getActiveAccountId()
return try await keychainRepository.getRefreshToken(userId: userId)
}
func setTokens(accessToken: String, refreshToken: String) async throws {
try await stateService.setTokens(accessToken: accessToken, refreshToken: refreshToken)
let userId = try await stateService.getActiveAccountId()
try await keychainRepository.setAccessToken(accessToken, userId: userId)
try await keychainRepository.setRefreshToken(refreshToken, userId: userId)
}
}

View File

@ -5,6 +5,7 @@ import XCTest
class TokenServiceTests: BitwardenTestCase {
// MARK: Properties
var keychainRepository: MockKeychainRepository!
var stateService: MockStateService!
var subject: DefaultTokenService!
@ -13,14 +14,16 @@ class TokenServiceTests: BitwardenTestCase {
override func setUp() {
super.setUp()
keychainRepository = MockKeychainRepository()
stateService = MockStateService()
subject = DefaultTokenService(stateService: stateService)
subject = DefaultTokenService(keychainRepository: keychainRepository, stateService: stateService)
}
override func tearDown() {
super.tearDown()
keychainRepository = nil
stateService = nil
subject = nil
}
@ -34,7 +37,7 @@ class TokenServiceTests: BitwardenTestCase {
let accessToken = try await subject.getAccessToken()
XCTAssertEqual(accessToken, "ACCESS_TOKEN")
stateService.activeAccount = .fixture(tokens: Account.AccountTokens(accessToken: "🔑", refreshToken: "🔒"))
keychainRepository.getAccessTokenResult = .success("🔑")
let updatedAccessToken = try await subject.getAccessToken()
XCTAssertEqual(updatedAccessToken, "🔑")
@ -56,7 +59,7 @@ class TokenServiceTests: BitwardenTestCase {
let refreshToken = try await subject.getRefreshToken()
XCTAssertEqual(refreshToken, "REFRESH_TOKEN")
stateService.activeAccount = .fixture(tokens: Account.AccountTokens(accessToken: "🔑", refreshToken: "🔒"))
keychainRepository.getRefreshTokenResult = .success("🔒")
let updatedRefreshToken = try await subject.getRefreshToken()
XCTAssertEqual(updatedRefreshToken, "🔒")
@ -78,8 +81,12 @@ class TokenServiceTests: BitwardenTestCase {
try await subject.setTokens(accessToken: "🔑", refreshToken: "🔒")
XCTAssertEqual(
stateService.accountTokens,
Account.AccountTokens(accessToken: "🔑", refreshToken: "🔒")
keychainRepository.mockStorage[keychainRepository.formattedKey(for: .accessToken(userId: "1"))],
"🔑"
)
XCTAssertEqual(
keychainRepository.mockStorage[keychainRepository.formattedKey(for: .refreshToken(userId: "1"))],
"🔒"
)
}
}

View File

@ -84,15 +84,16 @@ public class AppProcessor {
window?.overrideUserInterfaceStyle = appTheme.userInterfaceStyle
}
}
Task {
await services.environmentService.loadURLsForActiveAccount()
}
if let initialRoute {
coordinator.navigate(to: initialRoute)
} else {
// Navigate to the .didStart rotue
Task {
Task {
await services.migrationService.performMigrations()
await services.environmentService.loadURLsForActiveAccount()
services.application?.registerForRemoteNotifications()
if let initialRoute {
coordinator.navigate(to: initialRoute)
} else {
await coordinator.handleEvent(.didStart)
}
}

View File

@ -10,6 +10,7 @@ class AppProcessorTests: BitwardenTestCase {
var appSettingStore: MockAppSettingsStore!
var coordinator: MockCoordinator<AppRoute, AppEvent>!
var errorReporter: MockErrorReporter!
var migrationService: MockMigrationService!
var notificationCenterService: MockNotificationCenterService!
var notificationService: MockNotificationService!
var router: MockRouter<AuthEvent, AuthRoute>!
@ -31,6 +32,7 @@ class AppProcessorTests: BitwardenTestCase {
appModule.appCoordinator = coordinator
appSettingStore = MockAppSettingsStore()
errorReporter = MockErrorReporter()
migrationService = MockMigrationService()
notificationCenterService = MockNotificationCenterService()
notificationService = MockNotificationService()
stateService = MockStateService()
@ -43,6 +45,7 @@ class AppProcessorTests: BitwardenTestCase {
services: ServiceContainer.withMocks(
appSettingsStore: appSettingStore,
errorReporter: errorReporter,
migrationService: migrationService,
notificationService: notificationService,
stateService: stateService,
syncService: syncService,
@ -59,6 +62,7 @@ class AppProcessorTests: BitwardenTestCase {
appSettingStore = nil
coordinator = nil
errorReporter = nil
migrationService = nil
notificationCenterService = nil
notificationService = nil
stateService = nil
@ -161,11 +165,14 @@ class AppProcessorTests: BitwardenTestCase {
window: nil
)
waitFor(!coordinator.routes.isEmpty)
XCTAssertTrue(appModule.appCoordinator.isStarted)
XCTAssertEqual(
appModule.appCoordinator.routes,
[.extensionSetup(.extensionActivation(type: .appExtension))]
)
XCTAssertEqual(migrationService.didPerformMigrations, true)
}
/// `start(navigator:)` builds the AppCoordinator and navigates to the `.didStart` route.
@ -178,5 +185,6 @@ class AppProcessorTests: BitwardenTestCase {
XCTAssertTrue(appModule.appCoordinator.isStarted)
XCTAssertEqual(appModule.appCoordinator.events, [.didStart])
XCTAssertEqual(migrationService.didPerformMigrations, true)
}
}