diff --git a/DuckDuckGo/Application/AppDelegate.swift b/DuckDuckGo/Application/AppDelegate.swift index f0811eca7f..2b734be8f9 100644 --- a/DuckDuckGo/Application/AppDelegate.swift +++ b/DuckDuckGo/Application/AppDelegate.swift @@ -94,7 +94,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var networkProtectionSubscriptionEventHandler: NetworkProtectionSubscriptionEventHandler? #if DBP - private var dataBrokerProtectionSubscriptionEventHandler: DataBrokerProtectionSubscriptionEventHandler? + private lazy var dataBrokerProtectionSubscriptionEventHandler: DataBrokerProtectionSubscriptionEventHandler = { + let authManager = DataBrokerAuthenticationManagerBuilder.buildAuthenticationManager(subscriptionManager: subscriptionManager) + return DataBrokerProtectionSubscriptionEventHandler(featureDisabler: DataBrokerProtectionFeatureDisabler(), + authenticationManager: authManager, + pixelHandler: DataBrokerProtectionPixelsHandler()) + }() + #endif private var didFinishLaunching = false @@ -216,9 +222,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate { networkProtectionSubscriptionEventHandler = NetworkProtectionSubscriptionEventHandler(subscriptionManager: subscriptionManager, tunnelController: tunnelController, vpnUninstaller: vpnUninstaller) -#if DBP - dataBrokerProtectionSubscriptionEventHandler = DataBrokerProtectionSubscriptionEventHandler(subscriptionManager: subscriptionManager) -#endif } // swiftlint:disable:next function_body_length @@ -310,7 +313,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { UNUserNotificationCenter.current().delegate = self #if DBP - dataBrokerProtectionSubscriptionEventHandler?.registerForSubscriptionAccountManagerEvents() + dataBrokerProtectionSubscriptionEventHandler.registerForSubscriptionAccountManagerEvents() #endif #if DBP diff --git a/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift b/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift index 494d141be5..3632dc549b 100644 --- a/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift +++ b/DuckDuckGo/DBP/DataBrokerProtectionPixelsHandler.swift @@ -98,7 +98,11 @@ public class DataBrokerProtectionPixelsHandler: EventMapping - init(subscriptionManager: SubscriptionManaging, - authRepository: AuthenticationRepository = KeychainAuthenticationData(), - featureDisabler: DataBrokerProtectionFeatureDisabling = DataBrokerProtectionFeatureDisabler()) { - self.subscriptionManager = subscriptionManager - self.authRepository = authRepository + init(featureDisabler: DataBrokerProtectionFeatureDisabling, + authenticationManager: DataBrokerProtectionAuthenticationManaging, + pixelHandler: EventMapping) { self.featureDisabler = featureDisabler + self.authenticationManager = authenticationManager + self.pixelHandler = pixelHandler } func registerForSubscriptionAccountManagerEvents() { - NotificationCenter.default.addObserver(self, selector: #selector(handleAccountDidSignOut), name: .accountDidSignOut, object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(handleAccountDidSignOut), + name: .accountDidSignOut, + object: nil) + + NotificationCenter.default.addObserver(self, + selector: #selector(entitlementsDidChange), + name: .entitlementsDidChange, + object: nil) } @objc private func handleAccountDidSignOut() { featureDisabler.disableAndDelete() } + + @objc private func entitlementsDidChange() { + Task { @MainActor in + do { + if try await authenticationManager.hasValidEntitlement() { + pixelHandler.fire(.entitlementCheckValid) + } else { + pixelHandler.fire(.entitlementCheckInvalid) + featureDisabler.disableAndDelete() + } + } catch { + /// We don't want to disable the agent in case of an error while checking for entitlements. + /// Since this is a destructive action, the only situation that should cause the data to be deleted and the agent to be removed is .success(false) + pixelHandler.fire(.entitlementCheckError) + assertionFailure("Error validating entitlement \(error)") + } + } + } } #endif diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Authentication/DataBrokerProtectionEntitlementMonitoring.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Authentication/DataBrokerProtectionEntitlementMonitoring.swift new file mode 100644 index 0000000000..77b6d883b2 --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Authentication/DataBrokerProtectionEntitlementMonitoring.swift @@ -0,0 +1,56 @@ +// +// DataBrokerProtectionEntitlementMonitoring.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol DataBrokerProtectionEntitlementMonitoring { + func start(checkEntitlementFunction: @escaping () async throws -> Bool, interval: TimeInterval, callback: @escaping (DataBrokerProtectionEntitlementMonitorResult) -> Void) + func stop() +} + +public enum DataBrokerProtectionEntitlementMonitorResult { + case enabled + case disabled + case error +} + +final class DataBrokerProtectionEntitlementMonitor: DataBrokerProtectionEntitlementMonitoring { + private var timer: Timer? + + func start(checkEntitlementFunction: @escaping () async throws -> Bool, interval: TimeInterval, callback: @escaping (DataBrokerProtectionEntitlementMonitorResult) -> Void) { + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in + Task { + do { + switch try await checkEntitlementFunction() { + case true: + callback(.enabled) + case false: + callback(.disabled) + } + } catch { + callback(.error) + } + } + } + } + + func stop() { + timer?.invalidate() + timer = nil + } +} diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift index f83d3c6aeb..45f954a4d4 100644 --- a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Pixels/DataBrokerProtectionPixels.swift @@ -164,6 +164,11 @@ public enum DataBrokerProtectionPixels { case initialScanSiteLoadDuration(duration: Double, hasError: Bool, brokerURL: String, sleepDuration: Double) case initialScanPostLoadingDuration(duration: Double, hasError: Bool, brokerURL: String, sleepDuration: Double) case initialScanPreStartDuration(duration: Double) + + // Entitlements + case entitlementCheckValid + case entitlementCheckInvalid + case entitlementCheckError } extension DataBrokerProtectionPixels: PixelKitEvent { @@ -267,6 +272,11 @@ extension DataBrokerProtectionPixels: PixelKitEvent { case .initialScanSiteLoadDuration: return "m_mac_dbp_scan_broker_site_loaded" case .initialScanPostLoadingDuration: return "m_mac_dbp_initial_scan_broker_post_loading" case .initialScanPreStartDuration: return "m_mac_dbp_initial_scan_pre_start_duration" + + // Entitlements + case .entitlementCheckValid: return "m_mac_dbp_macos_entitlement_valid" + case .entitlementCheckInvalid: return "m_mac_dbp_macos_entitlement_invalid" + case .entitlementCheckError: return "m_mac_dbp_macos_entitlement_error" } } @@ -361,7 +371,9 @@ extension DataBrokerProtectionPixels: PixelKitEvent { .homeViewShowBadPathError, .homeViewCTAMoveApplicationClicked, .homeViewCTAGrantPermissionClicked, - + .entitlementCheckValid, + .entitlementCheckInvalid, + .entitlementCheckError, .secureVaultInitError, .secureVaultError: return [:] @@ -486,7 +498,10 @@ public class DataBrokerProtectionPixelsHandler: EventMapping + private let agentStopper: DataBrokerProtectionAgentStopper // Used for debug functions only, so not injected private lazy var browserWindowManager = BrowserWindowManager() @@ -111,7 +119,9 @@ public final class DataBrokerProtectionAgentManager { queueManager: DataBrokerProtectionQueueManager, dataManager: DataBrokerProtectionDataManaging, operationDependencies: DataBrokerOperationDependencies, - pixelHandler: EventMapping) { + pixelHandler: EventMapping, + agentStopper: DataBrokerProtectionAgentStopper + ) { self.userNotificationService = userNotificationService self.activityScheduler = activityScheduler self.ipcServer = ipcServer @@ -119,6 +129,7 @@ public final class DataBrokerProtectionAgentManager { self.dataManager = dataManager self.operationDependencies = operationDependencies self.pixelHandler = pixelHandler + self.agentStopper = agentStopper self.activityScheduler.delegate = self self.ipcServer.serverDelegate = self @@ -127,20 +138,20 @@ public final class DataBrokerProtectionAgentManager { public func agentFinishedLaunching() { - do { - // If there's no saved profile we don't need to start the scheduler - // Theoretically this should never happen, if there's no data, the agent shouldn't be running - guard (try dataManager.fetchProfile()) != nil else { - return - } - } catch { - os_log("Error during AgentManager.agentFinishedLaunching when trying to fetchProfile, error: %{public}@", log: .dataBrokerProtection, error.localizedDescription) - return + Task { @MainActor in + // The browser shouldn't start the agent if these prerequisites aren't met. + // However, since the agent can auto-start after a reboot without the browser, we need to validate it again. + // If the agent needs to be stopped, this function will stop it, so the subsequent calls after it will not be made. + await agentStopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + activityScheduler.startScheduler() + didStartActivityScheduler = true + queueManager.startScheduledOperationsIfPermitted(showWebView: false, operationDependencies: operationDependencies, completion: nil) + + /// Monitors entitlement changes every 60 minutes to optimize system performance and resource utilization by avoiding unnecessary operations when entitlement is invalid. + /// While keeping the agent active with invalid entitlement has no significant risk, setting the monitoring interval at 60 minutes is a good balance to minimize backend checks. + agentStopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: .minutes(60)) } - - activityScheduler.startScheduler() - didStartActivityScheduler = true - queueManager.startScheduledOperationsIfPermitted(showWebView: false, operationDependencies: operationDependencies, completion: nil) } } diff --git a/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Utils/DataBrokerProtectionAgentStopper.swift b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Utils/DataBrokerProtectionAgentStopper.swift new file mode 100644 index 0000000000..fe6c7734be --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Sources/DataBrokerProtection/Utils/DataBrokerProtectionAgentStopper.swift @@ -0,0 +1,110 @@ +// +// DataBrokerProtectionAgentStopper.swift +// +// Copyright © 2023 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 Common + +protocol DataBrokerProtectionAgentStopper { + /// Validates if the user has profile data, is authenticated, and has valid entitlement. If any of these conditions are not met, the agent will be stopped. + func validateRunPrerequisitesAndStopAgentIfNecessary() async + + /// Monitors the entitlement package. If the entitlement check returns false, the agent will be stopped. + /// This function ensures that the agent is stopped if the user's subscription has expired, even if the browser is not active. Regularly checking for entitlement is required since notifications are not posted to agents. + func monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: TimeInterval) +} + +struct DefaultDataBrokerProtectionAgentStopper: DataBrokerProtectionAgentStopper { + private let dataManager: DataBrokerProtectionDataManaging + private let entitlementMonitor: DataBrokerProtectionEntitlementMonitoring + private let authenticationManager: DataBrokerProtectionAuthenticationManaging + private let pixelHandler: EventMapping + private let stopAction: DataProtectionStopAction + + init(dataManager: DataBrokerProtectionDataManaging, + entitlementMonitor: DataBrokerProtectionEntitlementMonitoring, + authenticationManager: DataBrokerProtectionAuthenticationManaging, + pixelHandler: EventMapping, + stopAction: DataProtectionStopAction = DefaultDataProtectionStopAction()) { + self.dataManager = dataManager + self.entitlementMonitor = entitlementMonitor + self.authenticationManager = authenticationManager + self.pixelHandler = pixelHandler + self.stopAction = stopAction + } + + public func validateRunPrerequisitesAndStopAgentIfNecessary() async { + do { + guard try dataManager.fetchProfile() != nil, + authenticationManager.isUserAuthenticated else { + os_log("Prerequisites are invalid", log: .dataBrokerProtection) + stopAgent() + return + } + os_log("Prerequisites are valid", log: .dataBrokerProtection) + } catch { + os_log("Error validating prerequisites, error: %{public}@", log: .dataBrokerProtection, error.localizedDescription) + stopAgent() + } + + do { + let result = try await authenticationManager.hasValidEntitlement() + stopAgentBasedOnEntitlementCheckResult(result ? .enabled : .disabled) + } catch { + stopAgentBasedOnEntitlementCheckResult(.error) + } + } + + public func monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: TimeInterval) { + entitlementMonitor.start(checkEntitlementFunction: authenticationManager.hasValidEntitlement, + interval: interval) { result in + stopAgentBasedOnEntitlementCheckResult(result) + } + } + + private func stopAgent() { + stopAction.stopAgent() + } + + private func stopAgentBasedOnEntitlementCheckResult(_ result: DataBrokerProtectionEntitlementMonitorResult) { + switch result { + case .enabled: + os_log("Valid entitlement", log: .dataBrokerProtection) + pixelHandler.fire(.entitlementCheckValid) + case .disabled: + os_log("Invalid entitlement", log: .dataBrokerProtection) + pixelHandler.fire(.entitlementCheckInvalid) + stopAgent() + case .error: + os_log("Error when checking entitlement", log: .dataBrokerProtection) + /// We don't want to disable the agent in case of an error while checking for entitlements. + /// Since this is a destructive action, the only situation that should cause the data to be deleted and the agent to be removed is .success(false) + pixelHandler.fire(.entitlementCheckError) + } + } +} + +protocol DataProtectionStopAction { + func stopAgent() +} + +struct DefaultDataProtectionStopAction: DataProtectionStopAction { + func stopAgent() { + os_log("Stopping DataBrokerProtection Agent", log: .dataBrokerProtection) + exit(EXIT_SUCCESS) + } +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentManagerTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentManagerTests.swift index f4d00a5d5c..185e21f2f8 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentManagerTests.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentManagerTests.swift @@ -31,12 +31,14 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { private var mockPixelHandler: MockPixelHandler! private var mockDependencies: DefaultDataBrokerOperationDependencies! private var mockProfile: DataBrokerProtectionProfile! + private var mockAgentStopper: MockAgentStopper! override func setUpWithError() throws { mockPixelHandler = MockPixelHandler() mockActivityScheduler = MockDataBrokerProtectionBackgroundActivityScheduler() mockNotificationService = MockUserNotificationService() + mockAgentStopper = MockAgentStopper() let mockDatabase = MockDatabase() let mockMismatchCalculator = MockMismatchCalculator(database: mockDatabase, pixelHandler: mockPixelHandler) @@ -75,30 +77,42 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { queueManager: mockQueueManager, dataManager: mockDataManager, operationDependencies: mockDependencies, - pixelHandler: mockPixelHandler) + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper) mockDataManager.profileToReturn = mockProfile + let schedulerStartedExpectation = XCTestExpectation(description: "Scheduler started") var schedulerStarted = false mockActivityScheduler.startSchedulerCompletion = { schedulerStarted = true + schedulerStartedExpectation.fulfill() } + let scanCalledExpectation = XCTestExpectation(description: "Scan called") var startScheduledScansCalled = false mockQueueManager.startScheduledOperationsIfPermittedCalledCompletion = { _ in startScheduledScansCalled = true + scanCalledExpectation.fulfill() } // When sut.agentFinishedLaunching() // Then + await fulfillment(of: [scanCalledExpectation, schedulerStartedExpectation], timeout: 1.0) XCTAssertTrue(schedulerStarted) XCTAssertTrue(startScheduledScansCalled) } - func testWhenAgentStart_andProfileDoesNotExist_thenActivityIsNotScheduled_andSheduledOpereationsNotRun() async throws { + func testWhenAgentStart_andProfileDoesNotExist_thenActivityIsNotScheduled_andStopAgentIsCalled() async throws { // Given + let mockStopAction = MockDataProtectionStopAction() + let agentStopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: DataBrokerProtectionEntitlementMonitor(), + authenticationManager: MockAuthenticationManager(), + pixelHandler: mockPixelHandler, + stopAction: mockStopAction) sut = DataBrokerProtectionAgentManager( userNotificationService: mockNotificationService, activityScheduler: mockActivityScheduler, @@ -106,26 +120,64 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { queueManager: mockQueueManager, dataManager: mockDataManager, operationDependencies: mockDependencies, - pixelHandler: mockPixelHandler) + pixelHandler: mockPixelHandler, + agentStopper: agentStopper) mockDataManager.profileToReturn = nil - var schedulerStarted = false - mockActivityScheduler.startSchedulerCompletion = { - schedulerStarted = true + let stopAgentExpectation = XCTestExpectation(description: "Stop agent expectation") + + var stopAgentWasCalled = false + mockStopAction.stopAgentCompletion = { + stopAgentWasCalled = true + stopAgentExpectation.fulfill() } - var startScheduledScansCalled = false - mockQueueManager.startScheduledOperationsIfPermittedCalledCompletion = { _ in - startScheduledScansCalled = true + // When + sut.agentFinishedLaunching() + await fulfillment(of: [stopAgentExpectation], timeout: 1.0) + + // Then + XCTAssertTrue(stopAgentWasCalled) + } + + func testWhenAgentStart_thenPrerequisitesAreValidated_andEntitlementsAreMonitored() async { + // Given + let mockAgentStopper = MockAgentStopper() + + sut = DataBrokerProtectionAgentManager( + userNotificationService: mockNotificationService, + activityScheduler: mockActivityScheduler, + ipcServer: mockIPCServer, + queueManager: mockQueueManager, + dataManager: mockDataManager, + operationDependencies: mockDependencies, + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper) + + mockDataManager.profileToReturn = nil + + let preRequisitesExpectation = XCTestExpectation(description: "preRequisitesExpectation expectation") + var runPrerequisitesWasCalled = false + mockAgentStopper.validateRunPrerequisitesCompletion = { + runPrerequisitesWasCalled = true + preRequisitesExpectation.fulfill() + } + + let monitorEntitlementExpectation = XCTestExpectation(description: "monitorEntitlement expectation") + var monitorEntitlementWasCalled = false + mockAgentStopper.monitorEntitlementCompletion = { + monitorEntitlementWasCalled = true + monitorEntitlementExpectation.fulfill() } // When sut.agentFinishedLaunching() + await fulfillment(of: [preRequisitesExpectation, monitorEntitlementExpectation], timeout: 1.0) // Then - XCTAssertFalse(schedulerStarted) - XCTAssertFalse(startScheduledScansCalled) + XCTAssertTrue(runPrerequisitesWasCalled) + XCTAssertTrue(monitorEntitlementWasCalled) } func testWhenActivitySchedulerTriggers_thenSheduledOpereationsRun() async throws { @@ -137,7 +189,8 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { queueManager: mockQueueManager, dataManager: mockDataManager, operationDependencies: mockDependencies, - pixelHandler: mockPixelHandler) + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper) mockDataManager.profileToReturn = mockProfile @@ -162,7 +215,8 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { queueManager: mockQueueManager, dataManager: mockDataManager, operationDependencies: mockDependencies, - pixelHandler: mockPixelHandler) + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper) mockDataManager.profileToReturn = mockProfile @@ -187,7 +241,8 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { queueManager: mockQueueManager, dataManager: mockDataManager, operationDependencies: mockDependencies, - pixelHandler: mockPixelHandler) + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper) mockNotificationService.reset() @@ -207,7 +262,8 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { queueManager: mockQueueManager, dataManager: mockDataManager, operationDependencies: mockDependencies, - pixelHandler: mockPixelHandler) + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper) mockNotificationService.reset() @@ -227,7 +283,8 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { queueManager: mockQueueManager, dataManager: mockDataManager, operationDependencies: mockDependencies, - pixelHandler: mockPixelHandler) + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper) mockNotificationService.reset() mockQueueManager.startImmediateOperationsIfPermittedCompletionError = DataBrokerProtectionAgentErrorCollection(oneTimeError: NSError(domain: "test", code: 10)) @@ -248,7 +305,8 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { queueManager: mockQueueManager, dataManager: mockDataManager, operationDependencies: mockDependencies, - pixelHandler: mockPixelHandler) + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper) mockNotificationService.reset() mockDataManager.shouldReturnHasMatches = true @@ -269,7 +327,8 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { queueManager: mockQueueManager, dataManager: mockDataManager, operationDependencies: mockDependencies, - pixelHandler: mockPixelHandler) + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper) mockNotificationService.reset() mockDataManager.shouldReturnHasMatches = false @@ -290,7 +349,8 @@ final class DataBrokerProtectionAgentManagerTests: XCTestCase { queueManager: mockQueueManager, dataManager: mockDataManager, operationDependencies: mockDependencies, - pixelHandler: mockPixelHandler) + pixelHandler: mockPixelHandler, + agentStopper: mockAgentStopper) var startScheduledScansCalled = false mockQueueManager.startScheduledOperationsIfPermittedCalledCompletion = { _ in diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentStopperTests.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentStopperTests.swift new file mode 100644 index 0000000000..5d8c25ebee --- /dev/null +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/DataBrokerProtectionAgentStopperTests.swift @@ -0,0 +1,198 @@ +// +// DataBrokerProtectionAgentStopperTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +import Common + +@testable import DataBrokerProtection + +final class DataBrokerProtectionAgentStopperTests: XCTestCase { + private var mockPixelHandler: EventMapping! + private var mockAuthenticationManager: MockAuthenticationManager! + private var mockEntitlementMonitor: DataBrokerProtectionEntitlementMonitor! + private var mockDataManager: MockDataBrokerProtectionDataManager! + private var mockStopAction: MockDataProtectionStopAction! + + private var fakeProfile: DataBrokerProtectionProfile { + let name = DataBrokerProtectionProfile.Name(firstName: "John", lastName: "Doe") + let address = DataBrokerProtectionProfile.Address(city: "City", state: "State") + + return DataBrokerProtectionProfile(names: [name], addresses: [address], phones: [String](), birthYear: 1900) + } + + override func setUp() { + mockPixelHandler = MockDataBrokerProtectionPixelsHandler() + mockAuthenticationManager = MockAuthenticationManager() + mockPixelHandler = MockPixelHandler() + mockEntitlementMonitor = DataBrokerProtectionEntitlementMonitor() + mockDataManager = MockDataBrokerProtectionDataManager(pixelHandler: mockPixelHandler, + fakeBrokerFlag: DataBrokerDebugFlagFakeBroker()) + mockStopAction = MockDataProtectionStopAction() + } + + override func tearDown() { + mockPixelHandler = nil + mockAuthenticationManager = nil + mockPixelHandler = nil + mockEntitlementMonitor = nil + mockDataManager = nil + mockStopAction = nil + } + + func testNoProfile_thenStopAgentIsCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.hasValidEntitlementValue = true + mockDataManager.profileToReturn = nil + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertTrue(mockStopAction.wasStopCalled) + } + + func testInvalidEntitlement_thenStopAgentIsCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.hasValidEntitlementValue = false + mockDataManager.profileToReturn = fakeProfile + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertTrue(mockStopAction.wasStopCalled) + } + + func testUserNotAuthenticated_thenStopAgentIsCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = false + mockAuthenticationManager.hasValidEntitlementValue = true + mockDataManager.profileToReturn = fakeProfile + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertTrue(mockStopAction.wasStopCalled) + } + + func testErrorEntitlement_thenStopAgentIsNotCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.shouldThrowEntitlementError = true + mockDataManager.profileToReturn = fakeProfile + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertFalse(mockStopAction.wasStopCalled) + } + + func testValidEntitlement_thenStopAgentIsNotCalled() async { + mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.hasValidEntitlementValue = true + mockDataManager.profileToReturn = fakeProfile + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction) + await stopper.validateRunPrerequisitesAndStopAgentIfNecessary() + + XCTAssertFalse(mockStopAction.wasStopCalled) + } + + func testEntitlementMonitorWithValidResult_thenStopAgentIsNotCalled() { + mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.hasValidEntitlementValue = true + mockDataManager.profileToReturn = fakeProfile + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction) + + let expectation = XCTestExpectation(description: "Wait for monitor") + stopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: 0.1) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [self] in + XCTAssertFalse(mockStopAction.wasStopCalled) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testEntitlementMonitorWithInValidResult_thenStopAgentIsCalled() { + mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.hasValidEntitlementValue = false + mockDataManager.profileToReturn = fakeProfile + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction) + + let expectation = XCTestExpectation(description: "Wait for monitor") + stopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: 0.1) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [self] in + XCTAssertTrue(mockStopAction.wasStopCalled) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } + + func testEntitlementMonitorWithErrorResult_thenStopAgentIsNotCalled() { + mockAuthenticationManager.isUserAuthenticatedValue = true + mockAuthenticationManager.shouldThrowEntitlementError = true + mockDataManager.profileToReturn = fakeProfile + + let stopper = DefaultDataBrokerProtectionAgentStopper(dataManager: mockDataManager, + entitlementMonitor: mockEntitlementMonitor, + authenticationManager: mockAuthenticationManager, + pixelHandler: mockPixelHandler, + stopAction: mockStopAction) + + let expectation = XCTestExpectation(description: "Wait for monitor") + stopper.monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: 0.1) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [self] in + XCTAssertFalse(mockStopAction.wasStopCalled) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + } +} diff --git a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift index c4b2804e3f..d6bb74149d 100644 --- a/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift +++ b/LocalPackages/DataBrokerProtection/Tests/DataBrokerProtectionTests/Mocks.swift @@ -1374,13 +1374,18 @@ final class MockAuthenticationManager: DataBrokerProtectionAuthenticationManagin var shouldAskForInviteCodeValue = false var redeemCodeCalled = false var authHeaderValue: String? = "fake auth header" + var hasValidEntitlementValue = false + var shouldThrowEntitlementError = false var isUserAuthenticated: Bool { isUserAuthenticatedValue } var accessToken: String? { accessTokenValue } func hasValidEntitlement() async throws -> Bool { - return true + if shouldThrowEntitlementError { + throw NSError(domain: "duck.com", code: 0, userInfo: [NSLocalizedDescriptionKey: "Error"]) + } + return hasValidEntitlementValue } func shouldAskForInviteCode() -> Bool { shouldAskForInviteCodeValue } @@ -1397,5 +1402,34 @@ final class MockAuthenticationManager: DataBrokerProtectionAuthenticationManagin shouldAskForInviteCodeValue = false redeemCodeCalled = false authHeaderValue = "fake auth header" + hasValidEntitlementValue = false + shouldThrowEntitlementError = false + } +} + +final class MockAgentStopper: DataBrokerProtectionAgentStopper { + var validateRunPrerequisitesCompletion: (() -> Void)? + var monitorEntitlementCompletion: (() -> Void)? + + func validateRunPrerequisitesAndStopAgentIfNecessary() async { + validateRunPrerequisitesCompletion?() + } + + func monitorEntitlementAndStopAgentIfEntitlementIsInvalid(interval: TimeInterval) { + monitorEntitlementCompletion?() + } +} + +final class MockDataProtectionStopAction: DataProtectionStopAction { + var wasStopCalled = false + var stopAgentCompletion: (() -> Void)? + + func stopAgent() { + wasStopCalled = true + stopAgentCompletion?() + } + + func reset() { + wasStopCalled = false } }