From a374b64cb8e6786f79279e8cf37e2d673ba058e5 Mon Sep 17 00:00:00 2001 From: amddg44 Date: Mon, 9 Dec 2024 15:44:39 +0100 Subject: [PATCH] QuickType support for iOS (#1123) Task/Issue URL: iOS PR: https://github.com/duckduckgo/iOS/pull/3696 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3648 What kind of version bump will this require?: Minor **Description**: Adds support for QuickType to the Credential provider extension along with deduplication logic --- .../ASCredentialIdentityStoring.swift | 37 +++ ...tofillCredentialIdentityStoreManager.swift | 272 ++++++++++++++++++ .../SecureVault/SecureVaultModels.swift | 32 +++ .../Credentials/CredentialsProvider.swift | 16 +- .../internal/CredentialsResponseHandler.swift | 19 +- ...lCredentialIdentityStoreManagerTests.swift | 207 +++++++++++++ .../MockASCredentialIdentityStore.swift | 125 ++++++++ .../SecureVault/SecureVaultModelTests.swift | 155 ++++++++++ .../CredentialsProviderTestsBase.swift | 3 +- 9 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 Sources/BrowserServicesKit/SecureVault/ASCredentialIdentityStoring.swift create mode 100644 Sources/BrowserServicesKit/SecureVault/AutofillCredentialIdentityStoreManager.swift create mode 100644 Tests/BrowserServicesKitTests/SecureVault/AutofillCredentialIdentityStoreManagerTests.swift create mode 100644 Tests/BrowserServicesKitTests/SecureVault/MockASCredentialIdentityStore.swift diff --git a/Sources/BrowserServicesKit/SecureVault/ASCredentialIdentityStoring.swift b/Sources/BrowserServicesKit/SecureVault/ASCredentialIdentityStoring.swift new file mode 100644 index 000000000..a8523d9b9 --- /dev/null +++ b/Sources/BrowserServicesKit/SecureVault/ASCredentialIdentityStoring.swift @@ -0,0 +1,37 @@ +// +// ASCredentialIdentityStoring.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// 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 Foundation +import AuthenticationServices + +// This is used to abstract the ASCredentialIdentityStore for testing purposes +public protocol ASCredentialIdentityStoring { + func state() async -> ASCredentialIdentityStoreState + func saveCredentialIdentities(_ credentials: [ASPasswordCredentialIdentity]) async throws + func removeCredentialIdentities(_ credentials: [ASPasswordCredentialIdentity]) async throws + func replaceCredentialIdentities(with newCredentials: [ASPasswordCredentialIdentity]) async throws + + @available(iOS 17.0, macOS 14.0, *) + func saveCredentialIdentities(_ credentials: [ASCredentialIdentity]) async throws + @available(iOS 17.0, macOS 14.0, *) + func removeCredentialIdentities(_ credentials: [ASCredentialIdentity]) async throws + @available(iOS 17.0, macOS 14.0, *) + func replaceCredentialIdentities(_ newCredentials: [ASCredentialIdentity]) async throws +} + +extension ASCredentialIdentityStore: ASCredentialIdentityStoring {} diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillCredentialIdentityStoreManager.swift b/Sources/BrowserServicesKit/SecureVault/AutofillCredentialIdentityStoreManager.swift new file mode 100644 index 000000000..7ecdc5012 --- /dev/null +++ b/Sources/BrowserServicesKit/SecureVault/AutofillCredentialIdentityStoreManager.swift @@ -0,0 +1,272 @@ +// +// AutofillCredentialIdentityStoreManager.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// 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 Foundation +import AuthenticationServices +import Common +import os.log + +public protocol AutofillCredentialIdentityStoreManaging { + func credentialStoreStateEnabled() async -> Bool + func populateCredentialStore() async + func replaceCredentialStore(with accounts: [SecureVaultModels.WebsiteAccount]) async + func updateCredentialStore(for domain: String) async + func updateCredentialStoreWith(updatedAccounts: [SecureVaultModels.WebsiteAccount], deletedAccounts: [SecureVaultModels.WebsiteAccount]) async +} + +final public class AutofillCredentialIdentityStoreManager: AutofillCredentialIdentityStoreManaging { + + private let credentialStore: ASCredentialIdentityStoring + private let vault: (any AutofillSecureVault)? + private let tld: TLD + + public init(credentialStore: ASCredentialIdentityStoring = ASCredentialIdentityStore.shared, + vault: (any AutofillSecureVault)?, + tld: TLD) { + self.credentialStore = credentialStore + self.vault = vault + self.tld = tld + } + + // MARK: - Credential Store State + + public func credentialStoreStateEnabled() async -> Bool { + let state = await credentialStore.state() + return state.isEnabled + } + + // MARK: - Credential Store Operations + + public func populateCredentialStore() async { + guard await credentialStoreStateEnabled() else { return } + + do { + let accounts = try fetchAccounts() + try await generateAndSaveCredentialIdentities(from: accounts) + } catch { + Logger.autofill.error("Failed to populate credential store: \(error.localizedDescription, privacy: .public)") + } + } + + public func replaceCredentialStore(with accounts: [SecureVaultModels.WebsiteAccount]) async { + guard await credentialStoreStateEnabled() else { return } + + do { + if #available(iOS 17, macOS 14.0, *) { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [any ASCredentialIdentity] + try await replaceCredentialStoreIdentities(credentialIdentities) + } else { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [ASPasswordCredentialIdentity] + try await replaceCredentialStoreIdentities(with: credentialIdentities) + } + } catch { + Logger.autofill.error("Failed to replace credential store: \(error.localizedDescription, privacy: .public)") + } + } + + public func updateCredentialStore(for domain: String) async { + guard await credentialStoreStateEnabled() else { return } + + do { + if await storeSupportsIncrementalUpdates() { + let accounts = try fetchAccountsFor(domain: domain) + try await generateAndSaveCredentialIdentities(from: accounts) + } else { + await replaceCredentialStore() + } + } catch { + Logger.autofill.error("Failed to update credential store \(error.localizedDescription, privacy: .public)") + } + } + + public func updateCredentialStoreWith(updatedAccounts: [SecureVaultModels.WebsiteAccount], deletedAccounts: [SecureVaultModels.WebsiteAccount]) async { + guard await credentialStoreStateEnabled() else { return } + + do { + if await storeSupportsIncrementalUpdates() { + if !updatedAccounts.isEmpty { + try await generateAndSaveCredentialIdentities(from: updatedAccounts) + } + + if !deletedAccounts.isEmpty { + try await generateAndDeleteCredentialIdentities(from: deletedAccounts) + } + } else { + await replaceCredentialStore() + } + } catch { + Logger.autofill.error("Failed to update credential store with updated / deleted accounts \(error.localizedDescription, privacy: .public)") + } + + } + + // MARK: - Private Store Operations + + private func storeSupportsIncrementalUpdates() async -> Bool { + let state = await credentialStore.state() + return state.supportsIncrementalUpdates + } + + private func replaceCredentialStore() async { + guard await credentialStoreStateEnabled() else { return } + + do { + let accounts = try fetchAccounts() + + Task { + await replaceCredentialStore(with: accounts) + } + } catch { + Logger.autofill.error("Failed to replace credential store: \(error.localizedDescription, privacy: .public)") + } + } + + private func generateAndSaveCredentialIdentities(from accounts: [SecureVaultModels.WebsiteAccount]) async throws { + if #available(iOS 17, macOS 14.0, *) { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [any ASCredentialIdentity] + try await saveToCredentialStore(credentials: credentialIdentities) + } else { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [ASPasswordCredentialIdentity] + try await saveToCredentialStore(credentials: credentialIdentities) + } + } + + private func generateAndDeleteCredentialIdentities(from accounts: [SecureVaultModels.WebsiteAccount]) async throws { + if #available(iOS 17, macOS 14.0, *) { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [any ASCredentialIdentity] + try await removeCredentialStoreIdentities(credentialIdentities) + } else { + let credentialIdentities = try await generateCredentialIdentities(from: accounts) as [ASPasswordCredentialIdentity] + try await removeCredentialStoreIdentities(credentialIdentities) + } + } + + private func saveToCredentialStore(credentials: [ASPasswordCredentialIdentity]) async throws { + do { + try await credentialStore.saveCredentialIdentities(credentials) + } catch { + Logger.autofill.error("Failed to save credentials to ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + @available(iOS 17.0, macOS 14.0, *) + private func saveToCredentialStore(credentials: [ASCredentialIdentity]) async throws { + do { + try await credentialStore.saveCredentialIdentities(credentials) + } catch { + Logger.autofill.error("Failed to save credentials to ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + private func replaceCredentialStoreIdentities(with credentials: [ASPasswordCredentialIdentity]) async throws { + do { + try await credentialStore.replaceCredentialIdentities(with: credentials) + } catch { + Logger.autofill.error("Failed to replace credentials in ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + @available(iOS 17.0, macOS 14.0, *) + private func replaceCredentialStoreIdentities(_ credentials: [ASCredentialIdentity]) async throws { + do { + try await credentialStore.replaceCredentialIdentities(credentials) + } catch { + Logger.autofill.error("Failed to replace credentials in ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + private func removeCredentialStoreIdentities(_ credentials: [ASPasswordCredentialIdentity]) async throws { + do { + try await credentialStore.removeCredentialIdentities(credentials) + } catch { + Logger.autofill.error("Failed to remove credentials from ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + @available(iOS 17.0, macOS 14.0, *) + private func removeCredentialStoreIdentities(_ credentials: [ASCredentialIdentity]) async throws { + do { + try await credentialStore.removeCredentialIdentities(credentials) + } catch { + Logger.autofill.error("Failed to remove credentials from ASCredentialIdentityStore: \(error.localizedDescription, privacy: .public)") + throw error + } + } + + private func generateCredentialIdentities(from accounts: [SecureVaultModels.WebsiteAccount]) async throws -> [ASPasswordCredentialIdentity] { + let sortedAndDedupedAccounts = accounts.sortedAndDeduplicated(tld: tld) + let groupedAccounts = Dictionary(grouping: sortedAndDedupedAccounts, by: { $0.domain ?? "" }) + var credentialIdentities: [ASPasswordCredentialIdentity] = [] + + for (_, accounts) in groupedAccounts { + // Since accounts are sorted, ranking can be assigned based on index + // but first need to be reversed as highest ranking should apply to the most recently used account + for (rank, account) in accounts.reversed().enumerated() { + let credentialIdentity = createPasswordCredentialIdentity(from: account) + credentialIdentity.rank = rank + credentialIdentities.append(credentialIdentity) + } + } + + return credentialIdentities + } + + private func createPasswordCredentialIdentity(from account: SecureVaultModels.WebsiteAccount) -> ASPasswordCredentialIdentity { + let serviceIdentifier = ASCredentialServiceIdentifier(identifier: account.domain ?? "", type: .domain) + return ASPasswordCredentialIdentity(serviceIdentifier: serviceIdentifier, + user: account.username ?? "", + recordIdentifier: account.id) + } + + // MARK: - Private Secure Vault Operations + + private func fetchAccounts() throws -> [SecureVaultModels.WebsiteAccount] { + guard let vault = vault else { + Logger.autofill.error("Vault not created") + return [] + } + + do { + return try vault.accounts() + } catch { + Logger.autofill.error("Failed to fetch accounts \(error.localizedDescription, privacy: .public)") + throw error + } + + } + + private func fetchAccountsFor(domain: String) throws -> [SecureVaultModels.WebsiteAccount] { + guard let vault = vault else { + Logger.autofill.error("Vault not created") + return [] + } + + do { + return try vault.accountsFor(domain: domain) + } catch { + Logger.autofill.error("Failed to fetch accounts \(error.localizedDescription, privacy: .public)") + throw error + } + + } +} diff --git a/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift b/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift index 688cc77cc..2ba635f1f 100644 --- a/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift +++ b/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift @@ -569,6 +569,38 @@ extension Array where Element == SecureVaultModels.WebsiteAccount { return (removeDuplicates ? result.removeDuplicates() : result).filter { $0.domain?.isEmpty == false } } + public func sortedAndDeduplicated(tld: TLD, urlMatcher: AutofillDomainNameUrlMatcher = AutofillDomainNameUrlMatcher()) -> [SecureVaultModels.WebsiteAccount] { + + let groupedBySignature = Dictionary(grouping: self) { $0.signature ?? "" } + + let deduplicatedAccounts = groupedBySignature + .flatMap { (signature, accounts) -> [SecureVaultModels.WebsiteAccount] in + + // no need to dedupe accounts with no signature, or where a signature group only has 1 account + if signature.isEmpty || accounts.count == 1 { + return accounts + } + + // This set is required as accounts can have duplicate signatures but different domains if the domain has a SLD + TLD like `co.uk` + // e.g. accounts with the same username & password for `example.co.uk` and `domain.co.uk` will have the same signature + var uniqueHosts = Set() + + for account in accounts { + if let domain = account.domain, + let urlComponents = urlMatcher.normalizeSchemeForAutofill(domain), + let host = urlComponents.eTLDplus1(tld: tld) ?? urlComponents.host { + uniqueHosts.insert(host) + } + } + + return uniqueHosts.flatMap { host in + accounts.sortedForDomain(host, tld: tld, removeDuplicates: true) + } + } + + return deduplicatedAccounts.sorted { compareAccount($0, $1) } + } + private func extractTLD(domain: String, tld: TLD, urlMatcher: AutofillDomainNameUrlMatcher) -> String? { guard var urlComponents = urlMatcher.normalizeSchemeForAutofill(domain) else { return nil } guard urlComponents.host != .localhost else { return domain } diff --git a/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift b/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift index 1110adfe2..10ad4386b 100644 --- a/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift +++ b/Sources/SyncDataProviders/Credentials/CredentialsProvider.swift @@ -25,19 +25,30 @@ import GRDB import SecureStorage import os.log +public struct CredentialsInput { + public var modifiedAccounts: [SecureVaultModels.WebsiteAccount] + public var deletedAccounts: [SecureVaultModels.WebsiteAccount] +} + public final class CredentialsProvider: DataProvider { + public private(set) var credentialsInput: CredentialsInput = .init(modifiedAccounts: [], deletedAccounts: []) + public init( secureVaultFactory: AutofillVaultFactory = AutofillSecureVaultFactory, secureVaultErrorReporter: SecureVaultReporting, metadataStore: SyncMetadataStore, metricsEvents: EventMapping? = nil, - syncDidUpdateData: @escaping () -> Void + syncDidUpdateData: @escaping () -> Void, + syncDidFinish: @escaping (CredentialsInput?) -> Void ) throws { self.secureVaultFactory = secureVaultFactory self.secureVaultErrorReporter = secureVaultErrorReporter self.metricsEvents = metricsEvents super.init(feature: .init(name: "credentials"), metadataStore: metadataStore, syncDidUpdateData: syncDidUpdateData) + self.syncDidFinish = { [weak self] in + syncDidFinish(self?.credentialsInput) + } } // MARK: - DataProviding @@ -166,6 +177,9 @@ public final class CredentialsProvider: DataProvider { ) try responseHandler.processReceivedCredentials() + + self.credentialsInput.modifiedAccounts = responseHandler.incomingModifiedAccounts + self.credentialsInput.deletedAccounts = responseHandler.incomingDeletedAccounts #if DEBUG try self.willSaveContextAfterApplyingSyncResponse() #endif diff --git a/Sources/SyncDataProviders/Credentials/internal/CredentialsResponseHandler.swift b/Sources/SyncDataProviders/Credentials/internal/CredentialsResponseHandler.swift index b3d00bcbb..80add013c 100644 --- a/Sources/SyncDataProviders/Credentials/internal/CredentialsResponseHandler.swift +++ b/Sources/SyncDataProviders/Credentials/internal/CredentialsResponseHandler.swift @@ -34,6 +34,9 @@ final class CredentialsResponseHandler { let allReceivedIDs: Set private var credentialsByUUID: [String: SecureVaultModels.SyncableCredentials] = [:] + var incomingModifiedAccounts = [SecureVaultModels.WebsiteAccount]() + var incomingDeletedAccounts = [SecureVaultModels.WebsiteAccount]() + private let decrypt: (String) throws -> String private let metricsEvents: EventMapping? @@ -117,6 +120,7 @@ final class CredentialsResponseHandler { if syncable.isDeleted { try secureVault.deleteSyncableCredentials(existingEntity, in: database) + trackCredentialChange(of: existingEntity, with: syncable) } else if isModifiedAfterSyncTimestamp { metricsEvents?.fire(.localTimestampResolutionTriggered(feature: feature)) } else { @@ -126,10 +130,10 @@ final class CredentialsResponseHandler { in: database, encryptedUsing: secureVaultEncryptionKey, hashedUsing: secureVaultHashingSalt) + trackCredentialChange(of: existingEntity, with: syncable) } } else if !syncable.isDeleted { - let newEntity = try SecureVaultModels.SyncableCredentials(syncable: syncable, decryptedUsing: decrypt) assert(newEntity.metadata.lastModified == nil, "lastModified should be nil for a new metadata entity") try secureVault.storeSyncableCredentials(newEntity, @@ -137,6 +141,7 @@ final class CredentialsResponseHandler { encryptedUsing: secureVaultEncryptionKey, hashedUsing: secureVaultHashingSalt) credentialsByUUID[syncableUUID] = newEntity + trackCredentialChange(of: newEntity, with: syncable) } } @@ -183,6 +188,18 @@ final class CredentialsResponseHandler { } return syncableCredentials.first(where: { $0.credentialsRecord?.password == nil }) } + + private func trackCredentialChange(of entity: SecureVaultModels.SyncableCredentials, with syncable: SyncableCredentialsAdapter) { + guard let account = entity.account else { + return + } + + if syncable.isDeleted { + incomingDeletedAccounts.append(account) + } else { + incomingModifiedAccounts.append(account) + } + } } extension SecureVaultModels.SyncableCredentials { diff --git a/Tests/BrowserServicesKitTests/SecureVault/AutofillCredentialIdentityStoreManagerTests.swift b/Tests/BrowserServicesKitTests/SecureVault/AutofillCredentialIdentityStoreManagerTests.swift new file mode 100644 index 000000000..0fed822e4 --- /dev/null +++ b/Tests/BrowserServicesKitTests/SecureVault/AutofillCredentialIdentityStoreManagerTests.swift @@ -0,0 +1,207 @@ +// +// AutofillCredentialIdentityStoreManagerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// 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 +import AuthenticationServices +import Common +import SecureStorage +import SecureStorageTestsUtils +@testable import BrowserServicesKit + +final class AutofillCredentialIdentityStoreManagerTests: XCTestCase { + + var mockCryptoProvider = MockCryptoProvider() + var mockDatabaseProvider = (try! MockAutofillDatabaseProvider()) + var mockKeystoreProvider = MockKeystoreProvider() + var mockVault: (any AutofillSecureVault)! + var tld: TLD! + + var manager: AutofillCredentialIdentityStoreManaging! + var mockStore: MockASCredentialIdentityStore! + + override func setUp() { + super.setUp() + mockStore = MockASCredentialIdentityStore() + let providers = SecureStorageProviders(crypto: mockCryptoProvider, + database: mockDatabaseProvider, + keystore: mockKeystoreProvider) + + mockVault = DefaultAutofillSecureVault(providers: providers) + + tld = TLD() + manager = AutofillCredentialIdentityStoreManager(credentialStore: mockStore, vault: mockVault, tld: tld) + } + + override func tearDown() { + manager = nil + mockStore = nil + mockVault = nil + tld = nil + super.tearDown() + } + + func testCredentialStoreStateEnabled() async { + let isEnabled = await manager.credentialStoreStateEnabled() + XCTAssertTrue(isEnabled) + } + + func testPopulateCredentialStore() async throws { + let accounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234"), + createWebsiteAccount(id: "2", domain: "example.org", username: "user2", signature: "5678") + ] + + mockDatabaseProvider._accounts = accounts + await manager.populateCredentialStore() + + if #available(iOS 17.0, macOS 14.0, *) { + XCTAssertEqual(mockStore.savedCredentialIdentities.count, 2) + } else { + XCTAssertEqual(mockStore.savedPasswordCredentialIdentities.count, 2) + } + } + + func testPopulateCredentialStoreWithDuplicateAccounts() async throws { + let accounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234"), + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234") + ] + + mockDatabaseProvider._accounts = accounts + await manager.populateCredentialStore() + + if #available(iOS 17.0, macOS 14.0, *) { + XCTAssertEqual(mockStore.savedCredentialIdentities.count, 1) + } else { + XCTAssertEqual(mockStore.savedPasswordCredentialIdentities.count, 1) + } + } + + func testReplaceCredentialStore() async throws { + let accounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234"), + createWebsiteAccount(id: "2", domain: "example.org", username: "user2", signature: "5678"), + createWebsiteAccount(id: "3", domain: "example.org", username: "newUser3", signature: "44") + ] + + mockDatabaseProvider._accounts = accounts + await manager.populateCredentialStore() + + let replacementAccounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "newUser1", signature: "123"), + createWebsiteAccount(id: "2", domain: "example.org", username: "newUser2", signature: "567") + ] + mockDatabaseProvider._accounts = accounts + + await manager.replaceCredentialStore(with: replacementAccounts) + + if #available(iOS 17.0, macOS 14.0, *) { + XCTAssertEqual(mockStore.savedCredentialIdentities.count, 2) + // loop through the saved credential identities and check if the username is updated + for identity in mockStore.savedCredentialIdentities { + let replacedAccount = replacementAccounts.first { $0.id == identity.recordIdentifier } + XCTAssertEqual(identity.user, replacedAccount?.username) + } + + } else { + XCTAssertEqual(mockStore.savedPasswordCredentialIdentities.count, 2) + for identity in mockStore.savedPasswordCredentialIdentities { + let replacedAccount = replacementAccounts.first { $0.id == identity.recordIdentifier } + XCTAssertEqual(identity.user, replacedAccount?.username) + } + } + } + + func testUpdateCredentialStoreForDomain() async { + let accounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234"), + createWebsiteAccount(id: "2", domain: "example.com", username: "user2", signature: "5678"), + createWebsiteAccount(id: "3", domain: "example.com", username: "newUser3", signature: "44") + ] + + mockDatabaseProvider._accounts = accounts + await manager.populateCredentialStore() + + let updatedAccounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234", lastUsed: Date() - TimeInterval(60)), + createWebsiteAccount(id: "2", domain: "example.com", username: "user2", signature: "5678", lastUpdated: Date() - TimeInterval(60)), + createWebsiteAccount(id: "3", domain: "example.com", username: "newUser3", signature: "44", lastUsed: Date()) + ] + mockDatabaseProvider._accounts = updatedAccounts + + await manager.updateCredentialStore(for: "example.com") + + if #available(iOS 17.0, macOS 14.0, *) { + XCTAssertEqual(mockStore.savedCredentialIdentities.count, 3) + + let rankedCredentials = mockStore.savedCredentialIdentities.sorted { $0.rank < $1.rank } + XCTAssertEqual(rankedCredentials[0].recordIdentifier, "2") + XCTAssertEqual(rankedCredentials[1].recordIdentifier, "1") + XCTAssertEqual(rankedCredentials[2].recordIdentifier, "3") + } else { + XCTAssertEqual(mockStore.savedPasswordCredentialIdentities.count, 3) + + let rankedCredentials = mockStore.savedPasswordCredentialIdentities.sorted { $0.rank < $1.rank } + XCTAssertEqual(rankedCredentials[0].recordIdentifier, "2") + XCTAssertEqual(rankedCredentials[1].recordIdentifier, "1") + XCTAssertEqual(rankedCredentials[2].recordIdentifier, "3") + + } + + } + + func testUpdateCredentialStoreWithUpdatedAndDeletedAccounts() async { + let accounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1", signature: "1234"), + createWebsiteAccount(id: "2", domain: "example.com", username: "user2", signature: "5678"), + createWebsiteAccount(id: "3", domain: "example.com", username: "newUser3", signature: "44"), + createWebsiteAccount(id: "4", domain: "example.com", username: "user4", signature: "4422") + ] + + mockDatabaseProvider._accounts = accounts + await manager.populateCredentialStore() + + let updatedAccounts = [ + createWebsiteAccount(id: "1", domain: "example.com", username: "user1a", signature: "1234", lastUsed: Date() - TimeInterval(60)), + createWebsiteAccount(id: "2", domain: "example.com", username: "user2b", signature: "5678"), + createWebsiteAccount(id: "5", domain: "example.com", username: "user5IsNew", signature: "1111") + ] + + let deletedAccounts = [ + createWebsiteAccount(id: "3", domain: "example.com", username: "newUser3", signature: "44") + ] + + await manager.updateCredentialStoreWith(updatedAccounts: updatedAccounts, deletedAccounts: deletedAccounts) + if #available(iOS 17.0, macOS 14.0, *) { + XCTAssertEqual(mockStore.savedCredentialIdentities.count, 4) + XCTAssertEqual(mockStore.savedCredentialIdentities.first { $0.recordIdentifier == "1" }?.user, "user1a") + XCTAssertEqual(mockStore.savedCredentialIdentities.first { $0.recordIdentifier == "2" }?.user, "user2b") + XCTAssertEqual(mockStore.savedCredentialIdentities.first { $0.recordIdentifier == "5" }?.user, "user5IsNew") + XCTAssertNil(mockStore.savedCredentialIdentities.first { $0.recordIdentifier == "3" }) + } else { + XCTAssertEqual(mockStore.savedPasswordCredentialIdentities.count, 4) + } + } + + // MARK: - Helper Methods + + private func createWebsiteAccount(id: String, domain: String, username: String, signature: String, created: Date = Date(), lastUpdated: Date = Date(), lastUsed: Date? = nil) -> SecureVaultModels.WebsiteAccount { + return SecureVaultModels.WebsiteAccount(id: id, username: username, domain: domain, signature: signature, created: created, lastUpdated: lastUpdated, lastUsed: lastUsed) + } + +} diff --git a/Tests/BrowserServicesKitTests/SecureVault/MockASCredentialIdentityStore.swift b/Tests/BrowserServicesKitTests/SecureVault/MockASCredentialIdentityStore.swift new file mode 100644 index 000000000..6f00fab29 --- /dev/null +++ b/Tests/BrowserServicesKitTests/SecureVault/MockASCredentialIdentityStore.swift @@ -0,0 +1,125 @@ +// +// MockASCredentialIdentityStore.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// 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 Foundation +import AuthenticationServices +@testable import BrowserServicesKit + +final class MockASCredentialIdentityStore: ASCredentialIdentityStoring { + var isEnabled = true + var supportsIncrementalUpdates = true + var savedPasswordCredentialIdentities: [ASPasswordCredentialIdentity] = [] + var error: Error? + + // Using this computed property to handle iOS 17 availability for ASCredentialIdentity + private var _savedCredentialIdentities: [Any] = [] + + func state() async -> ASCredentialIdentityStoreState { + return MockASCredentialIdentityStoreState(isEnabled: isEnabled, supportsIncrementalUpdates: supportsIncrementalUpdates) + } + + func saveCredentialIdentities(_ credentials: [ASPasswordCredentialIdentity]) async throws { + if let error = error { + throw error + } + + for credential in credentials { + if let index = savedPasswordCredentialIdentities.firstIndex(where: { $0.recordIdentifier == credential.recordIdentifier }) { + savedPasswordCredentialIdentities[index] = credential + } else { + savedPasswordCredentialIdentities.append(credential) + } + } + } + + func removeCredentialIdentities(_ credentials: [ASPasswordCredentialIdentity]) async throws { + if let error = error { + throw error + } + let identifiersToRemove = Set(credentials.map { $0.recordIdentifier }) + savedPasswordCredentialIdentities.removeAll { identifiersToRemove.contains($0.recordIdentifier) } + } + + func replaceCredentialIdentities(with newCredentials: [ASPasswordCredentialIdentity]) async throws { + if let error = error { + throw error + } + savedPasswordCredentialIdentities = newCredentials + } + +} + +@available(iOS 17.0, macOS 14.0, *) +extension MockASCredentialIdentityStore { + + var savedCredentialIdentities: [ASCredentialIdentity] { + get { + return _savedCredentialIdentities as? [ASCredentialIdentity] ?? [] + } + set { + _savedCredentialIdentities = newValue + } + } + + func saveCredentialIdentities(_ credentials: [ASCredentialIdentity]) async throws { + if let error = error { + throw error + } + for credential in credentials { + if let index = savedCredentialIdentities.firstIndex(where: { $0.recordIdentifier == credential.recordIdentifier }) { + savedCredentialIdentities[index] = credential + } else { + savedCredentialIdentities.append(credential) + } + } + } + + func removeCredentialIdentities(_ credentials: [ASCredentialIdentity]) async throws { + if let error = error { + throw error + } + let identifiersToRemove = Set(credentials.map { $0.recordIdentifier }) + savedCredentialIdentities.removeAll { identifiersToRemove.contains($0.recordIdentifier) } + } + + func replaceCredentialIdentities(_ newCredentials: [ASCredentialIdentity]) async throws { + if let error = error { + throw error + } + savedCredentialIdentities = newCredentials + } +} + +private class MockASCredentialIdentityStoreState: ASCredentialIdentityStoreState { + private var _isEnabled: Bool + private var _supportsIncrementalUpdates: Bool + + override var isEnabled: Bool { + return _isEnabled + } + + override var supportsIncrementalUpdates: Bool { + return _supportsIncrementalUpdates + } + + init(isEnabled: Bool, supportsIncrementalUpdates: Bool) { + self._isEnabled = isEnabled + self._supportsIncrementalUpdates = supportsIncrementalUpdates + super.init() + } +} diff --git a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultModelTests.swift b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultModelTests.swift index 634e382bd..527e56c0a 100644 --- a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultModelTests.swift +++ b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultModelTests.swift @@ -527,6 +527,161 @@ class SecureVaultModelTests: XCTestCase { } } + func testSortedAndDeduplicatedForSameSignatureReturnsTLD() { + let controlAccounts = [ + testAccount("user1", "example.com", "sig1", 0), + testAccount("user1", "sub1.example.com", "sig1", 0, 1 * days), + testAccount("user1", "sub2.example.com", "sig1", 0), + ] + + let sortedAccounts = controlAccounts.sortedAndDeduplicated(tld: tld) + + XCTAssertEqual(sortedAccounts[0].domain, "example.com") + XCTAssertEqual(sortedAccounts.count, 1) + } + + func testSortedAndDeduplicatedForSameSignatureReturnsWww() { + let controlAccounts = [ + testAccount("user1", "sub.example.com", "sig1", 0, 1 * days), + testAccount("user1", "sub1.example.com", "sig1", 0), + testAccount("user1", "sub2.example.com", "sig1", 0), + testAccount("user1", "www.example.com", "sig1", 0), + ] + + let sortedAccounts = controlAccounts.sortedAndDeduplicated(tld: tld) + + XCTAssertEqual(sortedAccounts[0].domain, "www.example.com") + XCTAssertEqual(sortedAccounts.count, 1) + } + + func testSortedAndDeduplicatedForSameSignatureDifferentSubdomainsReturnsSortedLastUsed() { + let controlAccounts = [ + testAccount("user1", "sub.example.com", "sig1", 0), + testAccount("user1", "sub1.example.com", "sig1", 0), + testAccount("user1", "sub2.example.com", "sig1", 0, 1 * days), + testAccount("user1", "any.example.com", "sig1", 0), + ] + + let sortedAccounts = controlAccounts.sortedAndDeduplicated(tld: tld) + + XCTAssertEqual(sortedAccounts[0].domain, "sub2.example.com") + XCTAssertEqual(sortedAccounts.count, 1) + } + + func testSortedAndDeduplicatedForSameSignatureDifferentDomainsReturnsUniqueDomains() { + let controlAccounts = [ + testAccount("user1", "example.co.uk", "sig1", 0), + testAccount("user1", "sub.example.co.uk", "sig1", 0), + testAccount("user1", "domain.co.uk", "sig1", 0), + testAccount("user1", "www.domain.co.uk", "sig1", 0), + ] + + let sortedAccounts = controlAccounts.sortedAndDeduplicated(tld: tld) + + XCTAssertEqual(sortedAccounts[0].domain, "domain.co.uk") + XCTAssertEqual(sortedAccounts[1].domain, "example.co.uk") + XCTAssertEqual(sortedAccounts.count, 2) + } + + func testSortedAndDeduplicatedForNoSignatureReturnsAllAccounts() { + let controlAccounts = [ + SecureVaultModels.WebsiteAccount(id: "1234567890", + username: "username", + domain: "example.co.uk", + created: Date(), + lastUpdated: Date()), + SecureVaultModels.WebsiteAccount(id: "1234567890", + username: "username", + domain: "sub.example.co.uk", + created: Date(), + lastUpdated: Date()), + SecureVaultModels.WebsiteAccount(id: "1234567890", + username: "username", + domain: "domain.co.uk", + created: Date(), + lastUpdated: Date()), + SecureVaultModels.WebsiteAccount(id: "1234567890", + username: "username", + domain: "www.domain.co.uk", + created: Date(), + lastUpdated: Date()) + ] + + let sortedAccounts = controlAccounts.sortedAndDeduplicated(tld: tld) + + XCTAssertEqual(sortedAccounts.count, 4) + } + + func testSortedAndDeduplicatedWithComplexDomains() { + let accounts = [ + // Multiple subdomains + testAccount("user1", "deep.sub.example.com", "sig1", 0), + testAccount("user1", "other.sub.example.com", "sig1", 0), + + // Different ports + testAccount("user2", "example.com:8080", "sig2", 0), + testAccount("user2", "example.com:443", "sig2", 0), + + // Mix of www and non-www + testAccount("user3", "www.example.com", "sig3", 0), + testAccount("user3", "example.com", "sig3", 0), + + // Different TLDs + testAccount("user4", "example.com", "sig4", 0), + testAccount("user4", "example.net", "sig4", 0), + testAccount("user4", "example.org", "sig4", 0) + ] + + let sortedAccounts = accounts.sortedAndDeduplicated(tld: tld) + + // Verify subdomains are properly handled + let sig1Accounts = sortedAccounts.filter { $0.signature == "sig1" } + XCTAssertEqual(sig1Accounts[0].domain, "deep.sub.example.com") + XCTAssertEqual(sig1Accounts.count, 1) + + // Verify ports are considered in deduplication + let sig2Accounts = sortedAccounts.filter { $0.signature == "sig2" } + XCTAssertEqual(sig2Accounts[0].domain, "example.com:443") + XCTAssertEqual(sig2Accounts.count, 1) + + // Verify www and non-www are considered same domain + let sig3Accounts = sortedAccounts.filter { $0.signature == "sig3" } + XCTAssertEqual(sig3Accounts[0].domain, "example.com") + XCTAssertEqual(sig3Accounts.count, 1) + + // Verify different TLDs are preserved + let sig4Accounts = sortedAccounts.filter { $0.signature == "sig4" } + XCTAssertEqual(sig4Accounts.count, 3) + } + + func testSortedAndDeduplicatedWithLastUsedDates() { + let accounts = [ + // Same signature, different last used dates + testAccount("user1", "example.com", "sig1", 0, 3 * days), + testAccount("user1", "sub.example.com", "sig1", 0, 1 * days), + testAccount("user1", "other.example.com", "sig1", 0, 2 * days), + + // Different signatures, same domain, mixed dates + testAccount("user2", "example.com", "sig2", 0, 1 * days), + testAccount("user3", "example.com", "sig3", 0, 2 * days), + testAccount("user4", "example.com", "sig4", 0) // No last used date + ] + + let sortedAccounts = accounts.sortedAndDeduplicated(tld: tld) + + // Verify accounts are sorted by last used date + XCTAssertEqual(sortedAccounts[0].domain, "example.com") // 3 days ago + XCTAssertEqual(sortedAccounts[1].username, "user3") // 2 days ago + XCTAssertEqual(sortedAccounts[2].username, "user2") // 1 day ago + XCTAssertEqual(sortedAccounts[3].username, "user4") // No last used date + + // Verify deduplication still works with different dates + let sig1Accounts = sortedAccounts.filter { $0.signature == "sig1" } + XCTAssertEqual(sig1Accounts.count, 1) + // Verify the most recently used account is kept + XCTAssertEqual(sig1Accounts[0].lastUsed?.timeIntervalSince1970, 3 * days) + } + func testPatternMatchedTitle() { let domainTitles: [String] = [ diff --git a/Tests/SyncDataProvidersTests/Credentials/helpers/CredentialsProviderTestsBase.swift b/Tests/SyncDataProvidersTests/Credentials/helpers/CredentialsProviderTestsBase.swift index 0de0a3f27..7ea3a282f 100644 --- a/Tests/SyncDataProvidersTests/Credentials/helpers/CredentialsProviderTestsBase.swift +++ b/Tests/SyncDataProvidersTests/Credentials/helpers/CredentialsProviderTestsBase.swift @@ -90,7 +90,8 @@ internal class CredentialsProviderTestsBase: XCTestCase { secureVaultFactory: secureVaultFactory, secureVaultErrorReporter: MockSecureVaultErrorReporter(), metadataStore: LocalSyncMetadataStore(database: metadataDatabase), - syncDidUpdateData: {} + syncDidUpdateData: {}, + syncDidFinish: { _ in } ) }