diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..54782e32f --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme new file mode 100644 index 000000000..d3085d9c5 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/BrowserServicesKit-Package.xcscheme @@ -0,0 +1,645 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SubscriptionTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SubscriptionTests.xcscheme new file mode 100644 index 000000000..d5d6b036a --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/SubscriptionTests.xcscheme @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.resolved b/Package.resolved index 15038b08d..a36194371 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "4689746e42b24c40c18896d697ea02b854e90d35", - "version" : "5.21.0" + "revision" : "7ac68ae3bc052fa59adbc1ba8fd5cb5849a6bc99", + "version" : "5.25.0" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/duckduckgo-autofill.git", "state" : { - "revision" : "10aeff1ec7f533d1705233a9b14f9393a699b1c0", - "version" : "11.0.2" + "revision" : "2b81745565db09eee8c1cd44d38eefa1011a9f0a", + "version" : "12.0.1" } }, { diff --git a/Package.swift b/Package.swift index 36632e94a..739111e9b 100644 --- a/Package.swift +++ b/Package.swift @@ -39,12 +39,12 @@ let package = Package( .library(name: "PixelKitTestingUtilities", targets: ["PixelKitTestingUtilities"]), ], dependencies: [ - .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "11.0.2"), + .package(url: "https://github.com/duckduckgo/duckduckgo-autofill.git", exact: "12.0.1"), .package(url: "https://github.com/duckduckgo/GRDB.swift.git", exact: "2.3.0"), .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "2.1.2"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.2.0"), .package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "2.1.0"), - .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "5.21.0"), + .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "5.25.0"), .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "4.1.0"), .package(url: "https://github.com/httpswift/swifter.git", exact: "1.5.0"), .package(url: "https://github.com/duckduckgo/bloom_cpp.git", exact: "3.0.0"), diff --git a/Sources/Bookmarks/FaviconsFetcher/FaviconsFetchOperation.swift b/Sources/Bookmarks/FaviconsFetcher/FaviconsFetchOperation.swift index 1ca0d5ed2..a8787d4a2 100644 --- a/Sources/Bookmarks/FaviconsFetcher/FaviconsFetchOperation.swift +++ b/Sources/Bookmarks/FaviconsFetcher/FaviconsFetchOperation.swift @@ -22,7 +22,7 @@ import Common import CoreData import Persistence -final class FaviconsFetchOperation: Operation { +final class FaviconsFetchOperation: Operation, @unchecked Sendable { enum FaviconFetchError: Error { case connectionError diff --git a/Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift b/Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift index 83d3d7aa9..a5a663860 100644 --- a/Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift +++ b/Sources/BrowserServicesKit/Autofill/AutofillPixelReporter.swift @@ -29,6 +29,7 @@ public enum AutofillPixelEvent { case autofillToggledOff case autofillLoginsStacked case autofillCreditCardsStacked + case autofillIdentitiesStacked enum Parameter { static let countBucket = "count_bucket" @@ -148,6 +149,10 @@ public final class AutofillPixelReporter { if let cardsCount = try? vault()?.creditCardsCount() { eventMapping.fire(.autofillCreditCardsStacked, parameters: [AutofillPixelEvent.Parameter.countBucket: creditCardsBucketNameFrom(count: cardsCount)]) } + + if let identitiesCount = try? vault()?.identitiesCount() { + eventMapping.fire(.autofillIdentitiesStacked, parameters: [AutofillPixelEvent.Parameter.countBucket: identitiesBucketNameFrom(count: identitiesCount)]) + } } switch type { @@ -247,6 +252,18 @@ public final class AutofillPixelReporter { } } + private func identitiesBucketNameFrom(count: Int) -> String { + if count == 0 { + return BucketName.none.rawValue + } else if count < 5 { + return BucketName.some.rawValue + } else if count < 12 { + return BucketName.many.rawValue + } else { + return BucketName.lots.rawValue + } + } + } public extension NSNotification.Name { diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift index 2419623b6..34445414d 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift @@ -47,6 +47,7 @@ public enum PrivacyFeature: String { case privacyPro case sslCertificates case brokenSiteReportExperiment + case toggleReports } /// An abstraction to be implemented by any "subfeature" of a given `PrivacyConfiguration` feature. @@ -92,14 +93,6 @@ public enum SyncSubfeature: String, PrivacySubfeature { case level3AllowCreateAccount } -public enum PrivacyDashboardSubfeature: String, PrivacySubfeature { - - public var parent: PrivacyFeature { .privacyDashboard } - - case toggleReports - -} - public enum AutoconsentSubfeature: String, PrivacySubfeature { public var parent: PrivacyFeature { .autoconsent @@ -127,4 +120,5 @@ public enum sslCertificatesSubfeature: String, PrivacySubfeature { public enum DuckPlayerSubfeature: String, PrivacySubfeature { public var parent: PrivacyFeature { .duckPlayer } case pip + case autoplay } diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift b/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift index 70ec30ddc..d86104389 100644 --- a/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift +++ b/Sources/BrowserServicesKit/SecureVault/AutofillDatabaseProvider.swift @@ -51,6 +51,7 @@ public protocol AutofillDatabaseProvider: SecureStorageDatabaseProvider { func deleteNoteForNoteId(_ noteId: Int64) throws func identities() throws -> [SecureVaultModels.Identity] + func identitiesCount() throws -> Int func identityForIdentityId(_ identityId: Int64) throws -> SecureVaultModels.Identity? @discardableResult func storeIdentity(_ identity: SecureVaultModels.Identity) throws -> Int64 @@ -503,6 +504,13 @@ public final class DefaultAutofillDatabaseProvider: GRDBSecureStorageDatabasePro } } + public func identitiesCount() throws -> Int { + let count = try db.read { + try SecureVaultModels.Identity.fetchCount($0) + } + return count + } + public func identityForIdentityId(_ identityId: Int64) throws -> SecureVaultModels.Identity? { try db.read { return try SecureVaultModels.Identity.fetchOne($0, sql: """ diff --git a/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift b/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift index 6c7f49c56..be81e062f 100644 --- a/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift +++ b/Sources/BrowserServicesKit/SecureVault/AutofillSecureVault.swift @@ -80,6 +80,7 @@ public protocol AutofillSecureVault: SecureVault { func deleteNoteFor(noteId: Int64) throws func identities() throws -> [SecureVaultModels.Identity] + func identitiesCount() throws -> Int func identityFor(id: Int64) throws -> SecureVaultModels.Identity? func existingIdentityForAutofill(matching proposedIdentity: SecureVaultModels.Identity) throws -> SecureVaultModels.Identity? @discardableResult @@ -480,6 +481,12 @@ public class DefaultAutofillSecureVault: AutofillSe } } + public func identitiesCount() throws -> Int { + return try executeThrowingDatabaseOperation { + return try self.providers.database.identitiesCount() + } + } + public func identityFor(id: Int64) throws -> SecureVaultModels.Identity? { return try executeThrowingDatabaseOperation { return try self.providers.database.identityForIdentityId(id) diff --git a/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgrade.swift b/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgrade.swift index 6d7fad50d..171fa90e8 100644 --- a/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgrade.swift +++ b/Sources/BrowserServicesKit/SmarterEncryption/HTTPSUpgrade.swift @@ -33,12 +33,12 @@ public enum HTTPSUpgradeError: Error { public actor HTTPSUpgrade { private var dataReloadTask: Task? - private let store: HTTPSUpgradeStore - private let privacyManager: PrivacyConfigurationManaging + private nonisolated let store: HTTPSUpgradeStore + private nonisolated let privacyManager: PrivacyConfigurationManaging private var bloomFilter: BloomFilter? - private let getLog: () -> OSLog + private nonisolated let getLog: () -> OSLog nonisolated private var log: OSLog { getLog() } diff --git a/Sources/BrowserServicesKit/SubscriptionFeatureAvailability.swift b/Sources/BrowserServicesKit/Subscription/SubscriptionFeatureAvailability.swift similarity index 100% rename from Sources/BrowserServicesKit/SubscriptionFeatureAvailability.swift rename to Sources/BrowserServicesKit/Subscription/SubscriptionFeatureAvailability.swift diff --git a/Sources/Common/Concurrency/MainActorExtension.swift b/Sources/Common/Concurrency/MainActorExtension.swift new file mode 100644 index 000000000..cccc5a0a6 --- /dev/null +++ b/Sources/Common/Concurrency/MainActorExtension.swift @@ -0,0 +1,45 @@ +// +// MainActorExtension.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 + +#if swift(<5.10) +private protocol MainActorPerformer { + func perform(_ operation: @MainActor () throws -> T) rethrows -> T +} +private struct OnMainActor: MainActorPerformer { + private init() {} + static func instance() -> MainActorPerformer { OnMainActor() } + + @MainActor(unsafe) + func perform(_ operation: @MainActor () throws -> T) rethrows -> T { + try operation() + } +} +public extension MainActor { + static func assumeIsolated(_ operation: @MainActor () throws -> T) rethrows -> T { + if #available(macOS 14.0, iOS 17.0, *) { + return try assumeIsolated(operation, file: #fileID, line: #line) + } + dispatchPrecondition(condition: .onQueue(.main)) + return try OnMainActor.instance().perform(operation) + } +} +#else + #warning("This needs to be removed as it‘s no longer necessary.") +#endif diff --git a/Sources/Common/Extensions/DateExtension.swift b/Sources/Common/Extensions/DateExtension.swift index 7bcba129c..f2463a8b0 100644 --- a/Sources/Common/Extensions/DateExtension.swift +++ b/Sources/Common/Extensions/DateExtension.swift @@ -37,6 +37,14 @@ public extension Date { return Calendar.current.date(byAdding: .month, value: -1, to: Date())! } + static var yearAgo: Date! { + return Calendar.current.date(byAdding: .year, value: -1, to: Date())! + } + + static var aYearFromNow: Date! { + return Calendar.current.date(byAdding: .year, value: 1, to: Date())! + } + static func daysAgo(_ days: Int) -> Date! { return Calendar.current.date(byAdding: .day, value: -days, to: Date())! } diff --git a/Sources/Common/Extensions/StringExtension.swift b/Sources/Common/Extensions/StringExtension.swift index f9e375b2a..07390d0d3 100644 --- a/Sources/Common/Extensions/StringExtension.swift +++ b/Sources/Common/Extensions/StringExtension.swift @@ -110,6 +110,16 @@ public extension String { return false } + var isValidIpv4Host: Bool { + guard let toIPv4Host, !toIPv4Host.isEmpty else { return false } + return true + } + + var toIPv4Host: String? { + guard let ipv4 = IPv4Address(self) else { return nil } + return [UInt8](ipv4.rawValue).map { String($0) }.joined(separator: ".") + } + // MARK: Regex func matches(_ regex: NSRegularExpression) -> Bool { diff --git a/Sources/Common/Extensions/URLExtension.swift b/Sources/Common/Extensions/URLExtension.swift index 0942d42ae..762532564 100644 --- a/Sources/Common/Extensions/URLExtension.swift +++ b/Sources/Common/Extensions/URLExtension.swift @@ -79,7 +79,7 @@ extension URL { return host == domain || host.hasSuffix(".\(domain)") } - public struct NavigationalScheme: RawRepresentable, Hashable { + public struct NavigationalScheme: RawRepresentable, Hashable, Sendable { public let rawValue: String public static let separator = "://" diff --git a/Sources/Common/Extensions/WKWebViewExtension.swift b/Sources/Common/Extensions/WKWebViewExtension.swift new file mode 100644 index 000000000..d683aba38 --- /dev/null +++ b/Sources/Common/Extensions/WKWebViewExtension.swift @@ -0,0 +1,48 @@ +// +// WKWebViewExtension.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 WebKit + +public extension WKWebView { + + /// Calling this method is equivalent to calling `evaluateJavaScript:inFrame:inContentWorld:completionHandler:` with: + /// - A `frame` value of `nil` to represent the main frame + /// - A `contentWorld` value of `WKContentWorld.pageWorld` + @MainActor func evaluateJavaScript(_ script: String) async throws -> T? { + try await withUnsafeThrowingContinuation { c in + evaluateJavaScript(script) { result, error in + if let error { + c.resume(with: .failure(error)) + } else { + c.resume(with: .success(result as? T)) + } + } + } + } + + // This is meant to cause the `Ambiguous use` error, because async `evaluateJavaScript(_) -> Any` + // call will crash when its result is `nil`. + // Use typed `try await evaluateJavaScript(script) as Void?` (or other type you need), + // or even better `try await evaluateJavaScript(script, in: nil, in: .page|.defaultClient) -> Any?` (available in macOS 12/iOS 15) + @available(*, deprecated, message: "Use `try await evaluateJavaScript(script) as Void?` instead.") + @MainActor func evaluateJavaScript(_ script: String) async throws { + assertionFailure("Use `try await evaluateJavaScript(script) as Void?` instead of `try await evaluateJavaScript(script)` as it will crash in runtime") + try await evaluateJavaScript(script) as Void? + } + +} diff --git a/Sources/DDGSync/internal/SyncOperation.swift b/Sources/DDGSync/internal/SyncOperation.swift index ee1fa9669..9abe6d3dd 100644 --- a/Sources/DDGSync/internal/SyncOperation.swift +++ b/Sources/DDGSync/internal/SyncOperation.swift @@ -21,7 +21,7 @@ import Combine import Common import Gzip -final class SyncOperation: Operation { +final class SyncOperation: Operation, @unchecked Sendable { let dataProviders: [DataProviding] let storage: SecureStoring diff --git a/Sources/Navigation/DistributedNavigationDelegate.swift b/Sources/Navigation/DistributedNavigationDelegate.swift index 3e19e3db1..de7b40daf 100644 --- a/Sources/Navigation/DistributedNavigationDelegate.swift +++ b/Sources/Navigation/DistributedNavigationDelegate.swift @@ -881,21 +881,21 @@ extension DistributedNavigationDelegate: WKNavigationDelegate { } } else { - // don‘t mark extra Session State Pop navigations as `current` when there‘s a `current` Anchor navigation stored in `startedNavigation` - let isCurrent = if let startedNavigation { - !(startedNavigation.navigationAction.navigationType.isSameDocumentNavigation && startedNavigation.isCurrent) - } else { - true - } + let shouldBecomeCurrent = { + guard let startedNavigation else { return true } // no current navigation, make the same-doc navigation current + guard startedNavigation.navigationAction.navigationType.isSameDocumentNavigation else { return false } // don‘t intrude into current non-same-doc navigation + // don‘t mark extra Session State Pop navigations as `current` when there‘s a `current` same-doc Anchor navigation stored in `startedNavigation` + return !startedNavigation.isCurrent + }() - navigation = Navigation(identity: NavigationIdentity(wkNavigation), responders: responders, state: .expected(nil), isCurrent: isCurrent) + navigation = Navigation(identity: NavigationIdentity(wkNavigation), responders: responders, state: .expected(nil), isCurrent: shouldBecomeCurrent) let request = wkNavigation?.request ?? URLRequest(url: webView.url ?? .empty) let navigationAction = NavigationAction(request: request, navigationType: .sameDocumentNavigation(navigationType), currentHistoryItemIdentity: currentHistoryItemIdentity, redirectHistory: nil, isUserInitiated: wkNavigation?.isUserInitiated ?? false, sourceFrame: .mainFrame(for: webView), targetFrame: .mainFrame(for: webView), shouldDownload: false, mainFrameNavigation: navigation) navigation.navigationActionReceived(navigationAction) - os_log("new same-doc navigation(.%d): %s (%s): %s, isCurrent: %d", log: log, type: .debug, wkNavigationType, wkNavigation.debugDescription, navigation.debugDescription, navigationAction.debugDescription, isCurrent ? 1 : 0) + os_log("new same-doc navigation(.%d): %s (%s): %s, isCurrent: %d", log: log, type: .debug, wkNavigationType, wkNavigation.debugDescription, navigation.debugDescription, navigationAction.debugDescription, shouldBecomeCurrent ? 1 : 0) // store `current` navigations in `startedNavigation` to get `currentNavigation` published - if isCurrent { + if shouldBecomeCurrent { self.startedNavigation = navigation } // mark Navigation as finished as we‘re in __did__SameDocumentNavigation diff --git a/Sources/Navigation/Navigation.swift b/Sources/Navigation/Navigation.swift index cc53eaa45..ab8d8dcf5 100644 --- a/Sources/Navigation/Navigation.swift +++ b/Sources/Navigation/Navigation.swift @@ -91,6 +91,7 @@ public final class Navigation { } public protocol NavigationProtocol: AnyObject { + @MainActor var navigationResponders: ResponderChain { get set } } @@ -386,8 +387,14 @@ extension Navigation { } extension Navigation: CustomDebugStringConvertible { - public var debugDescription: String { - "<\(identity) #\(navigationAction.identifier): url:\(url.absoluteString) state:\(state)\(isCommitted ? "(committed)" : "") type:\(navigationActions.last?.navigationType.debugDescription ?? "")\(isCurrent ? "" : " non-current")>" + public nonisolated var debugDescription: String { + guard Thread.isMainThread else { + assertionFailure("Accessing Navigation from background thread") + return "" + } + return MainActor.assumeIsolated { + "<\(identity) #\(navigationAction.identifier): url:\(url.absoluteString) state:\(state)\(isCommitted ? "(committed)" : "") type:\(navigationActions.last?.navigationType.debugDescription ?? "")\(isCurrent ? "" : " non-current")>" + } } } diff --git a/Sources/Navigation/NavigationAction.swift b/Sources/Navigation/NavigationAction.swift index 68b207057..49e88534e 100644 --- a/Sources/Navigation/NavigationAction.swift +++ b/Sources/Navigation/NavigationAction.swift @@ -20,7 +20,7 @@ import Common import Foundation import WebKit -public struct MainFrame { +public struct MainFrame: Sendable { fileprivate init() {} } @@ -239,11 +239,11 @@ public struct NavigationPreferences: Equatable { } -public enum NavigationActionPolicy { +public enum NavigationActionPolicy: Sendable { case allow case cancel case download - case redirect(MainFrame, (Navigator) -> Void) + case redirect(MainFrame, @Sendable @MainActor (Navigator) -> Void) } extension NavigationActionPolicy? { diff --git a/Sources/Navigation/NavigationResponse.swift b/Sources/Navigation/NavigationResponse.swift index 29a055c26..f749aa18f 100644 --- a/Sources/Navigation/NavigationResponse.swift +++ b/Sources/Navigation/NavigationResponse.swift @@ -50,7 +50,7 @@ public struct NavigationResponse { public extension NavigationResponse { var url: URL { - response.url! + response.url ?? .empty } var httpResponse: HTTPURLResponse? { diff --git a/Sources/Navigation/Navigator.swift b/Sources/Navigation/Navigator.swift index 04147968b..f03e7ffc4 100644 --- a/Sources/Navigation/Navigator.swift +++ b/Sources/Navigation/Navigator.swift @@ -16,6 +16,7 @@ // limitations under the License. // +import Common import Foundation import WebKit @@ -149,8 +150,14 @@ public final class ExpectedNavigation { } extension ExpectedNavigation: NavigationProtocol {} extension ExpectedNavigation: CustomDebugStringConvertible { - public var debugDescription: String { - "" + public nonisolated var debugDescription: String { + guard Thread.isMainThread else { + assertionFailure("Accessing ExpectedNavigation from background thread") + return "" + } + return MainActor.assumeIsolated { + "" + } } } diff --git a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionBandwidthAnalyzer.swift b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionBandwidthAnalyzer.swift index 31de5820c..e28611f08 100644 --- a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionBandwidthAnalyzer.swift +++ b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionBandwidthAnalyzer.swift @@ -90,7 +90,6 @@ final class NetworkProtectionConnectionBandwidthAnalyzer { os_log("Bytes per second in last time-interval: (rx: %{public}@, tx: %{public}@)", log: .networkProtectionBandwidthAnalysis, - type: .info, String(describing: rx), String(describing: tx)) idle = UInt64(rx) < Self.rxThreshold && UInt64(tx) < Self.txThreshold @@ -103,7 +102,7 @@ final class NetworkProtectionConnectionBandwidthAnalyzer { /// Useful when servers are swapped /// func reset() { - os_log("Bandwidth analyzer reset", log: .networkProtectionBandwidthAnalysis, type: .info) + os_log("Bandwidth analyzer reset", log: .networkProtectionBandwidthAnalysis) entries.removeAll() } diff --git a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift index be98ebf95..ec09ba5ba 100644 --- a/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift +++ b/Sources/NetworkProtection/Diagnostics/NetworkProtectionConnectionTester.swift @@ -79,7 +79,7 @@ final class NetworkProtectionConnectionTester { // MARK: - Logging - private let log: OSLog + private nonisolated let log: OSLog // MARK: - Test result handling @@ -222,7 +222,7 @@ final class NetworkProtectionConnectionTester { // After completing the connection tests we check if the tester is still supposed to be running // to avoid giving results when it should not be running. guard isRunning else { - os_log("Tester skipped returning results as it was stopped while running the tests", log: log, type: .info) + os_log("Tester skipped returning results as it was stopped while running the tests", log: log) return } diff --git a/Sources/NetworkProtection/Diagnostics/NetworkProtectionTunnelFailureMonitor.swift b/Sources/NetworkProtection/Diagnostics/NetworkProtectionTunnelFailureMonitor.swift index 58eedab19..037361c1c 100644 --- a/Sources/NetworkProtection/Diagnostics/NetworkProtectionTunnelFailureMonitor.swift +++ b/Sources/NetworkProtection/Diagnostics/NetworkProtectionTunnelFailureMonitor.swift @@ -108,7 +108,7 @@ public actor NetworkProtectionTunnelFailureMonitor { guard firstCheckSkipped else { // Avoid running the first tunnel failure check after startup to avoid reading the first handshake after sleep, which will almost always // be out of date. In normal operation, the first check will frequently be 0 as WireGuard hasn't had the chance to handshake yet. - os_log("⚫️ Skipping first tunnel failure check", log: .networkProtectionTunnelFailureMonitorLog, type: .debug) + os_log("⚫️ Skipping first tunnel failure check", log: .networkProtectionTunnelFailureMonitorLog) firstCheckSkipped = true return } @@ -116,23 +116,23 @@ public actor NetworkProtectionTunnelFailureMonitor { let mostRecentHandshake = (try? await handshakeReporter.getMostRecentHandshake()) ?? 0 guard mostRecentHandshake > 0 else { - os_log("⚫️ Got handshake timestamp at or below 0, skipping check", log: .networkProtectionTunnelFailureMonitorLog, type: .debug) + os_log("⚫️ Got handshake timestamp at or below 0, skipping check", log: .networkProtectionTunnelFailureMonitorLog) return } let difference = Date().timeIntervalSince1970 - mostRecentHandshake - os_log("⚫️ Last handshake: %{public}f seconds ago", log: .networkProtectionTunnelFailureMonitorLog, type: .debug, difference) + os_log("⚫️ Last handshake: %{public}f seconds ago", log: .networkProtectionTunnelFailureMonitorLog, difference) if difference > Result.failureDetected.threshold, isConnected { if failureReported { - os_log("⚫️ Tunnel failure already reported", log: .networkProtectionTunnelFailureMonitorLog, type: .debug) + os_log("⚫️ Tunnel failure already reported", log: .networkProtectionTunnelFailureMonitorLog) } else { - os_log("⚫️ Tunnel failure reported", log: .networkProtectionTunnelFailureMonitorLog, type: .debug) + os_log("⚫️ Tunnel failure reported", log: .networkProtectionTunnelFailureMonitorLog) callback(.failureDetected) failureReported = true } } else if difference <= Result.failureRecovered.threshold, failureReported { - os_log("⚫️ Tunnel failure recovery", log: .networkProtectionTunnelFailureMonitorLog, type: .debug) + os_log("⚫️ Tunnel recovered from failure", log: .networkProtectionTunnelFailureMonitorLog) callback(.failureRecovered) failureReported = false } diff --git a/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift b/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift index 03f59244c..693cde61a 100644 --- a/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift +++ b/Sources/NetworkProtection/ExtensionMessage/ExtensionRequest.swift @@ -25,6 +25,7 @@ public enum VPNCommand: Codable { case sendTestNotification case uninstallVPN case disableConnectOnDemandAndShutDown + case quitAgent } public enum ExtensionRequest: Codable { diff --git a/Sources/NetworkProtection/KeyManagement/NetworkProtectionKeychainStore.swift b/Sources/NetworkProtection/KeyManagement/NetworkProtectionKeychainStore.swift index 384a87e5a..88ecb495d 100644 --- a/Sources/NetworkProtection/KeyManagement/NetworkProtectionKeychainStore.swift +++ b/Sources/NetworkProtection/KeyManagement/NetworkProtectionKeychainStore.swift @@ -72,7 +72,7 @@ final class NetworkProtectionKeychainStore { case errSecItemNotFound: return nil default: - os_log("🔵 SecItemCopyMatching status %{public}@", type: .error, String(describing: status)) + os_log("🔴 SecItemCopyMatching status %{public}@", type: .error, String(describing: status)) throw NetworkProtectionKeychainStoreError.keychainReadError(field: name, status: status) } } diff --git a/Sources/NetworkProtection/Logging/Logging.swift b/Sources/NetworkProtection/Logging/Logging.swift index 240599f1d..2cfb99633 100644 --- a/Sources/NetworkProtection/Logging/Logging.swift +++ b/Sources/NetworkProtection/Logging/Logging.swift @@ -41,10 +41,6 @@ extension OSLog { Logging.networkProtectionTunnelFailureMonitorLoggingEnabled ? Logging.networkProtectionTunnelFailureMonitor : .disabled } - public static var networkProtectionServerFailureRecoveryLog: OSLog { - Logging.networkProtectionServerFailureRecoveryLoggingEnabled ? Logging.networkProtectionServerFailureRecovery : .disabled - } - public static var networkProtectionConnectionTesterLog: OSLog { Logging.networkProtectionConnectionTesterLoggingEnabled ? Logging.networkProtectionConnectionTesterLog : .disabled } @@ -84,7 +80,7 @@ extension OSLog { struct Logging { - static let subsystem = "com.duckduckgo.macos.browser.network-protection" + static let subsystem = Bundle.main.bundleIdentifier! fileprivate static let networkProtectionLoggingEnabled = true fileprivate static let networkProtection: OSLog = OSLog(subsystem: subsystem, category: "Network Protection") diff --git a/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift b/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift index f899f419f..d8df572c1 100644 --- a/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift +++ b/Sources/NetworkProtection/NetworkProtectionDeviceManager.swift @@ -43,12 +43,30 @@ public enum NetworkProtectionServerSelectionMethod: CustomDebugStringConvertible case failureRecovery(serverName: String) } +public enum NetworkProtectionDNSSettings: Codable, Equatable, CustomStringConvertible { + case `default` + case custom([String]) + + public var usesCustomDNS: Bool { + guard case .custom(let servers) = self, !servers.isEmpty else { return false } + return true + } + + public var description: String { + switch self { + case .default: return "DuckDuckGo" + case .custom(let servers): return servers.joined(separator: ", ") + } + } +} + public protocol NetworkProtectionDeviceManagement { typealias GenerateTunnelConfigurationResult = (tunnelConfiguration: TunnelConfiguration, server: NetworkProtectionServer) func generateTunnelConfiguration(selectionMethod: NetworkProtectionServerSelectionMethod, includedRoutes: [IPAddressRange], excludedRoutes: [IPAddressRange], + dnsSettings: NetworkProtectionDNSSettings, isKillSwitchEnabled: Bool, regenerateKey: Bool) async throws -> GenerateTunnelConfigurationResult @@ -118,6 +136,7 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement { public func generateTunnelConfiguration(selectionMethod: NetworkProtectionServerSelectionMethod, includedRoutes: [IPAddressRange], excludedRoutes: [IPAddressRange], + dnsSettings: NetworkProtectionDNSSettings, isKillSwitchEnabled: Bool, regenerateKey: Bool) async throws -> GenerateTunnelConfigurationResult { var keyPair: KeyPair @@ -156,6 +175,7 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement { server: selectedServer, includedRoutes: includedRoutes, excludedRoutes: excludedRoutes, + dnsSettings: dnsSettings, isKillSwitchEnabled: isKillSwitchEnabled) return (configuration, selectedServer) } catch let error as NetworkProtectionError { @@ -246,6 +266,7 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement { server: NetworkProtectionServer, includedRoutes: [IPAddressRange], excludedRoutes: [IPAddressRange], + dnsSettings: NetworkProtectionDNSSettings, isKillSwitchEnabled: Bool) throws -> TunnelConfiguration { guard let allowedIPs = server.allowedIPs else { @@ -266,11 +287,21 @@ public actor NetworkProtectionDeviceManager: NetworkProtectionDeviceManagement { throw NetworkProtectionError.couldNotGetInterfaceAddressRange } + let dns: [DNSServer] + switch dnsSettings { + case .default: + dns = [DNSServer(address: server.serverInfo.internalIP)] + case .custom(let servers): + dns = servers + .compactMap { IPv4Address($0) } + .map { DNSServer(address: $0) } + } + let interface = interfaceConfiguration(privateKey: interfacePrivateKey, addressRange: interfaceAddressRange, includedRoutes: includedRoutes, excludedRoutes: excludedRoutes, - dns: [DNSServer(address: server.serverInfo.internalIP)], + dns: dns, isKillSwitchEnabled: isKillSwitchEnabled) return TunnelConfiguration(name: "DuckDuckGo VPN", interface: interface, peers: [peerConfiguration]) diff --git a/Sources/NetworkProtection/NetworkProtectionOptionKey.swift b/Sources/NetworkProtection/NetworkProtectionOptionKey.swift index 3c0316b37..54a15a7f8 100644 --- a/Sources/NetworkProtection/NetworkProtectionOptionKey.swift +++ b/Sources/NetworkProtection/NetworkProtectionOptionKey.swift @@ -23,6 +23,7 @@ public enum NetworkProtectionOptionKey { public static let selectedEnvironment = "selectedEnvironment" public static let selectedServer = "selectedServer" public static let selectedLocation = "selectedLocation" + public static let dnsSettings = "dnsSettings" public static let authToken = "authToken" public static let isOnDemand = "is-on-demand" public static let activationAttemptId = "activationAttemptId" diff --git a/Sources/NetworkProtection/PacketTunnelProvider.swift b/Sources/NetworkProtection/PacketTunnelProvider.swift index 886872394..730c044e7 100644 --- a/Sources/NetworkProtection/PacketTunnelProvider.swift +++ b/Sources/NetworkProtection/PacketTunnelProvider.swift @@ -37,6 +37,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { case tunnelStopAttempt(_ step: TunnelStopAttemptStep) case tunnelUpdateAttempt(_ step: TunnelUpdateAttemptStep) case tunnelWakeAttempt(_ step: TunnelWakeAttemptStep) + case tunnelStartOnDemandWithoutAccessToken case reportTunnelFailure(result: NetworkProtectionTunnelFailureMonitor.Result) case reportLatency(result: NetworkProtectionLatencyMonitor.Result) case rekeyAttempt(_ step: RekeyAttemptStep) @@ -125,9 +126,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private lazy var adapter: WireGuardAdapter = { WireGuardAdapter(with: self) { logLevel, message in if logLevel == .error { - os_log("🔵 Received error from adapter: %{public}@", log: .networkProtection, type: .error, message) + os_log("🔴 Received error from adapter: %{public}@", log: .networkProtection, type: .error, message) } else { - os_log("🔵 Received message from adapter: %{public}@", log: .networkProtection, message) + os_log("Received message from adapter: %{public}@", log: .networkProtection, message) } } }() @@ -244,14 +245,15 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { return } - os_log("Rekeying...", log: .networkProtectionKeyManagement) providerEvents.fire(.rekeyAttempt(.begin)) do { - try await updateTunnelConfiguration(reassert: false, regenerateKey: true) + try await updateTunnelConfiguration( + updateMethod: .selectServer(currentServerSelectionMethod), + reassert: false, + regenerateKey: true) providerEvents.fire(.rekeyAttempt(.success)) } catch { - os_log("Rekey attempt failed. This is not an error if you're using debug Key Management options: %{public}@", log: .networkProtectionKeyManagement, type: .error, String(describing: error)) providerEvents.fire(.rekeyAttempt(.failure(error))) throw error } @@ -439,6 +441,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { loadSelectedEnvironment(from: options) loadSelectedServer(from: options) loadSelectedLocation(from: options) + loadDNSSettings(from: options) loadTesterEnabled(from: options) #if os(macOS) try loadAuthToken(from: options) @@ -486,8 +489,8 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func loadSelectedLocation(from options: StartupOptions) { switch options.selectedLocation { - case .set(let selectedServer): - settings.selectedLocation = selectedServer + case .set(let selectedLocation): + settings.selectedLocation = selectedLocation case .useExisting: break case .reset: @@ -495,6 +498,17 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } } + private func loadDNSSettings(from options: StartupOptions) { + switch options.dnsSettings { + case .set(let dnsSettings): + settings.dnsSettings = dnsSettings + case .useExisting: + break + case .reset: + settings.dnsSettings = .default + } + } + private func loadTesterEnabled(from options: StartupOptions) { switch options.enableTester { case .set(let value): @@ -578,6 +592,10 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { do { try load(options: startupOptions) try loadVendorOptions(from: tunnelProviderProtocol) + + if (try? tokenStore.fetchToken()) == nil { + throw TunnelError.startingTunnelWithoutAuthToken + } } catch { if startupOptions.startupMethod == .automaticOnDemand { // If the VPN was started by on-demand without the basic prerequisites for @@ -585,9 +603,10 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // manual start attempt that preceded failed, or if the subscription has // expired. In either case it should be enough to record the manual failures // for these prerequisited to avoid flooding our metrics. - try? await Task.sleep(interval: .seconds(15)) + providerEvents.fire(.tunnelStartOnDemandWithoutAccessToken) + try await Task.sleep(interval: .seconds(15)) } else { - // If the VPN was started manually without the basic prerequisited we always + // If the VPN was started manually without the basic prerequisites we always // want to know as this should not be possible. providerEvents.fire(.tunnelStartAttempt(.begin)) providerEvents.fire(.tunnelStartAttempt(.failure(error))) @@ -613,19 +632,16 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // We add a delay when the VPN is started by // on-demand and there's an error, to avoid frenetic ON/OFF // cycling. - try? await Task.sleep(interval: .seconds(15)) + try await Task.sleep(interval: .seconds(15)) } let errorDescription = (error as? LocalizedError)?.localizedDescription ?? String(describing: error) - os_log("Tunnel startup error: %{public}@", type: .error, errorDescription) self.controllerErrorStore.lastErrorMessage = errorDescription self.connectionStatus = .disconnected self.knownFailureStore.lastKnownFailure = KnownFailure(error) providerEvents.fire(.tunnelStartAttempt(.failure(error))) - - os_log("🔴 Stopping VPN due to error: %{public}s", log: .networkProtection, error.localizedDescription) throw error } } @@ -654,20 +670,19 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func startTunnel(onDemand: Bool) async throws { do { - os_log("🔵 Generating tunnel config", log: .networkProtection, type: .info) - os_log("🔵 Excluded ranges are: %{public}@", log: .networkProtection, type: .info, String(describing: settings.excludedRanges)) - os_log("🔵 Server selection method: %{public}@", log: .networkProtection, type: .info, currentServerSelectionMethod.debugDescription) + os_log("Generating tunnel config", log: .networkProtection) + os_log("Excluded ranges are: %{public}@", log: .networkProtection, String(describing: settings.excludedRanges)) + os_log("Server selection method: %{public}@", log: .networkProtection, currentServerSelectionMethod.debugDescription) + os_log("DNS server: %{public}@", log: .networkProtection, String(describing: settings.dnsSettings)) let tunnelConfiguration = try await generateTunnelConfiguration(serverSelectionMethod: currentServerSelectionMethod, includedRoutes: includedRoutes ?? [], excludedRoutes: settings.excludedRanges, + dnsSettings: settings.dnsSettings, regenerateKey: true) try await startTunnel(with: tunnelConfiguration, onDemand: onDemand) - os_log("🔵 Done generating tunnel config", log: .networkProtection, type: .info) + os_log("Done generating tunnel config", log: .networkProtection) } catch { - os_log("🔵 Error starting tunnel: %{public}@", log: .networkProtection, type: .info, error.localizedDescription) - controllerErrorStore.lastErrorMessage = error.localizedDescription - throw error } } @@ -677,7 +692,6 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in adapter.start(tunnelConfiguration: tunnelConfiguration) { [weak self] error in if let error { - os_log("🔵 Starting tunnel failed with %{public}@", log: .networkProtection, type: .error, error.localizedDescription) self?.debugEvents?.fire(error.networkProtectionError) continuation.resume(throwing: error) return @@ -699,7 +713,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { #if os(iOS) if #available(iOS 17.0, *), startReason == .manual { try? await updateConnectOnDemand(enabled: true) - os_log("Enabled Connect on Demand due to user-initiated startup", log: .networkProtection, type: .info) + os_log("Enabled Connect on Demand due to user-initiated startup", log: .networkProtection) } #endif } catch { @@ -717,7 +731,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { open override func stopTunnel(with reason: NEProviderStopReason) async { providerEvents.fire(.tunnelStopAttempt(.begin)) - os_log("Stopping tunnel with reason %{public}@", log: .networkProtection, type: .info, String(describing: reason)) + os_log("Stopping tunnel with reason %{public}@", log: .networkProtection, String(describing: reason)) do { try await stopTunnel() @@ -726,7 +740,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // Disable Connect on Demand when disabling the tunnel from iOS settings on iOS 17.0+. if #available(iOS 17.0, *), case .userInitiated = reason { try? await updateConnectOnDemand(enabled: false) - os_log("Disabled Connect on Demand due to user-initiated shutdown", log: .networkProtection, type: .info) + os_log("Disabled Connect on Demand due to user-initiated shutdown", log: .networkProtection) } } catch { providerEvents.fire(.tunnelStopAttempt(.failure(error))) @@ -774,7 +788,6 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { } if let error { - os_log("🔵 Error while stopping adapter: %{public}@", log: .networkProtection, type: .error, error.localizedDescription) self?.debugEvents?.fire(error.networkProtectionError) continuation.resume(throwing: error) @@ -804,19 +817,15 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // MARK: - Tunnel Configuration - @MainActor - public func updateTunnelConfiguration(reassert: Bool, regenerateKey: Bool = false) async throws { - try await updateTunnelConfiguration( - serverSelectionMethod: currentServerSelectionMethod, - reassert: reassert, - regenerateKey: regenerateKey - ) + enum TunnelUpdateMethod { + case selectServer(_ method: NetworkProtectionServerSelectionMethod) + case useConfiguration(_ configuration: TunnelConfiguration) } @MainActor - public func updateTunnelConfiguration(serverSelectionMethod: NetworkProtectionServerSelectionMethod, - reassert: Bool, - regenerateKey: Bool = false) async throws { + func updateTunnelConfiguration(updateMethod: TunnelUpdateMethod, + reassert: Bool, + regenerateKey: Bool = false) async throws { providerEvents.fire(.tunnelUpdateAttempt(.begin)) @@ -824,54 +833,50 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { await stopMonitors() } - let tunnelConfiguration: TunnelConfiguration do { - tunnelConfiguration = try await generateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod, + let tunnelConfiguration: TunnelConfiguration + + switch updateMethod { + case .selectServer(let serverSelectionMethod): + tunnelConfiguration = try await generateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod, includedRoutes: includedRoutes ?? [], excludedRoutes: settings.excludedRanges, + dnsSettings: settings.dnsSettings, regenerateKey: regenerateKey) + case .useConfiguration(let newTunnelConfiguration): + tunnelConfiguration = newTunnelConfiguration + } + + try await updateAdapterConfiguration(tunnelConfiguration: tunnelConfiguration, reassert: reassert) + + if reassert { + try await handleAdapterStarted(startReason: .reconnected) + } + + providerEvents.fire(.tunnelUpdateAttempt(.success)) } catch { providerEvents.fire(.tunnelUpdateAttempt(.failure(error))) throw error } - try await updateAdapterConfiguration(tunnelConfiguration: tunnelConfiguration, reassert: reassert) } @MainActor private func updateAdapterConfiguration(tunnelConfiguration: TunnelConfiguration, reassert: Bool) async throws { - do { - try await withCheckedThrowingContinuation { [weak self] (continuation: CheckedContinuation) in - guard let self = self else { - continuation.resume() + try await withCheckedThrowingContinuation { [weak self] (continuation: CheckedContinuation) in + guard let self = self else { + continuation.resume() + return + } + + self.adapter.update(tunnelConfiguration: tunnelConfiguration, reassert: reassert) { [weak self] error in + if let error = error { + self?.debugEvents?.fire(error.networkProtectionError) + continuation.resume(throwing: error) return } - self.adapter.update(tunnelConfiguration: tunnelConfiguration, reassert: reassert) { [weak self] error in - if let error = error { - os_log("🔵 Failed to update the configuration: %{public}@", type: .error, error.localizedDescription) - self?.debugEvents?.fire(error.networkProtectionError) - continuation.resume(throwing: error) - return - } - - Task { [weak self] in - if reassert { - do { - try await self?.handleAdapterStarted(startReason: .reconnected) - } catch { - continuation.resume(throwing: error) - return - } - } - - continuation.resume() - } - } + continuation.resume() } - providerEvents.fire(.tunnelUpdateAttempt(.success)) - } catch { - providerEvents.fire(.tunnelUpdateAttempt(.failure(error))) - throw error } } @@ -879,6 +884,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func generateTunnelConfiguration(serverSelectionMethod: NetworkProtectionServerSelectionMethod, includedRoutes: [IPAddressRange], excludedRoutes: [IPAddressRange], + dnsSettings: NetworkProtectionDNSSettings, regenerateKey: Bool) async throws -> TunnelConfiguration { let configurationResult: NetworkProtectionDeviceManager.GenerateTunnelConfigurationResult @@ -888,6 +894,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { selectionMethod: serverSelectionMethod, includedRoutes: includedRoutes, excludedRoutes: excludedRoutes, + dnsSettings: dnsSettings, isKillSwitchEnabled: isKillSwitchEnabled, regenerateKey: regenerateKey ) @@ -903,11 +910,11 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { let newSelectedServer = configurationResult.server self.lastSelectedServer = newSelectedServer - os_log("🔵 Generated tunnel configuration for server at location: %{public}s (preferred server is %{public}s)", + os_log("⚪️ Generated tunnel configuration for server at location: %{public}s (preferred server is %{public}s)", log: .networkProtection, newSelectedServer.serverInfo.serverLocation, newSelectedServer.serverInfo.name) - os_log("🔵 Excluded routes: %{public}@", log: .networkProtection, type: .info, String(describing: excludedRoutes)) + os_log("Excluded routes: %{public}@", log: .networkProtection, String(describing: excludedRoutes)) return configurationResult.tunnelConfiguration } @@ -925,11 +932,19 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // swiftlint:disable:next cyclomatic_complexity @MainActor public override func handleAppMessage(_ messageData: Data, completionHandler: ((Data?) -> Void)? = nil) { + guard let message = ExtensionMessage(rawValue: messageData) else { + os_log("🔴 Received unknown app message", log: .networkProtectionIPCLog, type: .error) completionHandler?(nil) return } + /// We're skipping messages that are very frequent and not likely to affect anything in terms of functionality. + /// We can opt to aggregate them somehow if we ever need them - for now I'm disabling. + if message != .getDataVolume { + os_log("⚪️ Received app message: %{public}@", log: .networkProtectionIPCLog, String(describing: message)) + } + switch message { case .request(let request): handleRequest(request, completionHandler: completionHandler) @@ -987,13 +1002,15 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { settings.apply(change: change) } - // swiftlint:disable:next cyclomatic_complexity + // swiftlint:disable:next cyclomatic_complexity function_body_length private func handleSettingsChange(_ change: VPNSettings.Change, completionHandler: ((Data?) -> Void)? = nil) { switch change { case .setExcludeLocalNetworks: Task { @MainActor in if case .connected = connectionStatus { - try? await updateTunnelConfiguration(reassert: false) + try? await updateTunnelConfiguration( + updateMethod: .selectServer(currentServerSelectionMethod), + reassert: false) } completionHandler?(nil) } @@ -1009,7 +1026,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { Task { @MainActor in if case .connected = connectionStatus { - try? await updateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod, reassert: true) + try? await updateTunnelConfiguration( + updateMethod: .selectServer(serverSelectionMethod), + reassert: true) } completionHandler?(nil) } @@ -1025,7 +1044,18 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { Task { @MainActor in if case .connected = connectionStatus { - try? await updateTunnelConfiguration(serverSelectionMethod: serverSelectionMethod, reassert: true) + try? await updateTunnelConfiguration( + updateMethod: .selectServer(serverSelectionMethod), + reassert: true) + } + completionHandler?(nil) + } + case .setDNSSettings: + Task { @MainActor in + if case .connected = connectionStatus { + try? await updateTunnelConfiguration( + updateMethod: .selectServer(currentServerSelectionMethod), + reassert: true) } completionHandler?(nil) } @@ -1062,6 +1092,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { case .uninstallVPN: // Since the VPN configuration is being removed we may as well reset all state handleResetAllState(completionHandler: completionHandler) + case .quitAgent: + // No-op since this is intended for the agent app + break } } @@ -1110,7 +1143,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { settings.selectedServer = .automatic if case .connected = connectionStatus { - try? await updateTunnelConfiguration(reassert: true) + try? await updateTunnelConfiguration( + updateMethod: .selectServer(currentServerSelectionMethod), + reassert: true) } } completionHandler?(nil) @@ -1124,7 +1159,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { settings.selectedServer = .endpoint(serverName) if case .connected = connectionStatus { - try? await updateTunnelConfiguration(serverSelectionMethod: .preferredServer(serverName: serverName), reassert: true) + try? await updateTunnelConfiguration( + updateMethod: .selectServer(.preferredServer(serverName: serverName)), + reassert: true) } completionHandler?(nil) } @@ -1175,7 +1212,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { return } - os_log("🔵 Disabling Connect On Demand and shutting down the tunnel", log: .networkProtection, type: .info) + os_log("⚪️ Disabling Connect On Demand and shutting down the tunnel", log: .networkProtection) manager.isOnDemandEnabled = false try await manager.saveToPreferences() @@ -1193,7 +1230,9 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { self.includedRoutes = includedRoutes if case .connected = connectionStatus { - try? await updateTunnelConfiguration(reassert: false) + try? await updateTunnelConfiguration( + updateMethod: .selectServer(currentServerSelectionMethod), + reassert: false) } completionHandler?(nil) } @@ -1201,12 +1240,12 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { private func simulateTunnelFailure(completionHandler: ((Data?) -> Void)? = nil) { Task { - os_log("Simulating tunnel failure", log: .networkProtection, type: .info) + os_log("Simulating tunnel failure", log: .networkProtection) adapter.stop { [weak self] error in if let error { self?.debugEvents?.fire(error.networkProtectionError) - os_log("🔵 Failed to stop WireGuard adapter: %{public}@", log: .networkProtection, type: .info, error.localizedDescription) + os_log("🔴 Failed to stop WireGuard adapter: %{public}@", log: .networkProtection, error.localizedDescription) } completionHandler?(error.map { ExtensionMessageString($0.localizedDescription).rawValue }) @@ -1263,14 +1302,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { connectionStatus = .connected(connectedDate: Date()) } - if !settings.disableRekeying { - guard !isKeyExpired else { - try await rekey() - return - } - } - - os_log("🔵 Tunnel interface is %{public}@", log: .networkProtection, type: .info, adapter.interfaceName ?? "unknown") + os_log("⚪️ Tunnel interface is %{public}@", log: .networkProtection, adapter.interfaceName ?? "unknown") // These cases only make sense in the context of a connection that had trouble // and is being fixed, so we want to test the connection immediately. @@ -1326,6 +1358,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { to: server, includedRoutes: self.includedRoutes ?? [], excludedRoutes: self.settings.excludedRanges, + dnsSettings: self.settings.dnsSettings, isKillSwitchEnabled: self.isKillSwitchEnabled ) { [weak self] generateConfigResult in try await self?.handleFailureRecoveryConfigUpdate(result: generateConfigResult) @@ -1337,7 +1370,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { @MainActor private func handleFailureRecoveryConfigUpdate(result: NetworkProtectionDeviceManagement.GenerateTunnelConfigurationResult) async throws { self.lastSelectedServer = result.server - try await self.updateAdapterConfiguration(tunnelConfiguration: result.tunnelConfiguration, reassert: true) + try await updateTunnelConfiguration(updateMethod: .useConfiguration(result.tunnelConfiguration), reassert: true) } @MainActor @@ -1397,13 +1430,19 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { await serverStatusMonitor.start(serverName: serverName) { status in if status.shouldMigrate { - Task { - self.providerEvents.fire(.serverMigrationAttempt(.begin)) + Task { [ weak self] in + guard let self else { return } + + providerEvents.fire(.serverMigrationAttempt(.begin)) + do { - try await self.updateTunnelConfiguration(reassert: true, regenerateKey: true) - self.providerEvents.fire(.serverMigrationAttempt(.success)) + try await self.updateTunnelConfiguration( + updateMethod: .selectServer(currentServerSelectionMethod), + reassert: true, + regenerateKey: true) + providerEvents.fire(.serverMigrationAttempt(.success)) } catch { - self.providerEvents.fire(.serverMigrationAttempt(.failure(error))) + providerEvents.fire(.serverMigrationAttempt(.failure(error))) } } } @@ -1446,7 +1485,7 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { do { try await startConnectionTester(testImmediately: testImmediately) } catch { - os_log("🔵 Connection Tester error: %{public}@", log: .networkProtectionConnectionTesterLog, type: .error, String(reflecting: error)) + os_log("🔴 Connection Tester error: %{public}@", log: .networkProtectionConnectionTesterLog, type: .error, String(reflecting: error)) throw error } } @@ -1516,26 +1555,34 @@ open class PacketTunnelProvider: NEPacketTunnelProvider { // MARK: - Computer sleeping + @MainActor public override func sleep() async { - os_log("Sleep", log: .networkProtectionSleepLog, type: .info) + os_log("Sleep", log: .networkProtectionSleepLog) - await connectionTester.stop() - await tunnelFailureMonitor.stop() - await latencyMonitor.stop() - await entitlementMonitor.stop() - await serverStatusMonitor.stop() + await stopMonitors() } + @MainActor public override func wake() { - os_log("Wake up", log: .networkProtectionSleepLog, type: .info) + os_log("Wake up", log: .networkProtectionSleepLog) + + // macOS can launch the extension due to calls to `sendProviderMessage`, so there's + // a chance this is being called when the VPN isn't really meant to be connected or + // running. We want to avoid firing pixels or handling adapter changes when this is + // the case. + guard connectionStatus != .disconnected else { + return + } Task { providerEvents.fire(.tunnelWakeAttempt(.begin)) do { try await handleAdapterStarted(startReason: .wake) + os_log("🟢 Wake success", log: .networkProtectionConnectionTesterLog) providerEvents.fire(.tunnelWakeAttempt(.success)) } catch { + os_log("🔴 Wake error: ${public}@", log: .networkProtectionConnectionTesterLog, type: .error, error.localizedDescription) providerEvents.fire(.tunnelWakeAttempt(.failure(error))) } } diff --git a/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift b/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift index 7fd22f042..9d0ad2210 100644 --- a/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift +++ b/Sources/NetworkProtection/Recovery/FailureRecoveryHandler.swift @@ -35,6 +35,7 @@ protocol FailureRecoveryHandling { to lastConnectedServer: NetworkProtectionServer, includedRoutes: [IPAddressRange], excludedRoutes: [IPAddressRange], + dnsSettings: NetworkProtectionDNSSettings, isKillSwitchEnabled: Bool, updateConfig: @escaping (NetworkProtectionDeviceManagement.GenerateTunnelConfigurationResult) async throws -> Void ) async @@ -85,6 +86,7 @@ actor FailureRecoveryHandler: FailureRecoveryHandling { to lastConnectedServer: NetworkProtectionServer, includedRoutes: [IPAddressRange], excludedRoutes: [IPAddressRange], + dnsSettings: NetworkProtectionDNSSettings, isKillSwitchEnabled: Bool, updateConfig: @escaping (NetworkProtectionDeviceManagement.GenerateTunnelConfigurationResult) async throws -> Void ) async { @@ -92,6 +94,7 @@ actor FailureRecoveryHandler: FailureRecoveryHandling { defer { reassertingControl?.stopReasserting() } + let eventHandler = eventHandler await incrementalPeriodicChecks(retryConfig) { [weak self] in guard let self else { return } eventHandler(.started) @@ -100,6 +103,7 @@ actor FailureRecoveryHandler: FailureRecoveryHandling { to: lastConnectedServer, includedRoutes: includedRoutes, excludedRoutes: excludedRoutes, + dnsSettings: dnsSettings, isKillSwitchEnabled: isKillSwitchEnabled ) switch result { @@ -127,6 +131,7 @@ actor FailureRecoveryHandler: FailureRecoveryHandling { to lastConnectedServer: NetworkProtectionServer, includedRoutes: [IPAddressRange], excludedRoutes: [IPAddressRange], + dnsSettings: NetworkProtectionDNSSettings, isKillSwitchEnabled: Bool ) async throws -> FailureRecoveryResult { let serverSelectionMethod: NetworkProtectionServerSelectionMethod = .failureRecovery(serverName: lastConnectedServer.serverName) @@ -136,17 +141,17 @@ actor FailureRecoveryHandler: FailureRecoveryHandling { selectionMethod: serverSelectionMethod, includedRoutes: includedRoutes, excludedRoutes: excludedRoutes, + dnsSettings: dnsSettings, isKillSwitchEnabled: isKillSwitchEnabled, regenerateKey: false ) - os_log("🟢 Failure recovery fetched new config.", log: .networkProtectionServerFailureRecoveryLog, type: .info) + os_log("🟢 Failure recovery fetched new config.", log: .networkProtectionTunnelFailureMonitorLog) let newServer = configurationResult.server os_log( "🟢 Failure recovery - originalServerName: %{public}s, newServerName: %{public}s, originalAllowedIPs: %{public}s, newAllowedIPs: %{public}s", log: .networkProtection, - type: .info, lastConnectedServer.serverName, newServer.serverName, String(describing: lastConnectedServer.allowedIPs), @@ -154,7 +159,7 @@ actor FailureRecoveryHandler: FailureRecoveryHandling { ) guard lastConnectedServer.shouldReplace(with: newServer) else { - os_log("🟢 Server failure recovery not necessary.", log: .networkProtectionServerFailureRecoveryLog, type: .info) + os_log("🟢 Server failure recovery not necessary.", log: .networkProtectionTunnelFailureMonitorLog) return .noRecoveryNecessary } @@ -177,10 +182,10 @@ actor FailureRecoveryHandler: FailureRecoveryHandling { } do { try await action() - os_log("🟢 Failure recovery success!", log: .networkProtectionServerFailureRecoveryLog, type: .info) + os_log("🟢 Failure recovery success!", log: .networkProtectionTunnelFailureMonitorLog) return } catch { - os_log("🟢 Failure recovery failed. Retrying...", log: .networkProtectionServerFailureRecoveryLog, type: .info) + os_log("🟢 Failure recovery failed. Retrying...", log: .networkProtectionTunnelFailureMonitorLog) } do { try await Task.sleep(interval: currentDelay) diff --git a/Sources/NetworkProtection/Recovery/Reasserting.swift b/Sources/NetworkProtection/Recovery/Reasserting.swift index 13848f3d9..6f266a2fb 100644 --- a/Sources/NetworkProtection/Recovery/Reasserting.swift +++ b/Sources/NetworkProtection/Recovery/Reasserting.swift @@ -26,12 +26,10 @@ protocol Reasserting: AnyObject { extension NEPacketTunnelProvider: Reasserting { - @MainActor func startReasserting() { reasserting = true } - @MainActor func stopReasserting() { reasserting = false } diff --git a/Sources/NetworkProtection/Settings/Extensions/UserDefaults+dnsSettings.swift b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+dnsSettings.swift new file mode 100644 index 000000000..7c1432ac2 --- /dev/null +++ b/Sources/NetworkProtection/Settings/Extensions/UserDefaults+dnsSettings.swift @@ -0,0 +1,85 @@ +// +// UserDefaults+dnsSettings.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 Combine +import Foundation + +extension UserDefaults { + final class StorableDNSSettings: NSObject, Codable { + let usesCustomDNS: Bool + let dnsServers: [String] + + init(usesCustomDNS: Bool = false, dnsServers: [String] = []) { + self.usesCustomDNS = usesCustomDNS + self.dnsServers = dnsServers + } + } + + private var dnsSettingKey: String { + "dnsSettingStorageValue" + } + + private static func dnsSettingsFromStorageValue(_ value: StorableDNSSettings) -> NetworkProtectionDNSSettings { + guard value.usesCustomDNS, !value.dnsServers.isEmpty else { return .default } + return .custom(value.dnsServers) + } + + @objc + dynamic var dnsSettingStorageValue: StorableDNSSettings { + get { + guard let data = data(forKey: dnsSettingKey) else { return StorableDNSSettings() } + return (try? JSONDecoder().decode(StorableDNSSettings.self, from: data)) ?? StorableDNSSettings() + } + + set { + if let data = try? JSONEncoder().encode(newValue) { + set(data, forKey: dnsSettingKey) + } + } + } + + var dnsSettings: NetworkProtectionDNSSettings { + get { + Self.dnsSettingsFromStorageValue(dnsSettingStorageValue) + } + + set { + switch newValue { + case .default: + dnsSettingStorageValue = StorableDNSSettings() + case .custom(let dnsServers): + let hosts = dnsServers.compactMap(\.toIPv4Host) + if hosts.isEmpty { + dnsSettingStorageValue = StorableDNSSettings() + } else { + dnsSettingStorageValue = StorableDNSSettings(usesCustomDNS: true, dnsServers: hosts) + } + } + } + } + + var dnsSettingsPublisher: AnyPublisher { + publisher(for: \.dnsSettingStorageValue) + .map(Self.dnsSettingsFromStorageValue(_:)) + .eraseToAnyPublisher() + } + + func resetDNSSettings() { + dnsSettings = .default + } +} diff --git a/Sources/NetworkProtection/Settings/VPNSettings.swift b/Sources/NetworkProtection/Settings/VPNSettings.swift index 5c57c43e6..d568b8acc 100644 --- a/Sources/NetworkProtection/Settings/VPNSettings.swift +++ b/Sources/NetworkProtection/Settings/VPNSettings.swift @@ -37,6 +37,7 @@ public final class VPNSettings { case setSelectedServer(_ selectedServer: SelectedServer) case setSelectedLocation(_ selectedLocation: SelectedLocation) case setSelectedEnvironment(_ selectedEnvironment: SelectedEnvironment) + case setDNSSettings(_ dnsSettings: NetworkProtectionDNSSettings) case setShowInMenuBar(_ showInMenuBar: Bool) case setDisableRekeying(_ disableRekeying: Bool) } @@ -153,6 +154,13 @@ public final class VPNSettings { Change.setSelectedEnvironment(environment) }.eraseToAnyPublisher() + let dnsSettingsChangePublisher = dnsSettingsPublisher + .dropFirst() + .removeDuplicates() + .map { settings in + Change.setDNSSettings(settings) + }.eraseToAnyPublisher() + let showInMenuBarPublisher = showInMenuBarPublisher .dropFirst() .removeDuplicates() @@ -176,6 +184,7 @@ public final class VPNSettings { serverChangePublisher, locationChangePublisher, environmentChangePublisher, + dnsSettingsChangePublisher, showInMenuBarPublisher, disableRekeyingPublisher).eraseToAnyPublisher() }() @@ -194,6 +203,7 @@ public final class VPNSettings { defaults.resetNetworkProtectionSettingNotifyStatusChanges() defaults.resetNetworkProtectionSettingRegistrationKeyValidity() defaults.resetNetworkProtectionSettingSelectedServer() + defaults.resetDNSSettings() defaults.resetNetworkProtectionSettingShowInMenuBar() } @@ -220,6 +230,8 @@ public final class VPNSettings { self.selectedLocation = selectedLocation case .setSelectedEnvironment(let selectedEnvironment): self.selectedEnvironment = selectedEnvironment + case .setDNSSettings(let dnsSettings): + self.dnsSettings = dnsSettings case .setShowInMenuBar(let showInMenuBar): self.showInMenuBar = showInMenuBar case .setDisableRekeying(let disableRekeying): @@ -360,6 +372,22 @@ public final class VPNSettings { } } + // MARK: - DNS Settings + + public var dnsSettingsPublisher: AnyPublisher { + defaults.dnsSettingsPublisher + } + + public var dnsSettings: NetworkProtectionDNSSettings { + get { + defaults.dnsSettings + } + + set { + defaults.dnsSettings = newValue + } + } + // MARK: - Show in Menu Bar public var showInMenuBarPublisher: AnyPublisher { diff --git a/Sources/NetworkProtection/StartupOptions.swift b/Sources/NetworkProtection/StartupOptions.swift index f895c8fc8..201cf2f81 100644 --- a/Sources/NetworkProtection/StartupOptions.swift +++ b/Sources/NetworkProtection/StartupOptions.swift @@ -107,6 +107,7 @@ struct StartupOptions { let selectedEnvironment: StoredOption let selectedServer: StoredOption let selectedLocation: StoredOption + let dnsSettings: StoredOption #if os(macOS) let authToken: StoredOption #endif @@ -138,6 +139,7 @@ struct StartupOptions { selectedEnvironment = Self.readSelectedEnvironment(from: options, resetIfNil: resetStoredOptionsIfNil) selectedServer = Self.readSelectedServer(from: options, resetIfNil: resetStoredOptionsIfNil) selectedLocation = Self.readSelectedLocation(from: options, resetIfNil: resetStoredOptionsIfNil) + dnsSettings = Self.readDNSSettings(from: options, resetIfNil: resetStoredOptionsIfNil) } var description: String { @@ -151,6 +153,7 @@ struct StartupOptions { selectedEnvironment: \(self.selectedEnvironment.description), selectedServer: \(self.selectedServer.description), selectedLocation: \(self.selectedLocation.description), + dnsSettings: \(self.dnsSettings.description), enableTester: \(self.enableTester) ) """ @@ -216,6 +219,17 @@ struct StartupOptions { } } + private static func readDNSSettings(from options: [String: Any], resetIfNil: Bool) -> StoredOption { + StoredOption(resetIfNil: resetIfNil) { + guard let data = options[NetworkProtectionOptionKey.dnsSettings] as? Data, + let dnsSettings = try? JSONDecoder().decode(NetworkProtectionDNSSettings.self, from: data) else { + return nil + } + + return dnsSettings + } + } + private static func readEnableTester(from options: [String: Any], resetIfNil: Bool) -> StoredOption { StoredOption(resetIfNil: resetIfNil) { guard let value = options[NetworkProtectionOptionKey.connectionTesterEnabled] as? Bool else { diff --git a/Sources/NetworkProtectionTestUtils/MockNetworkProtectionDeviceManagement.swift b/Sources/NetworkProtectionTestUtils/MockNetworkProtectionDeviceManagement.swift index 8c9e30e17..fad0a52e6 100644 --- a/Sources/NetworkProtectionTestUtils/MockNetworkProtectionDeviceManagement.swift +++ b/Sources/NetworkProtectionTestUtils/MockNetworkProtectionDeviceManagement.swift @@ -46,6 +46,7 @@ public final class MockNetworkProtectionDeviceManagement: NetworkProtectionDevic selectionMethod: NetworkProtection.NetworkProtectionServerSelectionMethod, includedRoutes: [NetworkProtection.IPAddressRange], excludedRoutes: [NetworkProtection.IPAddressRange], + dnsSettings: NetworkProtectionDNSSettings, isKillSwitchEnabled: Bool, regenerateKey: Bool) async throws -> (tunnelConfiguration: NetworkProtection.TunnelConfiguration, server: NetworkProtection.NetworkProtectionServer) { spyGenerateTunnelConfiguration = ( diff --git a/Sources/PrivacyDashboard/BrokenSiteReporting/ReferrerInfo.swift b/Sources/PrivacyDashboard/BrokenSiteReporting/ReferrerInfo.swift index b62280115..9a78ca35d 100644 --- a/Sources/PrivacyDashboard/BrokenSiteReporting/ReferrerInfo.swift +++ b/Sources/PrivacyDashboard/BrokenSiteReporting/ReferrerInfo.swift @@ -16,22 +16,24 @@ // limitations under the License. // +import Common import Foundation import WebKit extension WKWebView { + @MainActor - public var isCurrentSiteReferredFromDuckDuckGo: Bool { + var referrer: String? { get async { - do { - if let result = try await self.evaluateJavaScript("document.referrer") as? String { - return result.contains("duckduckgo.com") - } - } catch { - return false - } + try? await self.evaluateJavaScript("document.referrer") + } + } - return false + @MainActor + public var isCurrentSiteReferredFromDuckDuckGo: Bool { + get async { + await referrer?.contains("duckduckgo.com") ?? false } } + } diff --git a/Sources/PrivacyDashboard/Model/TrackerInfo.swift b/Sources/PrivacyDashboard/Model/TrackerInfo.swift index 5494c40da..d15d15dda 100644 --- a/Sources/PrivacyDashboard/Model/TrackerInfo.swift +++ b/Sources/PrivacyDashboard/Model/TrackerInfo.swift @@ -27,7 +27,7 @@ public struct TrackerInfo: Encodable { case installedSurrogates } - public private (set) var trackers = Set() + public private(set) var trackers = Set() private(set) var thirdPartyRequests = Set() public private(set) var installedSurrogates = Set() diff --git a/Sources/PrivacyDashboard/PrivacyDashboardController.swift b/Sources/PrivacyDashboard/PrivacyDashboardController.swift index 07ed0ea45..9217de25c 100644 --- a/Sources/PrivacyDashboard/PrivacyDashboardController.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardController.swift @@ -146,13 +146,17 @@ public protocol PrivacyDashboardControllerDelegate: AnyObject { private weak var webView: WKWebView? private let privacyDashboardScript: PrivacyDashboardUserScript private var cancellables = Set() + private var protectionStateToSubmitOnToggleReportDismiss: ProtectionState? + private var didSendToggleReport: Bool = false + private let privacyConfigurationManager: PrivacyConfigurationManaging private let eventMapping: EventMapping private let variant: PrivacyDashboardVariant private var toggleReportCounter: Int? { userDefaults.toggleReportCounter > 20 ? nil : userDefaults.toggleReportCounter } + private var toggleReportsManager: ToggleReportsManager private let userDefaults: UserDefaults private var didOpenReportInfo: Bool = false @@ -170,6 +174,7 @@ public protocol PrivacyDashboardControllerDelegate: AnyObject { privacyDashboardScript = PrivacyDashboardUserScript(privacyConfigurationManager: privacyConfigurationManager) self.eventMapping = eventMapping self.userDefaults = userDefaults + self.toggleReportsManager = ToggleReportsManager(feature: ToggleReportsFeature(manager: privacyConfigurationManager)) } public func setup(for webView: WKWebView) { @@ -377,11 +382,7 @@ extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate { } private func shouldSegueToToggleReportScreen(with protectionState: ProtectionState) -> Bool { - !protectionState.isProtected && protectionState.eventOrigin.screen == .primaryScreen && isToggleReportsFeatureEnabled - } - - private var isToggleReportsFeatureEnabled: Bool { - return ToggleReportsFeature(privacyConfiguration: privacyConfigurationManager.privacyConfig).isEnabled + !protectionState.isProtected && protectionState.eventOrigin.screen == .primaryScreen && toggleReportsManager.shouldShowToggleReport } private func didChangeProtectionState(_ protectionState: ProtectionState, didSendReport: Bool = false) { @@ -421,12 +422,14 @@ extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate { private func handleDismiss(with type: ToggleReportDismissType, source: ToggleReportDismissSource) { #if os(iOS) + // called when protection is toggled off from app menu if case .toggleReport(completionHandler: let completionHandler) = initDashboardMode { completionHandler(type == .send) - fireToggleReportEventIfNeeded(for: type) + processToggleReport(for: type) + // called when protection is toggled off from privacy dashboard } else if let protectionStateToSubmitOnToggleReportDismiss { didChangeProtectionState(protectionStateToSubmitOnToggleReportDismiss, didSendReport: type == .send) - fireToggleReportEventIfNeeded(for: type) + processToggleReport(for: type) } if source == .userScript { closeDashboard() @@ -437,13 +440,23 @@ extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate { if type != .send { if let protectionStateToSubmitOnToggleReportDismiss { didChangeProtectionState(protectionStateToSubmitOnToggleReportDismiss) - fireToggleReportEventIfNeeded(for: type) + if !didSendToggleReport { + fireToggleReportEventIfNeeded(for: type) + toggleReportsManager.recordDismissal() + } } closeDashboard() } #endif } + private func processToggleReport(for type: ToggleReportDismissType) { + fireToggleReportEventIfNeeded(for: type) + if type != .send { + toggleReportsManager.recordDismissal() + } + } + public func handleViewWillDisappear() { handleDismiss(with: .dismiss, source: .viewWillDisappear) } @@ -508,10 +521,12 @@ extension PrivacyDashboardController: PrivacyDashboardUserScriptDelegate { func userScript(_ userScript: PrivacyDashboardUserScript, didSelectReportAction shouldSendReport: Bool) { if shouldSendReport { + toggleReportsManager.recordPrompt() privacyDashboardToggleReportDelegate?.privacyDashboardController(self, didRequestSubmitToggleReportWithSource: source, didOpenReportInfo: didOpenReportInfo, toggleReportCounter: toggleReportCounter) + didSendToggleReport = true } let toggleReportDismissType: ToggleReportDismissType = shouldSendReport ? .send : .doNotSend handleUserScriptClosing(toggleReportDismissType: toggleReportDismissType) diff --git a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift index ecc2e4208..bdde66f0e 100644 --- a/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift +++ b/Sources/PrivacyDashboard/PrivacyDashboardUserScript.swift @@ -23,6 +23,7 @@ import UserScript import Common import BrowserServicesKit +@MainActor protocol PrivacyDashboardUserScriptDelegate: AnyObject { func userScript(_ userScript: PrivacyDashboardUserScript, didChangeProtectionState protectionState: ProtectionState) diff --git a/Sources/PrivacyDashboard/ToggleReportsFeature.swift b/Sources/PrivacyDashboard/ToggleReportsFeature.swift index c78b99fb3..9e327f82d 100644 --- a/Sources/PrivacyDashboard/ToggleReportsFeature.swift +++ b/Sources/PrivacyDashboard/ToggleReportsFeature.swift @@ -17,19 +17,57 @@ // import Foundation +import Combine import BrowserServicesKit -public struct ToggleReportsFeature { +public protocol ToggleReporting { + + var isEnabled: Bool { get } + + var isDismissLogicEnabled: Bool { get } + var dismissInterval: TimeInterval { get } + + var isPromptLimitLogicEnabled: Bool { get } + var promptInterval: TimeInterval { get } + var maxPromptCount: Int { get } + +} + +public final class ToggleReportsFeature: ToggleReporting { + + enum Constants { + + static let dismissLogicEnabledKey = "dismissLogicEnabled" + static let dismissIntervalKey = "dismissInterval" + + static let promptLimitLogicEnabledKey = "promptLimitLogicEnabled" + static let promptIntervalKey = "promptInterval" + static let maxPromptCountKey = "maxPromptCount" + + static let defaultTimeInterval: TimeInterval = 48 * 60 * 60 // Two days + static let defaultPromptCount = 3 - public var privacyConfiguration: PrivacyConfiguration - public init(privacyConfiguration: PrivacyConfiguration) { - self.privacyConfiguration = privacyConfiguration } - public var isEnabled: Bool { - let isFeatureEnabledInConfig = privacyConfiguration.isSubfeatureEnabled(PrivacyDashboardSubfeature.toggleReports) + public private(set) var isEnabled: Bool = false + + public private(set) var isDismissLogicEnabled: Bool = true + public private(set) var dismissInterval: TimeInterval = 0 + + public private(set) var isPromptLimitLogicEnabled: Bool = true + public private(set) var promptInterval: TimeInterval = 0 + public private(set) var maxPromptCount: Int = 0 + + public init(manager: PrivacyConfigurationManaging) { let isCurrentLanguageEnglish = Locale.current.languageCode == "en" - return isFeatureEnabledInConfig && isCurrentLanguageEnglish + isEnabled = manager.privacyConfig.isEnabled(featureKey: .toggleReports) && isCurrentLanguageEnglish + guard isEnabled else { return } + let settings = manager.privacyConfig.settings(for: .toggleReports) + isDismissLogicEnabled = settings[Constants.dismissLogicEnabledKey] as? Bool ?? false + dismissInterval = settings[Constants.dismissIntervalKey] as? TimeInterval ?? Constants.defaultTimeInterval + isPromptLimitLogicEnabled = settings[Constants.promptLimitLogicEnabledKey] as? Bool ?? false + promptInterval = settings[Constants.promptIntervalKey] as? TimeInterval ?? Constants.defaultTimeInterval + maxPromptCount = settings[Constants.maxPromptCountKey] as? Int ?? Constants.defaultPromptCount } } diff --git a/Sources/PrivacyDashboard/ToggleReportsManager.swift b/Sources/PrivacyDashboard/ToggleReportsManager.swift new file mode 100644 index 000000000..b481a0f5e --- /dev/null +++ b/Sources/PrivacyDashboard/ToggleReportsManager.swift @@ -0,0 +1,118 @@ +// +// ToggleReportsManager.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 BrowserServicesKit +import Persistence + +public protocol ToggleReportsStoring { + + var dismissedAt: Date? { get set } + var promptWindowStart: Date? { get set } + var promptCount: Int { get set } + +} + +public struct ToggleReportsStore: ToggleReportsStoring { + + private enum Key { + + static let dismissedAt = "com.duckduckgo.app.toggleReports.dismissedAt" + static let promptWindowStart = "com.duckduckgo.app.toggleReports.promptWindowStart" + static let promptCount = "com.duckduckgo.app.toggleReports.promptCount" + + } + + private let userDefaults: KeyValueStoring + public init(userDefaults: KeyValueStoring = UserDefaults()) { + self.userDefaults = userDefaults + } + + public var dismissedAt: Date? { + get { userDefaults.object(forKey: Key.dismissedAt) as? Date } + set { userDefaults.set(newValue, forKey: Key.dismissedAt) } + } + + public var promptWindowStart: Date? { + get { userDefaults.object(forKey: Key.promptWindowStart) as? Date } + set { userDefaults.set(newValue, forKey: Key.promptWindowStart) } + } + + public var promptCount: Int { + get { userDefaults.object(forKey: Key.promptCount) as? Int ?? 0 } + set { userDefaults.set(newValue, forKey: Key.promptCount) } + } + +} + +public struct ToggleReportsManager { + + private let feature: ToggleReporting + private var store: ToggleReportsStoring + + public init(feature: ToggleReporting, store: ToggleReportsStoring = ToggleReportsStore()) { + self.store = store + self.feature = feature + } + + public mutating func recordPrompt(date: Date = Date()) { + if let windowStart = store.promptWindowStart, date.timeIntervalSince(windowStart) > feature.promptInterval { + resetPromptWindow() + } else if store.promptWindowStart == nil { + startPromptWindow() + } + store.promptCount += 1 + + func resetPromptWindow() { + store.promptWindowStart = date + store.promptCount = 0 + } + + func startPromptWindow() { + store.promptWindowStart = date + } + } + + public mutating func recordDismissal(date: Date = Date()) { + store.dismissedAt = date + } + + public var shouldShowToggleReport: Bool { shouldShowToggleReport(date: Date()) } + public func shouldShowToggleReport(date: Date = Date()) -> Bool { + var didDismissalIntervalPass: Bool { + guard feature.isDismissLogicEnabled, let dismissedAt = store.dismissedAt else { return true } + let timeIntervalSinceLastDismiss = date.timeIntervalSince(dismissedAt) + return timeIntervalSinceLastDismiss > feature.dismissInterval + } + + var isWithinPromptLimit: Bool { + guard feature.isPromptLimitLogicEnabled else { return true } + return store.promptCount < feature.maxPromptCount + } + + var didPromptIntervalPass: Bool { + guard feature.isPromptLimitLogicEnabled, let windowStart = store.promptWindowStart else { return true } + let timeIntervalSincePromptWindowStart = date.timeIntervalSince(windowStart) + return timeIntervalSincePromptWindowStart > feature.promptInterval + } + + guard feature.isEnabled else { return false } + return didDismissalIntervalPass && (isWithinPromptLimit || didPromptIntervalPass) + } + +} diff --git a/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift b/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift index 582665f4b..97e669c89 100644 --- a/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift +++ b/Sources/RemoteMessaging/Matchers/UserAttributeMatcher.swift @@ -160,19 +160,19 @@ public struct UserAttributeMatcher: AttributeMatcher { case let matchingAttribute as PrivacyProPurchasePlatformMatchingAttribute: return StringArrayMatchingAttribute(matchingAttribute.value).matches(value: privacyProPurchasePlatform ?? "") case let matchingAttribute as PrivacyProSubscriptionStatusMatchingAttribute: - guard let value = matchingAttribute.value else { - return .fail + let mappedStatuses = matchingAttribute.value.compactMap { status in + return PrivacyProSubscriptionStatus(rawValue: status) } - guard let status = PrivacyProSubscriptionStatus(rawValue: value) else { - return .fail + for status in mappedStatuses { + switch status { + case .active: if isPrivacyProSubscriptionActive { return .match } + case .expiring: if isPrivacyProSubscriptionExpiring { return .match } + case .expired: if isPrivacyProSubscriptionExpired { return .match } + } } - switch status { - case .active: return isPrivacyProSubscriptionActive ? .match : .fail - case .expiring: return isPrivacyProSubscriptionExpiring ? .match : .fail - case .expired: return isPrivacyProSubscriptionExpired ? .match : .fail - } + return .fail case let matchingAttribute as InteractedWithMessageMatchingAttribute: if dismissedMessageIds.contains(where: { messageId in StringArrayMatchingAttribute(matchingAttribute.value).matches(value: messageId) == .match diff --git a/Sources/RemoteMessaging/Model/MatchingAttributes.swift b/Sources/RemoteMessaging/Model/MatchingAttributes.swift index 62aef41aa..388d15995 100644 --- a/Sources/RemoteMessaging/Model/MatchingAttributes.swift +++ b/Sources/RemoteMessaging/Model/MatchingAttributes.swift @@ -799,13 +799,14 @@ struct PrivacyProPurchasePlatformMatchingAttribute: MatchingAttribute, Equatable } struct PrivacyProSubscriptionStatusMatchingAttribute: MatchingAttribute, Equatable { - var value: String? + var value: [String] = [] var fallback: Bool? init(jsonMatchingAttribute: AnyDecodable) { - guard let jsonMatchingAttribute = jsonMatchingAttribute.value as? [String: Any] else { return } - - if let value = jsonMatchingAttribute[RuleAttributes.value] as? String { + guard let jsonMatchingAttribute = jsonMatchingAttribute.value as? [String: Any] else { + return + } + if let value = jsonMatchingAttribute[RuleAttributes.value] as? [String] { self.value = value } if let fallback = jsonMatchingAttribute[RuleAttributes.fallback] as? Bool { @@ -813,7 +814,7 @@ struct PrivacyProSubscriptionStatusMatchingAttribute: MatchingAttribute, Equatab } } - init(value: String?, fallback: Bool?) { + init(value: [String], fallback: Bool?) { self.value = value self.fallback = fallback } diff --git a/Sources/Subscription/Services/APIService.swift b/Sources/Subscription/API/APIService.swift similarity index 73% rename from Sources/Subscription/Services/APIService.swift rename to Sources/Subscription/API/APIService.swift index b1f299676..647a21c48 100644 --- a/Sources/Subscription/Services/APIService.swift +++ b/Sources/Subscription/API/APIService.swift @@ -22,7 +22,7 @@ import Common public enum APIServiceError: Swift.Error { case decodingError case encodingError - case serverError(description: String) + case serverError(statusCode: Int, error: String?) case unknownServerError case connectionError } @@ -32,14 +32,26 @@ struct ErrorResponse: Decodable { } public protocol APIService { - var baseURL: URL { get } - var session: URLSession { get } func executeAPICall(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result where T: Decodable + func makeAuthorizationHeader(for token: String) -> [String: String] } -public extension APIService { +public enum APICachePolicy { + case reloadIgnoringLocalCacheData + case returnCacheDataElseLoad + case returnCacheDataDontLoad +} + +public struct DefaultAPIService: APIService { + private let baseURL: URL + private let session: URLSession + + public init(baseURL: URL, session: URLSession) { + self.baseURL = baseURL + self.session = session + } - func executeAPICall(method: String, endpoint: String, headers: [String: String]? = nil, body: Data? = nil) async -> Result where T: Decodable { + public func executeAPICall(method: String, endpoint: String, headers: [String: String]? = nil, body: Data? = nil) async -> Result where T: Decodable { let request = makeAPIRequest(method: method, endpoint: endpoint, headers: headers, body: body) do { @@ -47,7 +59,9 @@ public extension APIService { printDebugInfo(method: method, endpoint: endpoint, data: data, response: urlResponse) - if let httpResponse = urlResponse as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) { + guard let httpResponse = urlResponse as? HTTPURLResponse else { return .failure(.unknownServerError) } + + if (200..<300).contains(httpResponse.statusCode) { if let decodedResponse = decode(T.self, from: data) { return .success(decodedResponse) } else { @@ -55,14 +69,15 @@ public extension APIService { return .failure(.decodingError) } } else { + var errorString: String? + if let decodedResponse = decode(ErrorResponse.self, from: data) { - let errorDescription = "[\(endpoint)] \(urlResponse.httpStatusCodeAsString ?? ""): \(decodedResponse.error)" - os_log(.error, log: .subscription, "Service error: %{public}@", errorDescription) - return .failure(.serverError(description: errorDescription)) - } else { - os_log(.error, log: .subscription, "Service error: APIServiceError.unknownServerError") - return .failure(.unknownServerError) + errorString = decodedResponse.error } + + let errorLogMessage = "/\(endpoint) \(httpResponse.statusCode): \(errorString ?? "")" + os_log(.error, log: .subscription, "Service error: %{public}@", errorLogMessage) + return .failure(.serverError(statusCode: httpResponse.statusCode, error: errorString)) } } catch { os_log(.error, log: .subscription, "Service error: %{public}@", error.localizedDescription) @@ -99,7 +114,7 @@ public extension APIService { os_log(.info, log: .subscription, "[API] %d %{public}s /%{public}s :: %{public}s", statusCode, method, endpoint, stringData) } - func makeAuthorizationHeader(for token: String) -> [String: String] { + public func makeAuthorizationHeader(for token: String) -> [String: String] { ["Authorization": "Bearer " + token] } } diff --git a/Sources/Subscription/API/AuthEndpointService.swift b/Sources/Subscription/API/AuthEndpointService.swift new file mode 100644 index 000000000..31972404a --- /dev/null +++ b/Sources/Subscription/API/AuthEndpointService.swift @@ -0,0 +1,110 @@ +// +// AuthEndpointService.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 + +public struct AccessTokenResponse: Decodable { + public let accessToken: String +} + +public struct ValidateTokenResponse: Decodable { + public let account: Account + + public struct Account: Decodable { + public let email: String? + public let entitlements: [Entitlement] + public let externalID: String + + enum CodingKeys: String, CodingKey { + case email, entitlements, externalID = "externalId" // no underscores due to keyDecodingStrategy = .convertFromSnakeCase + } + } +} + +public struct CreateAccountResponse: Decodable { + public let authToken: String + public let externalID: String + public let status: String + + enum CodingKeys: String, CodingKey { + case authToken = "authToken", externalID = "externalId", status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase + } +} + +public struct StoreLoginResponse: Decodable { + public let authToken: String + public let email: String + public let externalID: String + public let id: Int + public let status: String + + enum CodingKeys: String, CodingKey { + case authToken = "authToken", email, externalID = "externalId", id, status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase + } +} + +public protocol AuthEndpointService { + func getAccessToken(token: String) async -> Result + func validateToken(accessToken: String) async -> Result + func createAccount(emailAccessToken: String?) async -> Result + func storeLogin(signature: String) async -> Result +} + +public struct DefaultAuthEndpointService: AuthEndpointService { + private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment + private let apiService: APIService + + public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: APIService) { + self.currentServiceEnvironment = currentServiceEnvironment + self.apiService = apiService + } + + public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment) { + self.currentServiceEnvironment = currentServiceEnvironment + let baseURL = currentServiceEnvironment == .production ? URL(string: "https://quack.duckduckgo.com/api/auth")! : URL(string: "https://quackdev.duckduckgo.com/api/auth")! + let session = URLSession(configuration: URLSessionConfiguration.ephemeral) + self.apiService = DefaultAPIService(baseURL: baseURL, session: session) + } + + public func getAccessToken(token: String) async -> Result { + await apiService.executeAPICall(method: "GET", endpoint: "access-token", headers: apiService.makeAuthorizationHeader(for: token), body: nil) + } + + public func validateToken(accessToken: String) async -> Result { + await apiService.executeAPICall(method: "GET", endpoint: "validate-token", headers: apiService.makeAuthorizationHeader(for: accessToken), body: nil) + } + + public func createAccount(emailAccessToken: String?) async -> Result { + var headers: [String: String]? + + if let emailAccessToken { + headers = apiService.makeAuthorizationHeader(for: emailAccessToken) + } + + return await apiService.executeAPICall(method: "POST", endpoint: "account/create", headers: headers, body: nil) + } + + public func storeLogin(signature: String) async -> Result { + let bodyDict = ["signature": signature, + "store": "apple_app_store"] + + guard let bodyData = try? JSONEncoder().encode(bodyDict) else { return .failure(.encodingError) } + return await apiService.executeAPICall(method: "POST", endpoint: "store-login", headers: nil, body: bodyData) + } +} diff --git a/Sources/Subscription/Services/Model/Entitlement.swift b/Sources/Subscription/API/Model/Entitlement.swift similarity index 98% rename from Sources/Subscription/Services/Model/Entitlement.swift rename to Sources/Subscription/API/Model/Entitlement.swift index ece4b618a..c90e7342c 100644 --- a/Sources/Subscription/Services/Model/Entitlement.swift +++ b/Sources/Subscription/API/Model/Entitlement.swift @@ -19,7 +19,6 @@ import Foundation public struct Entitlement: Codable, Equatable { - let name: String public let product: ProductName public enum ProductName: String, Codable { diff --git a/Sources/Subscription/Services/Model/Subscription.swift b/Sources/Subscription/API/Model/Subscription.swift similarity index 100% rename from Sources/Subscription/Services/Model/Subscription.swift rename to Sources/Subscription/API/Model/Subscription.swift diff --git a/Sources/Subscription/Services/SubscriptionService.swift b/Sources/Subscription/API/SubscriptionEndpointService.swift similarity index 56% rename from Sources/Subscription/Services/SubscriptionService.swift rename to Sources/Subscription/API/SubscriptionEndpointService.swift index 978eb3406..7898bbddb 100644 --- a/Sources/Subscription/Services/SubscriptionService.swift +++ b/Sources/Subscription/API/SubscriptionEndpointService.swift @@ -1,5 +1,5 @@ // -// SubscriptionService.swift +// SubscriptionEndpointService.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -19,48 +19,69 @@ import Common import Foundation -/// Communicates with our backend -public final class SubscriptionService: APIService { +public struct GetProductsItem: Decodable { + public let productId: String + public let productLabel: String + public let billingPeriod: String + public let price: String + public let currency: String +} - private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment +public struct GetCustomerPortalURLResponse: Decodable { + public let customerPortalUrl: String +} - public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment) { - self.currentServiceEnvironment = currentServiceEnvironment - } +public struct ConfirmPurchaseResponse: Decodable { + public let email: String? + public let entitlements: [Entitlement] + public let subscription: Subscription +} - public let session = { - let configuration = URLSessionConfiguration.ephemeral - return URLSession(configuration: configuration) - }() - - public var baseURL: URL { - switch currentServiceEnvironment { - case .production: - URL(string: "https://subscriptions.duckduckgo.com/api")! - case .staging: - URL(string: "https://subscriptions-dev.duckduckgo.com/api")! - } +public enum SubscriptionServiceError: Error { + case noCachedData + case apiError(APIServiceError) +} + +public protocol SubscriptionEndpointService { + func updateCache(with subscription: Subscription) + func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result + func signOut() + func getProducts() async -> Result<[GetProductsItem], APIServiceError> + func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result + func confirmPurchase(accessToken: String, signature: String) async -> Result +} + +extension SubscriptionEndpointService { + + public func getSubscription(accessToken: String) async -> Result { + await getSubscription(accessToken: accessToken, cachePolicy: .returnCacheDataElseLoad) } +} +/// Communicates with our backend +public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService { + private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment + private let apiService: APIService private let subscriptionCache = UserDefaultsCache(key: UserDefaultsCacheKey.subscription, settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) - public enum CachePolicy { - case reloadIgnoringLocalCacheData - case returnCacheDataElseLoad - case returnCacheDataDontLoad + public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: APIService) { + self.currentServiceEnvironment = currentServiceEnvironment + self.apiService = apiService } - public enum SubscriptionServiceError: Error { - case noCachedData - case apiError(APIServiceError) + public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment) { + self.currentServiceEnvironment = currentServiceEnvironment + let baseURL = currentServiceEnvironment == .production ? URL(string: "https://subscriptions.duckduckgo.com/api")! : URL(string: "https://subscriptions-dev.duckduckgo.com/api")! + let session = URLSession(configuration: URLSessionConfiguration.ephemeral) + self.apiService = DefaultAPIService(baseURL: baseURL, session: session) } // MARK: - Subscription fetching with caching private func getRemoteSubscription(accessToken: String) async -> Result { - let result: Result = await executeAPICall(method: "GET", endpoint: "subscription", headers: makeAuthorizationHeader(for: accessToken)) + let result: Result = await apiService.executeAPICall(method: "GET", endpoint: "subscription", headers: apiService.makeAuthorizationHeader(for: accessToken), body: nil) switch result { case .success(let subscriptionResponse): updateCache(with: subscriptionResponse) @@ -82,7 +103,7 @@ public final class SubscriptionService: APIService { } } - public func getSubscription(accessToken: String, cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result { + public func getSubscription(accessToken: String, cachePolicy: APICachePolicy = .returnCacheDataElseLoad) async -> Result { switch cachePolicy { case .reloadIgnoringLocalCacheData: @@ -111,42 +132,24 @@ public final class SubscriptionService: APIService { // MARK: - public func getProducts() async -> Result<[GetProductsItem], APIServiceError> { - await executeAPICall(method: "GET", endpoint: "products") - } - - public struct GetProductsItem: Decodable { - public let productId: String - public let productLabel: String - public let billingPeriod: String - public let price: String - public let currency: String + await apiService.executeAPICall(method: "GET", endpoint: "products", headers: nil, body: nil) } // MARK: - public func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result { - var headers = makeAuthorizationHeader(for: accessToken) + var headers = apiService.makeAuthorizationHeader(for: accessToken) headers["externalAccountId"] = externalID - return await executeAPICall(method: "GET", endpoint: "checkout/portal", headers: headers) - } - - public struct GetCustomerPortalURLResponse: Decodable { - public let customerPortalUrl: String + return await apiService.executeAPICall(method: "GET", endpoint: "checkout/portal", headers: headers, body: nil) } // MARK: - public func confirmPurchase(accessToken: String, signature: String) async -> Result { - let headers = makeAuthorizationHeader(for: accessToken) + let headers = apiService.makeAuthorizationHeader(for: accessToken) let bodyDict = ["signedTransactionInfo": signature] guard let bodyData = try? JSONEncoder().encode(bodyDict) else { return .failure(.encodingError) } - return await executeAPICall(method: "POST", endpoint: "purchase/confirm/apple", headers: headers, body: bodyData) - } - - public struct ConfirmPurchaseResponse: Decodable { - public let email: String? - public let entitlements: [Entitlement] - public let subscription: Subscription + return await apiService.executeAPICall(method: "POST", endpoint: "purchase/confirm/apple", headers: headers, body: bodyData) } } diff --git a/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift b/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift index 4cc23a70d..5faddd81e 100644 --- a/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift +++ b/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift @@ -48,7 +48,7 @@ public enum AccountKeychainAccessError: Error, Equatable { } } -public class AccountKeychainStorage: AccountStoring { +public final class AccountKeychainStorage: AccountStoring { public init() {} diff --git a/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift b/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift index 40f6442b8..3e5772a44 100644 --- a/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift +++ b/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift @@ -18,7 +18,7 @@ import Foundation -public class SubscriptionTokenKeychainStorage: SubscriptionTokenStoring { +public final class SubscriptionTokenKeychainStorage: SubscriptionTokenStoring { private let keychainType: KeychainType diff --git a/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift index ab3cffe46..acbf4ab82 100644 --- a/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStoreAccountManagementFlow.swift @@ -20,36 +20,42 @@ import Foundation import StoreKit import Common +public enum AppStoreAccountManagementFlowError: Swift.Error { + case noPastTransaction + case authenticatingWithTransactionFailed +} + @available(macOS 12.0, iOS 15.0, *) -public final class AppStoreAccountManagementFlow { +public protocol AppStoreAccountManagementFlow { + @discardableResult func refreshAuthTokenIfNeeded() async -> Result +} - private let subscriptionManager: SubscriptionManaging - private var accountManager: AccountManaging { - subscriptionManager.accountManager - } +@available(macOS 12.0, iOS 15.0, *) +public final class DefaultAppStoreAccountManagementFlow: AppStoreAccountManagementFlow { - public init(subscriptionManager: SubscriptionManaging) { - self.subscriptionManager = subscriptionManager - } + private let authEndpointService: AuthEndpointService + private let storePurchaseManager: StorePurchaseManager + private let accountManager: AccountManager - public enum Error: Swift.Error { - case noPastTransaction - case authenticatingWithTransactionFailed + public init(authEndpointService: any AuthEndpointService, storePurchaseManager: any StorePurchaseManager, accountManager: any AccountManager) { + self.authEndpointService = authEndpointService + self.storePurchaseManager = storePurchaseManager + self.accountManager = accountManager } @discardableResult - public func refreshAuthTokenIfNeeded() async -> Result { + public func refreshAuthTokenIfNeeded() async -> Result { os_log(.info, log: .subscription, "[AppStoreAccountManagementFlow] refreshAuthTokenIfNeeded") var authToken = accountManager.authToken ?? "" // Check if auth token if still valid - if case let .failure(validateTokenError) = await subscriptionManager.authService.validateToken(accessToken: authToken) { + if case let .failure(validateTokenError) = await authEndpointService.validateToken(accessToken: authToken) { os_log(.error, log: .subscription, "[AppStoreAccountManagementFlow] validateToken error: %{public}s", String(reflecting: validateTokenError)) // In case of invalid token attempt store based authentication to obtain a new one - guard let lastTransactionJWSRepresentation = await subscriptionManager.storePurchaseManager().mostRecentTransaction() else { return .failure(.noPastTransaction) } + guard let lastTransactionJWSRepresentation = await storePurchaseManager.mostRecentTransaction() else { return .failure(.noPastTransaction) } - switch await subscriptionManager.authService.storeLogin(signature: lastTransactionJWSRepresentation) { + switch await authEndpointService.storeLogin(signature: lastTransactionJWSRepresentation) { case .success(let response): if response.externalID == accountManager.externalID { authToken = response.authToken diff --git a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift index e3248a9f7..ff35bbcd0 100644 --- a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift @@ -20,33 +20,47 @@ import Foundation import StoreKit import Common -@available(macOS 12.0, iOS 15.0, *) -public final class AppStorePurchaseFlow { - - public enum Error: Swift.Error { - case noProductsFound - case activeSubscriptionAlreadyPresent - case authenticatingWithTransactionFailed - case accountCreationFailed - case purchaseFailed - case cancelledByUser - case missingEntitlements - case internalError - } +public enum AppStorePurchaseFlowError: Swift.Error { + case noProductsFound + case activeSubscriptionAlreadyPresent + case authenticatingWithTransactionFailed + case accountCreationFailed + case purchaseFailed + case cancelledByUser + case missingEntitlements + case internalError +} - private let subscriptionManager: SubscriptionManaging - var accountManager: AccountManaging { - subscriptionManager.accountManager - } +@available(macOS 12.0, iOS 15.0, *) +public protocol AppStorePurchaseFlow { + typealias TransactionJWS = String + func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result + @discardableResult + func completeSubscriptionPurchase(with transactionJWS: AppStorePurchaseFlow.TransactionJWS) async -> Result +} - public init(subscriptionManager: SubscriptionManaging) { - self.subscriptionManager = subscriptionManager +@available(macOS 12.0, iOS 15.0, *) +public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow { + private let subscriptionEndpointService: SubscriptionEndpointService + private let storePurchaseManager: StorePurchaseManager + private let accountManager: AccountManager + private let appStoreRestoreFlow: AppStoreRestoreFlow + private let authEndpointService: AuthEndpointService + + public init(subscriptionEndpointService: any SubscriptionEndpointService, + storePurchaseManager: any StorePurchaseManager, + accountManager: any AccountManager, + appStoreRestoreFlow: any AppStoreRestoreFlow, + authEndpointService: any AuthEndpointService) { + self.subscriptionEndpointService = subscriptionEndpointService + self.storePurchaseManager = storePurchaseManager + self.accountManager = accountManager + self.appStoreRestoreFlow = appStoreRestoreFlow + self.authEndpointService = authEndpointService } - public typealias TransactionJWS = String - // swiftlint:disable cyclomatic_complexity - public func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result { + public func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result { os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription") let externalID: String @@ -57,7 +71,6 @@ public final class AppStorePurchaseFlow { // Otherwise, try to retrieve an expired Apple subscription or create a new one } else { // Check for past transactions most recent - let appStoreRestoreFlow = AppStoreRestoreFlow(subscriptionManager: subscriptionManager) switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { case .success: os_log(.info, log: .subscription, "[AppStorePurchaseFlow] purchaseSubscription -> restoreAccountFromPastPurchase: activeSubscriptionAlreadyPresent") @@ -70,7 +83,7 @@ public final class AppStorePurchaseFlow { accountManager.storeAuthToken(token: expiredAccountDetails.authToken) accountManager.storeAccount(token: expiredAccountDetails.accessToken, email: expiredAccountDetails.email, externalID: expiredAccountDetails.externalID) default: - switch await subscriptionManager.authService.createAccount(emailAccessToken: emailAccessToken) { + switch await authEndpointService.createAccount(emailAccessToken: emailAccessToken) { case .success(let response): externalID = response.externalID @@ -88,7 +101,7 @@ public final class AppStorePurchaseFlow { } // Make the purchase - switch await subscriptionManager.storePurchaseManager().purchaseSubscription(with: subscriptionIdentifier, externalID: externalID) { + switch await storePurchaseManager.purchaseSubscription(with: subscriptionIdentifier, externalID: externalID) { case .success(let transactionJWS): return .success(transactionJWS) case .failure(let error): @@ -105,19 +118,19 @@ public final class AppStorePurchaseFlow { // swiftlint:enable cyclomatic_complexity @discardableResult - public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS) async -> Result { + public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS) async -> Result { // Clear subscription Cache - subscriptionManager.subscriptionService.signOut() + subscriptionEndpointService.signOut() os_log(.info, log: .subscription, "[AppStorePurchaseFlow] completeSubscriptionPurchase") guard let accessToken = accountManager.accessToken else { return .failure(.missingEntitlements) } let result = await callWithRetries(retry: 5, wait: 2.0) { - switch await subscriptionManager.subscriptionService.confirmPurchase(accessToken: accessToken, signature: transactionJWS) { + switch await subscriptionEndpointService.confirmPurchase(accessToken: accessToken, signature: transactionJWS) { case .success(let confirmation): - subscriptionManager.subscriptionService.updateCache(with: confirmation.subscription) + subscriptionEndpointService.updateCache(with: confirmation.subscription) accountManager.updateCache(with: confirmation.entitlements) return true case .failure: @@ -152,7 +165,7 @@ public final class AppStorePurchaseFlow { let token = accountManager.accessToken else { return nil } - let subscriptionInfo = await subscriptionManager.subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) + let subscriptionInfo = await subscriptionEndpointService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) // Only return an externalID if the subscription is expired // To prevent creating multiple subscriptions in the same account diff --git a/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift index c09afb7e1..15a8b5c94 100644 --- a/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift @@ -20,38 +20,48 @@ import Foundation import StoreKit import Common -@available(macOS 12.0, iOS 15.0, *) -public final class AppStoreRestoreFlow { - - public typealias RestoredAccountDetails = (authToken: String, accessToken: String, externalID: String, email: String?) - - public enum Error: Swift.Error { - case missingAccountOrTransactions - case pastTransactionAuthenticationError - case failedToObtainAccessToken - case failedToFetchAccountDetails - case failedToFetchSubscriptionDetails - case subscriptionExpired(accountDetails: RestoredAccountDetails) - } +public typealias RestoredAccountDetails = (authToken: String, accessToken: String, externalID: String, email: String?) + +public enum AppStoreRestoreFlowError: Swift.Error { + case missingAccountOrTransactions + case pastTransactionAuthenticationError + case failedToObtainAccessToken + case failedToFetchAccountDetails + case failedToFetchSubscriptionDetails + case subscriptionExpired(accountDetails: RestoredAccountDetails) +} - private let subscriptionManager: SubscriptionManaging - var accountManager: AccountManaging { - subscriptionManager.accountManager - } +@available(macOS 12.0, iOS 15.0, *) +public protocol AppStoreRestoreFlow { + @discardableResult func restoreAccountFromPastPurchase() async -> Result +} - public init(subscriptionManager: SubscriptionManaging) { - self.subscriptionManager = subscriptionManager +@available(macOS 12.0, iOS 15.0, *) +public final class DefaultAppStoreRestoreFlow: AppStoreRestoreFlow { + private let accountManager: AccountManager + private let storePurchaseManager: StorePurchaseManager + private let subscriptionEndpointService: SubscriptionEndpointService + private let authEndpointService: AuthEndpointService + + public init(accountManager: any AccountManager, + storePurchaseManager: any StorePurchaseManager, + subscriptionEndpointService: any SubscriptionEndpointService, + authEndpointService: any AuthEndpointService) { + self.accountManager = accountManager + self.storePurchaseManager = storePurchaseManager + self.subscriptionEndpointService = subscriptionEndpointService + self.authEndpointService = authEndpointService } @discardableResult - public func restoreAccountFromPastPurchase() async -> Result { + public func restoreAccountFromPastPurchase() async -> Result { // Clear subscription Cache - subscriptionManager.subscriptionService.signOut() + subscriptionEndpointService.signOut() os_log(.info, log: .subscription, "[AppStoreRestoreFlow] restoreAccountFromPastPurchase") - guard let lastTransactionJWSRepresentation = await subscriptionManager.storePurchaseManager().mostRecentTransaction() else { + guard let lastTransactionJWSRepresentation = await storePurchaseManager.mostRecentTransaction() else { os_log(.error, log: .subscription, "[AppStoreRestoreFlow] Error: missingAccountOrTransactions") return .failure(.missingAccountOrTransactions) } @@ -59,7 +69,7 @@ public final class AppStoreRestoreFlow { // Do the store login to get short-lived token let authToken: String - switch await subscriptionManager.authService.storeLogin(signature: lastTransactionJWSRepresentation) { + switch await authEndpointService.storeLogin(signature: lastTransactionJWSRepresentation) { case .success(let response): authToken = response.authToken case .failure: @@ -90,7 +100,7 @@ public final class AppStoreRestoreFlow { var isSubscriptionActive = false - switch await subscriptionManager.subscriptionService.getSubscription(accessToken: accessToken, cachePolicy: .reloadIgnoringLocalCacheData) { + switch await subscriptionEndpointService.getSubscription(accessToken: accessToken, cachePolicy: .reloadIgnoringLocalCacheData) { case .success(let subscription): isSubscriptionActive = subscription.isActive case .failure: diff --git a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift index 24c3021ee..2e561882a 100644 --- a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift +++ b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift @@ -20,26 +20,34 @@ import Foundation import StoreKit import Common -public final class StripePurchaseFlow { - - private let subscriptionManager: SubscriptionManaging - var accountManager: AccountManaging { - subscriptionManager.accountManager - } +public enum StripePurchaseFlowError: Swift.Error { + case noProductsFound + case accountCreationFailed +} - public init(subscriptionManager: SubscriptionManaging) { - self.subscriptionManager = subscriptionManager - } +public protocol StripePurchaseFlow { + func subscriptionOptions() async -> Result + func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result + func completeSubscriptionPurchase() async +} - public enum Error: Swift.Error { - case noProductsFound - case accountCreationFailed +public final class DefaultStripePurchaseFlow: StripePurchaseFlow { + private let subscriptionEndpointService: SubscriptionEndpointService + private let authEndpointService: AuthEndpointService + private let accountManager: AccountManager + + public init(subscriptionEndpointService: any SubscriptionEndpointService, + authEndpointService: any AuthEndpointService, + accountManager: any AccountManager) { + self.subscriptionEndpointService = subscriptionEndpointService + self.authEndpointService = authEndpointService + self.accountManager = accountManager } - public func subscriptionOptions() async -> Result { + public func subscriptionOptions() async -> Result { os_log(.info, log: .subscription, "[StripePurchaseFlow] subscriptionOptions") - guard case let .success(products) = await subscriptionManager.subscriptionService.getProducts(), !products.isEmpty else { + guard case let .success(products) = await subscriptionEndpointService.getProducts(), !products.isEmpty else { os_log(.error, log: .subscription, "[StripePurchaseFlow] Error: noProductsFound") return .failure(.noProductsFound) } @@ -70,11 +78,11 @@ public final class StripePurchaseFlow { features: features)) } - public func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result { + public func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result { os_log(.info, log: .subscription, "[StripePurchaseFlow] prepareSubscriptionPurchase") // Clear subscription Cache - subscriptionManager.subscriptionService.signOut() + subscriptionEndpointService.signOut() var token: String = "" if let accessToken = accountManager.accessToken { @@ -82,7 +90,7 @@ public final class StripePurchaseFlow { token = accessToken } } else { - switch await subscriptionManager.authService.createAccount(emailAccessToken: emailAccessToken) { + switch await authEndpointService.createAccount(emailAccessToken: emailAccessToken) { case .success(let response): token = response.authToken accountManager.storeAuthToken(token: token) @@ -96,7 +104,7 @@ public final class StripePurchaseFlow { } private func isSubscriptionExpired(accessToken: String) async -> Bool { - if case .success(let subscription) = await subscriptionManager.subscriptionService.getSubscription(accessToken: accessToken) { + if case .success(let subscription) = await subscriptionEndpointService.getSubscription(accessToken: accessToken) { return !subscription.isActive } @@ -104,9 +112,8 @@ public final class StripePurchaseFlow { } public func completeSubscriptionPurchase() async { - // Clear subscription Cache - subscriptionManager.subscriptionService.signOut() + subscriptionEndpointService.signOut() os_log(.info, log: .subscription, "[StripePurchaseFlow] completeSubscriptionPurchase") if !accountManager.isUserAuthenticated, diff --git a/Sources/Subscription/SubManagers/AccountManager.swift b/Sources/Subscription/Managers/AccountManager.swift similarity index 79% rename from Sources/Subscription/SubManagers/AccountManager.swift rename to Sources/Subscription/Managers/AccountManager.swift index 7055005fc..caf5e8db1 100644 --- a/Sources/Subscription/SubManagers/AccountManager.swift +++ b/Sources/Subscription/Managers/AccountManager.swift @@ -19,29 +19,72 @@ import Foundation import Common -public class AccountManager: AccountManaging { +public protocol AccountManagerKeychainAccessDelegate: AnyObject { + func accountManagerKeychainAccessFailed(accessType: AccountKeychainAccessType, error: AccountKeychainAccessError) +} + +public protocol AccountManager { + + var delegate: AccountManagerKeychainAccessDelegate? { get set } + var accessToken: String? { get } + var authToken: String? { get } + var email: String? { get } + var externalID: String? { get } + + func storeAuthToken(token: String) + func storeAccount(token: String, email: String?, externalID: String?) + func signOut(skipNotification: Bool) + func signOut() + func migrateAccessTokenToNewStore() throws + + // Entitlements + func hasEntitlement(forProductName productName: Entitlement.ProductName, cachePolicy: APICachePolicy) async -> Result + + func updateCache(with entitlements: [Entitlement]) + @discardableResult func fetchEntitlements(cachePolicy: APICachePolicy) async -> Result<[Entitlement], Error> + func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result + + typealias AccountDetails = (email: String?, externalID: String) + func fetchAccountDetails(with accessToken: String) async -> Result + + @discardableResult func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool +} + +extension AccountManager { + + public func hasEntitlement(forProductName productName: Entitlement.ProductName) async -> Result { + await hasEntitlement(forProductName: productName, cachePolicy: .returnCacheDataElseLoad) + } + + public func fetchEntitlements() async -> Result<[Entitlement], Error> { + await fetchEntitlements(cachePolicy: .returnCacheDataElseLoad) + } + + public var isUserAuthenticated: Bool { accessToken != nil } +} + +public final class DefaultAccountManager: AccountManager { private let storage: AccountStoring private let entitlementsCache: UserDefaultsCache<[Entitlement]> private let accessTokenStorage: SubscriptionTokenStoring - private let subscriptionService: SubscriptionService - private let authService: AuthService + private let subscriptionEndpointService: SubscriptionEndpointService + private let authEndpointService: AuthEndpointService public weak var delegate: AccountManagerKeychainAccessDelegate? - public var isUserAuthenticated: Bool { accessToken != nil } // MARK: - Initialisers public init(storage: AccountStoring = AccountKeychainStorage(), accessTokenStorage: SubscriptionTokenStoring, entitlementsCache: UserDefaultsCache<[Entitlement]>, - subscriptionService: SubscriptionService, - authService: AuthService) { + subscriptionEndpointService: SubscriptionEndpointService, + authEndpointService: AuthEndpointService) { self.storage = storage self.entitlementsCache = entitlementsCache self.accessTokenStorage = accessTokenStorage - self.subscriptionService = subscriptionService - self.authService = authService + self.subscriptionEndpointService = subscriptionEndpointService + self.authEndpointService = authEndpointService } // MARK: - @@ -161,7 +204,7 @@ public class AccountManager: AccountManaging { do { try storage.clearAuthenticationState() try accessTokenStorage.removeAccessToken() - subscriptionService.signOut() + subscriptionEndpointService.signOut() entitlementsCache.reset() } catch { if let error = error as? AccountKeychainAccessError { @@ -199,32 +242,22 @@ public class AccountManager: AccountManaging { } // MARK: - - - public enum EntitlementsError: Error { - case noAccessToken - case noCachedData - } - - public func hasEntitlement(for entitlement: Entitlement.ProductName, cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result { + public func hasEntitlement(forProductName productName: Entitlement.ProductName, cachePolicy: APICachePolicy) async -> Result { switch await fetchEntitlements(cachePolicy: cachePolicy) { case .success(let entitlements): - return .success(entitlements.compactMap { $0.product }.contains(entitlement)) + return .success(entitlements.compactMap { $0.product }.contains(productName)) case .failure(let error): return .failure(error) } } - public func hasEntitlement(for entitlement: Entitlement.ProductName) async -> Result { - return await hasEntitlement(for: entitlement, cachePolicy: .returnCacheDataElseLoad) - } - private func fetchRemoteEntitlements() async -> Result<[Entitlement], Error> { guard let accessToken else { entitlementsCache.reset() return .failure(EntitlementsError.noAccessToken) } - switch await authService.validateToken(accessToken: accessToken) { + switch await authEndpointService.validateToken(accessToken: accessToken) { case .success(let response): let entitlements = response.account.entitlements updateCache(with: entitlements) @@ -249,8 +282,13 @@ public class AccountManager: AccountManaging { } } + public enum EntitlementsError: Error { + case noAccessToken + case noCachedData + } + @discardableResult - public func fetchEntitlements(cachePolicy: CachePolicy = .returnCacheDataElseLoad) async -> Result<[Entitlement], Error> { + public func fetchEntitlements(cachePolicy: APICachePolicy) async -> Result<[Entitlement], Error> { switch cachePolicy { case .reloadIgnoringLocalCacheData: @@ -274,7 +312,7 @@ public class AccountManager: AccountManaging { } public func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result { - switch await authService.getAccessToken(token: authToken) { + switch await authEndpointService.getAccessToken(token: authToken) { case .success(let response): return .success(response.accessToken) case .failure(let error): @@ -284,7 +322,7 @@ public class AccountManager: AccountManaging { } public func fetchAccountDetails(with accessToken: String) async -> Result { - switch await authService.validateToken(accessToken: accessToken) { + switch await authEndpointService.validateToken(accessToken: accessToken) { case .success(let response): return .success(AccountDetails(email: response.account.email, externalID: response.account.externalID)) case .failure(let error): @@ -293,24 +331,6 @@ public class AccountManager: AccountManaging { } } - public func refreshSubscriptionAndEntitlements() async { - os_log(.info, log: .subscription, "[AccountManager] refreshSubscriptionAndEntitlements") - - guard let token = accessToken else { - subscriptionService.signOut() - entitlementsCache.reset() - return - } - - if case .success(let subscription) = await subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) { - if !subscription.isActive { - signOut() - } - } - - await fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData) - } - @discardableResult public func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool { var count = 0 diff --git a/Sources/Subscription/SubManagers/StorePurchaseManager.swift b/Sources/Subscription/Managers/StorePurchaseManager.swift similarity index 84% rename from Sources/Subscription/SubManagers/StorePurchaseManager.swift rename to Sources/Subscription/Managers/StorePurchaseManager.swift index d475a91df..25f550d8c 100644 --- a/Sources/Subscription/SubManagers/StorePurchaseManager.swift +++ b/Sources/Subscription/Managers/StorePurchaseManager.swift @@ -20,12 +20,40 @@ import Foundation import StoreKit import Common +public enum StoreError: Error { + case failedVerification +} + +public enum StorePurchaseManagerError: Error { + case productNotFound + case externalIDisNotAValidUUID + case purchaseFailed + case transactionCannotBeVerified + case transactionPendingAuthentication + case purchaseCancelledByUser + case unknownError +} + +public protocol StorePurchaseManager { + typealias TransactionJWS = String + func subscriptionOptions() async -> SubscriptionOptions? + var purchasedProductIDs: [String] { get } + var purchaseQueue: [String] { get } + var areProductsAvailable: Bool { get } + @MainActor func syncAppleIDAccount() async throws + @MainActor func updateAvailableProducts() async + @MainActor func updatePurchasedProducts() async + @MainActor func mostRecentTransaction() async -> String? + @MainActor func hasActiveSubscription() async -> Bool + @MainActor func purchaseSubscription(with identifier: String, externalID: String) async -> Result +} + @available(macOS 12.0, iOS 15.0, *) typealias Transaction = StoreKit.Transaction @available(macOS 12.0, iOS 15.0, *) typealias RenewalInfo = StoreKit.Product.SubscriptionInfo.RenewalInfo @available(macOS 12.0, iOS 15.0, *) typealias RenewalState = StoreKit.Product.SubscriptionInfo.RenewalState @available(macOS 12.0, iOS 15.0, *) -public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging { +public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseManager { let productIdentifiers = ["ios.subscription.1month", "ios.subscription.1year", "subscription.1month", "subscription.1year", @@ -38,10 +66,7 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging @Published public private(set) var purchaseQueue: [String] = [] @Published private var subscriptionGroupStatus: RenewalState? - public var areProductsAvailable: Bool { - !availableProducts.isEmpty - } - + public var areProductsAvailable: Bool { !availableProducts.isEmpty } private var transactionUpdates: Task? private var storefrontChanges: Task? @@ -56,8 +81,7 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging } @MainActor - @discardableResult - public func syncAppleIDAccount() async -> Result { + public func syncAppleIDAccount() async throws { do { purchaseQueue.removeAll() @@ -69,11 +93,9 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging await updatePurchasedProducts() await updateAvailableProducts() - - return .success(()) } catch { os_log(.error, log: .subscription, "[StorePurchaseManager] Error: %{public}s (%{public}s)", String(reflecting: error), error.localizedDescription) - return .failure(error) + throw error } } @@ -178,12 +200,10 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging return !transactions.isEmpty } - public typealias TransactionJWS = String - @MainActor - public func purchaseSubscription(with identifier: String, externalID: String) async -> Result { + public func purchaseSubscription(with identifier: String, externalID: String) async -> Result { - guard let product = availableProducts.first(where: { $0.id == identifier }) else { return .failure(PurchaseManagerError.productNotFound) } + guard let product = availableProducts.first(where: { $0.id == identifier }) else { return .failure(StorePurchaseManagerError.productNotFound) } os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription %{public}s (%{public}s)", product.displayName, externalID) @@ -195,7 +215,7 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging options.insert(.appAccountToken(token)) } else { os_log(.error, log: .subscription, "[StorePurchaseManager] Error: Failed to create UUID") - return .failure(PurchaseManagerError.externalIDisNotAValidUUID) + return .failure(StorePurchaseManagerError.externalIDisNotAValidUUID) } let purchaseResult: Product.PurchaseResult @@ -203,7 +223,7 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging purchaseResult = try await product.purchase(options: options) } catch { os_log(.error, log: .subscription, "[StorePurchaseManager] Error: %{public}s", String(reflecting: error)) - return .failure(PurchaseManagerError.purchaseFailed) + return .failure(StorePurchaseManagerError.purchaseFailed) } os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription complete") @@ -223,19 +243,19 @@ public final class StorePurchaseManager: ObservableObject, StorePurchaseManaging os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription result: success /unverified/ - %{public}s", String(reflecting: error)) // Successful purchase but transaction/receipt can't be verified // Could be a jailbroken phone - return .failure(PurchaseManagerError.transactionCannotBeVerified) + return .failure(StorePurchaseManagerError.transactionCannotBeVerified) } case .pending: os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription result: pending") // Transaction waiting on SCA (Strong Customer Authentication) or // approval from Ask to Buy - return .failure(PurchaseManagerError.transactionPendingAuthentication) + return .failure(StorePurchaseManagerError.transactionPendingAuthentication) case .userCancelled: os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription result: user cancelled") - return .failure(PurchaseManagerError.purchaseCancelledByUser) + return .failure(StorePurchaseManagerError.purchaseCancelledByUser) @unknown default: os_log(.info, log: .subscription, "[StorePurchaseManager] purchaseSubscription result: unknown") - return .failure(PurchaseManagerError.unknownError) + return .failure(StorePurchaseManagerError.unknownError) } } diff --git a/Sources/Subscription/SubscriptionManager.swift b/Sources/Subscription/Managers/SubscriptionManager.swift similarity index 59% rename from Sources/Subscription/SubscriptionManager.swift rename to Sources/Subscription/Managers/SubscriptionManager.swift index 96d1806b9..c85b80224 100644 --- a/Sources/Subscription/SubscriptionManager.swift +++ b/Sources/Subscription/Managers/SubscriptionManager.swift @@ -19,39 +19,42 @@ import Foundation import Common -public protocol SubscriptionManaging { - - var accountManager: AccountManaging { get } - var subscriptionService: SubscriptionService { get } - var authService: AuthService { get } +public protocol SubscriptionManager { + // Dependencies + var accountManager: AccountManager { get } + var subscriptionEndpointService: SubscriptionEndpointService { get } + var authEndpointService: AuthEndpointService { get } + + // Environment + static func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment? + static func save(subscriptionEnvironment: SubscriptionEnvironment, userDefaults: UserDefaults) var currentEnvironment: SubscriptionEnvironment { get } + var canPurchase: Bool { get } - @available(macOS 12.0, iOS 15.0, *) func storePurchaseManager() -> StorePurchaseManaging + @available(macOS 12.0, iOS 15.0, *) func storePurchaseManager() -> StorePurchaseManager func loadInitialData() - func updateSubscriptionStatus(completion: @escaping (_ isActive: Bool) -> Void) + func refreshCachedSubscriptionAndEntitlements(completion: @escaping (_ isSubscriptionActive: Bool) -> Void) func url(for type: SubscriptionURL) -> URL } /// Single entry point for everything related to Subscription. This manager is disposable, every time something related to the environment changes this need to be recreated. -final public class SubscriptionManager: SubscriptionManaging { - - private let _storePurchaseManager: StorePurchaseManaging? - - public let accountManager: AccountManaging - public let subscriptionService: SubscriptionService - public let authService: AuthService +public final class DefaultSubscriptionManager: SubscriptionManager { + private let _storePurchaseManager: StorePurchaseManager? + public let accountManager: AccountManager + public let subscriptionEndpointService: SubscriptionEndpointService + public let authEndpointService: AuthEndpointService public let currentEnvironment: SubscriptionEnvironment public private(set) var canPurchase: Bool = false - public init(storePurchaseManager: StorePurchaseManaging? = nil, - accountManager: AccountManaging, - subscriptionService: SubscriptionService, - authService: AuthService, + public init(storePurchaseManager: StorePurchaseManager? = nil, + accountManager: AccountManager, + subscriptionEndpointService: SubscriptionEndpointService, + authEndpointService: AuthEndpointService, subscriptionEnvironment: SubscriptionEnvironment) { self._storePurchaseManager = storePurchaseManager self.accountManager = accountManager - self.subscriptionService = subscriptionService - self.authService = authService + self.subscriptionEndpointService = subscriptionEndpointService + self.authEndpointService = authEndpointService self.currentEnvironment = subscriptionEnvironment switch currentEnvironment.purchasePlatform { case .appStore: @@ -66,7 +69,7 @@ final public class SubscriptionManager: SubscriptionManaging { } @available(macOS 12.0, iOS 15.0, *) - public func storePurchaseManager() -> StorePurchaseManaging { + public func storePurchaseManager() -> StorePurchaseManager { return _storePurchaseManager! } @@ -101,7 +104,7 @@ final public class SubscriptionManager: SubscriptionManaging { private func setupForStripe() { Task { - if case let .success(products) = await subscriptionService.getProducts() { + if case let .success(products) = await subscriptionEndpointService.getProducts() { canPurchase = !products.isEmpty } } @@ -112,20 +115,37 @@ final public class SubscriptionManager: SubscriptionManaging { public func loadInitialData() { Task { if let token = accountManager.accessToken { - _ = await subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) + _ = await subscriptionEndpointService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) _ = await accountManager.fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData) } } } - public func updateSubscriptionStatus(completion: @escaping (_ isActive: Bool) -> Void) { + public func refreshCachedSubscriptionAndEntitlements(completion: @escaping (_ isSubscriptionActive: Bool) -> Void) { Task { - guard let token = accountManager.accessToken else { return } + guard let token = accountManager.accessToken else { return } + + var isSubscriptionActive = false + + defer { + completion(isSubscriptionActive) + } - if case .success(let subscription) = await subscriptionService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) { - completion(subscription.isActive) + // Refetch and cache subscription + switch await subscriptionEndpointService.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) { + case .success(let subscription): + isSubscriptionActive = subscription.isActive + case .failure(let error): + if case let .apiError(serviceError) = error, case let .serverError(statusCode, _) = serviceError { + if statusCode == 401 { + // Token is no longer valid + accountManager.signOut() + return + } + } } + // Refetch and cache entitlements _ = await accountManager.fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData) } } diff --git a/Sources/Subscription/Services/AuthService.swift b/Sources/Subscription/Services/AuthService.swift deleted file mode 100644 index d35ac3b0c..000000000 --- a/Sources/Subscription/Services/AuthService.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// AuthService.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 - -public struct AuthService: APIService { - - private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment - - public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment) { - self.currentServiceEnvironment = currentServiceEnvironment - } - - public let session = { - let configuration = URLSessionConfiguration.ephemeral - return URLSession(configuration: configuration) - }() - - public var baseURL: URL { - switch currentServiceEnvironment { - case .production: - URL(string: "https://quack.duckduckgo.com/api/auth")! - case .staging: - URL(string: "https://quackdev.duckduckgo.com/api/auth")! - } - } - - // MARK: - - - public func getAccessToken(token: String) async -> Result { - await executeAPICall(method: "GET", endpoint: "access-token", headers: makeAuthorizationHeader(for: token)) - } - - public struct AccessTokenResponse: Decodable { - public let accessToken: String - } - - // MARK: - - - public func validateToken(accessToken: String) async -> Result { - await executeAPICall(method: "GET", endpoint: "validate-token", headers: makeAuthorizationHeader(for: accessToken)) - } - - public struct ValidateTokenResponse: Decodable { - public let account: Account - - public struct Account: Decodable { - public let email: String? - let entitlements: [Entitlement] - public let externalID: String - - enum CodingKeys: String, CodingKey { - case email, entitlements, externalID = "externalId" // no underscores due to keyDecodingStrategy = .convertFromSnakeCase - } - } - } - - // MARK: - - - public func createAccount(emailAccessToken: String?) async -> Result { - var headers: [String: String]? - - if let emailAccessToken { - headers = makeAuthorizationHeader(for: emailAccessToken) - } - - return await executeAPICall(method: "POST", endpoint: "account/create", headers: headers) - } - - public struct CreateAccountResponse: Decodable { - public let authToken: String - public let externalID: String - public let status: String - - enum CodingKeys: String, CodingKey { - case authToken = "authToken", externalID = "externalId", status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase - } - } - - // MARK: - - - public func storeLogin(signature: String) async -> Result { - let bodyDict = ["signature": signature, - "store": "apple_app_store"] - - guard let bodyData = try? JSONEncoder().encode(bodyDict) else { return .failure(.encodingError) } - return await executeAPICall(method: "POST", endpoint: "store-login", body: bodyData) - } - - public struct StoreLoginResponse: Decodable { - public let authToken: String - public let email: String - public let externalID: String - public let id: Int - public let status: String - - enum CodingKeys: String, CodingKey { - case authToken = "authToken", email, externalID = "externalId", id, status // no underscores due to keyDecodingStrategy = .convertFromSnakeCase - } - } -} diff --git a/Sources/Subscription/SubManagers/AccountManaging.swift b/Sources/Subscription/SubManagers/AccountManaging.swift deleted file mode 100644 index 673860ef7..000000000 --- a/Sources/Subscription/SubManagers/AccountManaging.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// AccountManaging.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 - -public protocol AccountManagerKeychainAccessDelegate: AnyObject { - func accountManagerKeychainAccessFailed(accessType: AccountKeychainAccessType, error: AccountKeychainAccessError) -} - -public enum AccountManagingCachePolicy { - case reloadIgnoringLocalCacheData - case returnCacheDataElseLoad - case returnCacheDataDontLoad -} - -public protocol AccountManaging { - - var delegate: AccountManagerKeychainAccessDelegate? { get set } - var isUserAuthenticated: Bool { get } - var accessToken: String? { get } - var authToken: String? { get } - var email: String? { get } - var externalID: String? { get } - - func storeAuthToken(token: String) - func storeAccount(token: String, email: String?, externalID: String?) - func signOut(skipNotification: Bool) - func signOut() - func migrateAccessTokenToNewStore() throws - - // Entitlements - typealias CachePolicy = AccountManagingCachePolicy - func hasEntitlement(for entitlement: Entitlement.ProductName, cachePolicy: CachePolicy) async -> Result - func hasEntitlement(for entitlement: Entitlement.ProductName) async -> Result - func updateCache(with entitlements: [Entitlement]) - @discardableResult func fetchEntitlements(cachePolicy: CachePolicy) async -> Result<[Entitlement], Error> - func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result - - typealias AccountDetails = (email: String?, externalID: String) - func fetchAccountDetails(with accessToken: String) async -> Result - func refreshSubscriptionAndEntitlements() async - @discardableResult func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool -} diff --git a/Sources/Subscription/SubManagers/StorePurchaseManaging.swift b/Sources/Subscription/SubManagers/StorePurchaseManaging.swift deleted file mode 100644 index c4ac847cb..000000000 --- a/Sources/Subscription/SubManagers/StorePurchaseManaging.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// StorePurchaseManaging.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 - -public enum StoreError: Error { - case failedVerification -} - -public enum PurchaseManagerError: Error { - case productNotFound - case externalIDisNotAValidUUID - case purchaseFailed - case transactionCannotBeVerified - case transactionPendingAuthentication - case purchaseCancelledByUser - case unknownError -} - -public protocol StorePurchaseManaging { - - func subscriptionOptions() async -> SubscriptionOptions? - - var purchasedProductIDs: [String] { get } - - var purchaseQueue: [String] { get } - - var areProductsAvailable: Bool { get } - - @discardableResult @MainActor func syncAppleIDAccount() async -> Result - - @MainActor func updateAvailableProducts() async - - @MainActor func updatePurchasedProducts() async - - @MainActor func mostRecentTransaction() async -> String? - - @MainActor func hasActiveSubscription() async -> Bool - - typealias TransactionJWS = String - - @MainActor func purchaseSubscription(with identifier: String, externalID: String) async -> Result -} diff --git a/Sources/Subscription/SubscriptionURL.swift b/Sources/Subscription/SubscriptionURL.swift index 6c99c151f..a6cd6a10e 100644 --- a/Sources/Subscription/SubscriptionURL.swift +++ b/Sources/Subscription/SubscriptionURL.swift @@ -34,7 +34,7 @@ public enum SubscriptionURL { case identityTheftRestoration // swiftlint:disable:next cyclomatic_complexity - func subscriptionURL(environment: SubscriptionEnvironment.ServiceEnvironment) -> URL { + public func subscriptionURL(environment: SubscriptionEnvironment.ServiceEnvironment) -> URL { switch self { case .baseURL: switch environment { diff --git a/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift new file mode 100644 index 000000000..ab7916690 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift @@ -0,0 +1,47 @@ +// +// APIServiceMock.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 Subscription + +public struct APIServiceMock: APIService { + public var mockAuthHeaders: [String: String] + public var mockAPICallSuccessResult: Any? + public var mockAPICallError: APIServiceError? + + public init(mockAuthHeaders: [String: String], mockAPICallSuccessResult: Any? = nil, mockAPICallError: APIServiceError? = nil) { + self.mockAuthHeaders = mockAuthHeaders + self.mockAPICallSuccessResult = mockAPICallSuccessResult + self.mockAPICallError = mockAPICallError + } + + // swiftlint:disable force_cast + public func executeAPICall(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result where T: Decodable { + if let success = mockAPICallSuccessResult { + return .success(success as! T) + } else if let error = mockAPICallError { + return .failure(error) + } + return .failure(.unknownServerError) + } + // swiftlint:enable force_cast + + public func makeAuthorizationHeader(for token: String) -> [String: String] { + return mockAuthHeaders + } +} diff --git a/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift new file mode 100644 index 000000000..3284b616b --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift @@ -0,0 +1,53 @@ +// +// AuthEndpointServiceMock.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 Subscription + +public struct AuthEndpointServiceMock: AuthEndpointService { + public var accessTokenResult: Result? + public var validateTokenResult: Result? + public var createAccountResult: Result? + public var storeLoginResult: Result? + + public init(accessTokenResult: Result?, + validateTokenResult: Result?, + createAccountResult: Result?, + storeLoginResult: Result?) { + self.accessTokenResult = accessTokenResult + self.validateTokenResult = validateTokenResult + self.createAccountResult = createAccountResult + self.storeLoginResult = storeLoginResult + } + + public func getAccessToken(token: String) async -> Result { + accessTokenResult! + } + + public func validateToken(accessToken: String) async -> Result { + validateTokenResult! + } + + public func createAccount(emailAccessToken: String?) async -> Result { + createAccountResult! + } + + public func storeLogin(signature: String) async -> Result { + storeLoginResult! + } +} diff --git a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift new file mode 100644 index 000000000..0890fe30d --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift @@ -0,0 +1,61 @@ +// +// SubscriptionEndpointServiceMock.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 Subscription + +public struct SubscriptionEndpointServiceMock: SubscriptionEndpointService { + public var getSubscriptionResult: Result? + public var getProductsResult: Result<[GetProductsItem], APIServiceError>? + public var getCustomerPortalURLResult: Result? + public var confirmPurchaseResult: Result? + + public init(getSubscriptionResult: Result? = nil, + getProductsResult: Result<[GetProductsItem], APIServiceError>? = nil, + getCustomerPortalURLResult: Result? = nil, + confirmPurchaseResult: Result? = nil) { + self.getSubscriptionResult = getSubscriptionResult + self.getProductsResult = getProductsResult + self.getCustomerPortalURLResult = getCustomerPortalURLResult + self.confirmPurchaseResult = confirmPurchaseResult + } + + public func updateCache(with subscription: Subscription) { + + } + + public func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result { + getSubscriptionResult! + } + + public func signOut() { + + } + + public func getProducts() async -> Result<[GetProductsItem], APIServiceError> { + getProductsResult! + } + + public func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result { + getCustomerPortalURLResult! + } + + public func confirmPurchase(accessToken: String, signature: String) async -> Result { + confirmPurchaseResult! + } +} diff --git a/Sources/SubscriptionTestingUtilities/AccountKeychainStorageMock.swift b/Sources/SubscriptionTestingUtilities/AccountKeychainStorageMock.swift new file mode 100644 index 000000000..bf48ee36d --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/AccountKeychainStorageMock.swift @@ -0,0 +1,73 @@ +// +// AccountKeychainStorageMock.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 Subscription + +public class AccountKeychainStorageMock: AccountStoring { + public var authToken: String? + public var accessToken: String? + public var email: String? + public var externalID: String? + + public init(authToken: String? = nil, accessToken: String? = nil, email: String? = nil, externalID: String? = nil) { + self.authToken = authToken + self.accessToken = accessToken + self.email = email + self.externalID = externalID + } + + public func getAuthToken() throws -> String? { + authToken + } + + public func store(authToken: String) throws { + self.authToken = authToken + } + + public func getAccessToken() throws -> String? { + accessToken + } + + public func store(accessToken: String) throws { + self.accessToken = accessToken + } + + public func getEmail() throws -> String? { + email + } + + public func store(email: String?) throws { + self.email = email + } + + public func getExternalID() throws -> String? { + externalID + } + + public func store(externalID: String?) throws { + self.externalID = externalID + } + + public func clearAuthenticationState() throws { + authToken = nil + accessToken = nil + email = nil + externalID = nil + } +} diff --git a/Sources/SubscriptionTestingUtilities/AccountManagerMock.swift b/Sources/SubscriptionTestingUtilities/AccountManagerMock.swift index 070f827b5..4755d9e49 100644 --- a/Sources/SubscriptionTestingUtilities/AccountManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/AccountManagerMock.swift @@ -19,23 +19,19 @@ import Foundation import Subscription -public final class AccountManagerMock: AccountManaging { - +public final class AccountManagerMock: AccountManager { public var delegate: AccountManagerKeychainAccessDelegate? - public var isUserAuthenticated: Bool public var accessToken: String? public var authToken: String? public var email: String? public var externalID: String? public init(delegate: AccountManagerKeychainAccessDelegate? = nil, - isUserAuthenticated: Bool, accessToken: String? = nil, authToken: String? = nil, email: String? = nil, externalID: String? = nil) { self.delegate = delegate - self.isUserAuthenticated = isUserAuthenticated self.accessToken = accessToken self.authToken = authToken self.email = email @@ -62,11 +58,7 @@ public final class AccountManagerMock: AccountManaging { } - public func hasEntitlement(for entitlement: Entitlement.ProductName, cachePolicy: CachePolicy) async -> Result { - return .success(true) - } - - public func hasEntitlement(for entitlement: Entitlement.ProductName) async -> Result { + public func hasEntitlement(forProductName productName: Entitlement.ProductName, cachePolicy: APICachePolicy) async -> Result { return .success(true) } @@ -74,7 +66,7 @@ public final class AccountManagerMock: AccountManaging { } - public func fetchEntitlements(cachePolicy: CachePolicy) async -> Result<[Entitlement], Error> { + public func fetchEntitlements(cachePolicy: APICachePolicy) async -> Result<[Entitlement], Error> { return .success([]) } @@ -91,10 +83,6 @@ public final class AccountManagerMock: AccountManaging { } } - public func refreshSubscriptionAndEntitlements() async { - - } - public func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool { return true } diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift new file mode 100644 index 000000000..79bd4ba9c --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift @@ -0,0 +1,32 @@ +// +// AppStoreAccountManagementFlowMock.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 Subscription + +public class AppStoreAccountManagementFlowMock: AppStoreAccountManagementFlow { + public var refreshAuthTokenIfNeededResult: Result + + public init(refreshAuthTokenIfNeededResult: Result) { + self.refreshAuthTokenIfNeededResult = refreshAuthTokenIfNeededResult + } + + public func refreshAuthTokenIfNeeded() async -> Result { + refreshAuthTokenIfNeededResult + } +} diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift new file mode 100644 index 000000000..b471de47c --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift @@ -0,0 +1,38 @@ +// +// AppStorePurchaseFlowMock.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 Subscription + +public class AppStorePurchaseFlowMock: AppStorePurchaseFlow { + public var purchaseSubscriptionResult: Result + public var completeSubscriptionPurchaseResult: Result + + public init(purchaseSubscriptionResult: Result, completeSubscriptionPurchaseResult: Result) { + self.purchaseSubscriptionResult = purchaseSubscriptionResult + self.completeSubscriptionPurchaseResult = completeSubscriptionPurchaseResult + } + + public func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result { + purchaseSubscriptionResult + } + + public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS) async -> Result { + completeSubscriptionPurchaseResult + } +} diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift new file mode 100644 index 000000000..55a7ba094 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift @@ -0,0 +1,32 @@ +// +// AppStoreRestoreFlowMock.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 Subscription + +public class AppStoreRestoreFlowMock: AppStoreRestoreFlow { + public var restoreAccountFromPastPurchaseResult: Result + + public init(restoreAccountFromPastPurchaseResult: Result) { + self.restoreAccountFromPastPurchaseResult = restoreAccountFromPastPurchaseResult + } + + public func restoreAccountFromPastPurchase() async -> Result { + restoreAccountFromPastPurchaseResult + } +} diff --git a/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift new file mode 100644 index 000000000..8f7b0b5f1 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift @@ -0,0 +1,42 @@ +// +// StripePurchaseFlowMock.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 Subscription + +public class StripePurchaseFlowMock: StripePurchaseFlow { + public var subscriptionOptionsResult: Result + public var prepareSubscriptionPurchaseResult: Result + + public init(subscriptionOptionsResult: Result, prepareSubscriptionPurchaseResult: Result) { + self.subscriptionOptionsResult = subscriptionOptionsResult + self.prepareSubscriptionPurchaseResult = prepareSubscriptionPurchaseResult + } + + public func subscriptionOptions() async -> Result { + subscriptionOptionsResult + } + + public func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result { + prepareSubscriptionPurchaseResult + } + + public func completeSubscriptionPurchase() async { + + } +} diff --git a/Sources/SubscriptionTestingUtilities/README.md b/Sources/SubscriptionTestingUtilities/README.md new file mode 100644 index 000000000..e73f025a3 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/README.md @@ -0,0 +1,25 @@ +# Subscription manual smoke tests + +### Common: +- Search for "privacy pro" in DDG and open the /pro link: it should intercept and open the purchase flow +- (on Mac) with active subscription, open welcome page, DBP, ITR and FAQ tabs, sign out: all tabs are closed automatically + +### App store: +- Buy +- Remove from device +- Cancel in the middle of buying +- Simulate an error while buying (in code) +- Restore successfully +- Restore inexistent subscription +- After purchase, sign out and try to buy again +- Add another email +- Manage email > Remove +- When purchased, remove from device and attempt purchase again: shows alert suggesting to restore +- Restore when the subscription is expired: triggers the "view plans" prompt + +### Stripe: +- Buy +- Remove +- Restore successfully +- Add another email +- Manage email > Remove diff --git a/Sources/SubscriptionTestingUtilities/StorePurchaseManagerMock.swift b/Sources/SubscriptionTestingUtilities/StorePurchaseManagerMock.swift new file mode 100644 index 000000000..9829fb2df --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/StorePurchaseManagerMock.swift @@ -0,0 +1,75 @@ +// +// StorePurchaseManagerMock.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 Subscription + +public struct StorePurchaseManagerMock: StorePurchaseManager { + public var purchasedProductIDs: [String] + public var purchaseQueue: [String] + public var areProductsAvailable: Bool + public var subscriptionOptionsResult: SubscriptionOptions? + public var syncAppleIDAccountResultError: Error? + public var mostRecentTransactionResult: String? + public var hasActiveSubscriptionResult: Bool + public var purchaseSubscriptionResult: Result + + public init(purchasedProductIDs: [String], + purchaseQueue: [String], + areProductsAvailable: Bool, + subscriptionOptionsResult: SubscriptionOptions? = nil, + syncAppleIDAccountResultError: Error? = nil, + mostRecentTransactionResult: String? = nil, + hasActiveSubscriptionResult: Bool, + purchaseSubscriptionResult: Result) { + self.purchasedProductIDs = purchasedProductIDs + self.purchaseQueue = purchaseQueue + self.areProductsAvailable = areProductsAvailable + self.subscriptionOptionsResult = subscriptionOptionsResult + self.syncAppleIDAccountResultError = syncAppleIDAccountResultError + self.mostRecentTransactionResult = mostRecentTransactionResult + self.hasActiveSubscriptionResult = hasActiveSubscriptionResult + self.purchaseSubscriptionResult = purchaseSubscriptionResult + } + + public func subscriptionOptions() async -> SubscriptionOptions? { + subscriptionOptionsResult + } + + public func syncAppleIDAccount() async throws { + if let syncAppleIDAccountResultError { + throw syncAppleIDAccountResultError + } + } + + public func updateAvailableProducts() async { } + + public func updatePurchasedProducts() async { } + + public func mostRecentTransaction() async -> String? { + mostRecentTransactionResult + } + + public func hasActiveSubscription() async -> Bool { + hasActiveSubscriptionResult + } + + public func purchaseSubscription(with identifier: String, externalID: String) async -> Result { + purchaseSubscriptionResult + } +} diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift index ca2566eaf..46fdc77e0 100644 --- a/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/SubscriptionManagerMock.swift @@ -19,15 +19,24 @@ import Foundation @testable import Subscription -public final class SubscriptionManagerMock: SubscriptionManaging { +public final class SubscriptionManagerMock: SubscriptionManager { + public var accountManager: AccountManager + public var subscriptionEndpointService: SubscriptionEndpointService + public var authEndpointService: AuthEndpointService + + public static var storedEnvironment: SubscriptionEnvironment? + public static func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment? { + return storedEnvironment + } + + public static func save(subscriptionEnvironment: SubscriptionEnvironment, userDefaults: UserDefaults) { + storedEnvironment = subscriptionEnvironment + } - public var accountManager: AccountManaging - public var subscriptionService: SubscriptionService - public var authService: AuthService public var currentEnvironment: SubscriptionEnvironment public var canPurchase: Bool - public func storePurchaseManager() -> StorePurchaseManaging { + public func storePurchaseManager() -> StorePurchaseManager { internalStorePurchaseManager } @@ -35,7 +44,7 @@ public final class SubscriptionManagerMock: SubscriptionManaging { } - public func updateSubscriptionStatus(completion: @escaping (Bool) -> Void) { + public func refreshCachedSubscriptionAndEntitlements(completion: @escaping (Bool) -> Void) { completion(true) } @@ -43,15 +52,15 @@ public final class SubscriptionManagerMock: SubscriptionManaging { type.subscriptionURL(environment: currentEnvironment.serviceEnvironment) } - public init(accountManager: AccountManaging, - subscriptionService: SubscriptionService, - authService: AuthService, - storePurchaseManager: StorePurchaseManaging, + public init(accountManager: AccountManager, + subscriptionEndpointService: SubscriptionEndpointService, + authEndpointService: AuthEndpointService, + storePurchaseManager: StorePurchaseManager, currentEnvironment: SubscriptionEnvironment, canPurchase: Bool) { self.accountManager = accountManager - self.subscriptionService = subscriptionService - self.authService = authService + self.subscriptionEndpointService = subscriptionEndpointService + self.authEndpointService = authEndpointService self.internalStorePurchaseManager = storePurchaseManager self.currentEnvironment = currentEnvironment self.canPurchase = canPurchase @@ -59,5 +68,5 @@ public final class SubscriptionManagerMock: SubscriptionManaging { // MARK: - - let internalStorePurchaseManager: StorePurchaseManaging + let internalStorePurchaseManager: StorePurchaseManager } diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionMockFactory.swift b/Sources/SubscriptionTestingUtilities/SubscriptionMockFactory.swift new file mode 100644 index 000000000..33fa98ec2 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/SubscriptionMockFactory.swift @@ -0,0 +1,90 @@ +// +// SubscriptionMockFactory.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 +@testable import Subscription + +/// Provides all mocks needed for testing subscription initialised with positive outcomes and basic configurations. All mocks can be partially reconfigured with failures or incorrect data +public struct SubscriptionMockFactory { + + public static let email = "5p2d4sx1@duck.com" // Some sandbox account + public static let externalId = UUID().uuidString + public static let accountManager = AccountManagerMock(email: email, + externalID: externalId) + /// No mock result or error configured, that must be done per-test basis + public static let apiService = APIServiceMock(mockAuthHeaders: [:]) + public static let subscription = Subscription(productId: UUID().uuidString, + name: "Subscription test #1", + billingPeriod: .monthly, + startedAt: Date(), + expiresOrRenewsAt: Date().addingTimeInterval(TimeInterval.days(+30)), + platform: .apple, + status: .autoRenewable) + public static let productsItems: [GetProductsItem] = [GetProductsItem(productId: subscription.productId, + productLabel: subscription.name, + billingPeriod: subscription.billingPeriod.rawValue, + price: "0.99", + currency: "USD")] + public static let customerPortalURL = GetCustomerPortalURLResponse(customerPortalUrl: "https://duckduckgo.com") + public static let entitlements = [Entitlement(product: .dataBrokerProtection), + Entitlement(product: .identityTheftRestoration), + Entitlement(product: .networkProtection)] + public static let confirmPurchase = ConfirmPurchaseResponse(email: email, + entitlements: entitlements, + subscription: subscription) + public static let subscriptionEndpointService = SubscriptionEndpointServiceMock(getSubscriptionResult: .success(subscription), + getProductsResult: .success(productsItems), + getCustomerPortalURLResult: .success(customerPortalURL), + confirmPurchaseResult: .success(confirmPurchase)) + public static let authToken = "someAuthToken" + + private static let validateTokenResponse = ValidateTokenResponse(account: ValidateTokenResponse.Account(email: email, + entitlements: entitlements, + externalID: UUID().uuidString)) + public static let authEndpointService = AuthEndpointServiceMock(accessTokenResult: .success(AccessTokenResponse(accessToken: "SomeAccessToken")), + validateTokenResult: .success(validateTokenResponse), + createAccountResult: .success(CreateAccountResponse(authToken: authToken, + externalID: "?", + status: "?")), + storeLoginResult: .success(StoreLoginResponse(authToken: authToken, + email: email, + externalID: UUID().uuidString, + id: 1, + status: "?"))) + + public static let storePurchaseManager = StorePurchaseManagerMock(purchasedProductIDs: [UUID().uuidString], + purchaseQueue: ["?"], + areProductsAvailable: true, + subscriptionOptionsResult: SubscriptionOptions.empty, + syncAppleIDAccountResultError: nil, + mostRecentTransactionResult: nil, + hasActiveSubscriptionResult: false, + purchaseSubscriptionResult: .success("someTransactionJWS")) + + public static let currentEnvironment = SubscriptionEnvironment(serviceEnvironment: .staging, + purchasePlatform: .appStore) + + public static let subscriptionManager = SubscriptionManagerMock(accountManager: accountManager, + subscriptionEndpointService: subscriptionEndpointService, + authEndpointService: authEndpointService, + storePurchaseManager: storePurchaseManager, + currentEnvironment: currentEnvironment, + canPurchase: true) + + public static let appStoreRestoreFlow = AppStoreRestoreFlowMock(restoreAccountFromPastPurchaseResult: .success(Void())) +} diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionTokenKeychainStorageMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionTokenKeychainStorageMock.swift new file mode 100644 index 000000000..8e4e61912 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/SubscriptionTokenKeychainStorageMock.swift @@ -0,0 +1,40 @@ +// +// SubscriptionTokenKeychainStorageMock.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 Subscription + +public class SubscriptionTokenKeychainStorageMock: SubscriptionTokenStoring { + public var accessToken: String? + + public init(accessToken: String? = nil) { + self.accessToken = accessToken + } + + public func getAccessToken() throws -> String? { + accessToken + } + + public func store(accessToken: String) throws { + self.accessToken = accessToken + } + + public func removeAccessToken() throws { + accessToken = nil + } +} diff --git a/Sources/WireGuardC/include/WireGuardC.h b/Sources/WireGuardC/include/WireGuardC.h index 36218b9c5..34a39d139 100644 --- a/Sources/WireGuardC/include/WireGuardC.h +++ b/Sources/WireGuardC/include/WireGuardC.h @@ -3,6 +3,7 @@ #include "key.h" #include "x25519.h" +#include /* From */ #define CTLIOCGINFO 0xc0644e03UL diff --git a/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift b/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift index ae1494e3d..ad675dada 100644 --- a/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift +++ b/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift @@ -29,6 +29,7 @@ final class AutofillPixelReporterTests: XCTestCase { static var events: [AutofillPixelEvent] = [] static var loginsParam: String? static var creditCardsParam: String? + static var identitiesParam: String? public init() { super.init { event, _, param, _ in @@ -38,6 +39,8 @@ final class AutofillPixelReporterTests: XCTestCase { Self.loginsParam = param?[AutofillPixelEvent.Parameter.countBucket] case .autofillCreditCardsStacked: Self.creditCardsParam = param?[AutofillPixelEvent.Parameter.countBucket] + case .autofillIdentitiesStacked: + Self.identitiesParam = param?[AutofillPixelEvent.Parameter.countBucket] default: break } @@ -90,19 +93,21 @@ final class AutofillPixelReporterTests: XCTestCase { XCTAssertEqual(MockEventMapping.events.count, 0) } - func testWhenFirstFillAndSearchDauIsTodayAndAccountsCountIsZeroThenThreeEventsAreFiredWithNoneParams() { + func testWhenFirstFillAndSearchDauIsTodayAndAccountsCountIsZeroThenFourEventsAreFiredWithNoneParams() { let autofillPixelReporter = createAutofillPixelReporter() autofillPixelReporter.resetStoreDefaults() setAutofillSearchDauDate(daysAgo: 0) NotificationCenter.default.post(name: .autofillFillEvent, object: nil) - XCTAssertEqual(MockEventMapping.events.count, 3) + XCTAssertEqual(MockEventMapping.events.count, 4) XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue) } func testWhenFirstSearchDauAndAutofillDisabledAndFillDateIsNotTodayAndAccountsCountIsZeroThenOneEventIsFired() throws { @@ -155,7 +160,7 @@ final class AutofillPixelReporterTests: XCTestCase { XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn)) } - func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsZeroThenFourEventsAreFiredWithNoneParams() { + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsZeroThenFiveEventsAreFiredWithNoneParams() { let autofillPixelReporter = createAutofillPixelReporter() autofillPixelReporter.resetStoreDefaults() createAccountsInVault(count: 0) @@ -163,16 +168,18 @@ final class AutofillPixelReporterTests: XCTestCase { NotificationCenter.default.post(name: .searchDAU, object: nil) NotificationCenter.default.post(name: .autofillFillEvent, object: nil) - XCTAssertEqual(MockEventMapping.events.count, 4) + XCTAssertEqual(MockEventMapping.events.count, 5) XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn)) XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue) } - func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsThreeThenFourEventsAreFiredWithCorrectParams() { + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsThreeThenFiveEventsAreFiredWithCorrectParams() { let autofillPixelReporter = createAutofillPixelReporter() autofillPixelReporter.resetStoreDefaults() createAccountsInVault(count: 3) @@ -180,16 +187,18 @@ final class AutofillPixelReporterTests: XCTestCase { NotificationCenter.default.post(name: .searchDAU, object: nil) NotificationCenter.default.post(name: .autofillFillEvent, object: nil) - XCTAssertEqual(MockEventMapping.events.count, 4) + XCTAssertEqual(MockEventMapping.events.count, 5) XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn)) XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.few.rawValue) XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue) } - func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsTenThenFiveEventsAreFiredWithCorrectParams() { + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsTenThenSixEventsAreFiredWithCorrectParams() { let autofillPixelReporter = createAutofillPixelReporter() autofillPixelReporter.resetStoreDefaults() createAccountsInVault(count: 10) @@ -197,17 +206,19 @@ final class AutofillPixelReporterTests: XCTestCase { NotificationCenter.default.post(name: .searchDAU, object: nil) NotificationCenter.default.post(name: .autofillFillEvent, object: nil) - XCTAssertEqual(MockEventMapping.events.count, 5) + XCTAssertEqual(MockEventMapping.events.count, 6) XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn)) XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser)) XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.some.rawValue) XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue) } - func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsElevenThenFiveEventsAreFiredWithManyParam() { + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsElevenThenSixEventsAreFiredWithManyParam() { let autofillPixelReporter = createAutofillPixelReporter() autofillPixelReporter.resetStoreDefaults() createAccountsInVault(count: 11) @@ -215,17 +226,19 @@ final class AutofillPixelReporterTests: XCTestCase { NotificationCenter.default.post(name: .searchDAU, object: nil) NotificationCenter.default.post(name: .autofillFillEvent, object: nil) - XCTAssertEqual(MockEventMapping.events.count, 5) + XCTAssertEqual(MockEventMapping.events.count, 6) XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn)) XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser)) XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.many.rawValue) XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue) } - func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsFortyThenFiveEventsAreFiredWithCorrectParams() { + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsFortyThenSixEventsAreFiredWithCorrectParams() { let autofillPixelReporter = createAutofillPixelReporter() autofillPixelReporter.resetStoreDefaults() createAccountsInVault(count: 40) @@ -233,16 +246,19 @@ final class AutofillPixelReporterTests: XCTestCase { NotificationCenter.default.post(name: .searchDAU, object: nil) NotificationCenter.default.post(name: .autofillFillEvent, object: nil) - XCTAssertEqual(MockEventMapping.events.count, 5) + XCTAssertEqual(MockEventMapping.events.count, 6) XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn)) XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser)) XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.many.rawValue) XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue) } - func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsFiftyThenFiveEventsAreFiredWithCorrectParams() { + func testWhenFirstSearchDauAndThenFirstFillAndAccountsCountIsFiftyThenSixEventsAreFiredWithCorrectParams() { let autofillPixelReporter = createAutofillPixelReporter() autofillPixelReporter.resetStoreDefaults() createAccountsInVault(count: 50) @@ -250,17 +266,19 @@ final class AutofillPixelReporterTests: XCTestCase { NotificationCenter.default.post(name: .searchDAU, object: nil) NotificationCenter.default.post(name: .autofillFillEvent, object: nil) - XCTAssertEqual(MockEventMapping.events.count, 5) + XCTAssertEqual(MockEventMapping.events.count, 6) XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn)) XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillEnabledUser)) XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.lots.rawValue) XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue) } - func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsOneThenFourEventsAreFiredWithCorrectParams() { + func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsOneThenFiveEventsAreFiredWithCorrectParams() { let autofillPixelReporter = createAutofillPixelReporter() autofillPixelReporter.resetStoreDefaults() createAccountsInVault(count: 0) @@ -269,16 +287,18 @@ final class AutofillPixelReporterTests: XCTestCase { NotificationCenter.default.post(name: .searchDAU, object: nil) NotificationCenter.default.post(name: .autofillFillEvent, object: nil) - XCTAssertEqual(MockEventMapping.events.count, 4) + XCTAssertEqual(MockEventMapping.events.count, 5) XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn)) XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.some.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue) } - func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsThreeThenFourEventsAreFiredWithCorrectParams() { + func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsThreeThenFiveEventsAreFiredWithCorrectParams() { let autofillPixelReporter = createAutofillPixelReporter() autofillPixelReporter.resetStoreDefaults() createAccountsInVault(count: 0) @@ -287,16 +307,18 @@ final class AutofillPixelReporterTests: XCTestCase { NotificationCenter.default.post(name: .searchDAU, object: nil) NotificationCenter.default.post(name: .autofillFillEvent, object: nil) - XCTAssertEqual(MockEventMapping.events.count, 4) + XCTAssertEqual(MockEventMapping.events.count, 5) XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn)) XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.some.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue) } - func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsFourThenFourEventsAreFiredWithCorrectParams() { + func testWhenFirstSearchDauAndThenFirstFillAndCreditCardsCountIsFourThenFiveEventsAreFiredWithCorrectParams() { let autofillPixelReporter = createAutofillPixelReporter() autofillPixelReporter.resetStoreDefaults() createAccountsInVault(count: 0) @@ -305,15 +327,95 @@ final class AutofillPixelReporterTests: XCTestCase { NotificationCenter.default.post(name: .searchDAU, object: nil) NotificationCenter.default.post(name: .autofillFillEvent, object: nil) - XCTAssertEqual(MockEventMapping.events.count, 4) + XCTAssertEqual(MockEventMapping.events.count, 5) XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) XCTAssertTrue(MockEventMapping.events.contains(.autofillToggledOn)) XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.many.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.none.rawValue) + } + + func testWhenFirstSearchDauAndThenFirstFillAndIdentitiesCountIsOneThenFiveEventsAreFiredWithCorrectParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 0) + createCreditCardsInVault(count: 0) + createIdentitiesInVault(count: 1) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 5) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.some.rawValue) } + func testWhenFirstSearchDauAndThenFirstFillAndIdentitiesCountIsFourThenFiveEventsAreFiredWithCorrectParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 0) + createCreditCardsInVault(count: 0) + createIdentitiesInVault(count: 4) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 5) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.some.rawValue) + } + + func testWhenFirstSearchDauAndThenFirstFillAndIdentitiesCountIsFiveThenFiveEventsAreFiredWithCorrectParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 0) + createIdentitiesInVault(count: 5) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 5) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.many.rawValue) + } + + func testWhenFirstSearchDauAndThenFirstFillAndIdentitiesCountIsTwelveThenFiveEventsAreFiredWithCorrectParams() { + let autofillPixelReporter = createAutofillPixelReporter() + autofillPixelReporter.resetStoreDefaults() + createAccountsInVault(count: 0) + createIdentitiesInVault(count: 12) + + NotificationCenter.default.post(name: .searchDAU, object: nil) + NotificationCenter.default.post(name: .autofillFillEvent, object: nil) + + XCTAssertEqual(MockEventMapping.events.count, 5) + XCTAssertTrue(MockEventMapping.events.contains(.autofillActiveUser)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillLoginsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillCreditCardsStacked)) + XCTAssertTrue(MockEventMapping.events.contains(.autofillIdentitiesStacked)) + XCTAssertEqual(MockEventMapping.loginsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.creditCardsParam, AutofillPixelReporter.BucketName.none.rawValue) + XCTAssertEqual(MockEventMapping.identitiesParam, AutofillPixelReporter.BucketName.lots.rawValue) + } + func testWhenSubsequentFillAndSearchDauIsNotTodayThenNoEventsAreFired() { let autofillPixelReporter = createAutofillPixelReporter() autofillPixelReporter.resetStoreDefaults() @@ -442,6 +544,23 @@ final class AutofillPixelReporterTests: XCTestCase { } } + private func createIdentitiesInVault(count: Int) { + let identities = try? vault.identities() + for identity in identities ?? [] { + if let id = identity.id { + try? vault.deleteIdentityFor(identityId: id) + } + } + + for i in 0.. Int { + return _identities.count + } + func identityForIdentityId(_ identityId: Int64) throws -> SecureVaultModels.Identity? { return _identities[identityId] } diff --git a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift index ed65ea76e..a20d3dd35 100644 --- a/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift +++ b/Tests/BrowserServicesKitTests/Subscription/SubscriptionFeatureAvailabilityTests.swift @@ -19,7 +19,7 @@ import XCTest import Common import Combine -@testable import Subscription +import Subscription @testable import BrowserServicesKit final class SubscriptionFeatureAvailabilityTests: XCTestCase { diff --git a/Tests/CommonTests/Extensions/StringExtensionTests.swift b/Tests/CommonTests/Extensions/StringExtensionTests.swift index cc341fdfa..93989b838 100644 --- a/Tests/CommonTests/Extensions/StringExtensionTests.swift +++ b/Tests/CommonTests/Extensions/StringExtensionTests.swift @@ -88,4 +88,9 @@ final class StringExtensionTests: XCTestCase { XCTAssertEqual("about:blank#navlink1".url!.absoluteString.droppingHashedSuffix(), "about:blank") } + func testToIPv4Host() { + XCTAssertEqual("1.1.1.1".toIPv4Host, "1.1.1.1") + XCTAssertEqual("1".toIPv4Host, "0.0.0.1") + XCTAssertEqual("1.2".toIPv4Host, "1.0.0.2") + } } diff --git a/Tests/HistoryTests/HistoryCoordinatorTests.swift b/Tests/HistoryTests/HistoryCoordinatorTests.swift index 968f203e9..4d41286d8 100644 --- a/Tests/HistoryTests/HistoryCoordinatorTests.swift +++ b/Tests/HistoryTests/HistoryCoordinatorTests.swift @@ -23,7 +23,6 @@ import Persistence import Common @testable import History -@MainActor class HistoryCoordinatorTests: XCTestCase { var location: URL! diff --git a/Tests/NavigationTests/SameDocumentNavigationTests.swift b/Tests/NavigationTests/SameDocumentNavigationTests.swift index 4d65002e2..cc15637f6 100644 --- a/Tests/NavigationTests/SameDocumentNavigationTests.swift +++ b/Tests/NavigationTests/SameDocumentNavigationTests.swift @@ -442,9 +442,9 @@ class SameDocumentNavigationTests: DistributedNavigationDelegateTestsBase { .response(Nav(action: navAct(1), .responseReceived, resp: .resp(urls.local, data.sessionStatePushClientRedirectData.count, headers: .default + ["Content-Type": "text/html"]))), .didCommit(Nav(action: navAct(1), .responseReceived, resp: resp(0), .committed)), - .didSameDocumentNavigation(Nav(action: NavAction(req(urls.localHashed1, [:]), .sameDocumentNavigation(.sessionStatePush), from: history[1], src: main(urls.localHashed1)), .finished), 1), + .didSameDocumentNavigation(Nav(action: NavAction(req(urls.localHashed1, [:]), .sameDocumentNavigation(.sessionStatePush), from: history[1], src: main(urls.localHashed1)), .finished, isCurrent: false), 1), - .didFinish(Nav(action: navAct(1), .finished, resp: resp(0), .committed, isCurrent: false)), + .didFinish(Nav(action: navAct(1), .finished, resp: resp(0), .committed, isCurrent: true)), ]) } diff --git a/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift b/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift index fca554d4a..f2760c7ba 100644 --- a/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift +++ b/Tests/NetworkProtectionTests/NetworkProtectionDeviceManagerTests.swift @@ -215,6 +215,7 @@ extension NetworkProtectionDeviceManager { selectionMethod: selectionMethod, includedRoutes: [], excludedRoutes: [], + dnsSettings: .default, isKillSwitchEnabled: false, regenerateKey: regenerateKey ) diff --git a/Tests/NetworkProtectionTests/Recovery/FailureRecoveryHandlerTests.swift b/Tests/NetworkProtectionTests/Recovery/FailureRecoveryHandlerTests.swift index b7945ed8d..01968a6f2 100644 --- a/Tests/NetworkProtectionTests/Recovery/FailureRecoveryHandlerTests.swift +++ b/Tests/NetworkProtectionTests/Recovery/FailureRecoveryHandlerTests.swift @@ -61,6 +61,7 @@ final class FailureRecoveryHandlerTests: XCTestCase { to: server, includedRoutes: expectedIncludedRoutes, excludedRoutes: expectedExcludedRoutes, + dnsSettings: .default, isKillSwitchEnabled: expectedKillSwitchEnabledValue ) {_ in } guard let spyGenerateTunnelConfiguration = deviceManager.spyGenerateTunnelConfiguration else { @@ -128,6 +129,7 @@ final class FailureRecoveryHandlerTests: XCTestCase { to: .mockRegisteredServer, includedRoutes: [], excludedRoutes: [], + dnsSettings: .default, isKillSwitchEnabled: false ) {_ in } @@ -314,6 +316,7 @@ final class FailureRecoveryHandlerTests: XCTestCase { to: .mockRegisteredServer, includedRoutes: [], excludedRoutes: [], + dnsSettings: .default, isKillSwitchEnabled: false ) {_ in } } @@ -331,6 +334,7 @@ final class FailureRecoveryHandlerTests: XCTestCase { to: .mockRegisteredServer, includedRoutes: [], excludedRoutes: [], + dnsSettings: .default, isKillSwitchEnabled: false ) { _ in throw WireGuardAdapterError.startWireGuardBackend(0) @@ -349,7 +353,7 @@ final class FailureRecoveryHandlerTests: XCTestCase { var newConfigResult: NetworkProtectionDeviceManagement.GenerateTunnelConfigurationResult? - await failureRecoveryHandler.attemptRecovery(to: lastServer, includedRoutes: [], excludedRoutes: [], isKillSwitchEnabled: true) { configResult in + await failureRecoveryHandler.attemptRecovery(to: lastServer, includedRoutes: [], excludedRoutes: [], dnsSettings: .default, isKillSwitchEnabled: true) { configResult in newConfigResult = configResult } return newConfigResult diff --git a/Tests/PrivacyDashboardTests/ToggleReportsManagerTests.swift b/Tests/PrivacyDashboardTests/ToggleReportsManagerTests.swift new file mode 100644 index 000000000..aab0b5b49 --- /dev/null +++ b/Tests/PrivacyDashboardTests/ToggleReportsManagerTests.swift @@ -0,0 +1,223 @@ +// +// ToggleReportsManagerTests.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. +// + +@testable import PrivacyDashboard +import XCTest + +final class MockToggleReportsFeature: ToggleReporting { + + var isEnabled: Bool = true + var isDismissLogicEnabled: Bool = true + var dismissInterval: TimeInterval = 60 * 60 * 48 + var isPromptLimitLogicEnabled: Bool = true + var promptInterval: TimeInterval = 60 * 60 * 48 + var maxPromptCount: Int = 3 + +} + +final class MockToggleReportsStore: ToggleReportsStoring { + + var dismissedAt: Date? + var promptWindowStart: Date? + var promptCount: Int = 0 + +} + +final class ToggleReportsManagerTests: XCTestCase { + + // MARK: - Dismissal logic + + func testShouldShowToggleReportWhenNoDismissedDate() { + let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: MockToggleReportsStore()) + XCTAssertTrue(manager.shouldShowToggleReport) + } + + func testRecordDismissal() { + let store = MockToggleReportsStore() + var manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + + let now = Date() + manager.recordDismissal(date: now) + XCTAssertEqual(store.dismissedAt, now) + } + + func testShouldShowToggleReportWhenDismissedDateIsMoreThan48HoursAgo() { + let store = MockToggleReportsStore() + let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let pastDate = Date(timeIntervalSinceNow: -49 * 60 * 60) // 49 hours ago + store.dismissedAt = pastDate + + XCTAssertTrue(manager.shouldShowToggleReport(date: Date())) + } + + func testShouldNotShowToggleReportWhenDismissedDateIsLessThan48HoursAgo() { + let store = MockToggleReportsStore() + let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + let recentDate = Date(timeIntervalSinceNow: -47 * 60 * 60) // 47 hours ago + store.dismissedAt = recentDate + + XCTAssertFalse(manager.shouldShowToggleReport(date: Date())) + } + + // MARK: - Prompt logic + + func testShouldShowToggleReportWhenPromptLimitNotReached() { + let store = MockToggleReportsStore() + store.promptCount = 2 + let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + + XCTAssertTrue(manager.shouldShowToggleReport) + } + + func testShouldNotShowToggleReportWhenPromptLimitReachedAndPromptIntervalIsLessThan48HoursAgo() { + let store = MockToggleReportsStore() + store.promptCount = 3 + store.promptWindowStart = Date().addingTimeInterval(-24 * 60 * 60) + let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + + XCTAssertFalse(manager.shouldShowToggleReport) + } + + func testShouldShowToggleReportWhenPromptLimitReachedButPromptIntervalIsMoreThan48HoursAgo() { + let store = MockToggleReportsStore() + store.promptCount = 3 + store.promptWindowStart = Date().addingTimeInterval(-72 * 60 * 60) + let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + + XCTAssertTrue(manager.shouldShowToggleReport) + } + + // MARK: - Rolling window + + func testRecordPromptWhenWithinWindowShouldIncrementCount() { + let store = MockToggleReportsStore() + // Set initial window start within the last 48 hours + let windowStart = Date().addingTimeInterval(-24 * 60 * 60) + store.promptWindowStart = windowStart + store.promptCount = 1 + + var manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + // Record another prompt within the same window + manager.recordPrompt(date: Date()) + + XCTAssertEqual(store.promptCount, 2) + XCTAssertEqual(store.promptWindowStart, windowStart) + } + + func testRecordPromptWhenOutsideWindowShouldResetCount() { + let store = MockToggleReportsStore() + // Set initial window start more than 48 hours ago + store.promptWindowStart = Date().addingTimeInterval(-72 * 60 * 60) + store.promptCount = 2 + + var manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + // Record prompt outside the previous window + let now = Date() + manager.recordPrompt(date: now) + + XCTAssertEqual(store.promptCount, 1) + XCTAssertNotNil(store.promptWindowStart) + XCTAssertEqual(store.promptWindowStart, now) + } + + func testRecordPromptWhenNoWindowShouldStartNewWindow() { + let store = MockToggleReportsStore() + // No initial window start + store.promptWindowStart = nil + store.promptCount = 0 + + var manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + // Record prompt without previous window + let now = Date() + manager.recordPrompt(date: now) + + XCTAssertEqual(store.promptCount, 1) + XCTAssertNotNil(store.promptWindowStart) + XCTAssertEqual(store.promptWindowStart, now) + } + + // MARK: - Combination of both prompts and dismissal logic + + func testShouldNotShowToggleReportWhenDismissedLessThan48HoursAndPromptLimitNotReached() { + let store = MockToggleReportsStore() + store.dismissedAt = Date().addingTimeInterval(-24 * 60 * 60) + store.promptCount = 2 + let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + + XCTAssertFalse(manager.shouldShowToggleReport(date: Date())) + } + + func testShouldNotShowToggleReportWhenDismissedLessThan48HoursAndPromptLimitReached() { + let store = MockToggleReportsStore() + store.dismissedAt = Date().addingTimeInterval(-24 * 60 * 60) + store.promptCount = 3 + let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + + XCTAssertFalse(manager.shouldShowToggleReport(date: Date())) + } + + func testShouldNotShowToggleReportWhenDismissedMoreThan48HoursAndPromptLimitReached() { + let store = MockToggleReportsStore() + store.dismissedAt = Date().addingTimeInterval(-72 * 60 * 60) + store.promptWindowStart = Date().addingTimeInterval(-24 * 60 * 60) + store.promptCount = 3 + let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + + XCTAssertFalse(manager.shouldShowToggleReport(date: Date())) + } + + func testShouldShowToggleReportWhenDismissedMoreThan48HoursAndPromptLimitNotReached() { + let store = MockToggleReportsStore() + store.dismissedAt = Date().addingTimeInterval(-72 * 60 * 60) + store.promptCount = 2 + let manager = ToggleReportsManager(feature: MockToggleReportsFeature(), store: store) + + XCTAssertTrue(manager.shouldShowToggleReport(date: Date())) + } + + // MARK: - Feature + + func testShouldNotShowToggleReportWhenFeatureDisabled() { + let feature = MockToggleReportsFeature() + feature.isEnabled = false + let manager = ToggleReportsManager(feature: feature, store: MockToggleReportsStore()) + XCTAssertFalse(manager.shouldShowToggleReport) + } + + func testShouldShowToggleReportWhenPromptLimitReachedButPromptLimitLogicDisabled() { + let store = MockToggleReportsStore() + store.promptWindowStart = Date().addingTimeInterval(-24 * 60 * 60) + store.promptCount = 5 + let feature = MockToggleReportsFeature() + let manager = ToggleReportsManager(feature: feature, store: store) + XCTAssertFalse(manager.shouldShowToggleReport) + feature.isPromptLimitLogicEnabled = false + XCTAssertTrue(manager.shouldShowToggleReport) + } + + func testShouldShowToggleReportWhenDismissedDateIsLessThan48HoursAgoButDismissLogicDisabled() { + let store = MockToggleReportsStore() + store.dismissedAt = Date().addingTimeInterval(-47 * 60 * 60) + let feature = MockToggleReportsFeature() + let manager = ToggleReportsManager(feature: feature, store: store) + XCTAssertFalse(manager.shouldShowToggleReport) + feature.isDismissLogicEnabled = false + XCTAssertTrue(manager.shouldShowToggleReport) + } + +} diff --git a/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift b/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift new file mode 100644 index 000000000..5759edd57 --- /dev/null +++ b/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift @@ -0,0 +1,71 @@ +// +// AuthEndpointServiceTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities + +final class AuthEndpointServiceTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testCreateAccountSuccess() async throws { + let token = "someToken" + let externalID = "id?" + let status = "noidea" + let mockedCreateAccountResponse = CreateAccountResponse(authToken: token, externalID: externalID, status: status) + let apiService = APIServiceMock(mockAuthHeaders: ["Authorization": "Bearer " + token], + mockAPICallSuccessResult: mockedCreateAccountResponse) + let service = DefaultAuthEndpointService(currentServiceEnvironment: .staging, + apiService: apiService) + let result = await service.createAccount(emailAccessToken: token) + switch result { + case .success(let success): + XCTAssertEqual(success.authToken, token) + XCTAssertEqual(success.externalID, externalID) + XCTAssertEqual(success.status, status) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testCreateAccountFailure() async throws { + let token = "someToken" + let apiService = APIServiceMock(mockAuthHeaders: ["Authorization": "Bearer " + token], + mockAPICallError: .encodingError) + let service = DefaultAuthEndpointService(currentServiceEnvironment: .staging, + apiService: apiService) + let result = await service.createAccount(emailAccessToken: token) + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let failure): + switch failure { + case APIServiceError.encodingError: break + default: + XCTFail("Wrong error") + } + } + } +} diff --git a/Tests/SubscriptionTests/API/Models/EntitlementTests.swift b/Tests/SubscriptionTests/API/Models/EntitlementTests.swift new file mode 100644 index 000000000..c6fa25087 --- /dev/null +++ b/Tests/SubscriptionTests/API/Models/EntitlementTests.swift @@ -0,0 +1,41 @@ +// +// EntitlementTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities + +final class EntitlementTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testEquality() throws { + XCTAssertEqual(Entitlement(product: .dataBrokerProtection), Entitlement(product: .dataBrokerProtection)) + XCTAssertNotEqual(Entitlement(product: .dataBrokerProtection), Entitlement(product: .networkProtection)) + } + + func testDecoding() throws { + // Decode Entitlement + } +} diff --git a/Tests/SubscriptionTests/API/Models/SubscriptionTests.swift b/Tests/SubscriptionTests/API/Models/SubscriptionTests.swift new file mode 100644 index 000000000..aacbe8a45 --- /dev/null +++ b/Tests/SubscriptionTests/API/Models/SubscriptionTests.swift @@ -0,0 +1,62 @@ +// +// SubscriptionTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities + +final class SubscriptionTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testEquality() throws { + let a = DDGSubscription(productId: "1", + name: "a", + billingPeriod: .monthly, + startedAt: Date(timeIntervalSince1970: 1000), + expiresOrRenewsAt: Date(timeIntervalSince1970: 2000), + platform: .apple, + status: .autoRenewable) + let b = DDGSubscription(productId: "1", + name: "a", + billingPeriod: .monthly, + startedAt: Date(timeIntervalSince1970: 1000), + expiresOrRenewsAt: Date(timeIntervalSince1970: 2000), + platform: .apple, + status: .autoRenewable) + let c = DDGSubscription(productId: "2", + name: "a", + billingPeriod: .monthly, + startedAt: Date(timeIntervalSince1970: 1000), + expiresOrRenewsAt: Date(timeIntervalSince1970: 2000), + platform: .apple, + status: .autoRenewable) + XCTAssertEqual(a, b) + XCTAssertNotEqual(a, c) + } + + func testDecoding() throws { + // Decode + } +} diff --git a/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift new file mode 100644 index 000000000..2b7fa61cb --- /dev/null +++ b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift @@ -0,0 +1,61 @@ +// +// SubscriptionEndpointServiceTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities + +final class SubscriptionEndpointServiceTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testGetSubscriptionSuccessNoCache() async throws { + let token = "someToken" + let subscription = DDGSubscription(productId: "productID", + name: "name", + billingPeriod: .monthly, + startedAt: Date.yearAgo, + expiresOrRenewsAt: Date.aYearFromNow, + platform: .apple, + status: .autoRenewable) + let apiService = APIServiceMock(mockAuthHeaders: ["Authorization": "Bearer " + token], + mockAPICallSuccessResult: subscription) + let service = DefaultSubscriptionEndpointService(currentServiceEnvironment: .staging, + apiService: apiService) + switch await service.getSubscription(accessToken: token, cachePolicy: .reloadIgnoringLocalCacheData) { + case .success(let success): + XCTAssertEqual(subscription, success) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testGetSubscriptionSuccessCache() async throws { + // Implement + } + + func testGetSubscriptionFailure() async throws { + // Implement + } +} diff --git a/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift new file mode 100644 index 000000000..ddf695eed --- /dev/null +++ b/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift @@ -0,0 +1,48 @@ +// +// AppStoreAccountManagementFlowTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities + +final class AppStoreAccountManagementFlowTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testRefreshAuthTokenIfNeededSuccess() async throws { + let authEndpointService = SubscriptionMockFactory.authEndpointService + let storePurchaseManager = SubscriptionMockFactory.storePurchaseManager + let accountManager = SubscriptionMockFactory.accountManager + + let flow = DefaultAppStoreAccountManagementFlow(authEndpointService: authEndpointService, + storePurchaseManager: storePurchaseManager, + accountManager: accountManager) + switch await flow.refreshAuthTokenIfNeeded() { + case .success: + break + case .failure(let error): + XCTFail("Unexpected failure: \(error.localizedDescription)") + } + } +} diff --git a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift new file mode 100644 index 000000000..532d4a113 --- /dev/null +++ b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift @@ -0,0 +1,55 @@ +// +// AppStorePurchaseFlowTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities + +final class AppStorePurchaseFlowTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testPurchaseSubscriptionSuccess() async throws { + let subscriptionEndpointService = SubscriptionMockFactory.subscriptionEndpointService + let storePurchaseManager = SubscriptionMockFactory.storePurchaseManager + let appStoreRestoreFlow = SubscriptionMockFactory.appStoreRestoreFlow + appStoreRestoreFlow.restoreAccountFromPastPurchaseResult = .failure(.missingAccountOrTransactions) + let authEndpointService = SubscriptionMockFactory.authEndpointService + let accountManager = SubscriptionMockFactory.accountManager + + let flow = DefaultAppStorePurchaseFlow(subscriptionEndpointService: subscriptionEndpointService, + storePurchaseManager: storePurchaseManager, + accountManager: accountManager, + appStoreRestoreFlow: appStoreRestoreFlow, + authEndpointService: authEndpointService) + + switch await flow.purchaseSubscription(with: SubscriptionMockFactory.subscription.productId, + emailAccessToken: SubscriptionMockFactory.authToken) { + case .success: + break + case .failure(let error): + XCTFail("Unexpected failure: \(error.localizedDescription)") + } + } +} diff --git a/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift new file mode 100644 index 000000000..ba3a1f653 --- /dev/null +++ b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift @@ -0,0 +1,50 @@ +// +// AppStoreRestoreFlowTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities + +final class AppStoreRestoreFlowTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testRestoreAccountFromPastPurchaseSuccess() async throws { + + var storePurchaseManager = SubscriptionMockFactory.storePurchaseManager + storePurchaseManager.mostRecentTransactionResult = "eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWZUbGZkMGZOdkZXdnpDMVlJQU5zWGpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJek1Ea3hNakU1TlRFMU0xb1hEVEkxTVRBeE1URTVOVEUxTWxvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRmR2Y214a2QybGtaU0JFWlhabGJHOXdaWElnVW1Wc1lYUnBiMjV6TVJNd0VRWURWUVFLREFwQmNIQnNaU0JKYm1NdU1Rc3dDUVlEVlFRR0V3SlZVekJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCRUZFWWUvSnFUcXlRdi9kdFhrYXVESENTY1YxMjlGWVJWLzB4aUIyNG5DUWt6UWYzYXNISk9OUjVyMFJBMGFMdko0MzJoeTFTWk1vdXZ5ZnBtMjZqWFNqZ2dJSU1JSUNCREFNQmdOVkhSTUJBZjhFQWpBQU1COEdBMVVkSXdRWU1CYUFGRDh2bENOUjAxREptaWc5N2JCODVjK2xrR0taTUhBR0NDc0dBUVVGQndFQkJHUXdZakF0QmdnckJnRUZCUWN3QW9ZaGFIUjBjRG92TDJObGNuUnpMbUZ3Y0d4bExtTnZiUzkzZDJSeVp6WXVaR1Z5TURFR0NDc0dBUVVGQnpBQmhpVm9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMWGQzWkhKbk5qQXlNSUlCSGdZRFZSMGdCSUlCRlRDQ0FSRXdnZ0VOQmdvcWhraUc5Mk5rQlFZQk1JSCtNSUhEQmdnckJnRUZCUWNDQWpDQnRneUJzMUpsYkdsaGJtTmxJRzl1SUhSb2FYTWdZMlZ5ZEdsbWFXTmhkR1VnWW5rZ1lXNTVJSEJoY25SNUlHRnpjM1Z0WlhNZ1lXTmpaWEIwWVc1alpTQnZaaUIwYUdVZ2RHaGxiaUJoY0hCc2FXTmhZbXhsSUhOMFlXNWtZWEprSUhSbGNtMXpJR0Z1WkNCamIyNWthWFJwYjI1eklHOW1JSFZ6WlN3Z1kyVnlkR2xtYVdOaGRHVWdjRzlzYVdONUlHRnVaQ0JqWlhKMGFXWnBZMkYwYVc5dUlIQnlZV04wYVdObElITjBZWFJsYldWdWRITXVNRFlHQ0NzR0FRVUZCd0lCRmlwb2RIUndPaTh2ZDNkM0xtRndjR3hsTG1OdmJTOWpaWEowYVdacFkyRjBaV0YxZEdodmNtbDBlUzh3SFFZRFZSME9CQllFRkFNczhQanM2VmhXR1FsekUyWk9FK0dYNE9vL01BNEdBMVVkRHdFQi93UUVBd0lIZ0RBUUJnb3Foa2lHOTJOa0Jnc0JCQUlGQURBS0JnZ3Foa2pPUFFRREF3Tm9BREJsQWpFQTh5Uk5kc2twNTA2REZkUExnaExMSndBdjVKOGhCR0xhSThERXhkY1BYK2FCS2pqTzhlVW85S3BmcGNOWVVZNVlBakFQWG1NWEVaTCtRMDJhZHJtbXNoTnh6M05uS20rb3VRd1U3dkJUbjBMdmxNN3ZwczJZc2xWVGFtUllMNGFTczVrPSIsIk1JSURGakNDQXB5Z0F3SUJBZ0lVSXNHaFJ3cDBjMm52VTRZU3ljYWZQVGp6Yk5jd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NakV3TXpFM01qQXpOekV3V2hjTk16WXdNekU1TURBd01EQXdXakIxTVVRd1FnWURWUVFERER0QmNIQnNaU0JYYjNKc1pIZHBaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBhVzl1Y3lCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVMTUFrR0ExVUVDd3dDUnpZeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUhZd0VBWUhLb1pJemowQ0FRWUZLNEVFQUNJRFlnQUVic1FLQzk0UHJsV21aWG5YZ3R4emRWSkw4VDBTR1luZ0RSR3BuZ24zTjZQVDhKTUViN0ZEaTRiQm1QaENuWjMvc3E2UEYvY0djS1hXc0w1dk90ZVJoeUo0NXgzQVNQN2NPQithYW85MGZjcHhTdi9FWkZibmlBYk5nWkdoSWhwSW80SDZNSUgzTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0h3WURWUjBqQkJnd0ZvQVV1N0Rlb1ZnemlKcWtpcG5ldnIzcnI5ckxKS3N3UmdZSUt3WUJCUVVIQVFFRU9qQTRNRFlHQ0NzR0FRVUZCekFCaGlwb2RIUndPaTh2YjJOemNDNWhjSEJzWlM1amIyMHZiMk56Y0RBekxXRndjR3hsY205dmRHTmhaek13TndZRFZSMGZCREF3TGpBc29DcWdLSVltYUhSMGNEb3ZMMk55YkM1aGNIQnNaUzVqYjIwdllYQndiR1Z5YjI5MFkyRm5NeTVqY213d0hRWURWUjBPQkJZRUZEOHZsQ05SMDFESm1pZzk3YkI4NWMrbGtHS1pNQTRHQTFVZER3RUIvd1FFQXdJQkJqQVFCZ29xaGtpRzkyTmtCZ0lCQkFJRkFEQUtCZ2dxaGtqT1BRUURBd05vQURCbEFqQkFYaFNxNUl5S29nTUNQdHc0OTBCYUI2NzdDYUVHSlh1ZlFCL0VxWkdkNkNTamlDdE9udU1UYlhWWG14eGN4ZmtDTVFEVFNQeGFyWlh2TnJreFUzVGtVTUkzM3l6dkZWVlJUNHd4V0pDOTk0T3NkY1o0K1JHTnNZRHlSNWdtZHIwbkRHZz0iLCJNSUlDUXpDQ0FjbWdBd0lCQWdJSUxjWDhpTkxGUzVVd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NVFF3TkRNd01UZ3hPVEEyV2hjTk16a3dORE13TVRneE9UQTJXakJuTVJzd0dRWURWUVFEREJKQmNIQnNaU0JTYjI5MElFTkJJQzBnUnpNeEpqQWtCZ05WQkFzTUhVRndjR3hsSUVObGNuUnBabWxqWVhScGIyNGdRWFYwYUc5eWFYUjVNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVFzd0NRWURWUVFHRXdKVlV6QjJNQkFHQnlxR1NNNDlBZ0VHQlN1QkJBQWlBMklBQkpqcEx6MUFjcVR0a3lKeWdSTWMzUkNWOGNXalRuSGNGQmJaRHVXbUJTcDNaSHRmVGpqVHV4eEV0WC8xSDdZeVlsM0o2WVJiVHpCUEVWb0EvVmhZREtYMUR5eE5CMGNUZGRxWGw1ZHZNVnp0SzUxN0lEdll1VlRaWHBta09sRUtNYU5DTUVBd0hRWURWUjBPQkJZRUZMdXczcUZZTTRpYXBJcVozcjY5NjYvYXl5U3JNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdEZ1lEVlIwUEFRSC9CQVFEQWdFR01Bb0dDQ3FHU000OUJBTURBMmdBTUdVQ01RQ0Q2Y0hFRmw0YVhUUVkyZTN2OUd3T0FFWkx1Tit5UmhIRkQvM21lb3locG12T3dnUFVuUFdUeG5TNGF0K3FJeFVDTUcxbWloREsxQTNVVDgyTlF6NjBpbU9sTTI3amJkb1h0MlFmeUZNbStZaGlkRGtMRjF2TFVhZ002QmdENTZLeUtBPT0iXX0.eyJ0cmFuc2FjdGlvbklkIjoiMjAwMDAwMDYzNzEzMjQ2MSIsIm9yaWdpbmFsVHJhbnNhY3Rpb25JZCI6IjIwMDAwMDA1NDU3MzkzODQiLCJ3ZWJPcmRlckxpbmVJdGVtSWQiOiIyMDAwMDAwMDY1MzMwMjg5IiwiYnVuZGxlSWQiOiJjb20uZHVja2R1Y2tnby5tYWNvcy5icm93c2VyLmRlYnVnIiwicHJvZHVjdElkIjoic3Vic2NyaXB0aW9uLjFtb250aCIsInN1YnNjcmlwdGlvbkdyb3VwSWRlbnRpZmllciI6IjIxMzQxODQ2IiwicHVyY2hhc2VEYXRlIjoxNzE5MjM2MDQ1MDAwLCJvcmlnaW5hbFB1cmNoYXNlRGF0ZSI6MTcxMDE3OTQwNTAwMCwiZXhwaXJlc0RhdGUiOjE3MTkyMzYzNDUwMDAsInF1YW50aXR5IjoxLCJ0eXBlIjoiQXV0by1SZW5ld2FibGUgU3Vic2NyaXB0aW9uIiwiZGV2aWNlVmVyaWZpY2F0aW9uIjoiMzhyMkpPVFpHV3lMMnZiNDBKQktsYmpzUzVqOG15a01Dc3VFV3c2MEd5NWl1RlVLeW0rTHpJeGM3VUpjVXRkKyIsImRldmljZVZlcmlmaWNhdGlvbk5vbmNlIjoiZTUwOTUwNzctYmQ4My00MWJjLWIxNDItZGJkMzUxODBhYTE1IiwiYXBwQWNjb3VudFRva2VuIjoiNzIyMzYzMmMtNWI2ZC00Njk0LTg0OTUtMDA2N2IxMzBiOWQyIiwiaW5BcHBPd25lcnNoaXBUeXBlIjoiUFVSQ0hBU0VEIiwic2lnbmVkRGF0ZSI6MTcxOTQxMjU1MDkzNywiZW52aXJvbm1lbnQiOiJTYW5kYm94IiwidHJhbnNhY3Rpb25SZWFzb24iOiJSRU5FV0FMIiwic3RvcmVmcm9udCI6IlVTQSIsInN0b3JlZnJvbnRJZCI6IjE0MzQ0MSIsInByaWNlIjo5OTkwLCJjdXJyZW5jeSI6IlVTRCJ9.XMCzyP0gvSCXmOci3x9PMW3vP_A1F9ekZ_8M9j7cFR0klBGondmZjiCTw0t-gRtaCWN9jSvvoIWx2LPkHATAlA" + + let accountManager = AccountManagerMock(email: "5p2d4sx1@duck.com", externalID: "something") + let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, + storePurchaseManager: storePurchaseManager, + subscriptionEndpointService: SubscriptionMockFactory.subscriptionEndpointService, + authEndpointService: SubscriptionMockFactory.authEndpointService) + switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + break + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + } + } +} diff --git a/Tests/SubscriptionTests/SubscriptionTests.swift b/Tests/SubscriptionTests/Flows/Models/PurchaseUpdateTests.swift similarity index 61% rename from Tests/SubscriptionTests/SubscriptionTests.swift rename to Tests/SubscriptionTests/Flows/Models/PurchaseUpdateTests.swift index 6225b8df6..b01628132 100644 --- a/Tests/SubscriptionTests/SubscriptionTests.swift +++ b/Tests/SubscriptionTests/Flows/Models/PurchaseUpdateTests.swift @@ -1,5 +1,5 @@ // -// SubscriptionTests.swift +// PurchaseUpdateTests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -20,6 +20,17 @@ import XCTest @testable import Subscription import SubscriptionTestingUtilities -final class SubscriptionTests: XCTestCase { +final class PurchaseUpdateTests: XCTestCase { + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testCodable() throws { + + } } diff --git a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift new file mode 100644 index 000000000..8f6070609 --- /dev/null +++ b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift @@ -0,0 +1,36 @@ +// +// SubscriptionOptionsTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities + +final class SubscriptionOptionsTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testCodable() throws { + + } +} diff --git a/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift new file mode 100644 index 000000000..e4d8781fe --- /dev/null +++ b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift @@ -0,0 +1,58 @@ +// +// StripePurchaseFlowTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities + +final class StripePurchaseFlowTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testSubscriptionOptionsSuccess() async throws { + let subscriptionEndpointService = SubscriptionEndpointServiceMock(getSubscriptionResult: nil, + getProductsResult: .success(SubscriptionMockFactory.productsItems), + getCustomerPortalURLResult: nil, + confirmPurchaseResult: nil) + let authEndpointService = AuthEndpointServiceMock(accessTokenResult: nil, + validateTokenResult: nil, + createAccountResult: nil, + storeLoginResult: nil) + let stripePurchaseFlow = DefaultStripePurchaseFlow(subscriptionEndpointService: subscriptionEndpointService, + authEndpointService: authEndpointService, + accountManager: AccountManagerMock()) + switch await stripePurchaseFlow.subscriptionOptions() { + case .success(let success): + XCTAssertEqual(success.platform, SubscriptionPlatformName.stripe.rawValue) + XCTAssertEqual(success.options.count, 1) + XCTAssertEqual(success.features.count, 7) + let allNames = success.features.compactMap({ feature in feature.name}) + for name in SubscriptionFeatureName.allCases { + XCTAssertTrue(allNames.contains(name.rawValue)) + } + case .failure(let failure): + XCTFail("Unexpected failure: \(failure)") + } + } +} diff --git a/Tests/SubscriptionTests/Managers/AccountManagerTests.swift b/Tests/SubscriptionTests/Managers/AccountManagerTests.swift new file mode 100644 index 000000000..4a466c2c0 --- /dev/null +++ b/Tests/SubscriptionTests/Managers/AccountManagerTests.swift @@ -0,0 +1,55 @@ +// +// AccountManagerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities +import Common + +final class AccountManagerTests: XCTestCase { + + var userDefaults: UserDefaults! + let testGroupName = "com.ddg.unitTests.AccountManagerTests" + + override func setUpWithError() throws { + userDefaults = UserDefaults(suiteName: testGroupName)! + } + + override func tearDownWithError() throws { + userDefaults.removePersistentDomain(forName: testGroupName) + } + + func testExample() throws { + let accessToken = "someAccessToken" + let storage = AccountKeychainStorageMock() + let accessTokenStorage = SubscriptionTokenKeychainStorageMock() + let entitlementsCache = UserDefaultsCache<[Entitlement]>(userDefaults: userDefaults, + key: UserDefaultsCacheKey.subscriptionEntitlements, + settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) + let accountManager = DefaultAccountManager(storage: storage, + accessTokenStorage: accessTokenStorage, + entitlementsCache: entitlementsCache, + subscriptionEndpointService: SubscriptionMockFactory.subscriptionEndpointService, + authEndpointService: SubscriptionMockFactory.authEndpointService) + + accountManager.storeAccount(token: accessToken, email: SubscriptionMockFactory.email, externalID: SubscriptionMockFactory.externalId) + XCTAssertEqual(accessTokenStorage.accessToken, accessToken) + XCTAssertEqual(storage.email, SubscriptionMockFactory.email) + XCTAssertEqual(storage.externalID, SubscriptionMockFactory.externalId) + } +} diff --git a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift new file mode 100644 index 000000000..3158e96b7 --- /dev/null +++ b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift @@ -0,0 +1,52 @@ +// +// StorePurchaseManagerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities +import StoreKit +import StoreKitTest + +final class StorePurchaseManagerTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() async throws { + + // Option 1: make the `SubscriptionsTestConfig.storekit` to work as explained in https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode and https://developer.apple.com/videos/play/wwdc2020/10659/, then test `DefaultStorePurchaseManager` as it is + /* + let manager = DefaultStorePurchaseManager() + try await manager.syncAppleIDAccount() + XCTAssert(manager.availableProducts.count == 2) + */ + + // Option 2: create a protocol abstracting `StoreKit` from `DefaultStorePurchaseManager` and create a mock that uses SKTestSession + /* + let path = Bundle.module.path(forResource: "TestingConfiguration", ofType: "storekit")! + let session = try SKTestSession(contentsOf: URL(fileURLWithPath: path, isDirectory: false)) + session.disableDialogs = true + session.clearTransactions() + */ + } +} diff --git a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift new file mode 100644 index 000000000..da15986cb --- /dev/null +++ b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift @@ -0,0 +1,43 @@ +// +// SubscriptionManagerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities + +final class SubscriptionManagerTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testExample() throws { + + let subscriptionManager = DefaultSubscriptionManager(storePurchaseManager: SubscriptionMockFactory.storePurchaseManager, + accountManager: SubscriptionMockFactory.accountManager, + subscriptionEndpointService: SubscriptionMockFactory.subscriptionEndpointService, + authEndpointService: SubscriptionMockFactory.authEndpointService, + subscriptionEnvironment: SubscriptionMockFactory.currentEnvironment) + let url = subscriptionManager.url(for: .activateSuccess) + XCTAssertEqual(url.absoluteString, "https://duckduckgo.com/subscriptions/activate/success?environment=staging") + } +} diff --git a/Tests/SubscriptionTests/Resources/StoreKitTestCertificate.cer b/Tests/SubscriptionTests/Resources/StoreKitTestCertificate.cer new file mode 100644 index 000000000..58961565e Binary files /dev/null and b/Tests/SubscriptionTests/Resources/StoreKitTestCertificate.cer differ diff --git a/Tests/SubscriptionTests/Resources/TestingConfiguration.storekit b/Tests/SubscriptionTests/Resources/TestingConfiguration.storekit new file mode 100644 index 000000000..c78c38581 --- /dev/null +++ b/Tests/SubscriptionTests/Resources/TestingConfiguration.storekit @@ -0,0 +1,132 @@ +{ + "identifier" : "A2F4D5EE", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + + ], + "settings" : { + "_compatibilityTimeRate" : { + "3" : 6, + "5" : 1004 + }, + "_disableDialogs" : true, + "_failTransactionsEnabled" : false, + "_locale" : "en_US", + "_storefront" : "USA", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ], + "_timeRate" : 1005 + }, + "subscriptionGroups" : [ + { + "id" : "21416043", + "localizations" : [ + + ], + "name" : "Premium Privacy Protection Subscription", + "subscriptions" : [ + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "9.99", + "familyShareable" : false, + "groupNumber" : 2, + "internalID" : "6473453075", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Monthly Subscription", + "displayName" : "Monthly Subscription", + "locale" : "en_US" + } + ], + "productID" : "ios.subscription.1month", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Monthly Subscription", + "subscriptionGroupID" : "21416043", + "type" : "RecurringSubscription" + }, + { + "adHocOffers" : [ + + ], + "codeOffers" : [ + + ], + "displayPrice" : "99.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "6473453289", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Yearly Subscription", + "displayName" : "Yearly Subscription", + "locale" : "en_US" + } + ], + "productID" : "ios.subscription.1year", + "recurringSubscriptionPeriod" : "P1Y", + "referenceName" : "Yearly Subscription", + "subscriptionGroupID" : "21416043", + "type" : "RecurringSubscription" + } + ] + } + ], + "version" : { + "major" : 3, + "minor" : 0 + } +} diff --git a/Tests/SubscriptionTests/SubscriptionURLTests.swift b/Tests/SubscriptionTests/SubscriptionURLTests.swift new file mode 100644 index 000000000..2b613bd5e --- /dev/null +++ b/Tests/SubscriptionTests/SubscriptionURLTests.swift @@ -0,0 +1,38 @@ +// +// SubscriptionURLTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities + +final class SubscriptionURLTests: XCTestCase { + + override func setUpWithError() throws { + // Put setup code here. This method is called before the invocation of each test method in the class. + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testURLs() throws { + let url = SubscriptionURL.activateSuccess.subscriptionURL(environment: SubscriptionEnvironment.ServiceEnvironment.staging) + XCTAssertEqual(url.absoluteString, "https://duckduckgo.com/subscriptions/activate/success?environment=staging") + // test all other URLs + } +}