diff --git a/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift b/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift index a6bd1c6a6..621f31d16 100644 --- a/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift +++ b/Sources/BrowserServicesKit/Autofill/AutofillUserScript+SecureVault.swift @@ -28,7 +28,7 @@ public protocol AutofillSecureVaultDelegate: AnyObject { ) -> Void) func autofillUserScript(_: AutofillUserScript, didRequestPasswordManagerForDomain domain: String) - func autofillUserScript(_: AutofillUserScript, didRequestStoreCredentialsForDomain domain: String, username: String, password: String) + func autofillUserScript(_: AutofillUserScript, didRequestStoreDataForDomain domain: String, data: AutofillUserScript.DetectedAutofillData) func autofillUserScript(_: AutofillUserScript, didRequestAccountsForDomain domain: String, completionHandler: @escaping ([SecureVaultModels.WebsiteAccount]) -> Void) func autofillUserScript(_: AutofillUserScript, didRequestCredentialsForAccount accountId: Int64, @@ -84,7 +84,7 @@ extension AutofillUserScript { addressPostalCode: identity.addressPostalCode, addressCountryCode: identity.addressCountryCode, phone: identity.homePhone, // Replace with single "phone number" column - emailAddress: identity.emailAddress) + emailAddress: identity.emailAddress ?? "") } } @@ -131,6 +131,60 @@ extension AutofillUserScript { let id: Int64 let username: String } + + // MARK: - Requests + + public struct IncomingCredentials { + + private enum Constants { + static let credentialsKey = "credentials" + static let usernameKey = "username" + static let passwordKey = "password" + } + + let username: String? + let password: String + + init(username: String?, password: String) { + self.username = username + self.password = password + } + + init?(autofillDictionary: [String: Any]) { + guard let credentialsDictionary = autofillDictionary[Constants.credentialsKey] as? [String: String], + let password = credentialsDictionary[Constants.passwordKey] else { + return nil + } + + // Usernames are optional, as the Autofill script can pass a generated password through without a corresponding username. + self.init(username: credentialsDictionary[Constants.usernameKey], password: password) + } + + } + + /// Represents the incoming Autofill data provided by the user script. + /// + /// Identities and Credit Cards can be converted to their final model objects directly, but credentials cannot as they have to looked up in the Secure Vault first, hence the existence of a standalone + /// `IncomingCredentials` type. + public struct DetectedAutofillData { + + public let identity: SecureVaultModels.Identity? + public let credentials: IncomingCredentials? + public let creditCard: SecureVaultModels.CreditCard? + + init(dictionary: [String: Any]) { + self.identity = .init(autofillDictionary: dictionary) + self.creditCard = .init(autofillDictionary: dictionary) + self.credentials = IncomingCredentials(autofillDictionary: dictionary) + } + + init(identity: SecureVaultModels.Identity?, credentials: AutofillUserScript.IncomingCredentials?, creditCard: SecureVaultModels.CreditCard?) { + self.identity = identity + self.credentials = credentials + self.creditCard = creditCard + } + + } // MARK: - Responses @@ -210,20 +264,20 @@ extension AutofillUserScript { } } - - func pmStoreCredentials(_ message: AutofillMessage, _ replyHandler: @escaping MessageReplyHandler) { + + func pmStoreData(_ message: AutofillMessage, _ replyHandler: @escaping MessageReplyHandler) { defer { replyHandler(nil) } - - guard let body = message.messageBody as? [String: Any], - let username = body["username"] as? String, - let password = body["password"] as? String else { + + guard let body = message.messageBody as? [String: Any] else { return } - + + let incomingData = DetectedAutofillData(dictionary: body) let domain = hostProvider.hostForMessage(message) - vaultDelegate?.autofillUserScript(self, didRequestStoreCredentialsForDomain: domain, username: username, password: password) + + vaultDelegate?.autofillUserScript(self, didRequestStoreDataForDomain: domain, data: incomingData) } func pmGetAccounts(_ message: AutofillMessage, _ replyHandler: @escaping MessageReplyHandler) { diff --git a/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift b/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift index 7ee294ea3..0a209c018 100644 --- a/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift +++ b/Sources/BrowserServicesKit/Autofill/AutofillUserScript.swift @@ -18,6 +18,7 @@ // import WebKit +import os.log public class AutofillUserScript: NSObject, UserScript { @@ -35,7 +36,7 @@ public class AutofillUserScript: NSObject, UserScript { case pmHandlerGetAutofillInitData - case pmHandlerStoreCredentials + case pmHandlerStoreData case pmHandlerGetAccounts case pmHandlerGetAutofillCredentials case pmHandlerGetIdentity @@ -80,8 +81,11 @@ public class AutofillUserScript: NSObject, UserScript { internal func messageHandlerFor(_ messageName: String) -> MessageHandler? { guard let message = MessageName(rawValue: messageName) else { + os_log("Failed to parse Autofill User Script message: '%{public}s'", log: .userScripts, type: .debug, messageName) return nil } + + os_log("AutofillUserScript: received '%{public}s'", log: .userScripts, type: .debug, messageName) switch message { case .emailHandlerStoreToken: return emailStoreToken @@ -93,10 +97,9 @@ public class AutofillUserScript: NSObject, UserScript { case .pmHandlerGetAutofillInitData: return pmGetAutoFillInitData - case .pmHandlerStoreCredentials: return pmStoreCredentials + case .pmHandlerStoreData: return pmStoreData case .pmHandlerGetAccounts: return pmGetAccounts case .pmHandlerGetAutofillCredentials: return pmGetAutofillCredentials - case .pmHandlerGetIdentity: return pmGetIdentity case .pmHandlerGetCreditCard: return pmGetCreditCard diff --git a/Sources/BrowserServicesKit/Common/Extensions/StringExtension.swift b/Sources/BrowserServicesKit/Common/Extensions/StringExtension.swift index 98334290c..55faf6a4a 100644 --- a/Sources/BrowserServicesKit/Common/Extensions/StringExtension.swift +++ b/Sources/BrowserServicesKit/Common/Extensions/StringExtension.swift @@ -37,5 +37,23 @@ extension String { func encodingPlusesAsSpaces() -> String { return replacingOccurrences(of: "+", with: "%20") } + + func removingCharacters(in set: CharacterSet) -> String { + let filtered = unicodeScalars.filter { !set.contains($0) } + return String(String.UnicodeScalarView(filtered)) + } + + func autofillNormalized() -> String { + let autofillCharacterSet = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters).union(.symbols) + + var normalizedString = self + + normalizedString = normalizedString.removingCharacters(in: autofillCharacterSet) + normalizedString = normalizedString.folding(options: .diacriticInsensitive, locale: .current) + normalizedString = normalizedString.localizedLowercase + + return normalizedString + } } + diff --git a/Sources/BrowserServicesKit/Common/Logging.swift b/Sources/BrowserServicesKit/Common/Logging.swift new file mode 100644 index 000000000..5af2b3452 --- /dev/null +++ b/Sources/BrowserServicesKit/Common/Logging.swift @@ -0,0 +1,43 @@ +// +// Logging.swift +// DuckDuckGo +// +// Copyright © 2022 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 os + +extension OSLog { + + static var userScripts: OSLog { + Logging.userScriptsEnabled ? Logging.userScriptsLog : .disabled + } + + static var passwordManager: OSLog { + Logging.passwordManagerEnabled ? Logging.passwordManagerLog : .disabled + } + +} + +struct Logging { + + fileprivate static let userScriptsEnabled = false + fileprivate static let userScriptsLog: OSLog = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "DuckDuckGo", category: "User Scripts") + + fileprivate static let passwordManagerEnabled = false + fileprivate static let passwordManagerLog: OSLog = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "DuckDuckGo", category: "Password Manager") + +} diff --git a/Sources/BrowserServicesKit/Resources/duckduckgo-autofill b/Sources/BrowserServicesKit/Resources/duckduckgo-autofill index a42fdfe67..42202c481 160000 --- a/Sources/BrowserServicesKit/Resources/duckduckgo-autofill +++ b/Sources/BrowserServicesKit/Resources/duckduckgo-autofill @@ -1 +1 @@ -Subproject commit a42fdfe675261aabcad42e7ab084c76c09f58c6f +Subproject commit 42202c4819b7ab44ecfc681b03611c827fc6af8c diff --git a/Sources/BrowserServicesKit/SecureVault/CreditCardValidation.swift b/Sources/BrowserServicesKit/SecureVault/CreditCardValidation.swift index 29ccfe8ac..39fb96b53 100644 --- a/Sources/BrowserServicesKit/SecureVault/CreditCardValidation.swift +++ b/Sources/BrowserServicesKit/SecureVault/CreditCardValidation.swift @@ -31,7 +31,7 @@ public struct CreditCardValidation { case unknown - var displayName: String { + public var displayName: String { switch self { case .amex: return "American Express" diff --git a/Sources/BrowserServicesKit/SecureVault/SecureVault.swift b/Sources/BrowserServicesKit/SecureVault/SecureVault.swift index 3c298e3e0..8e2a429b6 100644 --- a/Sources/BrowserServicesKit/SecureVault/SecureVault.swift +++ b/Sources/BrowserServicesKit/SecureVault/SecureVault.swift @@ -46,12 +46,14 @@ public protocol SecureVault { func identities() throws -> [SecureVaultModels.Identity] func identityFor(id: Int64) throws -> SecureVaultModels.Identity? + func existingIdentityForAutofill(matching proposedIdentity: SecureVaultModels.Identity) throws -> SecureVaultModels.Identity? @discardableResult func storeIdentity(_ identity: SecureVaultModels.Identity) throws -> Int64 func deleteIdentityFor(identityId: Int64) throws func creditCards() throws -> [SecureVaultModels.CreditCard] func creditCardFor(id: Int64) throws -> SecureVaultModels.CreditCard? + func existingCardForAutofill(matching proposedCard: SecureVaultModels.CreditCard) throws -> SecureVaultModels.CreditCard? @discardableResult func storeCreditCard(_ card: SecureVaultModels.CreditCard) throws -> Int64 func deleteCreditCardFor(cardId: Int64) throws @@ -275,6 +277,14 @@ class DefaultSecureVault: SecureVault { try self.providers.database.deleteIdentityForIdentityId(identityId) } } + + func existingIdentityForAutofill(matching proposedIdentity: SecureVaultModels.Identity) throws -> SecureVaultModels.Identity? { + let identities = try self.identities() + + return identities.first { existingIdentity in + existingIdentity.hasAutofillEquality(comparedTo: proposedIdentity) + } + } // MARK: - Credit Cards @@ -304,6 +314,14 @@ class DefaultSecureVault: SecureVault { return card } } + + func existingCardForAutofill(matching proposedCard: SecureVaultModels.CreditCard) throws -> SecureVaultModels.CreditCard? { + let cards = try self.creditCards() + + return cards.first { existingCard in + existingCard.hasAutofillEquality(comparedTo: proposedCard) + } + } @discardableResult func storeCreditCard(_ card: SecureVaultModels.CreditCard) throws -> Int64 { diff --git a/Sources/BrowserServicesKit/SecureVault/SecureVaultDatabaseProvider.swift b/Sources/BrowserServicesKit/SecureVault/SecureVaultDatabaseProvider.swift index 7e761bab6..b9f129313 100644 --- a/Sources/BrowserServicesKit/SecureVault/SecureVaultDatabaseProvider.swift +++ b/Sources/BrowserServicesKit/SecureVault/SecureVaultDatabaseProvider.swift @@ -878,6 +878,9 @@ extension SecureVaultModels.Identity: PersistableRecord, FetchableRecord { homePhone = row[Columns.homePhone] mobilePhone = row[Columns.mobilePhone] emailAddress = row[Columns.emailAddress] + + autofillEqualityName = normalizedAutofillName() + autofillEqualityAddressStreet = addressStreet?.autofillNormalized() } public func encode(to container: inout PersistenceContainer) { diff --git a/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift b/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift index a49ff5c04..ddec65a7d 100644 --- a/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift +++ b/Sources/BrowserServicesKit/SecureVault/SecureVaultManager.swift @@ -27,10 +27,15 @@ public enum AutofillType { case identity } +public struct AutofillData { + public let identity: SecureVaultModels.Identity? + public let credentials: SecureVaultModels.WebsiteCredentials? + public let creditCard: SecureVaultModels.CreditCard? +} + public protocol SecureVaultManagerDelegate: SecureVaultErrorReporting { - func secureVaultManager(_: SecureVaultManager, - promptUserToStoreCredentials credentials: SecureVaultModels.WebsiteCredentials) + func secureVaultManager(_: SecureVaultManager, promptUserToStoreAutofillData data: AutofillData) func secureVaultManager(_: SecureVaultManager, didAutofill type: AutofillType, withObjectId objectId: Int64) @@ -70,30 +75,17 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { public func autofillUserScript(_: AutofillUserScript, didRequestPasswordManagerForDomain domain: String) { // no-op at this point } - - public func autofillUserScript(_: AutofillUserScript, didRequestStoreCredentialsForDomain domain: String, username: String, password: String) { - guard let passwordData = password.data(using: .utf8) else { return } - + + /// Receives each of the types of data that the Autofill script has detected, and determines whether the user should be prompted to save them. + /// This involves checking each proposed object to determine whether it already exists in the store. + /// Currently, only one new type of data is presented to the user, but that decision is handled client-side so that it's easier to adapt in the future when multiple types are presented at once. + public func autofillUserScript(_: AutofillUserScript, didRequestStoreDataForDomain domain: String, data: AutofillUserScript.DetectedAutofillData) { do { - - if let account = try SecureVaultFactory.default.makeVault(errorReporter: self.delegate) - .accountsFor(domain: domain) - .first(where: { $0.username == username }) { - - let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: passwordData) - delegate?.secureVaultManager(self, promptUserToStoreCredentials: credentials) - - } else { - - let account = SecureVaultModels.WebsiteAccount(username: username, domain: domain) - let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: passwordData) - delegate?.secureVaultManager(self, promptUserToStoreCredentials: credentials) - - } + let dataToPrompt = try existingEntries(for: domain, autofillData: data) + delegate?.secureVaultManager(self, promptUserToStoreAutofillData: dataToPrompt) } catch { - os_log(.error, "Error storing accounts: %{public}@", error.localizedDescription) + os_log(.error, "Error storing data: %{public}@", error.localizedDescription) } - } public func autofillUserScript(_: AutofillUserScript, @@ -158,5 +150,67 @@ extension SecureVaultManager: AutofillSecureVaultDelegate { completionHandler(nil) } } + + func existingEntries(for domain: String, autofillData: AutofillUserScript.DetectedAutofillData) throws -> AutofillData { + let vault = try SecureVaultFactory.default.makeVault(errorReporter: self.delegate) + + let proposedIdentity = try existingIdentity(with: autofillData, vault: vault) + let proposedCredentials = try existingCredentials(with: autofillData, domain: domain, vault: vault) + let proposedCard = try existingPaymentMethod(with: autofillData, vault: vault) + + return AutofillData(identity: proposedIdentity, credentials: proposedCredentials, creditCard: proposedCard) + } + + private func existingIdentity(with autofillData: AutofillUserScript.DetectedAutofillData, + vault: SecureVault) throws -> SecureVaultModels.Identity? { + if let identity = autofillData.identity, try vault.existingIdentityForAutofill(matching: identity) == nil { + os_log("Got new identity/address to save", log: .passwordManager) + return identity + } else { + os_log("No new identity/address found, avoid prompting user", log: .passwordManager) + return nil + } + } + + private func existingCredentials(with autofillData: AutofillUserScript.DetectedAutofillData, + domain: String, + vault: SecureVault) throws -> SecureVaultModels.WebsiteCredentials? { + if let credentials = autofillData.credentials, let passwordData = credentials.password.data(using: .utf8) { + if let account = try vault + .accountsFor(domain: domain) + .first(where: { $0.username == credentials.username }) { + + if let existingAccountID = account.id, + let existingCredentials = try vault.websiteCredentialsFor(accountId: existingAccountID), + existingCredentials.password == passwordData { + os_log("Found duplicate credentials, avoid prompting user", log: .passwordManager) + return nil + } else { + os_log("Found existing credentials to update", log: .passwordManager) + return SecureVaultModels.WebsiteCredentials(account: account, password: passwordData) + } + + } else { + os_log("Received new credentials to save", log: .passwordManager) + let account = SecureVaultModels.WebsiteAccount(username: credentials.username ?? "", domain: domain) + return SecureVaultModels.WebsiteCredentials(account: account, password: passwordData) + } + } else { + os_log("No new credentials found, avoid prompting user", log: .passwordManager) + } + + return nil + } + + private func existingPaymentMethod(with autofillData: AutofillUserScript.DetectedAutofillData, + vault: SecureVault) throws -> SecureVaultModels.CreditCard? { + if let card = autofillData.creditCard, try vault.existingCardForAutofill(matching: card) == nil { + os_log("Got new payment method to save", log: .passwordManager) + return card + } else { + os_log("No new payment method found, avoid prompting user", log: .passwordManager) + return nil + } + } } diff --git a/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift b/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift index b68ad7d02..1a6872abb 100644 --- a/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift +++ b/Sources/BrowserServicesKit/SecureVault/SecureVaultModels.swift @@ -71,6 +71,15 @@ public struct SecureVaultModels { public struct CreditCard { + private enum Constants { + static let creditCardsKey = "creditCards" + static let cardNumberKey = "cardNumber" + static let cardNameKey = "cardName" + static let cardSecurityCodeKey = "cardSecurityCode" + static let expirationMonthKey = "expirationMonth" + static let expirationYearKey = "expirationYear" + } + public var id: Int64? public var title: String public let created: Date @@ -116,6 +125,28 @@ public struct SecureVaultModels { self.expirationMonth = expirationMonth self.expirationYear = expirationYear } + + public init?(autofillDictionary: [String: Any]) { + guard let creditCardsDictionary = autofillDictionary[Constants.creditCardsKey] as? [String: Any] else { + return nil + } + + self.init(creditCardsDictionary: creditCardsDictionary) + } + + public init?(creditCardsDictionary: [String: Any]) { + guard let cardNumber = creditCardsDictionary[Constants.cardNumberKey] as? String else { + return nil + } + + self.init(id: nil, + title: nil, + cardNumber: cardNumber, + cardholderName: creditCardsDictionary[Constants.cardNameKey] as? String, + cardSecurityCode: creditCardsDictionary[Constants.cardSecurityCodeKey] as? String, + expirationMonth: Int(creditCardsDictionary[Constants.expirationMonthKey] as? String ?? ""), + expirationYear: Int(creditCardsDictionary[Constants.expirationYearKey] as? String ?? "")) + } } @@ -171,9 +202,7 @@ public struct SecureVaultModels { return firstNonEmptyLine ?? "" } - // The title's empty, so assume that the first non-empty line is used as the title, and find the second non- - // empty line instead. - + // The title is empty, so assume that the first non-empty line is used as the title, and find the second non-empty line instead. let noteLines = text.components(separatedBy: .newlines) var alreadyFoundFirstNonEmptyLine = false @@ -197,20 +226,72 @@ public struct SecureVaultModels { public struct Identity { + private static let mediumPersonNameComponentsFormatter: PersonNameComponentsFormatter = { + let nameFormatter = PersonNameComponentsFormatter() + nameFormatter.style = .medium + return nameFormatter + }() + + private static let longPersonNameComponentsFormatter: PersonNameComponentsFormatter = { + let nameFormatter = PersonNameComponentsFormatter() + nameFormatter.style = .long + return nameFormatter + }() + + private var nameComponents: PersonNameComponents { + var nameComponents = PersonNameComponents() + + nameComponents.givenName = firstName + nameComponents.middleName = middleName + nameComponents.familyName = lastName + + return nameComponents + } + + public var formattedName: String { + return Self.mediumPersonNameComponentsFormatter.string(from: nameComponents) + } + + public var longFormattedName: String { + return Self.longPersonNameComponentsFormatter.string(from: nameComponents) + } + + var autofillEqualityName: String? + var autofillEqualityAddressStreet: String? + public var id: Int64? public var title: String public let created: Date public let lastUpdated: Date - public var firstName: String? - public var middleName: String? - public var lastName: String? + public var firstName: String? { + didSet { + autofillEqualityName = normalizedAutofillName() + } + } + + public var middleName: String? { + didSet { + autofillEqualityName = normalizedAutofillName() + } + } + + public var lastName: String? { + didSet { + autofillEqualityName = normalizedAutofillName() + } + } public var birthdayDay: Int? public var birthdayMonth: Int? public var birthdayYear: Int? - public var addressStreet: String? + public var addressStreet: String? { + didSet { + autofillEqualityAddressStreet = addressStreet?.autofillNormalized() + } + } + public var addressStreet2: String? public var addressCity: String? public var addressProvince: String? @@ -265,8 +346,77 @@ public struct SecureVaultModels { self.homePhone = homePhone self.mobilePhone = mobilePhone self.emailAddress = emailAddress + + self.autofillEqualityName = normalizedAutofillName() + self.autofillEqualityAddressStreet = addressStreet?.autofillNormalized() + } + + public init?(autofillDictionary: [String: Any]) { + guard let dictionary = autofillDictionary["identities"] as? [String: Any] else { + return nil + } + + self.init(identityDictionary: dictionary) } + public init(identityDictionary: [String: Any]) { + self.init(id: nil, + title: nil, + created: Date(), + lastUpdated: Date(), + firstName: identityDictionary["firstName"] as? String, + middleName: identityDictionary["middleName"] as? String, + lastName: identityDictionary["lastName"] as? String, + birthdayDay: identityDictionary["birthdayDay"] as? Int, + birthdayMonth: identityDictionary["birthdayMonth"] as? Int, + birthdayYear: identityDictionary["birthdayYear"] as? Int, + addressStreet: identityDictionary["addressStreet"] as? String, + addressStreet2: identityDictionary["addressStreet2"] as? String, + addressCity: identityDictionary["addressCity"] as? String, + addressProvince: identityDictionary["addressProvince"] as? String, + addressPostalCode: identityDictionary["addressPostalCode"] as? String, + addressCountryCode: identityDictionary["addressCountryCode"] as? String, + homePhone: identityDictionary["phone"] as? String, + mobilePhone: nil, + emailAddress: identityDictionary["emailAddress"] as? String) + } + + func normalizedAutofillName() -> String { + let nameString = (firstName ?? "") + (middleName ?? "") + (lastName ?? "") + return nameString.autofillNormalized() + } + + } + +} + +// MARK: - Autofill Equality + +protocol SecureVaultAutofillEquatable { + + func hasAutofillEquality(comparedTo object: Self) -> Bool + +} + +extension SecureVaultModels.Identity: SecureVaultAutofillEquatable { + + func hasAutofillEquality(comparedTo otherIdentity: SecureVaultModels.Identity) -> Bool { + let hasNameEquality = self.autofillEqualityName == otherIdentity.autofillEqualityName + let hasAddressEquality = self.autofillEqualityAddressStreet == otherIdentity.autofillEqualityAddressStreet + + return hasNameEquality && hasAddressEquality } + +} +extension SecureVaultModels.CreditCard: SecureVaultAutofillEquatable { + + func hasAutofillEquality(comparedTo object: Self) -> Bool { + if self.cardNumber.autofillNormalized() == object.cardNumber.autofillNormalized() { + return true + } + + return false + } + } diff --git a/Tests/BrowserServicesKitTests/Autofill/AutofillVaultUserScriptTests.swift b/Tests/BrowserServicesKitTests/Autofill/AutofillVaultUserScriptTests.swift index 887106668..63a363ab1 100644 --- a/Tests/BrowserServicesKitTests/Autofill/AutofillVaultUserScriptTests.swift +++ b/Tests/BrowserServicesKitTests/Autofill/AutofillVaultUserScriptTests.swift @@ -137,17 +137,16 @@ class AutofillVaultUserScriptTests: XCTestCase { } @available(macOS 11, iOS 14, *) - func testWhenStoreCredentialsCalled_ThenDelegateIsCalled() { + func testWhenStoreDataCalled_ThenDelegateIsCalled() { let delegate = MockSecureVaultDelegate() userScript.vaultDelegate = delegate var body = encryptedMessagingParams - body["username"] = "username@example.com" - body["password"] = "password" + body["credentials"] = ["username": "username@example.com", "password": "password"] let mockWebView = MockWebView() - let message = MockAutofillMessage(name: "pmHandlerStoreCredentials", body: body, + let message = MockAutofillMessage(name: "pmHandlerStoreData", body: body, host: "example.com", webView: mockWebView) userScript.processMessage(userContentController, didReceive: message) @@ -299,6 +298,45 @@ class AutofillVaultUserScriptTests: XCTestCase { XCTAssertEqual(delegate.lastDomain, "example.com") } + + func testWhenInitializingAutofillData_WhenCredentialsAreProvidedWithoutAUsername_ThenAutofillDataIsStillInitialized() { + let password = "password" + let detectedAutofillData = [ + "credentials": [ + "password": password + ] + ] + + let autofillData = AutofillUserScript.DetectedAutofillData(dictionary: detectedAutofillData) + + XCTAssertNil(autofillData.creditCard) + XCTAssertNil(autofillData.identity) + XCTAssertNotNil(autofillData.credentials) + + XCTAssertEqual(autofillData.credentials?.username, nil) + XCTAssertEqual(autofillData.credentials?.password, password) + } + + func testWhenInitializingAutofillData_WhenCredentialsAreProvidedWithAUsername_ThenAutofillDataIsStillInitialized() { + let username = "username" + let password = "password" + + let detectedAutofillData = [ + "credentials": [ + "username": username, + "password": password + ] + ] + + let autofillData = AutofillUserScript.DetectedAutofillData(dictionary: detectedAutofillData) + + XCTAssertNil(autofillData.creditCard) + XCTAssertNil(autofillData.identity) + XCTAssertNotNil(autofillData.credentials) + + XCTAssertEqual(autofillData.credentials?.username, username) + XCTAssertEqual(autofillData.credentials?.password, password) + } } @@ -312,12 +350,10 @@ class MockSecureVaultDelegate: AutofillSecureVaultDelegate { lastDomain = domain } - func autofillUserScript(_: AutofillUserScript, didRequestStoreCredentialsForDomain domain: String, - username: String, - password: String) { + func autofillUserScript(_: AutofillUserScript, didRequestStoreDataForDomain domain: String, data: AutofillUserScript.DetectedAutofillData) { lastDomain = domain - lastUsername = username - lastPassword = password + lastUsername = data.credentials?.username + lastPassword = data.credentials?.password } func autofillUserScript(_: AutofillUserScript, diff --git a/Tests/BrowserServicesKitTests/Common/Extensions/StringExtensionTests.swift b/Tests/BrowserServicesKitTests/Common/Extensions/StringExtensionTests.swift new file mode 100644 index 000000000..ded3ec62c --- /dev/null +++ b/Tests/BrowserServicesKitTests/Common/Extensions/StringExtensionTests.swift @@ -0,0 +1,68 @@ +// +// StringExtensionTests.swift +// +// Copyright © 2022 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 XCTest +@testable import BrowserServicesKit + +final class StringExtensionTests: XCTestCase { + + func testWhenNormalizingStringsForAutofill_ThenDiacriticsAreRemoved() { + let stringToNormalize = "Dáx Thê Dûck" + let normalizedString = stringToNormalize.autofillNormalized() + + XCTAssertEqual(normalizedString, "daxtheduck") + } + + func testWhenNormalizingStringsForAutofill_ThenWhitespaceIsRemoved() { + let stringToNormalize = "Dax The Duck" + let normalizedString = stringToNormalize.autofillNormalized() + + XCTAssertEqual(normalizedString, "daxtheduck") + } + + func testWhenNormalizingStringsForAutofill_ThenPunctuationIsRemoved() { + let stringToNormalize = ",Dax+The_Duck." + let normalizedString = stringToNormalize.autofillNormalized() + + XCTAssertEqual(normalizedString, "daxtheduck") + } + + func testWhenNormalizingStringsForAutofill_ThenNumbersAreRetained() { + let stringToNormalize = "Dax123" + let normalizedString = stringToNormalize.autofillNormalized() + + XCTAssertEqual(normalizedString, "dax123") + } + + func testWhenNormalizingStringsForAutofill_ThenStringsThatDoNotNeedNormalizationAreUntouched() { + let stringToNormalize = "firstmiddlelast" + let normalizedString = stringToNormalize.autofillNormalized() + + XCTAssertEqual(normalizedString, "firstmiddlelast") + } + + func testWhenNormalizingStringsForAutofill_ThenEmojiAreRemoved() { + let stringToNormalize = "Dax 🤔" + let normalizedString = stringToNormalize.autofillNormalized() + + XCTAssertEqual(normalizedString, "dax") + } + +} diff --git a/Tests/BrowserServicesKitTests/SecureVault/SecureVaultModelTests.swift b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultModelTests.swift new file mode 100644 index 000000000..08624d600 --- /dev/null +++ b/Tests/BrowserServicesKitTests/SecureVault/SecureVaultModelTests.swift @@ -0,0 +1,195 @@ +// +// SecureVaultModelTests.swift +// +// Copyright © 2022 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 XCTest +@testable import BrowserServicesKit + +class SecureVaultModelTests: XCTestCase { + + // MARK: - Identities + + func testWhenCreatingIdentities_ThenTheyHaveCachedAutofillProperties() { + let identity = identity(named: ("First", "Middle", "Last"), addressStreet: "Address Street") + + XCTAssertEqual(identity.autofillEqualityName, "firstmiddlelast") + XCTAssertEqual(identity.autofillEqualityAddressStreet, "addressstreet") + } + + + func testWhenCreatingIdentities_AndTheyHaveCachedAutofillProperties_ThenMutatingThePropertiesUpdatesTheCachedVersions() { + var identity = identity(named: ("First", "Middle", "Last"), addressStreet: "Address Street") + + XCTAssertEqual(identity.autofillEqualityName, "firstmiddlelast") + XCTAssertEqual(identity.autofillEqualityAddressStreet, "addressstreet") + + identity.firstName = "Dax" + identity.middleName = "The" + identity.lastName = "Duck" + identity.addressStreet = "New Street" + + XCTAssertEqual(identity.autofillEqualityName, "daxtheduck") + XCTAssertEqual(identity.autofillEqualityAddressStreet, "newstreet") + } + + func testWhenIdentitiesHaveTheSameNames_ThenAutoFillEqualityIsTrue() { + let identity1 = identity(named: ("First", "Middle", "Last"), addressStreet: "Address Street") + let identity2 = identity(named: ("First", "Middle", "Last"), addressStreet: "Address Street") + + XCTAssertTrue(identity1.hasAutofillEquality(comparedTo: identity2)) + } + + func testWhenIdentitiesHaveTheSameNames_AndNoAddress_ThenAutoFillEqualityIsTrue() { + let identity1 = identity(named: ("First", "Middle", "Last"), addressStreet: nil) + let identity2 = identity(named: ("First", "Middle", "Last"), addressStreet: nil) + + XCTAssertTrue(identity1.hasAutofillEquality(comparedTo: identity2)) + } + + func testWhenIdentitiesHaveTheSameNames_AndDifferentAddresses_ThenAutoFillEqualityIsFalse() { + let identity1 = identity(named: ("First", "Middle", "Last"), addressStreet: "First Address") + let identity2 = identity(named: ("First", "Middle", "Last"), addressStreet: "Second Address") + + XCTAssertFalse(identity1.hasAutofillEquality(comparedTo: identity2)) + } + + func testWhenIdentitiesHaveTheSameNames_AndHaveArbitraryWhitespace_ThenAutoFillEqualityIsTrue() { + let identity1 = identity(named: ("First ", " Middle", " Last"), addressStreet: " Address Street ") + let identity2 = identity(named: ("First", "Middle", "Last"), addressStreet: "Address Street") + + XCTAssertTrue(identity1.hasAutofillEquality(comparedTo: identity2)) + } + + func testWhenIdentitiesHaveTheSameNames_AndSomeNamesHaveDiacritics_ThenAutoFillEqualityIsTrue() { + let identity1 = identity(named: ("Fírst", "Mïddlé", "Lâst"), addressStreet: "Address Street") + let identity2 = identity(named: ("First", "Middle", "Last"), addressStreet: "Address Street") + + XCTAssertTrue(identity1.hasAutofillEquality(comparedTo: identity2)) + } + + func testWhenIdentitiesHaveTheSameNames_AndSomeNamesHaveEmoji_ThenAutoFillEqualityIsTrue() { + let identity1 = identity(named: ("First 😎", "Middle", "Last"), addressStreet: "Address Street") + let identity2 = identity(named: ("First", "Middle", "Last"), addressStreet: "Address Street") + + XCTAssertTrue(identity1.hasAutofillEquality(comparedTo: identity2)) + } + + func testWhenIdentitiesHaveTheFullNameInOneField_ThenAutoFillEqualityIsTrue() { + let identity1 = identity(named: ("First Middle Last", "", ""), addressStreet: "Address Street") + let identity2 = identity(named: ("First", "Middle", "Last"), addressStreet: "Address Street") + + XCTAssertTrue(identity1.hasAutofillEquality(comparedTo: identity2)) + } + + func testWhenIdentitiesHaveDifferentNames_ButOtherValuesMatch_ThenAutofillEqualityIsFalse() { + let identity1 = identity(named: ("One", "Two", "Three"), addressStreet: "Address Street") + let identity2 = identity(named: ("First", "Middle", "Last"), addressStreet: "Address Street") + + XCTAssertFalse(identity1.hasAutofillEquality(comparedTo: identity2)) + } + + func testIdentityEqualityPerformance() { + let identity = identity(named: ("First", "Middle", "Last"), addressStreet: "Address Street") + + let identitiesToCheck = (1...10000).map { + return self.identity(named: ("First", "Middle", "Last"), addressStreet: "Address Street \($0)") + } + + measure { + for identityToCheck in identitiesToCheck { + _ = identity.hasAutofillEquality(comparedTo: identityToCheck) + } + } + } + + // MARK: - Payment Methods + + func testWhenCardNumbersAreTheSame_ThenAutofillEqualityIsTrue() { + let card1 = paymentMethod(cardNumber: "5555555555555557", cardholderName: "Name", cvv: "123", month: 1, year: 3000) + let card2 = paymentMethod(cardNumber: "5555555555555557", cardholderName: "Name", cvv: "123", month: 1, year: 3000) + + XCTAssertTrue(card1.hasAutofillEquality(comparedTo: card2)) + } + + func testWhenCardNumbersAreTheSame_ButTheyHaveDifferentSpacing_ThenAutofillEqualityIsTrue() { + let card1 = paymentMethod(cardNumber: "5555555555555557", cardholderName: "Name", cvv: "123", month: 1, year: 3000) + let card2 = paymentMethod(cardNumber: "5555 5555 5555 5557", cardholderName: "Name", cvv: "123", month: 1, year: 3000) + + XCTAssertTrue(card1.hasAutofillEquality(comparedTo: card2)) + } + + func testWhenCardNumbersAreDifferent_ThenAutofillEqualityIsFalse() { + let card1 = paymentMethod(cardNumber: "1234 1234 1234 1234", cardholderName: "Name", cvv: "123", month: 1, year: 3000) + let card2 = paymentMethod(cardNumber: "5555 5555 5555 5557", cardholderName: "Name", cvv: "123", month: 1, year: 3000) + + XCTAssertFalse(card1.hasAutofillEquality(comparedTo: card2)) + } + + func testPaymentMethodEqualityPerformance() { + let paymentMethod = paymentMethod(cardNumber: "5555555555555557", cardholderName: "Name", cvv: "123", month: 1, year: 3000) + + let cardsToCheck = (1...10000).map { + return self.paymentMethod(cardNumber: "5555555555555557", cardholderName: "Name \($0)", cvv: "123", month: 1, year: 3000) + } + + measure { + for cardToCheck in cardsToCheck { + _ = paymentMethod.hasAutofillEquality(comparedTo: cardToCheck) + } + } + } + + // MARK: - Test Utilities + + private func identity(named name: (String, String, String), addressStreet: String?) -> SecureVaultModels.Identity { + return SecureVaultModels.Identity(id: nil, + title: nil, + created: Date(), + lastUpdated: Date(), + firstName: name.0, + middleName: name.1, + lastName: name.2, + birthdayDay: nil, + birthdayMonth: nil, + birthdayYear: nil, + addressStreet: addressStreet, + addressStreet2: nil, + addressCity: nil, + addressProvince: nil, + addressPostalCode: nil, + addressCountryCode: nil, + homePhone: nil, + mobilePhone: nil, + emailAddress: nil) + } + + private func paymentMethod(cardNumber: String, + cardholderName: String, + cvv: String, + month: Int, + year: Int) -> SecureVaultModels.CreditCard { + return SecureVaultModels.CreditCard(id: nil, + title: nil, + cardNumber: cardNumber, + cardholderName: cardholderName, + cardSecurityCode: cvv, + expirationMonth: month, + expirationYear: year) + } + +}