diff --git a/DuckDuckGo.xcodeproj/project.pbxproj b/DuckDuckGo.xcodeproj/project.pbxproj index 2f0cc08309..32d078fad0 100644 --- a/DuckDuckGo.xcodeproj/project.pbxproj +++ b/DuckDuckGo.xcodeproj/project.pbxproj @@ -761,14 +761,15 @@ 9F254ACE2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ACD2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift */; }; 9F254AD22CF5D3A80063B308 /* MockSpecialErrorWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */; }; 9F254AD32CF5D3A80063B308 /* MockSpecialErrorWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */; }; - 9F254AD52CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */; }; - 9F254AD62CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */; }; + 9F254AD52CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */; }; + 9F254AD62CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */; }; 9F254AD82CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */; }; 9F254AD92CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */; }; 9F254ADB2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */; }; 9F254ADC2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */; }; 9F254ADE2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */; }; 9F254ADF2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */; }; + 9F254AF12CF8D5250063B308 /* MaliciousSiteProtection in Frameworks */ = {isa = PBXBuildFile; productRef = 9F254AF02CF8D5250063B308 /* MaliciousSiteProtection */; }; 9F254AFF2CF9FA1B0063B308 /* WebViewNavigationHandling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254AFE2CF9FA1B0063B308 /* WebViewNavigationHandling.swift */; }; 9F254B012CF9FA8D0063B308 /* SpecialErrorPageActionHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254B002CF9FA8D0063B308 /* SpecialErrorPageActionHandler.swift */; }; 9F254B032CF9FB2E0063B308 /* SpecialErrorPageNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F254B022CF9FB2E0063B308 /* SpecialErrorPageNavigationDelegate.swift */; }; @@ -819,6 +820,9 @@ 9FB027192C26BC29009EA190 /* BrowsersComparisonModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB027182C26BC29009EA190 /* BrowsersComparisonModel.swift */; }; 9FB0271B2C2927D0009EA190 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB0271A2C2927D0009EA190 /* OnboardingView.swift */; }; 9FB0271D2C293619009EA190 /* OnboardingIntroViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FB0271C2C293619009EA190 /* OnboardingIntroViewModel.swift */; }; + 9FBC76672CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBC76662CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift */; }; + 9FBC766A2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */; }; + 9FBC766B2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */; }; 9FCFCD802C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD7F2C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift */; }; 9FCFCD812C6B020D006EB7A0 /* LaunchOptionsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD7D2C6AF52A006EB7A0 /* LaunchOptionsHandler.swift */; }; 9FCFCD852C75C91A006EB7A0 /* ProgressBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FCFCD842C75C91A006EB7A0 /* ProgressBarView.swift */; }; @@ -2632,7 +2636,7 @@ 9F254ACA2CF5CDC60063B308 /* SpecialErrorPageNavigationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageNavigationHandlerTests.swift; sourceTree = ""; }; 9F254ACD2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpecialErrorPageNavigationHandlerIntegrationTests.swift; sourceTree = ""; }; 9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSpecialErrorWebView.swift; sourceTree = ""; }; - 9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DummyMaliciousSiteProtectionNavigationHandler.swift; sourceTree = ""; }; + 9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMaliciousSiteProtectionNavigationHandler.swift; sourceTree = ""; }; 9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSSLErrorPageNavigationHandler.swift; sourceTree = ""; }; 9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSpecialErrorPageNavigationDelegate.swift; sourceTree = ""; }; 9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DummyWKNavigation.swift; sourceTree = ""; }; @@ -2684,6 +2688,8 @@ 9FB027182C26BC29009EA190 /* BrowsersComparisonModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowsersComparisonModel.swift; sourceTree = ""; }; 9FB0271A2C2927D0009EA190 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = ""; }; 9FB0271C2C293619009EA190 /* OnboardingIntroViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingIntroViewModel.swift; sourceTree = ""; }; + 9FBC76662CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaliciousSiteProtectionNavigationHandlerTests.swift; sourceTree = ""; }; + 9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMaliciousSiteProtectionManager.swift; sourceTree = ""; }; 9FCFCD7D2C6AF52A006EB7A0 /* LaunchOptionsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LaunchOptionsHandler.swift; path = ../DuckDuckGo/LaunchOptionsHandler.swift; sourceTree = ""; }; 9FCFCD7F2C6AF56D006EB7A0 /* LaunchOptionsHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchOptionsHandlerTests.swift; sourceTree = ""; }; 9FCFCD842C75C91A006EB7A0 /* ProgressBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBarView.swift; sourceTree = ""; }; @@ -3234,6 +3240,7 @@ 31E69A63280F4CB600478327 /* DuckUI in Frameworks */, CB941A6E2B96AB08000F9E7A /* PrivacyDashboard in Frameworks */, F42D541D29DCA40B004C4FF1 /* DesignResourcesKit in Frameworks */, + 9F254AF12CF8D5250063B308 /* MaliciousSiteProtection in Frameworks */, 1E5918472CA422A7008ED2B3 /* Navigation in Frameworks */, 85875B6129912A9900115F05 /* SyncUI in Frameworks */, F4D7F634298C00C3006C3AE9 /* FindInPageIOSJSSupport in Frameworks */, @@ -5097,6 +5104,7 @@ CBC88EE22C7F8B1700F0F8C5 /* SSLErrorPageNavigationHandlerTests.swift */, 9F254ACA2CF5CDC60063B308 /* SpecialErrorPageNavigationHandlerTests.swift */, 9F254ACD2CF5D3540063B308 /* SpecialErrorPageNavigationHandlerIntegrationTests.swift */, + 9FBC76662CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift */, ); path = SpecialErrorPage; sourceTree = ""; @@ -5105,10 +5113,11 @@ isa = PBXGroup; children = ( 9F254AD12CF5D3A20063B308 /* MockSpecialErrorWebView.swift */, - 9F254AD42CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift */, + 9F254AD42CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift */, 9F254AD72CF605310063B308 /* MockSSLErrorPageNavigationHandler.swift */, 9F254ADA2CF6120E0063B308 /* MockSpecialErrorPageNavigationDelegate.swift */, 9F254ADD2CF636CF0063B308 /* DummyWKNavigation.swift */, + 9FBC76692CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift */, ); path = TestDoubles; sourceTree = ""; @@ -6974,6 +6983,7 @@ 9F96F73A2C9144D5009E45D5 /* Onboarding */, 1E5918462CA422A7008ED2B3 /* Navigation */, 315C77812CFA41A400699683 /* AIChat */, + 9F254AF02CF8D5250063B308 /* MaliciousSiteProtection */, ); productName = DuckDuckGo; productReference = 84E341921E2F7EFB00BDBA6F /* DuckDuckGo.app */; @@ -8457,7 +8467,7 @@ 569437342BE4E41500C0881B /* SyncErrorHandlerSyncErrorsAlertsTests.swift in Sources */, CBC88EE32C7F8B1700F0F8C5 /* SSLErrorPageNavigationHandlerTests.swift in Sources */, 85C11E4120904BBE00BFFEB4 /* VariantManagerTests.swift in Sources */, - 9F254AD52CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */, + 9F254AD52CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */, F1134ECE1F40EA9C00B73467 /* AtbParserTests.swift in Sources */, F189AEE41F18FDAF001EBAE1 /* LinkTests.swift in Sources */, 6F7FB8E12C660B3E00867DA7 /* NewTabPageFavoritesModelTests.swift in Sources */, @@ -8497,6 +8507,7 @@ 9F254ADF2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */, 6F7BACD42CEE084B00F561D8 /* OmniBarEqualityCheckTests.swift in Sources */, 6F7FB8E72C66197E00867DA7 /* NewTabPageSectionsSettingsModelTests.swift in Sources */, + 9FBC766A2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */, 851CD674244D7E6000331B98 /* UserDefaultsExtension.swift in Sources */, 569437362BE5160600C0881B /* SyncSettingsViewControllerErrorTests.swift in Sources */, EEC02C162B065BE00045CE11 /* NetworkProtectionVPNLocationViewModelTests.swift in Sources */, @@ -8570,6 +8581,7 @@ 9F4CC51F2C48D758006A96EB /* ContextualDaxDialogsFactoryTests.swift in Sources */, 8521FDE6238D414B00A44CC3 /* FileStoreTests.swift in Sources */, F14E491F1E391CE900DC037C /* URLExtensionTests.swift in Sources */, + 9FBC76672CFE33B5008B21E7 /* MaliciousSiteProtectionNavigationHandlerTests.swift in Sources */, 9F23B8062C2BE22700950875 /* OnboardingIntroViewModelTests.swift in Sources */, 9F69331D2C5A191400CD6A5D /* MockTutorialSettings.swift in Sources */, 85D2187424BF25CD004373D2 /* FaviconsTests.swift in Sources */, @@ -8671,8 +8683,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 9F254AD62CF5E5B10063B308 /* DummyMaliciousSiteProtectionNavigationHandler.swift in Sources */, + 9F254AD62CF5E5B10063B308 /* MockMaliciousSiteProtectionNavigationHandler.swift in Sources */, 85F21DB0210F5E32002631A6 /* AtbIntegrationTests.swift in Sources */, + 9FBC766B2CFE3802008B21E7 /* MockMaliciousSiteProtectionManager.swift in Sources */, 9F254AD22CF5D3A80063B308 /* MockSpecialErrorWebView.swift in Sources */, 9F254ADE2CF636CF0063B308 /* DummyWKNavigation.swift in Sources */, 8551912724746EDC0010FDD0 /* SnapshotHelper.swift in Sources */, @@ -12114,6 +12127,10 @@ package = 98A16C2928A11BDE00A6C003 /* XCRemoteSwiftPackageReference "BrowserServicesKit" */; productName = Bookmarks; }; + 9F254AF02CF8D5250063B308 /* MaliciousSiteProtection */ = { + isa = XCSwiftPackageProductDependency; + productName = MaliciousSiteProtection; + }; 9F8FE9482BAE50E50071E372 /* Lottie */ = { isa = XCSwiftPackageProductDependency; package = 9F8FE9472BAE50E50071E372 /* XCRemoteSwiftPackageReference "lottie-spm" */; diff --git a/DuckDuckGo/MaliciousSiteProtection/MaliciousSiteProtectionManager.swift b/DuckDuckGo/MaliciousSiteProtection/MaliciousSiteProtectionManager.swift index 5175a16879..46dab041d0 100644 --- a/DuckDuckGo/MaliciousSiteProtection/MaliciousSiteProtectionManager.swift +++ b/DuckDuckGo/MaliciousSiteProtection/MaliciousSiteProtectionManager.swift @@ -18,38 +18,20 @@ // import Foundation +import MaliciousSiteProtection final class MaliciousSiteProtectionManager: MaliciousSiteDetecting { func evaluate(_ url: URL) async -> ThreatKind? { try? await Task.sleep(interval: 0.3) - return .none - } - -} - -// MARK: - To Remove - -// These entities are copied from BSK and they will be used to mock the library -import SpecialErrorPages - -protocol MaliciousSiteDetecting { - func evaluate(_ url: URL) async -> ThreatKind? -} - -public enum ThreatKind: String, CaseIterable, CustomStringConvertible { - public var description: String { rawValue } - - case phishing - case malware -} - -public extension ThreatKind { - var errorPageType: SpecialErrorKind { - switch self { - case .malware: .phishing // WIP in BSK - case .phishing: .phishing + switch url.absoluteString { + case "http://privacy-test-pages.site/security/badware/phishing.html": + return .phishing + case "http://privacy-test-pages.site/security/badware/malware.html": + return .malware + default: + return .none } } diff --git a/DuckDuckGo/SpecialErrorPage/Model/SpecialErrorModel.swift b/DuckDuckGo/SpecialErrorPage/Model/SpecialErrorModel.swift index cd4b3b3411..8e1f344fcb 100644 --- a/DuckDuckGo/SpecialErrorPage/Model/SpecialErrorModel.swift +++ b/DuckDuckGo/SpecialErrorPage/Model/SpecialErrorModel.swift @@ -19,6 +19,7 @@ import Foundation import SpecialErrorPages +import WebKit struct SpecialErrorModel: Equatable { let url: URL @@ -29,3 +30,8 @@ struct SSLSpecialError { let type: SSLErrorType let error: SpecialErrorModel } + +struct MaliciousSiteDetectionNavigationResponse: Equatable { + let navigationAction: WKNavigationAction + let errorData: SpecialErrorData +} diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageActionHandler.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageActionHandler.swift index 2d12540cde..6315a30c30 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageActionHandler.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageActionHandler.swift @@ -18,6 +18,7 @@ // import Foundation +import SpecialErrorPages /// A type that defines actions for handling special error pages. /// @@ -26,11 +27,14 @@ import Foundation /// advanced information related to the error. protocol SpecialErrorPageActionHandler { /// Handles the action of navigating to the site associated with the error page - func visitSite() + @MainActor + func visitSite(url: URL, errorData: SpecialErrorData) /// Handles the action of leaving the site associated with the error page + @MainActor func leaveSite() /// Handles the action of requesting more detailed information about the error + @MainActor func advancedInfoPresented() } diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageContextHandling.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageContextHandling.swift index 0c3ce79206..6719cb0876 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageContextHandling.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/SpecialErrorPageContextHandling.swift @@ -32,6 +32,9 @@ protocol SpecialErrorPageContextHandling: AnyObject { /// The URL that failed to load, if any. var failedURL: URL? { get } + /// A boolean value indicating whether the WebView request requires showing a special error page. + var isSpecialErrorPageRequest: Bool { get } + /// Attaches a web view to the special error page handling. func attachWebView(_ webView: WKWebView) diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/WebViewNavigationHandling.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/WebViewNavigationHandling.swift index 31cb18a9bf..74dc85f68b 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/WebViewNavigationHandling.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageInterfaces/WebViewNavigationHandling.swift @@ -19,6 +19,7 @@ import Foundation import WebKit +import MaliciousSiteProtection // MARK: - WebViewNavigation @@ -41,8 +42,16 @@ protocol WebViewNavigationHandling: AnyObject { /// - Parameters: /// - navigationAction: Details about the action that triggered the navigation request. /// - webView: The web view from which the navigation request began. - /// - Returns: A Boolean value that indicates whether the navigation action was handled. - func handleSpecialErrorNavigation(navigationAction: WKNavigationAction, webView: WKWebView) async -> Bool + @MainActor + func handleDecidePolicyFor(navigationAction: WKNavigationAction, webView: WKWebView) + + /// Decides whether to to navigate to new content after the response to the navigation request is known or cancel the navigation and show a special error page based on the specified action information. + /// - Parameters: + /// - navigationResponse: Descriptive information about the navigation response. + /// - webView: The web view from which the navigation request began. + /// - Returns: A Boolean value that indicates whether to cancel or allow the navigation. + @MainActor + func handleDecidePolicyfor(navigationResponse: WKNavigationResponse, webView: WKWebView) async -> Bool /// Handles authentication challenges received by the web view. /// @@ -50,6 +59,7 @@ protocol WebViewNavigationHandling: AnyObject { /// - webView: The web view that receives the authentication challenge. /// - challenge: The authentication challenge. /// - completionHandler: A completion handler block to execute with the response. + @MainActor func handleWebView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) /// Handles failures during provisional navigation. @@ -58,6 +68,7 @@ protocol WebViewNavigationHandling: AnyObject { /// - webView: The `WKWebView` instance that failed the navigation. /// - navigation: The navigation object for the operation. /// - error: The error that occurred. + @MainActor func handleWebView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WebViewNavigation, withError error: NSError) /// Handles the successful completion of a navigation in the web view. @@ -65,5 +76,6 @@ protocol WebViewNavigationHandling: AnyObject { /// - Parameters: /// - webView: The web view that loaded the content. /// - navigation: The navigation object that finished. + @MainActor func handleWebView(_ webView: WKWebView, didFinish navigation: WebViewNavigation) } diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+MaliciousSite.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+MaliciousSite.swift index e49ea0e215..91c14f6695 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+MaliciousSite.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+MaliciousSite.swift @@ -22,26 +22,49 @@ import BrowserServicesKit import Core import SpecialErrorPages import WebKit +import MaliciousSiteProtection enum MaliciousSiteProtectionNavigationResult: Equatable { - case navigationHandled(SpecialErrorModel) + case navigationHandled(NavigationType) case navigationNotHandled + + enum NavigationType: Equatable { + case mainFrame(MaliciousSiteDetectionNavigationResponse) + case iFrame(maliciousURL: URL, error: SpecialErrorData) + } } protocol MaliciousSiteProtectionNavigationHandling: AnyObject { - /// Decides whether to cancel navigation to prevent opening the YouTube app from the web view. + /// Creates a task for detecting malicious sites based on the provided navigation action. + /// + /// - Parameters: + /// - navigationAction: The `WKNavigationAction` object that contains information about + /// the navigation event. + /// - webView: The web view from which the navigation request began. + @MainActor + func createMaliciousSiteDetectionTask(for navigationAction: WKNavigationAction, webView: WKWebView) + + /// Retrieves a task for detecting malicious sites based on the provided navigation response. /// /// - Parameters: - /// - navigationAction: The navigation action to evaluate. - /// - webView: The web view where navigation is occurring. - /// - Returns: `true` if the navigation should be canceled, `false` otherwise. - func handleMaliciousSiteProtectionNavigation(for navigationAction: WKNavigationAction, webView: WKWebView) async -> MaliciousSiteProtectionNavigationResult + /// - navigationResponse: The `WKNavigationResponse` object that contains information about + /// the navigation event. + /// - webView: The web view from which the navigation request began. + /// - Returns: A `Task?` that represents the + /// asynchronous operation for detecting malicious sites. If the task cannot be found, + /// the function returns `nil`. + @MainActor + func getMaliciousSiteDectionTask(for navigationResponse: WKNavigationResponse, webView: WKWebView) -> Task? } final class MaliciousSiteProtectionNavigationHandler { private let maliciousSiteProtectionManager: MaliciousSiteDetecting private let storageCache: StorageCache + @MainActor private(set) var maliciousURLExemptions: [URL: ThreatKind] = [:] + @MainActor private(set) var bypassedMaliciousSiteThreatKind: ThreatKind? + @MainActor private(set) var maliciousSiteDetectionTasks: [URL: Task] = [:] + init( maliciousSiteProtectionManager: MaliciousSiteDetecting = MaliciousSiteProtectionManager(), storageCache: StorageCache = AppDependencyProvider.shared.storageCache @@ -56,10 +79,53 @@ final class MaliciousSiteProtectionNavigationHandler { extension MaliciousSiteProtectionNavigationHandler: MaliciousSiteProtectionNavigationHandling { @MainActor - func handleMaliciousSiteProtectionNavigation(for navigationAction: WKNavigationAction, webView: WKWebView) async -> MaliciousSiteProtectionNavigationResult { - // Implement logic to use `maliciousSiteProtectionManager.evaluate(url)` - // Return navigationNotHandled for the time being - return .navigationNotHandled + func createMaliciousSiteDetectionTask(for navigationAction: WKNavigationAction, webView: WKWebView) { + + guard let url = navigationAction.request.url else { + return + } + + if let aboutBlankURL = URL(string: "about:blank"), url == aboutBlankURL { + return + } + + handleMaliciousExemptions(for: navigationAction.navigationType, url: url) + + guard !shouldBypassMaliciousSiteProtection(for: url) else { + return + } + + let threatDetectionTask: Task = Task.detached { [weak self] in + guard let self else { return .navigationNotHandled } + + guard let threatKind = await self.maliciousSiteProtectionManager.evaluate(url) else { + return .navigationNotHandled + } + + if await navigationAction.isTargetingMainFrame { + let errorData = SpecialErrorData.maliciousSite(kind: threatKind, url: url) + let response = MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData) + return .navigationHandled(.mainFrame(response)) + } else { + // Extract the URL of the source frame (the iframe) that initiated the navigation action + let iFrameTopURL = await navigationAction.sourceFrame.safeRequest?.url ?? url + let errorData = SpecialErrorData.maliciousSite(kind: threatKind, url: iFrameTopURL) + return .navigationHandled(.iFrame(maliciousURL: url, error: errorData)) + } + } + + maliciousSiteDetectionTasks[url] = threatDetectionTask + } + + @MainActor + func getMaliciousSiteDectionTask(for navigationResponse: WKNavigationResponse, webView: WKWebView) -> Task? { + + guard let url = navigationResponse.response.url else { + assertionFailure("Could not find Malicious Site Detection Task for URL") + return nil + } + + return maliciousSiteDetectionTasks.removeValue(forKey: url) } } @@ -68,7 +134,10 @@ extension MaliciousSiteProtectionNavigationHandler: MaliciousSiteProtectionNavig extension MaliciousSiteProtectionNavigationHandler: SpecialErrorPageActionHandler { - func visitSite() { + func visitSite(url: URL, errorData: SpecialErrorData) { + maliciousURLExemptions[url] = errorData.threatKind + bypassedMaliciousSiteThreatKind = errorData.threatKind + // Fire Pixel } @@ -81,3 +150,36 @@ extension MaliciousSiteProtectionNavigationHandler: SpecialErrorPageActionHandle } } + +// MARK: - Private + +private extension MaliciousSiteProtectionNavigationHandler { + + @MainActor + func handleMaliciousExemptions(for navigationType: WKNavigationType, url: URL) { + // TODO: check storing redirects + // Re-set the flag every time we load a web page + bypassedMaliciousSiteThreatKind = maliciousURLExemptions[url] + } + + @MainActor + func shouldBypassMaliciousSiteProtection(for url: URL) -> Bool { + bypassedMaliciousSiteThreatKind != .none || url.isDuckDuckGo || url.isDuckURLScheme + } + +} + +// MARK: - Helpers + +private extension SpecialErrorData { + + var threatKind: ThreatKind? { + switch self { + case .ssl: + return nil + case let .maliciousSite(threatKind, _): + return threatKind + } + } + +} diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+SSL.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+SSL.swift index 10d5b71f3a..67a7efc087 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+SSL.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler+SSL.swift @@ -71,7 +71,11 @@ extension SSLErrorPageNavigationHandler: SSLSpecialErrorPageNavigationHandling { return nil } - let errorData = SpecialErrorData.ssl(type: errorType, domain: host, eTldPlus1: storageCache.tld.eTLDplus1(host)) + let errorData = SpecialErrorData.ssl( + type: errorType, + domain: host, + eTldPlus1: storageCache.tld.eTLDplus1(host) + ) return SSLSpecialError(type: errorType, error: SpecialErrorModel(url: failedURL, errorData: errorData)) } @@ -90,7 +94,7 @@ extension SSLErrorPageNavigationHandler: SpecialErrorPageActionHandler { Pixel.fire(pixel: .certificateWarningLeaveClicked) } - func visitSite() { + func visitSite(url: URL, errorData: SpecialErrorData) { shouldBypassSSLError = true } diff --git a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler.swift b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler.swift index 57422c2610..c5d596ec77 100644 --- a/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler.swift +++ b/DuckDuckGo/SpecialErrorPage/SpecialErrorPageNavigationHandler.swift @@ -21,18 +21,20 @@ import Foundation import WebKit import SpecialErrorPages import Core +import MaliciousSiteProtection typealias SpecialErrorPageManaging = SpecialErrorPageContextHandling & WebViewNavigationHandling & SpecialErrorPageUserScriptDelegate final class SpecialErrorPageNavigationHandler: SpecialErrorPageContextHandling { private var webView: WKWebView? - private(set) var errorData: SpecialErrorData? - private var errorPageType: SpecialErrorKind? - private(set) var isSpecialErrorPageVisible = false - private(set) var failedURL: URL? private weak var userScript: SpecialErrorPageUserScript? weak var delegate: SpecialErrorPageNavigationDelegate? + @MainActor private(set) var errorData: SpecialErrorData? + @MainActor private(set) var isSpecialErrorPageVisible = false + @MainActor private(set) var failedURL: URL? + @MainActor private(set) var isSpecialErrorPageRequest = false + private let sslErrorPageNavigationHandler: SSLSpecialErrorPageNavigationHandling & SpecialErrorPageActionHandler private let maliciousSiteProtectionNavigationHandler: MaliciousSiteProtectionNavigationHandling & SpecialErrorPageActionHandler @@ -58,41 +60,61 @@ final class SpecialErrorPageNavigationHandler: SpecialErrorPageContextHandling { extension SpecialErrorPageNavigationHandler: WebViewNavigationHandling { - func handleSpecialErrorNavigation(navigationAction: WKNavigationAction, webView: WKWebView) async -> Bool { - let result = await maliciousSiteProtectionNavigationHandler.handleMaliciousSiteProtectionNavigation(for: navigationAction, webView: webView) - - return await MainActor.run { - switch result { - case let .navigationHandled(model): - var request = navigationAction.request - request.url = model.url - failedURL = model.url - errorData = model.errorData - errorPageType = .phishing - loadSpecialErrorPage(request: request) - return true - case .navigationNotHandled: - return false - } + @MainActor + func handleDecidePolicyFor(navigationAction: WKNavigationAction, webView: WKWebView) { + maliciousSiteProtectionNavigationHandler.createMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + } + + @MainActor + func handleDecidePolicyfor(navigationResponse: WKNavigationResponse, webView: WKWebView) async -> Bool { + guard let task = maliciousSiteProtectionNavigationHandler.getMaliciousSiteDectionTask(for: navigationResponse, webView: webView) else { + return false + } + + let result = await task.value + + switch result { + case let .navigationHandled(.mainFrame(response)): + // Re-use the same request to avoid that the new sideload request is intercepted and cancelled + // due to parameters added to the header. + var request = response.navigationAction.request + request.url = response.errorData.url + isSpecialErrorPageRequest = true + failedURL = response.errorData.url + errorData = response.errorData + loadSpecialErrorPage(request: request) + return true + case let .navigationHandled(.iFrame(maliciousURL, error)): + isSpecialErrorPageRequest = true + failedURL = maliciousURL + errorData = error + loadSpecialErrorPage(url: maliciousURL) + return true + case .navigationNotHandled: + isSpecialErrorPageRequest = false + return false } } - + + @MainActor func handleWebView(_ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else { return } sslErrorPageNavigationHandler.handleServerTrustChallenge(challenge, completionHandler: completionHandler) } - + + @MainActor func handleWebView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WebViewNavigation, withError error: NSError) { guard let sslSpecialError = sslErrorPageNavigationHandler.makeNewRequestURLAndSpecialErrorDataIfEnabled(error: error) else { return } failedURL = sslSpecialError.error.url sslErrorPageNavigationHandler.errorPageVisited(errorType: sslSpecialError.type) errorData = sslSpecialError.error.errorData - errorPageType = .ssl loadSpecialErrorPage(url: sslSpecialError.error.url) } - + + @MainActor func handleWebView(_ webView: WKWebView, didFinish navigation: WebViewNavigation) { + isSpecialErrorPageRequest = false userScript?.isEnabled = webView.url == failedURL if webView.url != failedURL { isSpecialErrorPageVisible = false @@ -105,45 +127,59 @@ extension SpecialErrorPageNavigationHandler: WebViewNavigationHandling { extension SpecialErrorPageNavigationHandler: SpecialErrorPageUserScriptDelegate { + @MainActor func leaveSiteAction() { - switch errorPageType { - case .ssl: - sslErrorPageNavigationHandler.leaveSite() - case .phishing: - maliciousSiteProtectionNavigationHandler.leaveSite() - default: - break + + func navigateBackIfPossible() { + if webView?.canGoBack == true { + _ = webView?.goBack() + } else { + closeTab() + } } - if webView?.canGoBack == true { - _ = webView?.goBack() - } else { + func closeTab() { delegate?.closeSpecialErrorPageTab() } + + guard let errorData else { return } + + switch errorData { + case .ssl: + sslErrorPageNavigationHandler.leaveSite() + navigateBackIfPossible() + case .maliciousSite: + maliciousSiteProtectionNavigationHandler.leaveSite() + closeTab() + } } + @MainActor func visitSiteAction() { - switch errorPageType { - case .ssl: - sslErrorPageNavigationHandler.visitSite() - case .phishing: - maliciousSiteProtectionNavigationHandler.visitSite() - default: - break + defer { + isSpecialErrorPageVisible = false + _ = webView?.reload() } - isSpecialErrorPageVisible = false - _ = webView?.reload() + guard let errorData, let url = webView?.url else { return } + + switch errorData { + case .ssl: + sslErrorPageNavigationHandler.visitSite(url: url, errorData: errorData) + case .maliciousSite: + maliciousSiteProtectionNavigationHandler.visitSite(url: url, errorData: errorData) + } } + @MainActor func advancedInfoPresented() { - switch errorPageType { + guard let errorData else { return } + + switch errorData { case .ssl: sslErrorPageNavigationHandler.advancedInfoPresented() - case .phishing: + case .maliciousSite: maliciousSiteProtectionNavigationHandler.advancedInfoPresented() - default: - break } } } @@ -152,10 +188,12 @@ extension SpecialErrorPageNavigationHandler: SpecialErrorPageUserScriptDelegate private extension SpecialErrorPageNavigationHandler { + @MainActor func loadSpecialErrorPage(url: URL) { loadSpecialErrorPage(request: URLRequest(url: url)) } + @MainActor func loadSpecialErrorPage(request: URLRequest) { let html = SpecialErrorPageHTMLTemplate.htmlFromTemplate webView?.loadSimulatedRequest(request, responseHTML: html) @@ -163,3 +201,18 @@ private extension SpecialErrorPageNavigationHandler { } } + +// MARK: - Helpers + +private extension SpecialErrorData { + + var url: URL? { + switch self { + case .ssl: + return nil + case let .maliciousSite(_, url): + return url + } + } + +} diff --git a/DuckDuckGo/TabViewController.swift b/DuckDuckGo/TabViewController.swift index 4586314235..252b000f50 100644 --- a/DuckDuckGo/TabViewController.swift +++ b/DuckDuckGo/TabViewController.swift @@ -1375,55 +1375,64 @@ extension TabViewController: WKNavigationDelegate { NotificationCenter.default.post(Notification(name: AppUserDefaults.Notifications.didVerifyInternalUser)) } - // Important: Order of these checks matter! - if urlSchemeType == .blob { - // 1. To properly handle BLOB we need to trigger its download, if temporaryDownloadForPreviewedFile is set we allow its load in the web view - if let temporaryDownloadForPreviewedFile, temporaryDownloadForPreviewedFile.url == navigationResponse.response.url { - // BLOB already has a temporary downloaded so and we can allow loading it - blobDownloadTargetFrame = nil - decisionHandler(.allow) - } else { - // First we need to trigger download to handle it then in webView:navigationAction:didBecomeDownload - decisionHandler(.download) - } - } else if FilePreviewHelper.canAutoPreviewMIMEType(mimeType) { - // 2. For this MIME type we are able to provide a better custom preview via FilePreviewHelper so it takes priority - let download = self.startDownload(with: navigationResponse, decisionHandler: decisionHandler) - mostRecentAutoPreviewDownloadID = download?.id - Pixel.fire(pixel: .downloadStarted, - withAdditionalParameters: [PixelParameters.canAutoPreviewMIMEType: "1"]) - } else if shouldTriggerDownloadAction(for: navigationResponse), - let downloadMetadata = AppDependencyProvider.shared.downloadManager.downloadMetaData(for: navigationResponse.response) { - // 3a. We know it is a download, but allow WebKit handle the "data" scheme natively - if urlNavigationalScheme == .data { - decisionHandler(.download) - return - } - - // 3b. We know the response should trigger the file download prompt - self.presentSaveToDownloadsAlert(with: downloadMetadata) { - self.startDownload(with: navigationResponse, decisionHandler: decisionHandler) - } cancelHandler: { + // If the navigation has been handled by the special error page handler cancel navigating to new content as the special error page will be shown. + Task { @MainActor in + if + !specialErrorPageNavigationHandler.isSpecialErrorPageRequest, + await specialErrorPageNavigationHandler.handleDecidePolicyfor(navigationResponse: navigationResponse, webView: webView) { decisionHandler(.cancel) - } - } else if navigationResponse.canShowMIMEType { - // 4. WebView can preview the MIME type and it is not to be handled by our custom FilePreviewHelper - url = webView.url - if navigationResponse.isForMainFrame, let decision = setupOrClearTemporaryDownload(for: navigationResponse.response) { - // Loading a file preview in web view - decisionHandler(decision) } else { - // Loading HTML - if navigationResponse.isForMainFrame && isSuccessfulResponse { - adClickAttributionDetection.on2XXResponse(url: url) - } - adClickAttributionLogic.onProvisionalNavigation { + // Important: Order of these checks matter! + if urlSchemeType == .blob { + // 1. To properly handle BLOB we need to trigger its download, if temporaryDownloadForPreviewedFile is set we allow its load in the web view + if let temporaryDownloadForPreviewedFile, temporaryDownloadForPreviewedFile.url == navigationResponse.response.url { + // BLOB already has a temporary downloaded so and we can allow loading it + blobDownloadTargetFrame = nil + decisionHandler(.allow) + } else { + // First we need to trigger download to handle it then in webView:navigationAction:didBecomeDownload + decisionHandler(.download) + } + } else if FilePreviewHelper.canAutoPreviewMIMEType(mimeType) { + // 2. For this MIME type we are able to provide a better custom preview via FilePreviewHelper so it takes priority + let download = self.startDownload(with: navigationResponse, decisionHandler: decisionHandler) + mostRecentAutoPreviewDownloadID = download?.id + Pixel.fire(pixel: .downloadStarted, + withAdditionalParameters: [PixelParameters.canAutoPreviewMIMEType: "1"]) + } else if shouldTriggerDownloadAction(for: navigationResponse), + let downloadMetadata = AppDependencyProvider.shared.downloadManager.downloadMetaData(for: navigationResponse.response) { + // 3a. We know it is a download, but allow WebKit handle the "data" scheme natively + if urlNavigationalScheme == .data { + decisionHandler(.download) + return + } + + // 3b. We know the response should trigger the file download prompt + self.presentSaveToDownloadsAlert(with: downloadMetadata) { + self.startDownload(with: navigationResponse, decisionHandler: decisionHandler) + } cancelHandler: { + decisionHandler(.cancel) + } + } else if navigationResponse.canShowMIMEType { + // 4. WebView can preview the MIME type and it is not to be handled by our custom FilePreviewHelper + url = webView.url + if navigationResponse.isForMainFrame, let decision = setupOrClearTemporaryDownload(for: navigationResponse.response) { + // Loading a file preview in web view + decisionHandler(decision) + } else { + // Loading HTML + if navigationResponse.isForMainFrame && isSuccessfulResponse { + adClickAttributionDetection.on2XXResponse(url: url) + } + adClickAttributionLogic.onProvisionalNavigation { + decisionHandler(.allow) + } + } + } else { + // Fallback decisionHandler(.allow) } } - } else { - // Fallback - decisionHandler(.allow) } } @@ -1858,6 +1867,11 @@ extension TabViewController: WKNavigationDelegate { self.delegate?.closeFindInPage(tab: self) } + // If navigating to the URL is allowed and the URL request is not sideloaded ask the specialErrorPageNavigationHandler forward the event to + // the SpecialErrorPageNavigationHandler. + if let self, decision == .allow, !self.specialErrorPageNavigationHandler.isSpecialErrorPageRequest { + self.specialErrorPageNavigationHandler.handleDecidePolicyFor(navigationAction: navigationAction, webView: webView) + } decisionHandler(decision) } } diff --git a/DuckDuckGoTests/DownloadMocks.swift b/DuckDuckGoTests/DownloadMocks.swift index 1601392430..9b7267b6e3 100644 --- a/DuckDuckGoTests/DownloadMocks.swift +++ b/DuckDuckGoTests/DownloadMocks.swift @@ -51,11 +51,12 @@ class MockDownloadSession: DownloadSession { } class MockNavigationResponse: WKNavigationResponse { + var url = URL(string: "https://www.duck.com")! var suggestedFileName: String? var mimeType: String? override var response: URLResponse { - let response = MockURLResponse(url: URL(string: "https://www.duck.com")!, + let response = MockURLResponse(url: url, mimeType: mimeType!, expectedContentLength: 1234, textEncodingName: "") diff --git a/DuckDuckGoTests/SpecialErrorPage/MaliciousSiteProtectionNavigationHandlerTests.swift b/DuckDuckGoTests/SpecialErrorPage/MaliciousSiteProtectionNavigationHandlerTests.swift new file mode 100644 index 0000000000..96b5a06854 --- /dev/null +++ b/DuckDuckGoTests/SpecialErrorPage/MaliciousSiteProtectionNavigationHandlerTests.swift @@ -0,0 +1,220 @@ +// +// MaliciousSiteProtectionNavigationHandlerTests.swift +// DuckDuckGo +// +// 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 Testing +import WebKit +import SpecialErrorPages +import MaliciousSiteProtection +@testable import DuckDuckGo + +@Suite("Special Error Pages - Malicious Site Protection Navigation Handler Unit Tests", .serialized) +struct MaliciousSiteProtectionNavigationHandlerTests { + private var sut: MaliciousSiteProtectionNavigationHandler! + private var mockMaliciousSiteProtectionManager: MockMaliciousSiteProtectionManager! + private var webView: MockWebView! + + @MainActor + init() { + webView = MockWebView() + mockMaliciousSiteProtectionManager = MockMaliciousSiteProtectionManager() + sut = MaliciousSiteProtectionNavigationHandler(maliciousSiteProtectionManager: mockMaliciousSiteProtectionManager) + } + + @MainActor + @Test( + "URLs that should not be handled do not create a Malicious Detection Task", + arguments: [ + "about:blank", + "https://duckduckgo.com?q=swift-testing", + "duck://player" + ] + ) + func unhandledURLTypes(path: String) async throws { + // GIVEN + let url = try #require(URL(string: path)) + let navigationAction = MockNavigationAction(request: URLRequest(url: url)) + + // WHEN + sut.createMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + + // THEN + #expect(sut.maliciousSiteDetectionTasks[url] == nil) + } + + @MainActor + @Test("Non Bypassed Malicious Site creates a Malicious Detection Task", arguments: [ThreatKind.phishing, .malware]) + func whenBypassedMaliciousSiteThreatKindIsNotSetThenReturnNavigationHandled(threat: ThreatKind) throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + mockMaliciousSiteProtectionManager.threatKind = threat + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + + // WHEN + sut.createMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + + // THEN + #expect(sut.maliciousSiteDetectionTasks[url] != nil) + } + + @MainActor + @Test( + "Bypassed Malicious Site does not create a Malicious Detection Task", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenBypassedMaliciousSiteThreatKindIsSetThenReturnNavigationNotHandled(threat: ThreatKind) throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + mockMaliciousSiteProtectionManager.threatKind = threat + sut.visitSite(url: url, errorData: .maliciousSite(kind: threat, url: url)) + let navigationAction = MockNavigationAction(request: URLRequest(url: url)) + + // WHEN + sut.createMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + + // THEN + #expect(sut.maliciousSiteDetectionTasks[url] == nil) + } + + @MainActor + @Test("Retrieving Malicious Site Detection Task Nullifies it") + func whenHandleDecidePolicyForNavigationResponse_AndTaskIsNotNil_ReturnTaskAndRemoveItFromTheDictionary() throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url)) + sut.createMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + let navigationResponse = MockNavigationResponse.with(url: url) + #expect(sut.maliciousSiteDetectionTasks[url] != nil) + + // WHEN + _ = try #require(sut.getMaliciousSiteDectionTask(for: navigationResponse, webView: webView)) + + // THEN + #expect(sut.maliciousSiteDetectionTasks[url] == nil) + } + + @MainActor + @Test("Do not handle navigation when Threat is nil") + func whenThreatKindIsNilThenReturnNavigationNotHandled() async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url)) + sut.createMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + let navigationResponse = MockNavigationResponse.with(url: url) + mockMaliciousSiteProtectionManager.threatKind = nil + + // WHEN + let result = try #require(sut.getMaliciousSiteDectionTask(for: navigationResponse, webView: webView)) + + // THEN + #expect(await result.value == .navigationNotHandled) + } + + @MainActor + @Test( + "Handle known threat in Main Frame", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenThreatKindIsNotNil_AndNavigationIsMainFrame_ThenReturnNavigationHandledMainFrame(threat: ThreatKind) async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + sut.createMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + let navigationResponse = MockNavigationResponse.with(url: url) + mockMaliciousSiteProtectionManager.threatKind = threat + + // WHEN + let result = try #require(sut.getMaliciousSiteDectionTask(for: navigationResponse, webView: webView)) + + // THEN + #expect(await result.value == .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: .maliciousSite(kind: threat, url: url))))) + } + + @MainActor + @Test( + "Handle known threat in IFrame", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenThreatKindIsNotNil_AndNavigationIsIFrame_ThenReturnNavigationHandledIFrame(threat: ThreatKind) async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: false)) + sut.createMaliciousSiteDetectionTask(for: navigationAction, webView: webView) + let navigationResponse = MockNavigationResponse.with(url: url) + mockMaliciousSiteProtectionManager.threatKind = threat + + // WHEN + let result = try #require(sut.getMaliciousSiteDectionTask(for: navigationResponse, webView: webView)) + + // THEN + #expect(await result.value == .navigationHandled(.iFrame(maliciousURL: url, error: .maliciousSite(kind: threat, url: url)))) + } + + @MainActor + @Test( + "Visit Site sets Exemption URL and Threat Kind", + arguments: [ + ThreatKind.phishing, .malware + ] + ) + func whenVisitSiteActionThenSetExemptionURLAndThreatKind(threat: ThreatKind) throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + #expect(sut.maliciousURLExemptions.isEmpty) + #expect(sut.bypassedMaliciousSiteThreatKind == nil) + + // WHEN + sut.visitSite(url: url, errorData: errorData) + + // THEN + #expect(sut.maliciousURLExemptions[url] == threat) + #expect(sut.bypassedMaliciousSiteThreatKind == threat) + } + + @Test("Leave Site Pixel", .disabled("Will be implmented in upcoming PR")) + func whenLeaveSiteActionThenFirePixel() throws { + + } + + @Test("Advanced Site Info Pixel", .disabled("Will be implmented in upcoming PR")) + func whenAdvancedSiteInfoActionThenFirePixel() throws { + + } + +} + +extension MockNavigationResponse { + + static func with(url: URL) -> MockNavigationResponse { + let response = MockNavigationResponse() + response.url = url + response.mimeType = "text/html" + return response + } + +} diff --git a/DuckDuckGoTests/SpecialErrorPage/SSLErrorPageNavigationHandlerTests.swift b/DuckDuckGoTests/SpecialErrorPage/SSLErrorPageNavigationHandlerTests.swift index d7b7ce923b..5ca5c32ef1 100644 --- a/DuckDuckGoTests/SpecialErrorPage/SSLErrorPageNavigationHandlerTests.swift +++ b/DuckDuckGoTests/SpecialErrorPage/SSLErrorPageNavigationHandlerTests.swift @@ -131,13 +131,15 @@ final class SSLSpecialErrorPageTests { #expect(expectedCredential == nil) } + @MainActor @Test - func whenDidReceiveChallengeIfChallengeForCertificateValidationAndUserRequestBypassThenReturnsCredentials() { + func whenDidReceiveChallengeIfChallengeForCertificateValidationAndUserRequestBypassThenReturnsCredentials() throws { // GIVEN let protectionSpace = URLProtectionSpace(host: "", port: 4, protocol: nil, realm: nil, authenticationMethod: NSURLAuthenticationMethodServerTrust) let challenge = URLAuthenticationChallenge(protectionSpace: protectionSpace, proposedCredential: nil, previousFailureCount: 0, failureResponse: nil, error: nil, sender: ChallengeSender()) var expectedCredential: URLCredential? - sut.visitSite() + let dummyURL = try #require(URL(string: "https://example.com")) + sut.visitSite(url: dummyURL, errorData: .ssl(type: .invalid, domain: "", eTldPlus1: nil)) // WHEN sut.handleServerTrustChallenge(challenge) { _, credential in diff --git a/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerIntegrationTests.swift b/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerIntegrationTests.swift index 415386e733..f655b398bb 100644 --- a/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerIntegrationTests.swift +++ b/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerIntegrationTests.swift @@ -36,7 +36,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { sslErrorPageNavigationHandler = SSLErrorPageNavigationHandler(featureFlagger: featureFlagger) sut = SpecialErrorPageNavigationHandler( sslErrorPageNavigationHandler: sslErrorPageNavigationHandler, - maliciousSiteProtectionNavigationHandler: DummyMaliciousSiteProtectionNavigationHandler() + maliciousSiteProtectionNavigationHandler: MockMaliciousSiteProtectionNavigationHandler() ) } @@ -50,7 +50,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { @Test func whenCertificateExpiredThenExpectedErrorPageIsShown() async throws { // GIVEN - let error = NSError(domain: "test", + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: ["_kCFStreamErrorCodeKey": errSSLCertExpired, NSURLErrorFailingURLErrorKey: try #require(URL(string: "https://expired.badssl.com"))]) @@ -84,7 +84,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { @Test func whenCertificateWrongHostThenExpectedErrorPageIsShown() async throws { // GIVEN - let error = NSError(domain: "test", + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: ["_kCFStreamErrorCodeKey": errSSLHostNameMismatch, NSURLErrorFailingURLErrorKey: try #require(URL(string: "https://wrong.host.badssl.com"))]) @@ -118,7 +118,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { @Test func whenCertificateSelfSignedThenExpectedErrorPageIsShown() async throws { // GIVEN - let error = NSError(domain: "test", + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: ["_kCFStreamErrorCodeKey": errSSLXCertChainInvalid, NSURLErrorFailingURLErrorKey: try #require(URL(string: "https://self-signed.badssl.com"))]) @@ -152,7 +152,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { @Test func whenOtherCertificateIssueThenExpectedErrorPageIsShown() async throws { // GIVEN - let error = NSError(domain: "test", + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: ["_kCFStreamErrorCodeKey": errSSLUnknownRootCert, NSURLErrorFailingURLErrorKey: try #require(URL(string: "https://untrusted-root.badssl.com"))]) @@ -203,7 +203,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { @Test func whenNavigationEndedIfSSLFailureButURLIsDifferentFromNavigationURLThenSSLUserScriptIsNotEnabled() throws { // GIVEN - let error = NSError(domain: "test", + let error = NSError(domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: ["_kCFStreamErrorCodeKey": errSSLUnknownRootCert, NSURLErrorFailingURLErrorKey: try #require(URL(string: "https://untrusted-root.badssl.com"))]) @@ -234,7 +234,7 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { sut.attachWebView(webView) let navigation = DummyWKNavigation() let error = NSError( - domain: "test", + domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: [ "_kCFStreamErrorCodeKey": errSSLCertExpired, @@ -249,5 +249,4 @@ final class SpecialErrorPageNavigationHandlerIntegrationTests { // THEN #expect(script.isEnabled) } - } diff --git a/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerTests.swift b/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerTests.swift index d5ea9bd078..ccb5edb02c 100644 --- a/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerTests.swift +++ b/DuckDuckGoTests/SpecialErrorPage/SpecialErrorPageNavigationHandlerTests.swift @@ -20,6 +20,7 @@ import Testing import WebKit import SpecialErrorPages +import MaliciousSiteProtection @testable import DuckDuckGo @Suite("Special Error Pages - SpecialErrorPageNavigationHandler Unit Tests", .serialized) @@ -27,6 +28,7 @@ final class SpecialErrorPageNavigationHandlerTests { private var sut: SpecialErrorPageNavigationHandler! private var webView: MockSpecialErrorWebView! private var sslErrorPageNavigationHandler: MockSSLErrorPageNavigationHandler! + private var maliciousSiteProtectionNavigationHandler: MockMaliciousSiteProtectionNavigationHandler! @MainActor init() { @@ -34,9 +36,10 @@ final class SpecialErrorPageNavigationHandlerTests { featureFlagger.enabledFeatureFlags = [.sslCertificatesBypass] webView = MockSpecialErrorWebView(frame: CGRect(), configuration: .nonPersistent()) sslErrorPageNavigationHandler = MockSSLErrorPageNavigationHandler() + maliciousSiteProtectionNavigationHandler = MockMaliciousSiteProtectionNavigationHandler() sut = SpecialErrorPageNavigationHandler( sslErrorPageNavigationHandler: sslErrorPageNavigationHandler, - maliciousSiteProtectionNavigationHandler: DummyMaliciousSiteProtectionNavigationHandler() + maliciousSiteProtectionNavigationHandler: maliciousSiteProtectionNavigationHandler ) } @@ -46,7 +49,154 @@ final class SpecialErrorPageNavigationHandlerTests { webView = nil } - @Test("Receive Challenge forward event to SSL Error Page Navigation Handler") + @MainActor + @Test("Decide Policy For Navigation Action forwards event to Malicious Site Protection Handler") + func whenHandleDecidePolicyForNavigationActionIsCalledThenAskMaliciousSiteProtectionNavigationHandlerToHandleTheDecision() throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url)) + + // WHEN + sut.handleDecidePolicyFor(navigationAction: navigationAction, webView: webView) + + // THEN + #expect(maliciousSiteProtectionNavigationHandler.didCallHandleMaliciousSiteProtectionForNavigationAction) + #expect(maliciousSiteProtectionNavigationHandler.capturedNavigationAction == navigationAction) + #expect(maliciousSiteProtectionNavigationHandler.capturedWebView == webView) + } + + @MainActor + @Test("Decide Policy For Navigation Response forwards event to Malicious Site Protection Handler") + func whenHandleDecidePolicyforNavigationResponseThenAskMaliciousSiteProtectionNavigationHandlerToHandleTheDecision() async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationResponse = MockNavigationResponse.with(url: url) + + // WHEN + _ = await sut.handleDecidePolicyfor(navigationResponse: navigationResponse, webView: webView) + + // THEN + #expect(maliciousSiteProtectionNavigationHandler.didCallHandleMaliciousSiteProtectionForNavigationResponse) + #expect(maliciousSiteProtectionNavigationHandler.capturedNavigationResponse == navigationResponse) + #expect(maliciousSiteProtectionNavigationHandler.capturedWebView == webView) + } + + @MainActor + @Test("Decide Policy For Navigation Response returns false when malicious site detection Task is not found") + func whenHandleDecidePolicyForNavigationResponse_And_TaskIsNil_ThenReturnFalse() async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = nil + + // WHEN + let result = await sut.handleDecidePolicyfor(navigationResponse: navigationResponse, webView: webView) + + // THEN + #expect(result == false) + } + + @MainActor + @Test( + "When Main Frame Threat Then Load Bundled Response And Return True", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenHandleDecidePolicyForNavigationResponse_AndMainFrameThreat_ThenLoadBundledReponseAndReturnTrue(threat: ThreatKind) async throws { + // GIVEN + sut.attachWebView(webView) + let url = try #require(URL(string: "https://www.example.com")) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + let navigationResponse = MockNavigationResponse.with(url: url) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData))) + } + var didCallLoadSimulatedRequest = false + webView.loadRequestHandler = { _, _ in + didCallLoadSimulatedRequest = true + } + + // WHEN + let result = await sut.handleDecidePolicyfor(navigationResponse: navigationResponse, webView: webView) + + // THEN + #expect(sut.isSpecialErrorPageRequest) + #expect(sut.failedURL == url) + #expect(sut.errorData == errorData) + #expect(didCallLoadSimulatedRequest) + #expect(result) + } + + @MainActor + @Test( + "When iFrame Threat Then Load Bundled Response And Return True", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenHandleDecidePolicyForNavigationResponse_AndIFrameThreat_ThenLoadBundledReponseAndReturnTrue(threat: ThreatKind) async throws { + // GIVEN + sut.attachWebView(webView) + let topFrameURL = try #require(URL(string: "https://www.example.com")) + let iFrameURL = try #require(URL(string: "https://www.iframe.example.com")) + let navigationResponse = MockNavigationResponse.with(url: topFrameURL) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: topFrameURL) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.iFrame(maliciousURL: iFrameURL, error: errorData)) + } + var didCallLoadSimulatedRequest = false + webView.loadRequestHandler = { _, _ in + didCallLoadSimulatedRequest = true + } + + // WHEN + let result = await sut.handleDecidePolicyfor(navigationResponse: navigationResponse, webView: webView) + + // THEN + #expect(sut.isSpecialErrorPageRequest) + #expect(sut.failedURL == iFrameURL) + #expect(sut.errorData == errorData) + #expect(didCallLoadSimulatedRequest) + #expect(result) + } + + @MainActor + @Test( + "When No Threat Found Then Return False", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenHandleDecidePolicyForNavigationResponse_AndNoFrameThreat_ThenReturnFalse(threat: ThreatKind) async throws { + // GIVEN + sut.attachWebView(webView) + let url = try #require(URL(string: "https://www.example.com")) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationNotHandled + } + var didCallLoadSimulatedRequest = false + webView.loadRequestHandler = { _, _ in + didCallLoadSimulatedRequest = true + } + + // WHEN + let result = await sut.handleDecidePolicyfor(navigationResponse: navigationResponse, webView: webView) + + // THEN + #expect(sut.isSpecialErrorPageRequest == false) + #expect(sut.failedURL == nil) + #expect(didCallLoadSimulatedRequest == false) + #expect(result == false) + } + + @MainActor + @Test("Receive Challenge forwards event to SSL Error Page Navigation Handler") func whenDidHandleWebViewReceiveChallengeIsCalledAskSSLErrorPageNavigationHandlerToHandleTheChallenge() { // GIVEN let protectionSpace = URLProtectionSpace(host: "", port: 4, protocol: nil, realm: nil, authenticationMethod: NSURLAuthenticationMethodServerTrust) @@ -74,17 +224,41 @@ final class SpecialErrorPageNavigationHandlerTests { #expect(sslErrorPageNavigationHandler.didCallLeaveSite) } - @Test("Leave Site forward event to Malicious Site Protection Navigation Handler", .disabled("Will implement in upcoming PR")) - func whenLeaveSite_AndPhishingError_ThenCallLeaveSiteOnMaliciousSiteProtectioneNavigationHandler() { + @MainActor + @Test( + "Leave Site forward event to Malicious Site Protection Navigation Handler", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenLeaveSite_AndMaliciousSiteError_ThenCallLeaveSiteOnMaliciousSiteProtectioneNavigationHandler(threat: ThreatKind) async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData))) + } + _ = await sut.handleDecidePolicyfor(navigationResponse: navigationResponse, webView: webView) + + #expect(!maliciousSiteProtectionNavigationHandler.didCallLeaveSite) + + // WHEN + sut.leaveSiteAction() + // THEN + #expect(maliciousSiteProtectionNavigationHandler.didCallLeaveSite) } @MainActor - @Test("Lave Site navigate Back") - func whenLeaveSite_AndWebViewCanNavigateBack_ThenNavigateBack() { + @Test("Lave Site navigates Back when SSL Error") + func whenLeaveSite_AndSSLError_AndWebViewCanNavigateBack_ThenNavigateBack() { // GIVEN webView.setCanGoBack(true) sut.attachWebView(webView) + sut.handleWebView(webView, didFailProvisionalNavigation: DummyWKNavigation(), withError: .genericSSL) #expect(!webView.didCallGoBack) // WHEN @@ -95,14 +269,47 @@ final class SpecialErrorPageNavigationHandlerTests { } @MainActor - @Test("Lave Site close Tab") - func whenLeaveSite_AndWebViewCannotNavigateBack_ThenAskDelegateToCloseTab() { + @Test("Lave Site closes Tab when SSL Error") + func whenLeaveSite_AndSSLError_AndWebViewCannotNavigateBack_ThenAskDelegateToCloseTab() { // GIVEN webView.setCanGoBack(false) let delegate = SpySpecialErrorPageNavigationDelegate() sut.delegate = delegate sut.attachWebView(webView) + sut.handleWebView(webView, didFailProvisionalNavigation: DummyWKNavigation(), withError: .genericSSL) + #expect(!delegate.didCallCloseSpecialErrorPageTab) + + // WHEN + sut.leaveSiteAction() + + // THEN + #expect(delegate.didCallCloseSpecialErrorPageTab) + } + + @MainActor + @Test( + "Lave Site closes Tab when Malicious Site Error", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenLeaveSite_AndMaliciousSiteError_AndWebViewCanNavigateBack_ThenNavigateBack(threat: ThreatKind) async throws { + // GIVEN + webView.setCanGoBack(true) + sut.attachWebView(webView) + let url = try #require(URL(string: "https://example.com")) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData))) + } + _ = await sut.handleDecidePolicyfor(navigationResponse: navigationResponse, webView: webView) + let delegate = SpySpecialErrorPageNavigationDelegate() + sut.delegate = delegate #expect(!delegate.didCallCloseSpecialErrorPageTab) + // WHEN sut.leaveSiteAction() @@ -113,8 +320,11 @@ final class SpecialErrorPageNavigationHandlerTests { @MainActor @Test("Visit Site forward event to SSL Error Page Navigation Handler") - func whenVisitSite_AndSSLError_ThenCallVisitSiteOnSSLErrorPageNavigationHandler() { + func whenVisitSite_AndSSLError_ThenCallVisitSiteOnSSLErrorPageNavigationHandler() throws { // GIVEN + let url = try #require(URL(string: "https://example.com")) + webView.setCurrentURL(url) + sut.attachWebView(webView) sut.handleWebView(webView, didFailProvisionalNavigation: DummyWKNavigation(), withError: .genericSSL) #expect(!sslErrorPageNavigationHandler.didCallVisitSite) @@ -125,9 +335,34 @@ final class SpecialErrorPageNavigationHandlerTests { #expect(sslErrorPageNavigationHandler.didCallVisitSite) } - @Test("Visit Site forward event to Malicious Site Protection Navigation Handler", .disabled("Will implement in upcoming PR")) - func whenVisitSite_AndPhishingError_ThenCallVisitSiteOnMaliciousSiteProtectioneNavigationHandler() { + @MainActor + @Test( + "Visit Site forward event to Malicious Site Protection Navigation Handler", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenVisitSite_AndPhishingError_ThenCallVisitSiteOnMaliciousSiteProtectioneNavigationHandler(threat: ThreatKind) async throws { + // GIVEN + let url = try #require(URL(string: "https://www.example.com")) + webView.setCurrentURL(url) + sut.attachWebView(webView) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData))) + } + _ = await sut.handleDecidePolicyfor(navigationResponse: navigationResponse, webView: webView) + + #expect(!maliciousSiteProtectionNavigationHandler.didCallVisitSite) + // WHEN + sut.visitSiteAction() + + // THEN + #expect(maliciousSiteProtectionNavigationHandler.didCallVisitSite) } @MainActor @@ -161,16 +396,38 @@ final class SpecialErrorPageNavigationHandlerTests { #expect(sslErrorPageNavigationHandler.didCalladvancedInfoPresented) } - @Test("Advanced Info Presented forward event to Malicious Site Protection Navigation Handler", .disabled("Will implement in upcoming PR")) - func whenAdvancedInfoPresented_AndPhishingError_ThenCallAdvancedInfoPresentedOnMaliciousSiteProtectionNavigationHandler() { + @MainActor + @Test( + "Advanced Info Presented forward event to Malicious Site Protection Navigation Handler", + arguments: [ + ThreatKind.phishing, + .malware + ] + ) + func whenAdvancedInfoPresented_AndPhishingError_ThenCallAdvancedInfoPresentedOnMaliciousSiteProtectionNavigationHandler(threat: ThreatKind) async throws { + let url = try #require(URL(string: "https://www.example.com")) + let errorData = SpecialErrorData.maliciousSite(kind: threat, url: url) + let navigationAction = MockNavigationAction(request: URLRequest(url: url), targetFrame: MockFrameInfo(isMainFrame: true)) + let navigationResponse = MockNavigationResponse.with(url: url) + maliciousSiteProtectionNavigationHandler.task = Task { + .navigationHandled(.mainFrame(MaliciousSiteDetectionNavigationResponse(navigationAction: navigationAction, errorData: errorData))) + } + _ = await sut.handleDecidePolicyfor(navigationResponse: navigationResponse, webView: webView) + + #expect(!maliciousSiteProtectionNavigationHandler.didCallAdvancedInfoPresented) + + // WHEN + sut.advancedInfoPresented() + // THEN + #expect(maliciousSiteProtectionNavigationHandler.didCallAdvancedInfoPresented) } } private extension NSError { static let genericSSL = NSError( - domain: "test", + domain: NSURLErrorDomain, code: NSURLErrorServerCertificateUntrusted, userInfo: [ "_kCFStreamErrorCodeKey": errSSLUnknownRootCert, diff --git a/DuckDuckGoTests/SpecialErrorPage/TestDoubles/DummyMaliciousSiteProtectionNavigationHandler.swift b/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionManager.swift similarity index 59% rename from DuckDuckGoTests/SpecialErrorPage/TestDoubles/DummyMaliciousSiteProtectionNavigationHandler.swift rename to DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionManager.swift index e842c63cfa..325bb5a46d 100644 --- a/DuckDuckGoTests/SpecialErrorPage/TestDoubles/DummyMaliciousSiteProtectionNavigationHandler.swift +++ b/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionManager.swift @@ -1,5 +1,5 @@ // -// DummyMaliciousSiteProtectionNavigationHandler.swift +// MockMaliciousSiteProtectionManager.swift // DuckDuckGo // // Copyright © 2024 DuckDuckGo. All rights reserved. @@ -18,17 +18,15 @@ // import Foundation -import WebKit +import MaliciousSiteProtection @testable import DuckDuckGo -class DummyMaliciousSiteProtectionNavigationHandler: MaliciousSiteProtectionNavigationHandling & SpecialErrorPageActionHandler { - func handleMaliciousSiteProtectionNavigation(for navigationAction: WKNavigationAction, webView: WKWebView) async -> DuckDuckGo.MaliciousSiteProtectionNavigationResult { - .navigationNotHandled - } +final class MockMaliciousSiteProtectionManager: MaliciousSiteDetecting { - func visitSite() {} + var threatKind: ThreatKind? - func leaveSite() {} + func evaluate(_ url: URL) async -> MaliciousSiteProtection.ThreatKind? { + threatKind + } - func advancedInfoPresented() {} } diff --git a/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionNavigationHandler.swift b/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionNavigationHandler.swift new file mode 100644 index 0000000000..6ece26386b --- /dev/null +++ b/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockMaliciousSiteProtectionNavigationHandler.swift @@ -0,0 +1,70 @@ +// +// MockMaliciousSiteProtectionNavigationHandler.swift +// DuckDuckGo +// +// 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 WebKit +import SpecialErrorPages +@testable import DuckDuckGo + +final class MockMaliciousSiteProtectionNavigationHandler: MaliciousSiteProtectionNavigationHandling & SpecialErrorPageActionHandler { + private(set) var didCallHandleMaliciousSiteProtectionForNavigationAction = false + private(set) var capturedNavigationAction: WKNavigationAction? + private(set) var capturedWebView: WKWebView? + + private(set) var didCallHandleMaliciousSiteProtectionForNavigationResponse = false + private(set) var capturedNavigationResponse: WKNavigationResponse? + + private(set) var didCallVisitSite = false + private(set) var capturedVisitSiteURL: URL? + private(set) var capturedErrorData: SpecialErrorData? + + private(set) var didCallLeaveSite = false + + private(set) var didCallAdvancedInfoPresented = false + + var task: Task? + + func createMaliciousSiteDetectionTask(for navigationAction: WKNavigationAction, webView: WKWebView) { + didCallHandleMaliciousSiteProtectionForNavigationAction = true + capturedNavigationAction = navigationAction + capturedWebView = webView + } + + func getMaliciousSiteDectionTask(for navigationResponse: WKNavigationResponse, webView: WKWebView) -> Task? { + didCallHandleMaliciousSiteProtectionForNavigationResponse = true + capturedNavigationResponse = navigationResponse + capturedWebView = webView + + return task + } + + func visitSite(url: URL, errorData: SpecialErrorData) { + didCallVisitSite = true + capturedVisitSiteURL = url + capturedErrorData = errorData + } + + func leaveSite() { + didCallLeaveSite = true + } + + func advancedInfoPresented() { + didCallAdvancedInfoPresented = true + } +} diff --git a/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockSSLErrorPageNavigationHandler.swift b/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockSSLErrorPageNavigationHandler.swift index 0580bb6f37..7abd659d2a 100644 --- a/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockSSLErrorPageNavigationHandler.swift +++ b/DuckDuckGoTests/SpecialErrorPage/TestDoubles/MockSSLErrorPageNavigationHandler.swift @@ -51,7 +51,7 @@ final class MockSSLErrorPageNavigationHandler: SSLSpecialErrorPageNavigationHand capturedSpecialErrorType = errorType } - func visitSite() { + func visitSite(url: URL, errorData: SpecialErrorData) { didCallVisitSite = true }