From 029008d6209e92a0a1f67c1731145b8a6317c506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mariusz=20=C5=9Apiewak?= Date: Tue, 21 Jan 2025 15:53:22 +0100 Subject: [PATCH 01/28] Add WebView state restoration privacy feature (#1173) Please review the release process for BrowserServicesKit [here](https://app.asana.com/0/1200194497630846/1200837094583426). **Required**: Task/Issue URL: https://app.asana.com/0/1206226850447395/1209192796290067/f iOS PR: https://github.com/duckduckgo/iOS/pull/3838 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3752 What kind of version bump will this require?: Major **Optional**: Tech Design URL: CC: **Description**: **Steps to test this PR**: 1. See steps for iOS: https://github.com/duckduckgo/iOS/pull/3838 **OS Testing**: * [ ] iOS 14 * [ ] iOS 15 * [ ] iOS 16 * [ ] macOS 10.15 * [ ] macOS 11 * [ ] macOS 12 --- ###### Internal references: [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) --- .../PrivacyConfig/Features/PrivacyFeature.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift index 47143df0f..1f2a4455d 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift @@ -67,6 +67,7 @@ public enum PrivacyFeature: String { case forceOldAppDelegate case htmlNewTabPage case tabManager + case webViewStateRestoration } /// An abstraction to be implemented by any "subfeature" of a given `PrivacyConfiguration` feature. From 1260917d038eb73528bc86589332330d84b304de Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 22 Jan 2025 14:52:19 +0600 Subject: [PATCH 02/28] Fire event on matches api request failure (#1176) Task/Issue URL: https://app.asana.com/0/1202406491309510/1209190998168436/f iOS PR: https://github.com/duckduckgo/iOS/pull/3847 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3757 --- .../API/APIRequest.swift | 2 +- .../MaliciousSiteDetector.swift | 5 +- .../MaliciousSiteProtection/Model/Event.swift | 13 +++++ Sources/Networking/v2/APIRequestErrorV2.swift | 8 +++ .../MaliciousSiteDetectorTests.swift | 57 +++++++++++++++++++ ...aliciousSiteProtectionAPIClientTests.swift | 42 +++++++++++++- .../Mocks/MockEventMapping.swift | 15 +++-- ...MockMaliciousSiteProtectionAPIClient.swift | 4 +- 8 files changed, 132 insertions(+), 14 deletions(-) diff --git a/Sources/MaliciousSiteProtection/API/APIRequest.swift b/Sources/MaliciousSiteProtection/API/APIRequest.swift index 16128ccb0..db8bfffcf 100644 --- a/Sources/MaliciousSiteProtection/API/APIRequest.swift +++ b/Sources/MaliciousSiteProtection/API/APIRequest.swift @@ -101,7 +101,7 @@ public extension APIRequestType { .matches(self) } - var defaultTimeout: TimeInterval? { 10 } + var defaultTimeout: TimeInterval? { 5 } } } /// extension to call generic `load(_: some Request)` method like this: `load(.matches(…))` diff --git a/Sources/MaliciousSiteProtection/MaliciousSiteDetector.swift b/Sources/MaliciousSiteProtection/MaliciousSiteDetector.swift index 998c397c6..9235b276d 100644 --- a/Sources/MaliciousSiteProtection/MaliciousSiteDetector.swift +++ b/Sources/MaliciousSiteProtection/MaliciousSiteDetector.swift @@ -68,8 +68,11 @@ public final class MaliciousSiteDetector: MaliciousSiteDetecting { let matches: [Match] do { matches = try await apiClient.matches(forHashPrefix: hashPrefixParam).matches + } catch APIRequestV2.Error.urlSession(URLError.timedOut) { + eventMapping.fire(.matchesApiTimeout) + return nil } catch { - Logger.general.error("Error fetching matches from API: \(error)") + eventMapping.fire(.matchesApiFailure(error)) return nil } diff --git a/Sources/MaliciousSiteProtection/Model/Event.swift b/Sources/MaliciousSiteProtection/Model/Event.swift index 2f3136433..6217b35a4 100644 --- a/Sources/MaliciousSiteProtection/Model/Event.swift +++ b/Sources/MaliciousSiteProtection/Model/Event.swift @@ -32,6 +32,8 @@ public enum Event: PixelKitEventV2 { case visitSite(category: ThreatKind) case iframeLoaded(category: ThreatKind) case settingToggled(to: Bool) + case matchesApiTimeout + case matchesApiFailure(Error) public var name: String { switch self { @@ -43,6 +45,10 @@ public enum Event: PixelKitEventV2 { return "malicious-site-protection_iframe-loaded" case .settingToggled: return "malicious-site-protection_feature-toggled" + case .matchesApiTimeout: + return "malicious-site-protection.client-timeout" + case .matchesApiFailure: + return "malicious-site-protection.matches-api-error" } } @@ -62,6 +68,9 @@ public enum Event: PixelKitEventV2 { return [ PixelKit.Parameters.settingToggledTo: String(state) ] + case .matchesApiTimeout, + .matchesApiFailure: + return [:] } } @@ -75,6 +84,10 @@ public enum Event: PixelKitEventV2 { return nil case .settingToggled: return nil + case .matchesApiTimeout: + return nil + case .matchesApiFailure(let error): + return error } } diff --git a/Sources/Networking/v2/APIRequestErrorV2.swift b/Sources/Networking/v2/APIRequestErrorV2.swift index f371b4fb6..e1f76be9e 100644 --- a/Sources/Networking/v2/APIRequestErrorV2.swift +++ b/Sources/Networking/v2/APIRequestErrorV2.swift @@ -44,6 +44,14 @@ extension APIRequestV2 { return "The response body is nil" } } + + public var isTimedOut: Bool { + if case .urlSession(URLError.timedOut) = self { + true + } else { + false + } + } } } diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteDetectorTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteDetectorTests.swift index f6b0de23a..14674610f 100644 --- a/Tests/MaliciousSiteProtectionTests/MaliciousSiteDetectorTests.swift +++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteDetectorTests.swift @@ -16,6 +16,7 @@ // limitations under the License. // import Foundation +import Networking import XCTest @testable import MaliciousSiteProtection @@ -100,4 +101,60 @@ class MaliciousSiteDetectorTests: XCTestCase { XCTAssertNil(result) } + + func testWhenMatchesApiFailsThenEventIsFired() async { + let e = expectation(description: "matchesForHashPrefix called") + mockAPIClient.matchesForHashPrefix = { _ in + let error = Networking.APIRequestV2.Error.urlSession(URLError(.badServerResponse)) + XCTAssertFalse(error.isTimedOut) + e.fulfill() + throw error + } + + await mockDataManager.store(HashPrefixSet(revision: 0, items: ["255a8a79"]), for: .hashPrefixes(threatKind: .phishing)) + + let url = URL(string: "https://malicious.com/")! + let result = await detector.evaluate(url) + XCTAssertNil(result) + + await fulfillment(of: [e], timeout: 0) + + XCTAssertEqual(mockEventMapping.events.count, 1) + switch mockEventMapping.events.last { + case .matchesApiFailure(APIRequestV2.Error.urlSession(URLError.badServerResponse)): + break + case .none: + XCTFail( "No event fired") + case .some(let event): + XCTFail("Unexpected event \(event)") + } + } + + func testWhenMatchesApiFailsWithTimeoutThenEventIsFired() async { + let e = expectation(description: "matchesForHashPrefix called") + mockAPIClient.matchesForHashPrefix = { _ in + let error = Networking.APIRequestV2.Error.urlSession(URLError(.timedOut)) + XCTAssertTrue(error.isTimedOut) // should match testWhenMatchesRequestTimeouts_TimeoutErrorThrown! + e.fulfill() + throw error + } + + await mockDataManager.store(HashPrefixSet(revision: 0, items: ["255a8a79"]), for: .hashPrefixes(threatKind: .phishing)) + + let url = URL(string: "https://malicious.com/")! + let result = await detector.evaluate(url) + XCTAssertNil(result) + + await fulfillment(of: [e], timeout: 0) + + XCTAssertEqual(mockEventMapping.events.count, 1) + switch mockEventMapping.events.last { + case .matchesApiTimeout: + break + case .none: + XCTFail( "No event fired") + case .some(let event): + XCTFail("Unexpected event \(event)") + } + } } diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift index fcea80939..6318a6d70 100644 --- a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift +++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift @@ -24,16 +24,21 @@ import XCTest final class MaliciousSiteProtectionAPIClientTests: XCTestCase { + var apiEnvironment: MaliciousSiteProtection.APIClientEnvironment! var mockService: MockAPIService! - var client: MaliciousSiteProtection.APIClient! + lazy var apiService: APIService! = mockService + lazy var client: MaliciousSiteProtection.APIClient! = { + .init(environment: apiEnvironment, service: apiService) + }() override func setUp() { super.setUp() + apiEnvironment = MaliciousSiteDetector.APIEnvironment.staging mockService = MockAPIService() - client = .init(environment: MaliciousSiteDetector.APIEnvironment.staging, service: mockService) } override func tearDown() { + apiEnvironment = nil mockService = nil client = nil super.tearDown() @@ -140,4 +145,37 @@ final class MaliciousSiteProtectionAPIClientTests: XCTestCase { } } + func testWhenMatchesRequestTimeouts_TimeoutErrorThrown() async throws { + // Given + apiService = DefaultAPIService() + apiEnvironment = MockEnvironment(timeout: 0.0001) + + do { + let response = try await client.matches(forHashPrefix: "") + XCTFail("Unexpected \(response) expected throw") + } catch let error as Networking.APIRequestV2.Error { + switch error { + case Networking.APIRequestV2.Error.urlSession(URLError.timedOut): + XCTAssertTrue(error.isTimedOut) // should match testWhenMatchesApiFailsThenEventIsFired! + default: + XCTFail("Unexpected \(error)") + } + } + } + +} + +extension MaliciousSiteProtectionAPIClientTests { + struct MockEnvironment: MaliciousSiteProtection.APIClientEnvironment { + let timeout: TimeInterval? + func headers(for requestType: MaliciousSiteProtection.APIRequestType) -> Networking.APIRequestV2.HeadersV2 { + .init() + } + func url(for requestType: MaliciousSiteProtection.APIRequestType) -> URL { + MaliciousSiteDetector.APIEnvironment.production.url(for: requestType) + } + func timeout(for requestType: APIRequestType) -> TimeInterval? { + timeout + } + } } diff --git a/Tests/MaliciousSiteProtectionTests/Mocks/MockEventMapping.swift b/Tests/MaliciousSiteProtectionTests/Mocks/MockEventMapping.swift index bf739a450..8256f4392 100644 --- a/Tests/MaliciousSiteProtectionTests/Mocks/MockEventMapping.swift +++ b/Tests/MaliciousSiteProtectionTests/Mocks/MockEventMapping.swift @@ -22,23 +22,22 @@ import MaliciousSiteProtection import PixelKit public class MockEventMapping: EventMapping { - static var events: [MaliciousSiteProtection.Event] = [] - static var clientSideHitParam: String? - static var errorParam: Error? + var events: [MaliciousSiteProtection.Event] = [] + var clientSideHitParam: String? + var errorParam: Error? public init() { + weak var weakSelf: MockEventMapping! super.init { event, error, params, _ in - Self.events.append(event) + weakSelf!.events.append(event) switch event { case .errorPageShown: - Self.clientSideHitParam = params?[PixelKit.Parameters.clientSideHit] + weakSelf!.clientSideHitParam = params?[PixelKit.Parameters.clientSideHit] default: break } } + weakSelf = self } - override init(mapping: @escaping EventMapping.Mapping) { - fatalError("Use init()") - } } diff --git a/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionAPIClient.swift b/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionAPIClient.swift index 4f2062edd..7e71a9c48 100644 --- a/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionAPIClient.swift +++ b/Tests/MaliciousSiteProtectionTests/Mocks/MockMaliciousSiteProtectionAPIClient.swift @@ -80,7 +80,7 @@ class MockMaliciousSiteProtectionAPIClient: MaliciousSiteProtection.APIClient.Mo case .filterSet(let configuration): return _filtersChangeSet(for: configuration.threatKind, revision: configuration.revision ?? 0) as! Request.Response case .matches(let configuration): - return _matches(forHashPrefix: configuration.hashPrefix) as! Request.Response + return try matchesForHashPrefix(configuration.hashPrefix) as! Request.Response } } func _filtersChangeSet(for threatKind: MaliciousSiteProtection.ThreatKind, revision: Int) -> MaliciousSiteProtection.APIClient.Response.FiltersChangeSet { @@ -93,7 +93,7 @@ class MockMaliciousSiteProtectionAPIClient: MaliciousSiteProtection.APIClient.Mo return hashPrefixRevisions[revision] ?? .init(insert: [], delete: [], revision: revision, replace: false) } - func _matches(forHashPrefix hashPrefix: String) -> APIClient.Response.Matches { + var matchesForHashPrefix: (String) throws -> APIClient.Response.Matches = { _ in .init(matches: [ Match(hostname: "example.com", url: "https://example.com/mal", regex: ".*", hash: "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947", category: nil), Match(hostname: "test.com", url: "https://test.com/mal", regex: ".*test.*", hash: "aa00bb11aa00cc11bb00cc11", category: nil) From 423ff3aaa526abd0e7d658ed88e48209b015e86b Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Wed, 22 Jan 2025 18:18:16 +0600 Subject: [PATCH 03/28] Add Malicious site detection auth header (#1175) Task/Issue URL: https://app.asana.com/0/1202406491309510/1209190998168434/f iOS PR: https://github.com/duckduckgo/iOS/pull/3841 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3754 --- .../API/APIClient.swift | 48 +++++++++++++------ .../v2/HTTP Components/HTTPHeaderKey.swift | 1 + ...aliciousSiteProtectionAPIClientTests.swift | 9 ++-- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/Sources/MaliciousSiteProtection/API/APIClient.swift b/Sources/MaliciousSiteProtection/API/APIClient.swift index 6bf0319f4..e74e7267c 100644 --- a/Sources/MaliciousSiteProtection/API/APIClient.swift +++ b/Sources/MaliciousSiteProtection/API/APIClient.swift @@ -29,8 +29,8 @@ extension APIClient { extension APIClient: APIClient.Mockable {} public protocol APIClientEnvironment { - func headers(for requestType: APIRequestType) -> APIRequestV2.HeadersV2 - func url(for requestType: APIRequestType) -> URL + func headers(for requestType: APIRequestType, platform: MaliciousSiteDetector.APIEnvironment.Platform, authToken: String?) -> APIRequestV2.HeadersV2 + func url(for requestType: APIRequestType, platform: MaliciousSiteDetector.APIEnvironment.Platform) -> URL func timeout(for requestType: APIRequestType) -> TimeInterval? } @@ -44,15 +44,16 @@ public extension MaliciousSiteDetector { case production case staging - var endpoint: URL { + func endpoint(for platform: Platform) -> URL { switch self { - case .production: URL(string: "https://duckduckgo.com/api/protection/")! - case .staging: URL(string: "https://staging.duckduckgo.com/api/protection/")! + case .production: URL(string: "https://duckduckgo.com/api/protection/v2/\(platform.rawValue)/")! + case .staging: URL(string: "https://staging.duckduckgo.com/api/protection/v2/\(platform.rawValue)/")! } } - var defaultHeaders: APIRequestV2.HeadersV2 { - .init(userAgent: Networking.APIRequest.Headers.userAgent) + public enum Platform: String { + case macOS = "macos" + case iOS = "ios" } enum APIPath { @@ -67,8 +68,9 @@ public extension MaliciousSiteDetector { static let hashPrefix = "hashPrefix" } - public func url(for requestType: APIRequestType) -> URL { - switch requestType { + public func url(for requestType: APIRequestType, platform: Platform) -> URL { + let endpoint = endpoint(for: platform) + return switch requestType { case .hashPrefixSet(let configuration): endpoint.appendingPathComponent(APIPath.hashPrefix).appendingParameters([ QueryParameter.category: configuration.threatKind.rawValue, @@ -84,8 +86,11 @@ public extension MaliciousSiteDetector { } } - public func headers(for requestType: APIRequestType) -> APIRequestV2.HeadersV2 { - defaultHeaders + public func headers(for requestType: APIRequestType, platform: Platform, authToken: String?) -> APIRequestV2.HeadersV2 { + .init(userAgent: Networking.APIRequest.Headers.userAgent, + additionalHeaders: [ + HTTPHeaderKey.authToken: authToken ?? "36d11d1b4acee44a6f0b3902337b8b4c459100e1c73021ef48acb73fccf7a2a8", + ]) } } @@ -93,18 +98,33 @@ public extension MaliciousSiteDetector { struct APIClient { + typealias Platform = MaliciousSiteDetector.APIEnvironment.Platform + let platform: Platform + let authToken: String? let environment: APIClientEnvironment private let service: APIService - init(environment: APIClientEnvironment, service: APIService = DefaultAPIService(urlSession: .shared)) { + init(environment: APIClientEnvironment, platform: Platform? = nil, authToken: String? = nil, service: APIService = DefaultAPIService(urlSession: .shared)) { + if let platform { + self.platform = platform + } else { +#if os(macOS) + self.platform = .macOS +#elseif os(iOS) + self.platform = .iOS +#else + fatalError("Unsupported platform") +#endif + } + self.authToken = authToken self.environment = environment self.service = service } func load(_ requestConfig: R) async throws -> R.Response { let requestType = requestConfig.requestType - let headers = environment.headers(for: requestType) - let url = environment.url(for: requestType) + let headers = environment.headers(for: requestType, platform: platform, authToken: authToken) + let url = environment.url(for: requestType, platform: platform) let timeout = environment.timeout(for: requestType) ?? requestConfig.defaultTimeout ?? 60 let apiRequest = APIRequestV2(url: url, method: .get, headers: headers, timeoutInterval: timeout) diff --git a/Sources/Networking/v2/HTTP Components/HTTPHeaderKey.swift b/Sources/Networking/v2/HTTP Components/HTTPHeaderKey.swift index b73f2eb35..6aa202b86 100644 --- a/Sources/Networking/v2/HTTP Components/HTTPHeaderKey.swift +++ b/Sources/Networking/v2/HTTP Components/HTTPHeaderKey.swift @@ -82,6 +82,7 @@ public struct HTTPHeaderKey { public static let via = "Via" public static let warning = "Warning" public static let wwwAuthenticate = "WWW-Authenticate" + public static let authToken = "X-Auth-Token" public static let xContentTypeOptions = "X-Content-Type-Options" public static let xFrameOptions = "X-Frame-Options" public static let xPoweredBy = "X-Powered-By" diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift index 6318a6d70..da52e26e2 100644 --- a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift +++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift @@ -46,11 +46,12 @@ final class MaliciousSiteProtectionAPIClientTests: XCTestCase { func testWhenPhishingFilterSetRequestedAndSucceeds_ChangeSetIsReturned() async throws { // Given + client = .init(environment: MaliciousSiteDetector.APIEnvironment.staging, platform: .iOS, service: mockService) let insertFilter = Filter(hash: "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947", regex: ".") let deleteFilter = Filter(hash: "6a929cd0b3ba4677eaedf1b2bdaf3ff89281cca94f688c83103bc9a676aea46d", regex: "(?i)^https?\\:\\/\\/[\\w\\-\\.]+(?:\\:(?:80|443))?") let expectedResponse = APIClient.Response.FiltersChangeSet(insert: [insertFilter], delete: [deleteFilter], revision: 666, replace: false) mockService.requestHandler = { [unowned self] in - XCTAssertEqual($0.urlRequest.url, client.environment.url(for: .filterSet(.init(threatKind: .phishing, revision: 666)))) + XCTAssertEqual($0.urlRequest.url, client.environment.url(for: .filterSet(.init(threatKind: .phishing, revision: 666)), platform: .iOS)) let data = try? JSONEncoder().encode(expectedResponse) let response = HTTPURLResponse(url: $0.urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! return .success(.init(data: data, httpResponse: response)) @@ -65,9 +66,10 @@ final class MaliciousSiteProtectionAPIClientTests: XCTestCase { func testWhenHashPrefixesRequestedAndSucceeds_ChangeSetIsReturned() async throws { // Given + client = .init(environment: MaliciousSiteDetector.APIEnvironment.staging, platform: .iOS, service: mockService) let expectedResponse = APIClient.Response.HashPrefixesChangeSet(insert: ["abc"], delete: ["def"], revision: 1, replace: false) mockService.requestHandler = { [unowned self] in - XCTAssertEqual($0.urlRequest.url, client.environment.url(for: .hashPrefixSet(.init(threatKind: .phishing, revision: 1)))) + XCTAssertEqual($0.urlRequest.url, client.environment.url(for: .hashPrefixSet(.init(threatKind: .phishing, revision: 1)), platform: .iOS)) let data = try? JSONEncoder().encode(expectedResponse) let response = HTTPURLResponse(url: $0.urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! return .success(.init(data: data, httpResponse: response)) @@ -82,9 +84,10 @@ final class MaliciousSiteProtectionAPIClientTests: XCTestCase { func testWhenMatchesRequestedAndSucceeds_MatchesAreReturned() async throws { // Given + client = .init(environment: MaliciousSiteDetector.APIEnvironment.staging, platform: .macOS, service: mockService) let expectedResponse = APIClient.Response.Matches(matches: [Match(hostname: "example.com", url: "https://example.com/test", regex: ".", hash: "a379a6f6eeafb9a55e378c118034e2751e682fab9f2d30ab13d2125586ce1947", category: nil)]) mockService.requestHandler = { [unowned self] in - XCTAssertEqual($0.urlRequest.url, client.environment.url(for: .matches(.init(hashPrefix: "abc")))) + XCTAssertEqual($0.urlRequest.url, client.environment.url(for: .matches(.init(hashPrefix: "abc")), platform: .macOS)) let data = try? JSONEncoder().encode(expectedResponse) let response = HTTPURLResponse(url: $0.urlRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! return .success(.init(data: data, httpResponse: response)) From 78fe852520f58c6c4de481768406d4066a39555d Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 23 Jan 2025 13:55:41 +0600 Subject: [PATCH 04/28] Fix build failing on main (MockEnvironment) (#1183) Please review the release process for BrowserServicesKit [here](https://app.asana.com/0/1200194497630846/1200837094583426). **Required**: Task/Issue URL: iOS PR: macOS PR: What kind of version bump will this require?: Major/Minor/Patch **Optional**: Tech Design URL: CC: **Description**: - Fix `MockEnvironment` definition to fix build failing on main **Steps to test this PR**: 1. Validate CI is green **OS Testing**: * [ ] iOS 14 * [ ] iOS 15 * [ ] iOS 16 * [ ] macOS 10.15 * [ ] macOS 11 * [ ] macOS 12 --- ###### Internal references: [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) --- .../MaliciousSiteProtectionAPIClientTests.swift | 7 +++---- .../MaliciousSiteProtectionUpdateManagerTests.swift | 5 +++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift index da52e26e2..5aa9dc066 100644 --- a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift +++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift @@ -171,11 +171,10 @@ final class MaliciousSiteProtectionAPIClientTests: XCTestCase { extension MaliciousSiteProtectionAPIClientTests { struct MockEnvironment: MaliciousSiteProtection.APIClientEnvironment { let timeout: TimeInterval? - func headers(for requestType: MaliciousSiteProtection.APIRequestType) -> Networking.APIRequestV2.HeadersV2 { - .init() + func headers(for requestType: MaliciousSiteProtection.APIRequestType, platform: MaliciousSiteProtection.MaliciousSiteDetector.APIEnvironment.Platform, authToken: String?) -> Networking.APIRequestV2.HeadersV2 { .init() } - func url(for requestType: MaliciousSiteProtection.APIRequestType) -> URL { - MaliciousSiteDetector.APIEnvironment.production.url(for: requestType) + func url(for requestType: MaliciousSiteProtection.APIRequestType, platform: MaliciousSiteProtection.MaliciousSiteDetector.APIEnvironment.Platform) -> URL { + MaliciousSiteDetector.APIEnvironment.production.url(for: requestType, platform: platform) } func timeout(for requestType: APIRequestType) -> TimeInterval? { timeout diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift index 15718eb7b..564e47529 100644 --- a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift +++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift @@ -344,12 +344,12 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { await fulfillment(of: [expectations[0], sleepExpectations[0]], timeout: 1) // Advance the clock by 1 seconds - await self.clock.advance(by: .seconds(1)) + await self.clock.advance(by: .seconds(2)) // expect to receive v.2 update for hashPrefixes await fulfillment(of: [expectations[1], sleepExpectations[1]], timeout: 1) // Advance the clock by 1 seconds - await self.clock.advance(by: .seconds(1)) + await self.clock.advance(by: .seconds(2)) // expect to receive v.3 update for hashPrefixes await fulfillment(of: [expectations[2], sleepExpectations[2]], timeout: 1) @@ -369,6 +369,7 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { // Cancel the update task updateTask!.cancel() + await Task.megaYield(count: 10) // Reset expectations for further updates let c = await dataManager.$store.dropFirst().sink { data in From b103a2a05dff415102978e0c1a2e5fbd6b86e813 Mon Sep 17 00:00:00 2001 From: Dominik Kapusta Date: Thu, 23 Jan 2025 09:40:58 +0100 Subject: [PATCH 05/28] Add UserScriptActionsManager package and a placeholder for HistoryView user script (#1179) Task/Issue URL: https://app.asana.com/0/72649045549333/1209210475594362/f Description: This change updates C-S-S to a version including a placeholder history view. --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index c90faa261..7d4985a7e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "0ac30560ec969a321caea321f95537120416c323", - "version" : "7.7.0" + "revision" : "5d2ab9f5da3c3fc5ec330f23ec35d558dcc8e35b", + "version" : "7.8.0" } }, { diff --git a/Package.swift b/Package.swift index 373174e28..13edb18d5 100644 --- a/Package.swift +++ b/Package.swift @@ -55,7 +55,7 @@ let package = Package( .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "3.0.0"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.4.0"), .package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "3.0.0"), - .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "7.7.0"), + .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "7.8.0"), .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "8.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"), From 12227e2c97ad93f8924551e5ead831c79386b19b Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Thu, 23 Jan 2025 15:50:36 +0600 Subject: [PATCH 06/28] move MaliciousSiteProtectionFeatureFlags to BSK (#1180) Task/Issue URL: https://app.asana.com/0/1202406491309510/1209190998168432/f iOS PR: https://github.com/duckduckgo/iOS/pull/3849 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3761 What kind of version bump will this require?: Major --- Package.swift | 1 + .../Features/PrivacyFeature.swift | 5 + .../MaliciousSiteProtectionFeatureFlags.swift | 100 +++++++++++ .../BrokenSitePromptLimiterTests.swift | 4 +- ...aliciousSiteProtectionAPIClientTests.swift | 3 +- ...ciousSiteProtectionFeatureFlagsTests.swift | 133 +++++++++++++++ .../PrivacyConfigurationManagerMock.swift | 158 ++++++++++++++++++ 7 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 Sources/MaliciousSiteProtection/FeatureFlags/MaliciousSiteProtectionFeatureFlags.swift create mode 100644 Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionFeatureFlagsTests.swift create mode 100644 Tests/MaliciousSiteProtectionTests/Mocks/PrivacyConfigurationManagerMock.swift diff --git a/Package.swift b/Package.swift index 13edb18d5..5b25a5a65 100644 --- a/Package.swift +++ b/Package.swift @@ -415,6 +415,7 @@ let package = Package( .target( name: "MaliciousSiteProtection", dependencies: [ + "BrowserServicesKit", "Common", "Networking", "PixelKit", diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift index 1f2a4455d..ca81478bd 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift @@ -227,3 +227,8 @@ public enum ContentBlockingSubfeature: String, Equatable, PrivacySubfeature { case tdsNextExperimentNov25 case tdsNextExperimentDec25 } + +public enum MaliciousSiteProtectionSubfeature: String, PrivacySubfeature { + public var parent: PrivacyFeature { .maliciousSiteProtection } + case onByDefault // Rollout feature +} diff --git a/Sources/MaliciousSiteProtection/FeatureFlags/MaliciousSiteProtectionFeatureFlags.swift b/Sources/MaliciousSiteProtection/FeatureFlags/MaliciousSiteProtectionFeatureFlags.swift new file mode 100644 index 000000000..fe94958eb --- /dev/null +++ b/Sources/MaliciousSiteProtection/FeatureFlags/MaliciousSiteProtectionFeatureFlags.swift @@ -0,0 +1,100 @@ +// +// MaliciousSiteProtectionFeatureFlags.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 + +public protocol MaliciousSiteProtectionFeatureFlagger { + /// A Boolean value indicating whether malicious site protection is enabled. + /// - Returns: `true` if malicious site protection is enabled; otherwise, `false`. + var isMaliciousSiteProtectionEnabled: Bool { get } + + /// Checks if should detect malicious threats for a specific domain. + /// - Parameter domain: The domain to check for malicious threat. + /// - Returns: `true` if should check for malicious threats for the specified domain; otherwise, `false`. + func shouldDetectMaliciousThreat(forDomain domain: String?) -> Bool +} + +public protocol MaliciousSiteProtectionFeatureFlagsSettingsProvider { + /// The frequency, in minutes, at which the hash prefix should be updated. + var hashPrefixUpdateFrequency: Int { get } + /// The frequency, in minutes, at which the filter set should be updated. + var filterSetUpdateFrequency: Int { get } +} + +/// An enum representing the different settings for malicious site protection feature flags. +public enum MaliciousSiteProtectionFeatureSettings: String { + /// The setting for hash prefix update frequency. + case hashPrefixUpdateFrequency + /// The setting for filter set update frequency. + case filterSetUpdateFrequency + + public var defaultValue: Int { + switch self { + case .hashPrefixUpdateFrequency: return 20 // Default frequency for hash prefix updates is 20 minutes. + case .filterSetUpdateFrequency: return 720 // Default frequency for filter set updates is 720 minutes (12 hours). + } + } +} + +public struct MaliciousSiteProtectionFeatureFlags { + private let privacyConfigManager: PrivacyConfigurationManaging + private let isMaliciousSiteProtectionEnabledGetter: () -> Bool + + private var remoteSettings: PrivacyConfigurationData.PrivacyFeature.FeatureSettings { + privacyConfigManager.privacyConfig.settings(for: .maliciousSiteProtection) + } + + public init(privacyConfigManager: PrivacyConfigurationManaging, + isMaliciousSiteProtectionEnabled: @escaping () -> Bool) { + self.privacyConfigManager = privacyConfigManager + self.isMaliciousSiteProtectionEnabledGetter = isMaliciousSiteProtectionEnabled + } +} + +// MARK: - MaliciousSiteProtectionFeatureFlagger + +extension MaliciousSiteProtectionFeatureFlags: MaliciousSiteProtectionFeatureFlagger { + + public var isMaliciousSiteProtectionEnabled: Bool { + return isMaliciousSiteProtectionEnabledGetter() + } + + public func shouldDetectMaliciousThreat(forDomain domain: String?) -> Bool { + isMaliciousSiteProtectionEnabled && privacyConfigManager.privacyConfig.isFeature(.maliciousSiteProtection, enabledForDomain: domain) + } + +} + +// MARK: - MaliciousSiteProtectionFeatureFlagsSettingsProvider + +extension MaliciousSiteProtectionFeatureFlags: MaliciousSiteProtectionFeatureFlagsSettingsProvider { + + public var hashPrefixUpdateFrequency: Int { + getSettings(MaliciousSiteProtectionFeatureSettings.hashPrefixUpdateFrequency) + } + + public var filterSetUpdateFrequency: Int { + getSettings(MaliciousSiteProtectionFeatureSettings.filterSetUpdateFrequency) + } + + private func getSettings(_ value: MaliciousSiteProtectionFeatureSettings) -> Int { + remoteSettings[value.rawValue] as? Int ?? value.defaultValue + } + +} diff --git a/Tests/BrokenSitePromptTests/BrokenSitePromptLimiterTests.swift b/Tests/BrokenSitePromptTests/BrokenSitePromptLimiterTests.swift index 7b84e4179..b583d8d85 100644 --- a/Tests/BrokenSitePromptTests/BrokenSitePromptLimiterTests.swift +++ b/Tests/BrokenSitePromptTests/BrokenSitePromptLimiterTests.swift @@ -16,8 +16,10 @@ // limitations under the License. // -import XCTest import BrowserServicesKit +import TestUtils +import XCTest + @testable import BrokenSitePrompt final class MockBrokenSitePromptLimiterStore: BrokenSitePromptLimiterStoring { diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift index 5aa9dc066..7c667b98f 100644 --- a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift +++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift @@ -171,7 +171,8 @@ final class MaliciousSiteProtectionAPIClientTests: XCTestCase { extension MaliciousSiteProtectionAPIClientTests { struct MockEnvironment: MaliciousSiteProtection.APIClientEnvironment { let timeout: TimeInterval? - func headers(for requestType: MaliciousSiteProtection.APIRequestType, platform: MaliciousSiteProtection.MaliciousSiteDetector.APIEnvironment.Platform, authToken: String?) -> Networking.APIRequestV2.HeadersV2 { .init() + func headers(for requestType: MaliciousSiteProtection.APIRequestType, platform: MaliciousSiteProtection.MaliciousSiteDetector.APIEnvironment.Platform, authToken: String?) -> Networking.APIRequestV2.HeadersV2 { + .init() } func url(for requestType: MaliciousSiteProtection.APIRequestType, platform: MaliciousSiteProtection.MaliciousSiteDetector.APIEnvironment.Platform) -> URL { MaliciousSiteDetector.APIEnvironment.production.url(for: requestType, platform: platform) diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionFeatureFlagsTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionFeatureFlagsTests.swift new file mode 100644 index 000000000..840c292e5 --- /dev/null +++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionFeatureFlagsTests.swift @@ -0,0 +1,133 @@ +// +// MaliciousSiteProtectionFeatureFlagsTests.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 TestUtils +import XCTest + +@testable import MaliciousSiteProtection + +class MaliciousSiteProtectionFeatureFlagsTests: XCTestCase { + private var sut: MaliciousSiteProtectionFeatureFlags! + private var isFeatureEnabled: Bool = false + private var configurationManagerMock: PrivacyConfigurationManagerMock! + + override func setUp() { + configurationManagerMock = PrivacyConfigurationManagerMock() + sut = MaliciousSiteProtectionFeatureFlags(privacyConfigManager: configurationManagerMock, isMaliciousSiteProtectionEnabled: { [unowned self] in isFeatureEnabled }) + } + + // MARK: - Web Error Page + + func testWhenThreatDetectionEnabled_AndFeatureFlagIsOn_ThenReturnTrue() throws { + // GIVEN + isFeatureEnabled = true + + // WHEN + let result = sut.isMaliciousSiteProtectionEnabled + + // THEN + XCTAssertTrue(result) + } + + func testWhenThreatDetectionEnabled_AndFeatureFlagIsOff_ThenReturnFalse() throws { + // GIVEN + isFeatureEnabled = false + + // WHEN + let result = sut.isMaliciousSiteProtectionEnabled + + // THEN + XCTAssertFalse(result) + } + + func testWhenThreatDetectionEnabledForDomain_AndFeatureIsAvailableForDomain_ThenReturnTrue() throws { + // GIVEN + isFeatureEnabled = true + let privacyConfigMock = configurationManagerMock.privacyConfig as! PrivacyConfigurationMock + privacyConfigMock.enabledFeatures = [.maliciousSiteProtection: ["example.com"]] + let domain = "example.com" + + // WHEN + let result = sut.shouldDetectMaliciousThreat(forDomain: domain) + + // THEN + XCTAssertTrue(result) + } + + func testWhenThreatDetectionCalledEnabledForDomain_AndFeatureIsNotAvailableForDomain_ThenReturnFalse() throws { + // GIVEN + isFeatureEnabled = true + let privacyConfigMock = configurationManagerMock.privacyConfig as! PrivacyConfigurationMock + privacyConfigMock.enabledFeatures = [.maliciousSiteProtection: []] + let domain = "example.com" + + // WHEN + let result = sut.shouldDetectMaliciousThreat(forDomain: domain) + + // THEN + XCTAssertFalse(result) + } + + func testWhenThreatDetectionEnabledForDomain_AndPrivacyConfigFeatureFlagIsOn_AndThreatDetectionSubFeatureIsOff_ThenReturnTrue() throws { + // GIVEN + let privacyConfigMock = configurationManagerMock.privacyConfig as! PrivacyConfigurationMock + privacyConfigMock.enabledFeatures = [.adClickAttribution: ["example.com"]] + let domain = "example.com" + + // WHEN + let result = sut.shouldDetectMaliciousThreat(forDomain: domain) + + // THEN + XCTAssertFalse(result) + } + + func testWhenSettingIsDefinedReturnValue() throws { + // GIVEN + let privacyConfigMock = configurationManagerMock.privacyConfig as! PrivacyConfigurationMock + privacyConfigMock.settings[.maliciousSiteProtection] = [ + MaliciousSiteProtectionFeatureSettings.hashPrefixUpdateFrequency.rawValue: 10, + MaliciousSiteProtectionFeatureSettings.filterSetUpdateFrequency.rawValue: 50 + ] + sut = MaliciousSiteProtectionFeatureFlags(privacyConfigManager: configurationManagerMock, isMaliciousSiteProtectionEnabled: { [unowned self] in isFeatureEnabled }) + + // WHEN + let hashPrefixUpdateFrequency = sut.hashPrefixUpdateFrequency + let filterSetUpdateFrequency = sut.filterSetUpdateFrequency + + // THEN + XCTAssertEqual(hashPrefixUpdateFrequency, 10) + XCTAssertEqual(filterSetUpdateFrequency, 50) + } + + func testWhenSettingIsNotDefinedReturnDefaultValue() throws { + // GIVEN + let privacyConfigMock = configurationManagerMock.privacyConfig as! PrivacyConfigurationMock + privacyConfigMock.settings[.maliciousSiteProtection] = [:] + sut = MaliciousSiteProtectionFeatureFlags(privacyConfigManager: configurationManagerMock, isMaliciousSiteProtectionEnabled: { [unowned self] in isFeatureEnabled }) + + // WHEN + let hashPrefixUpdateFrequency = sut.hashPrefixUpdateFrequency + let filterSetUpdateFrequency = sut.filterSetUpdateFrequency + + // THEN + XCTAssertEqual(hashPrefixUpdateFrequency, 20) + XCTAssertEqual(filterSetUpdateFrequency, 720) + } + +} diff --git a/Tests/MaliciousSiteProtectionTests/Mocks/PrivacyConfigurationManagerMock.swift b/Tests/MaliciousSiteProtectionTests/Mocks/PrivacyConfigurationManagerMock.swift new file mode 100644 index 000000000..49f0cff86 --- /dev/null +++ b/Tests/MaliciousSiteProtectionTests/Mocks/PrivacyConfigurationManagerMock.swift @@ -0,0 +1,158 @@ +// +// PrivacyConfigurationManagerMock.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 Combine + +class PrivacyConfigurationMock: PrivacyConfiguration { + + var identifier: String = "id" + var version: String? = "123456789" + + var userUnprotectedDomains: [String] = [] + + var tempUnprotectedDomains: [String] = [] + + var trackerAllowlist: PrivacyConfigurationData.TrackerAllowlist = .init(entries: [:], + state: PrivacyConfigurationData.State.enabled) + + var exceptionList: [PrivacyFeature: [String]] = [:] + func exceptionsList(forFeature featureKey: PrivacyFeature) -> [String] { + return exceptionList[featureKey] ?? [] + } + + var enabledFeatures: [PrivacyFeature: Set] = [:] + func isFeature(_ feature: PrivacyFeature, enabledForDomain domain: String?) -> Bool { + return enabledFeatures[feature]?.contains(domain ?? "") ?? false + } + + var enabledFeaturesForVersions: [PrivacyFeature: Set] = [:] + func isEnabled(featureKey: PrivacyFeature, versionProvider: AppVersionProvider) -> Bool { + return enabledFeaturesForVersions[featureKey]?.contains(versionProvider.appVersion() ?? "") ?? false + } + + func stateFor(featureKey: BrowserServicesKit.PrivacyFeature, versionProvider: BrowserServicesKit.AppVersionProvider) -> BrowserServicesKit.PrivacyConfigurationFeatureState { + if isEnabled(featureKey: featureKey, versionProvider: versionProvider) { + return .enabled + } + return .disabled(.disabledInConfig) // this is not used in platform tests, so mocking this poorly for now + } + + var enabledSubfeaturesForVersions: [String: Set] = [:] + func isSubfeatureEnabled(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> Bool { + return enabledSubfeaturesForVersions[subfeature.rawValue]?.contains(versionProvider.appVersion() ?? "") ?? false + } + + func stateFor(_ subfeature: any PrivacySubfeature, versionProvider: AppVersionProvider, randomizer: (Range) -> Double) -> PrivacyConfigurationFeatureState { + if isSubfeatureEnabled(subfeature, versionProvider: versionProvider, randomizer: randomizer) { + return .enabled + } + return .disabled(.disabledInConfig) // this is not used in platform tests, so mocking this poorly for now + } + + var protectedDomains = Set() + func isProtected(domain: String?) -> Bool { + return protectedDomains.contains(domain ?? "") + } + + var tempUnprotected = Set() + func isTempUnprotected(domain: String?) -> Bool { + return tempUnprotected.contains(domain ?? "") + } + + func isInExceptionList(domain: String?, forFeature featureKey: PrivacyFeature) -> Bool { + return exceptionList[featureKey]?.contains(domain ?? "") ?? false + } + + var settings: [PrivacyFeature: PrivacyConfigurationData.PrivacyFeature.FeatureSettings] = [:] + func settings(for feature: PrivacyFeature) -> PrivacyConfigurationData.PrivacyFeature.FeatureSettings { + return settings[feature] ?? [:] + } + + func settings(for subfeature: any BrowserServicesKit.PrivacySubfeature) -> PrivacyConfigurationData.PrivacyFeature.SubfeatureSettings? { + return nil + } + + var userUnprotected = Set() + func userEnabledProtection(forDomain domain: String) { + userUnprotected.remove(domain) + } + + func userDisabledProtection(forDomain domain: String) { + userUnprotected.insert(domain) + } + + func isUserUnprotected(domain: String?) -> Bool { + return userUnprotected.contains(domain ?? "") + } + + func stateFor(subfeatureID: BrowserServicesKit.SubfeatureID, parentFeatureID: BrowserServicesKit.ParentFeatureID, versionProvider: BrowserServicesKit.AppVersionProvider, randomizer: (Range) -> Double) -> BrowserServicesKit.PrivacyConfigurationFeatureState { + return .enabled + } + + func cohorts(for subfeature: any BrowserServicesKit.PrivacySubfeature) -> [BrowserServicesKit.PrivacyConfigurationData.Cohort]? { + return nil + } + + func cohorts(subfeatureID: BrowserServicesKit.SubfeatureID, parentFeatureID: BrowserServicesKit.ParentFeatureID) -> [BrowserServicesKit.PrivacyConfigurationData.Cohort]? { + return nil + } + +} + +class PrivacyConfigurationManagerMock: PrivacyConfigurationManaging { + + var embeddedConfigData: BrowserServicesKit.PrivacyConfigurationManager.ConfigurationData { + fatalError("not implemented") + } + + var fetchedConfigData: BrowserServicesKit.PrivacyConfigurationManager.ConfigurationData? { + fatalError("not implemented") + } + + var currentConfig: Data { + Data() + } + + var updatesSubject = PassthroughSubject() + var updatesPublisher: AnyPublisher { + updatesSubject.eraseToAnyPublisher() + } + + var privacyConfig: PrivacyConfiguration = PrivacyConfigurationMock() + var internalUserDecider: InternalUserDecider = DefaultInternalUserDecider() + + var reloadFired = [(etag: String?, data: Data?)]() + var reloadResult: PrivacyConfigurationManager.ReloadResult = .embedded + func reload(etag: String?, data: Data?) -> PrivacyConfigurationManager.ReloadResult { + reloadFired.append((etag, data)) + return reloadResult + } + +} + +final class MockInternalUserStoring: InternalUserStoring { + var isInternalUser: Bool = false +} + +extension DefaultInternalUserDecider { + convenience init(mockedStore: MockInternalUserStoring = MockInternalUserStoring()) { + self.init(store: mockedStore) + } +} From 4232acb81dc42311832cdaab042cbcafd530f9bc Mon Sep 17 00:00:00 2001 From: Sabrina Tardio <44158575+SabrinaTardio@users.noreply.github.com> Date: Thu, 23 Jan 2025 14:09:41 +0100 Subject: [PATCH 07/28] tds experiment metrics (#1172) **Required**: Task/Issue URL: https://app.asana.com/0/1204186595873227/1208547600159201/f iOS PR: https://github.com/duckduckgo/iOS/pull/3839 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3755/files What kind of version bump will this require?: Major **Optional**: Tech Design URL: https://app.asana.com/0/1204186595873227/1209001242859416/f CC: **Description**: Adds pixel reporting for TDS override experiment --- Package.swift | 6 +- .../TrackerDataURLOverrider.swift | 16 +-- .../PageRefreshMonitor.swift | 45 ++++---- .../TDSOverrideExperimentMetrics.swift | 92 ++++++++++++++++ .../TrackerDataURLOverriderTests.swift | 22 ++-- .../PageRefreshMonitorTests.swift | 68 ++++++++---- .../TDSOverrideExperimentMetricsTests.swift | 101 ++++++++++++++++++ 7 files changed, 285 insertions(+), 65 deletions(-) create mode 100644 Sources/PixelExperimentKit/TDSOverrideExperimentMetrics.swift create mode 100644 Tests/PixelExperimentKitTests/TDSOverrideExperimentMetricsTests.swift diff --git a/Package.swift b/Package.swift index 5b25a5a65..33a3f997f 100644 --- a/Package.swift +++ b/Package.swift @@ -440,7 +440,8 @@ let package = Package( name: "PixelExperimentKit", dependencies: [ "PixelKit", - "BrowserServicesKit" + "BrowserServicesKit", + "Configuration" ], resources: [ .process("Resources") @@ -703,7 +704,8 @@ let package = Package( .testTarget( name: "PixelExperimentKitTests", dependencies: [ - "PixelExperimentKit" + "PixelExperimentKit", + "Configuration" ] ), .testTarget( diff --git a/Sources/Configuration/TrackerDataURLOverrider.swift b/Sources/Configuration/TrackerDataURLOverrider.swift index d2c272c2a..8e44436be 100644 --- a/Sources/Configuration/TrackerDataURLOverrider.swift +++ b/Sources/Configuration/TrackerDataURLOverrider.swift @@ -30,7 +30,7 @@ public final class TrackerDataURLOverrider: TrackerDataURLProviding { var featureFlagger: FeatureFlagger public enum Constants { - public static let baseTdsURLString = "https://staticcdn.duckduckgo.com/trackerblocking/" + public static let baseTDSURLString = "https://staticcdn.duckduckgo.com/trackerblocking/" } public init (privacyConfigurationManager: PrivacyConfigurationManaging, @@ -40,8 +40,8 @@ public final class TrackerDataURLOverrider: TrackerDataURLProviding { } public var trackerDataURL: URL? { - for experimentType in TdsExperimentType.allCases { - if let cohort = featureFlagger.getCohortIfEnabled(for: experimentType.experiment) as? TdsNextExperimentFlag.Cohort, + for experimentType in TDSExperimentType.allCases { + if let cohort = featureFlagger.getCohortIfEnabled(for: experimentType.experiment) as? TDSNextExperimentFlag.Cohort, let url = trackerDataURL(for: experimentType.subfeature, cohort: cohort) { return url } @@ -49,13 +49,13 @@ public final class TrackerDataURLOverrider: TrackerDataURLProviding { return nil } - private func trackerDataURL(for subfeature: any PrivacySubfeature, cohort: TdsNextExperimentFlag.Cohort) -> URL? { + private func trackerDataURL(for subfeature: any PrivacySubfeature, cohort: TDSNextExperimentFlag.Cohort) -> URL? { guard let settings = privacyConfigurationManager.privacyConfig.settings(for: subfeature), let jsonData = settings.data(using: .utf8) else { return nil } do { if let settingsDict = try JSONSerialization.jsonObject(with: jsonData) as? [String: String], let urlString = cohort == .control ? settingsDict["controlUrl"] : settingsDict["treatmentUrl"] { - return URL(string: Constants.baseTdsURLString + urlString) + return URL(string: Constants.baseTDSURLString + urlString) } } catch { Logger.config.info("privacyConfiguration: Failed to parse subfeature settings JSON: \(error)") @@ -64,7 +64,7 @@ public final class TrackerDataURLOverrider: TrackerDataURLProviding { } } -public enum TdsExperimentType: Int, CaseIterable { +public enum TDSExperimentType: Int, CaseIterable { case baseline case feb25 case mar25 @@ -79,7 +79,7 @@ public enum TdsExperimentType: Int, CaseIterable { case dec25 public var experiment: any FeatureFlagExperimentDescribing { - TdsNextExperimentFlag(subfeature: self.subfeature) + TDSNextExperimentFlag(subfeature: self.subfeature) } public var subfeature: any PrivacySubfeature { @@ -113,7 +113,7 @@ public enum TdsExperimentType: Int, CaseIterable { } -public struct TdsNextExperimentFlag: FeatureFlagExperimentDescribing { +public struct TDSNextExperimentFlag: FeatureFlagExperimentDescribing { public var rawValue: String public var source: FeatureFlagSource diff --git a/Sources/PageRefreshMonitor/PageRefreshMonitor.swift b/Sources/PageRefreshMonitor/PageRefreshMonitor.swift index a52eb66ba..0028f0458 100644 --- a/Sources/PageRefreshMonitor/PageRefreshMonitor.swift +++ b/Sources/PageRefreshMonitor/PageRefreshMonitor.swift @@ -25,12 +25,6 @@ public extension Notification.Name { } -public protocol PageRefreshStoring { - - var refreshTimestamps: [Date] { get set } - -} - public protocol PageRefreshMonitoring { func register(for url: URL, date: Date) @@ -50,43 +44,48 @@ public extension PageRefreshMonitoring { /// /// Triggers `onDidDetectRefreshPattern` and posts a `pageRefreshMonitorDidDetectRefreshPattern` notification /// if three refreshes occur within a 20-second window. +/// Triggers `onDidDetectRefreshPattern` +/// if two refresh occur within a 12-second window public final class PageRefreshMonitor: PageRefreshMonitoring { - private let onDidDetectRefreshPattern: () -> Void - private var store: PageRefreshStoring + public typealias NumberOfRefreshes = Int + private let onDidDetectRefreshPattern: (_ numberOfRefreshes: NumberOfRefreshes) -> Void private var lastRefreshedURL: URL? - public init(onDidDetectRefreshPattern: @escaping () -> Void, - store: PageRefreshStoring) { + public init(onDidDetectRefreshPattern: @escaping (NumberOfRefreshes) -> Void) { self.onDidDetectRefreshPattern = onDidDetectRefreshPattern - self.store = store } - var refreshTimestamps: [Date] { - get { store.refreshTimestamps } - set { store.refreshTimestamps = newValue } - } + var refreshTimestamps2x: [Date] = [] + var refreshTimestamps3x: [Date] = [] public func register(for url: URL, date: Date = Date()) { resetIfURLChanged(to: url) // Add the new refresh timestamp - refreshTimestamps.append(date) + refreshTimestamps2x.append(date) + refreshTimestamps3x.append(date) - // Retain only timestamps within the last 20 seconds - refreshTimestamps = refreshTimestamps.filter { date.timeIntervalSince($0) < 20.0 } + let refreshesInLast12Secs = refreshTimestamps2x.filter { date.timeIntervalSince($0) < 12.0 } + let refreshesInLast20Secs = refreshTimestamps3x.filter { date.timeIntervalSince($0) < 20.0 } - // Trigger detection if three refreshes occurred within 20 seconds, then reset timestamps - if refreshTimestamps.count > 2 { - onDidDetectRefreshPattern() + // Trigger detection if two refreshes occurred within 12 seconds + if refreshesInLast12Secs.count > 1 { + onDidDetectRefreshPattern(2) + refreshTimestamps2x.removeAll() + } + // Trigger detection if three refreshes occurred within 20 seconds + if refreshesInLast20Secs.count > 2 { + onDidDetectRefreshPattern(3) NotificationCenter.default.post(name: .pageRefreshMonitorDidDetectRefreshPattern, object: self) - refreshTimestamps.removeAll() // Reset timestamps after detection + refreshTimestamps3x.removeAll() // Reset timestamps after detection } } private func resetIfURLChanged(to newURL: URL) { if lastRefreshedURL != newURL { - refreshTimestamps.removeAll() + refreshTimestamps2x.removeAll() + refreshTimestamps3x.removeAll() lastRefreshedURL = newURL } } diff --git a/Sources/PixelExperimentKit/TDSOverrideExperimentMetrics.swift b/Sources/PixelExperimentKit/TDSOverrideExperimentMetrics.swift new file mode 100644 index 000000000..626975778 --- /dev/null +++ b/Sources/PixelExperimentKit/TDSOverrideExperimentMetrics.swift @@ -0,0 +1,92 @@ +// +// TDSOverrideExperimentMetrics.swift +// +// Copyright © 2025 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 PixelKit +import Configuration +import BrowserServicesKit + +public enum TDSExperimentMetricType: String { + /// Metric triggered when the privacy toggle is used. + case privacyToggleUsed = "privacyToggleUsed" + /// Metric triggered after 2 quick refreshes. + case refresh2X = "2XRefresh" + /// Metric triggered after 3 quick refreshes. + case refresh3X = "3XRefresh" +} + +public struct TDSOverrideExperimentMetrics { + + public typealias FirePixelExperiment = (SubfeatureID, String, ConversionWindow, String) -> Void + public typealias FireDebugExperiment = (_ parameters: [String: String]) -> Void + + private struct ExperimentConfig { + static var firePixelExperiment: FirePixelExperiment = { subfeatureID, metric, conversionWindow, value in + PixelKit.fireExperimentPixel(for: subfeatureID, metric: metric, conversionWindowDays: conversionWindow, value: value) + } + } + + static func configureTDSOverrideExperimentMetrics(firePixelExperiment: @escaping FirePixelExperiment) { + ExperimentConfig.firePixelExperiment = firePixelExperiment + } + + public static var activeTDSExperimentNameWithCohort: String? { + guard let featureFlagger = PixelKit.ExperimentConfig.featureFlagger else { return nil } + + return TDSExperimentType.allCases.compactMap { experimentType in + guard let experimentData = featureFlagger.getAllActiveExperiments()[experimentType.subfeature.rawValue] else { return nil } + return "\(experimentType.subfeature.rawValue)_\(experimentData.cohortID)" + }.first + } + + public static func fireTDSExperimentMetric( metricType: TDSExperimentMetricType, + etag: String, + fireDebugExperiment: @escaping FireDebugExperiment) { + for experiment in TDSExperimentType.allCases { + for day in 0...5 { + ExperimentConfig.firePixelExperiment(experiment.subfeature.rawValue, + metricType.rawValue, + day...day, + "1" + ) + fireDebugBreakageExperiment(experimentType: experiment, + etag: etag, + fire: fireDebugExperiment + ) + } + } + } + + private static func fireDebugBreakageExperiment(experimentType: TDSExperimentType, + etag: String, + fire: @escaping FireDebugExperiment) { + guard + let featureFlagger = PixelKit.ExperimentConfig.featureFlagger, + let experimentData = featureFlagger.getAllActiveExperiments()[experimentType.subfeature.rawValue] + else { return } + + let experimentName: String = experimentType.subfeature.rawValue + experimentData.cohortID + let enrolmentDate = experimentData.enrollmentDate.toYYYYMMDDInET() + let parameters = [ + "experiment": experimentName, + "enrolmentDate": enrolmentDate, + "tdsEtag": etag + ] + fire(parameters) + } +} diff --git a/Tests/ConfigurationTests/TrackerDataURLOverriderTests.swift b/Tests/ConfigurationTests/TrackerDataURLOverriderTests.swift index c26b59dc6..93c0aa3ad 100644 --- a/Tests/ConfigurationTests/TrackerDataURLOverriderTests.swift +++ b/Tests/ConfigurationTests/TrackerDataURLOverriderTests.swift @@ -45,7 +45,7 @@ final class TrackerDataURLOverriderTests: XCTestCase { func testTrackerDataURL_forControlCohort_returnsControlUrl() throws { // GIVEN mockFeatureFlagger.mockCohorts = [ - TdsExperimentType(rawValue: 0)!.subfeature.rawValue: TdsNextExperimentFlag.Cohort.control] + TDSExperimentType(rawValue: 0)!.subfeature.rawValue: TDSNextExperimentFlag.Cohort.control] let privacyConfig = MockPrivacyConfiguration() privacyConfig.subfeatureSettings = "{ \"controlUrl\": \"\(controlURL)\", \"treatmentUrl\": \"\(treatmentURL)\"}" mockPrivacyConfigurationManager.privacyConfig = privacyConfig @@ -54,13 +54,13 @@ final class TrackerDataURLOverriderTests: XCTestCase { let url = try XCTUnwrap(urlProvider.trackerDataURL) // THEN - XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTdsURLString + controlURL) + XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTDSURLString + controlURL) } func testTrackerDataURL_forTreatmentCohort_returnsTreatmentUrl() throws { // GIVEN mockFeatureFlagger.mockCohorts = [ - TdsExperimentType(rawValue: 0)!.subfeature.rawValue: TdsNextExperimentFlag.Cohort.treatment] + TDSExperimentType(rawValue: 0)!.subfeature.rawValue: TDSNextExperimentFlag.Cohort.treatment] let privacyConfig = MockPrivacyConfiguration() privacyConfig.subfeatureSettings = "{ \"controlUrl\": \"\(controlURL)\", \"treatmentUrl\": \"\(treatmentURL)\"}" mockPrivacyConfigurationManager.privacyConfig = privacyConfig @@ -69,13 +69,13 @@ final class TrackerDataURLOverriderTests: XCTestCase { let url = try XCTUnwrap(urlProvider.trackerDataURL) // THEN - XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTdsURLString + treatmentURL) + XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTDSURLString + treatmentURL) } func testTrackerDataURL_ifNoSettings_returnsDefaultURL() throws { // GIVEN mockFeatureFlagger.mockCohorts = [ - TdsExperimentType(rawValue: 0)!.subfeature.rawValue: TdsNextExperimentFlag.Cohort.treatment] + TDSExperimentType(rawValue: 0)!.subfeature.rawValue: TDSNextExperimentFlag.Cohort.treatment] let privacyConfig = MockPrivacyConfiguration() mockPrivacyConfigurationManager.privacyConfig = privacyConfig @@ -106,19 +106,19 @@ final class TrackerDataURLOverriderTests: XCTestCase { let thirdExperimentTreatmentURL = "third-treatment.json" let privacyConfig = MockPrivacyConfiguration() privacyConfig.mockSubfeatureSettings = [ - TdsExperimentType(rawValue: 0)!.subfeature.rawValue: """ + TDSExperimentType(rawValue: 0)!.subfeature.rawValue: """ { "controlUrl": "\(firstExperimentControlURL)", "treatmentUrl": "first-treatment.json" } """, - TdsExperimentType(rawValue: 1)!.subfeature.rawValue: """ + TDSExperimentType(rawValue: 1)!.subfeature.rawValue: """ { "controlUrl": "second-control.json", "treatmentUrl": "\(secondExperimentTreatmentURL)" } """, - TdsExperimentType(rawValue: 2)!.subfeature.rawValue: """ + TDSExperimentType(rawValue: 2)!.subfeature.rawValue: """ { "controlUrl": "third-control.json", "treatmentUrl": "\(thirdExperimentTreatmentURL)" @@ -127,15 +127,15 @@ final class TrackerDataURLOverriderTests: XCTestCase { ] mockPrivacyConfigurationManager.privacyConfig = privacyConfig mockFeatureFlagger.mockCohorts = [ - TdsExperimentType(rawValue: 1)!.subfeature.rawValue: TdsNextExperimentFlag.Cohort.treatment, - TdsExperimentType(rawValue: 2)!.subfeature.rawValue: TdsNextExperimentFlag.Cohort.treatment + TDSExperimentType(rawValue: 1)!.subfeature.rawValue: TDSNextExperimentFlag.Cohort.treatment, + TDSExperimentType(rawValue: 2)!.subfeature.rawValue: TDSNextExperimentFlag.Cohort.treatment ] // WHEN let url = try XCTUnwrap(urlProvider.trackerDataURL) // THEN - XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTdsURLString + secondExperimentTreatmentURL) + XCTAssertEqual(url.absoluteString, TrackerDataURLOverrider.Constants.baseTDSURLString + secondExperimentTreatmentURL) } } diff --git a/Tests/PageRefreshMonitorTests/PageRefreshMonitorTests.swift b/Tests/PageRefreshMonitorTests/PageRefreshMonitorTests.swift index 174e1ffa6..dd21e77b2 100644 --- a/Tests/PageRefreshMonitorTests/PageRefreshMonitorTests.swift +++ b/Tests/PageRefreshMonitorTests/PageRefreshMonitorTests.swift @@ -20,30 +20,30 @@ import XCTest import Common @testable import PageRefreshMonitor -final class MockPageRefreshStore: PageRefreshStoring { - - var refreshTimestamps: [Date] = [] - -} - final class PageRefreshMonitorTests: XCTestCase { var monitor: PageRefreshMonitor! - var detectionCount: Int = 0 + var detectionParameters: [Int] = [] override func setUp() { super.setUp() - monitor = PageRefreshMonitor(onDidDetectRefreshPattern: { self.detectionCount += 1 }, - store: MockPageRefreshStore()) + monitor = PageRefreshMonitor(onDidDetectRefreshPattern: { patternCount in self.detectionParameters.append(patternCount)}) } // MARK: - Pattern Detection Tests - func testDoesNotDetectEventWhenRefreshesAreFewerThanThree() { + func testDoesNotDetectEventWhenRefreshesAreFewerThanTwo() { let url = URL(string: "https://example.com/pageA")! monitor.register(for: url) + XCTAssertEqual(detectionParameters.count, 0) + } + + func testDetectsEventWhenTwoRefreshesOccurOnSameURL() { + let url = URL(string: "https://example.com/pageA")! monitor.register(for: url) - XCTAssertEqual(detectionCount, 0) + monitor.register(for: url) + XCTAssertEqual(detectionParameters.count, 1) + XCTAssertEqual(detectionParameters, [2]) } func testDetectsEventWhenThreeRefreshesOccurOnSameURL() { @@ -51,7 +51,8 @@ final class PageRefreshMonitorTests: XCTestCase { monitor.register(for: url) monitor.register(for: url) monitor.register(for: url) - XCTAssertEqual(detectionCount, 1) + XCTAssertEqual(detectionParameters.count, 2) + XCTAssertEqual(detectionParameters, [2, 3]) } func testDetectsEventTwiceWhenSixRefreshesOccurOnSameURL() { @@ -59,7 +60,8 @@ final class PageRefreshMonitorTests: XCTestCase { for _ in 1...6 { monitor.register(for: url) } - XCTAssertEqual(detectionCount, 2) + XCTAssertEqual(detectionParameters.count, 5) + XCTAssertEqual(detectionParameters, [2, 3, 2, 2, 3]) } // MARK: - URL Change Handling @@ -70,7 +72,7 @@ final class PageRefreshMonitorTests: XCTestCase { monitor.register(for: urlA) monitor.register(for: urlB) monitor.register(for: urlA) - XCTAssertEqual(detectionCount, 0) + XCTAssertEqual(detectionParameters.count, 0) } func testStartsNewCounterWhenURLChangesAndRegistersNewRefreshes() { @@ -79,21 +81,42 @@ final class PageRefreshMonitorTests: XCTestCase { monitor.register(for: urlA) monitor.register(for: urlA) monitor.register(for: urlB) - XCTAssertEqual(detectionCount, 0) + XCTAssertEqual(detectionParameters.count, 1) + XCTAssertEqual(detectionParameters, [2]) monitor.register(for: urlB) monitor.register(for: urlB) - XCTAssertEqual(detectionCount, 1) + XCTAssertEqual(detectionParameters.count, 3) + XCTAssertEqual(detectionParameters, [2, 2, 3]) } // MARK: - Timed Pattern Detection - func testDoesNotDetectEventIfThreeRefreshesOccurAfter20Seconds() { + func testDoesNotDetectEventIfThreeRefreshesOccurAfter12Seconds() { + let url = URL(string: "https://example.com/pageA")! + let date = Date() + monitor.register(for: url, date: date) + monitor.register(for: url, date: date + 13) // 13 seconds after the first event + XCTAssertEqual(detectionParameters.count, 0) + } + + func testDoesDetect2xEventIfSecondRefreshesOccurAfter12SecondsAndThirdAfter20Seconds() { + let url = URL(string: "https://example.com/pageA")! + let date = Date() + monitor.register(for: url, date: date) + monitor.register(for: url, date: date + 13) // 13 seconds after the first event + monitor.register(for: url, date: date + 21) // 21 seconds after the first event 8 seconds after second event + XCTAssertEqual(detectionParameters.count, 1) + XCTAssertEqual(detectionParameters, [2]) + } + + func testDoesDetect2xEventIfThreeRefreshesOccurAfter20Seconds() { let url = URL(string: "https://example.com/pageA")! let date = Date() monitor.register(for: url, date: date) monitor.register(for: url, date: date) monitor.register(for: url, date: date + 21) // 21 seconds after the first event - XCTAssertEqual(detectionCount, 0) + XCTAssertEqual(detectionParameters.count, 1) + XCTAssertEqual(detectionParameters, [2]) } func testDetectsEventIfThreeRefreshesOccurWithin20Seconds() { @@ -102,7 +125,8 @@ final class PageRefreshMonitorTests: XCTestCase { monitor.register(for: url, date: date) monitor.register(for: url, date: date) monitor.register(for: url, date: date + 19) // 19 seconds after the first event - XCTAssertEqual(detectionCount, 1) + XCTAssertEqual(detectionParameters.count, 2) + XCTAssertEqual(detectionParameters, [2, 3]) } func testDetectsEventIfRefreshesAreWithinOverall20SecondWindow() { @@ -111,9 +135,11 @@ final class PageRefreshMonitorTests: XCTestCase { monitor.register(for: url, date: date) monitor.register(for: url, date: date + 19) // 19 seconds after the first event monitor.register(for: url, date: date + 21) // 21 seconds after the first event (2 seconds after second event) - XCTAssertEqual(detectionCount, 0) + XCTAssertEqual(detectionParameters.count, 1) + XCTAssertEqual(detectionParameters, [2]) monitor.register(for: url, date: date + 23) // 23 seconds after the first event (4 seconds after second event) - XCTAssertEqual(detectionCount, 1) + XCTAssertEqual(detectionParameters.count, 2) + XCTAssertEqual(detectionParameters, [2, 3]) } } diff --git a/Tests/PixelExperimentKitTests/TDSOverrideExperimentMetricsTests.swift b/Tests/PixelExperimentKitTests/TDSOverrideExperimentMetricsTests.swift new file mode 100644 index 000000000..95cf05c2f --- /dev/null +++ b/Tests/PixelExperimentKitTests/TDSOverrideExperimentMetricsTests.swift @@ -0,0 +1,101 @@ +// +// TDSOverrideExperimentMetricsTests.swift +// +// Copyright © 2025 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 PixelExperimentKit +import BrowserServicesKit +import Configuration +import PixelKit + +final class TDSOverrideExperimentMetricsTests: XCTestCase { + + var mockFeatureFlagger: MockFeatureFlagger! + var pixelCalls: [(SubfeatureID, String, ClosedRange, String)] = [] + var debugCalls: [[String: String]] = [] + + override func setUpWithError() throws { + mockFeatureFlagger = MockFeatureFlagger() + PixelKit.configureExperimentKit(featureFlagger: mockFeatureFlagger, eventTracker: ExperimentEventTracker(store: MockExperimentActionPixelStore()), fire: { _, _, _ in }) + TDSOverrideExperimentMetrics.configureTDSOverrideExperimentMetrics { subfeatureID, metric, conversionWindow, value in + self.pixelCalls.append((subfeatureID, metric, conversionWindow, value)) + } + } + + override func tearDownWithError() throws { + mockFeatureFlagger = nil + } + + func test_OnfireTdsExperimentMetricPrivacyToggleUsed_WhenExperimentActive_ThenCorrectPixelFunctionsCalled() { + // GIVEN + mockFeatureFlagger.experiments = [ + TDSExperimentType.allCases[3].subfeature.rawValue: ExperimentData(parentID: "someParentID", cohortID: "testCohort", enrollmentDate: Date()) + ] + + // WHEN + TDSOverrideExperimentMetrics.fireTDSExperimentMetric(metricType: .privacyToggleUsed, etag: "testEtag") { parameters in + self.debugCalls.append(parameters) + } + + // THEN + XCTAssertEqual(pixelCalls.count, TDSExperimentType.allCases.count * 6, "firePixelExperiment should be called for each experiment and each conversionWindow 0...5.") + XCTAssertEqual(pixelCalls.first?.0, TDSExperimentType.allCases[0].subfeature.rawValue, "expected SubfeatureID should be passed as parameter") + XCTAssertEqual(pixelCalls.first?.1, "privacyToggleUsed", "expected metric should be passed as parameter") + XCTAssertEqual(pixelCalls.first?.2, 0...0, "expected Conversion Window should be passed as parameter") + XCTAssertEqual(pixelCalls.first?.3, "1", "expected Value should be passed as parameter") + XCTAssertEqual(debugCalls.count, 6, "fireDebugExperiment should be called for each conversionWindow on one experiment.") + XCTAssertEqual(debugCalls.first?["tdsEtag"], "testEtag") + XCTAssertEqual(debugCalls.first?["experiment"], "\(TDSExperimentType.allCases[3].experiment.rawValue)testCohort") + } + + func test_OnfireTdsExperimentMetricPrivacyToggleUsed_WhenNoExperimentActive_ThenCorrectPixelFunctionsCalled() { + // WHEN + TDSOverrideExperimentMetrics.fireTDSExperimentMetric(metricType: .privacyToggleUsed, etag: "testEtag") { parameters in + self.debugCalls.append(parameters) + } + + // THEN + XCTAssertEqual(pixelCalls.count, TDSExperimentType.allCases.count * 6, "firePixelExperiment should be called for each experiment and each conversionWindow 0...5.") + XCTAssertEqual(pixelCalls.first?.0, TDSExperimentType.allCases[0].subfeature.rawValue, "expected SubfeatureID should be passed as parameter") + XCTAssertEqual(pixelCalls.first?.1, "privacyToggleUsed", "expected metric should be passed as parameter") + XCTAssertEqual(pixelCalls.first?.2, 0...0, "expected Conversion Window should be passed as parameter") + XCTAssertEqual(pixelCalls.first?.3, "1", "expected Value should be passed as parameter") + XCTAssertTrue(debugCalls.isEmpty) + } + + func test_OnGetActiveTDSExperimentNameWithCohort_WhenExperimentActive_ThenCorrectExperimentNameReturned() { + // GIVEN + mockFeatureFlagger.experiments = [ + TDSExperimentType.allCases[3].subfeature.rawValue: ExperimentData(parentID: "someParentID", cohortID: "testCohort", enrollmentDate: Date()) + ] + + // WHEN + let experimentName = TDSOverrideExperimentMetrics.activeTDSExperimentNameWithCohort + + // THEN + XCTAssertEqual(experimentName, "\(TDSExperimentType.allCases[3].subfeature.rawValue)_testCohort") + } + + func test_OnGetActiveTDSExperimentNameWithCohort_WhenNoExperimentActive_ThenCorrectExperimentNameReturned() { + // WHEN + let experimentName = TDSOverrideExperimentMetrics.activeTDSExperimentNameWithCohort + + // THEN + XCTAssertNil(experimentName) + } + +} From 123efda94c60487ecd71e6f9870902cee3c89710 Mon Sep 17 00:00:00 2001 From: amddg44 Date: Thu, 23 Jan 2025 21:12:59 +0100 Subject: [PATCH 08/28] Migration of data import code to BSK for re-use on iOS (#1184) Task/Issue URL: https://app.asana.com/0/0/1209117022539264/f iOS PR: https://github.com/duckduckgo/iOS/pull/3855 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3768 What kind of version bump will this require?: Minor **Description**: Migration of data import code from macOS to BSK as needed for iOS 18.2 Safari data importing --- Package.swift | 3 +- .../Bookmarks/BookmarksImportSummary.swift | 38 ++ .../DataImport/DataImport.swift | 386 ++++++++++++++++++ .../AutofillLoginImportStateStoring.swift | 22 + .../DataImport/Logins/CSV/CSVImporter.swift | 341 ++++++++++++++++ .../DataImport/Logins/CSV/CSVParser.swift | 303 ++++++++++++++ .../DataImport/Logins/LoginImport.swift | 44 ++ .../SecureVaultLoginImporter.swift | 114 ++++++ .../DataImport/TaskWithProgress.swift | 169 ++++++++ .../DataImport/CSVImporterTests.swift | 133 ++++++ .../DataImport/CSVParserTests.swift | 226 ++++++++++ .../DataImport/MockLoginImporter.swift | 33 ++ .../DataImport/TemporaryFileCreator.swift | 53 +++ .../WebsiteAccount_isDuplicateTests.swift | 150 +++++++ 14 files changed, 2014 insertions(+), 1 deletion(-) create mode 100644 Sources/BrowserServicesKit/DataImport/Bookmarks/BookmarksImportSummary.swift create mode 100644 Sources/BrowserServicesKit/DataImport/DataImport.swift create mode 100644 Sources/BrowserServicesKit/DataImport/Logins/AutofillLoginImportStateStoring.swift create mode 100644 Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVImporter.swift create mode 100644 Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVParser.swift create mode 100644 Sources/BrowserServicesKit/DataImport/Logins/LoginImport.swift create mode 100644 Sources/BrowserServicesKit/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift create mode 100644 Sources/BrowserServicesKit/DataImport/TaskWithProgress.swift create mode 100644 Tests/BrowserServicesKitTests/DataImport/CSVImporterTests.swift create mode 100644 Tests/BrowserServicesKitTests/DataImport/CSVParserTests.swift create mode 100644 Tests/BrowserServicesKitTests/DataImport/MockLoginImporter.swift create mode 100644 Tests/BrowserServicesKitTests/DataImport/TemporaryFileCreator.swift create mode 100644 Tests/BrowserServicesKitTests/DataImport/WebsiteAccount_isDuplicateTests.swift diff --git a/Package.swift b/Package.swift index 33a3f997f..86e14f60c 100644 --- a/Package.swift +++ b/Package.swift @@ -75,7 +75,8 @@ let package = Package( "UserScript", "ContentBlocking", "SecureStorage", - "Subscription" + "Subscription", + "PixelKit" ], resources: [ .process("ContentBlocking/UserScripts/contentblockerrules.js"), diff --git a/Sources/BrowserServicesKit/DataImport/Bookmarks/BookmarksImportSummary.swift b/Sources/BrowserServicesKit/DataImport/Bookmarks/BookmarksImportSummary.swift new file mode 100644 index 000000000..9b6a1c9ad --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Bookmarks/BookmarksImportSummary.swift @@ -0,0 +1,38 @@ +// +// BookmarksImportSummary.swift +// +// Copyright © 2025 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 struct BookmarksImportSummary: Equatable { + public var successful: Int + public var duplicates: Int + public var failed: Int + + public init(successful: Int, duplicates: Int, failed: Int) { + self.successful = successful + self.duplicates = duplicates + self.failed = failed + } + + public static func += (left: inout BookmarksImportSummary, right: BookmarksImportSummary) { + left.successful += right.successful + left.duplicates += right.duplicates + left.failed += right.failed + } + +} diff --git a/Sources/BrowserServicesKit/DataImport/DataImport.swift b/Sources/BrowserServicesKit/DataImport/DataImport.swift new file mode 100644 index 000000000..f33a1d99f --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/DataImport.swift @@ -0,0 +1,386 @@ +// +// DataImport.swift +// +// Copyright © 2021 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 SecureStorage +import PixelKit +import Foundation + +public enum DataImport { + + public enum Source: String, RawRepresentable, CaseIterable, Equatable { + case brave + case chrome + case chromium + case coccoc + case edge + case firefox + case opera + case operaGX + case safari + case safariTechnologyPreview + case tor + case vivaldi + case yandex + case onePassword8 + case onePassword7 + case bitwarden + case lastPass + case csv + case bookmarksHTML + + static let preferredSources: [Self] = [.chrome, .safari] + + } + + public enum DataType: String, Hashable, CaseIterable, CustomStringConvertible { + + case bookmarks + case passwords + + public var description: String { rawValue } + + public var importAction: DataImportAction { + switch self { + case .bookmarks: .bookmarks + case .passwords: .passwords + } + } + + } + + public struct DataTypeSummary: Equatable { + public let successful: Int + public let duplicate: Int + public let failed: Int + + public var isEmpty: Bool { + self == .empty + } + + public static var empty: Self { + DataTypeSummary(successful: 0, duplicate: 0, failed: 0) + } + + public init(successful: Int, duplicate: Int, failed: Int) { + self.successful = successful + self.duplicate = duplicate + self.failed = failed + } + public init(_ bookmarksImportSummary: BookmarksImportSummary) { + self.init(successful: bookmarksImportSummary.successful, duplicate: bookmarksImportSummary.duplicates, failed: bookmarksImportSummary.failed) + } + } + + public enum ErrorType: String, CustomStringConvertible, CaseIterable { + case noData + case decryptionError + case dataCorrupted + case keychainError + case other + + public var description: String { rawValue } + } + +} + +public enum DataImportAction: String, RawRepresentable { + case bookmarks + case passwords + case favicons + case generic + + public init(_ type: DataImport.DataType) { + switch type { + case .bookmarks: self = .bookmarks + case .passwords: self = .passwords + } + } +} + +public protocol DataImportError: Error, CustomNSError, ErrorWithPixelParameters, LocalizedError { + associatedtype OperationType: RawRepresentable where OperationType.RawValue == Int + + var action: DataImportAction { get } + var type: OperationType { get } + var underlyingError: Error? { get } + + var errorType: DataImport.ErrorType { get } + +} +extension DataImportError /* : CustomNSError */ { + public var errorCode: Int { + type.rawValue + } + + public var errorUserInfo: [String: Any] { + guard let underlyingError else { return [:] } + return [ + NSUnderlyingErrorKey: underlyingError + ] + } +} +extension DataImportError /* : ErrorWithParameters */ { + public var errorParameters: [String: String] { + underlyingError?.pixelParameters ?? [:] + } +} +extension DataImportError /* : LocalizedError */ { + + public var errorDescription: String? { + let error = (self as NSError) + return "\(error.domain) \(error.code)" + { + guard let underlyingError = underlyingError as NSError? else { return "" } + return " (\(underlyingError.domain) \(underlyingError.code))" + }() + } + +} + +public struct FetchableRecordError: Error, CustomNSError { + let column: Int + + public static var errorDomain: String { "FetchableRecordError.\(T.self)" } + public var errorCode: Int { column } + + public init(column: Int) { + self.column = column + } + +} + +public enum DataImportProgressEvent { + case initial + case importingPasswords(numberOfPasswords: Int?, fraction: Double) + case importingBookmarks(numberOfBookmarks: Int?, fraction: Double) + case done +} + +public typealias DataImportSummary = [DataImport.DataType: DataImportResult] +public typealias DataImportTask = TaskWithProgress +public typealias DataImportProgressCallback = DataImportTask.ProgressUpdateCallback + +/// Represents an object able to import data from an outside source. The outside source may be capable of importing multiple types of data. +/// For instance, a browser data importer may be able to import passwords and bookmarks. +public protocol DataImporter { + + /// Performs a quick check to determine if the data is able to be imported. It does not guarantee that the import will succeed. + /// For example, a CSV importer will return true if the URL it has been created with is a CSV file, but does not check whether the CSV data matches the expected format. + var importableTypes: [DataImport.DataType] { get } + + /// validate file access/encryption password requirement before starting import. Returns non-empty dictionary with failures if access validation fails. + func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? + /// Start import process. Returns cancellable TaskWithProgress + func importData(types: Set) -> DataImportTask + + func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool + +} + +extension DataImporter { + + public var importableTypes: [DataImport.DataType] { + [.bookmarks, .passwords] + } + + public func validateAccess(for types: Set) -> [DataImport.DataType: any DataImportError]? { + nil + } + + public func requiresKeychainPassword(for selectedDataTypes: Set) -> Bool { + false + } + +} + +public enum DataImportResult: CustomStringConvertible { + case success(T) + case failure(any DataImportError) + + public func get() throws -> T { + switch self { + case .success(let value): + return value + case .failure(let error): + throw error + } + } + + public var isSuccess: Bool { + if case .success = self { + true + } else { + false + } + } + + public var error: (any DataImportError)? { + if case .failure(let error) = self { + error + } else { + nil + } + } + + /// Returns a new result, mapping any success value using the given transformation. + /// - Parameter transform: A closure that takes the success value of this instance. + /// - Returns: A `Result` instance with the result of evaluating `transform` + /// as the new success value if this instance represents a success. + @inlinable public func map(_ transform: (T) -> NewT) -> DataImportResult { + switch self { + case .success(let value): + return .success(transform(value)) + case .failure(let error): + return .failure(error) + } + } + + /// Returns a new result, mapping any success value using the given transformation and unwrapping the produced result. + /// + /// - Parameter transform: A closure that takes the success value of the instance. + /// - Returns: A `Result` instance, either from the closure or the previous + /// `.failure`. + @inlinable public func flatMap(_ transform: (T) throws -> DataImportResult) rethrows -> DataImportResult { + switch self { + case .success(let value): + switch try transform(value) { + case .success(let transformedValue): + return .success(transformedValue) + case .failure(let error): + return .failure(error) + } + case .failure(let error): + return .failure(error) + } + } + + public var description: String { + switch self { + case .success(let value): + ".success(\(value))" + case .failure(let error): + ".failure(\(error))" + } + } + +} + +extension DataImportResult: Equatable where T: Equatable { + public static func == (lhs: DataImportResult, rhs: DataImportResult) -> Bool { + switch lhs { + case .success(let value): + if case .success(value) = rhs { + true + } else { + false + } + case .failure(let error1): + if case .failure(let error2) = rhs { + error1.errorParameters == error2.errorParameters + } else { + false + } + } + } + +} + +public struct LoginImporterError: DataImportError { + + private let error: Error? + private let _type: OperationType? + + public var action: DataImportAction { .passwords } + + public init(error: Error?, type: OperationType? = nil) { + self.error = error + self._type = type + } + + public struct OperationType: RawRepresentable, Equatable { + public let rawValue: Int + + static let malformedCSV = OperationType(rawValue: -2) + + public init(rawValue: Int) { + self.rawValue = rawValue + } + } + + public var type: OperationType { + _type ?? OperationType(rawValue: (error as NSError?)?.code ?? 0) + } + + public var underlyingError: Error? { + switch error { + case let secureStorageError as SecureStorageError: + switch secureStorageError { + case .initFailed(let error), + .authError(let error), + .failedToOpenDatabase(let error), + .databaseError(let error): + return error + + case .keystoreError(let status), .keystoreReadError(let status), .keystoreUpdateError(let status): + return NSError(domain: "KeyStoreError", code: Int(status)) + + case .secError(let status): + return NSError(domain: "secError", code: Int(status)) + + case .authRequired, + .invalidPassword, + .noL1Key, + .noL2Key, + .duplicateRecord, + .generalCryptoError, + .encodingFailed: + return secureStorageError + } + default: + return error + } + } + + public var errorType: DataImport.ErrorType { + if case .malformedCSV = type { + return .dataCorrupted + } + if let secureStorageError = error as? SecureStorageError { + switch secureStorageError { + case .initFailed, + .authError, + .failedToOpenDatabase, + .databaseError: + return .keychainError + + case .keystoreError, .secError, .keystoreReadError, .keystoreUpdateError: + return .keychainError + + case .authRequired, + .invalidPassword, + .noL1Key, + .noL2Key, + .duplicateRecord, + .generalCryptoError, + .encodingFailed: + return .decryptionError + } + } + return .other + } + +} diff --git a/Sources/BrowserServicesKit/DataImport/Logins/AutofillLoginImportStateStoring.swift b/Sources/BrowserServicesKit/DataImport/Logins/AutofillLoginImportStateStoring.swift new file mode 100644 index 000000000..a53940734 --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Logins/AutofillLoginImportStateStoring.swift @@ -0,0 +1,22 @@ +// +// AutofillLoginImportStateStoring.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. +// + +public protocol AutofillLoginImportStateStoring { + var hasImportedLogins: Bool { get set } + var isCredentialsImportPromptPermanantlyDismissed: Bool { get set } +} diff --git a/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVImporter.swift b/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVImporter.swift new file mode 100644 index 000000000..67df5a551 --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVImporter.swift @@ -0,0 +1,341 @@ +// +// CSVImporter.swift +// +// Copyright © 2021 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 Common +import Foundation +import SecureStorage + +final public class CSVImporter: DataImporter { + + public struct ColumnPositions { + + private enum Regex { + // should end with "login" or "username" + static let username = regex("(?:^|\\b|\\s|_)(?:login|username)$", .caseInsensitive) + // should end with "password" or "pwd" + static let password = regex("(?:^|\\b|\\s|_)(?:password|pwd)$", .caseInsensitive) + // should end with "name" or "title" + static let title = regex("(?:^|\\b|\\s|_)(?:name|title)$", .caseInsensitive) + // should end with "url", "uri" + static let url = regex("(?:^|\\b|\\s|_)(?:url|uri)$", .caseInsensitive) + // should end with "notes" or "note" + static let notes = regex("(?:^|\\b|\\s|_)(?:notes|note)$", .caseInsensitive) + } + + static let rowFormatWithTitle = ColumnPositions(titleIndex: 0, urlIndex: 1, usernameIndex: 2, passwordIndex: 3) + static let rowFormatWithoutTitle = ColumnPositions(titleIndex: nil, urlIndex: 0, usernameIndex: 1, passwordIndex: 2) + + let maximumIndex: Int + + public let titleIndex: Int? + public let urlIndex: Int? + + public let usernameIndex: Int + public let passwordIndex: Int + + let notesIndex: Int? + + let isZohoVault: Bool + + init(titleIndex: Int?, urlIndex: Int?, usernameIndex: Int, passwordIndex: Int, notesIndex: Int? = nil, isZohoVault: Bool = false) { + self.titleIndex = titleIndex + self.urlIndex = urlIndex + self.usernameIndex = usernameIndex + self.passwordIndex = passwordIndex + self.notesIndex = notesIndex + self.maximumIndex = max(titleIndex ?? -1, urlIndex ?? -1, usernameIndex, passwordIndex, notesIndex ?? -1) + self.isZohoVault = isZohoVault + } + + private enum Format { + case general + case zohoGeneral + case zohoVault + } + + public init?(csv: [[String]]) { + guard csv.count > 1, + csv[1].count >= 3 else { return nil } + var headerRow = csv[0] + + var format = Format.general + + let usernameIndex: Int + if let idx = headerRow.firstIndex(where: { value in + Regex.username.matches(in: value, range: value.fullRange).isEmpty == false + }) { + usernameIndex = idx + headerRow[usernameIndex] = "" + + // Zoho + } else if headerRow.first == "Password Name" { + if let idx = csv[1].firstIndex(of: "SecretData") { + format = .zohoVault + usernameIndex = idx + } else if csv[1].count == 7 { + format = .zohoGeneral + usernameIndex = 5 + } else { + return nil + } + } else { + return nil + } + + let passwordIndex: Int + switch format { + case .general: + guard let idx = headerRow + .firstIndex(where: { !Regex.password.matches(in: $0, range: $0.fullRange).isEmpty }) else { return nil } + passwordIndex = idx + headerRow[passwordIndex] = "" + + case .zohoGeneral: + passwordIndex = usernameIndex + 1 + case .zohoVault: + passwordIndex = usernameIndex + } + + let titleIndex = headerRow.firstIndex(where: { !Regex.title.matches(in: $0, range: $0.fullRange).isEmpty }) + titleIndex.map { headerRow[$0] = "" } + + let urlIndex = headerRow.firstIndex(where: { !Regex.url.matches(in: $0, range: $0.fullRange).isEmpty }) + urlIndex.map { headerRow[$0] = "" } + + let notesIndex = headerRow.firstIndex(where: { !Regex.notes.matches(in: $0, range: $0.fullRange).isEmpty }) + + self.init(titleIndex: titleIndex, + urlIndex: urlIndex, + usernameIndex: usernameIndex, + passwordIndex: passwordIndex, + notesIndex: notesIndex, + isZohoVault: format == .zohoVault) + } + + public init?(source: DataImport.Source) { + switch source { + case .onePassword7, .onePassword8: + self.init(titleIndex: 3, urlIndex: 5, usernameIndex: 6, passwordIndex: 2) + case .lastPass, .firefox, .edge, .chrome, .chromium, .coccoc, .brave, .opera, .operaGX, + .safari, .safariTechnologyPreview, .tor, .vivaldi, .yandex, .csv, .bookmarksHTML, .bitwarden: + return nil + } + } + + } + + struct ImportError: DataImportError { + enum OperationType: Int { + case cannotReadFile + } + + var action: DataImportAction { .passwords } + let type: OperationType + let underlyingError: Error? + + var errorType: DataImport.ErrorType { + .dataCorrupted + } + } + + private let fileURL: URL? + private let csvContent: String? + private let loginImporter: LoginImporter + private let defaultColumnPositions: ColumnPositions? + private let secureVaultReporter: SecureVaultReporting + + public init(fileURL: URL?, csvContent: String? = nil, loginImporter: LoginImporter, defaultColumnPositions: ColumnPositions?, reporter: SecureVaultReporting) { + self.fileURL = fileURL + self.csvContent = csvContent + self.loginImporter = loginImporter + self.defaultColumnPositions = defaultColumnPositions + self.secureVaultReporter = reporter + } + + static func totalValidLogins(in fileURL: URL, defaultColumnPositions: ColumnPositions?) -> Int { + guard let fileContents = try? String(contentsOf: fileURL, encoding: .utf8) else { return 0 } + + let logins = extractLogins(from: fileContents, defaultColumnPositions: defaultColumnPositions) ?? [] + + return logins.count + } + + static public func totalValidLogins(in csvContent: String, defaultColumnPositions: ColumnPositions?) -> Int { + let logins = extractLogins(from: csvContent, defaultColumnPositions: defaultColumnPositions) ?? [] + + return logins.count + } + + public static func extractLogins(from fileContents: String, defaultColumnPositions: ColumnPositions? = nil) -> [ImportedLoginCredential]? { + guard let parsed = try? CSVParser().parse(string: fileContents) else { return nil } + + let columnPositions: ColumnPositions? + var startRow = 0 + if let autodetected = ColumnPositions(csv: parsed) { + columnPositions = autodetected + startRow = 1 + } else { + columnPositions = defaultColumnPositions + } + + guard parsed.indices.contains(startRow) else { return [] } // no data + + let result = parsed[startRow...].compactMap(columnPositions.read) + + guard !result.isEmpty else { + if parsed.filter({ !$0.isEmpty }).isEmpty { + return [] // no data + } else { + return nil // error: could not parse data + } + } + + return result + } + + public var importableTypes: [DataImport.DataType] { + return [.passwords] + } + + public func importData(types: Set) -> DataImportTask { + .detachedWithProgress { updateProgress in + do { + let result = try await self.importLoginsSync(updateProgress: updateProgress) + return [.passwords: result] + } catch is CancellationError { + } catch { + assertionFailure("Only CancellationError should be thrown here") + } + return [:] + } + } + + private func importLoginsSync(updateProgress: @escaping DataImportProgressCallback) async throws -> DataImportResult { + + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.0)) + + let fileContents: String + do { + if let csvContent = csvContent { + fileContents = csvContent + } else if let fileURL = fileURL { + fileContents = try String(contentsOf: fileURL, encoding: .utf8) + } else { + throw ImportError(type: .cannotReadFile, underlyingError: nil) + } + } catch { + return .failure(ImportError(type: .cannotReadFile, underlyingError: error)) + } + + do { + try updateProgress(.importingPasswords(numberOfPasswords: nil, fraction: 0.2)) + + let loginCredentials = try Self.extractLogins(from: fileContents, defaultColumnPositions: defaultColumnPositions) ?? { + try Task.checkCancellation() + throw LoginImporterError(error: nil, type: .malformedCSV) + }() + + try updateProgress(.importingPasswords(numberOfPasswords: loginCredentials.count, fraction: 0.5)) + + let summary = try loginImporter.importLogins(loginCredentials, reporter: secureVaultReporter) { count in + try updateProgress(.importingPasswords(numberOfPasswords: count, fraction: 0.5 + 0.5 * (Double(count) / Double(loginCredentials.count)))) + } + + try updateProgress(.importingPasswords(numberOfPasswords: loginCredentials.count, fraction: 1.0)) + + return .success(summary) + } catch is CancellationError { + throw CancellationError() + } catch let error as DataImportError { + return .failure(error) + } catch { + return .failure(LoginImporterError(error: error)) + } + } + +} + +extension ImportedLoginCredential { + + // Some browsers will export credentials with a header row. To detect this, the URL field on the first parsed row is checked whether it passes + // the data detector test. If it doesn't, it's assumed to be a header row. + fileprivate var isHeaderRow: Bool { + let types: NSTextCheckingResult.CheckingType = [.link] + + guard let detector = try? NSDataDetector(types: types.rawValue), + let url, !url.isEmpty else { return false } + + if detector.numberOfMatches(in: url, options: [], range: url.fullRange) > 0 { + return false + } + + return true + } + +} + +extension CSVImporter.ColumnPositions { + + func read(_ row: [String]) -> ImportedLoginCredential? { + let username: String + let password: String + + if isZohoVault { + // cell contents: + // SecretType:Web Account + // User Name:username + // Password:password + guard let lines = row[safe: usernameIndex]?.components(separatedBy: "\n"), + let usernameLine = lines.first(where: { $0.hasPrefix("User Name:") }), + let passwordLine = lines.first(where: { $0.hasPrefix("Password:") }) else { return nil } + + username = usernameLine.dropping(prefix: "User Name:") + password = passwordLine.dropping(prefix: "Password:") + + } else if let user = row[safe: usernameIndex], + let pass = row[safe: passwordIndex] { + + username = user + password = pass + } else { + return nil + } + + return ImportedLoginCredential(title: row[safe: titleIndex ?? -1], + url: row[safe: urlIndex ?? -1], + username: username, + password: password, + notes: row[safe: notesIndex ?? -1]) + } + +} + +extension CSVImporter.ColumnPositions? { + + func read(_ row: [String]) -> ImportedLoginCredential? { + let columnPositions = self ?? [ + .rowFormatWithTitle, + .rowFormatWithoutTitle + ].first(where: { + row.count > $0.maximumIndex + }) + + return columnPositions?.read(row) + } + +} diff --git a/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVParser.swift b/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVParser.swift new file mode 100644 index 000000000..f6d94acd9 --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Logins/CSV/CSVParser.swift @@ -0,0 +1,303 @@ +// +// CSVParser.swift +// +// Copyright © 2021 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 struct CSVParser { + + public init() {} + + public func parse(string: String) throws -> [[String]] { + var parser = Parser() + + for character in string { + try Task.checkCancellation() + // errors are only handled at the Parser level in `.branching` state + try? parser.accept(character) + } + + // errors are only handled at the Parser level in `.branching` state + try? parser.flushField(final: true) + + return parser.result + } + + private enum State { + case start + case field + case enquotedField + case branching([Parser]) + + /// returns: `true` if case is `.branching` + mutating func performIfBranching(_ action: (inout Parser) throws -> Void) -> Bool { + guard case .branching(var parsers) = self else { return false } + + for idx in parsers.indices.reversed() { + do { + try action(&parsers[idx]) + } catch { + parsers.remove(at: idx) + } + } + self = .branching(parsers) + return true + } + } + + private struct Parser { + var delimiter: Character? + + enum QuoteEscapingType { + case unknown + case doubleQuote + case backslash + } + var quoteEscapingType = QuoteEscapingType.unknown + + var result: [[String]] = [[]] + + var state = State.start + enum PrecedingCharKind { + case none + case quote + case backslash + } + var precedingCharKind = PrecedingCharKind.none + + var currentField = "" + + enum ParserError: Error { + case unexpectedCharacterAfterQuote(Character) + case nonEnquotedFieldPayloadStart(Character) + case unexpectedDoubleQuoteInBackslashEscapedQuoteMode + case unexpectedEOF + } + + init() {} + + private func copy(applying action: (inout Self) -> Void) -> Self { + var copy = self + action(©) + return copy + } + + @inline(__always) mutating func flushField(final: Bool = false) throws { + if state.performIfBranching({ try $0.flushField(final: final) }) { + guard case .branching(let parsers) = state else { fatalError("Unexpected state") } + if parsers.count == 1 { + self = parsers[0] + } else if final, + let bestResult = parsers.max(by: { $0.result.reduce(0, { $0 + $1.count }) < $1.result.reduce(0, { $0 + $1.count }) }) { + // not expected corner case: branching parser state at the EOF + // find parser resulting with most fields + self = bestResult + } + return + } + result[result.endIndex - 1].append(currentField) + + let lastState = state + let lastPrecedingCharKind = precedingCharKind + + currentField = "" + state = .start + precedingCharKind = .none + + if final, case .enquotedField = lastState, lastPrecedingCharKind != .quote { + throw ParserError.unexpectedEOF + } + } + + @inline(__always) mutating func nextLine() throws { + try flushField() + result.append([]) + state = .start + precedingCharKind = .none + } + + mutating func accept(_ character: Character) throws { + if state.performIfBranching({ try $0.accept(character) }) { + if case .branching(let parsers) = state, parsers.count == 1 { + self = parsers[0] + } + return + } + + let kind = character.kind(delimiter: delimiter) + switch (state, kind, preceding: precedingCharKind, quoteEscaping: quoteEscapingType) { + case (_, .unsupported, _, _): + return // skip control characters + + // expecting field start + case (.start, .quote, _, _): + // enquoted field starting + state = .enquotedField + case (.start, .delimiter, _, _): + // empty field + try flushField() + delimiter = character + case (.start, .whitespace, _, _): + return // trim leading whitespaces + case (.start, .newline, _, _): + try nextLine() + case (.start, .payload, _, quoteEscaping: .backslash): + // all non-empty fields should be enquoted in backslash-escaped-quote mode + state = .field + currentField.append(character) + throw ParserError.nonEnquotedFieldPayloadStart(character) + case (.start, .payload, _, _), (.start, .backslash, _, _): + state = .field + currentField.append(character) + + // handle backslash + case (.enquotedField, .backslash, preceding: .none, quoteEscaping: .unknown), + (.enquotedField, .backslash, preceding: .none, quoteEscaping: .backslash): + precedingCharKind = .backslash + + case (.enquotedField, .quote, preceding: .backslash, quoteEscaping: .unknown): + // `\"` received in an unknown `quoteEscaping` state. + // It may be either just a backslash-escaped quote or a `\` followed by a field end - `"\n` or `",`. + // To figure the right way we do branching: + // branch that finishes without errors will be the chosen one. + state = .branching([ + // one parser will continue parsing in backslash-escaped-quote mode + copy { + $0.quoteEscapingType = .backslash + }, + // - another one will continue parsing in double-quote-escaped mode + copy{ + $0.quoteEscapingType = .doubleQuote + // take the backslash as a payload + $0.currentField.append("\\") + $0.precedingCharKind = .none + } + ]) + try self.accept(character) // feed the quote character to the parsers + + case (.enquotedField, .quote, preceding: .backslash, quoteEscaping: .backslash): + // `\"` received in backslash-escaped-quote mode + // it either means an escaped quote or a backslash followed by a field ending quote. + // To figure the right way we do branching: + // branch that finishes without errors will be the chosen one. + state = .branching([ + // - one parser will finish the field and continue parsing + copy { + // take the backslash as a payload at the end of the field + $0.currentField.append("\\") + // delimeter received next will finish the field + $0.precedingCharKind = .quote + }, + // - another one will unescape the quote and continue parsing the field + copy { + // append the quote and resume building the field + $0.currentField.append(character /* quote */) + $0.precedingCharKind = .none + } + ]) + + case (_, _, preceding: .backslash, _): + // any non-quote character following a backslash: it was just a backslash + precedingCharKind = .none + currentField.append("\\") + try self.accept(character) // feed the character again + + // quote in field body is escaped with 2 quotes (or with a backslash) + case (_, .quote, preceding: .none, _): + precedingCharKind = .quote + case (_, .quote, preceding: .quote, quoteEscaping: .unknown), + (_, .quote, preceding: .quote, quoteEscaping: .doubleQuote): + currentField.append(character /* quote */) + precedingCharKind = .none + quoteEscapingType = .doubleQuote + + // double quotes not allowed in backslash-escaped-quote mode + case (.enquotedField, .quote, preceding: .quote, quoteEscaping: .backslash): + precedingCharKind = .quote + currentField.append(character /* quote */) + throw ParserError.unexpectedDoubleQuoteInBackslashEscapedQuoteMode + + // enquoted field end + case (.enquotedField, .delimiter, .quote, _): + try flushField() + delimiter = character + case (.enquotedField, .newline, preceding: .quote, _): + try nextLine() + case (.enquotedField, .whitespace, preceding: .quote, _): + return // trim whitespaces between fields + + // unbalanced quote + case (_, _, preceding: .quote, _): + // only expecting a second quote after a quote in field body + currentField.append("\"") + currentField.append(character) + precedingCharKind = .none + throw ParserError.unexpectedCharacterAfterQuote(character) + + // non-enquoted field end + case (.field, .delimiter, _, _): + try flushField() + delimiter = character + case (.field, .newline, _, _): + try nextLine() + + default: + currentField.append(character) + } + } + } + +} + +private extension Character { + + enum Kind { + case backslash + case quote + case delimiter + case newline + case whitespace + case unsupported + case payload + } + + func kind(delimiter: Character?) -> Kind { + if self == "\\" { + .backslash + } else if self == "\"" { + .quote + } else if self.unicodeScalars.contains(where: { CharacterSet.unsupportedCharacters.contains($0) }) { + .unsupported + } else if CharacterSet.newlines.contains(unicodeScalars.first!) { + .newline + } else if CharacterSet.whitespaces.contains(unicodeScalars.first!) { + .whitespace + } else if self == delimiter || (delimiter == nil && CharacterSet.delimiters.contains(unicodeScalars.first!)) { + .delimiter + } else { + .payload + } + } + +} + +private extension CharacterSet { + + static let unsupportedCharacters = CharacterSet.controlCharacters.union(.illegalCharacters).subtracting(.newlines) + static let delimiters = CharacterSet(charactersIn: ",;") + +} diff --git a/Sources/BrowserServicesKit/DataImport/Logins/LoginImport.swift b/Sources/BrowserServicesKit/DataImport/Logins/LoginImport.swift new file mode 100644 index 000000000..f3c8c3749 --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Logins/LoginImport.swift @@ -0,0 +1,44 @@ +// +// LoginImport.swift +// +// Copyright © 2021 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 SecureStorage + +public struct ImportedLoginCredential: Equatable { + + public let title: String? + public let url: String? + public let username: String + public let password: String + public let notes: String? + + public init(title: String? = nil, url: String?, username: String, password: String, notes: String? = nil) { + self.title = title + self.url = url.flatMap(URL.init(string:))?.host ?? url // Try to use the host if possible, as the Secure Vault saves credentials using the host. + self.username = username + self.password = password + self.notes = notes + } + +} + +public protocol LoginImporter { + + func importLogins(_ logins: [ImportedLoginCredential], reporter: SecureVaultReporting, progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary + +} diff --git a/Sources/BrowserServicesKit/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift b/Sources/BrowserServicesKit/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift new file mode 100644 index 000000000..c64f17149 --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/Logins/SecureVault/SecureVaultLoginImporter.swift @@ -0,0 +1,114 @@ +// +// SecureVaultLoginImporter.swift +// +// Copyright © 2021 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 SecureStorage + +public class SecureVaultLoginImporter: LoginImporter { + private var loginImportState: AutofillLoginImportStateStoring? + + public init(loginImportState: AutofillLoginImportStateStoring? = nil) { + self.loginImportState = loginImportState + } + + private enum ImporterError: Error { + case duplicate + } + + public func importLogins(_ logins: [ImportedLoginCredential], reporter: SecureVaultReporting, progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary { + + let vault = try AutofillSecureVaultFactory.makeVault(reporter: reporter) + + var successful: [String] = [] + var duplicates: [String] = [] + var failed: [String] = [] + + let encryptionKey = try vault.getEncryptionKey() + let hashingSalt = try vault.getHashingSalt() + + let accounts = (try? vault.accounts()) ?? .init() + + try vault.inDatabaseTransaction { database in + for (idx, login) in logins.enumerated() { + let title = login.title + let account = SecureVaultModels.WebsiteAccount(title: title, username: login.username, domain: login.url, notes: login.notes) + let credentials = SecureVaultModels.WebsiteCredentials(account: account, password: login.password.data(using: .utf8)!) + let importSummaryValue: String + + if let title = account.title { + importSummaryValue = "\(title): \(credentials.account.domain ?? "") (\(credentials.account.username ?? ""))" + } else { + importSummaryValue = "\(credentials.account.domain ?? "") (\(credentials.account.username ?? ""))" + } + + do { + if let signature = try vault.encryptPassword(for: credentials, key: encryptionKey, salt: hashingSalt).account.signature { + let isDuplicate = accounts.contains { + $0.isDuplicateOf(accountToBeImported: account, signatureOfAccountToBeImported: signature, passwordToBeImported: login.password) + } + if isDuplicate { + throw ImporterError.duplicate + } + } + _ = try vault.storeWebsiteCredentials(credentials, in: database, encryptedUsing: encryptionKey, hashedUsing: hashingSalt) + successful.append(importSummaryValue) + } catch { + if case .duplicateRecord = error as? SecureStorageError { + duplicates.append(importSummaryValue) + } else if case .duplicate = error as? ImporterError { + duplicates.append(importSummaryValue) + } else { + failed.append(importSummaryValue) + } + } + + try progressCallback(idx + 1) + } + } + + if successful.count > 0 { + NotificationCenter.default.post(name: .autofillSaveEvent, object: nil, userInfo: nil) + } + + loginImportState?.hasImportedLogins = true + return .init(successful: successful.count, duplicate: duplicates.count, failed: failed.count) + } +} + +extension SecureVaultModels.WebsiteAccount { + + // Deduplication rules: https://app.asana.com/0/0/1207598052765977/f + func isDuplicateOf(accountToBeImported: Self, signatureOfAccountToBeImported: String, passwordToBeImported: String?) -> Bool { + guard signature == signatureOfAccountToBeImported || passwordToBeImported.isNilOrEmpty else { + return false + } + guard username == accountToBeImported.username || accountToBeImported.username.isNilOrEmpty else { + return false + } + guard domain == accountToBeImported.domain || accountToBeImported.domain.isNilOrEmpty else { + return false + } + guard notes == accountToBeImported.notes || accountToBeImported.notes.isNilOrEmpty else { + return false + } + guard patternMatchedTitle() == accountToBeImported.patternMatchedTitle() || accountToBeImported.patternMatchedTitle().isEmpty else { + return false + } + return true + } +} diff --git a/Sources/BrowserServicesKit/DataImport/TaskWithProgress.swift b/Sources/BrowserServicesKit/DataImport/TaskWithProgress.swift new file mode 100644 index 000000000..cc1ecfc7b --- /dev/null +++ b/Sources/BrowserServicesKit/DataImport/TaskWithProgress.swift @@ -0,0 +1,169 @@ +// +// TaskWithProgress.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 + +public typealias TaskProgressProgressEvent = TaskWithProgress.ProgressEvent +public typealias TaskProgress = AsyncStream> +public typealias TaskProgressUpdateCallback = TaskWithProgress.ProgressUpdateCallback + +/// Used to run an async Task while tracking its completion progress +/// Usage: +/// ``` +/// func doSomeOperation() -> TaskWithProgress { +/// .withProgress((total: 100, completed: 0)) { updateProgress in +/// try await part1() +/// updateProgress(50) +/// +/// try await part2() +/// updateProgress(100) +/// } +/// } +/// ``` +public struct TaskWithProgress: Sendable where Success: Sendable, Failure: Error { + + public enum ProgressEvent { + case progress(ProgressUpdate) + case completed(Result) + } + + public let task: Task + + public typealias Progress = AsyncStream + public typealias ProgressUpdateCallback = (ProgressUpdate) throws -> Void + + public let progress: Progress + + fileprivate init(task: Task, progress: Progress) { + self.task = task + self.progress = progress + } + + /// The task's result + var value: Success { + get async throws { + try await task.value + } + } + + /// The task's result + public var result: Result { + get async { + await task.result + } + } + + /// Cancel the Task + public func cancel() { + task.cancel() + } + + var isCancelled: Bool { + task.isCancelled + } + +} + +extension TaskWithProgress: Hashable { + + public func hash(into hasher: inout Hasher) { + task.hash(into: &hasher) + } + + public static func == (lhs: TaskWithProgress, rhs: TaskWithProgress) -> Bool { + lhs.task == rhs.task + } + +} + +extension TaskWithProgress where Failure == Never { + + /// The result from a nonthrowing task, after it completes. + public var value: Success { + get async { + await task.value + } + } + +} + +public protocol AnyTask { + associatedtype Success + associatedtype Failure +} +extension Task: AnyTask {} +extension TaskWithProgress: AnyTask {} + +extension AnyTask where Failure == Error { + + public static func detachedWithProgress(_ progress: ProgressUpdate? = nil, priority: TaskPriority? = nil, do operation: @escaping @Sendable (@escaping TaskProgressUpdateCallback) async throws -> Success) -> TaskWithProgress { + let (progressStream, progressContinuation) = TaskProgress.makeStream() + if let progress { + progressContinuation.yield(.progress(progress)) + } + + let task = Task.detached { + let updateProgressCallback: TaskProgressUpdateCallback = { update in + try Task.checkCancellation() + progressContinuation.yield(.progress(update)) + } + + defer { + progressContinuation.finish() + } + do { + let result = try await operation(updateProgressCallback) + progressContinuation.yield(.completed(.success(result))) + + return result + } catch { + progressContinuation.yield(.completed(.failure(error))) + throw error + } + } + + return TaskWithProgress(task: task, progress: progressStream) + } + +} + +extension AnyTask where Failure == Never { + + public static func detachedWithProgress(_ progress: ProgressUpdate? = nil, completed: UInt? = nil, priority: TaskPriority? = nil, do operation: @escaping @Sendable (@escaping TaskProgressUpdateCallback) async -> Success) -> TaskWithProgress { + let (progressStream, progressContinuation) = TaskProgress.makeStream() + if let progress { + progressContinuation.yield(.progress(progress)) + } + + let task = Task.detached { + let updateProgressCallback: TaskProgressUpdateCallback = { update in + try Task.checkCancellation() + progressContinuation.yield(.progress(update)) + } + + let result = await operation(updateProgressCallback) + progressContinuation.yield(.completed(.success(result))) + progressContinuation.finish() + + return result + } + + return TaskWithProgress(task: task, progress: progressStream) + } + +} diff --git a/Tests/BrowserServicesKitTests/DataImport/CSVImporterTests.swift b/Tests/BrowserServicesKitTests/DataImport/CSVImporterTests.swift new file mode 100644 index 000000000..15a8f2d58 --- /dev/null +++ b/Tests/BrowserServicesKitTests/DataImport/CSVImporterTests.swift @@ -0,0 +1,133 @@ +// +// CSVImporterTests.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +@testable import BrowserServicesKit +import SecureStorage + +class CSVImporterTests: XCTestCase { + + let temporaryFileCreator = TemporaryFileCreator() + + override func tearDown() { + super.tearDown() + temporaryFileCreator.deleteCreatedTemporaryFiles() + } + + func testWhenImportingCSVFileWithHeader_ThenHeaderRowIsExcluded() { + let csvFileContents = """ + title,url,username,password + Some Title,duck.com,username,p4ssw0rd + """ + + let logins = CSVImporter.extractLogins(from: csvFileContents) + XCTAssertEqual(logins, [ImportedLoginCredential(title: "Some Title", url: "duck.com", username: "username", password: "p4ssw0rd")]) + } + + func testWhenImportingCSVFileWithHeader_AndHeaderHasBitwardenFormat_ThenHeaderRowIsExcluded() { + let csvFileContents = """ + name,login_uri,login_username,login_password + Some Title,duck.com,username,p4ssw0rd + """ + + let logins = CSVImporter.extractLogins(from: csvFileContents) + XCTAssertEqual(logins, [ImportedLoginCredential(title: "Some Title", url: "duck.com", username: "username", password: "p4ssw0rd")]) + } + + func testWhenImportingCSVFileWithHeader_ThenHeaderColumnPositionsAreRespected() { + let csvFileContents = """ + Password,Title,Username,Url + p4ssw0rd,"Some Title",username,duck.com + """ + + let logins = CSVImporter.extractLogins(from: csvFileContents) + XCTAssertEqual(logins, [ImportedLoginCredential(title: "Some Title", url: "duck.com", username: "username", password: "p4ssw0rd")]) + } + + func testWhenImportingCSVFileWithoutHeader_ThenNoRowsAreExcluded() { + let csvFileContents = """ + Some Title,duck.com,username,p4ssw0rd + """ + + let logins = CSVImporter.extractLogins(from: csvFileContents) + XCTAssertEqual(logins, [ImportedLoginCredential(title: "Some Title", url: "duck.com", username: "username", password: "p4ssw0rd")]) + } + + func testWhenImportingCSVDataFromTheFileSystem_AndNoTitleIsIncluded_ThenLoginCredentialsAreImported() async { + let mockLoginImporter = MockLoginImporter() + let file = "https://example.com/,username,password" + let savedFileURL = temporaryFileCreator.persist(fileContents: file.data(using: .utf8)!, named: "test.csv")! + let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter, defaultColumnPositions: nil, reporter: MockSecureVaultReporting()) + + let result = await csvImporter.importData(types: [.passwords]).task.value + + XCTAssertEqual(result, [.passwords: .success(.init(successful: 1, duplicate: 0, failed: 0))]) + } + + func testWhenImportingCSVDataFromTheFileSystem_AndTitleIsIncluded_ThenLoginCredentialsAreImported() async { + let mockLoginImporter = MockLoginImporter() + let file = "title,https://example.com/,username,password" + let savedFileURL = temporaryFileCreator.persist(fileContents: file.data(using: .utf8)!, named: "test.csv")! + let csvImporter = CSVImporter(fileURL: savedFileURL, loginImporter: mockLoginImporter, defaultColumnPositions: nil, reporter: MockSecureVaultReporting()) + + let result = await csvImporter.importData(types: [.passwords]).task.value + + XCTAssertEqual(result, [.passwords: .success(.init(successful: 1, duplicate: 0, failed: 0))]) + } + + func testWhenInferringColumnPostions_AndColumnsAreValid_AndTitleIsIncluded_ThenPositionsAreCalculated() { + let csvValues = ["url", "username", "password", "title"] + let inferred = CSVImporter.ColumnPositions(csvValues: csvValues) + + XCTAssertEqual(inferred?.urlIndex, 0) + XCTAssertEqual(inferred?.usernameIndex, 1) + XCTAssertEqual(inferred?.passwordIndex, 2) + XCTAssertEqual(inferred?.titleIndex, 3) + } + + func testWhenInferringColumnPostions_AndColumnsAreValid_AndTitleIsNotIncluded_ThenPositionsAreCalculated() { + let csvValues = ["url", "username", "password"] + let inferred = CSVImporter.ColumnPositions(csvValues: csvValues) + + XCTAssertEqual(inferred?.urlIndex, 0) + XCTAssertEqual(inferred?.usernameIndex, 1) + XCTAssertEqual(inferred?.passwordIndex, 2) + XCTAssertNil(inferred?.titleIndex) + } + + func testWhenInferringColumnPostions_AndColumnsAreInvalidThenPositionsAreCalculated() { + let csvValues = ["url", "username", "title"] // `password` is required, this test verifies that the inference fails when it's missing + let inferred = CSVImporter.ColumnPositions(csvValues: csvValues) + + XCTAssertNil(inferred) + } + +} + +extension CSVImporter.ColumnPositions { + + init?(csvValues: [String]) { + self.init(csv: [csvValues, Array(repeating: "", count: csvValues.count)]) + } + +} + +private class MockSecureVaultReporting: SecureVaultReporting { + func secureVaultError(_ error: SecureStorage.SecureStorageError) {} +} diff --git a/Tests/BrowserServicesKitTests/DataImport/CSVParserTests.swift b/Tests/BrowserServicesKitTests/DataImport/CSVParserTests.swift new file mode 100644 index 000000000..6c8772133 --- /dev/null +++ b/Tests/BrowserServicesKitTests/DataImport/CSVParserTests.swift @@ -0,0 +1,226 @@ +// +// CSVParserTests.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +@testable import BrowserServicesKit + +final class CSVParserTests: XCTestCase { + + func testWhenParsingMultipleRowsThenMultipleArraysAreReturned() throws { + let string = """ + line 1 + line 2 + """ + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["line 1"], ["line 2"]]) + } + + func testControlCharactersAreIgnored() throws { + let string = """ + \u{FEFF}line\u{10} 1\u{10} + line 2\u{FEFF} + """ + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["line 1"], ["line 2"]]) + } + + func testWhenParsingRowsWithVariableNumbersOfEntriesThenParsingSucceeds() throws { + let string = """ + one + two;three; + four;five;six + """ + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["one"], ["two", "three", ""], ["four", "five", "six"]]) + } + + func testWhenParsingRowsSurroundedByQuotesThenQuotesAreRemoved() throws { + let string = """ + "url","username", "password" + """ + " " + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["url", "username", "password"]]) + } + + func testWhenParsingMalformedCsvWithExtraQuote_ParserAddsItToOutput() throws { + let string = #""" + "url","user"name","password","" + "a.b.com/usdf","my@name.is","\"mypass\wrd\","" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [ + ["url", #"user"name"#, "password", ""], + ["a.b.com/usdf", "my@name.is", #""mypass\wrd\"#, ""], + ]) + } + + func testWhenParsingRowsWithDoubleQuoteEscapedQuoteThenQuoteIsUnescaped() throws { + let string = #""" + "url","username","password\""with""quotes\""and/slash""\" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["url", "username", #"password\"with"quotes\"and/slash"\"#]]) + } + + func testWhenParsingRowsWithBackslashEscapedQuoteThenQuoteIsUnescaped() throws { + let string = #""" + "\\","\"password\"with","quotes\","\",\", + """# + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [[#"\\"#, #""password"with"#, #"quotes\"#, #"",\"#, ""]]) + } + + func testWhenParsingRowsWithBackslashEscapedQuoteThenQuoteIsUnescaped2() throws { + let string = #""" + "\"", "\"\"","\\","\"password\"with","quotes\","\",\", + """# + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed.map({ $0.map { $0.replacingOccurrences(of: "\\", with: "|").replacingOccurrences(of: "\"", with: "”") } }), [["\"", "\"\"", #"\\"#, #""password"with"#, #"quotes\"#, #"",\"#, ""].map { $0.replacingOccurrences(of: "\\", with: "|").replacingOccurrences(of: "\"", with: "”") }]) + } + + func testWhenParsingRowsWithUnescapedTitleAndEscapedQuoteAndEmojiThenQuoteIsUnescaped() throws { + let string = #""" + Title,Url,Username,Password,OTPAuth,Notes + "A",,"",,,"It’s \\"you! 🖐 Select Edit to fill in more details, like your address and contact information.\", + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [ + ["Title", "Url", "Username", "Password", "OTPAuth", "Notes"], + ["A", "", "", "", "", #"It’s \"you! 🖐 Select Edit to fill in more details, like your address and contact information.\"#, ""] + ]) + } + + func testWhenParsingRowsWithEscapedQuotesAndLineBreaksQuotesUnescapedAndLinebreaksParsed() throws { + let string = #""" + Title,Url,Username,Password,OTPAuth,Notes + "А",,"",,,"It's\", + B,,,,you! 🖐 se\" ect Edit to fill in more details, like your address and contact + information.", + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [ + ["Title", "Url", "Username", "Password", "OTPAuth", "Notes"], + ["А", "", "", "", "", "It's\",\nB,,,,you! 🖐 se\" ect Edit to fill in more details, like your address and contact\ninformation.", ""] + ]) + } + + func testWhenParsingQuotedRowsContainingCommasThenTheyAreTreatedAsOneColumnEntry() throws { + let string = """ + "url","username","password,with,commas" + """ + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["url", "username", "password,with,commas"]]) + } + + func testWhenSingleValueWithQuotedEscapedQuoteThenQuoteIsUnescaped() throws { + let string = #""" + "\"" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["\""]]) + } + + func testSingleEnquotedBackslashValueIsParsedAsBackslash() throws { + let string = #""" + "\" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [[#"\"#]]) + } + + func testBackslashValueWithDoubleQuoteIsParsedAsBackslashWithQuote() throws { + let string = #""" + \"" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [[#"\""#]]) + } + + func testEnquotedBackslashValueWithDoubleQuoteIsParsedAsBackslashWithQuote() throws { + let string = #""" + "\""" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [[#"\""#]]) + } + + func testEscapedDoubleQuote() throws { + let string = #""" + """" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["\""]]) + } + + func testWhenValueIsDoubleQuotedThenQuotesAreUnescaped() throws { + let string = #""" + ""hello!"" + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [["\"hello!\""]]) + } + + func testWhenExpressionIsParsableEitherAsDoubleQuoteEscapedOrBackslashEscapedThenEqualFieldsNumberIsPreferred() throws { + let string = #""" + 1,2,3,4,5,6, + "\",b,c,d,e,f, + asdf,\"",hi there!,4,5,6, + a,b,c,d,e,f, + """# + + let parsed = try CSVParser().parse(string: string) + + XCTAssertEqual(parsed, [ + ["1", "2", "3", "4", "5", "6", ""], + ["\\", "b", "c", "d", "e", "f", ""], + ["asdf", #"\""#, "hi there!", "4", "5", "6", ""], + ["a", "b", "c", "d", "e", "f", ""], + ]) + } + +} diff --git a/Tests/BrowserServicesKitTests/DataImport/MockLoginImporter.swift b/Tests/BrowserServicesKitTests/DataImport/MockLoginImporter.swift new file mode 100644 index 000000000..ecf1f0e06 --- /dev/null +++ b/Tests/BrowserServicesKitTests/DataImport/MockLoginImporter.swift @@ -0,0 +1,33 @@ +// +// MockLoginImporter.swift +// +// Copyright © 2025 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 BrowserServicesKit +import SecureStorage + +final public class MockLoginImporter: LoginImporter { + var importedLogins: DataImportSummary? + + public func importLogins(_ logins: [BrowserServicesKit.ImportedLoginCredential], reporter: SecureVaultReporting, progressCallback: @escaping (Int) throws -> Void) throws -> DataImport.DataTypeSummary { + let summary = DataImport.DataTypeSummary(successful: logins.count, duplicate: 0, failed: 0) + + self.importedLogins = [.passwords: .success(summary)] + return summary + } + +} diff --git a/Tests/BrowserServicesKitTests/DataImport/TemporaryFileCreator.swift b/Tests/BrowserServicesKitTests/DataImport/TemporaryFileCreator.swift new file mode 100644 index 000000000..7b490798a --- /dev/null +++ b/Tests/BrowserServicesKitTests/DataImport/TemporaryFileCreator.swift @@ -0,0 +1,53 @@ +// +// TemporaryFileCreator.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest + +final class TemporaryFileCreator { + + var createdFileNames: [String] = [] + + func persist(fileContents: Data, named fileName: String) -> URL? { + let fileURL = temporaryURL(fileName: fileName) + + do { + try fileContents.write(to: fileURL) + createdFileNames.append(fileName) + + return fileURL + } catch { + XCTFail("\(#file): Failed to persist temporary file named '\(fileName)'") + } + + return nil + } + + func deleteCreatedTemporaryFiles() { + for fileName in createdFileNames { + let fileURL = temporaryURL(fileName: fileName) + try? FileManager.default.removeItem(at: fileURL) + } + } + + private func temporaryURL(fileName: String) -> URL { + let temporaryURL = FileManager.default.temporaryDirectory + return temporaryURL.appendingPathComponent(fileName) + } + +} diff --git a/Tests/BrowserServicesKitTests/DataImport/WebsiteAccount_isDuplicateTests.swift b/Tests/BrowserServicesKitTests/DataImport/WebsiteAccount_isDuplicateTests.swift new file mode 100644 index 000000000..84ff74a09 --- /dev/null +++ b/Tests/BrowserServicesKitTests/DataImport/WebsiteAccount_isDuplicateTests.swift @@ -0,0 +1,150 @@ +// +// WebsiteAccount_isDuplicateTests.swift +// +// Copyright © 2021 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import XCTest +import GRDB +@testable import BrowserServicesKit + +final class WebsiteAccount_isDuplicateTests: XCTestCase { + + func test_strictDuplicate_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertTrue(isDuplicate) + } + + func test_strictAntithesis_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blerg", username: "blerg789", domain: "blerg.com", notes: "blehp di blerg blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blerg0blerg4blerg2", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_differingUsernamesOnly_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "different123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blerg0blerg4blerg2", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_usernameInStored_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: nil, domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_usernameInImport_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: nil, domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + // The end-to-end behaviour will actually be false due to the URL being used to create the signature + // https://app.asana.com/0/0/1207598052765977/1207736565669077/f + XCTAssertTrue(isDuplicate) + } + + func test_differing_passwordsOnly_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "differentSignature123", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_passwordInStored_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "nilPasswordStillHasSignature", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "nilPasswordStillHasSignature", passwordToBeImported: "password123") + // The end-to-end behaviour will actually be false due to the URL being used to create the signature + // https://app.asana.com/0/0/1207598052765977/1207736565669077/f + XCTAssertTrue(isDuplicate) + } + + func test_nil_passwordInImport_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "nilPasswordStillHasSignature", passwordToBeImported: nil) + XCTAssertTrue(isDuplicate) + } + + func test_differing_URLOnly_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "differing.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_URLInStored_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: nil, signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_URLInImport_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: nil, notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + // The end-to-end behaviour will actually be false due to the URL being used to create the signature + // https://app.asana.com/0/0/1207598052765977/1207736565669077/f + XCTAssertTrue(isDuplicate) + } + + func test_differing_notesOnly_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "differing notes") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_notesInStored_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: nil) + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_notesInImport_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: nil) + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertTrue(isDuplicate) + } + + func test_differing_titleOnly_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Different", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_titleInStored_notDuplicate() { + let original = SecureVaultModels.WebsiteAccount(title: nil, username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertFalse(isDuplicate) + } + + func test_nil_titleInImport_duplicate() { + let original = SecureVaultModels.WebsiteAccount(title: "Blah", username: "blah123", domain: "blah.com", signature: "blah1blah2blah3", notes: "blah blah") + let new = SecureVaultModels.WebsiteAccount(title: nil, username: "blah123", domain: "blah.com", notes: "blah blah") + let isDuplicate = original.isDuplicateOf(accountToBeImported: new, signatureOfAccountToBeImported: "blah1blah2blah3", passwordToBeImported: "password123") + XCTAssertTrue(isDuplicate) + } +} From b33273c73b417cb812bd02385bee7ecdd527eb91 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Fri, 24 Jan 2025 12:00:50 +0600 Subject: [PATCH 09/28] Fix malsite timeout pixel name (#1185) Task/Issue URL: https://app.asana.com/0/1202406491309510/1209190998168436/f iOS PR: https://github.com/duckduckgo/iOS/pull/3856 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3772 --- Sources/MaliciousSiteProtection/API/APIRequest.swift | 8 ++++++-- Sources/MaliciousSiteProtection/Model/Event.swift | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/MaliciousSiteProtection/API/APIRequest.swift b/Sources/MaliciousSiteProtection/API/APIRequest.swift index db8bfffcf..267c20653 100644 --- a/Sources/MaliciousSiteProtection/API/APIRequest.swift +++ b/Sources/MaliciousSiteProtection/API/APIRequest.swift @@ -50,7 +50,7 @@ public extension APIRequestType { let threatKind: ThreatKind let revision: Int? - init(threatKind: ThreatKind, revision: Int?) { + public init(threatKind: ThreatKind, revision: Int?) { self.threatKind = threatKind self.revision = revision } @@ -74,7 +74,7 @@ public extension APIRequestType { let threatKind: ThreatKind let revision: Int? - init(threatKind: ThreatKind, revision: Int?) { + public init(threatKind: ThreatKind, revision: Int?) { self.threatKind = threatKind self.revision = revision } @@ -97,6 +97,10 @@ public extension APIRequestType { let hashPrefix: String + public init(hashPrefix: String) { + self.hashPrefix = hashPrefix + } + var requestType: APIRequestType { .matches(self) } diff --git a/Sources/MaliciousSiteProtection/Model/Event.swift b/Sources/MaliciousSiteProtection/Model/Event.swift index 6217b35a4..f07a1cc68 100644 --- a/Sources/MaliciousSiteProtection/Model/Event.swift +++ b/Sources/MaliciousSiteProtection/Model/Event.swift @@ -46,9 +46,9 @@ public enum Event: PixelKitEventV2 { case .settingToggled: return "malicious-site-protection_feature-toggled" case .matchesApiTimeout: - return "malicious-site-protection.client-timeout" + return "malicious-site-protection_client-timeout" case .matchesApiFailure: - return "malicious-site-protection.matches-api-error" + return "malicious-site-protection_matches-api-error" } } From c160f096fba96408ddb68fcdc126d8aac9c48e63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jan 2025 13:41:30 +0000 Subject: [PATCH 10/28] Bump github.com/duckduckgo/content-scope-scripts from 7.8.0 to 7.9.0 (#1186) Bumps [github.com/duckduckgo/content-scope-scripts](https://github.com/duckduckgo/content-scope-scripts) from 7.8.0 to 7.9.0.
Release notes

Sourced from github.com/duckduckgo/content-scope-scripts's releases.

7.9.0

  • Black background on Duck Player (#1422)
  • ntp: updated docs to support deeplinks (#1420)
  • build(deps-dev): bump json-schema-to-typescript from 15.0.3 to 15.0.4 (#1414)
  • build(deps-dev): bump typescript-eslint from 8.19.1 to 8.21.0 (#1417)
  • build(deps-dev): bump the eslint group across 1 directory with 2 updates (#1410)
Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/duckduckgo/content-scope-scripts&package-manager=swift&previous-version=7.8.0&new-version=7.9.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index 7d4985a7e..a5bb033ae 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "5d2ab9f5da3c3fc5ec330f23ec35d558dcc8e35b", - "version" : "7.8.0" + "revision" : "bd438c982a2680ed54778a4818dc1652f30cd254", + "version" : "7.9.0" } }, { diff --git a/Package.swift b/Package.swift index 86e14f60c..6575e16bb 100644 --- a/Package.swift +++ b/Package.swift @@ -55,7 +55,7 @@ let package = Package( .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "3.0.0"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.4.0"), .package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "3.0.0"), - .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "7.8.0"), + .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "7.9.0"), .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "8.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"), From 2ed8705a2e8a0ba6cb5253ccf3b2f73f98da6bb0 Mon Sep 17 00:00:00 2001 From: Fernando Bunn Date: Fri, 24 Jan 2025 16:02:19 +0000 Subject: [PATCH 11/28] Update CSS (#1187) **Required**: Task/Issue URL: https://app.asana.com/0/414235014887631/1209235599025435/f iOS PR: https://github.com/duckduckgo/iOS/pull/3864 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3777 What kind of version bump will this require?: Major/Minor/Patch **Description**: Update C-S-S version, fixing a messageBridge issue for production URLs --- Package.resolved | 4 ++-- Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Package.resolved b/Package.resolved index a5bb033ae..d1b98da42 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/duckduckgo/content-scope-scripts", "state" : { - "revision" : "bd438c982a2680ed54778a4818dc1652f30cd254", - "version" : "7.9.0" + "revision" : "181696656f3627c1b3ca8cda06d54971e03436ef", + "version" : "7.10.0" } }, { diff --git a/Package.swift b/Package.swift index 6575e16bb..df5b1f0bb 100644 --- a/Package.swift +++ b/Package.swift @@ -55,7 +55,7 @@ let package = Package( .package(url: "https://github.com/duckduckgo/TrackerRadarKit", exact: "3.0.0"), .package(url: "https://github.com/duckduckgo/sync_crypto", exact: "0.4.0"), .package(url: "https://github.com/gumob/PunycodeSwift.git", exact: "3.0.0"), - .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "7.9.0"), + .package(url: "https://github.com/duckduckgo/content-scope-scripts", exact: "7.10.0"), .package(url: "https://github.com/duckduckgo/privacy-dashboard", exact: "8.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"), From 81e09dafb4fe49745e38c7e36609270d3d367839 Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 09:40:12 +0000 Subject: [PATCH 12/28] Authv2 / Networking improvements (#1168) Task/Issue URL: https://app.asana.com/0/1205842942115003/1209170372758735/f iOS PR: https://github.com/duckduckgo/iOS/pull/3820 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3746 What kind of version bump will this require?: Major **Description**: Networking v2 Improvements - Introduction of RetryPolicy - Authentication support improved with refresh callback in case of 401 error - Equatable conformance - Hashable Conformance - APIRequestV2Error Equatable conformance - New Oauth framework inside Networking v2 - Malicious site protection APIClient updated to Networking v2 - A lot of utilities, moks and improvements added to `NetworkingTestingUtils` Additional changes include: - `DecodableHelper` expanded and renamed `CodableHelper` - `Date` extension with utilities + unit tests - Rationalisation of the package "Testing Utils" modules, now every library has its `XYZTestingUtils` module, I removed the generic `TestingUtils` --- .../xcschemes/BookmarksTestDBBuilder.xcscheme | 2 +- .../BrowserServicesKit-Package.xcscheme | 42 +- .../xcschemes/BrowserServicesKit.xcscheme | 2 +- .../xcschemes/Networking.xcscheme | 67 +++ .../xcschemes/NetworkingTests.xcscheme | 54 +++ .../xcschemes/SubscriptionTests.xcscheme | 2 +- .../SyncMetadataTestDBBuilder.xcscheme | 2 +- Package.resolved | 27 ++ Package.swift | 64 +-- Sources/Common/CodableHelper.swift | 55 +++ Sources/Common/Extensions/DateExtension.swift | 151 ++++--- .../Keychain => Common}/KeychainType.swift | 5 +- .../API/APIClient.swift | 5 +- Sources/Networking/Auth/Logger+OAuth.swift | 25 ++ Sources/Networking/Auth/OAuthClient.swift | 344 ++++++++++++++++ .../Networking/Auth/OAuthCodesGenerator.swift | 55 +++ .../Networking/Auth/OAuthEnvironment.swift | 41 ++ Sources/Networking/Auth/OAuthRequest.swift | 381 ++++++++++++++++++ Sources/Networking/Auth/OAuthService.swift | 320 +++++++++++++++ .../Networking/Auth/OAuthServiceError.swift | 59 +++ Sources/Networking/Auth/SessionDelegate.swift | 28 ++ Sources/Networking/Auth/TokenContainer.swift | 168 ++++++++ Sources/Networking/v1/APIHeaders.swift | 2 - .../v1/APIRequestConfiguration.swift | 1 - .../v1/HTTPURLResponseExtension.swift | 1 - Sources/Networking/v2/APIRequestV2.swift | 103 +++-- ...tErrorV2.swift => APIRequestV2Error.swift} | 30 +- Sources/Networking/v2/APIResponseV2.swift | 3 +- Sources/Networking/v2/APIService.swift | 62 ++- .../v2/Extensions/Dictionary+QueryItems.swift | 26 ++ .../Extensions/HTTPURLResponse+Cookie.swift} | 24 +- ...ities.swift => HTTPURLResponse+Etag.swift} | 9 +- .../HTTPURLResponse+HTTPStatusCode.swift | 26 ++ .../Extensions/URL+QueryParamExtraction.swift | 37 ++ .../v2/HTTP Components/HTTPStatusCode.swift | 14 +- Sources/Networking/v2/HeadersV2.swift | 52 ++- Sources/Networking/v2/QueryItems.swift | 38 ++ .../APIMockResponseFactory.swift | 106 +++++ .../HTTPURLResponseExtension.swift | 14 +- .../MockAPIService.swift | 66 +++ .../MockLegacyTokenStorage.swift} | 19 +- .../MockOAuthClient.swift | 155 +++++++ .../MockOAuthService.swift | 103 +++++ .../MockTokenStorage.swift | 29 ++ .../MockURLProtocol.swift | 0 .../OAuthTokensFactory.swift | 134 ++++++ .../MockKeyValueStore.swift | 1 - .../BrokenSitePromptLimiterTests.swift | 1 - Tests/BrowserServicesKit-Package.xctestplan | 213 ++++++++++ .../Autofill/AutofillPixelReporterTests.swift | 1 - .../AdClickAttributionCounterTests.swift | 2 +- .../DefaultFeatureFlaggerTests.swift | 1 - .../FeatureFlagLocalOverridesTests.swift | 2 +- .../Resources/privacy-reference-tests | 2 +- .../Extensions/DateExtensionTest.swift | 213 ++++++++++ .../ConfigurationFetcherTests.swift | 2 +- .../ConfigurationManagerTests.swift | 3 +- .../Mocks/MockStoreWithStorage.swift | 2 +- Tests/CrashesTests/CrashCollectionTests.swift | 2 +- .../DDGSyncTests/DDGSyncLifecycleTests.swift | 1 - Tests/DDGSyncTests/Mocks/Mocks.swift | 2 +- Tests/DDGSyncTests/SyncDailyStatsTests.swift | 2 +- ...aliciousSiteProtectionAPIClientTests.swift | 4 +- ...ciousSiteProtectionFeatureFlagsTests.swift | 1 - .../KnownFailureTests.swift | 2 +- ...tionConnectionBandwidthAnalyzerTests.swift | 2 +- Tests/NetworkingTests/APIRequestTests.swift | 2 +- .../Auth/OAuthClientTests.swift | 251 ++++++++++++ .../Auth/OAuthServiceTests.swift | 81 ++++ .../Auth/TokenContainerTests.swift | 139 +++++++ .../v2/APIRequestV2Tests.swift | 75 ++-- .../NetworkingTests/v2/APIServiceTests.swift | 110 ++++- .../DictionaryURLQueryItemsTests.swift | 117 ++++++ .../HTTPURLResponseCookiesTests.swift | 84 ++++ .../Extensions/HTTPURLResponseETagTests.swift | 67 +++ .../Extensions/URL+QueryParametersTests.swift | 95 +++++ .../BrokenSiteReporterTests.swift | 2 +- .../ExpiryStorageTests.swift | 2 +- ...gingPercentileUserDefaultsStoreTests.swift | 2 +- .../RemoteMessagingStoreTests.swift | 2 +- 80 files changed, 4193 insertions(+), 248 deletions(-) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/Networking.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/NetworkingTests.xcscheme create mode 100644 Sources/Common/CodableHelper.swift rename Sources/{NetworkProtection/Keychain => Common}/KeychainType.swift (96%) create mode 100644 Sources/Networking/Auth/Logger+OAuth.swift create mode 100644 Sources/Networking/Auth/OAuthClient.swift create mode 100644 Sources/Networking/Auth/OAuthCodesGenerator.swift create mode 100644 Sources/Networking/Auth/OAuthEnvironment.swift create mode 100644 Sources/Networking/Auth/OAuthRequest.swift create mode 100644 Sources/Networking/Auth/OAuthService.swift create mode 100644 Sources/Networking/Auth/OAuthServiceError.swift create mode 100644 Sources/Networking/Auth/SessionDelegate.swift create mode 100644 Sources/Networking/Auth/TokenContainer.swift rename Sources/Networking/v2/{APIRequestErrorV2.swift => APIRequestV2Error.swift} (60%) create mode 100644 Sources/Networking/v2/Extensions/Dictionary+QueryItems.swift rename Sources/{Common/DecodableHelper.swift => Networking/v2/Extensions/HTTPURLResponse+Cookie.swift} (55%) rename Sources/Networking/v2/Extensions/{HTTPURLResponse+Utilities.swift => HTTPURLResponse+Etag.swift} (82%) create mode 100644 Sources/Networking/v2/Extensions/HTTPURLResponse+HTTPStatusCode.swift create mode 100644 Sources/Networking/v2/Extensions/URL+QueryParamExtraction.swift create mode 100644 Sources/Networking/v2/QueryItems.swift create mode 100644 Sources/NetworkingTestingUtils/APIMockResponseFactory.swift rename Sources/{TestUtils/Utils => NetworkingTestingUtils}/HTTPURLResponseExtension.swift (74%) create mode 100644 Sources/NetworkingTestingUtils/MockAPIService.swift rename Sources/{TestUtils/MockAPIService.swift => NetworkingTestingUtils/MockLegacyTokenStorage.swift} (55%) create mode 100644 Sources/NetworkingTestingUtils/MockOAuthClient.swift create mode 100644 Sources/NetworkingTestingUtils/MockOAuthService.swift create mode 100644 Sources/NetworkingTestingUtils/MockTokenStorage.swift rename Sources/{TestUtils => NetworkingTestingUtils}/MockURLProtocol.swift (100%) create mode 100644 Sources/NetworkingTestingUtils/OAuthTokensFactory.swift rename Sources/{TestUtils => PersistenceTestingUtils}/MockKeyValueStore.swift (99%) create mode 100644 Tests/BrowserServicesKit-Package.xctestplan create mode 100644 Tests/CommonTests/Extensions/DateExtensionTest.swift create mode 100644 Tests/NetworkingTests/Auth/OAuthClientTests.swift create mode 100644 Tests/NetworkingTests/Auth/OAuthServiceTests.swift create mode 100644 Tests/NetworkingTests/Auth/TokenContainerTests.swift create mode 100644 Tests/NetworkingTests/v2/Extensions/DictionaryURLQueryItemsTests.swift create mode 100644 Tests/NetworkingTests/v2/Extensions/HTTPURLResponseCookiesTests.swift create mode 100644 Tests/NetworkingTests/v2/Extensions/HTTPURLResponseETagTests.swift create mode 100644 Tests/NetworkingTests/v2/Extensions/URL+QueryParametersTests.swift diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BookmarksTestDBBuilder.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BookmarksTestDBBuilder.xcscheme index 903ba019f..f23bed1fa 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/BookmarksTestDBBuilder.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/BookmarksTestDBBuilder.xcscheme @@ -1,6 +1,6 @@ + + + + + + + + + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + skipped = "NO" + parallelizable = "YES"> + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/NetworkingTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/NetworkingTests.xcscheme new file mode 100644 index 000000000..d5063487f --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/NetworkingTests.xcscheme @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/SubscriptionTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/SubscriptionTests.xcscheme index 63c498679..e00698aec 100644 --- a/.swiftpm/xcode/xcshareddata/xcschemes/SubscriptionTests.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/SubscriptionTests.xcscheme @@ -1,6 +1,6 @@ (from input: Input) -> T? { + do { + let json = try JSONSerialization.data(withJSONObject: input) + return try JSONDecoder().decode(T.self, from: json) + } catch { + Logger.general.error("Error decoding input: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + public static func decode(jsonData: Data) -> T? { + do { + return try JSONDecoder().decode(T.self, from: jsonData) + } catch { + Logger.general.error("Error decoding input: \(error.localizedDescription, privacy: .public)") + } + return nil + } + + public static func encode(_ object: T) -> Data? { + do { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + return try encoder.encode(object) + } catch let error { + Logger.general.error("Error encoding input: \(error.localizedDescription, privacy: .public)") + } + return nil + } +} + +public typealias DecodableHelper = CodableHelper diff --git a/Sources/Common/Extensions/DateExtension.swift b/Sources/Common/Extensions/DateExtension.swift index 4ae7be7f6..7b64514dd 100644 --- a/Sources/Common/Extensions/DateExtension.swift +++ b/Sources/Common/Extensions/DateExtension.swift @@ -25,122 +25,165 @@ public extension Date { public let index: Int } + /// Extracts day, month, and year components from the date. var components: DateComponents { - return Calendar.current.dateComponents([.day, .year, .month], from: self) + Calendar.current.dateComponents([.day, .year, .month], from: self) } + /// Returns the date exactly one week ago. static var weekAgo: Date { - return Calendar.current.date(byAdding: .weekOfMonth, value: -1, to: Date())! + guard let date = Calendar.current.date(byAdding: .weekOfMonth, value: -1, to: Date()) else { + fatalError("Unable to calculate a week ago date.") + } + return date } - static var monthAgo: Date! { - return Calendar.current.date(byAdding: .month, value: -1, to: Date())! + /// Returns the date exactly one month ago. + static var monthAgo: Date { + guard let date = Calendar.current.date(byAdding: .month, value: -1, to: Date()) else { + fatalError("Unable to calculate a month ago date.") + } + return date } - static var yearAgo: Date! { - return Calendar.current.date(byAdding: .year, value: -1, to: Date())! + /// Returns the date exactly one year ago. + static var yearAgo: Date { + guard let date = Calendar.current.date(byAdding: .year, value: -1, to: Date()) else { + fatalError("Unable to calculate a year ago date.") + } + return date } - static var aYearFromNow: Date! { - return Calendar.current.date(byAdding: .year, value: 1, to: Date())! + /// Returns the date exactly one year from now. + static var aYearFromNow: Date { + guard let date = Calendar.current.date(byAdding: .year, value: 1, to: Date()) else { + fatalError("Unable to calculate a year from now date.") + } + return date } - static func daysAgo(_ days: Int) -> Date! { - return Calendar.current.date(byAdding: .day, value: -days, to: Date())! + /// Returns the date a specific number of days ago. + static func daysAgo(_ days: Int) -> Date { + guard let date = Calendar.current.date(byAdding: .day, value: -days, to: Date()) else { + fatalError("Unable to calculate \(days) days ago date.") + } + return date } + /// Checks if two dates fall on the same calendar day. static func isSameDay(_ date1: Date, _ date2: Date?) -> Bool { guard let date2 = date2 else { return false } return Calendar.current.isDate(date1, inSameDayAs: date2) } + /// Returns the start of tomorrow's day. static var startOfDayTomorrow: Date { let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())! return Calendar.current.startOfDay(for: tomorrow) } + /// Returns the start of today's day. static var startOfDayToday: Date { - return Calendar.current.startOfDay(for: Date()) + Calendar.current.startOfDay(for: Date()) } + /// Returns the start of the day for this date instance. var startOfDay: Date { - return Calendar.current.startOfDay(for: self) + Calendar.current.startOfDay(for: self) } + /// Returns the date a specific number of days ago from this date instance. func daysAgo(_ days: Int) -> Date { - Calendar.current.date(byAdding: .day, value: -days, to: self)! + guard let date = Calendar.current.date(byAdding: .day, value: -days, to: self) else { + fatalError("Unable to calculate \(days) days ago date from this instance.") + } + return date } + /// Returns the start of the current minute. static var startOfMinuteNow: Date { - let date = Calendar.current.date(bySetting: .second, value: 0, of: Date())! - let start = Calendar.current.date(byAdding: .minute, value: -1, to: date)! + guard let date = Calendar.current.date(bySetting: .second, value: 0, of: Date()), + let start = Calendar.current.date(byAdding: .minute, value: -1, to: date) else { + fatalError("Unable to calculate the start of the current minute.") + } return start } + /// Provides a list of months with their names and indices. static var monthsWithIndex: [IndexedMonth] { - let months = Calendar.current.monthSymbols - - return months.enumerated().map { index, month in - return IndexedMonth(name: month, index: index + 1) + Calendar.current.monthSymbols.enumerated().map { index, month in + IndexedMonth(name: month, index: index + 1) } } - static var daysInMonth: [Int] = { - return Array(1...31) - }() - - static var nextTenYears: [Int] = { - let offsetComponents = DateComponents(year: 1) - - var years = [Int]() - var currentDate = Date() - - for _ in 0...10 { - let currentYear = Calendar.current.component(.year, from: currentDate) - years.append(currentYear) - - currentDate = Calendar.current.date(byAdding: offsetComponents, to: currentDate)! - } - - return years - }() - - static var lastHundredYears: [Int] = { - let offsetComponents = DateComponents(year: -1) - - var years = [Int]() - var currentDate = Date() + /// Provides a list of days in a month (1 through 31). + static let daysInMonth = Array(1...31) - for _ in 0...100 { - let currentYear = Calendar.current.component(.year, from: currentDate) - years.append(currentYear) - - currentDate = Calendar.current.date(byAdding: offsetComponents, to: currentDate)! - } + /// Provides a list of the next ten years including the current year. + static var nextTenYears: [Int] { + let currentYear = Calendar.current.component(.year, from: Date()) + return (0...10).map { currentYear + $0 } + } - return years - }() + /// Provides a list of the last hundred years including the current year. + static var lastHundredYears: [Int] { + let currentYear = Calendar.current.component(.year, from: Date()) + return (0...100).map { currentYear - $0 } + } + /// Returns the number of whole days since the reference date (January 1, 2001). var daySinceReferenceDate: Int { Int(self.timeIntervalSinceReferenceDate / TimeInterval.day) } - @inlinable + /// Adds a specific time interval to this date. func adding(_ timeInterval: TimeInterval) -> Date { addingTimeInterval(timeInterval) } + /// Checks if this date falls on the same calendar day as another date. func isSameDay(_ otherDate: Date?) -> Bool { guard let otherDate = otherDate else { return false } return Calendar.current.isDate(self, inSameDayAs: otherDate) } + /// Checks if this date is within a certain number of days ago. func isLessThan(daysAgo days: Int) -> Bool { - self > Date().addingTimeInterval(Double(-days) * 24 * 60 * 60) + self > Date().addingTimeInterval(Double(-days) * TimeInterval.day) } + /// Checks if this date is within a certain number of minutes ago. func isLessThan(minutesAgo minutes: Int) -> Bool { self > Date().addingTimeInterval(Double(-minutes) * 60) } + /// Returns the number of seconds since this date until now. + func secondsSinceNow() -> Int { + Int(Date().timeIntervalSince(self)) + } + + /// Returns the number of minutes since this date until now. + func minutesSinceNow() -> Int { + secondsSinceNow() / 60 + } + + /// Returns the number of hours since this date until now. + func hoursSinceNow() -> Int { + minutesSinceNow() / 60 + } + + /// Returns the number of days since this date until now. + func daysSinceNow() -> Int { + hoursSinceNow() / 24 + } + + /// Returns the number of months since this date until now. + func monthsSinceNow() -> Int { + Calendar.current.dateComponents([.month], from: self, to: Date()).month ?? 0 + } + + /// Returns the number of years since this date until now. + func yearsSinceNow() -> Int { + Calendar.current.dateComponents([.year], from: self, to: Date()).year ?? 0 + } } diff --git a/Sources/NetworkProtection/Keychain/KeychainType.swift b/Sources/Common/KeychainType.swift similarity index 96% rename from Sources/NetworkProtection/Keychain/KeychainType.swift rename to Sources/Common/KeychainType.swift index 0890501e3..8b550720f 100644 --- a/Sources/NetworkProtection/Keychain/KeychainType.swift +++ b/Sources/Common/KeychainType.swift @@ -19,12 +19,9 @@ import Foundation /// A convenience enum to unify the logic for selecting the right keychain through the query attributes. -/// public enum KeychainType { case dataProtection(_ accessGroup: AccessGroup) - /// Uses the system keychain. - /// case system public enum AccessGroup { @@ -32,7 +29,7 @@ public enum KeychainType { case named(_ name: String) } - func queryAttributes() -> [CFString: Any] { + public func queryAttributes() -> [CFString: Any] { switch self { case .dataProtection(let accessGroup): switch accessGroup { diff --git a/Sources/MaliciousSiteProtection/API/APIClient.swift b/Sources/MaliciousSiteProtection/API/APIClient.swift index e74e7267c..85efa7154 100644 --- a/Sources/MaliciousSiteProtection/API/APIClient.swift +++ b/Sources/MaliciousSiteProtection/API/APIClient.swift @@ -127,7 +127,10 @@ struct APIClient { let url = environment.url(for: requestType, platform: platform) let timeout = environment.timeout(for: requestType) ?? requestConfig.defaultTimeout ?? 60 - let apiRequest = APIRequestV2(url: url, method: .get, headers: headers, timeoutInterval: timeout) + guard let apiRequest = APIRequestV2(url: url, method: .get, headers: headers, timeoutInterval: timeout) else { + assertionFailure("Invalid URL") + throw APIRequestV2.Error.invalidURL + } let response = try await service.fetch(request: apiRequest) let result: R.Response = try response.decodeBody() diff --git a/Sources/Networking/Auth/Logger+OAuth.swift b/Sources/Networking/Auth/Logger+OAuth.swift new file mode 100644 index 000000000..9d1248ab9 --- /dev/null +++ b/Sources/Networking/Auth/Logger+OAuth.swift @@ -0,0 +1,25 @@ +// +// Logger+OAuth.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 os.log + +public extension Logger { + static var OAuth = { Logger(subsystem: "Networking", category: "OAuth") }() + static var OAuthClient = { Logger(subsystem: "Networking", category: "OAuthClient") }() +} diff --git a/Sources/Networking/Auth/OAuthClient.swift b/Sources/Networking/Auth/OAuthClient.swift new file mode 100644 index 000000000..91d16c2e0 --- /dev/null +++ b/Sources/Networking/Auth/OAuthClient.swift @@ -0,0 +1,344 @@ +// +// OAuthClient.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 os.log + +public enum OAuthClientError: Error, LocalizedError, Equatable { + case internalError(String) + case missingTokens + case missingRefreshToken + case unauthenticated + /// When both access token and refresh token are expired + case refreshTokenExpired + + public var errorDescription: String? { + switch self { + case .internalError(let error): + return "Internal error: \(error)" + case .missingTokens: + return "No token available" + case .missingRefreshToken: + return "No refresh token available, please re-authenticate" + case .unauthenticated: + return "The account is not authenticated, please re-authenticate" + case .refreshTokenExpired: + return "The refresh token is expired, the token is unrecoverable please re-authenticate" + } + } +} + +/// Provides the locally stored tokens container +public protocol AuthTokenStoring { + var tokenContainer: TokenContainer? { get set } +} + +/// Provides the legacy AuthToken V1 +public protocol LegacyAuthTokenStoring { + var token: String? { get set } +} + +public enum AuthTokensCachePolicy { + /// The token container from the local storage + case local + /// The token container from the local storage, refreshed if needed + case localValid + /// A refreshed token + case localForceRefresh + /// Like `.localValid`, if doesn't exist create a new one + case createIfNeeded + + public var description: String { + switch self { + case .local: + return "Local" + case .localValid: + return "Local valid" + case .localForceRefresh: + return "Local force refresh" + case .createIfNeeded: + return "Create if needed" + } + } +} + +public protocol OAuthClient { + + // MARK: - Public + + var isUserAuthenticated: Bool { get } + + var currentTokenContainer: TokenContainer? { get set } + + /// Returns a tokens container based on the policy + /// - `.local`: Returns what's in the storage, as it is, throws an error if no token is available + /// - `.localValid`: Returns what's in the storage, refreshes it if needed. throws an error if no token is available + /// - `.localForceRefresh`: Returns what's in the storage but forces a refresh first. throws an error if no refresh token is available. + /// - `.createIfNeeded`: Returns what's in the storage, if the stored token is expired refreshes it, if not token is available creates a new account/token + /// All options store new or refreshed tokens via the tokensStorage + func getTokens(policy: AuthTokensCachePolicy) async throws -> TokenContainer + + /// Migrate access token v1 to auth token v2 if needed + /// - Returns: A valid TokenContainer if a token v1 is found in the LegacyTokenContainer, nil if no v1 token is available. Throws an error in case of failures during the migration + func migrateV1Token() async throws -> TokenContainer? + + /// Use the TokenContainer provided + func adopt(tokenContainer: TokenContainer) + + /// Activate the account with a platform signature + /// - Parameter signature: The platform signature + /// - Returns: A container of tokens + func activate(withPlatformSignature signature: String) async throws -> TokenContainer + + /// Exchange token v1 for tokens v2 + /// - Parameter accessTokenV1: The legacy auth token + /// - Returns: A TokenContainer with access and refresh tokens + func exchange(accessTokenV1: String) async throws -> TokenContainer + + // MARK: Logout + + /// Logout by invalidating the current access token + func logout() async throws + + /// Remove the tokens container stored locally + func removeLocalAccount() +} + +final public class DefaultOAuthClient: OAuthClient { + + struct Constants { + /// https://app.asana.com/0/1205784033024509/1207979495854201/f + static let clientID = "f4311287-0121-40e6-8bbd-85c36daf1837" + static let redirectURI = "com.duckduckgo:/authcb" + static let availableScopes = [ "privacypro" ] + } + + // MARK: - + + let authService: any OAuthService + var tokenStorage: any AuthTokenStoring + var legacyTokenStorage: (any LegacyAuthTokenStoring)? + + public init(tokensStorage: any AuthTokenStoring, + legacyTokenStorage: (any LegacyAuthTokenStoring)?, + authService: OAuthService) { + self.tokenStorage = tokensStorage + self.legacyTokenStorage = legacyTokenStorage + self.authService = authService + } + + // MARK: - Internal + + @discardableResult + func getTokens(authCode: String, codeVerifier: String) async throws -> TokenContainer { + Logger.OAuthClient.log("Getting tokens") + let getTokensResponse = try await authService.getAccessToken(clientID: Constants.clientID, + codeVerifier: codeVerifier, + code: authCode, + redirectURI: Constants.redirectURI) + return try await decode(accessToken: getTokensResponse.accessToken, refreshToken: getTokensResponse.refreshToken) + } + + func getVerificationCodes() async throws -> (codeVerifier: String, codeChallenge: String) { + Logger.OAuthClient.log("Getting verification codes") + let codeVerifier = OAuthCodesGenerator.codeVerifier + guard let codeChallenge = OAuthCodesGenerator.codeChallenge(codeVerifier: codeVerifier) else { + Logger.OAuthClient.error("Failed to get verification codes") + throw OAuthClientError.internalError("Failed to generate code challenge") + } + return (codeVerifier, codeChallenge) + } + +#if DEBUG + var testingDecodedTokenContainer: TokenContainer? +#endif + func decode(accessToken: String, refreshToken: String) async throws -> TokenContainer { + Logger.OAuthClient.log("Decoding tokens") + +#if DEBUG + if let testingDecodedTokenContainer { + return testingDecodedTokenContainer + } +#endif + + let jwtSigners = try await authService.getJWTSigners() + let decodedAccessToken = try jwtSigners.verify(accessToken, as: JWTAccessToken.self) + let decodedRefreshToken = try jwtSigners.verify(refreshToken, as: JWTRefreshToken.self) + + return TokenContainer(accessToken: accessToken, + refreshToken: refreshToken, + decodedAccessToken: decodedAccessToken, + decodedRefreshToken: decodedRefreshToken) + } + + // MARK: - Public + + public var isUserAuthenticated: Bool { + tokenStorage.tokenContainer != nil + } + + public var currentTokenContainer: TokenContainer? { + get { + tokenStorage.tokenContainer + } + set { + tokenStorage.tokenContainer = newValue + } + } + + public func getTokens(policy: AuthTokensCachePolicy) async throws -> TokenContainer { + let localTokenContainer = tokenStorage.tokenContainer + + switch policy { + case .local: + guard let localTokenContainer else { + Logger.OAuthClient.debug("Tokens not found") + throw OAuthClientError.missingTokens + } + Logger.OAuthClient.debug("Local tokens found, expiry: \(localTokenContainer.decodedAccessToken.exp.value, privacy: .public)") + return localTokenContainer + case .localValid: + guard let localTokenContainer else { + Logger.OAuthClient.debug("Tokens not found") + throw OAuthClientError.missingTokens + } + Logger.OAuthClient.debug("Local tokens found, expiry: \(localTokenContainer.decodedAccessToken.exp.value, privacy: .public)") + if localTokenContainer.decodedAccessToken.isExpired() { + Logger.OAuthClient.debug("Local access token is expired, refreshing it") + return try await getTokens(policy: .localForceRefresh) + } else { + return localTokenContainer + } + case .localForceRefresh: + guard let refreshToken = localTokenContainer?.refreshToken else { + Logger.OAuthClient.debug("Refresh token not found") + throw OAuthClientError.missingRefreshToken + } + do { + let refreshTokenResponse = try await authService.refreshAccessToken(clientID: Constants.clientID, refreshToken: refreshToken) + let refreshedTokens = try await decode(accessToken: refreshTokenResponse.accessToken, refreshToken: refreshTokenResponse.refreshToken) + Logger.OAuthClient.debug("Tokens refreshed: \(refreshedTokens.debugDescription)") + tokenStorage.tokenContainer = refreshedTokens + return refreshedTokens + } catch OAuthServiceError.authAPIError(let code) where code == OAuthRequest.BodyErrorCode.invalidTokenRequest { + Logger.OAuthClient.error("Failed to refresh token: invalidTokenRequest") + throw OAuthClientError.refreshTokenExpired + } catch OAuthServiceError.authAPIError(let code) { + Logger.OAuthClient.error("Failed to refresh token: \(code.rawValue, privacy: .public), \(code.description, privacy: .public)") + throw OAuthServiceError.authAPIError(code: code) + } + case .createIfNeeded: + do { + return try await getTokens(policy: .localValid) + } catch { + Logger.OAuthClient.debug("Local token not found, creating a new account") + do { + let tokens = try await createAccount() + tokenStorage.tokenContainer = tokens + return tokens + } catch { + Logger.OAuthClient.fault("Failed to create account: \(error, privacy: .public)") + throw error + } + } + } + } + + /// Tries to retrieve the v1 auth token stored locally, if present performs a migration to v2 and removes the old token + public func migrateV1Token() async throws -> TokenContainer? { + guard !isUserAuthenticated, // Migration already performed, a v2 token is present + let legacyTokenStorage, + let legacyToken = legacyTokenStorage.token else { + return nil + } + + Logger.OAuthClient.log("Migrating legacy token") + do { + let tokenContainer = try await exchange(accessTokenV1: legacyToken) + Logger.OAuthClient.log("Tokens migrated successfully, removing legacy token") + + // NOTE: We don't remove the old token to allow roll back to Auth V1 + + // Store new tokens + tokenStorage.tokenContainer = tokenContainer + return tokenContainer + } catch { + Logger.OAuthClient.error("Failed to migrate legacy token: \(error, privacy: .public)") + throw error + } + } + + public func adopt(tokenContainer: TokenContainer) { + Logger.OAuthClient.log("Adopting TokenContainer: \(tokenContainer.debugDescription)") + tokenStorage.tokenContainer = tokenContainer + } + + // MARK: Create + + /// Create an accounts, stores all tokens and returns them + func createAccount() async throws -> TokenContainer { + Logger.OAuthClient.log("Creating new account") + let (codeVerifier, codeChallenge) = try await getVerificationCodes() + let authSessionID = try await authService.authorize(codeChallenge: codeChallenge) + let authCode = try await authService.createAccount(authSessionID: authSessionID) + let tokenContainer = try await getTokens(authCode: authCode, codeVerifier: codeVerifier) + Logger.OAuthClient.log("New account created successfully") + return tokenContainer + } + + public func activate(withPlatformSignature signature: String) async throws -> TokenContainer { + Logger.OAuthClient.log("Activating with platform signature") + let (codeVerifier, codeChallenge) = try await getVerificationCodes() + let authSessionID = try await authService.authorize(codeChallenge: codeChallenge) + let authCode = try await authService.login(withSignature: signature, authSessionID: authSessionID) + let tokens = try await getTokens(authCode: authCode, codeVerifier: codeVerifier) + tokenStorage.tokenContainer = tokens + Logger.OAuthClient.log("Activation completed") + return tokens + } + + // MARK: Exchange V1 to V2 token + + public func exchange(accessTokenV1: String) async throws -> TokenContainer { + Logger.OAuthClient.log("Exchanging access token V1 to V2") + let (codeVerifier, codeChallenge) = try await getVerificationCodes() + let authSessionID = try await authService.authorize(codeChallenge: codeChallenge) + let authCode = try await authService.exchangeToken(accessTokenV1: accessTokenV1, authSessionID: authSessionID) + let tokenContainer = try await getTokens(authCode: authCode, codeVerifier: codeVerifier) + tokenStorage.tokenContainer = tokenContainer + return tokenContainer + } + + // MARK: Logout + + public func logout() async throws { + let existingToken = tokenStorage.tokenContainer?.accessToken + removeLocalAccount() + + if let existingToken { + Logger.OAuthClient.log("Logging out") + try await authService.logout(accessToken: existingToken) + } + } + + public func removeLocalAccount() { + Logger.OAuthClient.log("Removing local account") + tokenStorage.tokenContainer = nil + legacyTokenStorage?.token = nil + } +} diff --git a/Sources/Networking/Auth/OAuthCodesGenerator.swift b/Sources/Networking/Auth/OAuthCodesGenerator.swift new file mode 100644 index 000000000..5210f9387 --- /dev/null +++ b/Sources/Networking/Auth/OAuthCodesGenerator.swift @@ -0,0 +1,55 @@ +// +// OAuthCodesGenerator.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 CommonCrypto + +/// Helper that generates codes used in the OAuth2 authentication process +struct OAuthCodesGenerator { + + /// https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow-with-pkce/add-login-using-the-authorization-code-flow-with-pkce#create-code-verifier + static var codeVerifier: String { + var buffer = [UInt8](repeating: 0, count: 128) + _ = SecRandomCopyBytes(kSecRandomDefault, buffer.count, &buffer) + return Data(buffer).base64EncodedString().replacingInvalidCharacters() + } + + /// https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow-with-pkce/add-login-using-the-authorization-code-flow-with-pkce#create-code-challenge + static func codeChallenge(codeVerifier: String) -> String? { + + guard let data = codeVerifier.data(using: .utf8) else { + assertionFailure("Failed to generate OAuth2 code challenge") + return nil + } + var buffer = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + _ = data.withUnsafeBytes { + CC_SHA256($0.baseAddress, CC_LONG(data.count), &buffer) + } + let hash = Data(buffer) + return hash.base64EncodedString().replacingInvalidCharacters() + } +} + +fileprivate extension String { + + func replacingInvalidCharacters() -> String { + self.replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Sources/Networking/Auth/OAuthEnvironment.swift b/Sources/Networking/Auth/OAuthEnvironment.swift new file mode 100644 index 000000000..878b974ed --- /dev/null +++ b/Sources/Networking/Auth/OAuthEnvironment.swift @@ -0,0 +1,41 @@ +// +// OAuthEnvironment.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 OAuthEnvironment: String, Codable, CustomStringConvertible { + case production, staging + + public var description: String { + switch self { + case .production: + "Production" + case .staging: + "Staging" + } + } + + public var url: URL { + switch self { + case .production: + URL(string: "https://quack.duckduckgo.com")! + case .staging: + URL(string: "https://quackdev.duckduckgo.com")! + } + } +} diff --git a/Sources/Networking/Auth/OAuthRequest.swift b/Sources/Networking/Auth/OAuthRequest.swift new file mode 100644 index 000000000..424d301fe --- /dev/null +++ b/Sources/Networking/Auth/OAuthRequest.swift @@ -0,0 +1,381 @@ +// +// OAuthRequest.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 os.log +import Common + +/// Auth API v2 Endpoints: https://dub.duckduckgo.com/duckduckgo/ddg/blob/main/components/auth/docs/AuthAPIV2Documentation.md#auth-api-v2-endpoints +public struct OAuthRequest { + + public let apiRequest: APIRequestV2 + public let httpSuccessCode: HTTPStatusCode + public let httpErrorCodes: [HTTPStatusCode] + public var url: URL { + apiRequest.urlRequest.url! + } + + public enum BodyErrorCode: String, Decodable { + case invalidAuthorizationRequest = "invalid_authorization_request" + case authorizeFailed = "authorize_failed" + case invalidRequest = "invalid_request" + case accountCreateFailed = "account_create_failed" + case invalidEmailAddress = "invalid_email_address" + case invalidSessionId = "invalid_session_id" + case suspendedAccount = "suspended_account" + case emailSendingError = "email_sending_error" + case invalidLoginCredentials = "invalid_login_credentials" + case unknownAccount = "unknown_account" + case invalidTokenRequest = "invalid_token_request" + case unverifiedAccount = "unverified_account" + case emailAddressNotChanged = "email_address_not_changed" + case failedMxCheck = "failed_mx_check" + case accountEditFailed = "account_edit_failed" + case invalidLinkSignature = "invalid_link_signature" + case accountChangeEmailAddressFailed = "account_change_email_address_failed" + case invalidToken = "invalid_token" + case expiredToken = "expired_token" + + public var description: String { + switch self { + case .invalidAuthorizationRequest: + return "One or more of the required parameters are missing or any provided parameters have invalid values" + case .authorizeFailed: + return "Failed to create the authorization session, either because of a reused code challenge or internal server error" + case .invalidRequest: + return "The ddg_auth_session_id is missing or has already been used to log in to a different account" + case .accountCreateFailed: + return "Failed to create the account because of an internal server error" + case .invalidEmailAddress: + return "Provided email address is missing or of an invalid format" + case .invalidSessionId: + return "The session id is missing, invalid or has already been used for logging in" + case .suspendedAccount: + return "The account you are logging in to is suspended" + case .emailSendingError: + return "Failed to send the OTP to the email address provided" + case .invalidLoginCredentials: + return "One or more of the provided parameters is invalid" + case .unknownAccount: + return "The login credentials appear valid but do not link to a known account" + case .invalidTokenRequest: + return "One or more of the required parameters are missing or any provided parameters have invalid values" + case .unverifiedAccount: + return "The token is valid but is for an unverified account" + case .emailAddressNotChanged: + return "New email address is the same as the old email address" + case .failedMxCheck: + return "DNS check to see if email address domain is valid failed" + case .accountEditFailed: + return "Something went wrong and the edit was aborted" + case .invalidLinkSignature: + return "The hash is invalid or does not match the provided email address and account" + case .accountChangeEmailAddressFailed: + return "Something went wrong and the edit was aborted" + case .invalidToken: + return "Provided access token is missing or invalid" + case .expiredToken: + return "Provided access token is expired" + } + } + } + + struct BodyError: Decodable { + let error: BodyErrorCode + } + + static func ddgAuthSessionCookie(domain: String, path: String, authSessionID: String) -> HTTPCookie? { + return HTTPCookie(properties: [ + .domain: domain, + .path: path, + .name: "ddg_auth_session_id", + .value: authSessionID + ]) + } + + // MARK: - + + init(apiRequest: APIRequestV2, + httpSuccessCode: HTTPStatusCode = HTTPStatusCode.ok, + httpErrorCodes: [HTTPStatusCode] = [HTTPStatusCode.badRequest, HTTPStatusCode.internalServerError]) { + self.apiRequest = apiRequest + self.httpSuccessCode = httpSuccessCode + self.httpErrorCodes = httpErrorCodes + } + + // MARK: Authorize + + static func authorize(baseURL: URL, codeChallenge: String) -> OAuthRequest? { + guard codeChallenge.isEmpty == false else { return nil } + + let path = "/api/auth/v2/authorize" + let queryItems: QueryItems = [ + (key: "response_type", value: "code"), + (key: "code_challenge", value: codeChallenge), + (key: "code_challenge_method", value: "S256"), + (key: "client_id", value: "f4311287-0121-40e6-8bbd-85c36daf1837"), + (key: "redirect_uri", value: "com.duckduckgo:/authcb"), + (key: "scope", value: "privacypro") + ] + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .get, + queryItems: queryItems) else { + return nil + } + return OAuthRequest(apiRequest: request, httpSuccessCode: HTTPStatusCode.found) + } + + // MARK: Create account + + static func createAccount(baseURL: URL, authSessionID: String) -> OAuthRequest? { + guard authSessionID.isEmpty == false else { return nil } + + let path = "/api/auth/v2/account/create" + guard let domain = baseURL.host, + let cookie = Self.ddgAuthSessionCookie(domain: domain, path: path, authSessionID: authSessionID) + else { return nil } + + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .post, + headers: APIRequestV2.HeadersV2(cookies: [cookie])) else { + return nil + } + return OAuthRequest(apiRequest: request, httpSuccessCode: HTTPStatusCode.found) + } + + // MARK: Sent OTP + + /// Unused in the current implementation + static func requestOTP(baseURL: URL, authSessionID: String, emailAddress: String) -> OAuthRequest? { + guard authSessionID.isEmpty == false, + emailAddress.isEmpty == false else { return nil } + + let path = "/api/auth/v2/otp" + let queryItems: QueryItems = [(key: "email", value: emailAddress)] + guard let domain = baseURL.host, + let cookie = Self.ddgAuthSessionCookie(domain: domain, path: path, authSessionID: authSessionID) + else { return nil } + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .post, + queryItems: queryItems, + headers: APIRequestV2.HeadersV2(cookies: [cookie])) else { + return nil + } + return OAuthRequest(apiRequest: request) + } + + // MARK: Login + + static func login(baseURL: URL, authSessionID: String, method: OAuthLoginMethod) -> OAuthRequest? { + guard authSessionID.isEmpty == false else { return nil } + + let path = "/api/auth/v2/login" + var body: [String: String] + + guard let domain = baseURL.host, + let cookie = Self.ddgAuthSessionCookie(domain: domain, path: path, authSessionID: authSessionID) + else { + Logger.OAuth.fault("Failed to create cookie") + assertionFailure("Failed to create cookie") + return nil + } + + switch method.self { + case is OAuthLoginMethodOTP: + guard let otpMethod = method as? OAuthLoginMethodOTP else { + return nil + } + body = [ + "method": otpMethod.name, + "email": otpMethod.email, + "otp": otpMethod.otp + ] + case is OAuthLoginMethodSignature: + guard let signatureMethod = method as? OAuthLoginMethodSignature else { + return nil + } + body = [ + "method": signatureMethod.name, + "signature": signatureMethod.signature, + "source": signatureMethod.source + ] + default: + Logger.OAuth.fault("Unknown login method: \(String(describing: method))") + assertionFailure("Unknown login method: \(String(describing: method))") + return nil + } + + guard let jsonBody = CodableHelper.encode(body) else { + assertionFailure("Failed to encode body: \(body)") + return nil + } + + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .post, + headers: APIRequestV2.HeadersV2(cookies: [cookie], + contentType: .json), + body: jsonBody, + retryPolicy: APIRequestV2.RetryPolicy(maxRetries: 3, delay: 2)) else { + return nil + } + return OAuthRequest(apiRequest: request, httpSuccessCode: HTTPStatusCode.found) + } + + // MARK: Access Token + // Note: The API has a single endpoint for both getting a new token and refreshing an old one, but here I'll split the endpoint in 2 different calls for clarity + // https://dub.duckduckgo.com/duckduckgo/ddg/blob/main/components/auth/docs/AuthAPIV2Documentation.md#access-token + + static func getAccessToken(baseURL: URL, clientID: String, codeVerifier: String, code: String, redirectURI: String) -> OAuthRequest? { + guard clientID.isEmpty == false, + codeVerifier.isEmpty == false, + code.isEmpty == false, + redirectURI.isEmpty == false else { return nil } + + let path = "/api/auth/v2/token" + let queryItems: QueryItems = [ + (key: "grant_type", value: "authorization_code"), + (key: "client_id", value: clientID), + (key: "code_verifier", value: codeVerifier), + (key: "code", value: code), + (key: "redirect_uri", value: redirectURI) + ] + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .get, + queryItems: queryItems) else { + return nil + } + + return OAuthRequest(apiRequest: request) + } + + static func refreshAccessToken(baseURL: URL, clientID: String, refreshToken: String) -> OAuthRequest? { + guard clientID.isEmpty == false, + refreshToken.isEmpty == false else { return nil } + + let path = "/api/auth/v2/token" + let queryItems: QueryItems = [ + (key: "grant_type", value: "refresh_token"), + (key: "client_id", value: clientID), + (key: "refresh_token", value: refreshToken) + ] + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .get, + queryItems: queryItems, + timeoutInterval: 20.0) else { + return nil + } + return OAuthRequest(apiRequest: request) + } + + // MARK: Edit Account + + /// Unused in the current implementation + static func editAccount(baseURL: URL, accessToken: String, email: String?) -> OAuthRequest? { + guard accessToken.isEmpty == false else { return nil } + + let path = "/api/auth/v2/account/edit" + var queryItems: QueryItems = [] + if let email { + queryItems.append((key: "email", value: email)) + } + + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .post, + queryItems: queryItems, + headers: APIRequestV2.HeadersV2( + authToken: accessToken)) else { + return nil + } + return OAuthRequest(apiRequest: request, httpErrorCodes: [.unauthorized, .internalServerError]) + } + + /// Unused in the current implementation + static func confirmEditAccount(baseURL: URL, accessToken: String, email: String, hash: String, otp: String) -> OAuthRequest? { + guard accessToken.isEmpty == false, + email.isEmpty == false, + hash.isEmpty == false, + otp.isEmpty == false else { return nil } + + let path = "/account/edit/confirm" + let queryItems: QueryItems = [ + (key: "email", value: email), + (key: "hash", value: hash), + (key: "otp", value: otp) + ] + + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .get, + queryItems: queryItems, + headers: APIRequestV2.HeadersV2(authToken: accessToken)) else { + return nil + } + return OAuthRequest(apiRequest: request, httpErrorCodes: [.unauthorized, .internalServerError]) + } + + // MARK: Logout + + static func logout(baseURL: URL, accessToken: String) -> OAuthRequest? { + guard accessToken.isEmpty == false else { return nil } + + let path = "/api/auth/v2/logout" + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .post, + headers: APIRequestV2.HeadersV2(authToken: accessToken)) else { + return nil + } + return OAuthRequest(apiRequest: request, httpErrorCodes: [.unauthorized, .internalServerError]) + } + + // MARK: Exchange token + + static func exchangeToken(baseURL: URL, accessTokenV1: String, authSessionID: String) -> OAuthRequest? { + guard accessTokenV1.isEmpty == false, + authSessionID.isEmpty == false else { return nil } + + let path = "/api/auth/v2/exchange" + guard let domain = baseURL.host, + let cookie = Self.ddgAuthSessionCookie(domain: domain, path: path, authSessionID: authSessionID) + else { return nil } + + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .post, + headers: APIRequestV2.HeadersV2(cookies: [cookie], + authToken: accessTokenV1)) else { + return nil + } + return OAuthRequest(apiRequest: request, + httpSuccessCode: .found, + httpErrorCodes: [.badRequest, .internalServerError]) + } + + // MARK: JWKs + + /// This endpoint is where the Auth service will publish public keys for consuming services and clients to use to independently verify access tokens. Tokens should be downloaded and cached for an hour upon first use. When rotating private keys for signing JWTs, the Auth service will publish new public keys 24 hours in advance of starting to sign new JWTs with them. This should provide consuming services with plenty of time to invalidate their public key cache and have the new key available before they can expect to start receiving JWTs signed with the old key. The old key will remain published until the next key rotation, so there should generally be two public keys available through this endpoint. The response format is a standard JWKS response, as documented in RFC 7517. + static func jwks(baseURL: URL) -> OAuthRequest? { + let path = "/api/auth/v2/.well-known/jwks.json" + + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .get, + retryPolicy: APIRequestV2.RetryPolicy(maxRetries: 2, delay: 1)) else { + return nil + } + return OAuthRequest(apiRequest: request, + httpSuccessCode: .ok, + httpErrorCodes: [.internalServerError]) + } +} diff --git a/Sources/Networking/Auth/OAuthService.swift b/Sources/Networking/Auth/OAuthService.swift new file mode 100644 index 000000000..9e5c8b446 --- /dev/null +++ b/Sources/Networking/Auth/OAuthService.swift @@ -0,0 +1,320 @@ +// +// OAuthService.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 JWTKit + +public protocol OAuthService { + + /// Authorizes a user with a given code challenge. + /// - Parameter codeChallenge: The code challenge for authorization. + /// - Returns: An OAuthSessionID. + /// - Throws: An error if the authorization fails. + func authorize(codeChallenge: String) async throws -> OAuthSessionID + + /// Creates a new account using the provided auth session ID. + /// - Parameter authSessionID: The authentication session ID. + /// - Returns: The authorization code needed for the Access Token request. + /// - Throws: An error if account creation fails. + func createAccount(authSessionID: String) async throws -> AuthorisationCode + + /// Logs in a user with a signature and auth session ID. + /// - Parameters: + /// - signature: The platform signature + /// - authSessionID: The authentication session ID. + /// - Returns: An OAuthRedirectionURI. + /// - Throws: An error if login fails. + func login(withSignature signature: String, authSessionID: String) async throws -> AuthorisationCode + + /// Retrieves an access token using the provided parameters. + /// - Parameters: + /// - clientID: The client ID. + /// - codeVerifier: The code verifier. + /// - code: The authorization code. + /// - redirectURI: The redirect URI. + /// - Returns: An OAuthTokenResponse. + /// - Throws: An error if token retrieval fails. + func getAccessToken(clientID: String, codeVerifier: String, code: String, redirectURI: String) async throws -> OAuthTokenResponse + + /// Refreshes an access token using the provided client ID and refresh token. + /// - Parameters: + /// - clientID: The client ID. + /// - refreshToken: The refresh token. + /// - Returns: An OAuthTokenResponse. + /// - Throws: An error if token refresh fails. + func refreshAccessToken(clientID: String, refreshToken: String) async throws -> OAuthTokenResponse + + /// Logs out the user using the provided access token. + /// - Parameter accessToken: The access token. + /// - Throws: An error if logout fails. + func logout(accessToken: String) async throws + + /// Exchanges an access token for a new one. + /// - Parameters: + /// - accessTokenV1: The old access token. + /// - authSessionID: The authentication session ID. + /// - Returns: An OAuthRedirectionURI. + /// - Throws: An error if the exchange fails. + func exchangeToken(accessTokenV1: String, authSessionID: String) async throws -> AuthorisationCode + + /// Retrieves JWT signers using JWKs from the endpoint. + /// - Returns: A JWTSigners instance. + /// - Throws: An error if retrieval fails. + func getJWTSigners() async throws -> JWTSigners +} + +public struct DefaultOAuthService: OAuthService { + + let baseURL: URL + let apiService: any APIService + + /// Default initialiser + /// - Parameters: + /// - baseURL: The API protocol + host url, used for building all API requests' URL + public init(baseURL: URL, apiService: any APIService) { + self.baseURL = baseURL + self.apiService = apiService + } + + /// Extract an header from the HTTP response + /// - Parameters: + /// - header: The header key + /// - httpResponse: The HTTP URL Response + /// - Returns: The header value, throws an error if not present + func extract(header: String, from httpResponse: HTTPURLResponse) throws -> String { + let headers = httpResponse.allHeaderFields + guard let result = headers[header] as? String else { + throw OAuthServiceError.missingResponseValue(header) + } + return result + } + + /// Extract an API error from the HTTP response body. + /// The Auth API can answer with errors in the HTTP response body, format: `{ "error": "$error_code" }`, this function decodes the body in `AuthRequest.BodyError`and generates an AuthServiceError containing the error info + /// - Parameter responseBody: The HTTP response body Data + /// - Returns: and AuthServiceError.authAPIError containing the error code and description, nil if the body + func extractError(from response: APIResponseV2) -> OAuthServiceError? { + if let bodyError: OAuthRequest.BodyError = try? response.decodeBody() { + return OAuthServiceError.authAPIError(code: bodyError.error) + } + return nil + } + + func throwError(forResponse response: APIResponseV2) throws { + if let error = extractError(from: response) { + throw error + } else { + throw OAuthServiceError.missingResponseValue("Body error") + } + } + + func fetch(request: OAuthRequest?) async throws -> APIResponseV2 { + try Task.checkCancellation() + guard let request else { + throw OAuthServiceError.invalidRequest + } + let response = try await apiService.fetch(request: request.apiRequest) + try Task.checkCancellation() + + let statusCode = response.httpResponse.httpStatus + if statusCode != request.httpSuccessCode { + if request.httpErrorCodes.contains(statusCode) { + try throwError(forResponse: response) + } else { + throw OAuthServiceError.invalidResponseCode(statusCode) + } + } + return response + } + + func fetch(request: OAuthRequest?) async throws -> T { + let response = try await fetch(request: request) + return try response.decodeBody() + } + + // MARK: - API requests + + // MARK: Authorize + + public func authorize(codeChallenge: String) async throws -> OAuthSessionID { + let request = OAuthRequest.authorize(baseURL: baseURL, codeChallenge: codeChallenge) + let response = try await fetch(request: request) + guard let cookieValue = response.httpResponse.getCookie(withName: "ddg_auth_session_id")?.value else { + throw OAuthServiceError.missingResponseValue("ddg_auth_session_id cookie") + } + return cookieValue + } + + // MARK: Create Account + + public func createAccount(authSessionID: String) async throws -> AuthorisationCode { + let request = OAuthRequest.createAccount(baseURL: baseURL, authSessionID: authSessionID) + let response = try await fetch(request: request) + // The redirect URI from the original Authorization request indicated by the ddg_auth_session_id in the provided Cookie header, with the authorization code needed for the Access Token request appended as a query param. The intention is that the client will intercept this redirect and extract the authorization code to make the Access Token request in the background. + let redirectURI = try extract(header: HTTPHeaderKey.location, from: response.httpResponse) + // Extract the code from the URL query params, example: com.duckduckgo:/authcb?code=NgNjnlLaqUomt9b5LDbzAtTyeW9cBNhCGtLB3vpcctluSZI51M9tb2ZDIZdijSPTYBr4w8dtVZl85zNSemxozv + guard let authCode = URLComponents(string: redirectURI)?.queryItems?.first(where: { queryItem in + queryItem.name == "code" + })?.value else { + throw OAuthServiceError.missingResponseValue("Authorization Code in redirect URI") + } + return authCode + } + + public func login(withSignature signature: String, authSessionID: String) async throws -> AuthorisationCode { + let method = OAuthLoginMethodSignature(signature: signature) + let request = OAuthRequest.login(baseURL: baseURL, authSessionID: authSessionID, method: method) + let response = try await fetch(request: request) + // Example: "com.duckduckgo:/authcb?code=eud8rNxyq2lhN4VFwQ7CAcir80dFBRIE4YpPY0gqeunTw4j6SoWkN4AA2c0TNO1sohqe84zubUtERkLLl94Qam" + guard let locationHeaderValue = try? extract(header: HTTPHeaderKey.location, from: response.httpResponse), + let redirectURL = URL(string: locationHeaderValue), + let authCode = redirectURL.queryParameters()?["code"] else { + throw OAuthServiceError.missingResponseValue("Auth code") + } + return authCode + } + + // MARK: Access token + + public func getAccessToken(clientID: String, codeVerifier: String, code: String, redirectURI: String) async throws -> OAuthTokenResponse { + let request = OAuthRequest.getAccessToken(baseURL: baseURL, clientID: clientID, codeVerifier: codeVerifier, code: code, redirectURI: redirectURI) + return try await fetch(request: request) + } + + public func refreshAccessToken(clientID: String, refreshToken: String) async throws -> OAuthTokenResponse { + let request = OAuthRequest.refreshAccessToken(baseURL: baseURL, clientID: clientID, refreshToken: refreshToken) + return try await fetch(request: request) + } + + // MARK: Logout + + public func logout(accessToken: String) async throws { + let request = OAuthRequest.logout(baseURL: baseURL, accessToken: accessToken) + let response: LogoutResponse = try await fetch(request: request) + guard response.status == "logged_out" else { + throw OAuthServiceError.missingResponseValue("LogoutResponse.status") + } + } + + // MARK: Access token exchange + + public func exchangeToken(accessTokenV1: String, authSessionID: String) async throws -> AuthorisationCode { + let request = OAuthRequest.exchangeToken(baseURL: baseURL, accessTokenV1: accessTokenV1, authSessionID: authSessionID) + let response = try await fetch(request: request) + let redirectURI = try extract(header: HTTPHeaderKey.location, from: response.httpResponse) + // Extract the code from the URL query params, example: com.duckduckgo:/authcb?code=NgNj...ozv + guard let authCode = URLComponents(string: redirectURI)?.queryItems?.first(where: { queryItem in + queryItem.name == "code" + })?.value else { + throw OAuthServiceError.missingResponseValue("Authorization Code in redirect URI") + } + return authCode + } + + // MARK: JWKs + + /// Create a JWTSigners with the JWKs provided by the endpoint + /// - Returns: A JWTSigners that can be used to verify JWTs + public func getJWTSigners() async throws -> JWTSigners { + let request = OAuthRequest.jwks(baseURL: baseURL) + let response: String = try await fetch(request: request) + let signers = JWTSigners() + try signers.use(jwksJSON: response) + return signers + } +} + +// MARK: - Requests' support models and types + +public typealias OAuthSessionID = String + +public protocol OAuthLoginMethod { + var name: String { get } +} + +public struct OAuthLoginMethodOTP: OAuthLoginMethod { + public let name = "otp" + public let email: String + public let otp: String +} + +public struct OAuthLoginMethodSignature: OAuthLoginMethod { + public let name = "signature" + public let signature: String + public let source = "apple_app_store" +} + +/// The redirect URI from the original Authorization request indicated by the ddg_auth_session_id in the provided Cookie header, with the authorization code needed for the Access Token request appended as a query param. The intention is that the client will intercept this redirect and extract the authorization code to make the Access Token request in the background. +public typealias AuthorisationCode = String + +/// https://www.rfc-editor.org/rfc/rfc6749#section-4.2.2 +public struct OAuthTokenResponse: Decodable { + /// JWT with encoded account details and entitlements. Can be verified using tokens published on the /api/auth/v2/.well-known/jwks.json endpoint. Used to gain access to Privacy Pro BE service resources (VPN, PIR, ITR). Expires after 4 hours, but can be refreshed with a refresh token. + let accessToken: String + /// JWT which can be used to get a new access token after the access token expires. Expires after 30 days. Can only be used once. Re-using a refresh token will invalidate any access tokens already issued from that refresh token. + let refreshToken: String + /// **ignored** access token expiry date in seconds. The real expiry date will be decoded from the JWT token itself + let expiresIn: Double + /// Fix as `Bearer` https://www.rfc-editor.org/rfc/rfc6749#section-7.1 + let tokenType: String + + enum CodingKeys: CodingKey { + case accessToken + case refreshToken + case expiresIn + case tokenType + + var stringValue: String { + switch self { + case .accessToken: return "access_token" + case .refreshToken: return "refresh_token" + case .expiresIn: return "expires_in" + case .tokenType: return "token_type" + } + } + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.accessToken = try container.decode(String.self, forKey: .accessToken) + self.refreshToken = try container.decode(String.self, forKey: .refreshToken) + self.expiresIn = try container.decode(Double.self, forKey: .expiresIn) + self.tokenType = try container.decode(String.self, forKey: .tokenType) + } + + init(accessToken: String, refreshToken: String) { + self.accessToken = accessToken + self.refreshToken = refreshToken + self.expiresIn = 14400 + self.tokenType = "Bearer" + } +} + +public struct EditAccountResponse: Decodable { + let status: String // Always "confirm" + let hash: String // Edit hash for edit confirmation +} + +public struct ConfirmEditAccountResponse: Decodable { + let status: String // Always "confirmed" + let email: String // The new email address +} + +public struct LogoutResponse: Decodable { + let status: String // Always "logged_out" +} diff --git a/Sources/Networking/Auth/OAuthServiceError.swift b/Sources/Networking/Auth/OAuthServiceError.swift new file mode 100644 index 000000000..5d39db557 --- /dev/null +++ b/Sources/Networking/Auth/OAuthServiceError.swift @@ -0,0 +1,59 @@ +// +// OAuthServiceError.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 OAuthServiceError: Error, LocalizedError, Equatable { + case authAPIError(code: OAuthRequest.BodyErrorCode) + case apiServiceError(Error) + case invalidRequest + case invalidResponseCode(HTTPStatusCode) + case missingResponseValue(String) + + public var errorDescription: String? { + switch self { + case .authAPIError(let code): + "Auth API responded with error \(code.rawValue) - \(code.description)" + case .apiServiceError(let error): + "API service error - \(error.localizedDescription)" + case .invalidRequest: + "Failed to generate the API request" + case .invalidResponseCode(let code): + "Invalid API request response code: \(code.rawValue) - \(code.description)" + case .missingResponseValue(let value): + "The API response is missing \(value)" + } + } + + public static func == (lhs: OAuthServiceError, rhs: OAuthServiceError) -> Bool { + switch (lhs, rhs) { + case (.authAPIError(let lhsCode), .authAPIError(let rhsCode)): + return lhsCode == rhsCode + case (.apiServiceError(let lhsError), .apiServiceError(let rhsError)): + return lhsError.localizedDescription == rhsError.localizedDescription + case (.invalidRequest, .invalidRequest): + return true + case (.invalidResponseCode(let lhsCode), .invalidResponseCode(let rhsCode)): + return lhsCode == rhsCode + case (.missingResponseValue(let lhsValue), .missingResponseValue(let rhsValue)): + return lhsValue == rhsValue + default: + return false + } + } +} diff --git a/Sources/Networking/Auth/SessionDelegate.swift b/Sources/Networking/Auth/SessionDelegate.swift new file mode 100644 index 000000000..d5052d1e8 --- /dev/null +++ b/Sources/Networking/Auth/SessionDelegate.swift @@ -0,0 +1,28 @@ +// +// SessionDelegate.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 os.log + +public final class SessionDelegate: NSObject, URLSessionTaskDelegate { + + /// Disable automatic redirection, in our specific OAuth implementation we manage the redirection, not the user + public func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest) async -> URLRequest? { + return nil + } +} diff --git a/Sources/Networking/Auth/TokenContainer.swift b/Sources/Networking/Auth/TokenContainer.swift new file mode 100644 index 000000000..114d4ca43 --- /dev/null +++ b/Sources/Networking/Auth/TokenContainer.swift @@ -0,0 +1,168 @@ +// +// TokenContainer.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 JWTKit + +/// Container for both access and refresh tokens +/// +/// WARNING: Specialised for Privacy Pro Subscription, abstract for other use cases. +/// +/// This is the object that should be stored in the keychain and used to make authenticated requests +/// The decoded tokens are used to determine the user's entitlements +/// The access token is used to make authenticated requests +/// The refresh token is used to get a new access token when the current one expires +public struct TokenContainer: Codable { + public let accessToken: String + public let refreshToken: String + public let decodedAccessToken: JWTAccessToken + public let decodedRefreshToken: JWTRefreshToken +} + +extension TokenContainer: Equatable { + + public static func == (lhs: TokenContainer, rhs: TokenContainer) -> Bool { + lhs.accessToken == rhs.accessToken && lhs.refreshToken == rhs.refreshToken + } +} + +extension TokenContainer: CustomDebugStringConvertible { + + public var debugDescription: String { + """ + Access Token: \(decodedAccessToken) + Refresh Token: \(decodedRefreshToken) + """ + } +} + +/// Convenience init and accessor used when the token container is send via IPC and NSData is needed +extension TokenContainer { + + public var data: NSData? { + return try? JSONEncoder().encode(self) as NSData + } + + public init(with data: NSData) throws { + self = try JSONDecoder().decode(TokenContainer.self, from: data as Data) + } +} + +public enum TokenPayloadError: Error { + case invalidTokenScope +} + +public struct JWTAccessToken: JWTPayload, Equatable { + let exp: ExpirationClaim + let iat: IssuedAtClaim + let sub: SubjectClaim + let aud: AudienceClaim + let iss: IssuerClaim + let jti: IDClaim + let scope: String + let api: String // always v2 + public let email: String? + let entitlements: [EntitlementPayload] + + public func verify(using signer: JWTKit.JWTSigner) throws { + try self.exp.verifyNotExpired() + if self.scope != "privacypro" { + throw TokenPayloadError.invalidTokenScope + } + } + + public func isExpired() -> Bool { + do { + try self.exp.verifyNotExpired() + } catch { + return true + } + return false + } + + public var externalID: String { + sub.value + } + + public var expirationDate: Date { + exp.value + } +} + +public struct JWTRefreshToken: JWTPayload, Equatable { + let exp: ExpirationClaim + let iat: IssuedAtClaim + let sub: SubjectClaim + let aud: AudienceClaim + let iss: IssuerClaim + let jti: IDClaim + let scope: String + let api: String + + public func verify(using signer: JWTKit.JWTSigner) throws { + try self.exp.verifyNotExpired() + if self.scope != "refresh" { + throw TokenPayloadError.invalidTokenScope + } + } + + public func isExpired() -> Bool { + do { + try self.exp.verifyNotExpired() + } catch { + return true + } + return false + } + + public var expirationDate: Date { + exp.value + } +} + +public enum SubscriptionEntitlement: String, Codable, Equatable, CustomDebugStringConvertible { + case networkProtection = "Network Protection" + case dataBrokerProtection = "Data Broker Protection" + case identityTheftRestoration = "Identity Theft Restoration" + case identityTheftRestorationGlobal = "Global Identity Theft Restoration" + case unknown + + public init(from decoder: Decoder) throws { + self = try Self(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .unknown + } + + public var debugDescription: String { + return self.rawValue + } +} + +public struct EntitlementPayload: Codable, Equatable { + public let product: SubscriptionEntitlement // Can expand in future + public let name: String // always `subscriber` +} + +public extension JWTAccessToken { + + var subscriptionEntitlements: [SubscriptionEntitlement] { + entitlements.map(\.product) + } + + func hasEntitlement(_ entitlement: SubscriptionEntitlement) -> Bool { + subscriptionEntitlements.contains(entitlement) + } +} diff --git a/Sources/Networking/v1/APIHeaders.swift b/Sources/Networking/v1/APIHeaders.swift index a5786c949..cd62366ba 100644 --- a/Sources/Networking/v1/APIHeaders.swift +++ b/Sources/Networking/v1/APIHeaders.swift @@ -18,8 +18,6 @@ import Foundation -public typealias HTTPHeaders = [String: String] - public extension APIRequest { struct Headers { diff --git a/Sources/Networking/v1/APIRequestConfiguration.swift b/Sources/Networking/v1/APIRequestConfiguration.swift index e158ddfc4..3dc29c323 100644 --- a/Sources/Networking/v1/APIRequestConfiguration.swift +++ b/Sources/Networking/v1/APIRequestConfiguration.swift @@ -17,7 +17,6 @@ // import Foundation -import Common extension APIRequest { diff --git a/Sources/Networking/v1/HTTPURLResponseExtension.swift b/Sources/Networking/v1/HTTPURLResponseExtension.swift index 5b00fe308..7b97c5ab1 100644 --- a/Sources/Networking/v1/HTTPURLResponseExtension.swift +++ b/Sources/Networking/v1/HTTPURLResponseExtension.swift @@ -17,7 +17,6 @@ // import Foundation -import Common public extension HTTPURLResponse { diff --git a/Sources/Networking/v2/APIRequestV2.swift b/Sources/Networking/v2/APIRequestV2.swift index a61604861..98156d505 100644 --- a/Sources/Networking/v2/APIRequestV2.swift +++ b/Sources/Networking/v2/APIRequestV2.swift @@ -16,14 +16,29 @@ // limitations under the License. // -import Common import Foundation -public struct APIRequestV2: CustomDebugStringConvertible { +public struct APIRequestV2: Hashable, CustomDebugStringConvertible { + + private(set) var urlRequest: URLRequest + + public struct RetryPolicy: Hashable, CustomDebugStringConvertible { + public let maxRetries: Int + public let delay: TimeInterval + + public init(maxRetries: Int, delay: TimeInterval = 0) { + self.maxRetries = maxRetries + self.delay = delay + } + + public var debugDescription: String { + "MaxRetries: \(maxRetries), delay: \(delay)" + } + } let timeoutInterval: TimeInterval let responseConstraints: [APIResponseConstraints]? - public let urlRequest: URLRequest + let retryPolicy: RetryPolicy? /// Designated initialiser /// - Parameters: @@ -36,25 +51,34 @@ public struct APIRequestV2: CustomDebugStringConvertible { /// - cachePolicy: The request cache policy, default is `.useProtocolCachePolicy` /// - responseRequirements: The response requirements /// - allowedQueryReservedCharacters: The characters in this character set will not be URL encoded in the query parameters - public init( - url: URL, - method: HTTPRequestMethod = .get, - queryItems: QueryParams?, - headers: APIRequestV2.HeadersV2? = APIRequestV2.HeadersV2(), - body: Data? = nil, - timeoutInterval: TimeInterval = 60.0, - cachePolicy: URLRequest.CachePolicy? = nil, - responseConstraints: [APIResponseConstraints]? = nil, - allowedQueryReservedCharacters: CharacterSet? = nil - ) where QueryParams.Element == (key: String, value: String) { + /// - Note: The init can return nil if the URLComponents fails to parse the provided URL + public init?(url: URL, + method: HTTPRequestMethod = .get, + queryItems: QueryItems? = nil, + headers: APIRequestV2.HeadersV2? = APIRequestV2.HeadersV2(), + body: Data? = nil, + timeoutInterval: TimeInterval = 60.0, + retryPolicy: RetryPolicy? = nil, + cachePolicy: URLRequest.CachePolicy? = nil, + responseConstraints: [APIResponseConstraints]? = nil, + allowedQueryReservedCharacters: CharacterSet? = nil) { self.timeoutInterval = timeoutInterval self.responseConstraints = responseConstraints - let finalURL = if let queryItems { - url.appendingParameters(queryItems, allowedReservedCharacters: allowedQueryReservedCharacters) - } else { - url + // Generate URL request + guard var urlComps = URLComponents(url: url, resolvingAgainstBaseURL: true) else { + assertionFailure("Malformed URL: \(url)") + return nil + } + if let queryItems { + // we append both the query items already added to the URL and the new passed as parameters + let originalQI = urlComps.queryItems ?? [] + urlComps.queryItems = originalQI + queryItems.toURLQueryItems(allowedReservedCharacters: allowedQueryReservedCharacters) + } + guard let finalURL = urlComps.url else { + assertionFailure("Malformed URL from URLComponents: \(urlComps)") + return nil } var request = URLRequest(url: finalURL, timeoutInterval: timeoutInterval) request.allHTTPHeaderFields = headers?.httpHeaders @@ -64,19 +88,7 @@ public struct APIRequestV2: CustomDebugStringConvertible { request.cachePolicy = cachePolicy } self.urlRequest = request - } - - public init( - url: URL, - method: HTTPRequestMethod = .get, - headers: APIRequestV2.HeadersV2? = APIRequestV2.HeadersV2(), - body: Data? = nil, - timeoutInterval: TimeInterval = 60.0, - cachePolicy: URLRequest.CachePolicy? = nil, - responseConstraints: [APIResponseConstraints]? = nil, - allowedQueryReservedCharacters: CharacterSet? = nil - ) { - self.init(url: url, method: method, queryItems: [String: String]?.none, headers: headers, body: body, timeoutInterval: timeoutInterval, cachePolicy: cachePolicy, responseConstraints: responseConstraints, allowedQueryReservedCharacters: allowedQueryReservedCharacters) + self.retryPolicy = retryPolicy } public var debugDescription: String { @@ -89,6 +101,35 @@ public struct APIRequestV2: CustomDebugStringConvertible { Timeout Interval: \(timeoutInterval)s Cache Policy: \(urlRequest.cachePolicy) Response Constraints: \(responseConstraints?.map { $0.rawValue } ?? []) + Retry Policy: \(retryPolicy?.debugDescription ?? "None") """ } + + public mutating func updateAuthorizationHeader(_ token: String) { + self.urlRequest.allHTTPHeaderFields?[HTTPHeaderKey.authorization] = "Bearer \(token)" + } + + public var isAuthenticated: Bool { + return urlRequest.allHTTPHeaderFields?[HTTPHeaderKey.authorization] != nil + } + + // MARK: Hashable Conformance + + public static func == (lhs: APIRequestV2, rhs: APIRequestV2) -> Bool { + let urlLhs = lhs.urlRequest.url?.pathComponents.joined(separator: "/") + let urlRhs = rhs.urlRequest.url?.pathComponents.joined(separator: "/") + + return urlLhs == urlRhs && + lhs.timeoutInterval == rhs.timeoutInterval && + lhs.responseConstraints == rhs.responseConstraints && + lhs.retryPolicy == rhs.retryPolicy + } + + public func hash(into hasher: inout Hasher) { + let urlPath = urlRequest.url?.pathComponents.joined(separator: "/") + hasher.combine(urlPath) + hasher.combine(timeoutInterval) + hasher.combine(responseConstraints) + hasher.combine(retryPolicy) + } } diff --git a/Sources/Networking/v2/APIRequestErrorV2.swift b/Sources/Networking/v2/APIRequestV2Error.swift similarity index 60% rename from Sources/Networking/v2/APIRequestErrorV2.swift rename to Sources/Networking/v2/APIRequestV2Error.swift index e1f76be9e..3b0d453dd 100644 --- a/Sources/Networking/v2/APIRequestErrorV2.swift +++ b/Sources/Networking/v2/APIRequestV2Error.swift @@ -1,5 +1,5 @@ // -// APIRequestErrorV2.swift +// APIRequestV2Error.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -20,13 +20,15 @@ import Foundation extension APIRequestV2 { - public enum Error: Swift.Error, LocalizedError { + public enum Error: Swift.Error, LocalizedError, Equatable { + case urlSession(Swift.Error) case invalidResponse case unsatisfiedRequirement(APIResponseConstraints) case invalidStatusCode(Int) case invalidDataType case emptyResponseBody + case invalidURL public var errorDescription: String? { switch self { @@ -42,6 +44,30 @@ extension APIRequestV2 { return "Invalid response data type" case .emptyResponseBody: return "The response body is nil" + case .invalidURL: + return "Invalid URL" + } + } + + // MARK: - Equatable Conformance + public static func == (lhs: Error, rhs: Error) -> Bool { + switch (lhs, rhs) { + case (.urlSession(let lhsError), .urlSession(let rhsError)): + return lhsError.localizedDescription == rhsError.localizedDescription + case (.invalidResponse, .invalidResponse): + return true + case (.unsatisfiedRequirement(let lhsRequirement), .unsatisfiedRequirement(let rhsRequirement)): + return lhsRequirement == rhsRequirement + case (.invalidStatusCode(let lhsStatusCode), .invalidStatusCode(let rhsStatusCode)): + return lhsStatusCode == rhsStatusCode + case (.invalidDataType, .invalidDataType): + return true + case (.emptyResponseBody, .emptyResponseBody): + return true + case (.invalidURL, .invalidURL): + return true + default: + return false } } diff --git a/Sources/Networking/v2/APIResponseV2.swift b/Sources/Networking/v2/APIResponseV2.swift index 8987e377b..889eb34c4 100644 --- a/Sources/Networking/v2/APIResponseV2.swift +++ b/Sources/Networking/v2/APIResponseV2.swift @@ -33,8 +33,9 @@ public extension APIResponseV2 { /// Decode the APIResponseV2 into the inferred `Decodable` type /// - Parameter decoder: A custom JSONDecoder, if not provided the default JSONDecoder() is used - /// - Returns: An instance of a Decodable model of the type inferred + /// - Returns: An instance of a Decodable model of the type inferred, throws an error if the body is empty or the decoding fails func decodeBody(decoder: JSONDecoder = JSONDecoder()) throws -> T { + decoder.dateDecodingStrategy = .millisecondsSince1970 guard let data = self.data else { throw APIRequestV2.Error.emptyResponseBody diff --git a/Sources/Networking/v2/APIService.swift b/Sources/Networking/v2/APIService.swift index 79eed52d5..22033b6ed 100644 --- a/Sources/Networking/v2/APIService.swift +++ b/Sources/Networking/v2/APIService.swift @@ -20,37 +20,81 @@ import Foundation import os.log public protocol APIService { - func fetch(request: APIRequestV2) async throws -> APIResponseV2 + typealias AuthorizationRefresherCallback = ((_: APIRequestV2) async throws -> String) + var authorizationRefresherCallback: AuthorizationRefresherCallback? { get set } + func fetch(request: APIRequestV2, authAlreadyRefreshed: Bool, failureRetryCount: Int) async throws -> APIResponseV2 } -public struct DefaultAPIService: APIService { +extension APIService { + public func fetch(request: APIRequestV2) async throws -> APIResponseV2 { + return try await fetch(request: request, authAlreadyRefreshed: false, failureRetryCount: 0) + } +} + +public class DefaultAPIService: APIService { private let urlSession: URLSession + public var authorizationRefresherCallback: AuthorizationRefresherCallback? - public init(urlSession: URLSession = .shared) { + public init(urlSession: URLSession = .shared, authorizationRefresherCallback: AuthorizationRefresherCallback? = nil) { self.urlSession = urlSession - + self.authorizationRefresherCallback = authorizationRefresherCallback } /// Fetch an API Request /// - Parameter request: A configured APIRequest /// - Returns: An `APIResponseV2` containing the body data and the HTTPURLResponse - public func fetch(request: APIRequestV2) async throws -> APIResponseV2 { + public func fetch(request: APIRequestV2, authAlreadyRefreshed: Bool = false, failureRetryCount: Int = 0) async throws -> APIResponseV2 { + var request = request Logger.networking.debug("Fetching: \(request.debugDescription)") let (data, response) = try await fetch(for: request.urlRequest) - Logger.networking.debug("Response: \(response.debugDescription) Data size: \(data.count) bytes") try Task.checkCancellation() // Check response code let httpResponse = try response.asHTTPURLResponse() let responseHTTPStatus = httpResponse.httpStatus - if responseHTTPStatus.isFailure { - return APIResponseV2(data: data, httpResponse: httpResponse) + + Logger.networking.debug("Response: [\(responseHTTPStatus.rawValue, privacy: .public)] \(response.debugDescription) Data size: \(data.count) bytes") +#if DEBUG + if let bodyString = String(data: data, encoding: .utf8), + !bodyString.isEmpty { + Logger.networking.debug("Request body: \(bodyString, privacy: .public)") + } +#endif + + // First time the request is executed and the response is `.unauthorized` we try to refresh the authentication token + if responseHTTPStatus == .unauthorized, + request.isAuthenticated == true, + !authAlreadyRefreshed, + let authorizationRefresherCallback { + + // Ask to refresh the token + let refreshedToken = try await authorizationRefresherCallback(request) + request.updateAuthorizationHeader(refreshedToken) + + // Try again + return try await fetch(request: request, authAlreadyRefreshed: true, failureRetryCount: failureRetryCount) } - try checkConstraints(in: httpResponse, for: request) + // It's a failure and the request must be retried + if let retryPolicy = request.retryPolicy, + responseHTTPStatus.isFailure, + responseHTTPStatus != .unauthorized, // No retries needed is unuathorised + failureRetryCount < retryPolicy.maxRetries { + + if retryPolicy.delay > 0 { + try? await Task.sleep(interval: retryPolicy.delay) + } + + // Try again + return try await fetch(request: request, authAlreadyRefreshed: authAlreadyRefreshed, failureRetryCount: failureRetryCount + 1) + } + // It's not a failure, we check the constraints + if !responseHTTPStatus.isFailure { + try checkConstraints(in: httpResponse, for: request) + } return APIResponseV2(data: data, httpResponse: httpResponse) } diff --git a/Sources/Networking/v2/Extensions/Dictionary+QueryItems.swift b/Sources/Networking/v2/Extensions/Dictionary+QueryItems.swift new file mode 100644 index 000000000..09ed8f57e --- /dev/null +++ b/Sources/Networking/v2/Extensions/Dictionary+QueryItems.swift @@ -0,0 +1,26 @@ +// +// Dictionary+QueryItems.swift +// +// Copyright © 2025 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 extension Dictionary { + + func toQueryItems() -> QueryItems { + self.map { QueryItem(key: $0.key, value: $0.value) } + } +} diff --git a/Sources/Common/DecodableHelper.swift b/Sources/Networking/v2/Extensions/HTTPURLResponse+Cookie.swift similarity index 55% rename from Sources/Common/DecodableHelper.swift rename to Sources/Networking/v2/Extensions/HTTPURLResponse+Cookie.swift index 44491301c..aff26ee0f 100644 --- a/Sources/Common/DecodableHelper.swift +++ b/Sources/Networking/v2/Extensions/HTTPURLResponse+Cookie.swift @@ -1,7 +1,7 @@ // -// DecodableHelper.swift +// HTTPURLResponse+Cookie.swift // -// Copyright © 2021 DuckDuckGo. All rights reserved. +// 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. @@ -17,16 +17,20 @@ // import Foundation -import os.log -public struct DecodableHelper { - public static func decode(from input: Input) -> Target? { - do { - let json = try JSONSerialization.data(withJSONObject: input) - return try JSONDecoder().decode(Target.self, from: json) - } catch { - Logger.general.error("Error decoding message body: \(error.localizedDescription, privacy: .public)") +public extension HTTPURLResponse { + + var cookies: [HTTPCookie]? { + guard let fields = allHeaderFields as? [String: String], let url else { return nil } + return HTTPCookie.cookies(withResponseHeaderFields: fields, for: url) + } + + func getCookie(withName name: String) -> HTTPCookie? { + if let cookie = cookies?.first(where: { $0.name == name }) { + return cookie + } + return nil } } diff --git a/Sources/Networking/v2/Extensions/HTTPURLResponse+Utilities.swift b/Sources/Networking/v2/Extensions/HTTPURLResponse+Etag.swift similarity index 82% rename from Sources/Networking/v2/Extensions/HTTPURLResponse+Utilities.swift rename to Sources/Networking/v2/Extensions/HTTPURLResponse+Etag.swift index 10e7b8028..b7889abf7 100644 --- a/Sources/Networking/v2/Extensions/HTTPURLResponse+Utilities.swift +++ b/Sources/Networking/v2/Extensions/HTTPURLResponse+Etag.swift @@ -1,7 +1,7 @@ // -// HTTPURLResponse+Utilities.swift +// HTTPURLResponse+Etag.swift // -// Copyright © 2023 DuckDuckGo. All rights reserved. +// 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. @@ -17,15 +17,10 @@ // import Foundation -import Common public extension HTTPURLResponse { - var httpStatus: HTTPStatusCode { - HTTPStatusCode(rawValue: statusCode) ?? .unknown - } var etag: String? { etag(droppingWeakPrefix: true) } - private static let weakEtagPrefix = "W/" func etag(droppingWeakPrefix: Bool) -> String? { diff --git a/Sources/Networking/v2/Extensions/HTTPURLResponse+HTTPStatusCode.swift b/Sources/Networking/v2/Extensions/HTTPURLResponse+HTTPStatusCode.swift new file mode 100644 index 000000000..196d188c7 --- /dev/null +++ b/Sources/Networking/v2/Extensions/HTTPURLResponse+HTTPStatusCode.swift @@ -0,0 +1,26 @@ +// +// HTTPURLResponse+HTTPStatusCode.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 + +public extension HTTPURLResponse { + + var httpStatus: HTTPStatusCode { + HTTPStatusCode(rawValue: statusCode) ?? .unknown + } +} diff --git a/Sources/Networking/v2/Extensions/URL+QueryParamExtraction.swift b/Sources/Networking/v2/Extensions/URL+QueryParamExtraction.swift new file mode 100644 index 000000000..989e25439 --- /dev/null +++ b/Sources/Networking/v2/Extensions/URL+QueryParamExtraction.swift @@ -0,0 +1,37 @@ +// +// URL+QueryParamExtraction.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 + +public extension URL { + + /// Extract the query parameters from the URL + func queryParameters() -> [String: String]? { + guard let urlComponents = URLComponents(url: self, resolvingAgainstBaseURL: false), + let queryItems = urlComponents.queryItems else { + return nil + } + // Convert the query items into a dictionary + var parameters: [String: String] = [:] + for item in queryItems { + parameters[item.name] = item.value + } + return parameters + } + +} diff --git a/Sources/Networking/v2/HTTP Components/HTTPStatusCode.swift b/Sources/Networking/v2/HTTP Components/HTTPStatusCode.swift index 633b92322..1ff8610b4 100644 --- a/Sources/Networking/v2/HTTP Components/HTTPStatusCode.swift +++ b/Sources/Networking/v2/HTTP Components/HTTPStatusCode.swift @@ -95,27 +95,27 @@ public enum HTTPStatusCode: Int, CustomDebugStringConvertible { case networkAuthenticationRequired = 511 // Utility functions - var isInformational: Bool { + public var isInformational: Bool { return (100...199).contains(self.rawValue) } - var isSuccess: Bool { + public var isSuccess: Bool { return (200...299).contains(self.rawValue) } - var isRedirection: Bool { + public var isRedirection: Bool { return (300...399).contains(self.rawValue) } - var isClientError: Bool { + public var isClientError: Bool { return (400...499).contains(self.rawValue) } - var isServerError: Bool { + public var isServerError: Bool { return (500...599).contains(self.rawValue) } - var isFailure: Bool { + public var isFailure: Bool { return isClientError || isServerError } @@ -123,7 +123,7 @@ public enum HTTPStatusCode: Int, CustomDebugStringConvertible { "\(self.rawValue) - \(description)" } - var description: String { + public var description: String { switch self { case .unknown: return "Unknown" diff --git a/Sources/Networking/v2/HeadersV2.swift b/Sources/Networking/v2/HeadersV2.swift index 8a1b91e20..c93772ee6 100644 --- a/Sources/Networking/v2/HeadersV2.swift +++ b/Sources/Networking/v2/HeadersV2.swift @@ -18,8 +18,34 @@ import Foundation +public typealias HTTPHeaders = [String: String] + public extension APIRequestV2 { + /// All possible request content types + enum ContentType: String, Codable { + case json = "application/json" + case xml = "application/xml" + case formURLEncoded = "application/x-www-form-urlencoded" + case multipartFormData = "multipart/form-data" + case html = "text/html" + case plainText = "text/plain" + case css = "text/css" + case javascript = "application/javascript" + case octetStream = "application/octet-stream" + case png = "image/png" + case jpeg = "image/jpeg" + case gif = "image/gif" + case svg = "image/svg+xml" + case pdf = "application/pdf" + case zip = "application/zip" + case csv = "text/csv" + case rtf = "application/rtf" + case mp4 = "video/mp4" + case webm = "video/webm" + case ogg = "application/ogg" + } + struct HeadersV2 { private var userAgent: String? @@ -32,13 +58,22 @@ public extension APIRequestV2 { }.joined(separator: ", ") }() let etag: String? - let additionalHeaders: HTTPHeaders? + let cookies: [HTTPCookie]? + let authToken: String? + let additionalHeaders: [String: String]? + let contentType: ContentType? public init(userAgent: String? = nil, etag: String? = nil, - additionalHeaders: HTTPHeaders? = nil) { + cookies: [HTTPCookie]? = nil, + authToken: String? = nil, + contentType: ContentType? = nil, + additionalHeaders: [String: String]? = nil) { self.userAgent = userAgent self.etag = etag + self.cookies = cookies + self.authToken = authToken + self.contentType = contentType self.additionalHeaders = additionalHeaders } @@ -53,6 +88,19 @@ public extension APIRequestV2 { if let etag { headers[HTTPHeaderKey.ifNoneMatch] = etag } + if let cookies, cookies.isEmpty == false { + let cookieHeaders = HTTPCookie.requestHeaderFields(with: cookies) + headers.merge(cookieHeaders) { lx, _ in + assertionFailure("Duplicated values in HTTPHeaders") + return lx + } + } + if let authToken { + headers[HTTPHeaderKey.authorization] = "Bearer \(authToken)" + } + if let contentType { + headers[HTTPHeaderKey.contentType] = contentType.rawValue + } if let additionalHeaders { headers.merge(additionalHeaders) { old, _ in old } } diff --git a/Sources/Networking/v2/QueryItems.swift b/Sources/Networking/v2/QueryItems.swift new file mode 100644 index 000000000..e0ecccded --- /dev/null +++ b/Sources/Networking/v2/QueryItems.swift @@ -0,0 +1,38 @@ +// +// QueryItems.swift +// +// Copyright © 2025 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 typealias QueryItem = Dictionary.Element +public typealias QueryItems = [QueryItem] + +extension QueryItems { + + public func toURLQueryItems(allowedReservedCharacters: CharacterSet? = nil) -> [URLQueryItem] { + return self.map { + if let allowedReservedCharacters { + return URLQueryItem(percentEncodingName: $0.key, + value: $0.value, + withAllowedCharacters: allowedReservedCharacters) + } else { + return URLQueryItem(name: $0.key, value: $0.value) + } + } + } +} diff --git a/Sources/NetworkingTestingUtils/APIMockResponseFactory.swift b/Sources/NetworkingTestingUtils/APIMockResponseFactory.swift new file mode 100644 index 000000000..d28495cc0 --- /dev/null +++ b/Sources/NetworkingTestingUtils/APIMockResponseFactory.swift @@ -0,0 +1,106 @@ +// +// APIMockResponseFactory.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 Networking + +public struct APIMockResponseFactory { + + static let authCookieHeaders = [ HTTPHeaderKey.setCookie: "ddg_auth_session_id=kADeCPMmCIHIV5uD6AFoB7Fk7pRiXFzlmQE4gW9r7FRKV8OGC1rRnZcTXoa7iIa8qgjiQCqZYq6Caww6k5HJl3; domain=duckduckgo.com; path=/api/auth/v2/; max-age=600; SameSite=Strict; secure; HttpOnly"] + + static let someAPIBodyErrorJSON = "{\"error\":\"invalid_authorization_request\"}" + static var someAPIBodyErrorJSONData: Data { + someAPIBodyErrorJSON.data(using: .utf8)! + } + + static func setErrorResponse(forRequest request: APIRequestV2, apiService: MockAPIService) { + let httpResponse = HTTPURLResponse(url: request.urlRequest.url!, + statusCode: HTTPStatusCode.badRequest.rawValue, + httpVersion: nil, + headerFields: [:])! + let response = APIResponseV2(data: someAPIBodyErrorJSONData, httpResponse: httpResponse) + apiService.set(response: response, forRequest: request) + } + + public static func mockAuthoriseResponse(destinationMockAPIService apiService: MockAPIService, success: Bool) { + let request = OAuthRequest.authorize(baseURL: OAuthEnvironment.staging.url, codeChallenge: "codeChallenge")! + if success { + let httpResponse = HTTPURLResponse(url: request.apiRequest.urlRequest.url!, + statusCode: request.httpSuccessCode.rawValue, + httpVersion: nil, + headerFields: authCookieHeaders)! + let response = APIResponseV2(data: nil, httpResponse: httpResponse) + apiService.set(response: response, forRequest: request.apiRequest) + } else { + setErrorResponse(forRequest: request.apiRequest, apiService: apiService) + } + } + + public static func mockCreateAccountResponse(destinationMockAPIService apiService: MockAPIService, success: Bool) { + let request = OAuthRequest.createAccount(baseURL: OAuthEnvironment.staging.url, authSessionID: "someAuthSessionID")! + if success { + let httpResponse = HTTPURLResponse(url: request.apiRequest.urlRequest.url!, + statusCode: request.httpSuccessCode.rawValue, + httpVersion: nil, + headerFields: [HTTPHeaderKey.location: "com.duckduckgo:/authcb?code=NgNjnlLaqUomt9b5LDbzAtTyeW9cBNhCGtLB3vpcctluSZI51M9tb2ZDIZdijSPTYBr4w8dtVZl85zNSemxozv"])! + let response = APIResponseV2(data: nil, httpResponse: httpResponse) + apiService.set(response: response, forRequest: request.apiRequest) + } else { + setErrorResponse(forRequest: request.apiRequest, apiService: apiService) + } + } + + public static func mockGetAccessTokenResponse(destinationMockAPIService apiService: MockAPIService, success: Bool) { + let request = OAuthRequest.getAccessToken(baseURL: OAuthEnvironment.staging.url, + clientID: "clientID", + codeVerifier: "codeVerifier", + code: "code", + redirectURI: "redirectURI")! + if success { + let jsonString = """ +{"access_token":"eyJraWQiOiIzODJiNzQ5Yy1hNTc3LTRkOTMtOTU0My04NTI5MWZiYTM3MmEiLCJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJxWHk2TlRjeEI2UkQ0UUtSU05RYkNSM3ZxYU1SQU1RM1Q1UzVtTWdOWWtCOVZTVnR5SHdlb1R4bzcxVG1DYkJKZG1GWmlhUDVWbFVRQnd5V1dYMGNGUjo3ZjM4MTljZi0xNTBmLTRjYjEtOGNjNy1iNDkyMThiMDA2ZTgiLCJzY29wZSI6InByaXZhY3lwcm8iLCJhdWQiOiJQcml2YWN5UHJvIiwic3ViIjoiZTM3NmQ4YzQtY2FhOS00ZmNkLThlODYtMTlhNmQ2M2VlMzcxIiwiZXhwIjoxNzMwMzAxNTcyLCJlbWFpbCI6bnVsbCwiaWF0IjoxNzMwMjg3MTcyLCJpc3MiOiJodHRwczovL3F1YWNrZGV2LmR1Y2tkdWNrZ28uY29tIiwiZW50aXRsZW1lbnRzIjpbXSwiYXBpIjoidjIifQ.wOYgz02TXPJjDcEsp-889Xe1zh6qJG0P1UNHUnFBBELmiWGa91VQpqdl41EOOW3aE89KGvrD8YphRoZKiA3nHg", + "refresh_token":"eyJraWQiOiIzODJiNzQ5Yy1hNTc3LTRkOTMtOTU0My04NTI5MWZiYTM3MmEiLCJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcGkiOiJ2MiIsImlzcyI6Imh0dHBzOi8vcXVhY2tkZXYuZHVja2R1Y2tnby5jb20iLCJleHAiOjE3MzI4NzkxNzIsInN1YiI6ImUzNzZkOGM0LWNhYTktNGZjZC04ZTg2LTE5YTZkNjNlZTM3MSIsImF1ZCI6IkF1dGgiLCJpYXQiOjE3MzAyODcxNzIsInNjb3BlIjoicmVmcmVzaCIsImp0aSI6InFYeTZOVGN4QjZSRDRRS1JTTlFiQ1IzdnFhTVJBTVEzVDVTNW1NZ05Za0I5VlNWdHlId2VvVHhvNzFUbUNiQkpkbUZaaWFQNVZsVVFCd3lXV1gwY0ZSOmU2ODkwMDE5LWJmMDUtNGQxZC04OGFhLThlM2UyMDdjOGNkOSJ9.OQaGCmDBbDMM5XIpyY-WCmCLkZxt5Obp4YAmtFP8CerBSRexbUUp6SNwGDjlvCF0-an2REBsrX92ZmQe5ewqyQ","expires_in": 14400,"token_type": "Bearer"} +""" + let httpResponse = HTTPURLResponse(url: request.apiRequest.urlRequest.url!, + statusCode: request.httpSuccessCode.rawValue, + httpVersion: nil, + headerFields: [:])! + let response = APIResponseV2(data: jsonString.data(using: .utf8), httpResponse: httpResponse) + apiService.set(response: response, forRequest: request.apiRequest) + } else { + setErrorResponse(forRequest: request.apiRequest, apiService: apiService) + } + } + + public static func mockGetJWKS(destinationMockAPIService apiService: MockAPIService, success: Bool) { + let request = OAuthRequest.jwks(baseURL: OAuthEnvironment.staging.url)! + if success { + let jsonString = """ +{"keys":[{"alg":"ES256","crv":"P-256","kid":"382b749c-a577-4d93-9543-85291fba372a","kty":"EC","ts":1727109704,"x":"e-WcWXtyf0mzVuc8lzAErb0EYq0kiOj7u8Ia4qsB4z4","y":"2WYzD5-POgIx2_3B_J6u84giGwSwgrYMTj83djMSWxM"},{"crv":"P-256","kid":"aa4c0019-9da9-4143-9866-3f7b54224a46","kty":"EC","ts":1722282670,"x":"kN2BXRyRbylNSaw3CrZKiKdATXjF1RIp2FpOxYMeuWg","y":"wovX-ifQuoKKAi-ZPYFcZ9YBhCxN_Fng3qKSW2wKpdg"}]} +""" + let httpResponse = HTTPURLResponse(url: request.apiRequest.urlRequest.url!, + statusCode: request.httpSuccessCode.rawValue, + httpVersion: nil, + headerFields: [:])! + let response = APIResponseV2(data: jsonString.data(using: .utf8), httpResponse: httpResponse) + apiService.set(response: response, forRequest: request.apiRequest) + } else { + setErrorResponse(forRequest: request.apiRequest, apiService: apiService) + } + } +} diff --git a/Sources/TestUtils/Utils/HTTPURLResponseExtension.swift b/Sources/NetworkingTestingUtils/HTTPURLResponseExtension.swift similarity index 74% rename from Sources/TestUtils/Utils/HTTPURLResponseExtension.swift rename to Sources/NetworkingTestingUtils/HTTPURLResponseExtension.swift index e246500da..ee8f4db73 100644 --- a/Sources/TestUtils/Utils/HTTPURLResponseExtension.swift +++ b/Sources/NetworkingTestingUtils/HTTPURLResponseExtension.swift @@ -26,27 +26,31 @@ public extension HTTPURLResponse { static let testUserAgent = "test-user-agent" static let ok = HTTPURLResponse(url: testUrl, - statusCode: 200, + statusCode: HTTPStatusCode.ok.rawValue, httpVersion: nil, headerFields: [HTTPHeaderKey.etag: testEtag])! static let okNoEtag = HTTPURLResponse(url: testUrl, - statusCode: 200, + statusCode: HTTPStatusCode.ok.rawValue, httpVersion: nil, headerFields: [:])! static let notModified = HTTPURLResponse(url: testUrl, - statusCode: 304, + statusCode: HTTPStatusCode.notModified.rawValue, httpVersion: nil, headerFields: [HTTPHeaderKey.etag: testEtag])! static let internalServerError = HTTPURLResponse(url: testUrl, - statusCode: 500, + statusCode: HTTPStatusCode.internalServerError.rawValue, httpVersion: nil, headerFields: [:])! static let okUserAgent = HTTPURLResponse(url: testUrl, - statusCode: 200, + statusCode: HTTPStatusCode.ok.rawValue, httpVersion: nil, headerFields: [HTTPHeaderKey.userAgent: testUserAgent])! + static let unauthorised = HTTPURLResponse(url: testUrl, + statusCode: HTTPStatusCode.unauthorized.rawValue, + httpVersion: nil, + headerFields: [:])! } diff --git a/Sources/NetworkingTestingUtils/MockAPIService.swift b/Sources/NetworkingTestingUtils/MockAPIService.swift new file mode 100644 index 000000000..2e16c4d7d --- /dev/null +++ b/Sources/NetworkingTestingUtils/MockAPIService.swift @@ -0,0 +1,66 @@ +// +// MockAPIService.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 Networking + +public class MockAPIService: APIService { + + public var authorizationRefresherCallback: AuthorizationRefresherCallback? + + /// Dictionary to store mocked responses for specific requests + private var mockResponses: [APIRequestV2: APIResponseV2] = [:] + /// Dictionary to store mocked responses for specific requests by URL + private var mockResponsesByURL: [URL: APIResponseV2] = [:] + /// Request handler + public var requestHandler: ((APIRequestV2) -> Result)? + + public init(requestHandler: ((APIRequestV2) -> Result)? = nil) { + self.requestHandler = requestHandler + } + + public func set(response: APIResponseV2, forRequest request: APIRequestV2) { + mockResponses[request] = response + } + + public func set(response: APIResponseV2, forRequestURL url: URL) { + mockResponsesByURL[url] = response + } + + // Function to fetch response for a given request + public func fetch(request: Networking.APIRequestV2, authAlreadyRefreshed: Bool, failureRetryCount: Int) async throws -> Networking.APIResponseV2 { + if let requestHandler { + switch requestHandler(request) { + case .success(let result): + return result + case .failure(let error): + throw error + } + } else if let response = mockResponses[request] { + return response + } else { + return mockResponsesByURL[request.urlRequest.url!]! // Intentionally crash if the mock is not available + } + } +} + +public extension APIRequestV2 { + var host: String { + return urlRequest.url!.host! + } +} diff --git a/Sources/TestUtils/MockAPIService.swift b/Sources/NetworkingTestingUtils/MockLegacyTokenStorage.swift similarity index 55% rename from Sources/TestUtils/MockAPIService.swift rename to Sources/NetworkingTestingUtils/MockLegacyTokenStorage.swift index f4d35b4b6..2527459d7 100644 --- a/Sources/TestUtils/MockAPIService.swift +++ b/Sources/NetworkingTestingUtils/MockLegacyTokenStorage.swift @@ -1,5 +1,5 @@ // -// MockAPIService.swift +// MockLegacyTokenStorage.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -19,20 +19,11 @@ import Foundation import Networking -public class MockAPIService: APIService { +public class MockLegacyTokenStorage: LegacyAuthTokenStoring { - public var requestHandler: ((APIRequestV2) -> Result)! - - public init(requestHandler: ((APIRequestV2) -> Result)? = nil) { - self.requestHandler = requestHandler + public init(token: String? = nil) { + self.token = token } - public func fetch(request: APIRequestV2) async throws -> APIResponseV2 { - switch requestHandler!(request) { - case .success(let result): - return result - case .failure(let error): - throw error - } - } + public var token: String? } diff --git a/Sources/NetworkingTestingUtils/MockOAuthClient.swift b/Sources/NetworkingTestingUtils/MockOAuthClient.swift new file mode 100644 index 000000000..395f18ff8 --- /dev/null +++ b/Sources/NetworkingTestingUtils/MockOAuthClient.swift @@ -0,0 +1,155 @@ +// +// MockOAuthClient.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 Networking + +public class MockOAuthClient: OAuthClient { + + public init() {} + public var isUserAuthenticated: Bool = false + public var currentTokenContainer: Networking.TokenContainer? + + func missingResponseError(request: String) -> Error { + return Networking.OAuthClientError.internalError("Missing mocked response for \(request)") + } + + public var getTokensResponse: Result! + public func getTokens(policy: Networking.AuthTokensCachePolicy) async throws -> Networking.TokenContainer { + switch getTokensResponse { + case .success(let success): + return success + case .failure(let failure): + throw failure + case .none: + throw missingResponseError(request: #function) + } + } + + public var migrateV1TokenResponse: Result! + public func migrateV1Token() async throws -> Networking.TokenContainer? { + switch migrateV1TokenResponse { + case .success(let success): + return success + case .failure(let failure): + throw failure + case .none: + throw missingResponseError(request: #function) + } + } + + public func adopt(tokenContainer: Networking.TokenContainer) { + + } + + public var createAccountResponse: Result! + public func createAccount() async throws -> Networking.TokenContainer { + switch createAccountResponse { + case .success(let success): + return success + case .failure(let failure): + throw failure + case .none: + throw missingResponseError(request: #function) + } + } + + public var requestOTPResponse: Result<(authSessionID: String, codeVerifier: String), Error>! + public func requestOTP(email: String) async throws -> (authSessionID: String, codeVerifier: String) { + switch requestOTPResponse { + case .success(let success): + return success + case .failure(let failure): + throw failure + case .none: + throw missingResponseError(request: #function) + } + } + + public var activateWithOTPError: Error? + public func activate(withOTP otp: String, email: String, codeVerifier: String, authSessionID: String) async throws { + if let activateWithOTPError { + throw activateWithOTPError + } + } + + public var activateWithPlatformSignatureResponse: Result! + public func activate(withPlatformSignature signature: String) async throws -> Networking.TokenContainer { + switch activateWithPlatformSignatureResponse { + case .success(let success): + return success + case .failure(let failure): + throw failure + case .none: + throw missingResponseError(request: #function) + } + } + + public var refreshTokensResponse: Result! + public func refreshTokens() async throws -> Networking.TokenContainer { + switch refreshTokensResponse { + case .success(let success): + return success + case .failure(let failure): + throw failure + case .none: + throw missingResponseError(request: #function) + } + } + + public var exchangeAccessTokenV1Response: Result! + public func exchange(accessTokenV1: String) async throws -> Networking.TokenContainer { + switch exchangeAccessTokenV1Response { + case .success(let success): + return success + case .failure(let failure): + throw failure + case .none: + throw missingResponseError(request: #function) + } + } + + public var logoutError: Error? + public func logout() async throws { + if let logoutError { + throw logoutError + } + } + + public func removeLocalAccount() {} + + public var changeAccountEmailResponse: Result! + public func changeAccount(email: String?) async throws -> String { + switch changeAccountEmailResponse { + case .success(let success): + return success + case .failure(let failure): + throw failure + case .none: + throw missingResponseError(request: #function) + } + } + + public var confirmChangeAccountEmailError: Error? + public func confirmChangeAccount(email: String, otp: String, hash: String) async throws { + if let confirmChangeAccountEmailError { + throw confirmChangeAccountEmailError + } + } + +} diff --git a/Sources/NetworkingTestingUtils/MockOAuthService.swift b/Sources/NetworkingTestingUtils/MockOAuthService.swift new file mode 100644 index 000000000..a14298960 --- /dev/null +++ b/Sources/NetworkingTestingUtils/MockOAuthService.swift @@ -0,0 +1,103 @@ +// +// MockOAuthService.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 Networking +import JWTKit + +public final class MockOAuthService: OAuthService { + + public init() {} + + public var authorizeResponse: Result? + public func authorize(codeChallenge: String) async throws -> Networking.OAuthSessionID { + switch authorizeResponse! { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + public var createAccountResponse: Result? + public func createAccount(authSessionID: String) async throws -> Networking.AuthorisationCode { + switch createAccountResponse! { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + public var loginWithSignatureResponse: Result? + public func login(withSignature signature: String, authSessionID: String) async throws -> Networking.AuthorisationCode { + switch loginWithSignatureResponse! { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + public var getAccessTokenResponse: Result? + public func getAccessToken(clientID: String, codeVerifier: String, code: String, redirectURI: String) async throws -> Networking.OAuthTokenResponse { + switch getAccessTokenResponse! { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + public var refreshAccessTokenResponse: Result? + public func refreshAccessToken(clientID: String, refreshToken: String) async throws -> Networking.OAuthTokenResponse { + switch refreshAccessTokenResponse! { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + public var logoutError: Error? + public func logout(accessToken: String) async throws { + if let logoutError { + throw logoutError + } + } + + public var exchangeTokenResponse: Result? + public func exchangeToken(accessTokenV1: String, authSessionID: String) async throws -> Networking.AuthorisationCode { + switch exchangeTokenResponse! { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + public var getJWTSignersResponse: Result? + public func getJWTSigners() async throws -> JWTKit.JWTSigners { + switch getJWTSignersResponse! { + case .success(let result): + return result + case .failure(let error): + throw error + } + } +} diff --git a/Sources/NetworkingTestingUtils/MockTokenStorage.swift b/Sources/NetworkingTestingUtils/MockTokenStorage.swift new file mode 100644 index 000000000..7606ea267 --- /dev/null +++ b/Sources/NetworkingTestingUtils/MockTokenStorage.swift @@ -0,0 +1,29 @@ +// +// MockTokenStorage.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 Networking + +public class MockTokenStorage: AuthTokenStoring { + + public init(tokenContainer: Networking.TokenContainer? = nil) { + self.tokenContainer = tokenContainer + } + + public var tokenContainer: Networking.TokenContainer? +} diff --git a/Sources/TestUtils/MockURLProtocol.swift b/Sources/NetworkingTestingUtils/MockURLProtocol.swift similarity index 100% rename from Sources/TestUtils/MockURLProtocol.swift rename to Sources/NetworkingTestingUtils/MockURLProtocol.swift diff --git a/Sources/NetworkingTestingUtils/OAuthTokensFactory.swift b/Sources/NetworkingTestingUtils/OAuthTokensFactory.swift new file mode 100644 index 000000000..8fcf4dc40 --- /dev/null +++ b/Sources/NetworkingTestingUtils/OAuthTokensFactory.swift @@ -0,0 +1,134 @@ +// +// OAuthTokensFactory.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 Networking +@testable import JWTKit + +public struct OAuthTokensFactory { + + // Helper function to create an expired JWTAccessToken + public static func makeExpiredAccessToken() -> JWTAccessToken { + return JWTAccessToken( + exp: ExpirationClaim(value: Date().addingTimeInterval(-3600)), // Expired 1 hour ago + iat: IssuedAtClaim(value: Date().addingTimeInterval(-7200)), + sub: SubjectClaim(value: "test-subject"), + aud: AudienceClaim(value: ["test-audience"]), + iss: IssuerClaim(value: "test-issuer"), + jti: IDClaim(value: "test-id"), + scope: "privacypro", + api: "v2", + email: "test@example.com", + entitlements: [] + ) + } + + // Helper function to create a valid JWTAccessToken with customizable scope + public static func makeAccessToken(scope: String, email: String = "test@example.com") -> JWTAccessToken { + return JWTAccessToken( + exp: ExpirationClaim(value: Date().addingTimeInterval(3600)), // 1 hour from now + iat: IssuedAtClaim(value: Date()), + sub: SubjectClaim(value: "test-subject"), + aud: AudienceClaim(value: ["test-audience"]), + iss: IssuerClaim(value: "test-issuer"), + jti: IDClaim(value: "test-id"), + scope: scope, + api: "v2", + email: email, + entitlements: [] + ) + } + + // Helper function to create a valid JWTRefreshToken with customizable scope + public static func makeRefreshToken(scope: String) -> JWTRefreshToken { + return JWTRefreshToken( + exp: ExpirationClaim(value: Date().addingTimeInterval(3600)), + iat: IssuedAtClaim(value: Date()), + sub: SubjectClaim(value: "test-subject"), + aud: AudienceClaim(value: ["test-audience"]), + iss: IssuerClaim(value: "test-issuer"), + jti: IDClaim(value: "test-id"), + scope: scope, + api: "v2" + ) + } + + public static func makeValidTokenContainer() -> TokenContainer { + return TokenContainer(accessToken: "accessToken", + refreshToken: "refreshToken", + decodedAccessToken: OAuthTokensFactory.makeAccessToken(scope: "privacypro"), + decodedRefreshToken: OAuthTokensFactory.makeRefreshToken(scope: "refresh")) + } + + public static func makeValidTokenContainerWithEntitlements() -> TokenContainer { + return TokenContainer(accessToken: "accessToken", + refreshToken: "refreshToken", + decodedAccessToken: JWTAccessToken.mock, + decodedRefreshToken: JWTRefreshToken.mock) + } + + public static func makeExpiredTokenContainer() -> TokenContainer { + return TokenContainer(accessToken: "accessToken", + refreshToken: "refreshToken", + decodedAccessToken: OAuthTokensFactory.makeExpiredAccessToken(), + decodedRefreshToken: OAuthTokensFactory.makeRefreshToken(scope: "refresh")) + } + + public static func makeExpiredOAuthTokenResponse() -> OAuthTokenResponse { + return OAuthTokenResponse(accessToken: "eyJraWQiOiIzODJiNzQ5Yy1hNTc3LTRkOTMtOTU0My04NTI5MWZiYTM3MmEiLCJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJxWHk2TlRjeEI2UkQ0UUtSU05RYkNSM3ZxYU1SQU1RM1Q1UzVtTWdOWWtCOVZTVnR5SHdlb1R4bzcxVG1DYkJKZG1GWmlhUDVWbFVRQnd5V1dYMGNGUjo3ZjM4MTljZi0xNTBmLTRjYjEtOGNjNy1iNDkyMThiMDA2ZTgiLCJzY29wZSI6InByaXZhY3lwcm8iLCJhdWQiOiJQcml2YWN5UHJvIiwic3ViIjoiZTM3NmQ4YzQtY2FhOS00ZmNkLThlODYtMTlhNmQ2M2VlMzcxIiwiZXhwIjoxNzMwMzAxNTcyLCJlbWFpbCI6bnVsbCwiaWF0IjoxNzMwMjg3MTcyLCJpc3MiOiJodHRwczovL3F1YWNrZGV2LmR1Y2tkdWNrZ28uY29tIiwiZW50aXRsZW1lbnRzIjpbXSwiYXBpIjoidjIifQ.wOYgz02TXPJjDcEsp-889Xe1zh6qJG0P1UNHUnFBBELmiWGa91VQpqdl41EOOW3aE89KGvrD8YphRoZKiA3nHg", + refreshToken: "eyJraWQiOiIzODJiNzQ5Yy1hNTc3LTRkOTMtOTU0My04NTI5MWZiYTM3MmEiLCJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcGkiOiJ2MiIsImlzcyI6Imh0dHBzOi8vcXVhY2tkZXYuZHVja2R1Y2tnby5jb20iLCJleHAiOjE3MzI4NzkxNzIsInN1YiI6ImUzNzZkOGM0LWNhYTktNGZjZC04ZTg2LTE5YTZkNjNlZTM3MSIsImF1ZCI6IkF1dGgiLCJpYXQiOjE3MzAyODcxNzIsInNjb3BlIjoicmVmcmVzaCIsImp0aSI6InFYeTZOVGN4QjZSRDRRS1JTTlFiQ1IzdnFhTVJBTVEzVDVTNW1NZ05Za0I5VlNWdHlId2VvVHhvNzFUbUNiQkpkbUZaaWFQNVZsVVFCd3lXV1gwY0ZSOmU2ODkwMDE5LWJmMDUtNGQxZC04OGFhLThlM2UyMDdjOGNkOSJ9.OQaGCmDBbDMM5XIpyY-WCmCLkZxt5Obp4YAmtFP8CerBSRexbUUp6SNwGDjlvCF0-an2REBsrX92ZmQe5ewqyQ") + } + + public static func makeValidOAuthTokenResponse() -> OAuthTokenResponse { + return OAuthTokenResponse(accessToken: "**validaccesstoken**", refreshToken: "**validrefreshtoken**") + } +} + +public extension JWTAccessToken { + + static var mock: Self { + let now = Date() + return JWTAccessToken(exp: ExpirationClaim(value: now.addingTimeInterval(3600)), + iat: IssuedAtClaim(value: now), + sub: SubjectClaim(value: "test-subject"), + aud: AudienceClaim(value: ["PrivacyPro"]), + iss: IssuerClaim(value: "test-issuer"), + jti: IDClaim(value: "test-id"), + scope: "privacypro", + api: "v2", + email: nil, + entitlements: [EntitlementPayload(product: .networkProtection, name: "subscriber"), + EntitlementPayload(product: .dataBrokerProtection, name: "subscriber"), + EntitlementPayload(product: .identityTheftRestoration, name: "subscriber")]) + } +} + +public extension JWTRefreshToken { + + static var mock: Self { + let now = Date() + return JWTRefreshToken(exp: ExpirationClaim(value: now.addingTimeInterval(3600)), + iat: IssuedAtClaim(value: now), + sub: SubjectClaim(value: "test-subject"), + aud: AudienceClaim(value: ["PrivacyPro"]), + iss: IssuerClaim(value: "test-issuer"), + jti: IDClaim(value: "test-id"), + scope: "privacypro", + api: "v2") + } +} diff --git a/Sources/TestUtils/MockKeyValueStore.swift b/Sources/PersistenceTestingUtils/MockKeyValueStore.swift similarity index 99% rename from Sources/TestUtils/MockKeyValueStore.swift rename to Sources/PersistenceTestingUtils/MockKeyValueStore.swift index b13963eba..ea4664422 100644 --- a/Sources/TestUtils/MockKeyValueStore.swift +++ b/Sources/PersistenceTestingUtils/MockKeyValueStore.swift @@ -40,7 +40,6 @@ public class MockKeyValueStore: KeyValueStoring { public func clearAll() { store.removeAll() } - } extension MockKeyValueStore: DictionaryRepresentable { diff --git a/Tests/BrokenSitePromptTests/BrokenSitePromptLimiterTests.swift b/Tests/BrokenSitePromptTests/BrokenSitePromptLimiterTests.swift index b583d8d85..c8310b64a 100644 --- a/Tests/BrokenSitePromptTests/BrokenSitePromptLimiterTests.swift +++ b/Tests/BrokenSitePromptTests/BrokenSitePromptLimiterTests.swift @@ -17,7 +17,6 @@ // import BrowserServicesKit -import TestUtils import XCTest @testable import BrokenSitePrompt diff --git a/Tests/BrowserServicesKit-Package.xctestplan b/Tests/BrowserServicesKit-Package.xctestplan new file mode 100644 index 000000000..14179517b --- /dev/null +++ b/Tests/BrowserServicesKit-Package.xctestplan @@ -0,0 +1,213 @@ +{ + "configurations" : [ + { + "id" : "CEDD46E5-DAEC-407E-B790-8A23D5B18D80", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "ConfigurationTests", + "name" : "ConfigurationTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "PageRefreshMonitorTests", + "name" : "PageRefreshMonitorTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "BookmarksTests", + "name" : "BookmarksTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "BrokenSitePromptTests", + "name" : "BrokenSitePromptTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "NetworkProtectionTests", + "name" : "NetworkProtectionTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "DDGSyncCryptoTests", + "name" : "DDGSyncCryptoTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "NavigationTests", + "name" : "NavigationTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "RemoteMessagingTests", + "name" : "RemoteMessagingTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "CrashesTests", + "name" : "CrashesTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "DDGSyncTests", + "name" : "DDGSyncTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "CommonTests", + "name" : "CommonTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "SecureStorageTests", + "name" : "SecureStorageTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "SyncDataProvidersTests", + "name" : "SyncDataProvidersTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "DuckPlayerTests", + "name" : "DuckPlayerTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "MaliciousSiteProtectionTests", + "name" : "MaliciousSiteProtectionTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "SubscriptionTests", + "name" : "SubscriptionTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "NetworkingTests", + "name" : "NetworkingTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "PrivacyStatsTests", + "name" : "PrivacyStatsTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "PixelExperimentKitTests", + "name" : "PixelExperimentKitTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "PersistenceTests", + "name" : "PersistenceTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "HistoryTests", + "name" : "HistoryTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "UserScriptTests", + "name" : "UserScriptTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "PrivacyDashboardTests", + "name" : "PrivacyDashboardTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "PixelKitTests", + "name" : "PixelKitTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "BrowserServicesKitTests", + "name" : "BrowserServicesKitTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "OnboardingTests", + "name" : "OnboardingTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "SuggestionsTests", + "name" : "SuggestionsTests" + } + }, + { + "target" : { + "containerPath" : "container:", + "identifier" : "SpecialErrorPagesTests", + "name" : "SpecialErrorPagesTests" + } + } + ], + "version" : 1 +} diff --git a/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift b/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift index ad675dada..7dfba974e 100644 --- a/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift +++ b/Tests/BrowserServicesKitTests/Autofill/AutofillPixelReporterTests.swift @@ -17,7 +17,6 @@ // import XCTest -import TestUtils import Common import SecureStorage import SecureStorageTestsUtils diff --git a/Tests/BrowserServicesKitTests/ContentBlocker/AdClickAttribution/AdClickAttributionCounterTests.swift b/Tests/BrowserServicesKitTests/ContentBlocker/AdClickAttribution/AdClickAttributionCounterTests.swift index 592d35498..d0161e6ab 100644 --- a/Tests/BrowserServicesKitTests/ContentBlocker/AdClickAttribution/AdClickAttributionCounterTests.swift +++ b/Tests/BrowserServicesKitTests/ContentBlocker/AdClickAttribution/AdClickAttributionCounterTests.swift @@ -18,7 +18,7 @@ import XCTest import Persistence -import TestUtils +import PersistenceTestingUtils @testable import BrowserServicesKit class AdClickAttributionCounterTests: XCTestCase { diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift index 5687c5267..dcc6d7978 100644 --- a/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/DefaultFeatureFlaggerTests.swift @@ -17,7 +17,6 @@ // import BrowserServicesKit -import TestUtils import XCTest final class CapturingFeatureFlagOverriding: FeatureFlagLocalOverriding { diff --git a/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlagLocalOverridesTests.swift b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlagLocalOverridesTests.swift index 5223ff059..977ac5766 100644 --- a/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlagLocalOverridesTests.swift +++ b/Tests/BrowserServicesKitTests/FeatureFlagging/FeatureFlagLocalOverridesTests.swift @@ -17,7 +17,7 @@ // import BrowserServicesKit -import TestUtils +import PersistenceTestingUtils import XCTest final class CapturingFeatureFlagLocalOverridesHandler: FeatureFlagLocalOverridesHandling { diff --git a/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests b/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests index 6133e7d9d..a603ff9af 160000 --- a/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests +++ b/Tests/BrowserServicesKitTests/Resources/privacy-reference-tests @@ -1 +1 @@ -Subproject commit 6133e7d9d9cd5f1b925cab1971b4d785dc639df7 +Subproject commit a603ff9af22ca3ff7ce2e7ffbfe18c447d9f23e8 diff --git a/Tests/CommonTests/Extensions/DateExtensionTest.swift b/Tests/CommonTests/Extensions/DateExtensionTest.swift new file mode 100644 index 000000000..5c6b00601 --- /dev/null +++ b/Tests/CommonTests/Extensions/DateExtensionTest.swift @@ -0,0 +1,213 @@ +// +// DateExtensionTest.swift +// +// Copyright © 2022 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import Common + +final class DateExtensionTests: XCTestCase { + + func testComponents() { + let date = Date() + let components = date.components + + XCTAssertNotNil(components.day) + XCTAssertNotNil(components.month) + XCTAssertNotNil(components.year) + } + + func testWeekAgo() { + let weekAgo = Date.weekAgo + let expectedDate = Calendar.current.date(byAdding: .weekOfMonth, value: -1, to: Date())! + + XCTAssertEqual(weekAgo.startOfDay, expectedDate.startOfDay) + } + + func testMonthAgo() { + let monthAgo = Date.monthAgo + let expectedDate = Calendar.current.date(byAdding: .month, value: -1, to: Date())! + + XCTAssertEqual(monthAgo.startOfDay, expectedDate.startOfDay) + } + + func testYearAgo() { + let yearAgo = Date.yearAgo + let expectedDate = Calendar.current.date(byAdding: .year, value: -1, to: Date())! + + XCTAssertEqual(yearAgo.startOfDay, expectedDate.startOfDay) + } + + func testAYearFromNow() { + let aYearFromNow = Date.aYearFromNow + let expectedDate = Calendar.current.date(byAdding: .year, value: 1, to: Date())! + + XCTAssertEqual(aYearFromNow.startOfDay, expectedDate.startOfDay) + } + + func testDaysAgo() { + let daysAgo = Date.daysAgo(5) + let expectedDate = Calendar.current.date(byAdding: .day, value: -5, to: Date())! + + XCTAssertEqual(daysAgo.startOfDay, expectedDate.startOfDay) + } + + func testIsSameDay() { + let today = Date() + let sameDay = today + let differentDay = Calendar.current.date(byAdding: .day, value: -1, to: today)! + + XCTAssertTrue(Date.isSameDay(today, sameDay)) + XCTAssertFalse(Date.isSameDay(today, differentDay)) + XCTAssertFalse(Date.isSameDay(today, nil)) + } + + func testStartOfDayTomorrow() { + let startOfDayTomorrow = Date.startOfDayTomorrow + let tomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())! + + XCTAssertEqual(startOfDayTomorrow, Calendar.current.startOfDay(for: tomorrow)) + } + + func testStartOfDayToday() { + let startOfDayToday = Date.startOfDayToday + XCTAssertEqual(startOfDayToday, Calendar.current.startOfDay(for: Date())) + } + + func testStartOfDay() { + let date = Date() + let startOfDay = date.startOfDay + + XCTAssertEqual(startOfDay, Calendar.current.startOfDay(for: date)) + } + + func testDaysAgoInstanceMethod() { + let date = Date() + let daysAgo = date.daysAgo(3) + let expectedDate = Calendar.current.date(byAdding: .day, value: -3, to: date)! + + XCTAssertEqual(daysAgo.startOfDay, expectedDate.startOfDay) + } + + func testStartOfMinuteNow() { + let startOfMinuteNow = Date.startOfMinuteNow + let now = Calendar.current.date(bySetting: .second, value: 0, of: Date())! + let expectedStart = Calendar.current.date(byAdding: .minute, value: -1, to: now)! + + XCTAssertEqual(startOfMinuteNow, expectedStart) + } + + func testMonthsWithIndex() { + let monthsWithIndex = Date.monthsWithIndex + let monthSymbols = Calendar.current.monthSymbols + + XCTAssertEqual(monthsWithIndex.count, 12) + XCTAssertEqual(monthsWithIndex.first?.name, monthSymbols.first) + XCTAssertEqual(monthsWithIndex.first?.index, 1) + } + + func testDaysInMonth() { + XCTAssertEqual(Date.daysInMonth, Array(1...31)) + } + + func testNextTenYears() { + let nextTenYears = Date.nextTenYears + let currentYear = Calendar.current.component(.year, from: Date()) + + XCTAssertEqual(nextTenYears.count, 11) + XCTAssertEqual(nextTenYears.first, currentYear) + XCTAssertEqual(nextTenYears.last, currentYear + 10) + } + + func testLastHundredYears() { + let lastHundredYears = Date.lastHundredYears + let currentYear = Calendar.current.component(.year, from: Date()) + + XCTAssertEqual(lastHundredYears.count, 101) + XCTAssertEqual(lastHundredYears.first, currentYear) + XCTAssertEqual(lastHundredYears.last, currentYear - 100) + } + + func testDaySinceReferenceDate() { + let date = Date() + let daysSinceReference = Int(date.timeIntervalSinceReferenceDate / TimeInterval.day) + + XCTAssertEqual(date.daySinceReferenceDate, daysSinceReference) + } + + func testAdding() { + let date = Date() + let addedDate = date.adding(60) + + XCTAssertEqual(addedDate.timeIntervalSince(date), 60) + } + + func testIsSameDayInstanceMethod() { + let today = Date() + let sameDay = today + let differentDay = Calendar.current.date(byAdding: .day, value: -1, to: today)! + + XCTAssertTrue(today.isSameDay(sameDay)) + XCTAssertFalse(today.isSameDay(differentDay)) + XCTAssertFalse(today.isSameDay(nil)) + } + + func testIsLessThanDaysAgo() { + let recentDate = Calendar.current.date(byAdding: .day, value: -2, to: Date())! + let olderDate = Calendar.current.date(byAdding: .day, value: -5, to: Date())! + + XCTAssertTrue(recentDate.isLessThan(daysAgo: 3)) + XCTAssertFalse(olderDate.isLessThan(daysAgo: 3)) + } + + func testIsLessThanMinutesAgo() { + let recentDate = Calendar.current.date(byAdding: .minute, value: -10, to: Date())! + let olderDate = Calendar.current.date(byAdding: .minute, value: -30, to: Date())! + + XCTAssertTrue(recentDate.isLessThan(minutesAgo: 15)) + XCTAssertFalse(olderDate.isLessThan(minutesAgo: 15)) + } + + func testSecondsSinceNow() { + let date = Calendar.current.date(byAdding: .second, value: -30, to: Date())! + XCTAssertEqual(date.secondsSinceNow(), 30) + } + + func testMinutesSinceNow() { + let date = Calendar.current.date(byAdding: .minute, value: -10, to: Date())! + XCTAssertEqual(date.minutesSinceNow(), 10) + } + + func testHoursSinceNow() { + let date = Calendar.current.date(byAdding: .hour, value: -5, to: Date())! + XCTAssertEqual(date.hoursSinceNow(), 5) + } + + func testDaysSinceNow() { + let date = Calendar.current.date(byAdding: .day, value: -7, to: Date())! + XCTAssertEqual(date.daysSinceNow(), 7) + } + + func testMonthsSinceNow() { + let date = Calendar.current.date(byAdding: .month, value: -3, to: Date())! + XCTAssertEqual(date.monthsSinceNow(), 3) + } + + func testYearsSinceNow() { + let date = Calendar.current.date(byAdding: .year, value: -2, to: Date())! + XCTAssertEqual(date.yearsSinceNow(), 2) + } +} diff --git a/Tests/ConfigurationTests/ConfigurationFetcherTests.swift b/Tests/ConfigurationTests/ConfigurationFetcherTests.swift index 0f25b3353..48bccbe24 100644 --- a/Tests/ConfigurationTests/ConfigurationFetcherTests.swift +++ b/Tests/ConfigurationTests/ConfigurationFetcherTests.swift @@ -19,7 +19,7 @@ import XCTest @testable import Configuration @testable import Networking -@testable import TestUtils +import NetworkingTestingUtils final class ConfigurationFetcherTests: XCTestCase { diff --git a/Tests/ConfigurationTests/ConfigurationManagerTests.swift b/Tests/ConfigurationTests/ConfigurationManagerTests.swift index 5e931a07d..138845805 100644 --- a/Tests/ConfigurationTests/ConfigurationManagerTests.swift +++ b/Tests/ConfigurationTests/ConfigurationManagerTests.swift @@ -18,9 +18,10 @@ import XCTest import Persistence +import PersistenceTestingUtils @testable import Configuration @testable import Networking -@testable import TestUtils +import NetworkingTestingUtils final class MockConfigurationManager: DefaultConfigurationManager { diff --git a/Tests/ConfigurationTests/Mocks/MockStoreWithStorage.swift b/Tests/ConfigurationTests/Mocks/MockStoreWithStorage.swift index b655896ae..f6770553f 100644 --- a/Tests/ConfigurationTests/Mocks/MockStoreWithStorage.swift +++ b/Tests/ConfigurationTests/Mocks/MockStoreWithStorage.swift @@ -18,7 +18,7 @@ import Foundation import Persistence -import TestUtils +import PersistenceTestingUtils @testable import Configuration final class MockStoreWithStorage: ConfigurationStoring { diff --git a/Tests/CrashesTests/CrashCollectionTests.swift b/Tests/CrashesTests/CrashCollectionTests.swift index 8c71605c9..0d2876824 100644 --- a/Tests/CrashesTests/CrashCollectionTests.swift +++ b/Tests/CrashesTests/CrashCollectionTests.swift @@ -20,7 +20,7 @@ import MetricKit import XCTest import Persistence -import TestUtils +import PersistenceTestingUtils import Common class CrashCollectionTests: XCTestCase { diff --git a/Tests/DDGSyncTests/DDGSyncLifecycleTests.swift b/Tests/DDGSyncTests/DDGSyncLifecycleTests.swift index 6781aefd3..9e6f56119 100644 --- a/Tests/DDGSyncTests/DDGSyncLifecycleTests.swift +++ b/Tests/DDGSyncTests/DDGSyncLifecycleTests.swift @@ -19,7 +19,6 @@ import Combine import Common import XCTest -import TestUtils @testable import DDGSync final class DDGSyncLifecycleTests: XCTestCase { diff --git a/Tests/DDGSyncTests/Mocks/Mocks.swift b/Tests/DDGSyncTests/Mocks/Mocks.swift index d6978feab..b64a7d7d9 100644 --- a/Tests/DDGSyncTests/Mocks/Mocks.swift +++ b/Tests/DDGSyncTests/Mocks/Mocks.swift @@ -22,7 +22,7 @@ import Common import Foundation import Gzip import Persistence -import TestUtils +import PersistenceTestingUtils import Networking @testable import DDGSync diff --git a/Tests/DDGSyncTests/SyncDailyStatsTests.swift b/Tests/DDGSyncTests/SyncDailyStatsTests.swift index 710e70001..3fa4d6522 100644 --- a/Tests/DDGSyncTests/SyncDailyStatsTests.swift +++ b/Tests/DDGSyncTests/SyncDailyStatsTests.swift @@ -17,7 +17,7 @@ // import XCTest -import TestUtils +import PersistenceTestingUtils @testable import DDGSync class SyncDailyStatsTests: XCTestCase { diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift index 7c667b98f..23e62b96d 100644 --- a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift +++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionAPIClientTests.swift @@ -16,8 +16,8 @@ // limitations under the License. // import Foundation -import Networking -import TestUtils +@testable import Networking +import NetworkingTestingUtils import XCTest @testable import MaliciousSiteProtection diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionFeatureFlagsTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionFeatureFlagsTests.swift index 840c292e5..d4bb0980f 100644 --- a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionFeatureFlagsTests.swift +++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionFeatureFlagsTests.swift @@ -17,7 +17,6 @@ // import Foundation -import TestUtils import XCTest @testable import MaliciousSiteProtection diff --git a/Tests/NetworkProtectionTests/KnownFailureTests.swift b/Tests/NetworkProtectionTests/KnownFailureTests.swift index 41f06648f..1339acb8a 100644 --- a/Tests/NetworkProtectionTests/KnownFailureTests.swift +++ b/Tests/NetworkProtectionTests/KnownFailureTests.swift @@ -33,4 +33,4 @@ final class KnownFailureTests: XCTestCase { } } -extension String: Error {} +extension String: @retroactive Error {} diff --git a/Tests/NetworkProtectionTests/NetworkProtectionConnectionBandwidthAnalyzerTests.swift b/Tests/NetworkProtectionTests/NetworkProtectionConnectionBandwidthAnalyzerTests.swift index 69e4e6c6e..9465e534d 100644 --- a/Tests/NetworkProtectionTests/NetworkProtectionConnectionBandwidthAnalyzerTests.swift +++ b/Tests/NetworkProtectionTests/NetworkProtectionConnectionBandwidthAnalyzerTests.swift @@ -19,7 +19,7 @@ import Foundation import XCTest @testable import NetworkProtection -@testable import NetworkProtectionTestUtils +import NetworkProtectionTestUtils final class NetworkProtectionConnectionBandwidthAnalyzerTests: XCTestCase { diff --git a/Tests/NetworkingTests/APIRequestTests.swift b/Tests/NetworkingTests/APIRequestTests.swift index d79463d5f..deb162306 100644 --- a/Tests/NetworkingTests/APIRequestTests.swift +++ b/Tests/NetworkingTests/APIRequestTests.swift @@ -18,7 +18,7 @@ import XCTest @testable import Networking -import TestUtils +import NetworkingTestingUtils final class APIRequestTests: XCTestCase { diff --git a/Tests/NetworkingTests/Auth/OAuthClientTests.swift b/Tests/NetworkingTests/Auth/OAuthClientTests.swift new file mode 100644 index 000000000..ae91b1a03 --- /dev/null +++ b/Tests/NetworkingTests/Auth/OAuthClientTests.swift @@ -0,0 +1,251 @@ +// +// OAuthClientTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import NetworkingTestingUtils +@testable import Networking +import JWTKit + +final class OAuthClientTests: XCTestCase { + + var oAuthClient: DefaultOAuthClient! + var mockOAuthService: MockOAuthService! + var tokenStorage: MockTokenStorage! + var legacyTokenStorage: MockLegacyTokenStorage! + + override func setUp() async throws { + mockOAuthService = MockOAuthService() + tokenStorage = MockTokenStorage() + legacyTokenStorage = MockLegacyTokenStorage() + oAuthClient = DefaultOAuthClient(tokensStorage: tokenStorage, + legacyTokenStorage: legacyTokenStorage, + authService: mockOAuthService) + } + + override func tearDown() async throws { + mockOAuthService = nil + oAuthClient = nil + tokenStorage = nil + legacyTokenStorage = nil + } + + // MARK: - + + func testUserNotAuthenticated() async throws { + XCTAssertFalse(oAuthClient.isUserAuthenticated) + } + + func testUserAuthenticated() async throws { + tokenStorage.tokenContainer = OAuthTokensFactory.makeValidTokenContainer() + XCTAssertTrue(oAuthClient.isUserAuthenticated) + } + + func testCurrentTokenContainer() async throws { + XCTAssertNil(oAuthClient.currentTokenContainer) + tokenStorage.tokenContainer = OAuthTokensFactory.makeValidTokenContainer() + XCTAssertNotNil(oAuthClient.currentTokenContainer) + } + + // MARK: - Get tokens + + // MARK: Local + + func testGetToken_Local_Fail() async throws { + let localContainer = try? await oAuthClient.getTokens(policy: .local) + XCTAssertNil(localContainer) + } + + func testGetToken_Local_Success() async throws { + tokenStorage.tokenContainer = OAuthTokensFactory.makeValidTokenContainer() + + let localContainer = try? await oAuthClient.getTokens(policy: .local) + XCTAssertNotNil(localContainer) + XCTAssertFalse(localContainer!.decodedAccessToken.isExpired()) + } + + func testGetToken_Local_SuccessExpired() async throws { + tokenStorage.tokenContainer = OAuthTokensFactory.makeExpiredTokenContainer() + + let localContainer = try? await oAuthClient.getTokens(policy: .local) + XCTAssertNotNil(localContainer) + XCTAssertTrue(localContainer!.decodedAccessToken.isExpired()) + } + + // MARK: Local Valid + + /// A valid local token exists + func testGetToken_localValid_local() async throws { + + tokenStorage.tokenContainer = OAuthTokensFactory.makeValidTokenContainer() + + let localContainer = try await oAuthClient.getTokens(policy: .localValid) + XCTAssertNotNil(localContainer.accessToken) + XCTAssertNotNil(localContainer.refreshToken) + XCTAssertNotNil(localContainer.decodedAccessToken) + XCTAssertNotNil(localContainer.decodedRefreshToken) + XCTAssertFalse(localContainer.decodedAccessToken.isExpired()) + } + + /// An expired local token exists and is refreshed successfully + func testGetToken_localValid_refreshSuccess() async throws { + + mockOAuthService.getJWTSignersResponse = .success(JWTSigners()) + mockOAuthService.refreshAccessTokenResponse = .success( OAuthTokensFactory.makeValidOAuthTokenResponse()) + tokenStorage.tokenContainer = OAuthTokensFactory.makeExpiredTokenContainer() + + oAuthClient.testingDecodedTokenContainer = TokenContainer(accessToken: "accessToken", + refreshToken: "refreshToken", + decodedAccessToken: JWTAccessToken.mock, + decodedRefreshToken: JWTRefreshToken.mock) + + let localContainer = try await oAuthClient.getTokens(policy: .localValid) + XCTAssertNotNil(localContainer.accessToken) + XCTAssertNotNil(localContainer.refreshToken) + XCTAssertNotNil(localContainer.decodedAccessToken) + XCTAssertNotNil(localContainer.decodedRefreshToken) + XCTAssertFalse(localContainer.decodedAccessToken.isExpired()) + } + + /// An expired local token exists but refresh fails + func testGetToken_localValid_refreshFail() async throws { + + mockOAuthService.getJWTSignersResponse = .success(JWTSigners()) + mockOAuthService.refreshAccessTokenResponse = .failure(OAuthServiceError.invalidResponseCode(HTTPStatusCode.gatewayTimeout)) + tokenStorage.tokenContainer = OAuthTokensFactory.makeExpiredTokenContainer() + + do { + _ = try await oAuthClient.getTokens(policy: .localValid) + XCTFail("Error expected") + } catch { + XCTAssertEqual(error as? OAuthServiceError, .invalidResponseCode(HTTPStatusCode.gatewayTimeout)) + } + } + + // MARK: Force Refresh + + /// Local token is missing, refresh fails + func testGetToken_localForceRefresh_missingLocal() async throws { + do { + _ = try await oAuthClient.getTokens(policy: .localForceRefresh) + XCTFail("Error expected") + } catch { + XCTAssertEqual(error as? Networking.OAuthClientError, .missingRefreshToken) + } + } + + /// An expired local token exists and is refreshed successfully + func testGetToken_localForceRefresh_success() async throws { + + mockOAuthService.getJWTSignersResponse = .success(JWTSigners()) + mockOAuthService.refreshAccessTokenResponse = .success( OAuthTokensFactory.makeValidOAuthTokenResponse()) + tokenStorage.tokenContainer = OAuthTokensFactory.makeExpiredTokenContainer() + + oAuthClient.testingDecodedTokenContainer = TokenContainer(accessToken: "accessToken", + refreshToken: "refreshToken", + decodedAccessToken: JWTAccessToken.mock, + decodedRefreshToken: JWTRefreshToken.mock) + + let localContainer = try await oAuthClient.getTokens(policy: .localForceRefresh) + XCTAssertNotNil(localContainer.accessToken) + XCTAssertNotNil(localContainer.refreshToken) + XCTAssertNotNil(localContainer.decodedAccessToken) + XCTAssertNotNil(localContainer.decodedRefreshToken) + XCTAssertFalse(localContainer.decodedAccessToken.isExpired()) + } + + func testGetToken_localForceRefresh_refreshFail() async throws { + + mockOAuthService.getJWTSignersResponse = .success(JWTSigners()) + mockOAuthService.refreshAccessTokenResponse = .failure(OAuthServiceError.invalidResponseCode(HTTPStatusCode.gatewayTimeout)) + tokenStorage.tokenContainer = OAuthTokensFactory.makeExpiredTokenContainer() + + do { + _ = try await oAuthClient.getTokens(policy: .localForceRefresh) + XCTFail("Error expected") + } catch { + XCTAssertEqual(error as? OAuthServiceError, .invalidResponseCode(HTTPStatusCode.gatewayTimeout)) + } + } + + // MARK: Create if needed + + func testGetToken_createIfNeeded_foundLocal() async throws { + tokenStorage.tokenContainer = OAuthTokensFactory.makeValidTokenContainer() + + let tokenContainer = try await oAuthClient.getTokens(policy: .createIfNeeded) + XCTAssertNotNil(tokenContainer.accessToken) + XCTAssertNotNil(tokenContainer.refreshToken) + XCTAssertNotNil(tokenContainer.decodedAccessToken) + XCTAssertNotNil(tokenContainer.decodedRefreshToken) + XCTAssertFalse(tokenContainer.decodedAccessToken.isExpired()) + } + + func testGetToken_createIfNeeded_missingLocal_createSuccess() async throws { + mockOAuthService.authorizeResponse = .success("auth_session_id") + mockOAuthService.createAccountResponse = .success("auth_code") + mockOAuthService.getAccessTokenResponse = .success(OAuthTokensFactory.makeValidOAuthTokenResponse()) + + oAuthClient.testingDecodedTokenContainer = TokenContainer(accessToken: "accessToken", + refreshToken: "refreshToken", + decodedAccessToken: JWTAccessToken.mock, + decodedRefreshToken: JWTRefreshToken.mock) + + let tokenContainer = try await oAuthClient.getTokens(policy: .createIfNeeded) + XCTAssertNotNil(tokenContainer.accessToken) + XCTAssertNotNil(tokenContainer.refreshToken) + XCTAssertNotNil(tokenContainer.decodedAccessToken) + XCTAssertNotNil(tokenContainer.decodedRefreshToken) + XCTAssertFalse(tokenContainer.decodedAccessToken.isExpired()) + } + + func testGetToken_createIfNeeded_missingLocal_createFail() async throws { + mockOAuthService.authorizeResponse = .failure(OAuthServiceError.invalidResponseCode(HTTPStatusCode.gatewayTimeout)) + + do { + _ = try await oAuthClient.getTokens(policy: .createIfNeeded) + XCTFail("Error expected") + } catch { + XCTAssertEqual(error as? OAuthServiceError, .invalidResponseCode(HTTPStatusCode.gatewayTimeout)) + } + } + + func testGetToken_createIfNeeded_missingLocal_createFail2() async throws { + mockOAuthService.authorizeResponse = .success("auth_session_id") + mockOAuthService.createAccountResponse = .failure(OAuthServiceError.invalidResponseCode(HTTPStatusCode.gatewayTimeout)) + + do { + _ = try await oAuthClient.getTokens(policy: .createIfNeeded) + XCTFail("Error expected") + } catch { + XCTAssertEqual(error as? OAuthServiceError, .invalidResponseCode(HTTPStatusCode.gatewayTimeout)) + } + } + + func testGetToken_createIfNeeded_missingLocal_createFail3() async throws { + mockOAuthService.authorizeResponse = .success("auth_session_id") + mockOAuthService.createAccountResponse = .success("auth_code") + mockOAuthService.getAccessTokenResponse = .failure(OAuthServiceError.invalidResponseCode(HTTPStatusCode.gatewayTimeout)) + + do { + _ = try await oAuthClient.getTokens(policy: .createIfNeeded) + XCTFail("Error expected") + } catch { + XCTAssertEqual(error as? OAuthServiceError, .invalidResponseCode(HTTPStatusCode.gatewayTimeout)) + } + } +} diff --git a/Tests/NetworkingTests/Auth/OAuthServiceTests.swift b/Tests/NetworkingTests/Auth/OAuthServiceTests.swift new file mode 100644 index 000000000..d3553cc22 --- /dev/null +++ b/Tests/NetworkingTests/Auth/OAuthServiceTests.swift @@ -0,0 +1,81 @@ +// +// OAuthServiceTests.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 Networking + +final class AuthServiceTests: XCTestCase { + + let baseURL = OAuthEnvironment.staging.url + + override func setUpWithError() throws { + /* + var mockedApiService = MockAPIService(decodableResponse: <#T##Result#>, + apiResponse: <#T##Result<(data: Data?, httpResponse: HTTPURLResponse), any Error>#>) + */ + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + var realAPISService: APIService { + let configuration = URLSessionConfiguration.default + configuration.httpCookieStorage = nil + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + let urlSession = URLSession(configuration: configuration, + delegate: SessionDelegate(), + delegateQueue: nil) + return DefaultAPIService(urlSession: urlSession) + } + + // MARK: - REAL tests, useful for development and debugging but disabled for normal testing + + func disabled_test_real_AuthoriseSuccess() async throws { + let authService = DefaultOAuthService(baseURL: baseURL, apiService: realAPISService) + let codeChallenge = OAuthCodesGenerator.codeChallenge(codeVerifier: OAuthCodesGenerator.codeVerifier)! + let result = try await authService.authorize(codeChallenge: codeChallenge) + XCTAssertNotNil(result) + } + + func disabled_test_real_AuthoriseFailure() async throws { + let authService = DefaultOAuthService(baseURL: baseURL, apiService: realAPISService) + do { + _ = try await authService.authorize(codeChallenge: "") + } catch { + switch error { + case OAuthServiceError.authAPIError(let code): + XCTAssertEqual(code.rawValue, "invalid_authorization_request") + XCTAssertEqual(code.description, "One or more of the required parameters are missing or any provided parameters have invalid values") + default: + XCTFail("Wrong error") + } + } + } + + func disabled_test_real_GetJWTSigner() async throws { + let authService = DefaultOAuthService(baseURL: baseURL, apiService: realAPISService) + let signer = try await authService.getJWTSigners() + do { + let _: JWTAccessToken = try signer.verify("sdfgdsdzfgsdf") + XCTFail("Should have thrown an error") + } catch { + XCTAssertNotNil(error) + } + } +} diff --git a/Tests/NetworkingTests/Auth/TokenContainerTests.swift b/Tests/NetworkingTests/Auth/TokenContainerTests.swift new file mode 100644 index 000000000..33ed67048 --- /dev/null +++ b/Tests/NetworkingTests/Auth/TokenContainerTests.swift @@ -0,0 +1,139 @@ +// +// TokenContainerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import JWTKit +@testable import Networking +import NetworkingTestingUtils + +final class TokenContainerTests: XCTestCase { + + // Test expired access token + func testExpiredAccessToken() { + let token = OAuthTokensFactory.makeExpiredAccessToken() + XCTAssertTrue(token.isExpired(), "Expected token to be expired.") + } + + // Test invalid scope in access token + func testAccessTokenInvalidScope() { + let token = OAuthTokensFactory.makeAccessToken(scope: "invalid-scope") + XCTAssertThrowsError(try token.verify(using: .hs256(key: "secret"))) { error in + XCTAssertEqual(error as? TokenPayloadError, .invalidTokenScope, "Expected invalidTokenScope error.") + } + } + + // Test invalid scope in refresh token + func testRefreshTokenInvalidScope() { + let token = OAuthTokensFactory.makeRefreshToken(scope: "invalid-scope") + XCTAssertThrowsError(try token.verify(using: .hs256(key: "secret"))) { error in + XCTAssertEqual(error as? TokenPayloadError, .invalidTokenScope, "Expected invalidTokenScope error.") + } + } + + // Test valid scope in access token + func testAccessTokenValidScope() { + let token = OAuthTokensFactory.makeAccessToken(scope: "privacypro") + XCTAssertNoThrow(try token.verify(using: .hs256(key: "secret")), "Expected no error for valid scope.") + } + + // Test valid scope in refresh token + func testRefreshTokenValidScope() { + let token = OAuthTokensFactory.makeRefreshToken(scope: "refresh") + XCTAssertNoThrow(try token.verify(using: .hs256(key: "secret")), "Expected no error for valid scope.") + } + + // Test entitlements with multiple types, including unsupported + func testSubscriptionEntitlements() { + let entitlements = [ + EntitlementPayload(product: .networkProtection, name: "subscriber"), + EntitlementPayload(product: .unknown, name: "subscriber") + ] + let token = JWTAccessToken( + exp: ExpirationClaim(value: Date().addingTimeInterval(3600)), + iat: IssuedAtClaim(value: Date()), + sub: SubjectClaim(value: "test-subject"), + aud: AudienceClaim(value: ["test-audience"]), + iss: IssuerClaim(value: "test-issuer"), + jti: IDClaim(value: "test-id"), + scope: "privacypro", + api: "v2", + email: "test@example.com", + entitlements: entitlements + ) + + XCTAssertEqual(token.subscriptionEntitlements, [.networkProtection, .unknown], "Expected mixed entitlements including unknown.") + XCTAssertTrue(token.hasEntitlement(.networkProtection), "Expected entitlement for networkProtection.") + XCTAssertFalse(token.hasEntitlement(.identityTheftRestoration), "Expected no entitlement for identityTheftRestoration.") + } + + // Test equatability of TokenContainer with same tokens but different fields + func testTokenContainerEquatabilitySameTokens() { + let accessToken = "same-access-token" + let refreshToken = "same-refresh-token" + + let container1 = TokenContainer( + accessToken: accessToken, + refreshToken: refreshToken, + decodedAccessToken: OAuthTokensFactory.makeAccessToken(scope: "privacypro"), + decodedRefreshToken: OAuthTokensFactory.makeRefreshToken(scope: "refresh") + ) + + let container2 = TokenContainer( + accessToken: accessToken, + refreshToken: refreshToken, + decodedAccessToken: OAuthTokensFactory.makeAccessToken(scope: "privacypro"), + decodedRefreshToken: OAuthTokensFactory.makeRefreshToken(scope: "refresh") + ) + + XCTAssertEqual(container1, container2, "Expected containers with identical tokens to be equal.") + } + + // Test equatability of TokenContainer with same token values but different decoded content + func testTokenContainerEquatabilityDifferentContent() { + let accessToken = "same-access-token" + let refreshToken = "same-refresh-token" + + let container1 = TokenContainer( + accessToken: accessToken, + refreshToken: refreshToken, + decodedAccessToken: OAuthTokensFactory.makeAccessToken(scope: "privacypro"), + decodedRefreshToken: OAuthTokensFactory.makeRefreshToken(scope: "refresh") + ) + + let modifiedAccessToken = OAuthTokensFactory.makeAccessToken(scope: "privacypro", email: "modified@example.com") // Changing a field in decoded token + + let container2 = TokenContainer( + accessToken: accessToken, + refreshToken: refreshToken, + decodedAccessToken: modifiedAccessToken, + decodedRefreshToken: OAuthTokensFactory.makeRefreshToken(scope: "refresh") + ) + + XCTAssertEqual(container1, container2, "Expected containers with identical tokens but different decoded content to be equal.") + } + + func testEncodeDecodeData() throws { + let container = OAuthTokensFactory.makeValidTokenContainer() + let tokenContainer = try TokenContainer(with: container.data!) + XCTAssertEqual(container, tokenContainer, "Expected decoded token container to be equal to original.") + XCTAssertEqual(container.accessToken, tokenContainer.accessToken) + XCTAssertEqual(container.refreshToken, tokenContainer.refreshToken) + XCTAssertEqual(container.decodedAccessToken, tokenContainer.decodedAccessToken) + XCTAssertEqual(container.decodedRefreshToken, tokenContainer.decodedRefreshToken) + } +} diff --git a/Tests/NetworkingTests/v2/APIRequestV2Tests.swift b/Tests/NetworkingTests/v2/APIRequestV2Tests.swift index 4ec1b8b59..a7d02918c 100644 --- a/Tests/NetworkingTests/v2/APIRequestV2Tests.swift +++ b/Tests/NetworkingTests/v2/APIRequestV2Tests.swift @@ -18,14 +18,14 @@ import XCTest @testable import Networking -import TestUtils +import NetworkingTestingUtils final class APIRequestV2Tests: XCTestCase { func testInitializationWithValidURL() { let url = URL(string: "https://www.example.com")! let method = HTTPRequestMethod.get - let queryItems = ["key": "value"] + let queryItems: QueryItems = [(key: "key", value: "value")] let headers = APIRequestV2.HeadersV2() let body = "Test body".data(using: .utf8) let timeoutInterval: TimeInterval = 30.0 @@ -41,24 +41,33 @@ final class APIRequestV2Tests: XCTestCase { cachePolicy: cachePolicy, responseConstraints: constraints) - let urlRequest = apiRequest.urlRequest - XCTAssertEqual(urlRequest.url?.host(), url.host()) + guard let urlRequest = apiRequest?.urlRequest else { + XCTFail("Nil URLRequest") + return + } + XCTAssertEqual(urlRequest.url?.host, url.host) XCTAssertEqual(urlRequest.httpMethod, method.rawValue) - let urlComponents = URLComponents(string: urlRequest.url!.absoluteString)! - XCTAssertTrue(urlComponents.queryItems!.contains(URLQueryItem(name: "key", value: "value"))) + if let urlComponents = URLComponents(url: urlRequest.url!, resolvingAgainstBaseURL: false) { + let expectedQueryItems = queryItems.map { queryItem in + URLQueryItem(name: queryItem.key, value: queryItem.value) + } + XCTAssertEqual(urlComponents.queryItems, expectedQueryItems) + } else { + XCTFail("Invalid URLComponents") + } XCTAssertEqual(urlRequest.allHTTPHeaderFields, headers.httpHeaders) XCTAssertEqual(urlRequest.httpBody, body) - XCTAssertEqual(apiRequest.timeoutInterval, timeoutInterval) + XCTAssertEqual(apiRequest?.timeoutInterval, timeoutInterval) XCTAssertEqual(urlRequest.cachePolicy, cachePolicy) - XCTAssertEqual(apiRequest.responseConstraints, constraints) + XCTAssertEqual(apiRequest?.responseConstraints, constraints) } func testURLRequestGeneration() { let url = URL(string: "https://www.example.com")! let method = HTTPRequestMethod.post - let queryItems = ["key": "value"] + let queryItems: QueryItems = [(key: "key", value: "value")] let headers = APIRequestV2.HeadersV2() let body = "Test body".data(using: .utf8) let timeoutInterval: TimeInterval = 30.0 @@ -72,16 +81,20 @@ final class APIRequestV2Tests: XCTestCase { timeoutInterval: timeoutInterval, cachePolicy: cachePolicy) - let urlComponents = URLComponents(string: apiRequest.urlRequest.url!.absoluteString)! - XCTAssertTrue(urlComponents.queryItems!.contains(URLQueryItem(name: "key", value: "value"))) + if let urlComponents = URLComponents(url: apiRequest!.urlRequest.url!, resolvingAgainstBaseURL: false) { + let expectedQueryItems = queryItems.map { URLQueryItem(name: $0.key, value: $0.value) } + XCTAssertEqual(urlComponents.queryItems, expectedQueryItems) + } else { + XCTFail("Invalid URLComponents") + } XCTAssertNotNil(apiRequest) - XCTAssertEqual(apiRequest.urlRequest.url?.absoluteString, "https://www.example.com?key=value") - XCTAssertEqual(apiRequest.urlRequest.httpMethod, method.rawValue) - XCTAssertEqual(apiRequest.urlRequest.allHTTPHeaderFields, headers.httpHeaders) - XCTAssertEqual(apiRequest.urlRequest.httpBody, body) - XCTAssertEqual(apiRequest.urlRequest.timeoutInterval, timeoutInterval) - XCTAssertEqual(apiRequest.urlRequest.cachePolicy, cachePolicy) + XCTAssertEqual(apiRequest?.urlRequest.url?.absoluteString, "https://www.example.com?key=value") + XCTAssertEqual(apiRequest?.urlRequest.httpMethod, method.rawValue) + XCTAssertEqual(apiRequest?.urlRequest.allHTTPHeaderFields, headers.httpHeaders) + XCTAssertEqual(apiRequest?.urlRequest.httpBody, body) + XCTAssertEqual(apiRequest?.urlRequest.timeoutInterval, timeoutInterval) + XCTAssertEqual(apiRequest?.urlRequest.cachePolicy, cachePolicy) } func testDefaultValues() { @@ -89,27 +102,43 @@ final class APIRequestV2Tests: XCTestCase { let apiRequest = APIRequestV2(url: url) let headers = APIRequestV2.HeadersV2() - let urlRequest = apiRequest.urlRequest + guard let urlRequest = apiRequest?.urlRequest else { + XCTFail("Nil URLRequest") + return + } XCTAssertEqual(urlRequest.httpMethod, HTTPRequestMethod.get.rawValue) XCTAssertEqual(urlRequest.timeoutInterval, 60.0) XCTAssertEqual(headers.httpHeaders, urlRequest.allHTTPHeaderFields) XCTAssertNil(urlRequest.httpBody) XCTAssertEqual(urlRequest.cachePolicy.rawValue, 0) - XCTAssertNil(apiRequest.responseConstraints) + XCTAssertNil(apiRequest?.responseConstraints) } func testAllowedQueryReservedCharacters() { let url = URL(string: "https://www.example.com")! - let queryItems = ["k#e,y": "val#ue"] + let queryItems: QueryItems = [(key: "k#e,y", value: "val#ue")] let apiRequest = APIRequestV2(url: url, queryItems: queryItems, allowedQueryReservedCharacters: CharacterSet(charactersIn: ",")) - let urlString = apiRequest.urlRequest.url!.absoluteString - XCTAssertEqual(urlString, "https://www.example.com?k%23e,y=val%23ue") + let urlString = apiRequest!.urlRequest.url!.absoluteString + XCTAssertTrue(urlString == "https://www.example.com?k%2523e,y=val%2523ue") + let urlComponents = URLComponents(string: urlString)! + XCTAssertTrue(urlComponents.queryItems?.count == 1) + } + + func testQueryParametersConcatenation() { + let url = URL(string: "https://www.example.com?originalKey=originalValue")! + let queryItems: QueryItems = [(key: "additionalKey", value: "additionalValue")] + + let apiRequest = APIRequestV2(url: url, + queryItems: queryItems, + allowedQueryReservedCharacters: CharacterSet(charactersIn: ",")) + let urlString = apiRequest!.urlRequest.url!.absoluteString + XCTAssertTrue(urlString == "https://www.example.com?originalKey=originalValue&additionalKey=additionalValue") let urlComponents = URLComponents(string: urlString)! - XCTAssertEqual(urlComponents.queryItems?.count, 1) + XCTAssertTrue(urlComponents.queryItems?.count == 2) } } diff --git a/Tests/NetworkingTests/v2/APIServiceTests.swift b/Tests/NetworkingTests/v2/APIServiceTests.swift index 730d6afbb..07ddc530e 100644 --- a/Tests/NetworkingTests/v2/APIServiceTests.swift +++ b/Tests/NetworkingTests/v2/APIServiceTests.swift @@ -18,7 +18,7 @@ import XCTest @testable import Networking -import TestUtils +import NetworkingTestingUtils final class APIServiceTests: XCTestCase { @@ -31,17 +31,16 @@ final class APIServiceTests: XCTestCase { // MARK: - Real API calls, do not enable func disabled_testRealFull() async throws { -// func testRealFull() async throws { let request = APIRequestV2(url: HTTPURLResponse.testUrl, method: .post, - queryItems: ["Query,Item1%Name": "Query,Item1%Value"], + queryItems: [(key: "Query,Item1%Name", value: "Query,Item1%Value")], headers: APIRequestV2.HeadersV2(userAgent: "UserAgent"), body: Data(), timeoutInterval: TimeInterval(20), cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, responseConstraints: [APIResponseConstraints.allowHTTPNotModified, APIResponseConstraints.requireETagHeader], - allowedQueryReservedCharacters: CharacterSet(charactersIn: ",")) + allowedQueryReservedCharacters: CharacterSet(charactersIn: ","))! let apiService = DefaultAPIService() let response = try await apiService.fetch(request: request) let responseHTML: String = try response.decodeBody() @@ -50,7 +49,7 @@ final class APIServiceTests: XCTestCase { func disabled_testRealCallJSON() async throws { // func testRealCallJSON() async throws { - let request = APIRequestV2(url: HTTPURLResponse.testUrl) + let request = APIRequestV2(url: HTTPURLResponse.testUrl)! let apiService = DefaultAPIService() let result = try await apiService.fetch(request: request) @@ -63,28 +62,31 @@ final class APIServiceTests: XCTestCase { func disabled_testRealCallString() async throws { // func testRealCallString() async throws { - let request = APIRequestV2(url: HTTPURLResponse.testUrl) + let request = APIRequestV2(url: HTTPURLResponse.testUrl)! let apiService = DefaultAPIService() let result = try await apiService.fetch(request: request) XCTAssertNotNil(result) } + // MARK: - + func testQueryItems() async throws { - let qItems = ["qName1": "qValue1", - "qName2": "qValue2"] + let qItems: QueryItems = [ + (key: "qName1", value: "qValue1"), + (key: "qName2", value: "qValue2")] MockURLProtocol.requestHandler = { request in let urlComponents = URLComponents(string: request.url!.absoluteString)! - XCTAssertTrue(urlComponents.queryItems!.contains(qItems.map { URLQueryItem(name: $0.key, value: $0.value) })) + XCTAssertTrue(urlComponents.queryItems!.contains(qItems.toURLQueryItems())) return (HTTPURLResponse.ok, nil) } - let request = APIRequestV2(url: HTTPURLResponse.testUrl, queryItems: qItems) + let request = APIRequestV2(url: HTTPURLResponse.testUrl, queryItems: qItems)! let apiService = DefaultAPIService(urlSession: mockURLSession) _ = try await apiService.fetch(request: request) } func testURLRequestError() async throws { - let request = APIRequestV2(url: HTTPURLResponse.testUrl) + let request = APIRequestV2(url: HTTPURLResponse.testUrl)! enum TestError: Error { case anError @@ -110,7 +112,7 @@ final class APIServiceTests: XCTestCase { func testResponseRequirementAllowHTTPNotModifiedSuccess() async throws { let requirements = [APIResponseConstraints.allowHTTPNotModified ] - let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements) + let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)! MockURLProtocol.requestHandler = { _ in ( HTTPURLResponse.notModified, Data()) } @@ -121,7 +123,7 @@ final class APIServiceTests: XCTestCase { } func testResponseRequirementAllowHTTPNotModifiedFailure() async throws { - let request = APIRequestV2(url: HTTPURLResponse.testUrl) + let request = APIRequestV2(url: HTTPURLResponse.testUrl)! MockURLProtocol.requestHandler = { _ in ( HTTPURLResponse.notModified, Data()) } @@ -146,7 +148,7 @@ final class APIServiceTests: XCTestCase { let requirements: [APIResponseConstraints] = [ APIResponseConstraints.requireETagHeader ] - let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements) + let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)! MockURLProtocol.requestHandler = { _ in ( HTTPURLResponse.ok, nil) } // HTTPURLResponse.ok contains etag let apiService = DefaultAPIService(urlSession: mockURLSession) @@ -157,7 +159,7 @@ final class APIServiceTests: XCTestCase { func testResponseRequirementRequireETagHeaderFailure() async throws { let requirements = [ APIResponseConstraints.requireETagHeader ] - let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements) + let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)! MockURLProtocol.requestHandler = { _ in ( HTTPURLResponse.okNoEtag, nil) } @@ -180,7 +182,7 @@ final class APIServiceTests: XCTestCase { func testResponseRequirementRequireUserAgentSuccess() async throws { let requirements = [ APIResponseConstraints.requireUserAgent ] - let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements) + let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)! MockURLProtocol.requestHandler = { _ in ( HTTPURLResponse.okUserAgent, nil) @@ -193,7 +195,7 @@ final class APIServiceTests: XCTestCase { func testResponseRequirementRequireUserAgentFailure() async throws { let requirements = [ APIResponseConstraints.requireUserAgent ] - let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements) + let request = APIRequestV2(url: HTTPURLResponse.testUrl, responseConstraints: requirements)! MockURLProtocol.requestHandler = { _ in ( HTTPURLResponse.ok, nil) } @@ -212,4 +214,78 @@ final class APIServiceTests: XCTestCase { } } + // MARK: - Retry + + func testRetry() async throws { + let request = APIRequestV2(url: HTTPURLResponse.testUrl, retryPolicy: APIRequestV2.RetryPolicy(maxRetries: 3))! + let requestCountExpectation = expectation(description: "Request performed count") + requestCountExpectation.expectedFulfillmentCount = 4 + + MockURLProtocol.requestHandler = { request in + requestCountExpectation.fulfill() + return ( HTTPURLResponse.internalServerError, nil) + } + + let apiService = DefaultAPIService(urlSession: mockURLSession) + _ = try? await apiService.fetch(request: request) + + await fulfillment(of: [requestCountExpectation], timeout: 1.0) + } + + func testNoRetry() async throws { + let request = APIRequestV2(url: HTTPURLResponse.testUrl)! + let requestCountExpectation = expectation(description: "Request performed count") + requestCountExpectation.expectedFulfillmentCount = 1 + + MockURLProtocol.requestHandler = { request in + requestCountExpectation.fulfill() + return ( HTTPURLResponse.internalServerError, nil) + } + + let apiService = DefaultAPIService(urlSession: mockURLSession) + do { + _ = try await apiService.fetch(request: request) + } + + await fulfillment(of: [requestCountExpectation], timeout: 1.0) + } + + // MARK: - Refresh auth + + func testRefreshIsCalledForAuthenticatedRequest() async throws { + let refreshCalledExpectation = expectation(description: "Refresh block called") + refreshCalledExpectation.expectedFulfillmentCount = 1 + + MockURLProtocol.requestHandler = { _ in + (HTTPURLResponse.unauthorised, nil) + } + + let request = APIRequestV2(url: HTTPURLResponse.testUrl, + headers: APIRequestV2.HeadersV2(authToken: "expiredToken"))! + let apiService = DefaultAPIService(urlSession: mockURLSession) { request in + refreshCalledExpectation.fulfill() + return "someToken" + } + _ = try await apiService.fetch(request: request) + + await fulfillment(of: [refreshCalledExpectation], timeout: 1.0) + } + + func testRefreshIsNotCalledForUnauthenticatedRequest() async throws { + let refreshCalledExpectation = expectation(description: "Refresh block NOT called") + refreshCalledExpectation.isInverted = true + + MockURLProtocol.requestHandler = { _ in + (HTTPURLResponse.unauthorised, nil) + } + + let request = APIRequestV2(url: HTTPURLResponse.testUrl)! + let apiService = DefaultAPIService(urlSession: mockURLSession) { request in + refreshCalledExpectation.fulfill() + return "someToken" + } + _ = try await apiService.fetch(request: request) + + await fulfillment(of: [refreshCalledExpectation], timeout: 1.0) + } } diff --git a/Tests/NetworkingTests/v2/Extensions/DictionaryURLQueryItemsTests.swift b/Tests/NetworkingTests/v2/Extensions/DictionaryURLQueryItemsTests.swift new file mode 100644 index 000000000..9533aea0d --- /dev/null +++ b/Tests/NetworkingTests/v2/Extensions/DictionaryURLQueryItemsTests.swift @@ -0,0 +1,117 @@ +// +// DictionaryURLQueryItemsTests.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 Networking + +final class DictionaryURLQueryItemsTests: XCTestCase { + + func queryParam(withName name: String, from queryItems: [URLQueryItem]) -> URLQueryItem { + return queryItems.compactMap({ queryItem in + if queryItem.name == name { + return queryItem + } else { + return nil + } + }).last! + } + + func testBasicKeyValuePairsConversion() { + let queryParamCollection: QueryItems = [ + (key: "key1", value: "value1"), + (key: "key2", value: "value2") + ] + let queryItems = queryParamCollection.toURLQueryItems() + + XCTAssertEqual(queryItems.count, 2) + let q0 = queryParam(withName: "key1", from: queryItems) + XCTAssertEqual(q0.name, "key1") + XCTAssertEqual(q0.value, "value1") + + let q1 = queryParam(withName: "key2", from: queryItems) + XCTAssertEqual(q1.name, "key2") + XCTAssertEqual(q1.value, "value2") + } + + func testReservedCharactersAreEncoded() { + let dict: QueryItems = [ + (key: "query", value: "value with spaces"), + (key: "special", value: "value/with/slash") + ] + let queryItems = dict.toURLQueryItems() + + XCTAssertEqual(queryItems.count, 2) + let q1 = queryParam(withName: "query", from: queryItems) + XCTAssertEqual(q1.name, "query") + XCTAssertEqual(q1.value, "value with spaces") + + let q2 = queryParam(withName: "special", from: queryItems) + XCTAssertEqual(q2.name, "special") + XCTAssertEqual(q2.value, "value/with/slash") + } + + func testReservedCharactersNotEncodedWhenAllowedCharacterSetProvided() { + let dict: QueryItems = [(key: "specialKey", value: "value/with/slash")] + let allowedCharacters = CharacterSet.urlPathAllowed + let queryItems = dict.toURLQueryItems(allowedReservedCharacters: allowedCharacters) + + XCTAssertEqual(queryItems.count, 1) + XCTAssertEqual(queryItems[0].name, "specialKey") + XCTAssertEqual(queryItems[0].value, "value/with/slash") // '/' should be preserved + } + + func testEmptyDictionaryReturnsEmptyQueryItems() { + let dict: QueryItems = [] + let queryItems = dict.toURLQueryItems() + + XCTAssertEqual(queryItems.count, 0) + } + + func testPercentEncodingWithCustomCharacterSet() { + let dict: QueryItems = [(key: "key", value: "value with spaces & symbols!")] + let allowedCharacters = CharacterSet.punctuationCharacters.union(.whitespaces) + let queryItems = dict.toURLQueryItems(allowedReservedCharacters: allowedCharacters) + + XCTAssertEqual(queryItems.count, 1) + XCTAssertEqual(queryItems[0].name, "key") + XCTAssertEqual(queryItems[0].value, "value with spaces & symbols!") + } + + func testMultipleItemsWithReservedCharacters() { + let dict: QueryItems = [ + (key: "path", value: "part/with/slashes"), + (key: "query", value: "value with spaces"), + (key: "fragment", value: "with#fragment") + ] + let allowedCharacters = CharacterSet.urlPathAllowed.union(.whitespaces).union(.punctuationCharacters) + let queryItems = dict.toURLQueryItems(allowedReservedCharacters: allowedCharacters) + + XCTAssertEqual(queryItems.count, 3) + let q0 = queryParam(withName: "path", from: queryItems) + XCTAssertEqual(q0.name, "path") + XCTAssertEqual(q0.value, "part/with/slashes") + + let q1 = queryParam(withName: "query", from: queryItems) + XCTAssertEqual(q1.name, "query") + XCTAssertEqual(q1.value, "value with spaces") + + let q2 = queryParam(withName: "fragment", from: queryItems) + XCTAssertEqual(q2.name, "fragment") + XCTAssertEqual(q2.value, "with#fragment") + } +} diff --git a/Tests/NetworkingTests/v2/Extensions/HTTPURLResponseCookiesTests.swift b/Tests/NetworkingTests/v2/Extensions/HTTPURLResponseCookiesTests.swift new file mode 100644 index 000000000..c3b3be9a2 --- /dev/null +++ b/Tests/NetworkingTests/v2/Extensions/HTTPURLResponseCookiesTests.swift @@ -0,0 +1,84 @@ +// +// HTTPURLResponseCookiesTests.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 + +final class HTTPURLResponseCookiesTests: XCTestCase { + + func getCookie(withName name: String, from cookies: [HTTPCookie]?) -> HTTPCookie? { + return cookies?.compactMap({ cookie in + if cookie.name == name { + return cookie + } else { + return nil + } + }).last + } + + func testCookiesRetrievesAllCookies() { + let url = URL(string: "https://example.com")! + let cookieHeader = "Set-Cookie" + let cookieValue1 = "name1=value1; Path=/; HttpOnly" + let cookieValue2 = "name2=value2; Path=/; Secure" + let headers = [cookieHeader: "\(cookieValue1), \(cookieValue2)"] + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers) + + let cookies = response?.cookies + XCTAssertEqual(cookies?.count, 2) + + let c0 = getCookie(withName: "name1", from: cookies) + XCTAssertEqual(c0?.name, "name1") + XCTAssertEqual(c0?.value, "value1") + + let c1 = getCookie(withName: "name2", from: cookies) + XCTAssertEqual(c1?.name, "name2") + XCTAssertEqual(c1?.value, "value2") + } + + func testGetCookieWithNameReturnsCorrectCookie() { + let url = URL(string: "https://example.com")! + let cookieHeader = "Set-Cookie" + let cookieValue1 = "name1=value1; Path=/; HttpOnly" + let cookieValue2 = "name2=value2; Path=/; Secure" + let headers = [cookieHeader: "\(cookieValue1), \(cookieValue2)"] + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers) + + let cookie = response?.getCookie(withName: "name2") + XCTAssertNotNil(cookie) + XCTAssertEqual(cookie?.name, "name2") + XCTAssertEqual(cookie?.value, "value2") + } + + func testGetCookieWithNameReturnsNilForNonExistentCookie() { + let url = URL(string: "https://example.com")! + let cookieHeader = "Set-Cookie" + let cookieValue1 = "name1=value1; Path=/; HttpOnly" + let headers = [cookieHeader: cookieValue1] + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers) + + let cookie = response?.getCookie(withName: "nonexistent") + XCTAssertNil(cookie) + } + + func testCookiesReturnsNilWhenNoCookieHeaderFields() { + let url = URL(string: "https://example.com")! + let headers: [String: String] = [:] + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers) + XCTAssertTrue(response!.cookies!.isEmpty) + } +} diff --git a/Tests/NetworkingTests/v2/Extensions/HTTPURLResponseETagTests.swift b/Tests/NetworkingTests/v2/Extensions/HTTPURLResponseETagTests.swift new file mode 100644 index 000000000..80f2a0483 --- /dev/null +++ b/Tests/NetworkingTests/v2/Extensions/HTTPURLResponseETagTests.swift @@ -0,0 +1,67 @@ +// +// HTTPURLResponseETagTests.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 + +final class HTTPURLResponseETagTests: XCTestCase { + + func testEtagReturnsStrongEtag() { + let url = URL(string: "https://example.com")! + let headers = ["Etag": "\"12345\""] + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers) + + let etag = response?.etag + XCTAssertEqual(etag, "\"12345\"") + } + + func testEtagReturnsWeakEtagWithoutPrefix() { + let url = URL(string: "https://example.com")! + let headers = ["Etag": "W/\"12345\""] + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers) + + let etag = response?.etag + XCTAssertEqual(etag, "\"12345\"") // Weak prefix "W/" should be dropped + } + + func testEtagRetainsWeakPrefixWhenDroppingWeakPrefixIsFalse() { + let url = URL(string: "https://example.com")! + let headers = ["Etag": "W/\"12345\""] + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers) + + let etag = response?.etag(droppingWeakPrefix: false) + XCTAssertEqual(etag, "W/\"12345\"") // Weak prefix "W/" should be retained + } + + func testEtagReturnsNilWhenNoEtagHeaderPresent() { + let url = URL(string: "https://example.com")! + let headers: [String: String] = [:] + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers) + + let etag = response?.etag + XCTAssertNil(etag) + } + + func testEtagReturnsEmptyStringForEmptyEtagHeader() { + let url = URL(string: "https://example.com")! + let headers = ["Etag": ""] + let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headers) + + let etag = response?.etag + XCTAssertEqual(etag, "") + } +} diff --git a/Tests/NetworkingTests/v2/Extensions/URL+QueryParametersTests.swift b/Tests/NetworkingTests/v2/Extensions/URL+QueryParametersTests.swift new file mode 100644 index 000000000..96901ffb7 --- /dev/null +++ b/Tests/NetworkingTests/v2/Extensions/URL+QueryParametersTests.swift @@ -0,0 +1,95 @@ +// +// URL+QueryParametersTests.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 + +class URLExtensionTests: XCTestCase { + + func testQueryParametersWithValidURL() { + // Given + let url = URL(string: "https://example.com?param1=value1¶m2=value2")! + + // When + let parameters = url.queryParameters() + + // Then + XCTAssertNotNil(parameters) + XCTAssertEqual(parameters?["param1"], "value1") + XCTAssertEqual(parameters?["param2"], "value2") + } + + func testQueryParametersWithEmptyQuery() { + // Given + let url = URL(string: "https://example.com")! + + // When + let parameters = url.queryParameters() + + // Then + XCTAssertNil(parameters) + } + + func testQueryParametersWithNoValue() { + // Given + let url = URL(string: "https://example.com?param1=¶m2=value2")! + + // When + let parameters = url.queryParameters() + + // Then + XCTAssertNotNil(parameters) + XCTAssertEqual(parameters?["param1"], "") + XCTAssertEqual(parameters?["param2"], "value2") + } + + func testQueryParametersWithSpecialCharacters() { + // Given + let url = URL(string: "https://example.com?param1=value%201¶m2=value%202")! + + // When + let parameters = url.queryParameters() + + // Then + XCTAssertNotNil(parameters) + XCTAssertEqual(parameters?["param1"], "value 1") + XCTAssertEqual(parameters?["param2"], "value 2") + } + + func testQueryParametersWithMultipleSameKeys() { + // Given + let url = URL(string: "https://example.com?param=value1¶m=value2")! + + // When + let parameters = url.queryParameters() + + // Then + XCTAssertNotNil(parameters) + XCTAssertEqual(parameters?["param"], "value2") // Last value should overwrite the first + } + + func testQueryParametersWithInvalidURL() { + // Given + let url = URL(string: "invalid-url")! + + // When + let parameters = url.queryParameters() + + // Then + XCTAssertNil(parameters) + } +} diff --git a/Tests/PrivacyDashboardTests/BrokenSiteReporterTests.swift b/Tests/PrivacyDashboardTests/BrokenSiteReporterTests.swift index 50d59895e..3a7796d15 100644 --- a/Tests/PrivacyDashboardTests/BrokenSiteReporterTests.swift +++ b/Tests/PrivacyDashboardTests/BrokenSiteReporterTests.swift @@ -18,7 +18,7 @@ import XCTest @testable import PrivacyDashboard -import TestUtils +import PersistenceTestingUtils final class BrokenSiteReporterTests: XCTestCase { diff --git a/Tests/PrivacyDashboardTests/ExpiryStorageTests.swift b/Tests/PrivacyDashboardTests/ExpiryStorageTests.swift index 3315329ac..646e8c690 100644 --- a/Tests/PrivacyDashboardTests/ExpiryStorageTests.swift +++ b/Tests/PrivacyDashboardTests/ExpiryStorageTests.swift @@ -17,7 +17,7 @@ // import XCTest -import TestUtils +import PersistenceTestingUtils @testable import PrivacyDashboard final class ExpiryStorageTests: XCTestCase { diff --git a/Tests/RemoteMessagingTests/RemoteMessagingPercentileUserDefaultsStoreTests.swift b/Tests/RemoteMessagingTests/RemoteMessagingPercentileUserDefaultsStoreTests.swift index b50b6364c..24d202263 100644 --- a/Tests/RemoteMessagingTests/RemoteMessagingPercentileUserDefaultsStoreTests.swift +++ b/Tests/RemoteMessagingTests/RemoteMessagingPercentileUserDefaultsStoreTests.swift @@ -16,8 +16,8 @@ // limitations under the License. // -import TestUtils import XCTest +import PersistenceTestingUtils @testable import RemoteMessaging class RemoteMessagingPercentileUserDefaultsStoreTests: XCTestCase { diff --git a/Tests/RemoteMessagingTests/RemoteMessagingStoreTests.swift b/Tests/RemoteMessagingTests/RemoteMessagingStoreTests.swift index a251a3d2f..9e89a1ab8 100644 --- a/Tests/RemoteMessagingTests/RemoteMessagingStoreTests.swift +++ b/Tests/RemoteMessagingTests/RemoteMessagingStoreTests.swift @@ -21,7 +21,7 @@ import CoreData import Foundation import Persistence import RemoteMessagingTestsUtils -import TestUtils +import PersistenceTestingUtils import XCTest @testable import RemoteMessaging From 337a9c0aa6bce04ffeba2f7d720ba83f900af425 Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 11:47:09 +0000 Subject: [PATCH 13/28] re-adding v1 classes and renaming new classes with v2 --- ...faultRemoteMessagingSurveyURLBuilder.swift | 6 +- .../API/AuthEndpointService.swift | 7 +- ...ion.swift => PrivacyProSubscription.swift} | 36 +- ...ice.swift => SubscriptionAPIService.swift} | 6 +- .../API/SubscriptionEndpointService.swift | 52 +- .../API/SubscriptionEndpointServiceV2.swift | 263 +++++++++ .../API/SubscriptionRequest.swift | 88 +++ ...vice+SubscriptionFeatureMappingCache.swift | 34 ++ .../Flows/AppStore/AppStorePurchaseFlow.swift | 26 +- .../AppStore/AppStorePurchaseFlowV2.swift | 209 +++++++ .../Flows/AppStore/AppStoreRestoreFlow.swift | 2 +- .../AppStore/AppStoreRestoreFlowV2.swift | 93 ++++ .../Flows/Models/PurchaseUpdate.swift | 2 +- .../Flows/Models/SubscriptionOptions.swift | 56 +- .../Flows/Models/SubscriptionOptionsV2.swift | 92 ++++ ...eFlow.swift => StripePurchaseFlowV1.swift} | 28 +- .../Flows/Stripe/StripePurchaseFlowV2.swift | 112 ++++ .../Subscription/Logger+Subscription.swift | 10 +- .../StorePurchaseManager.swift | 100 ++-- .../StorePurchaseManagerV2.swift | 392 ++++++++++++++ .../Managers/SubscriptionManager.swift | 2 +- .../Managers/SubscriptionManagerV2.swift | 423 +++++++++++++++ .../V1}/AccountKeychainStorage.swift | 4 +- .../V1}/AccountStoring.swift | 0 .../SubscriptionTokenKeychainStorage.swift | 34 +- .../V1}/SubscriptionTokenStoring.swift | 0 ...ychainStorage+LegacyAuthTokenStoring.swift | 45 ++ .../SubscriptionTokenKeychainStorageV2.swift | 168 ++++++ .../SubscriptionCookieManager.swift | 10 +- .../SubscriptionCookieManagerV2.swift | 199 +++++++ .../SubscriptionEnvironment.swift | 17 +- .../Subscription/SubscriptionFeature.swift | 31 ++ .../SubscriptionFeatureMappingCacheV2.swift | 25 + .../SubscriptionTokenProvider.swift | 30 ++ .../APIs/APIServiceMock.swift | 63 --- .../APIs/AuthEndpointServiceMock.swift | 57 -- .../SubscriptionAPIMockResponseFactory.swift | 100 ++++ .../SubscriptionEndpointServiceMock.swift | 72 ++- ...untManagerKeychainAccessDelegateMock.swift | 33 -- ...SubscriptionTokenKeychainStorageMock.swift | 44 -- .../AppStoreAccountManagementFlowMock.swift | 34 -- .../Flows/AppStorePurchaseFlowMock.swift | 5 +- .../Flows/AppStoreRestoreFlowMock.swift | 6 +- .../Flows/StripePurchaseFlowMock.swift | 8 +- .../Managers/AccountManagerMock.swift | 110 ---- .../Managers/StorePurchaseManagerMock.swift | 10 +- .../Managers/SubscriptionManagerMock.swift | 174 ++++-- .../SubscriptionCookieManagerMock.swift | 32 +- .../SubscriptionFeatureMappingCacheMock.swift | 7 +- .../SubscriptionMockFactory.swift | 6 +- .../API/AuthEndpointServiceTests.swift | 319 ----------- .../API/Models/EntitlementTests.swift | 47 -- .../Models/SubscriptionEntitlementTests.swift | 48 ++ .../API/Models/SubscriptionTests.swift | 126 ++--- .../SubscriptionEndpointServiceTests.swift | 254 ++++++++- .../AppStoreAccountManagementFlowTests.swift | 184 ------- .../Flows/AppStorePurchaseFlowTests.swift | 228 ++++++-- .../Flows/AppStoreRestoreFlowTests.swift | 327 ++--------- .../Models/SubscriptionOptionsTests.swift | 21 +- .../Flows/StripePurchaseFlowTests.swift | 15 +- .../Managers/AccountManagerTests.swift | 508 ------------------ .../Managers/StorePurchaseManagerTests.swift | 2 +- .../Managers/SubscriptionManagerTests.swift | 310 ++++++----- ...ivacyProSubscriptionIntegrationTests.swift | 331 ++++++++++++ .../SubscriptionCookieManagerTests.swift | 75 +-- 65 files changed, 3838 insertions(+), 2320 deletions(-) rename Sources/Subscription/API/Model/{Subscription.swift => PrivacyProSubscription.swift} (64%) rename Sources/Subscription/API/{APIService.swift => SubscriptionAPIService.swift} (97%) create mode 100644 Sources/Subscription/API/SubscriptionEndpointServiceV2.swift create mode 100644 Sources/Subscription/API/SubscriptionRequest.swift create mode 100644 Sources/Subscription/DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCache.swift create mode 100644 Sources/Subscription/Flows/AppStore/AppStorePurchaseFlowV2.swift create mode 100644 Sources/Subscription/Flows/AppStore/AppStoreRestoreFlowV2.swift create mode 100644 Sources/Subscription/Flows/Models/SubscriptionOptionsV2.swift rename Sources/Subscription/Flows/Stripe/{StripePurchaseFlow.swift => StripePurchaseFlowV1.swift} (86%) create mode 100644 Sources/Subscription/Flows/Stripe/StripePurchaseFlowV2.swift create mode 100644 Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManagerV2.swift create mode 100644 Sources/Subscription/Managers/SubscriptionManagerV2.swift rename Sources/Subscription/{AccountStorage => Storage/V1}/AccountKeychainStorage.swift (97%) rename Sources/Subscription/{AccountStorage => Storage/V1}/AccountStoring.swift (100%) rename Sources/Subscription/{AccountStorage => Storage/V1}/SubscriptionTokenKeychainStorage.swift (85%) rename Sources/Subscription/{AccountStorage => Storage/V1}/SubscriptionTokenStoring.swift (100%) create mode 100644 Sources/Subscription/Storage/V2/SubscriptionTokenKeychainStorage+LegacyAuthTokenStoring.swift create mode 100644 Sources/Subscription/Storage/V2/SubscriptionTokenKeychainStorageV2.swift create mode 100644 Sources/Subscription/SubscriptionCookie/SubscriptionCookieManagerV2.swift create mode 100644 Sources/Subscription/SubscriptionFeature.swift create mode 100644 Sources/Subscription/SubscriptionFeatureMappingCacheV2.swift create mode 100644 Sources/Subscription/SubscriptionTokenProvider.swift delete mode 100644 Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift delete mode 100644 Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift create mode 100644 Sources/SubscriptionTestingUtilities/APIs/SubscriptionAPIMockResponseFactory.swift delete mode 100644 Sources/SubscriptionTestingUtilities/AccountStorage/AccountManagerKeychainAccessDelegateMock.swift delete mode 100644 Sources/SubscriptionTestingUtilities/AccountStorage/SubscriptionTokenKeychainStorageMock.swift delete mode 100644 Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift delete mode 100644 Sources/SubscriptionTestingUtilities/Managers/AccountManagerMock.swift delete mode 100644 Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift delete mode 100644 Tests/SubscriptionTests/API/Models/EntitlementTests.swift create mode 100644 Tests/SubscriptionTests/API/Models/SubscriptionEntitlementTests.swift delete mode 100644 Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift delete mode 100644 Tests/SubscriptionTests/Managers/AccountManagerTests.swift create mode 100644 Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift diff --git a/Sources/RemoteMessaging/Mappers/DefaultRemoteMessagingSurveyURLBuilder.swift b/Sources/RemoteMessaging/Mappers/DefaultRemoteMessagingSurveyURLBuilder.swift index 37e53e352..4a96c9d38 100644 --- a/Sources/RemoteMessaging/Mappers/DefaultRemoteMessagingSurveyURLBuilder.swift +++ b/Sources/RemoteMessaging/Mappers/DefaultRemoteMessagingSurveyURLBuilder.swift @@ -30,12 +30,12 @@ public struct DefaultRemoteMessagingSurveyURLBuilder: RemoteMessagingSurveyActio private let statisticsStore: StatisticsStore private let vpnActivationDateStore: VPNActivationDateProviding - private let subscription: Subscription? + private let subscription: PrivacyProSubscription? private let localeIdentifier: String public init(statisticsStore: StatisticsStore, vpnActivationDateStore: VPNActivationDateProviding, - subscription: Subscription?, + subscription: PrivacyProSubscription?, localeIdentifier: String = Locale.current.identifier) { self.statisticsStore = statisticsStore self.vpnActivationDateStore = vpnActivationDateStore @@ -134,7 +134,7 @@ public struct DefaultRemoteMessagingSurveyURLBuilder: RemoteMessagingSurveyActio } -extension Subscription { +extension PrivacyProSubscription { var privacyProStatusSurveyParameter: String { switch status { case .autoRenewable: diff --git a/Sources/Subscription/API/AuthEndpointService.swift b/Sources/Subscription/API/AuthEndpointService.swift index 31972404a..6d45248fb 100644 --- a/Sources/Subscription/API/AuthEndpointService.swift +++ b/Sources/Subscription/API/AuthEndpointService.swift @@ -18,6 +18,7 @@ import Foundation import Common +import Networking public struct AccessTokenResponse: Decodable { public let accessToken: String @@ -68,9 +69,9 @@ public protocol AuthEndpointService { public struct DefaultAuthEndpointService: AuthEndpointService { private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment - private let apiService: APIService + private let apiService: SubscriptionAPIService - public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: APIService) { + public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: SubscriptionAPIService) { self.currentServiceEnvironment = currentServiceEnvironment self.apiService = apiService } @@ -79,7 +80,7 @@ public struct DefaultAuthEndpointService: AuthEndpointService { 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) + self.apiService = DefaultAPIServiceV1(baseURL: baseURL, session: session) } public func getAccessToken(token: String) async -> Result { diff --git a/Sources/Subscription/API/Model/Subscription.swift b/Sources/Subscription/API/Model/PrivacyProSubscription.swift similarity index 64% rename from Sources/Subscription/API/Model/Subscription.swift rename to Sources/Subscription/API/Model/PrivacyProSubscription.swift index 3dc9f8807..9b8b84546 100644 --- a/Sources/Subscription/API/Model/Subscription.swift +++ b/Sources/Subscription/API/Model/PrivacyProSubscription.swift @@ -1,5 +1,5 @@ // -// Subscription.swift +// PrivacyProSubscription.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -17,18 +17,20 @@ // import Foundation +import Networking -public typealias DDGSubscription = Subscription // to avoid conflicts when Combine is imported - -public struct Subscription: Codable, Equatable { +public struct PrivacyProSubscription: Codable, Equatable, CustomDebugStringConvertible { public let productId: String public let name: String - public let billingPeriod: Subscription.BillingPeriod + public let billingPeriod: BillingPeriod public let startedAt: Date public let expiresOrRenewsAt: Date - public let platform: Subscription.Platform + public let platform: Platform public let status: Status + /// Not parsed from + public var features: [SubscriptionEntitlement]? + public enum BillingPeriod: String, Codable { case monthly = "Monthly" case yearly = "Yearly" @@ -64,4 +66,26 @@ public struct Subscription: Codable, Equatable { public var isActive: Bool { status != .expired && status != .inactive } + + public var debugDescription: String { + return """ + Subscription: + - Product ID: \(productId) + - Name: \(name) + - Billing Period: \(billingPeriod.rawValue) + - Started At: \(formatDate(startedAt)) + - Expires/Renews At: \(formatDate(expiresOrRenewsAt)) + - Platform: \(platform.rawValue) + - Status: \(status.rawValue) + - Features: \(features?.map { $0.debugDescription } ?? []) + """ + } + + private func formatDate(_ date: Date) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + dateFormatter.timeZone = TimeZone.current + return dateFormatter.string(from: date) + } } diff --git a/Sources/Subscription/API/APIService.swift b/Sources/Subscription/API/SubscriptionAPIService.swift similarity index 97% rename from Sources/Subscription/API/APIService.swift rename to Sources/Subscription/API/SubscriptionAPIService.swift index 41c634706..d19ab79d9 100644 --- a/Sources/Subscription/API/APIService.swift +++ b/Sources/Subscription/API/SubscriptionAPIService.swift @@ -1,5 +1,5 @@ // -// APIService.swift +// SubscriptionAPIService.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -32,7 +32,7 @@ struct ErrorResponse: Decodable { let error: String } -public protocol APIService { +public protocol SubscriptionAPIService { func executeAPICall(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result where T: Decodable func makeAuthorizationHeader(for token: String) -> [String: String] } @@ -43,7 +43,7 @@ public enum APICachePolicy { case returnCacheDataDontLoad } -public struct DefaultAPIService: APIService { +public struct DefaultAPIServiceV1: SubscriptionAPIService { private let baseURL: URL private let session: URLSession diff --git a/Sources/Subscription/API/SubscriptionEndpointService.swift b/Sources/Subscription/API/SubscriptionEndpointService.swift index 0300c8804..671483e9f 100644 --- a/Sources/Subscription/API/SubscriptionEndpointService.swift +++ b/Sources/Subscription/API/SubscriptionEndpointService.swift @@ -18,27 +18,27 @@ import Common import Foundation - -public struct GetProductsItem: Decodable { - public let productId: String - public let productLabel: String - public let billingPeriod: String - public let price: String - public let currency: String -} +// +//public struct GetProductsItem: Decodable { +// public let productId: String +// public let productLabel: String +// public let billingPeriod: String +// public let price: String +// public let currency: String +//} public struct GetSubscriptionFeaturesResponse: Decodable { public let features: [Entitlement.ProductName] } -public struct GetCustomerPortalURLResponse: Decodable { - public let customerPortalUrl: String -} +//public struct GetCustomerPortalURLResponse: Decodable { +// public let customerPortalUrl: String +//} public struct ConfirmPurchaseResponse: Decodable { public let email: String? public let entitlements: [Entitlement] - public let subscription: Subscription + public let subscription: PrivacyProSubscription } public enum SubscriptionServiceError: Error { @@ -47,8 +47,8 @@ public enum SubscriptionServiceError: Error { } public protocol SubscriptionEndpointService { - func updateCache(with subscription: Subscription) - func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result + func updateCache(with subscription: PrivacyProSubscription) + func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result func signOut() func getProducts() async -> Result<[GetProductsItem], APIServiceError> func getSubscriptionFeatures(for subscriptionID: String) async -> Result @@ -73,19 +73,19 @@ public protocol SubscriptionEndpointService { extension SubscriptionEndpointService { - public func getSubscription(accessToken: String) async -> Result { + public func getSubscription(accessToken: String) async -> Result { await getSubscription(accessToken: accessToken, cachePolicy: .returnCacheDataElseLoad) } } /// Communicates with our backend -public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService { +public struct DefaultSubscriptionEndpointServiceV1: SubscriptionEndpointService { private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment - private let apiService: APIService - private let subscriptionCache = UserDefaultsCache(key: UserDefaultsCacheKey.subscription, - settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) + private let apiService: SubscriptionAPIService + private let subscriptionCache = UserDefaultsCache(key: UserDefaultsCacheKey.subscription, + settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) - public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: APIService) { + public init(currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment, apiService: SubscriptionAPIService) { self.currentServiceEnvironment = currentServiceEnvironment self.apiService = apiService } @@ -94,14 +94,14 @@ public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService { 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) + self.apiService = DefaultAPIServiceV1(baseURL: baseURL, session: session) } // MARK: - Subscription fetching with caching - private func getRemoteSubscription(accessToken: String) async -> Result { + private func getRemoteSubscription(accessToken: String) async -> Result { - let result: Result = await apiService.executeAPICall(method: "GET", endpoint: "subscription", headers: apiService.makeAuthorizationHeader(for: accessToken), body: nil) + 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) @@ -111,16 +111,16 @@ public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService { } } - public func updateCache(with subscription: Subscription) { + public func updateCache(with subscription: PrivacyProSubscription) { - let cachedSubscription: Subscription? = subscriptionCache.get() + let cachedSubscription = subscriptionCache.get() if subscription != cachedSubscription { subscriptionCache.set(subscription) NotificationCenter.default.post(name: .subscriptionDidChange, object: self, userInfo: [UserDefaultsCacheKey.subscription: subscription]) } } - public func getSubscription(accessToken: String, cachePolicy: APICachePolicy = .returnCacheDataElseLoad) async -> Result { + public func getSubscription(accessToken: String, cachePolicy: APICachePolicy = .returnCacheDataElseLoad) async -> Result { switch cachePolicy { case .reloadIgnoringLocalCacheData: diff --git a/Sources/Subscription/API/SubscriptionEndpointServiceV2.swift b/Sources/Subscription/API/SubscriptionEndpointServiceV2.swift new file mode 100644 index 000000000..0c9a494f8 --- /dev/null +++ b/Sources/Subscription/API/SubscriptionEndpointServiceV2.swift @@ -0,0 +1,263 @@ +// +// SubscriptionEndpointServiceV2.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 Common +import Foundation +import Networking +import os.log + +public struct GetProductsItem: Codable, Equatable { + public let productId: String + public let productLabel: String + public let billingPeriod: String + public let price: String + public let currency: String +} + +public struct GetCustomerPortalURLResponse: Codable, Equatable { + public let customerPortalUrl: String +} + +public struct ConfirmPurchaseResponseV2: Codable, Equatable { + public let email: String? +// public let entitlements: [Entitlement]? + public let subscription: PrivacyProSubscription +} + +public struct GetSubscriptionFeaturesResponseV2: Decodable { + public let features: [SubscriptionEntitlement] +} + +public enum SubscriptionEndpointServiceError: Error, Equatable { + case noData + case invalidRequest + case invalidResponseCode(HTTPStatusCode) +} + +public enum SubscriptionCachePolicy { + case reloadIgnoringLocalCacheData + case returnCacheDataElseLoad + case returnCacheDataDontLoad +} + +public protocol SubscriptionEndpointServiceV2 { + func ingestSubscription(_ subscription: PrivacyProSubscription) async throws + func getSubscription(accessToken: String, cachePolicy: SubscriptionCachePolicy) async throws -> PrivacyProSubscription + func clearSubscription() + func getProducts() async throws -> [GetProductsItem] + func getSubscriptionFeatures(for subscriptionID: String) async throws -> GetSubscriptionFeaturesResponseV2 + func getCustomerPortalURL(accessToken: String, externalID: String) async throws -> GetCustomerPortalURLResponse + + /// Confirms a subscription purchase by validating the provided access token and signature with the backend service. + /// + /// This method sends the necessary data to the server to confirm the purchase, + /// and optionally includes additional parameters for customization. + /// + /// - Parameters: + /// - accessToken: A string representing the user's access token, used for authentication. + /// - signature: A string representing the purchase signature. + /// - additionalParams: An optional dictionary of additional parameters to include in the request. + /// - Returns: A `ConfirmPurchaseResponse` object on success + func confirmPurchase(accessToken: String, signature: String, additionalParams: [String: String]?) async throws -> ConfirmPurchaseResponseV2 +} + +extension SubscriptionEndpointServiceV2 { + + public func getSubscription(accessToken: String) async throws -> PrivacyProSubscription { + try await getSubscription(accessToken: accessToken, cachePolicy: SubscriptionCachePolicy.returnCacheDataElseLoad) + } +} + +/// Communicates with our backend +public struct DefaultSubscriptionEndpointService: SubscriptionEndpointServiceV2 { + + private let apiService: APIService + private let baseURL: URL + private let subscriptionCache: UserDefaultsCache + private let cacheSerialQueue = DispatchQueue(label: "com.duckduckgo.subscriptionEndpointService.cache", qos: .background) + + public init(apiService: APIService, + baseURL: URL, + subscriptionCache: UserDefaultsCache = UserDefaultsCache(key: UserDefaultsCacheKey.subscription, settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20)))) { + self.apiService = apiService + self.baseURL = baseURL + self.subscriptionCache = subscriptionCache + } + + // MARK: - Subscription fetching with caching + + private func getRemoteSubscription(accessToken: String) async throws -> PrivacyProSubscription { + + Logger.subscriptionEndpointService.log("Requesting subscription details") + guard let request = SubscriptionRequest.getSubscription(baseURL: baseURL, accessToken: accessToken) else { + throw SubscriptionEndpointServiceError.invalidRequest + } + let response = try await apiService.fetch(request: request.apiRequest) + let statusCode = response.httpResponse.httpStatus + + if statusCode.isSuccess { + let subscription: PrivacyProSubscription = try response.decodeBody() + Logger.subscriptionEndpointService.log("Subscription details retrieved successfully: \(subscription.debugDescription, privacy: .public)") + return try await storeAndAddFeaturesIfNeededTo(subscription: subscription) + } else { + if statusCode == .badRequest { + Logger.subscriptionEndpointService.log("No subscription found") + clearSubscription() + throw SubscriptionEndpointServiceError.noData + } else { + let bodyString: String = try response.decodeBody() + Logger.subscriptionEndpointService.log("(\(statusCode.description) Failed to retrieve Subscription details: \(bodyString)") + throw SubscriptionEndpointServiceError.invalidResponseCode(statusCode) + } + } + } + + @discardableResult + private func storeAndAddFeaturesIfNeededTo(subscription: PrivacyProSubscription) async throws -> PrivacyProSubscription { + let cachedSubscription: PrivacyProSubscription? = subscriptionCache.get() + var subscription = subscription + // fetch remote features + Logger.subscriptionEndpointService.log("Getting features for subscription: \(subscription.productId, privacy: .public)") + subscription.features = try await getSubscriptionFeatures(for: subscription.productId).features + Logger.subscriptionEndpointService.debug(""" +Subscription: +Cached: \(cachedSubscription?.debugDescription ?? "nil", privacy: .public) +New: \(subscription.debugDescription, privacy: .public) +""") + if subscription != cachedSubscription { + updateCache(with: subscription) + } else { + Logger.subscriptionEndpointService.debug("No subscription update required") + } + return subscription + } + + func updateCache(with subscription: PrivacyProSubscription) { + cacheSerialQueue.sync { + subscriptionCache.set(subscription) + Logger.subscriptionEndpointService.debug("Notifying subscription changed") + NotificationCenter.default.post(name: .subscriptionDidChange, object: self, userInfo: [UserDefaultsCacheKey.subscription: subscription]) + } + } + + public func ingestSubscription(_ subscription: PrivacyProSubscription) async throws { + try await storeAndAddFeaturesIfNeededTo(subscription: subscription) + } + + public func getSubscription(accessToken: String, cachePolicy: SubscriptionCachePolicy = .returnCacheDataElseLoad) async throws -> PrivacyProSubscription { + switch cachePolicy { + case .reloadIgnoringLocalCacheData: + return try await getRemoteSubscription(accessToken: accessToken) + + case .returnCacheDataElseLoad: + if let cachedSubscription = getCachedSubscription() { + return cachedSubscription + } else { + return try await getRemoteSubscription(accessToken: accessToken) + } + + case .returnCacheDataDontLoad: + if let cachedSubscription = getCachedSubscription() { + return cachedSubscription + } else { + throw SubscriptionEndpointServiceError.noData + } + } + } + + private func getCachedSubscription() -> PrivacyProSubscription? { + var result: PrivacyProSubscription? + cacheSerialQueue.sync { + result = subscriptionCache.get() + } + return result + } + + public func clearSubscription() { + cacheSerialQueue.sync { + subscriptionCache.reset() + } +// NotificationCenter.default.post(name: .subscriptionDidChange, object: self, userInfo: nil) + } + + // MARK: - + + public func getProducts() async throws -> [GetProductsItem] { + guard let request = SubscriptionRequest.getProducts(baseURL: baseURL) else { + throw SubscriptionEndpointServiceError.invalidRequest + } + let response = try await apiService.fetch(request: request.apiRequest) + let statusCode = response.httpResponse.httpStatus + + if statusCode.isSuccess { + Logger.subscriptionEndpointService.log("\(#function) request completed") + return try response.decodeBody() + } else { + throw SubscriptionEndpointServiceError.invalidResponseCode(statusCode) + } + } + + // MARK: - + + public func getCustomerPortalURL(accessToken: String, externalID: String) async throws -> GetCustomerPortalURLResponse { + guard let request = SubscriptionRequest.getCustomerPortalURL(baseURL: baseURL, accessToken: accessToken, externalID: externalID) else { + throw SubscriptionEndpointServiceError.invalidRequest + } + let response = try await apiService.fetch(request: request.apiRequest) + let statusCode = response.httpResponse.httpStatus + if statusCode.isSuccess { + Logger.subscriptionEndpointService.log("\(#function) request completed") + return try response.decodeBody() + } else { + throw SubscriptionEndpointServiceError.invalidResponseCode(statusCode) + } + } + + // MARK: - + + public func confirmPurchase(accessToken: String, signature: String, additionalParams: [String: String]?) async throws -> ConfirmPurchaseResponseV2 { + guard let request = SubscriptionRequest.confirmPurchase(baseURL: baseURL, + accessToken: accessToken, + signature: signature, + additionalParams: additionalParams) else { + throw SubscriptionEndpointServiceError.invalidRequest + } + let response = try await apiService.fetch(request: request.apiRequest) + let statusCode = response.httpResponse.httpStatus + if statusCode.isSuccess { + Logger.subscriptionEndpointService.log("\(#function) request completed") + return try response.decodeBody() + } else { + throw SubscriptionEndpointServiceError.invalidResponseCode(statusCode) + } + } + + public func getSubscriptionFeatures(for subscriptionID: String) async throws -> GetSubscriptionFeaturesResponseV2 { + guard let request = SubscriptionRequest.subscriptionFeatures(baseURL: baseURL, subscriptionID: subscriptionID) else { + throw SubscriptionEndpointServiceError.invalidRequest + } + let response = try await apiService.fetch(request: request.apiRequest) + let statusCode = response.httpResponse.httpStatus + if statusCode.isSuccess { + Logger.subscriptionEndpointService.log("\(#function) request completed") + return try response.decodeBody() + } else { + throw SubscriptionEndpointServiceError.invalidResponseCode(statusCode) + } + } +} diff --git a/Sources/Subscription/API/SubscriptionRequest.swift b/Sources/Subscription/API/SubscriptionRequest.swift new file mode 100644 index 000000000..e46423280 --- /dev/null +++ b/Sources/Subscription/API/SubscriptionRequest.swift @@ -0,0 +1,88 @@ +// +// SubscriptionRequest.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 Networking +import Common + +struct SubscriptionRequest { + let apiRequest: APIRequestV2 + + // MARK: Get subscription + + static func getSubscription(baseURL: URL, accessToken: String) -> SubscriptionRequest? { + let path = "/subscription" + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + headers: APIRequestV2.HeadersV2(authToken: accessToken), + timeoutInterval: 20) else { + return nil + } + return SubscriptionRequest(apiRequest: request) + } + + static func getProducts(baseURL: URL) -> SubscriptionRequest? { + let path = "/products" + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .get) else { + return nil + } + return SubscriptionRequest(apiRequest: request) + } + + static func getCustomerPortalURL(baseURL: URL, accessToken: String, externalID: String) -> SubscriptionRequest? { + let path = "/checkout/portal" + let headers = [ + "externalAccountId": externalID + ] + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .get, + headers: APIRequestV2.HeadersV2(authToken: accessToken, + additionalHeaders: headers)) else { + return nil + } + return SubscriptionRequest(apiRequest: request) + } + + static func confirmPurchase(baseURL: URL, accessToken: String, signature: String, additionalParams: [String: String]?) -> SubscriptionRequest? { + let path = "/purchase/confirm/apple" + var bodyDict = ["signedTransactionInfo": signature] + + if let additionalParams { + bodyDict.merge(additionalParams) { (_, new) in new } + } + + guard let bodyData = CodableHelper.encode(bodyDict) else { return nil } + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + method: .post, + headers: APIRequestV2.HeadersV2(authToken: accessToken), + body: bodyData, + retryPolicy: APIRequestV2.RetryPolicy(maxRetries: 3, delay: 4.0)) else { + return nil + } + return SubscriptionRequest(apiRequest: request) + } + + static func subscriptionFeatures(baseURL: URL, subscriptionID: String) -> SubscriptionRequest? { + let path = "/products/\(subscriptionID)/features" + guard let request = APIRequestV2(url: baseURL.appendingPathComponent(path), + cachePolicy: .returnCacheDataElseLoad) else { // Cached on purpose, the response never changes + return nil + } + return SubscriptionRequest(apiRequest: request) + } +} diff --git a/Sources/Subscription/DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCache.swift b/Sources/Subscription/DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCache.swift new file mode 100644 index 000000000..edb3bb900 --- /dev/null +++ b/Sources/Subscription/DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCache.swift @@ -0,0 +1,34 @@ +// +// DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCacheV2.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 Networking +import os.log + +extension DefaultSubscriptionEndpointService: SubscriptionFeatureMappingCacheV2 { + + public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [Networking.SubscriptionEntitlement] { + do { + let response = try await getSubscriptionFeatures(for: subscriptionIdentifier) + return response.features + } catch { + Logger.subscription.error("Failed to get subscription features: \(error)") + return [.networkProtection, .dataBrokerProtection, .identityTheftRestoration] + } + } +} diff --git a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift index d8d056188..178d537a6 100644 --- a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift @@ -20,16 +20,16 @@ import Foundation import StoreKit import os.log -public enum AppStorePurchaseFlowError: Swift.Error { - case noProductsFound - case activeSubscriptionAlreadyPresent - case authenticatingWithTransactionFailed - case accountCreationFailed - case purchaseFailed - case cancelledByUser - case missingEntitlements - case internalError -} +//public enum AppStorePurchaseFlowErrorV1: Swift.Error { +// case noProductsFound +// case activeSubscriptionAlreadyPresent +// case authenticatingWithTransactionFailed +// case accountCreationFailed +// case purchaseFailed +// case cancelledByUser +// case missingEntitlements +// case internalError +//} @available(macOS 12.0, iOS 15.0, *) public protocol AppStorePurchaseFlow { @@ -68,7 +68,7 @@ public protocol AppStorePurchaseFlow { } @available(macOS 12.0, iOS 15.0, *) -public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow { +public final class DefaultAppStorePurchaseFlowV1: AppStorePurchaseFlow { private let subscriptionEndpointService: SubscriptionEndpointService private let storePurchaseManager: StorePurchaseManager private let accountManager: AccountManager @@ -121,7 +121,7 @@ public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow { } case .failure(let error): Logger.subscription.error("[AppStorePurchaseFlow] createAccount error: \(String(reflecting: error), privacy: .public)") - return .failure(.accountCreationFailed) + return .failure(.accountCreationFailed(error)) } } } @@ -138,7 +138,7 @@ public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow { case .purchaseCancelledByUser: return .failure(.cancelledByUser) default: - return .failure(.purchaseFailed) + return .failure(.purchaseFailed(error)) } } } diff --git a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlowV2.swift b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlowV2.swift new file mode 100644 index 000000000..71b3f4ad1 --- /dev/null +++ b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlowV2.swift @@ -0,0 +1,209 @@ +// +// AppStorePurchaseFlow.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 StoreKit +import os.log +import Networking + +public enum AppStorePurchaseFlowError: Swift.Error, Equatable, LocalizedError { + case noProductsFound + case activeSubscriptionAlreadyPresent + case authenticatingWithTransactionFailed + case accountCreationFailed(Swift.Error) + case purchaseFailed(Swift.Error) + case cancelledByUser + case missingEntitlements + case internalError(Swift.Error?) + + public var errorDescription: String? { + switch self { + case .noProductsFound: + "No products found" + case .activeSubscriptionAlreadyPresent: + "An active subscription is already present" + case .authenticatingWithTransactionFailed: + "Authenticating with transaction failed" + case .accountCreationFailed(let subError): + "Account creation failed: \(subError.localizedDescription)" + case .purchaseFailed(let subError): + "Purchase failed: \(subError.localizedDescription)" + case .cancelledByUser: + "Purchase cancelled by user" + case .missingEntitlements: + "Missing entitlements" + case .internalError(let error): + "Internal error: \(error?.localizedDescription ?? "" )" + } + } + + public static func == (lhs: AppStorePurchaseFlowError, rhs: AppStorePurchaseFlowError) -> Bool { + switch (lhs, rhs) { + case (.noProductsFound, .noProductsFound), + (.activeSubscriptionAlreadyPresent, .activeSubscriptionAlreadyPresent), + (.authenticatingWithTransactionFailed, .authenticatingWithTransactionFailed), + (.cancelledByUser, .cancelledByUser), + (.missingEntitlements, .missingEntitlements), + (.internalError, .internalError): + return true + case let (.accountCreationFailed(lhsError), .accountCreationFailed(rhsError)): + return lhsError.localizedDescription == rhsError.localizedDescription + case let (.purchaseFailed(lhsError), .purchaseFailed(rhsError)): + return lhsError.localizedDescription == rhsError.localizedDescription + default: + return false + } + } +} + +@available(macOS 12.0, iOS 15.0, *) +public protocol AppStorePurchaseFlowV2 { + typealias TransactionJWS = String + func purchaseSubscription(with subscriptionIdentifier: String) async -> Result + + /// Completes the subscription purchase by validating the transaction. + /// + /// - Parameters: + /// - transactionJWS: The JWS representation of the transaction to be validated. + /// - additionalParams: Optional additional parameters to send with the transaction validation request. + /// - Returns: A `Result` containing either a `PurchaseUpdate` object on success or an `AppStorePurchaseFlowError` on failure. + @discardableResult func completeSubscriptionPurchase(with transactionJWS: TransactionJWS, additionalParams: [String: String]?) async -> Result +} + +@available(macOS 12.0, iOS 15.0, *) +public final class DefaultAppStorePurchaseFlowV2: AppStorePurchaseFlowV2 { + private let subscriptionManager: any SubscriptionManagerV2 + private let storePurchaseManager: any StorePurchaseManagerV2 + private let appStoreRestoreFlow: any AppStoreRestoreFlowV2 + + public init(subscriptionManager: any SubscriptionManagerV2, + storePurchaseManager: any StorePurchaseManagerV2, + appStoreRestoreFlow: any AppStoreRestoreFlowV2 + ) { + self.subscriptionManager = subscriptionManager + self.storePurchaseManager = storePurchaseManager + self.appStoreRestoreFlow = appStoreRestoreFlow + } + + public func purchaseSubscription(with subscriptionIdentifier: String) async -> Result { + Logger.subscriptionAppStorePurchaseFlow.log("Purchasing Subscription") + + var externalID: String? + if let existingExternalID = await getExpiredSubscriptionID() { + Logger.subscriptionAppStorePurchaseFlow.log("External ID retrieved from expired subscription") + externalID = existingExternalID + } else { + Logger.subscriptionAppStorePurchaseFlow.log("Try to retrieve an expired Apple subscription or create a new one") + + // Try to restore an account from a past purchase + switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + Logger.subscriptionAppStorePurchaseFlow.log("An active subscription is already present") + return .failure(.activeSubscriptionAlreadyPresent) + case .failure(let error): + Logger.subscriptionAppStorePurchaseFlow.log("Failed to restore an account from a past purchase: \(error.localizedDescription, privacy: .public)") + do { + externalID = try await subscriptionManager.getTokenContainer(policy: .createIfNeeded).decodedAccessToken.externalID + } catch Networking.OAuthClientError.missingTokens { + Logger.subscriptionStripePurchaseFlow.error("Failed to create a new account: \(error.localizedDescription, privacy: .public)") + return .failure(.accountCreationFailed(error)) + } catch { + Logger.subscriptionStripePurchaseFlow.fault("Failed to create a new account: \(error.localizedDescription, privacy: .public), the operation is unrecoverable") + return .failure(.internalError(error)) + } + } + } + + guard let externalID else { + Logger.subscriptionAppStorePurchaseFlow.fault("Missing external ID, subscription purchase failed") + return .failure(.internalError(nil)) + } + + // Make the purchase + switch await storePurchaseManager.purchaseSubscription(with: subscriptionIdentifier, externalID: externalID) { + case .success(let transactionJWS): + return .success(transactionJWS) + case .failure(let error): + Logger.subscriptionAppStorePurchaseFlow.error("purchaseSubscription error: \(String(reflecting: error), privacy: .public)") + + await subscriptionManager.signOut(notifyUI: true) + + switch error { + case .purchaseCancelledByUser: + return .failure(.cancelledByUser) + default: + return .failure(.purchaseFailed(error)) + } + } + } + + @discardableResult + public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS, additionalParams: [String: String]?) async -> Result { + Logger.subscriptionAppStorePurchaseFlow.log("Completing Subscription Purchase") + subscriptionManager.clearSubscriptionCache() + + do { + let subscription = try await subscriptionManager.confirmPurchase(signature: transactionJWS, additionalParams: additionalParams) + let refreshedToken = try await subscriptionManager.getTokenContainer(policy: .localForceRefresh) // fetch new entitlements + if subscription.isActive { + if refreshedToken.decodedAccessToken.subscriptionEntitlements.isEmpty { + Logger.subscriptionAppStorePurchaseFlow.error("Missing entitlements") + return .failure(.missingEntitlements) + } else { + return .success(.completed) + } + } else { + Logger.subscriptionAppStorePurchaseFlow.error("Subscription expired") + return .failure(.purchaseFailed(AppStoreRestoreFlowErrorV2.subscriptionExpired)) + } + } catch { + Logger.subscriptionAppStorePurchaseFlow.error("Purchase Failed: \(error)") + return .failure(.purchaseFailed(error)) + } + } + + private func getExpiredSubscriptionID() async -> String? { + do { + let subscription = try await subscriptionManager.getSubscription(cachePolicy: .reloadIgnoringLocalCacheData) + // Only return an externalID if the subscription is expired so to prevent creating multiple subscriptions in the same account + if !subscription.isActive, + subscription.platform != .apple { + return try await subscriptionManager.getTokenContainer(policy: .localValid).decodedAccessToken.externalID + } + return nil + } catch { + Logger.subscription.error("Failed to retrieve the current subscription ID: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + public func recoverSubscriptionFromDeadToken() async throws { + Logger.subscriptionAppStorePurchaseFlow.log("Recovering Subscription From Dead Token") + + // Clear everything, the token is unrecoverable + await subscriptionManager.signOut(notifyUI: true) + + switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + Logger.subscriptionAppStorePurchaseFlow.log("Subscription recovered") + case .failure(let error): + Logger.subscriptionAppStorePurchaseFlow.fault("Failed to recover Apple subscription: \(error.localizedDescription, privacy: .public)") + throw error + } + } +} diff --git a/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift index 004b77f8f..94633ba0e 100644 --- a/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift @@ -1,5 +1,5 @@ // -// AppStoreRestoreFlow.swift +// AppStoreRestoreFlowV1.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // diff --git a/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlowV2.swift b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlowV2.swift new file mode 100644 index 000000000..533716d1e --- /dev/null +++ b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlowV2.swift @@ -0,0 +1,93 @@ +// +// AppStoreRestoreFlowV2.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 StoreKit +import os.log +import Networking + +public enum AppStoreRestoreFlowErrorV2: LocalizedError, Equatable { + case missingAccountOrTransactions + case pastTransactionAuthenticationError + case failedToObtainAccessToken + case failedToFetchAccountDetails + case failedToFetchSubscriptionDetails + case subscriptionExpired + + public var errorDescription: String? { + switch self { + case .missingAccountOrTransactions: + return "Missing account or transactions." + case .pastTransactionAuthenticationError: + return "Past transaction authentication error." + case .failedToObtainAccessToken: + return "Failed to obtain access token." + case .failedToFetchAccountDetails: + return "Failed to fetch account details." + case .failedToFetchSubscriptionDetails: + return "Failed to fetch subscription details." + case .subscriptionExpired: + return "Subscription expired." + } + } +} + +@available(macOS 12.0, iOS 15.0, *) +public protocol AppStoreRestoreFlowV2 { + @discardableResult func restoreAccountFromPastPurchase() async -> Result +} + +@available(macOS 12.0, iOS 15.0, *) +public final class DefaultAppStoreRestoreFlowV2: AppStoreRestoreFlowV2 { + private let subscriptionManager: any SubscriptionManagerV2 + private let storePurchaseManager: any StorePurchaseManagerV2 + + public init(subscriptionManager: any SubscriptionManagerV2, + storePurchaseManager: any StorePurchaseManagerV2) { + self.subscriptionManager = subscriptionManager + self.storePurchaseManager = storePurchaseManager + } + + @discardableResult + public func restoreAccountFromPastPurchase() async -> Result { + Logger.subscriptionAppStoreRestoreFlow.log("Restoring account from past purchase") + + // Clear subscription Cache + subscriptionManager.clearSubscriptionCache() + + guard let lastTransactionJWSRepresentation = await storePurchaseManager.mostRecentTransaction() else { + Logger.subscriptionAppStoreRestoreFlow.error("Missing last transaction") + return .failure(.missingAccountOrTransactions) + } + + do { + if let subscription = try await subscriptionManager.getSubscriptionFrom(lastTransactionJWSRepresentation: lastTransactionJWSRepresentation), + subscription.isActive { + return .success(lastTransactionJWSRepresentation) + } else { + Logger.subscriptionAppStoreRestoreFlow.error("Subscription expired") + // Removing all traces of the subscription and the account + await subscriptionManager.signOut(notifyUI: false) + return .failure(.subscriptionExpired) + } + } catch { + Logger.subscriptionAppStoreRestoreFlow.error("Error activating past transaction: \(error, privacy: .public)") + return .failure(.pastTransactionAuthenticationError) + } + } +} diff --git a/Sources/Subscription/Flows/Models/PurchaseUpdate.swift b/Sources/Subscription/Flows/Models/PurchaseUpdate.swift index 027fa5f7d..27f60fc80 100644 --- a/Sources/Subscription/Flows/Models/PurchaseUpdate.swift +++ b/Sources/Subscription/Flows/Models/PurchaseUpdate.swift @@ -18,7 +18,7 @@ import Foundation -public struct PurchaseUpdate: Codable { +public struct PurchaseUpdate: Codable, Equatable { let type: String let token: String? diff --git a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift index f73762fe0..ff9efebcf 100644 --- a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift +++ b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift @@ -20,13 +20,13 @@ import Foundation public struct SubscriptionOptions: Encodable, Equatable { let platform: SubscriptionPlatformName - let options: [SubscriptionOption] - let features: [SubscriptionFeature] + let options: [SubscriptionOptionV1] + let features: [SubscriptionFeatureV1] public static var empty: SubscriptionOptions { - let features = [SubscriptionFeature(name: .networkProtection), - SubscriptionFeature(name: .dataBrokerProtection), - SubscriptionFeature(name: .identityTheftRestoration)] + let features = [SubscriptionFeatureV1(name: .networkProtection), + SubscriptionFeatureV1(name: .dataBrokerProtection), + SubscriptionFeatureV1(name: .identityTheftRestoration)] let platform: SubscriptionPlatformName #if os(iOS) platform = .ios @@ -41,13 +41,13 @@ public struct SubscriptionOptions: Encodable, Equatable { } } -public enum SubscriptionPlatformName: String, Encodable { - case ios - case macos - case stripe -} +//public enum SubscriptionPlatformName: String, Encodable { +// case ios +// case macos +// case stripe +//} -public struct SubscriptionOption: Encodable, Equatable { +public struct SubscriptionOptionV1: Encodable, Equatable { let id: String let cost: SubscriptionOptionCost let offer: SubscriptionOptionOffer? @@ -59,24 +59,24 @@ public struct SubscriptionOption: Encodable, Equatable { } } -struct SubscriptionOptionCost: Encodable, Equatable { - let displayPrice: String - let recurrence: String -} +//struct SubscriptionOptionCost: Encodable, Equatable { +// let displayPrice: String +// let recurrence: String +//} -public struct SubscriptionFeature: Encodable, Equatable { +public struct SubscriptionFeatureV1: Encodable, Equatable { let name: Entitlement.ProductName } -/// A `SubscriptionOptionOffer` represents an offer (e.g Free Trials) associated with a Subscription -public struct SubscriptionOptionOffer: Encodable, Equatable { - - public enum OfferType: String, Codable, CaseIterable { - case freeTrial - } - - let type: OfferType - let id: String - let durationInDays: Int? - let isUserEligible: Bool -} +///// A `SubscriptionOptionOffer` represents an offer (e.g Free Trials) associated with a Subscription +//public struct SubscriptionOptionOffer: Encodable, Equatable { +// +// public enum OfferType: String, Codable, CaseIterable { +// case freeTrial +// } +// +// let type: OfferType +// let id: String +// let durationInDays: Int? +// let isUserEligible: Bool +//} diff --git a/Sources/Subscription/Flows/Models/SubscriptionOptionsV2.swift b/Sources/Subscription/Flows/Models/SubscriptionOptionsV2.swift new file mode 100644 index 000000000..96cf2574d --- /dev/null +++ b/Sources/Subscription/Flows/Models/SubscriptionOptionsV2.swift @@ -0,0 +1,92 @@ +// +// SubscriptionOptionsV2.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 Networking + +public struct SubscriptionOptionsV2: Encodable, Equatable { + struct Feature: Encodable, Equatable { + let name: SubscriptionEntitlement + } + + let platform: SubscriptionPlatformName + let options: [SubscriptionOptionV2] + /// The available features in the subscription based on the country and feature flags. Not based on user entitlements + let features: [SubscriptionOptionsV2.Feature] + + public init(platform: SubscriptionPlatformName, options: [SubscriptionOptionV2], availableEntitlements: [SubscriptionEntitlement]) { + self.platform = platform + self.options = options + self.features = availableEntitlements.map({ entitlement in + Feature(name: entitlement) + }) + } + + public static var empty: SubscriptionOptionsV2 { + let features: [SubscriptionEntitlement] = [.networkProtection, .dataBrokerProtection, .identityTheftRestoration] + let platform: SubscriptionPlatformName +#if os(iOS) + platform = .ios +#else + platform = .macos +#endif + return SubscriptionOptionsV2(platform: platform, options: [], availableEntitlements: features) + } + + public func withoutPurchaseOptions() -> Self { + SubscriptionOptionsV2(platform: platform, options: [], availableEntitlements: features.map({ feature in + feature.name + })) + } +} + +public enum SubscriptionPlatformName: String, Encodable { + case ios + case macos + case stripe +} + +public struct SubscriptionOptionV2: Encodable, Equatable { + let id: String + let cost: SubscriptionOptionCost + let offer: SubscriptionOptionOffer? + + init(id: String, cost: SubscriptionOptionCost, offer: SubscriptionOptionOffer? = nil) { + self.id = id + self.cost = cost + self.offer = offer + } +} + +struct SubscriptionOptionCost: Encodable, Equatable { + let displayPrice: String + let recurrence: String +} + +/// A `SubscriptionOptionOffer` represents an offer (e.g Free Trials) associated with a Subscription +public struct SubscriptionOptionOffer: Encodable, Equatable { + + public enum OfferType: String, Codable, CaseIterable { + case freeTrial + } + + let type: OfferType + let id: String + let durationInDays: Int? + let isUserEligible: Bool +} diff --git a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift b/Sources/Subscription/Flows/Stripe/StripePurchaseFlowV1.swift similarity index 86% rename from Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift rename to Sources/Subscription/Flows/Stripe/StripePurchaseFlowV1.swift index 43e0448e7..9233ab30c 100644 --- a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift +++ b/Sources/Subscription/Flows/Stripe/StripePurchaseFlowV1.swift @@ -1,5 +1,5 @@ // -// StripePurchaseFlow.swift +// StripePurchaseFlowV1.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -20,18 +20,18 @@ import Foundation import StoreKit import os.log -public enum StripePurchaseFlowError: Swift.Error { - case noProductsFound - case accountCreationFailed -} +//public enum StripePurchaseFlowError: Swift.Error { +// case noProductsFound +// case accountCreationFailed +//} -public protocol StripePurchaseFlow { +public protocol StripePurchaseFlowV1 { func subscriptionOptions() async -> Result func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result func completeSubscriptionPurchase() async } -public final class DefaultStripePurchaseFlow: StripePurchaseFlow { +public final class DefaultStripePurchaseFlowV1: StripePurchaseFlowV1 { private let subscriptionEndpointService: SubscriptionEndpointService private let authEndpointService: AuthEndpointService private let accountManager: AccountManager @@ -58,7 +58,7 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow { formatter.numberStyle = .currency formatter.locale = Locale(identifier: "en_US@currency=\(currency)") - let options: [SubscriptionOption] = products.map { + let options: [SubscriptionOptionV1] = products.map { var displayPrice = "\($0.price) \($0.currency)" if let price = Float($0.price), let formattedPrice = formatter.string(from: price as NSNumber) { @@ -67,17 +67,17 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow { let cost = SubscriptionOptionCost(displayPrice: displayPrice, recurrence: $0.billingPeriod.lowercased()) - return SubscriptionOption(id: $0.productId, + return SubscriptionOptionV1(id: $0.productId, cost: cost) } - let features = [SubscriptionFeature(name: .networkProtection), - SubscriptionFeature(name: .dataBrokerProtection), - SubscriptionFeature(name: .identityTheftRestoration)] + let features = [SubscriptionFeatureV1(name: .networkProtection), + SubscriptionFeatureV1(name: .dataBrokerProtection), + SubscriptionFeatureV1(name: .identityTheftRestoration)] return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe, - options: options, - features: features)) + options: options, + features: features)) } public func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result { diff --git a/Sources/Subscription/Flows/Stripe/StripePurchaseFlowV2.swift b/Sources/Subscription/Flows/Stripe/StripePurchaseFlowV2.swift new file mode 100644 index 000000000..9056281fd --- /dev/null +++ b/Sources/Subscription/Flows/Stripe/StripePurchaseFlowV2.swift @@ -0,0 +1,112 @@ +// +// StripePurchaseFlowV2.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 StoreKit +import os.log +import Networking + +public enum StripePurchaseFlowError: Swift.Error { + case noProductsFound + case accountCreationFailed +} + +public protocol StripePurchaseFlowV2 { + func subscriptionOptions() async -> Result + func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result + func completeSubscriptionPurchase() async +} + +public final class DefaultStripePurchaseFlow: StripePurchaseFlowV2 { + private let subscriptionManager: any SubscriptionManagerV2 + + public init(subscriptionManager: any SubscriptionManagerV2) { + self.subscriptionManager = subscriptionManager + } + + public func subscriptionOptions() async -> Result { + Logger.subscriptionStripePurchaseFlow.log("Getting subscription options for Stripe") + + guard let products = try? await subscriptionManager.getProducts(), + !products.isEmpty else { + Logger.subscriptionStripePurchaseFlow.error("Failed to obtain products") + return .failure(.noProductsFound) + } + + let currency = products.first?.currency ?? "USD" + + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.locale = Locale(identifier: "en_US@currency=\(currency)") + + let options: [SubscriptionOptionV2] = products.map { + var displayPrice = "\($0.price) \($0.currency)" + + if let price = Float($0.price), let formattedPrice = formatter.string(from: price as NSNumber) { + displayPrice = formattedPrice + } + let cost = SubscriptionOptionCost(displayPrice: displayPrice, recurrence: $0.billingPeriod.lowercased()) + return SubscriptionOptionV2(id: $0.productId, cost: cost) + } + + let features: [SubscriptionEntitlement] = [.networkProtection, + .dataBrokerProtection, + .identityTheftRestoration] + return .success(SubscriptionOptionsV2(platform: SubscriptionPlatformName.stripe, + options: options, + availableEntitlements: features)) + } + + public func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result { + Logger.subscription.log("Preparing subscription purchase") + + subscriptionManager.clearSubscriptionCache() + + if subscriptionManager.isUserAuthenticated { + if let subscriptionExpired = await isSubscriptionExpired(), + subscriptionExpired == true, + let tokenContainer = try? await subscriptionManager.getTokenContainer(policy: .localValid) { + return .success(PurchaseUpdate.redirect(withToken: tokenContainer.accessToken)) + } else { + return .success(PurchaseUpdate.redirect(withToken: "")) + } + } else { + do { + // Create account + let tokenContainer = try await subscriptionManager.getTokenContainer(policy: .createIfNeeded) + return .success(PurchaseUpdate.redirect(withToken: tokenContainer.accessToken)) + } catch { + Logger.subscriptionStripePurchaseFlow.error("Account creation failed: \(error.localizedDescription, privacy: .public)") + return .failure(.accountCreationFailed) + } + } + } + + private func isSubscriptionExpired() async -> Bool? { + guard let subscription = try? await subscriptionManager.getSubscription(cachePolicy: .reloadIgnoringLocalCacheData) else { + return nil + } + return !subscription.isActive + } + + public func completeSubscriptionPurchase() async { + Logger.subscriptionStripePurchaseFlow.log("Completing subscription purchase") + subscriptionManager.clearSubscriptionCache() + _ = try? await subscriptionManager.getTokenContainer(policy: .localForceRefresh) + } +} diff --git a/Sources/Subscription/Logger+Subscription.swift b/Sources/Subscription/Logger+Subscription.swift index a09bd370d..0242b2a30 100644 --- a/Sources/Subscription/Logger+Subscription.swift +++ b/Sources/Subscription/Logger+Subscription.swift @@ -20,5 +20,13 @@ import Foundation import os.log public extension Logger { - static var subscription = { Logger(subsystem: "Subscription", category: "") }() + private static var subscriptionSubsystem = "Subscription" + static var subscription = { Logger(subsystem: Self.subscriptionSubsystem, category: "") }() + static var subscriptionAppStorePurchaseFlow = { Logger(subsystem: Self.subscriptionSubsystem, category: "AppStorePurchaseFlow") }() + static var subscriptionAppStoreRestoreFlow = { Logger(subsystem: Self.subscriptionSubsystem, category: "AppStoreRestoreFlow") }() + static var subscriptionStripePurchaseFlow = { Logger(subsystem: Self.subscriptionSubsystem, category: "StripePurchaseFlow") }() + static var subscriptionEndpointService = { Logger(subsystem: Self.subscriptionSubsystem, category: "EndpointService") }() + static var subscriptionStorePurchaseManager = { Logger(subsystem: Self.subscriptionSubsystem, category: "StorePurchaseManager") }() + static var subscriptionKeychain = { Logger(subsystem: Self.subscriptionSubsystem, category: "KeyChain") }() + static var subscriptionCookieManager = { Logger(subsystem: Self.subscriptionSubsystem, category: "CookieManager") }() } diff --git a/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift index f6aea6f14..61b13eeab 100644 --- a/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift +++ b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift @@ -20,19 +20,19 @@ import Foundation import StoreKit import os.log -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 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 @@ -61,10 +61,10 @@ public protocol StorePurchaseManager { @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 Transaction = StoreKit.Transaction @available(macOS 12.0, iOS 15.0, *) -public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseManager { +public final class DefaultStorePurchaseManagerV1: ObservableObject, StorePurchaseManager { private let storeSubscriptionConfiguration: StoreSubscriptionConfiguration private let subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache @@ -300,10 +300,10 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM #endif }() - let options: [SubscriptionOption] = await [.init(from: monthly, withRecurrence: "monthly"), + let options: [SubscriptionOptionV1] = await [.init(from: monthly, withRecurrence: "monthly"), .init(from: yearly, withRecurrence: "yearly")] - let features: [SubscriptionFeature] = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeature(name: $0) } + let features: [SubscriptionFeatureV1] = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeatureV1(name: $0) } return SubscriptionOptions(platform: platform, options: options, @@ -350,7 +350,7 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM } @available(macOS 12.0, iOS 15.0, *) -private extension SubscriptionOption { +private extension SubscriptionOptionV1 { init(from product: any SubscriptionProduct, withRecurrence recurrence: String) async { var offer: SubscriptionOptionOffer? @@ -367,35 +367,35 @@ private extension SubscriptionOption { } } -public extension UserDefaults { - - enum Constants { - static let storefrontRegionOverrideKey = "Subscription.debug.storefrontRegionOverride" - static let usaValue = "usa" - static let rowValue = "row" - } - - dynamic var storefrontRegionOverride: SubscriptionRegion? { - get { - switch string(forKey: Constants.storefrontRegionOverrideKey) { - case "usa": - return .usa - case "row": - return .restOfWorld - default: - return nil - } - } - - set { - switch newValue { - case .usa: - set(Constants.usaValue, forKey: Constants.storefrontRegionOverrideKey) - case .restOfWorld: - set(Constants.rowValue, forKey: Constants.storefrontRegionOverrideKey) - default: - removeObject(forKey: Constants.storefrontRegionOverrideKey) - } - } - } -} +//public extension UserDefaults { +// +// enum Constants { +// static let storefrontRegionOverrideKey = "Subscription.debug.storefrontRegionOverride" +// static let usaValue = "usa" +// static let rowValue = "row" +// } +// +// dynamic var storefrontRegionOverride: SubscriptionRegion? { +// get { +// switch string(forKey: Constants.storefrontRegionOverrideKey) { +// case "usa": +// return .usa +// case "row": +// return .restOfWorld +// default: +// return nil +// } +// } +// +// set { +// switch newValue { +// case .usa: +// set(Constants.usaValue, forKey: Constants.storefrontRegionOverrideKey) +// case .restOfWorld: +// set(Constants.rowValue, forKey: Constants.storefrontRegionOverrideKey) +// default: +// removeObject(forKey: Constants.storefrontRegionOverrideKey) +// } +// } +// } +//} diff --git a/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManagerV2.swift b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManagerV2.swift new file mode 100644 index 000000000..af36f2046 --- /dev/null +++ b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManagerV2.swift @@ -0,0 +1,392 @@ +// +// StorePurchaseManagerV2.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 StoreKit +import os.log +import Networking + +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 StorePurchaseManagerV2 { + typealias TransactionJWS = String + + /// Returns the available subscription options that DON'T include Free Trial periods. + /// - Returns: A `SubscriptionOptions` object containing the available subscription plans and pricing, + /// or `nil` if no options are available or cannot be fetched. + func subscriptionOptions() async -> SubscriptionOptionsV2? + + /// Returns the subscription options that include Free Trial periods. + /// - Returns: A `SubscriptionOptions` object containing subscription plans with free trial offers, + /// or `nil` if no free trial options are available or the user is not eligible. + func freeTrialSubscriptionOptions() async -> SubscriptionOptionsV2? + + var purchasedProductIDs: [String] { get } + var purchaseQueue: [String] { get } + var areProductsAvailable: Bool { get } + var currentStorefrontRegion: SubscriptionRegion { 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, *) +public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseManagerV2 { + + private let storeSubscriptionConfiguration: any StoreSubscriptionConfiguration + private let subscriptionFeatureMappingCache: any SubscriptionFeatureMappingCacheV2 + private let subscriptionFeatureFlagger: FeatureFlaggerMapping? + + @Published public private(set) var availableProducts: [any SubscriptionProduct] = [] + @Published public private(set) var purchasedProductIDs: [String] = [] + @Published public private(set) var purchaseQueue: [String] = [] + + public var areProductsAvailable: Bool { !availableProducts.isEmpty } + public private(set) var currentStorefrontRegion: SubscriptionRegion = .usa + private var transactionUpdates: Task? + private var storefrontChanges: Task? + private var productFetcher: ProductFetching + + public init(subscriptionFeatureMappingCache: any SubscriptionFeatureMappingCacheV2, + subscriptionFeatureFlagger: FeatureFlaggerMapping? = nil, + productFetcher: ProductFetching = DefaultProductFetcher()) { + self.storeSubscriptionConfiguration = DefaultStoreSubscriptionConfiguration() + self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache + self.subscriptionFeatureFlagger = subscriptionFeatureFlagger + self.productFetcher = productFetcher + transactionUpdates = observeTransactionUpdates() + storefrontChanges = observeStorefrontChanges() + } + + deinit { + transactionUpdates?.cancel() + storefrontChanges?.cancel() + } + + @MainActor + public func syncAppleIDAccount() async throws { + do { + purchaseQueue.removeAll() + + Logger.subscriptionStorePurchaseManager.log("Before AppStore.sync()") + + try await AppStore.sync() + + Logger.subscriptionStorePurchaseManager.log("After AppStore.sync()") + + await updatePurchasedProducts() + await updateAvailableProducts() + } catch { + Logger.subscriptionStorePurchaseManager.error("[StorePurchaseManager] Error: \(String(reflecting: error), privacy: .public) (\(error.localizedDescription, privacy: .public))") + throw error + } + } + + public func subscriptionOptions() async -> SubscriptionOptionsV2? { + let nonFreeTrialProducts = availableProducts.filter { !$0.isFreeTrialProduct } + let ids = nonFreeTrialProducts.map(\.self.id) + Logger.subscription.debug("[StorePurchaseManager] Returning SubscriptionOptions for products: \(ids)") + return await subscriptionOptions(for: nonFreeTrialProducts) + } + + public func freeTrialSubscriptionOptions() async -> SubscriptionOptionsV2? { + let freeTrialProducts = availableProducts.filter { $0.isFreeTrialProduct } + let ids = freeTrialProducts.map(\.self.id) + Logger.subscription.debug("[StorePurchaseManager] Returning Free Trial SubscriptionOptions for products: \(ids)") + return await subscriptionOptions(for: freeTrialProducts) + } + + @MainActor + public func updateAvailableProducts() async { + Logger.subscriptionStorePurchaseManager.log("Update available products") + + do { + let storefrontCountryCode: String? + let storefrontRegion: SubscriptionRegion + + if let subscriptionFeatureFlagger, subscriptionFeatureFlagger.isFeatureOn(.usePrivacyProUSARegionOverride) { + storefrontCountryCode = "USA" + } else if let subscriptionFeatureFlagger, subscriptionFeatureFlagger.isFeatureOn(.usePrivacyProROWRegionOverride) { + storefrontCountryCode = "POL" + } else { + storefrontCountryCode = await Storefront.current?.countryCode + } + + storefrontRegion = SubscriptionRegion.matchingRegion(for: storefrontCountryCode ?? "USA") ?? .usa // Fallback to USA + + self.currentStorefrontRegion = storefrontRegion + let applicableProductIdentifiers = storeSubscriptionConfiguration.subscriptionIdentifiers(for: storefrontRegion) + let availableProducts = try await productFetcher.products(for: applicableProductIdentifiers) + Logger.subscription.info("[StorePurchaseManager] updateAvailableProducts fetched \(availableProducts.count) products for \(storefrontCountryCode ?? "", privacy: .public)") + + if Set(availableProducts.map { $0.id }) != Set(self.availableProducts.map { $0.id }) { + self.availableProducts = availableProducts + + // Update cached subscription features mapping + for id in availableProducts.compactMap({ $0.id }) { + _ = await subscriptionFeatureMappingCache.subscriptionFeatures(for: id) + } + } + } catch { + Logger.subscriptionStorePurchaseManager.error("Failed to fetch available products: \(String(reflecting: error), privacy: .public)") + } + } + + @MainActor + public func updatePurchasedProducts() async { + Logger.subscriptionStorePurchaseManager.log("Update purchased products") + + var purchasedSubscriptions: [String] = [] + + do { + for await result in Transaction.currentEntitlements { + let transaction = try checkVerified(result) + + guard transaction.productType == .autoRenewable else { continue } + guard transaction.revocationDate == nil else { continue } + + if let expirationDate = transaction.expirationDate, expirationDate > .now { + purchasedSubscriptions.append(transaction.productID) + } + } + } catch { + Logger.subscriptionStorePurchaseManager.error("Failed to update purchased products: \(String(reflecting: error), privacy: .public)") + } + + Logger.subscriptionStorePurchaseManager.log("UpdatePurchasedProducts fetched \(purchasedSubscriptions.count) active subscriptions") + + if self.purchasedProductIDs != purchasedSubscriptions { + self.purchasedProductIDs = purchasedSubscriptions + } + } + + @MainActor + public func mostRecentTransaction() async -> String? { + Logger.subscriptionStorePurchaseManager.log("Retrieving most recent transaction") + + var transactions: [VerificationResult] = [] + for await result in Transaction.all { + transactions.append(result) + } + let lastTransaction = transactions.first + Logger.subscriptionStorePurchaseManager.log("Most recent transaction fetched: \(lastTransaction?.debugDescription ?? "?") (tot: \(transactions.count) transactions)") + return transactions.first?.jwsRepresentation + } + + @MainActor + public func hasActiveSubscription() async -> Bool { + var transactions: [VerificationResult] = [] + for await result in Transaction.currentEntitlements { + transactions.append(result) + } + Logger.subscriptionStorePurchaseManager.log("hasActiveSubscription fetched \(transactions.count) transactions") + return !transactions.isEmpty + } + + @MainActor + public func purchaseSubscription(with identifier: String, externalID: String) async -> Result { + + guard let product = availableProducts.first(where: { $0.id == identifier }) else { return .failure(StorePurchaseManagerError.productNotFound) } + + Logger.subscriptionStorePurchaseManager.log("Purchasing Subscription: \(product.displayName, privacy: .public) (\(externalID, privacy: .public))") + + purchaseQueue.append(product.id) + + var options: Set = Set() + + if let token = UUID(uuidString: externalID) { + options.insert(.appAccountToken(token)) + } else { + Logger.subscriptionStorePurchaseManager.error("Failed to create UUID from \(externalID, privacy: .public)") + return .failure(StorePurchaseManagerError.externalIDisNotAValidUUID) + } + + let purchaseResult: Product.PurchaseResult + do { + purchaseResult = try await product.purchase(options: options) + } catch { + Logger.subscriptionStorePurchaseManager.error("Error: \(String(reflecting: error), privacy: .public)") + return .failure(StorePurchaseManagerError.purchaseFailed) + } + + Logger.subscriptionStorePurchaseManager.log("PurchaseSubscription complete") + + purchaseQueue.removeAll() + + switch purchaseResult { + case let .success(verificationResult): + switch verificationResult { + case let .verified(transaction): + Logger.subscriptionStorePurchaseManager.log("PurchaseSubscription result: success") + // Successful purchase + await transaction.finish() + await self.updatePurchasedProducts() + return .success(verificationResult.jwsRepresentation) + case let .unverified(_, error): + Logger.subscriptionStorePurchaseManager.log("purchaseSubscription result: success /unverified/ - \(String(reflecting: error), privacy: .public)") + // Successful purchase but transaction/receipt can't be verified + // Could be a jailbroken phone + return .failure(StorePurchaseManagerError.transactionCannotBeVerified) + } + case .pending: + Logger.subscriptionStorePurchaseManager.log("purchaseSubscription result: pending") + // Transaction waiting on SCA (Strong Customer Authentication) or + // approval from Ask to Buy + return .failure(StorePurchaseManagerError.transactionPendingAuthentication) + case .userCancelled: + Logger.subscriptionStorePurchaseManager.log("purchaseSubscription result: user cancelled") + return .failure(StorePurchaseManagerError.purchaseCancelledByUser) + @unknown default: + Logger.subscriptionStorePurchaseManager.log("purchaseSubscription result: unknown") + return .failure(StorePurchaseManagerError.unknownError) + } + } + + private func subscriptionOptions(for products: [any SubscriptionProduct]) async -> SubscriptionOptionsV2? { + Logger.subscription.info("[AppStorePurchaseFlow] subscriptionOptions") + let monthly = products.first(where: { $0.isMonthly }) + let yearly = products.first(where: { $0.isYearly }) + guard let monthly, let yearly else { + Logger.subscription.error("[AppStorePurchaseFlow] No products found") + return nil + } + + let platform: SubscriptionPlatformName = { +#if os(iOS) + .ios +#else + .macos +#endif + }() + + let options: [SubscriptionOptionV2] = await [.init(from: monthly, withRecurrence: "monthly"), + .init(from: yearly, withRecurrence: "yearly")] + let features: [SubscriptionEntitlement] = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id) + return SubscriptionOptionsV2(platform: platform, + options: options, + availableEntitlements: features) + } + + private func checkVerified(_ result: VerificationResult) throws -> T { + // Check whether the JWS passes StoreKit verification. + switch result { + case .unverified: + // StoreKit parses the JWS, but it fails verification. + throw StoreError.failedVerification + case .verified(let safe): + // The result is verified. Return the unwrapped value. + return safe + } + } + + private func observeTransactionUpdates() -> Task { + + Task.detached { [weak self] in + for await result in Transaction.updates { + Logger.subscriptionStorePurchaseManager.log("observeTransactionUpdates") + + if case .verified(let transaction) = result { + await transaction.finish() + } + + await self?.updatePurchasedProducts() + } + } + } + + private func observeStorefrontChanges() -> Task { + + Task.detached { [weak self] in + for await result in Storefront.updates { + Logger.subscriptionStorePurchaseManager.log("observeStorefrontChanges: \(result.countryCode)") + await self?.updatePurchasedProducts() + await self?.updateAvailableProducts() + } + } + } +} + +@available(macOS 12.0, iOS 15.0, *) +private extension SubscriptionOptionV2 { + + init(from product: any SubscriptionProduct, withRecurrence recurrence: String) async { + var offer: SubscriptionOptionOffer? + + if let introOffer = product.introductoryOffer, introOffer.isFreeTrial { + + let durationInDays = introOffer.periodInDays + let isUserEligible = await product.isEligibleForIntroOffer + + offer = .init(type: .freeTrial, id: introOffer.id ?? "", durationInDays: durationInDays, isUserEligible: isUserEligible) + } + + self.init(id: product.id, cost: .init(displayPrice: product.displayPrice, recurrence: recurrence), offer: offer) + } +} + +public extension UserDefaults { + + enum Constants { + static let storefrontRegionOverrideKey = "Subscription.debug.storefrontRegionOverride" + static let usaValue = "usa" + static let rowValue = "row" + } + + dynamic var storefrontRegionOverride: SubscriptionRegion? { + get { + switch string(forKey: Constants.storefrontRegionOverrideKey) { + case "usa": + return .usa + case "row": + return .restOfWorld + default: + return nil + } + } + + set { + switch newValue { + case .usa: + set(Constants.usaValue, forKey: Constants.storefrontRegionOverrideKey) + case .restOfWorld: + set(Constants.rowValue, forKey: Constants.storefrontRegionOverrideKey) + default: + removeObject(forKey: Constants.storefrontRegionOverrideKey) + } + } + } +} diff --git a/Sources/Subscription/Managers/SubscriptionManager.swift b/Sources/Subscription/Managers/SubscriptionManager.swift index fc297ba38..3ad8f4032 100644 --- a/Sources/Subscription/Managers/SubscriptionManager.swift +++ b/Sources/Subscription/Managers/SubscriptionManager.swift @@ -40,7 +40,7 @@ public protocol SubscriptionManager { } /// 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. -public final class DefaultSubscriptionManager: SubscriptionManager { +public final class DefaultSubscriptionManagerV1: SubscriptionManager { private let _storePurchaseManager: StorePurchaseManager? public let accountManager: AccountManager public let subscriptionEndpointService: SubscriptionEndpointService diff --git a/Sources/Subscription/Managers/SubscriptionManagerV2.swift b/Sources/Subscription/Managers/SubscriptionManagerV2.swift new file mode 100644 index 000000000..7980e757f --- /dev/null +++ b/Sources/Subscription/Managers/SubscriptionManagerV2.swift @@ -0,0 +1,423 @@ +// +// SubscriptionManagerV2.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 Common +import os.log +import Networking + +public enum SubscriptionManagerError: Error, Equatable { + case tokenUnavailable(error: Error?) + case confirmationHasInvalidSubscription + case noProductsFound + + public static func == (lhs: SubscriptionManagerError, rhs: SubscriptionManagerError) -> Bool { + switch (lhs, rhs) { + case (.tokenUnavailable(let lhsError), .tokenUnavailable(let rhsError)): + return lhsError?.localizedDescription == rhsError?.localizedDescription + case (.confirmationHasInvalidSubscription, .confirmationHasInvalidSubscription), + (.noProductsFound, .noProductsFound): + return true + default: + return false + } + } +} + +public enum SubscriptionPixelType { + case deadToken + case v1MigrationSuccessful + case v1MigrationFailed + case subscriptionIsActive +} + +public protocol SubscriptionManagerV2: SubscriptionTokenProvider { + + // Environment + static func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment? + static func save(subscriptionEnvironment: SubscriptionEnvironment, userDefaults: UserDefaults) + var currentEnvironment: SubscriptionEnvironment { get } + + /// Tries to get an authentication token and request the subscription + func loadInitialData() async + + // Subscription + @discardableResult func getSubscription(cachePolicy: SubscriptionCachePolicy) async throws -> PrivacyProSubscription + + /// Tries to activate a subscription using a platform signature + /// - Parameter lastTransactionJWSRepresentation: A platform signature coming from the AppStore + /// - Returns: A subscription if found + /// - Throws: An error if the access token is not available or something goes wrong in the api requests + func getSubscriptionFrom(lastTransactionJWSRepresentation: String) async throws -> PrivacyProSubscription? + + var canPurchase: Bool { get } + func getProducts() async throws -> [GetProductsItem] + + @available(macOS 12.0, iOS 15.0, *) func storePurchaseManager() -> StorePurchaseManagerV2 + func url(for type: SubscriptionURL) -> URL + + func getCustomerPortalURL() async throws -> URL + + // User + var isUserAuthenticated: Bool { get } + var userEmail: String? { get } + + /// Sign out the user and clear all the tokens and subscription cache + func signOut(notifyUI: Bool) async + + func clearSubscriptionCache() + + /// Confirm a purchase with a platform signature + func confirmPurchase(signature: String, additionalParams: [String: String]?) async throws -> PrivacyProSubscription + + /// Pixels handler + typealias PixelHandler = (SubscriptionPixelType) -> Void + + // MARK: - Features + + /// Get the current subscription features + /// A feature is based on an entitlement and can be enabled or disabled + /// A user cant have an entitlement without the feature, if a user is missing an entitlement the feature is disabled + func currentSubscriptionFeatures(forceRefresh: Bool) async -> [SubscriptionFeature] + + /// True if the feature can be used by the user, false otherwise + func isFeatureAvailableForUser(_ entitlement: SubscriptionEntitlement) async -> Bool + + // MARK: - Token Management + + /// Get a token container accordingly to the policy + /// - Parameter policy: The policy that will be used to get the token, it effects the tokens source and validity + /// - Returns: The TokenContainer + /// - Throws: OAuthClientError.deadToken if the token is unrecoverable. SubscriptionEndpointServiceError.noData if the token is not available. + @discardableResult + func getTokenContainer(policy: AuthTokensCachePolicy) async throws -> TokenContainer + + /// Exchange access token v1 for a access token v2 + /// - Parameter tokenV1: The Auth v1 access token + /// - Returns: An auth v2 TokenContainer + func exchange(tokenV1: String) async throws -> TokenContainer + + /// Used only from the Mac Packet Tunnel Provider when a token is received during configuration + func adopt(tokenContainer: TokenContainer) + + /// Remove the stored token container + func removeTokenContainer() +} + +/// 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. +public final class DefaultSubscriptionManager: SubscriptionManagerV2 { + + var oAuthClient: any OAuthClient + private let _storePurchaseManager: StorePurchaseManagerV2? + private let subscriptionEndpointService: SubscriptionEndpointServiceV2 + private let pixelHandler: PixelHandler + public let currentEnvironment: SubscriptionEnvironment + + public init(storePurchaseManager: StorePurchaseManagerV2? = nil, + oAuthClient: any OAuthClient, + subscriptionEndpointService: SubscriptionEndpointServiceV2, + subscriptionEnvironment: SubscriptionEnvironment, + pixelHandler: @escaping PixelHandler) { + self._storePurchaseManager = storePurchaseManager + self.oAuthClient = oAuthClient + self.subscriptionEndpointService = subscriptionEndpointService + self.currentEnvironment = subscriptionEnvironment + self.pixelHandler = pixelHandler + +#if !NETP_SYSTEM_EXTENSION + switch currentEnvironment.purchasePlatform { + case .appStore: + if #available(macOS 12.0, iOS 15.0, *) { + setupForAppStore() + } else { + assertionFailure("Trying to setup AppStore where not supported") + } + case .stripe: + break + } +#endif + } + + public var canPurchase: Bool { + guard let storePurchaseManager = _storePurchaseManager else { return false } + return storePurchaseManager.areProductsAvailable + } + + @available(macOS 12.0, iOS 15.0, *) + public func storePurchaseManager() -> StorePurchaseManagerV2 { + return _storePurchaseManager! + } + + // MARK: Load and Save SubscriptionEnvironment + + static private let subscriptionEnvironmentStorageKey = "com.duckduckgo.subscription.environment" + static public func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment? { + if let savedData = userDefaults.object(forKey: Self.subscriptionEnvironmentStorageKey) as? Data { + let decoder = JSONDecoder() + if let loadedData = try? decoder.decode(SubscriptionEnvironment.self, from: savedData) { + return loadedData + } + } + return nil + } + + static public func save(subscriptionEnvironment: SubscriptionEnvironment, userDefaults: UserDefaults) { + let encoder = JSONEncoder() + if let encodedData = try? encoder.encode(subscriptionEnvironment) { + userDefaults.set(encodedData, forKey: Self.subscriptionEnvironmentStorageKey) + } + } + + // MARK: - Environment + + @available(macOS 12.0, iOS 15.0, *) private func setupForAppStore() { + Task { + await storePurchaseManager().updateAvailableProducts() + } + } + + // MARK: - Subscription + + public func loadInitialData() async { + + // Attempting V1 token migration + // IMPORTANT: This MUST be the first operation executed by Subscription + do { + if (try await oAuthClient.migrateV1Token()) != nil { + pixelHandler(.v1MigrationSuccessful) + + // cleaning up old data + clearSubscriptionCache() + } + } catch { + Logger.subscription.error("Failed to migrate V1 token: \(error, privacy: .public)") + pixelHandler(.v1MigrationFailed) + } + + // Fetching fresh subscription + if isUserAuthenticated { + do { + let subscription = try await getSubscription(cachePolicy: .reloadIgnoringLocalCacheData) + Logger.subscription.log("Subscription is \(subscription.isActive ? "active" : "not active", privacy: .public)") + if subscription.isActive { + pixelHandler(.subscriptionIsActive) + } + } catch { + Logger.subscription.error("Failed to load initial subscription data: \(error, privacy: .public)") + } + } + } + + @discardableResult + public func getSubscription(cachePolicy: SubscriptionCachePolicy) async throws -> PrivacyProSubscription { + guard isUserAuthenticated else { + throw SubscriptionEndpointServiceError.noData + } + + do { + let tokenContainer = try await getTokenContainer(policy: .localValid) + return try await subscriptionEndpointService.getSubscription(accessToken: tokenContainer.accessToken, cachePolicy: cachePolicy) + } catch SubscriptionEndpointServiceError.noData { + throw SubscriptionEndpointServiceError.noData + } catch { + Logger.networking.error("Error getting subscription: \(error, privacy: .public)") + throw SubscriptionEndpointServiceError.noData + } + } + + public func getSubscriptionFrom(lastTransactionJWSRepresentation: String) async throws -> PrivacyProSubscription? { + do { + let tokenContainer = try await oAuthClient.activate(withPlatformSignature: lastTransactionJWSRepresentation) + return try await subscriptionEndpointService.getSubscription(accessToken: tokenContainer.accessToken, cachePolicy: .reloadIgnoringLocalCacheData) + } catch SubscriptionEndpointServiceError.noData { + return nil + } catch { + throw error + } + } + + public func getProducts() async throws -> [GetProductsItem] { + try await subscriptionEndpointService.getProducts() + } + + public func clearSubscriptionCache() { + subscriptionEndpointService.clearSubscription() + } + + // MARK: - URLs + + public func url(for type: SubscriptionURL) -> URL { + type.subscriptionURL(environment: currentEnvironment.serviceEnvironment) + } + + public func getCustomerPortalURL() async throws -> URL { + guard isUserAuthenticated else { + throw SubscriptionEndpointServiceError.noData + } + + let tokenContainer = try await getTokenContainer(policy: .localValid) + // Get Stripe Customer Portal URL and update the model + let serviceResponse = try await subscriptionEndpointService.getCustomerPortalURL(accessToken: tokenContainer.accessToken, externalID: tokenContainer.decodedAccessToken.externalID) + guard let url = URL(string: serviceResponse.customerPortalUrl) else { + throw SubscriptionEndpointServiceError.noData + } + return url + } + + // MARK: - User + public var isUserAuthenticated: Bool { + oAuthClient.isUserAuthenticated + } + + public var userEmail: String? { + return oAuthClient.currentTokenContainer?.decodedAccessToken.email + } + + // MARK: - + + @discardableResult public func getTokenContainer(policy: AuthTokensCachePolicy) async throws -> TokenContainer { + do { + Logger.subscription.debug("Get tokens \(policy.description, privacy: .public)") + + let referenceCachedTokenContainer = try? await oAuthClient.getTokens(policy: .local) // the currently stored one + let referenceCachedEntitlements = referenceCachedTokenContainer?.decodedAccessToken.subscriptionEntitlements + let resultTokenContainer = try await oAuthClient.getTokens(policy: policy) + let newEntitlements = resultTokenContainer.decodedAccessToken.subscriptionEntitlements + + // Send notification when entitlements change + if referenceCachedEntitlements != newEntitlements { + Logger.subscription.debug("Entitlements changed - New \(newEntitlements) Old \(String(describing: referenceCachedEntitlements))") + NotificationCenter.default.post(name: .entitlementsDidChange, object: self, userInfo: [UserDefaultsCacheKey.subscriptionEntitlements: newEntitlements]) + } + + if referenceCachedTokenContainer == nil { // new login + Logger.subscription.debug("New login detected") + NotificationCenter.default.post(name: .accountDidSignIn, object: self, userInfo: nil) + } + return resultTokenContainer + } catch OAuthClientError.refreshTokenExpired { + return try await throwAppropriateDeadTokenError() + } catch { + throw SubscriptionManagerError.tokenUnavailable(error: error) + } + } + + /// If the client succeeds in making a refresh request but does not get the response, then the second refresh request will fail with `invalidTokenRequest` and the stored token will become unusable and un-refreshable. + private func throwAppropriateDeadTokenError() async throws -> TokenContainer { + Logger.subscription.fault("Dead token detected") + do { + let subscription = try await subscriptionEndpointService.getSubscription(accessToken: "", // Token is unused + cachePolicy: .returnCacheDataDontLoad) + switch subscription.platform { + case .apple: + pixelHandler(.deadToken) + throw OAuthClientError.refreshTokenExpired + default: + throw SubscriptionManagerError.tokenUnavailable(error: nil) + } + } catch { + throw SubscriptionManagerError.tokenUnavailable(error: error) + } + } + + public func exchange(tokenV1: String) async throws -> TokenContainer { + let tokenContainer = try await oAuthClient.exchange(accessTokenV1: tokenV1) + NotificationCenter.default.post(name: .accountDidSignIn, object: self, userInfo: nil) + return tokenContainer + } + + public func adopt(tokenContainer: TokenContainer) { + oAuthClient.adopt(tokenContainer: tokenContainer) + } + + public func removeTokenContainer() { + oAuthClient.removeLocalAccount() + } + + public func signOut(notifyUI: Bool) async { + Logger.subscription.log("SignOut: Removing all traces of the subscription and auth tokens") + try? await oAuthClient.logout() + clearSubscriptionCache() + if notifyUI { + Logger.subscription.debug("SignOut: Notifying the UI") + NotificationCenter.default.post(name: .accountDidSignOut, object: self, userInfo: nil) + } + } + + public func confirmPurchase(signature: String, additionalParams: [String: String]?) async throws -> PrivacyProSubscription { + Logger.subscription.log("Confirming Purchase...") + let accessToken = try await getTokenContainer(policy: .localValid).accessToken + let confirmation = try await subscriptionEndpointService.confirmPurchase(accessToken: accessToken, + signature: signature, + additionalParams: additionalParams) + try await subscriptionEndpointService.ingestSubscription(confirmation.subscription) + Logger.subscription.log("Purchase confirmed!") + return confirmation.subscription + } + + // MARK: - Features + + /// Returns the features available for the current subscription, a feature is enabled only if the user has the corresponding entitlement + /// - Parameter forceRefresh: ignore subscription and token cache and re-download everything + /// - Returns: An Array of SubscriptionFeature where each feature is enabled or disabled based on the user entitlements + public func currentSubscriptionFeatures(forceRefresh: Bool) async -> [SubscriptionFeature] { + guard isUserAuthenticated else { return [] } + + do { + let tokenContainer = try await getTokenContainer(policy: forceRefresh ? .localForceRefresh : .localValid) + let currentSubscription = try await getSubscription(cachePolicy: forceRefresh ? .reloadIgnoringLocalCacheData : .returnCacheDataElseLoad) + + let userEntitlements = tokenContainer.decodedAccessToken.subscriptionEntitlements // What the user has access to + let availableFeatures = currentSubscription.features ?? [] // what the subscription is capable to provide + + // Filter out the features that are not available because the user doesn't have the right entitlements + let result = availableFeatures.map({ featureEntitlement in + let enabled = userEntitlements.contains(featureEntitlement) + return SubscriptionFeature(entitlement: featureEntitlement, availableForUser: enabled) + }) + Logger.subscription.log(""" +User entitlements: \(userEntitlements, privacy: .public) +Available Features: \(availableFeatures, privacy: .public) +Subscription features: \(result, privacy: .public) +""") + return result + } catch { + Logger.subscription.error("Error retrieving subscription features: \(error, privacy: .public)") + return [] + } + } + + public func isFeatureAvailableForUser(_ entitlement: SubscriptionEntitlement) async -> Bool { + guard isUserAuthenticated else { return false } + + let currentFeatures = await currentSubscriptionFeatures(forceRefresh: false) + return currentFeatures.contains { feature in + feature.entitlement == entitlement && feature.availableForUser + } + } +} + +extension DefaultSubscriptionManager: SubscriptionTokenProvider { + public func getAccessToken() async throws -> String { + try await getTokenContainer(policy: .localValid).accessToken + } + + public func removeAccessToken() { + removeTokenContainer() + } +} diff --git a/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift b/Sources/Subscription/Storage/V1/AccountKeychainStorage.swift similarity index 97% rename from Sources/Subscription/AccountStorage/AccountKeychainStorage.swift rename to Sources/Subscription/Storage/V1/AccountKeychainStorage.swift index 13e83d3ea..92efcd74e 100644 --- a/Sources/Subscription/AccountStorage/AccountKeychainStorage.swift +++ b/Sources/Subscription/Storage/V1/AccountKeychainStorage.swift @@ -31,6 +31,7 @@ public enum AccountKeychainAccessType: String { } public enum AccountKeychainAccessError: Error, Equatable { + case failedToDecodeKeychainData case failedToDecodeKeychainValueAsData case failedToDecodeKeychainDataAsString case keychainSaveFailure(OSStatus) @@ -39,6 +40,7 @@ public enum AccountKeychainAccessError: Error, Equatable { public var errorDescription: String { switch self { + case .failedToDecodeKeychainData: return "failedToDecodeKeychainData" case .failedToDecodeKeychainValueAsData: return "failedToDecodeKeychainValueAsData" case .failedToDecodeKeychainDataAsString: return "failedToDecodeKeychainDataAsString" case .keychainSaveFailure(let status): return "keychainSaveFailure(\(status))" @@ -100,7 +102,7 @@ public final class AccountKeychainStorage: AccountStoring { } } -private extension AccountKeychainStorage { +extension AccountKeychainStorage { /* Uses just kSecAttrService as the primary key, since we don't want to store diff --git a/Sources/Subscription/AccountStorage/AccountStoring.swift b/Sources/Subscription/Storage/V1/AccountStoring.swift similarity index 100% rename from Sources/Subscription/AccountStorage/AccountStoring.swift rename to Sources/Subscription/Storage/V1/AccountStoring.swift diff --git a/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift b/Sources/Subscription/Storage/V1/SubscriptionTokenKeychainStorage.swift similarity index 85% rename from Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift rename to Sources/Subscription/Storage/V1/SubscriptionTokenKeychainStorage.swift index 3e5772a44..89221f988 100644 --- a/Sources/Subscription/AccountStorage/SubscriptionTokenKeychainStorage.swift +++ b/Sources/Subscription/Storage/V1/SubscriptionTokenKeychainStorage.swift @@ -17,6 +17,7 @@ // import Foundation +import Common public final class SubscriptionTokenKeychainStorage: SubscriptionTokenStoring { @@ -145,40 +146,7 @@ private extension SubscriptionTokenKeychainStorage { kSecClass: kSecClassGenericPassword, kSecAttrSynchronizable: false ] - attributes.merge(keychainType.queryAttributes()) { $1 } - return attributes } } - -public enum KeychainType { - case dataProtection(_ accessGroup: AccessGroup) - /// Uses the system keychain. - case system - case fileBased - - public enum AccessGroup { - case unspecified - case named(_ name: String) - } - - func queryAttributes() -> [CFString: Any] { - switch self { - case .dataProtection(let accessGroup): - switch accessGroup { - case .unspecified: - return [kSecUseDataProtectionKeychain: true] - case .named(let accessGroup): - return [ - kSecUseDataProtectionKeychain: true, - kSecAttrAccessGroup: accessGroup - ] - } - case .system: - return [kSecUseDataProtectionKeychain: false] - case .fileBased: - return [kSecUseDataProtectionKeychain: false] - } - } -} diff --git a/Sources/Subscription/AccountStorage/SubscriptionTokenStoring.swift b/Sources/Subscription/Storage/V1/SubscriptionTokenStoring.swift similarity index 100% rename from Sources/Subscription/AccountStorage/SubscriptionTokenStoring.swift rename to Sources/Subscription/Storage/V1/SubscriptionTokenStoring.swift diff --git a/Sources/Subscription/Storage/V2/SubscriptionTokenKeychainStorage+LegacyAuthTokenStoring.swift b/Sources/Subscription/Storage/V2/SubscriptionTokenKeychainStorage+LegacyAuthTokenStoring.swift new file mode 100644 index 000000000..9a4fda517 --- /dev/null +++ b/Sources/Subscription/Storage/V2/SubscriptionTokenKeychainStorage+LegacyAuthTokenStoring.swift @@ -0,0 +1,45 @@ +// +// SubscriptionTokenKeychainStorage+LegacyAuthTokenStoring.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 Networking + +extension SubscriptionTokenKeychainStorage: LegacyAuthTokenStoring { + + public var token: String? { + get { + do { + return try getAccessToken() + } catch { + assertionFailure("Failed to retrieve auth token: \(error)") + } + return nil + } + set(newValue) { + do { + guard let newValue else { + try removeAccessToken() + return + } + try store(accessToken: newValue) + } catch { + assertionFailure("Failed set token: \(error)") + } + } + } +} diff --git a/Sources/Subscription/Storage/V2/SubscriptionTokenKeychainStorageV2.swift b/Sources/Subscription/Storage/V2/SubscriptionTokenKeychainStorageV2.swift new file mode 100644 index 000000000..afd3e19cf --- /dev/null +++ b/Sources/Subscription/Storage/V2/SubscriptionTokenKeychainStorageV2.swift @@ -0,0 +1,168 @@ +// +// SubscriptionTokenKeychainStorageV2.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 os.log +import Networking +import Common + +public final class SubscriptionTokenKeychainStorageV2: AuthTokenStoring { + + private let keychainType: KeychainType + private let errorHandler: (AccountKeychainAccessType, AccountKeychainAccessError) -> Void + + public init(keychainType: KeychainType = .dataProtection(.unspecified), + errorHandler: @escaping (AccountKeychainAccessType, AccountKeychainAccessError) -> Void) { + self.keychainType = keychainType + self.errorHandler = errorHandler + } + + public var tokenContainer: TokenContainer? { + get { + do { + guard let data = try retrieveData(forField: .tokens) else { + Logger.subscriptionKeychain.debug("TokenContainer not found") + return nil + } + return CodableHelper.decode(jsonData: data) + } catch { + if let error = error as? AccountKeychainAccessError { + errorHandler(AccountKeychainAccessType.getAuthToken, error) + } else { + assertionFailure("Unexpected error: \(error)") + Logger.OAuth.fault("Unexpected error: \(error, privacy: .public)") + } + + return nil + } + } + set { + do { + guard let newValue else { + Logger.subscriptionKeychain.debug("Remove TokenContainer") + try self.deleteItem(forField: .tokens) + return + } + + if let data = CodableHelper.encode(newValue) { + try self.store(data: data, forField: .tokens) + } else { + throw AccountKeychainAccessError.failedToDecodeKeychainData + } + } catch { + Logger.subscriptionKeychain.fault("Failed to set TokenContainer: \(error, privacy: .public)") + if let error = error as? AccountKeychainAccessError { + errorHandler(AccountKeychainAccessType.storeAuthToken, error) + } else { + assertionFailure("Unexpected error: \(error)") + Logger.OAuth.fault("Unexpected error: \(error, privacy: .public)") + } + } + } + } +} + +extension SubscriptionTokenKeychainStorageV2 { + + /* + Uses just kSecAttrService as the primary key, since we don't want to store + multiple accounts/tokens at the same time + */ + enum SubscriptionKeychainField: String, CaseIterable { + case tokens = "subscription.v2.tokens" + + var keyValue: String { + "com.duckduckgo" + "." + rawValue + } + } + + func retrieveData(forField field: SubscriptionKeychainField) throws -> Data? { + var query = defaultAttributes() + query[kSecAttrService] = field.keyValue + query[kSecMatchLimit] = kSecMatchLimitOne + query[kSecReturnData] = true + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + if status == errSecSuccess { + if let existingItem = item as? Data { + return existingItem + } else { + throw AccountKeychainAccessError.failedToDecodeKeychainData + } + } else if status == errSecItemNotFound { + return nil + } else { + throw AccountKeychainAccessError.keychainLookupFailure(status) + } + } + + func store(data: Data, forField field: SubscriptionKeychainField) throws { + var query = defaultAttributes() + query[kSecAttrService] = field.keyValue + query[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlock + query[kSecValueData] = data + + let status = SecItemAdd(query as CFDictionary, nil) + + switch status { + case errSecSuccess: + return + case errSecDuplicateItem: + let updateStatus = updateData(data, forField: field) + + if updateStatus != errSecSuccess { + throw AccountKeychainAccessError.keychainSaveFailure(status) + } + default: + throw AccountKeychainAccessError.keychainSaveFailure(status) + } + } + + private func updateData(_ data: Data, forField field: SubscriptionKeychainField) -> OSStatus { + var query = defaultAttributes() + query[kSecAttrService] = field.keyValue + + let newAttributes = [ + kSecValueData: data, + kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock + ] as [CFString: Any] + + return SecItemUpdate(query as CFDictionary, newAttributes as CFDictionary) + } + + func deleteItem(forField field: SubscriptionKeychainField, useDataProtectionKeychain: Bool = true) throws { + let query = defaultAttributes() + + let status = SecItemDelete(query as CFDictionary) + + if status != errSecSuccess && status != errSecItemNotFound { + throw AccountKeychainAccessError.keychainDeleteFailure(status) + } + } + + private func defaultAttributes() -> [CFString: Any] { + var attributes: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrSynchronizable: false + ] + attributes.merge(keychainType.queryAttributes()) { $1 } + return attributes + } +} diff --git a/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift index eff9f2e69..55e03ae47 100644 --- a/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift +++ b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift @@ -20,7 +20,7 @@ import Foundation import Common import os.log -public protocol SubscriptionCookieManaging { +public protocol SubscriptionCookieManagingV1 { init(subscriptionManager: SubscriptionManager, currentCookieStore: @MainActor @escaping () -> HTTPCookieStore?, eventMapping: EventMapping) func enableSettingSubscriptionCookie() func disableSettingSubscriptionCookie() async @@ -31,7 +31,7 @@ public protocol SubscriptionCookieManaging { var lastRefreshDate: Date? { get } } -public final class SubscriptionCookieManager: SubscriptionCookieManaging { +public final class SubscriptionCookieManager: SubscriptionCookieManagingV1 { public static let cookieDomain = "subscriptions.duckduckgo.com" public static let cookieName = "privacy_pro_access_token" @@ -166,9 +166,9 @@ public final class SubscriptionCookieManager: SubscriptionCookieManaging { } } -enum SubscriptionCookieManagerError: Error { - case failedToCreateSubscriptionCookie -} +//enum SubscriptionCookieManagerError: Error { +// case failedToCreateSubscriptionCookie +//} private extension HTTPCookieStore { diff --git a/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManagerV2.swift b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManagerV2.swift new file mode 100644 index 000000000..a1457807c --- /dev/null +++ b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManagerV2.swift @@ -0,0 +1,199 @@ +// +// SubscriptionCookieManagerV2.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 Common +import os.log + +public protocol SubscriptionCookieManagingV2 { + func enableSettingSubscriptionCookie() + func disableSettingSubscriptionCookie() async + + func refreshSubscriptionCookie() async + func resetLastRefreshDate() + + var lastRefreshDate: Date? { get } +} + +public final class SubscriptionCookieManagerV2: SubscriptionCookieManagingV2 { + + public static let cookieDomain = "subscriptions.duckduckgo.com" + public static let cookieName = "privacy_pro_access_token" + + private static let defaultRefreshTimeInterval: TimeInterval = .hours(4) + + private let subscriptionManager: SubscriptionManagerV2 + private let currentCookieStore: @MainActor () -> HTTPCookieStore? + private let eventMapping: EventMapping + + public private(set) var lastRefreshDate: Date? + private let refreshTimeInterval: TimeInterval + private var isSettingSubscriptionCookieEnabled: Bool = false + + convenience nonisolated public required init(subscriptionManager: SubscriptionManagerV2, + currentCookieStore: @MainActor @escaping () -> HTTPCookieStore?, + eventMapping: EventMapping) { + self.init(subscriptionManager: subscriptionManager, + currentCookieStore: currentCookieStore, + eventMapping: eventMapping, + refreshTimeInterval: SubscriptionCookieManagerV2.defaultRefreshTimeInterval) + } + + nonisolated public required init(subscriptionManager: SubscriptionManagerV2, + currentCookieStore: @MainActor @escaping () -> HTTPCookieStore?, + eventMapping: EventMapping, + refreshTimeInterval: TimeInterval) { + self.subscriptionManager = subscriptionManager + self.currentCookieStore = currentCookieStore + self.eventMapping = eventMapping + self.refreshTimeInterval = refreshTimeInterval + + NotificationCenter.default.addObserver(self, selector: #selector(handleAccountDidSignIn), name: .accountDidSignIn, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(handleAccountDidSignOut), name: .accountDidSignOut, object: nil) + } + + public func enableSettingSubscriptionCookie() { + isSettingSubscriptionCookieEnabled = true + } + + public func disableSettingSubscriptionCookie() async { + isSettingSubscriptionCookieEnabled = false + if let cookieStore = await currentCookieStore(), + let cookie = await cookieStore.fetchCurrentSubscriptionCookie() { + await cookieStore.deleteCookie(cookie) + } + } + + @objc private func handleAccountDidSignIn() { + Task { + guard isSettingSubscriptionCookieEnabled, + let cookieStore = await currentCookieStore() + else { return } + + do { + let accessToken = try await subscriptionManager.getTokenContainer(policy: .localValid).accessToken + Logger.subscriptionCookieManager.info("Handle .accountDidSignIn - setting cookie") + try await cookieStore.setSubscriptionCookie(for: accessToken) + updateLastRefreshDateToNow() + } catch SubscriptionCookieManagerError.failedToCreateSubscriptionCookie { + eventMapping.fire(.failedToSetSubscriptionCookie) + } catch { + Logger.subscriptionCookieManager.error("Handle .accountDidSignIn - can't set the cookie, token is missing") + eventMapping.fire(.errorHandlingAccountDidSignInTokenIsMissing) + return + } + } + } + + @objc private func handleAccountDidSignOut() { + Task { + guard isSettingSubscriptionCookieEnabled, + let cookieStore = await currentCookieStore() + else { return } + Logger.subscriptionCookieManager.info("Handle .accountDidSignOut - deleting cookie") + + do { + try await cookieStore.setEmptySubscriptionCookie() + updateLastRefreshDateToNow() + } catch { + eventMapping.fire(.failedToSetSubscriptionCookie) + } + } + } + + public func refreshSubscriptionCookie() async { + guard isSettingSubscriptionCookieEnabled, + shouldRefreshSubscriptionCookie(), + let cookieStore = await currentCookieStore() else { return } + + Logger.subscriptionCookieManager.info("Refresh subscription cookie") + updateLastRefreshDateToNow() + + let accessToken: String? = try? await subscriptionManager.getTokenContainer(policy: .localValid).accessToken + let subscriptionCookie = await cookieStore.fetchCurrentSubscriptionCookie() + + let noCookieOrWithUnexpectedValue = (accessToken ?? "") != subscriptionCookie?.value + + do { + if noCookieOrWithUnexpectedValue { + Logger.subscriptionCookieManager.info("Refresh: No cookie or one with unexpected value") + + if let accessToken { + try await cookieStore.setSubscriptionCookie(for: accessToken) + eventMapping.fire(.subscriptionCookieRefreshedWithAccessToken) + } else { + try await cookieStore.setEmptySubscriptionCookie() + eventMapping.fire(.subscriptionCookieRefreshedWithEmptyValue) + } + } + } catch { + eventMapping.fire(.failedToSetSubscriptionCookie) + } + } + + private func shouldRefreshSubscriptionCookie() -> Bool { + switch lastRefreshDate { + case .none: + return true + case .some(let previousLastRefreshDate): + return previousLastRefreshDate.timeIntervalSinceNow < -refreshTimeInterval + } + } + + private func updateLastRefreshDateToNow() { + lastRefreshDate = Date() + } + + public func resetLastRefreshDate() { + lastRefreshDate = nil + } +} + +enum SubscriptionCookieManagerError: Error { + case failedToCreateSubscriptionCookie +} + +private extension HTTPCookieStore { + + func fetchCurrentSubscriptionCookie() async -> HTTPCookie? { + await allCookies().first { $0.domain == SubscriptionCookieManagerV2.cookieDomain && $0.name == SubscriptionCookieManagerV2.cookieName } + } + + func setEmptySubscriptionCookie() async throws { + try await setSubscriptionCookie(for: "") + } + + func setSubscriptionCookie(for token: String) async throws { + guard let cookie = HTTPCookie(properties: [ + .domain: SubscriptionCookieManagerV2.cookieDomain, + .path: "/", + .expires: Date().addingTimeInterval(.days(365)), + .name: SubscriptionCookieManagerV2.cookieName, + .value: token, + .secure: true, + .init(rawValue: "HttpOnly"): true + ]) else { + Logger.subscriptionCookieManager.error("Subscription cookie could not be created") + assertionFailure("Subscription cookie could not be created") + throw SubscriptionCookieManagerError.failedToCreateSubscriptionCookie + } + + Logger.subscriptionCookieManager.info("Setting subscription cookie") + await setCookie(cookie) + } +} diff --git a/Sources/Subscription/SubscriptionEnvironment.swift b/Sources/Subscription/SubscriptionEnvironment.swift index 3f5ed3bf2..95dd9910b 100644 --- a/Sources/Subscription/SubscriptionEnvironment.swift +++ b/Sources/Subscription/SubscriptionEnvironment.swift @@ -17,20 +17,25 @@ // import Foundation +import Networking public struct SubscriptionEnvironment: Codable { - public enum ServiceEnvironment: Codable { + public enum ServiceEnvironment: String, Codable { case production, staging - public var description: String { + public var url: URL { switch self { - case .production: return "Production" - case .staging: return "Staging" + case .production: + URL(string: "https://subscriptions.duckduckgo.com/api")! + case .staging: + URL(string: "https://subscriptions-dev.duckduckgo.com/api")! } } } + public var authEnvironment: OAuthEnvironment { serviceEnvironment == .production ? .production : .staging } + public enum PurchasePlatform: String, Codable { case appStore, stripe } @@ -42,4 +47,8 @@ public struct SubscriptionEnvironment: Codable { self.serviceEnvironment = serviceEnvironment self.purchasePlatform = purchasePlatform } + + public var description: String { + "ServiceEnvironment: \(serviceEnvironment.rawValue), PurchasePlatform: \(purchasePlatform.rawValue)" + } } diff --git a/Sources/Subscription/SubscriptionFeature.swift b/Sources/Subscription/SubscriptionFeature.swift new file mode 100644 index 000000000..0b8eeb19e --- /dev/null +++ b/Sources/Subscription/SubscriptionFeature.swift @@ -0,0 +1,31 @@ +// +// SubscriptionFeature.swift +// +// Copyright © 2025 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 Networking + +/// A `SubscriptionFeature` is **available** if the specific feature is `on` for the specific subscription. Feature availability if decided based on the country and the local and remote feature flags. +/// A `SubscriptionFeature` is **availableForUser** if the logged in user has the required entitlements. +public struct SubscriptionFeature: Equatable, CustomDebugStringConvertible { + public var entitlement: SubscriptionEntitlement + public var availableForUser: Bool + + public var debugDescription: String { + "\(entitlement.rawValue) is \(availableForUser ? "available" : "unavailable")" + } +} diff --git a/Sources/Subscription/SubscriptionFeatureMappingCacheV2.swift b/Sources/Subscription/SubscriptionFeatureMappingCacheV2.swift new file mode 100644 index 000000000..33e6cedd0 --- /dev/null +++ b/Sources/Subscription/SubscriptionFeatureMappingCacheV2.swift @@ -0,0 +1,25 @@ +// +// SubscriptionFeatureMappingCacheV2.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 os.log +import Networking + +public protocol SubscriptionFeatureMappingCacheV2 { + func subscriptionFeatures(for subscriptionIdentifier: String) async -> [SubscriptionEntitlement] +} diff --git a/Sources/Subscription/SubscriptionTokenProvider.swift b/Sources/Subscription/SubscriptionTokenProvider.swift new file mode 100644 index 000000000..d4f512fc5 --- /dev/null +++ b/Sources/Subscription/SubscriptionTokenProvider.swift @@ -0,0 +1,30 @@ +// +// SubscriptionTokenProvider.swift +// +// Copyright © 2025 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 Networking + +/// The sole entity responsible of obtaining, storing and refreshing an OAuth Token +public protocol SubscriptionTokenProvider { + + /// Get a valid access token + func getAccessToken() async throws -> String + + /// Remove the access token + func removeAccessToken() +} diff --git a/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift deleted file mode 100644 index 97c923279..000000000 --- a/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// 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 final class APIServiceMock: APIService { - public var mockAuthHeaders: [String: String] = [String: String]() - - public var mockResponseJSONData: Data? - public var mockAPICallSuccessResult: Any? - public var mockAPICallError: APIServiceError? - - public var onExecuteAPICall: ((ExecuteAPICallParameters) -> Void)? - - public typealias ExecuteAPICallParameters = (method: String, endpoint: String, headers: [String: String]?, body: Data?) - - public init() { } - - // swiftlint:disable force_cast - public func executeAPICall(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result where T: Decodable { - - onExecuteAPICall?(ExecuteAPICallParameters(method, endpoint, headers, body)) - - if let data = mockResponseJSONData { - let decoder = JSONDecoder() - decoder.keyDecodingStrategy = .convertFromSnakeCase - decoder.dateDecodingStrategy = .millisecondsSince1970 - - if let decodedResponse = try? decoder.decode(T.self, from: data) { - return .success(decodedResponse) - } else { - return .failure(.decodingError) - } - } else 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 deleted file mode 100644 index e36f32fee..000000000 --- a/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// 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 final class AuthEndpointServiceMock: AuthEndpointService { - public var getAccessTokenResult: Result? - public var validateTokenResult: Result? - public var createAccountResult: Result? - public var storeLoginResult: Result? - - public var onValidateToken: ((String) -> Void)? - - public var getAccessTokenCalled: Bool = false - public var validateTokenCalled: Bool = false - public var createAccountCalled: Bool = false - public var storeLoginCalled: Bool = false - - public init() { } - - public func getAccessToken(token: String) async -> Result { - getAccessTokenCalled = true - return getAccessTokenResult! - } - - public func validateToken(accessToken: String) async -> Result { - validateTokenCalled = true - onValidateToken?(accessToken) - return validateTokenResult! - } - - public func createAccount(emailAccessToken: String?) async -> Result { - createAccountCalled = true - return createAccountResult! - } - - public func storeLogin(signature: String) async -> Result { - storeLoginCalled = true - return storeLoginResult! - } -} diff --git a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionAPIMockResponseFactory.swift b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionAPIMockResponseFactory.swift new file mode 100644 index 000000000..c5dae9f63 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionAPIMockResponseFactory.swift @@ -0,0 +1,100 @@ +// +// SubscriptionAPIMockResponseFactory.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 +@testable import Networking +import NetworkingTestingUtils + +public struct SubscriptionAPIMockResponseFactory { + + static let authCookieHeaders = [ HTTPHeaderKey.setCookie: "ddg_auth_session_id=kADeCPMmCIHIV5uD6AFoB7Fk7pRiXFzlmQE4gW9r7FRKV8OGC1rRnZcTXoa7iIa8qgjiQCqZYq6Caww6k5HJl3; domain=duckduckgo.com; path=/api/auth/v2/; max-age=600; SameSite=Strict; secure; HttpOnly"] + + static let someAPIBodyErrorJSON = "{\"error\":\"invalid_authorization_request\"}" + static var someAPIBodyErrorJSONData: Data { + someAPIBodyErrorJSON.data(using: .utf8)! + } + + static func setErrorResponse(forRequest request: APIRequestV2, apiService: MockAPIService) { + let httpResponse = HTTPURLResponse(url: request.urlRequest.url!, + statusCode: HTTPStatusCode.badRequest.rawValue, + httpVersion: nil, + headerFields: [:])! + let response = APIResponseV2(data: someAPIBodyErrorJSONData, httpResponse: httpResponse) + apiService.set(response: response, forRequest: request) + } + + public static func mockConfirmPurchase(destinationMockAPIService apiService: MockAPIService, success: Bool) { + let request = SubscriptionRequest.confirmPurchase(baseURL: SubscriptionEnvironment.ServiceEnvironment.staging.url, + accessToken: "somAccessToken", + signature: "someSignature", + additionalParams: nil)! + if success { + let jsonString = """ +{"email":"","entitlements":[{"product":"Data Broker Protection","name":"subscriber"},{"product":"Identity Theft Restoration","name":"subscriber"},{"product":"Network Protection","name":"subscriber"}],"subscription":{"productId":"ios.subscription.1month","name":"Monthly Subscription","billingPeriod":"Monthly","startedAt":1730991734000,"expiresOrRenewsAt":1730992034000,"platform":"apple","status":"Auto-Renewable"}} +""" + let httpResponse = HTTPURLResponse(url: request.apiRequest.urlRequest.url!, + statusCode: HTTPStatusCode.ok.rawValue, + httpVersion: nil, + headerFields: [:])! + let response = APIResponseV2(data: jsonString.data(using: .utf8), httpResponse: httpResponse) + apiService.set(response: response, forRequest: request.apiRequest) + } else { + setErrorResponse(forRequest: request.apiRequest, apiService: apiService) + } + } + + public static func mockGetProducts(destinationMockAPIService apiService: MockAPIService, success: Bool) { + let request = SubscriptionRequest.getProducts(baseURL: SubscriptionEnvironment.ServiceEnvironment.staging.url)! + if success { + let jsonString = """ +[{"productId":"ddg-privacy-pro-sandbox-monthly-renews-us","productLabel":"Monthly Subscription","billingPeriod":"Monthly","price":"9.99","currency":"USD"},{"productId":"ddg-privacy-pro-sandbox-yearly-renews-us","productLabel":"Yearly Subscription","billingPeriod":"Yearly","price":"99.99","currency":"USD"}] +""" + let httpResponse = HTTPURLResponse(url: request.apiRequest.urlRequest.url!, + statusCode: HTTPStatusCode.ok.rawValue, + httpVersion: nil, + headerFields: [:])! + let response = APIResponseV2(data: jsonString.data(using: .utf8), httpResponse: httpResponse) + apiService.set(response: response, forRequest: request.apiRequest) + } else { + let httpResponse = HTTPURLResponse(url: request.apiRequest.urlRequest.url!, + statusCode: HTTPStatusCode.badRequest.rawValue, + httpVersion: nil, + headerFields: [:])! + let response = APIResponseV2(data: someAPIBodyErrorJSONData, httpResponse: httpResponse) + apiService.set(response: response, forRequest: request.apiRequest) + } + } + + public static func mockGetFeatures(destinationMockAPIService apiService: MockAPIService, success: Bool, subscriptionID: String) { + let request = SubscriptionRequest.subscriptionFeatures(baseURL: SubscriptionEnvironment.ServiceEnvironment.staging.url, subscriptionID: subscriptionID)! + if success { + let jsonString = """ +{"features":["Data Broker Protection","Identity Theft Restoration","Network Protection"]} +""" + let httpResponse = HTTPURLResponse(url: request.apiRequest.urlRequest.url!, + statusCode: HTTPStatusCode.ok.rawValue, + httpVersion: nil, + headerFields: [:])! + let response = APIResponseV2(data: jsonString.data(using: .utf8), httpResponse: httpResponse) + apiService.set(response: response, forRequest: request.apiRequest) + } else { + setErrorResponse(forRequest: request.apiRequest, apiService: apiService) + } + } +} diff --git a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift index 526c7ad7c..cfe10e611 100644 --- a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift +++ b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift @@ -18,55 +18,69 @@ import Foundation import Subscription +import Networking -public final class SubscriptionEndpointServiceMock: SubscriptionEndpointService { - public var getSubscriptionResult: Result? - public var getProductsResult: Result<[GetProductsItem], APIServiceError>? - public var getSubscriptionFeaturesResult: Result? - public var getCustomerPortalURLResult: Result? - public var confirmPurchaseResult: Result? +public final class SubscriptionEndpointServiceMock: SubscriptionEndpointServiceV2 { - public var onUpdateCache: ((Subscription) -> Void)? - public var onConfirmPurchase: ((String, String, [String: String]?) -> Void)? - public var onGetSubscription: ((String, APICachePolicy) -> Void)? public var onSignOut: (() -> Void)? - - public var updateCacheWithSubscriptionCalled: Bool = false - public var getSubscriptionCalled: Bool = false public var signOutCalled: Bool = false public init() { } - public func updateCache(with subscription: Subscription) { + public var updateCacheWithSubscriptionCalled: Bool = false + public var onUpdateCache: ((PrivacyProSubscription) -> Void)? + public func updateCache(with subscription: Subscription.PrivacyProSubscription) { onUpdateCache?(subscription) updateCacheWithSubscriptionCalled = true } - public func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result { - getSubscriptionCalled = true - onGetSubscription?(accessToken, cachePolicy) - return getSubscriptionResult! + public func clearSubscription() {} + + public var getProductsResult: Result<[GetProductsItem], APIRequestV2.Error>? + public func getProducts() async throws -> [Subscription.GetProductsItem] { + switch getProductsResult! { + case .success(let result): return result + case .failure(let error): throw error + } } - public func signOut() { - signOutCalled = true - onSignOut?() + public var getSubscriptionCalled: Bool = false + public var onGetSubscription: ((String, SubscriptionCachePolicy) -> Void)? + public var getSubscriptionResult: Result? + public func getSubscription(accessToken: String, cachePolicy: Subscription.SubscriptionCachePolicy) async throws -> Subscription.PrivacyProSubscription { + getSubscriptionCalled = true + onGetSubscription?(accessToken, cachePolicy) + switch getSubscriptionResult! { + case .success(let subscription): return subscription + case .failure(let error): throw error + } } - public func getProducts() async -> Result<[GetProductsItem], APIServiceError> { - getProductsResult! + public var getCustomerPortalURLResult: Result? + public func getCustomerPortalURL(accessToken: String, externalID: String) async throws -> Subscription.GetCustomerPortalURLResponse { + switch getCustomerPortalURLResult! { + case .success(let result): return result + case .failure(let error): throw error + } } - public func getSubscriptionFeatures(for subscriptionID: String) async -> Result { - getSubscriptionFeaturesResult! + public var confirmPurchaseResult: Result? + public func confirmPurchase(accessToken: String, signature: String, additionalParams: [String: String]?) async throws -> Subscription.ConfirmPurchaseResponseV2 { + switch confirmPurchaseResult! { + case .success(let result): return result + case .failure(let error): throw error + } } - public func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result { - getCustomerPortalURLResult! + public var getSubscriptionFeaturesResult: Result? + public func getSubscriptionFeatures(for subscriptionID: String) async throws -> Subscription.GetSubscriptionFeaturesResponseV2 { + switch getSubscriptionFeaturesResult! { + case .success(let result): return result + case .failure(let error): throw error + } } - public func confirmPurchase(accessToken: String, signature: String, additionalParams: [String: String]?) async -> Result { - onConfirmPurchase?(accessToken, signature, additionalParams) - return confirmPurchaseResult! + public func ingestSubscription(_ subscription: Subscription.PrivacyProSubscription) async throws { + getSubscriptionResult = .success(subscription) } } diff --git a/Sources/SubscriptionTestingUtilities/AccountStorage/AccountManagerKeychainAccessDelegateMock.swift b/Sources/SubscriptionTestingUtilities/AccountStorage/AccountManagerKeychainAccessDelegateMock.swift deleted file mode 100644 index 0c15398b3..000000000 --- a/Sources/SubscriptionTestingUtilities/AccountStorage/AccountManagerKeychainAccessDelegateMock.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// AccountManagerKeychainAccessDelegateMock.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 final class AccountManagerKeychainAccessDelegateMock: AccountManagerKeychainAccessDelegate { - - public var onAccountManagerKeychainAccessFailed: ((AccountKeychainAccessType, AccountKeychainAccessError) -> Void)? - - public init(onAccountManagerKeychainAccessFailed: ( (AccountKeychainAccessType, AccountKeychainAccessError) -> Void)? = nil) { - self.onAccountManagerKeychainAccessFailed = onAccountManagerKeychainAccessFailed - } - - public func accountManagerKeychainAccessFailed(accessType: AccountKeychainAccessType, error: AccountKeychainAccessError) { - onAccountManagerKeychainAccessFailed?(accessType, error) - } -} diff --git a/Sources/SubscriptionTestingUtilities/AccountStorage/SubscriptionTokenKeychainStorageMock.swift b/Sources/SubscriptionTestingUtilities/AccountStorage/SubscriptionTokenKeychainStorageMock.swift deleted file mode 100644 index 7bb5b77a5..000000000 --- a/Sources/SubscriptionTestingUtilities/AccountStorage/SubscriptionTokenKeychainStorageMock.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// 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 final class SubscriptionTokenKeychainStorageMock: SubscriptionTokenStoring { - - public var accessToken: String? - - public var removeAccessTokenCalled: Bool = false - - 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 { - removeAccessTokenCalled = true - accessToken = nil - } -} diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift deleted file mode 100644 index cff7d88e6..000000000 --- a/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift +++ /dev/null @@ -1,34 +0,0 @@ -// -// 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 final class AppStoreAccountManagementFlowMock: AppStoreAccountManagementFlow { - public var refreshAuthTokenIfNeededResult: Result? - public var onRefreshAuthTokenIfNeeded: (() -> Void)? - public var refreshAuthTokenIfNeededCalled: Bool = false - - public init() { } - - public func refreshAuthTokenIfNeeded() async -> Result { - refreshAuthTokenIfNeededCalled = true - onRefreshAuthTokenIfNeeded?() - return refreshAuthTokenIfNeededResult! - } -} diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift index 1f1cf83a0..8d8ca8aa1 100644 --- a/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift +++ b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift @@ -19,16 +19,17 @@ import Foundation import Subscription -public final class AppStorePurchaseFlowMock: AppStorePurchaseFlow { +public final class AppStorePurchaseFlowMock: AppStorePurchaseFlowV2 { public var purchaseSubscriptionResult: Result? public var completeSubscriptionPurchaseResult: Result? public init() { } - public func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result { + public func purchaseSubscription(with subscriptionIdentifier: String) async -> Result { purchaseSubscriptionResult! } + @discardableResult public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS, additionalParams: [String: String]?) async -> Result { completeSubscriptionPurchaseResult! } diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift index 6daea9c44..cdef2230e 100644 --- a/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift +++ b/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift @@ -19,13 +19,13 @@ import Foundation import Subscription -public final class AppStoreRestoreFlowMock: AppStoreRestoreFlow { - public var restoreAccountFromPastPurchaseResult: Result? +public final class AppStoreRestoreFlowMock: AppStoreRestoreFlowV2 { + public var restoreAccountFromPastPurchaseResult: Result? public var restoreAccountFromPastPurchaseCalled: Bool = false public init() { } - public func restoreAccountFromPastPurchase() async -> Result { + @discardableResult public func restoreAccountFromPastPurchase() async -> Result { restoreAccountFromPastPurchaseCalled = true return restoreAccountFromPastPurchaseResult! } diff --git a/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift index 362e2758b..8a0932680 100644 --- a/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift +++ b/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift @@ -19,16 +19,16 @@ import Foundation import Subscription -public final class StripePurchaseFlowMock: StripePurchaseFlow { - public var subscriptionOptionsResult: Result +public final class StripePurchaseFlowMock: StripePurchaseFlowV2 { + public var subscriptionOptionsResult: Result public var prepareSubscriptionPurchaseResult: Result - public init(subscriptionOptionsResult: Result, prepareSubscriptionPurchaseResult: Result) { + public init(subscriptionOptionsResult: Result, prepareSubscriptionPurchaseResult: Result) { self.subscriptionOptionsResult = subscriptionOptionsResult self.prepareSubscriptionPurchaseResult = prepareSubscriptionPurchaseResult } - public func subscriptionOptions() async -> Result { + public func subscriptionOptions() async -> Result { subscriptionOptionsResult } diff --git a/Sources/SubscriptionTestingUtilities/Managers/AccountManagerMock.swift b/Sources/SubscriptionTestingUtilities/Managers/AccountManagerMock.swift deleted file mode 100644 index 2111700c8..000000000 --- a/Sources/SubscriptionTestingUtilities/Managers/AccountManagerMock.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// AccountManagerMock.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 final class AccountManagerMock: AccountManager { - public var delegate: AccountManagerKeychainAccessDelegate? - public var accessToken: String? - public var authToken: String? - public var email: String? - public var externalID: String? - - public var exchangeAuthTokenToAccessTokenResult: Result? - public var fetchAccountDetailsResult: Result? - - public var onStoreAuthToken: ((String) -> Void)? - public var onStoreAccount: ((String, String?, String?) -> Void)? - public var onFetchEntitlements: ((APICachePolicy) -> Void)? - public var onExchangeAuthTokenToAccessToken: ((String) -> Void)? - public var onFetchAccountDetails: ((String) -> Void)? - public var onCheckForEntitlements: ((Double, Int) -> Bool)? - - public var storeAuthTokenCalled: Bool = false - public var storeAccountCalled: Bool = false - public var signOutCalled: Bool = false - public var updateCacheWithEntitlementsCalled: Bool = false - public var fetchEntitlementsCalled: Bool = false - public var exchangeAuthTokenToAccessTokenCalled: Bool = false - public var fetchAccountDetailsCalled: Bool = false - public var checkForEntitlementsCalled: Bool = false - - public init() { } - - public func storeAuthToken(token: String) { - storeAuthTokenCalled = true - onStoreAuthToken?(token) - self.authToken = token - } - - public func storeAccount(token: String, email: String?, externalID: String?) { - storeAccountCalled = true - onStoreAccount?(token, email, externalID) - self.accessToken = token - self.email = email - self.externalID = externalID - } - - public func signOut(skipNotification: Bool) { - signOutCalled = true - self.authToken = nil - self.accessToken = nil - self.email = nil - self.externalID = nil - } - - public func signOut() { - signOutCalled = true - self.authToken = nil - self.accessToken = nil - self.email = nil - self.externalID = nil - } - - public func hasEntitlement(forProductName productName: Entitlement.ProductName, cachePolicy: APICachePolicy) async -> Result { - return .success(true) - } - - public func updateCache(with entitlements: [Entitlement]) { - updateCacheWithEntitlementsCalled = true - } - - public func fetchEntitlements(cachePolicy: APICachePolicy) async -> Result<[Entitlement], Error> { - fetchEntitlementsCalled = true - onFetchEntitlements?(cachePolicy) - return .success([]) - } - - public func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result { - exchangeAuthTokenToAccessTokenCalled = true - onExchangeAuthTokenToAccessToken?(authToken) - return exchangeAuthTokenToAccessTokenResult! - } - - public func fetchAccountDetails(with accessToken: String) async -> Result { - fetchAccountDetailsCalled = true - onFetchAccountDetails?(accessToken) - return fetchAccountDetailsResult! - } - - public func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool { - checkForEntitlementsCalled = true - return onCheckForEntitlements!(waitTime, retryCount) - } -} diff --git a/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift index 3d5c34f63..329a30012 100644 --- a/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift @@ -19,15 +19,15 @@ import Foundation import Subscription -public final class StorePurchaseManagerMock: StorePurchaseManager { +public final class StorePurchaseManagerMock: StorePurchaseManagerV2 { public var purchasedProductIDs: [String] = [] public var purchaseQueue: [String] = [] public var areProductsAvailable: Bool = false public var currentStorefrontRegion: SubscriptionRegion = .usa - public var subscriptionOptionsResult: SubscriptionOptions? - public var freeTrialSubscriptionOptionsResult: SubscriptionOptions? + public var subscriptionOptionsResult: SubscriptionOptionsV2? + public var freeTrialSubscriptionOptionsResult: SubscriptionOptionsV2? public var syncAppleIDAccountResultError: Error? public var mostRecentTransactionResult: String? @@ -42,11 +42,11 @@ public final class StorePurchaseManagerMock: StorePurchaseManager { public init() { } - public func subscriptionOptions() async -> SubscriptionOptions? { + public func subscriptionOptions() async -> SubscriptionOptionsV2? { subscriptionOptionsResult } - public func freeTrialSubscriptionOptions() async -> SubscriptionOptions? { + public func freeTrialSubscriptionOptions() async -> SubscriptionOptionsV2? { freeTrialSubscriptionOptionsResult } diff --git a/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift b/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift index 3217eedbb..6d5186bcf 100644 --- a/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift @@ -17,63 +17,163 @@ // import Foundation +@testable import Networking @testable import Subscription -public final class SubscriptionManagerMock: SubscriptionManager { - public var accountManager: AccountManager - public var subscriptionEndpointService: SubscriptionEndpointService - public var authEndpointService: AuthEndpointService - public var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache +public final class SubscriptionManagerMock: SubscriptionManagerV2 { + + public init() {} - public static var storedEnvironment: SubscriptionEnvironment? - public static func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment? { - return storedEnvironment + public static var environment: Subscription.SubscriptionEnvironment? + public static func loadEnvironmentFrom(userDefaults: UserDefaults) -> Subscription.SubscriptionEnvironment? { + return environment } - public static func save(subscriptionEnvironment: SubscriptionEnvironment, userDefaults: UserDefaults) { - storedEnvironment = subscriptionEnvironment + public static func save(subscriptionEnvironment: Subscription.SubscriptionEnvironment, userDefaults: UserDefaults) { + environment = subscriptionEnvironment } - public var currentEnvironment: SubscriptionEnvironment - public var canPurchase: Bool + public var currentEnvironment: Subscription.SubscriptionEnvironment = .init(serviceEnvironment: .staging, purchasePlatform: .appStore) - public func storePurchaseManager() -> StorePurchaseManager { - internalStorePurchaseManager + public func loadInitialData() {} + + public func refreshCachedSubscription(completion: @escaping (Bool) -> Void) {} + + public var resultSubscription: Subscription.PrivacyProSubscription? + + public func getSubscriptionFrom(lastTransactionJWSRepresentation: String) async throws -> Subscription.PrivacyProSubscription? { + guard let resultSubscription else { + throw OAuthClientError.missingTokens + } + return resultSubscription + } + + public var canPurchase: Bool = true + + public var resultStorePurchaseManager: (any Subscription.StorePurchaseManagerV2)? + public func storePurchaseManager() -> any Subscription.StorePurchaseManagerV2 { + return resultStorePurchaseManager! + } + + public var resultURL: URL! + public func url(for type: Subscription.SubscriptionURL) -> URL { + return resultURL + } + + public var customerPortalURL: URL? + public func getCustomerPortalURL() async throws -> URL { + guard let customerPortalURL else { + throw SubscriptionEndpointServiceError.noData + } + return customerPortalURL + } + + public var isUserAuthenticated: Bool { + resultTokenContainer != nil + } + + public var userEmail: String? { + resultTokenContainer?.decodedAccessToken.email + } + + public var resultTokenContainer: Networking.TokenContainer? + public var resultCreateAccountTokenContainer: Networking.TokenContainer? + public func getTokenContainer(policy: Networking.AuthTokensCachePolicy) async throws -> Networking.TokenContainer { + switch policy { + case .local, .localValid, .localForceRefresh: + guard let resultTokenContainer else { + throw OAuthClientError.missingTokens + } + return resultTokenContainer + case .createIfNeeded: + guard let resultCreateAccountTokenContainer else { + throw OAuthClientError.missingTokens + } + resultTokenContainer = resultCreateAccountTokenContainer + return resultCreateAccountTokenContainer + } } - public func loadInitialData() { + public var resultExchangeTokenContainer: Networking.TokenContainer? + public func exchange(tokenV1: String) async throws -> Networking.TokenContainer { + guard let resultExchangeTokenContainer else { + throw OAuthClientError.missingTokens + } + resultTokenContainer = resultExchangeTokenContainer + return resultExchangeTokenContainer + } + + public func signOut(notifyUI: Bool) { + resultTokenContainer = nil + } + + public func removeTokenContainer() { + resultTokenContainer = nil + } + + public func clearSubscriptionCache() { + + } + public var confirmPurchaseResponse: Result? + public func confirmPurchase(signature: String, additionalParams: [String: String]?) async throws -> Subscription.PrivacyProSubscription { + switch confirmPurchaseResponse! { + case .success(let result): + return result + case .failure(let error): + throw error + } } - public func refreshCachedSubscriptionAndEntitlements(completion: @escaping (Bool) -> Void) { - completion(true) + public func refreshAccount() async {} + + public var confirmPurchaseError: Error? + public func confirmPurchase(signature: String) async throws { + if let confirmPurchaseError { + throw confirmPurchaseError + } + } + + public func getSubscription(cachePolicy: Subscription.SubscriptionCachePolicy) async throws -> Subscription.PrivacyProSubscription { + guard let resultSubscription else { + throw SubscriptionEndpointServiceError.noData + } + return resultSubscription + } + + public var productsResponse: Result<[Subscription.GetProductsItem], Error>? + public func getProducts() async throws -> [Subscription.GetProductsItem] { + switch productsResponse! { + case .success(let result): + return result + case .failure(let error): + throw error + } } - public func url(for type: SubscriptionURL) -> URL { - type.subscriptionURL(environment: currentEnvironment.serviceEnvironment) + public func adopt(tokenContainer: Networking.TokenContainer) { + self.resultTokenContainer = tokenContainer } - public func currentSubscriptionFeatures() async -> [Entitlement.ProductName] { - return [] + public var resultFeatures: [Subscription.SubscriptionFeature] = [] + public func currentSubscriptionFeatures(forceRefresh: Bool) async -> [Subscription.SubscriptionFeature] { + resultFeatures } - public init(accountManager: AccountManager, - subscriptionEndpointService: SubscriptionEndpointService, - authEndpointService: AuthEndpointService, - storePurchaseManager: StorePurchaseManager, - currentEnvironment: SubscriptionEnvironment, - canPurchase: Bool, - subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache) { - self.accountManager = accountManager - self.subscriptionEndpointService = subscriptionEndpointService - self.authEndpointService = authEndpointService - self.internalStorePurchaseManager = storePurchaseManager - self.currentEnvironment = currentEnvironment - self.canPurchase = canPurchase - self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache + public func isFeatureAvailableForUser(_ entitlement: Networking.SubscriptionEntitlement) async -> Bool { + resultFeatures.contains { $0.entitlement == entitlement } } - // MARK: - + //MARK: - Subscription Token Provider + + public func getAccessToken() async throws -> String { + guard let accessToken = resultTokenContainer?.accessToken else { + throw SubscriptionManagerError.tokenUnavailable(error: nil) + } + return accessToken + } - let internalStorePurchaseManager: StorePurchaseManager + public func removeAccessToken() { + resultTokenContainer = nil + } } diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift index b2a5b8133..93f587d99 100644 --- a/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift @@ -18,38 +18,12 @@ import Foundation import Common -import Subscription +@testable import Subscription -public final class SubscriptionCookieManagerMock: SubscriptionCookieManaging { +public final class SubscriptionCookieManagerMock: SubscriptionCookieManagingV2 { public var lastRefreshDate: Date? - - public convenience init() { - let accountManager = AccountManagerMock() - let subscriptionService = DefaultSubscriptionEndpointService(currentServiceEnvironment: .production) - let authService = DefaultAuthEndpointService(currentServiceEnvironment: .production) - let storePurchaseManager = StorePurchaseManagerMock() - let subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() - let subscriptionManager = SubscriptionManagerMock(accountManager: accountManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService, - storePurchaseManager: storePurchaseManager, - currentEnvironment: SubscriptionEnvironment(serviceEnvironment: .production, - purchasePlatform: .appStore), - canPurchase: true, - subscriptionFeatureMappingCache: subscriptionFeatureMappingCache) - - self.init(subscriptionManager: subscriptionManager, - currentCookieStore: { return nil }, - eventMapping: MockSubscriptionCookieManagerEventPixelMapping()) - } - - public init(subscriptionManager: SubscriptionManager, - currentCookieStore: @MainActor @escaping () -> HTTPCookieStore?, - eventMapping: EventMapping) { - - } - + public init() {} public func enableSettingSubscriptionCookie() { } public func disableSettingSubscriptionCookie() async { } public func refreshSubscriptionCookie() async { } diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift index ef39c4d04..1a4938144 100644 --- a/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift +++ b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift @@ -18,17 +18,18 @@ import Foundation import Subscription +import Networking -public final class SubscriptionFeatureMappingCacheMock: SubscriptionFeatureMappingCache { +public final class SubscriptionFeatureMappingCacheMock: SubscriptionFeatureMappingCacheV2 { public var didCallSubscriptionFeatures = false public var lastCalledSubscriptionId: String? - public var mapping: [String: [Entitlement.ProductName]] = [:] + public var mapping: [String: [SubscriptionEntitlement]] = [:] public init() { } - public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [Entitlement.ProductName] { + public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [SubscriptionEntitlement] { didCallSubscriptionFeatures = true lastCalledSubscriptionId = subscriptionIdentifier return mapping[subscriptionIdentifier] ?? [] diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionMockFactory.swift b/Sources/SubscriptionTestingUtilities/SubscriptionMockFactory.swift index 99776fa1e..f508ee0f4 100644 --- a/Sources/SubscriptionTestingUtilities/SubscriptionMockFactory.swift +++ b/Sources/SubscriptionTestingUtilities/SubscriptionMockFactory.swift @@ -22,14 +22,14 @@ import Foundation /// 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 subscription = Subscription(productId: UUID().uuidString, + public static let subscription = PrivacyProSubscription(productId: UUID().uuidString, name: "Subscription test #1", billingPeriod: .monthly, startedAt: Date(), expiresOrRenewsAt: Date().addingTimeInterval(TimeInterval.days(+30)), platform: .apple, status: .autoRenewable) - public static let expiredSubscription = Subscription(productId: UUID().uuidString, + public static let expiredSubscription = PrivacyProSubscription(productId: UUID().uuidString, name: "Subscription test #2", billingPeriod: .monthly, startedAt: Date().addingTimeInterval(TimeInterval.days(-31)), @@ -37,7 +37,7 @@ public struct SubscriptionMockFactory { platform: .apple, status: .expired) - public static let expiredStripeSubscription = Subscription(productId: UUID().uuidString, + public static let expiredStripeSubscription = PrivacyProSubscription(productId: UUID().uuidString, name: "Subscription test #2", billingPeriod: .monthly, startedAt: Date().addingTimeInterval(TimeInterval.days(-31)), diff --git a/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift b/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift deleted file mode 100644 index 3fdae38a4..000000000 --- a/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift +++ /dev/null @@ -1,319 +0,0 @@ -// -// 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 { - - private struct Constants { - static let authToken = UUID().uuidString - static let accessToken = UUID().uuidString - static let externalID = UUID().uuidString - static let email = "dax@duck.com" - - static let mostRecentTransactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" - - static let authorizationHeader = ["Authorization": "Bearer TOKEN"] - - static let invalidTokenError = APIServiceError.serverError(statusCode: 401, error: "invalid_token") - } - - var apiService: APIServiceMock! - var authService: AuthEndpointService! - - override func setUpWithError() throws { - apiService = APIServiceMock() - authService = DefaultAuthEndpointService(currentServiceEnvironment: .staging, apiService: apiService) - } - - override func tearDownWithError() throws { - apiService = nil - authService = nil - } - - // MARK: - Tests for getAccessToken - - func testGetAccessTokenCall() async throws { - // Given - let apiServiceCalledExpectation = expectation(description: "apiService") - - apiService.mockAuthHeaders = Constants.authorizationHeader - apiService.onExecuteAPICall = { parameters in - let (method, endpoint, headers, _) = parameters - - apiServiceCalledExpectation.fulfill() - XCTAssertEqual(method, "GET") - XCTAssertEqual(endpoint, "access-token") - XCTAssertEqual(headers, Constants.authorizationHeader) - } - - // When - _ = await authService.getAccessToken(token: Constants.authToken) - - // Then - await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) - } - - func testGetAccessTokenSuccess() async throws { - // Given - apiService.mockAuthHeaders = Constants.authorizationHeader - apiService.mockResponseJSONData = """ - { - "accessToken": "\(Constants.accessToken)", - } - """.data(using: .utf8)! - - // When - let result = await authService.getAccessToken(token: Constants.authToken) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success.accessToken, Constants.accessToken) - case .failure: - XCTFail("Unexpected failure") - } - } - - func testGetAccessTokenError() async throws { - // Given - apiService.mockAuthHeaders = Constants.authorizationHeader - apiService.mockAPICallError = Constants.invalidTokenError - - // When - let result = await authService.getAccessToken(token: Constants.authToken) - - // Then - switch result { - case .success: - XCTFail("Unexpected success") - case .failure: - break - } - } - - // MARK: - Tests for validateToken - - func testValidateTokenCall() async throws { - // Given - let apiServiceCalledExpectation = expectation(description: "apiService") - - apiService.mockAuthHeaders = Constants.authorizationHeader - apiService.onExecuteAPICall = { parameters in - let (method, endpoint, headers, _) = parameters - - apiServiceCalledExpectation.fulfill() - XCTAssertEqual(method, "GET") - XCTAssertEqual(endpoint, "validate-token") - XCTAssertEqual(headers, Constants.authorizationHeader) - } - - // When - _ = await authService.validateToken(accessToken: Constants.accessToken) - - // Then - await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) - } - - func testValidateTokenSuccess() async throws { - // Given - apiService.mockAuthHeaders = Constants.authorizationHeader - apiService.mockResponseJSONData = """ - { - "account": { - "id": 149718, - "external_id": "\(Constants.externalID)", - "email": "\(Constants.email)", - "entitlements": [ - {"id":24, "name":"subscriber", "product":"Network Protection"}, - {"id":25, "name":"subscriber", "product":"Data Broker Protection"}, - {"id":26, "name":"subscriber", "product":"Identity Theft Restoration"} - ] - } - } - """.data(using: .utf8)! - - // When - let result = await authService.validateToken(accessToken: Constants.accessToken) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success.account.externalID, Constants.externalID) - XCTAssertEqual(success.account.email, Constants.email) - XCTAssertEqual(success.account.entitlements.count, 3) - case .failure: - XCTFail("Unexpected failure") - } - } - - func testValidateTokenError() async throws { - // Given - apiService.mockAuthHeaders = Constants.authorizationHeader - apiService.mockAPICallError = Constants.invalidTokenError - - // When - let result = await authService.validateToken(accessToken: Constants.accessToken) - - // Then - switch result { - case .success: - XCTFail("Unexpected success") - case .failure: - break - } - } - - // MARK: - Tests for createAccount - - func testCreateAccountCall() async throws { - // Given - let apiServiceCalledExpectation = expectation(description: "apiService") - - apiService.onExecuteAPICall = { parameters in - let (method, endpoint, headers, _) = parameters - - apiServiceCalledExpectation.fulfill() - XCTAssertEqual(method, "POST") - XCTAssertEqual(endpoint, "account/create") - XCTAssertNil(headers) - } - - // When - _ = await authService.createAccount(emailAccessToken: nil) - - // Then - await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) - } - - func testCreateAccountSuccess() async throws { - // Given - apiService.mockResponseJSONData = """ - { - "auth_token": "\(Constants.authToken)", - "external_id": "\(Constants.externalID)", - "status": "created" - } - """.data(using: .utf8)! - - // When - let result = await authService.createAccount(emailAccessToken: nil) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success.authToken, Constants.authToken) - XCTAssertEqual(success.externalID, Constants.externalID) - XCTAssertEqual(success.status, "created") - case .failure: - XCTFail("Unexpected failure") - } - } - - func testCreateAccountError() async throws { - // Given - apiService.mockAuthHeaders = Constants.authorizationHeader - apiService.mockAPICallError = Constants.invalidTokenError - - // When - let result = await authService.createAccount(emailAccessToken: nil) - - // Then - switch result { - case .success: - XCTFail("Unexpected success") - case .failure: - break - } - } - - // MARK: - Tests for storeLogin - - func testStoreLoginCall() async throws { - // Given - let apiServiceCalledExpectation = expectation(description: "apiService") - - apiService.onExecuteAPICall = { parameters in - let (method, endpoint, headers, body) = parameters - - apiServiceCalledExpectation.fulfill() - XCTAssertEqual(method, "POST") - XCTAssertEqual(endpoint, "store-login") - XCTAssertNil(headers) - - if let bodyDict = try? JSONDecoder().decode([String: String].self, from: body!) { - XCTAssertEqual(bodyDict["signature"], Constants.mostRecentTransactionJWS) - XCTAssertEqual(bodyDict["store"], "apple_app_store") - } else { - XCTFail("Failed to decode body") - } - } - - // When - _ = await authService.storeLogin(signature: Constants.mostRecentTransactionJWS) - - // Then - await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) - } - - func testStoreLoginSuccess() async throws { - // Given - apiService.mockResponseJSONData = """ - { - "auth_token": "\(Constants.authToken)", - "email": "\(Constants.email)", - "external_id": "\(Constants.externalID)", - "id": 1, - "status": "ok" - } - """.data(using: .utf8)! - - // When - let result = await authService.storeLogin(signature: Constants.mostRecentTransactionJWS) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success.authToken, Constants.authToken) - XCTAssertEqual(success.email, Constants.email) - XCTAssertEqual(success.externalID, Constants.externalID) - XCTAssertEqual(success.id, 1) - XCTAssertEqual(success.status, "ok") - case .failure: - XCTFail("Unexpected failure") - } - } - - func testStoreLoginError() async throws { - // Given - apiService.mockAPICallError = Constants.invalidTokenError - - // When - let result = await authService.storeLogin(signature: Constants.mostRecentTransactionJWS) - - // Then - switch result { - case .success: - XCTFail("Unexpected success") - case .failure: - break - } - } -} diff --git a/Tests/SubscriptionTests/API/Models/EntitlementTests.swift b/Tests/SubscriptionTests/API/Models/EntitlementTests.swift deleted file mode 100644 index 25409abce..000000000 --- a/Tests/SubscriptionTests/API/Models/EntitlementTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// 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 { - - func testEquality() throws { - XCTAssertEqual(Entitlement(product: .dataBrokerProtection), Entitlement(product: .dataBrokerProtection)) - XCTAssertNotEqual(Entitlement(product: .dataBrokerProtection), Entitlement(product: .networkProtection)) - } - - func testDecoding() throws { - let rawNetPEntitlement = "{\"id\":24,\"name\":\"subscriber\",\"product\":\"Network Protection\"}" - let netPEntitlement = try JSONDecoder().decode(Entitlement.self, from: Data(rawNetPEntitlement.utf8)) - XCTAssertEqual(netPEntitlement, Entitlement(product: .networkProtection)) - - let rawDBPEntitlement = "{\"id\":25,\"name\":\"subscriber\",\"product\":\"Data Broker Protection\"}" - let dbpEntitlement = try JSONDecoder().decode(Entitlement.self, from: Data(rawDBPEntitlement.utf8)) - XCTAssertEqual(dbpEntitlement, Entitlement(product: .dataBrokerProtection)) - - let rawITREntitlement = "{\"id\":26,\"name\":\"subscriber\",\"product\":\"Identity Theft Restoration\"}" - let itrEntitlement = try JSONDecoder().decode(Entitlement.self, from: Data(rawITREntitlement.utf8)) - XCTAssertEqual(itrEntitlement, Entitlement(product: .identityTheftRestoration)) - - let rawUnexpectedEntitlement = "{\"id\":27,\"name\":\"subscriber\",\"product\":\"something unexpected\"}" - let unexpectedEntitlement = try JSONDecoder().decode(Entitlement.self, from: Data(rawUnexpectedEntitlement.utf8)) - XCTAssertEqual(unexpectedEntitlement, Entitlement(product: .unknown)) - } -} diff --git a/Tests/SubscriptionTests/API/Models/SubscriptionEntitlementTests.swift b/Tests/SubscriptionTests/API/Models/SubscriptionEntitlementTests.swift new file mode 100644 index 000000000..0903c84a3 --- /dev/null +++ b/Tests/SubscriptionTests/API/Models/SubscriptionEntitlementTests.swift @@ -0,0 +1,48 @@ +// +// SubscriptionEntitlementTests.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 +@testable import Networking +import SubscriptionTestingUtilities + +final class SubscriptionEntitlementTests: XCTestCase { + + func testEquality() throws { + XCTAssertEqual(SubscriptionEntitlement.dataBrokerProtection, SubscriptionEntitlement.dataBrokerProtection) + XCTAssertNotEqual(SubscriptionEntitlement.dataBrokerProtection, SubscriptionEntitlement.networkProtection) + } + + func testDecoding() throws { + let rawNetPEntitlement = "Network Protection" + let netPEntitlement = SubscriptionEntitlement(rawValue: rawNetPEntitlement) + XCTAssertEqual(netPEntitlement, SubscriptionEntitlement.networkProtection) + + let rawDBPEntitlement = "Data Broker Protection" + let dbpEntitlement = SubscriptionEntitlement(rawValue: rawDBPEntitlement) + XCTAssertEqual(dbpEntitlement, SubscriptionEntitlement.dataBrokerProtection) + + let rawITREntitlement = "Identity Theft Restoration" + let itrEntitlement = SubscriptionEntitlement(rawValue: rawITREntitlement) + XCTAssertEqual(itrEntitlement, SubscriptionEntitlement.identityTheftRestoration) + + let rawUnexpectedEntitlement = "something unexpected" + let unexpectedEntitlement = SubscriptionEntitlement(rawValue: rawUnexpectedEntitlement) + XCTAssertNil(unexpectedEntitlement) + } +} diff --git a/Tests/SubscriptionTests/API/Models/SubscriptionTests.swift b/Tests/SubscriptionTests/API/Models/SubscriptionTests.swift index 59106b277..fc2d2e874 100644 --- a/Tests/SubscriptionTests/API/Models/SubscriptionTests.swift +++ b/Tests/SubscriptionTests/API/Models/SubscriptionTests.swift @@ -23,48 +23,48 @@ import SubscriptionTestingUtilities final class SubscriptionTests: XCTestCase { 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) + let a = PrivacyProSubscription(productId: "1", + name: "a", + billingPeriod: .monthly, + startedAt: Date(timeIntervalSince1970: 1000), + expiresOrRenewsAt: Date(timeIntervalSince1970: 2000), + platform: .apple, + status: .autoRenewable) + let b = PrivacyProSubscription(productId: "1", + name: "a", + billingPeriod: .monthly, + startedAt: Date(timeIntervalSince1970: 1000), + expiresOrRenewsAt: Date(timeIntervalSince1970: 2000), + platform: .apple, + status: .autoRenewable) + let c = PrivacyProSubscription(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 testIfSubscriptionWithGivenStatusIsActive() throws { - let autoRenewableSubscription = Subscription.make(withStatus: .autoRenewable) + let autoRenewableSubscription = PrivacyProSubscription.make(withStatus: .autoRenewable) XCTAssertTrue(autoRenewableSubscription.isActive) - let notAutoRenewableSubscription = Subscription.make(withStatus: .notAutoRenewable) + let notAutoRenewableSubscription = PrivacyProSubscription.make(withStatus: .notAutoRenewable) XCTAssertTrue(notAutoRenewableSubscription.isActive) - let gracePeriodSubscription = Subscription.make(withStatus: .gracePeriod) + let gracePeriodSubscription = PrivacyProSubscription.make(withStatus: .gracePeriod) XCTAssertTrue(gracePeriodSubscription.isActive) - let inactiveSubscription = Subscription.make(withStatus: .inactive) + let inactiveSubscription = PrivacyProSubscription.make(withStatus: .inactive) XCTAssertFalse(inactiveSubscription.isActive) - let expiredSubscription = Subscription.make(withStatus: .expired) + let expiredSubscription = PrivacyProSubscription.make(withStatus: .expired) XCTAssertFalse(expiredSubscription.isActive) - let unknownSubscription = Subscription.make(withStatus: .unknown) + let unknownSubscription = PrivacyProSubscription.make(withStatus: .unknown) XCTAssertTrue(unknownSubscription.isActive) } @@ -74,7 +74,7 @@ final class SubscriptionTests: XCTestCase { let decoder = JSONDecoder() decoder.keyDecodingStrategy = .convertFromSnakeCase decoder.dateDecodingStrategy = .millisecondsSince1970 - let subscription = try decoder.decode(Subscription.self, from: Data(rawSubscription.utf8)) + let subscription = try decoder.decode(PrivacyProSubscription.self, from: Data(rawSubscription.utf8)) XCTAssertEqual(subscription.productId, "ddg-privacy-pro-sandbox-monthly-renews-us") XCTAssertEqual(subscription.name, "Monthly Subscription") @@ -85,60 +85,60 @@ final class SubscriptionTests: XCTestCase { } func testBillingPeriodDecoding() throws { - let monthly = try JSONDecoder().decode(Subscription.BillingPeriod.self, from: Data("\"Monthly\"".utf8)) - XCTAssertEqual(monthly, Subscription.BillingPeriod.monthly) + let monthly = try JSONDecoder().decode(PrivacyProSubscription.BillingPeriod.self, from: Data("\"Monthly\"".utf8)) + XCTAssertEqual(monthly, PrivacyProSubscription.BillingPeriod.monthly) - let yearly = try JSONDecoder().decode(Subscription.BillingPeriod.self, from: Data("\"Yearly\"".utf8)) - XCTAssertEqual(yearly, Subscription.BillingPeriod.yearly) + let yearly = try JSONDecoder().decode(PrivacyProSubscription.BillingPeriod.self, from: Data("\"Yearly\"".utf8)) + XCTAssertEqual(yearly, PrivacyProSubscription.BillingPeriod.yearly) - let unknown = try JSONDecoder().decode(Subscription.BillingPeriod.self, from: Data("\"something unexpected\"".utf8)) - XCTAssertEqual(unknown, Subscription.BillingPeriod.unknown) + let unknown = try JSONDecoder().decode(PrivacyProSubscription.BillingPeriod.self, from: Data("\"something unexpected\"".utf8)) + XCTAssertEqual(unknown, PrivacyProSubscription.BillingPeriod.unknown) } func testPlatformDecoding() throws { - let apple = try JSONDecoder().decode(Subscription.Platform.self, from: Data("\"apple\"".utf8)) - XCTAssertEqual(apple, Subscription.Platform.apple) + let apple = try JSONDecoder().decode(PrivacyProSubscription.Platform.self, from: Data("\"apple\"".utf8)) + XCTAssertEqual(apple, PrivacyProSubscription.Platform.apple) - let google = try JSONDecoder().decode(Subscription.Platform.self, from: Data("\"google\"".utf8)) - XCTAssertEqual(google, Subscription.Platform.google) + let google = try JSONDecoder().decode(PrivacyProSubscription.Platform.self, from: Data("\"google\"".utf8)) + XCTAssertEqual(google, PrivacyProSubscription.Platform.google) - let stripe = try JSONDecoder().decode(Subscription.Platform.self, from: Data("\"stripe\"".utf8)) - XCTAssertEqual(stripe, Subscription.Platform.stripe) + let stripe = try JSONDecoder().decode(PrivacyProSubscription.Platform.self, from: Data("\"stripe\"".utf8)) + XCTAssertEqual(stripe, PrivacyProSubscription.Platform.stripe) - let unknown = try JSONDecoder().decode(Subscription.Platform.self, from: Data("\"something unexpected\"".utf8)) - XCTAssertEqual(unknown, Subscription.Platform.unknown) + let unknown = try JSONDecoder().decode(PrivacyProSubscription.Platform.self, from: Data("\"something unexpected\"".utf8)) + XCTAssertEqual(unknown, PrivacyProSubscription.Platform.unknown) } func testStatusDecoding() throws { - let autoRenewable = try JSONDecoder().decode(Subscription.Status.self, from: Data("\"Auto-Renewable\"".utf8)) - XCTAssertEqual(autoRenewable, Subscription.Status.autoRenewable) + let autoRenewable = try JSONDecoder().decode(PrivacyProSubscription.Status.self, from: Data("\"Auto-Renewable\"".utf8)) + XCTAssertEqual(autoRenewable, PrivacyProSubscription.Status.autoRenewable) - let notAutoRenewable = try JSONDecoder().decode(Subscription.Status.self, from: Data("\"Not Auto-Renewable\"".utf8)) - XCTAssertEqual(notAutoRenewable, Subscription.Status.notAutoRenewable) + let notAutoRenewable = try JSONDecoder().decode(PrivacyProSubscription.Status.self, from: Data("\"Not Auto-Renewable\"".utf8)) + XCTAssertEqual(notAutoRenewable, PrivacyProSubscription.Status.notAutoRenewable) - let gracePeriod = try JSONDecoder().decode(Subscription.Status.self, from: Data("\"Grace Period\"".utf8)) - XCTAssertEqual(gracePeriod, Subscription.Status.gracePeriod) + let gracePeriod = try JSONDecoder().decode(PrivacyProSubscription.Status.self, from: Data("\"Grace Period\"".utf8)) + XCTAssertEqual(gracePeriod, PrivacyProSubscription.Status.gracePeriod) - let inactive = try JSONDecoder().decode(Subscription.Status.self, from: Data("\"Inactive\"".utf8)) - XCTAssertEqual(inactive, Subscription.Status.inactive) + let inactive = try JSONDecoder().decode(PrivacyProSubscription.Status.self, from: Data("\"Inactive\"".utf8)) + XCTAssertEqual(inactive, PrivacyProSubscription.Status.inactive) - let expired = try JSONDecoder().decode(Subscription.Status.self, from: Data("\"Expired\"".utf8)) - XCTAssertEqual(expired, Subscription.Status.expired) + let expired = try JSONDecoder().decode(PrivacyProSubscription.Status.self, from: Data("\"Expired\"".utf8)) + XCTAssertEqual(expired, PrivacyProSubscription.Status.expired) - let unknown = try JSONDecoder().decode(Subscription.Status.self, from: Data("\"something unexpected\"".utf8)) - XCTAssertEqual(unknown, Subscription.Status.unknown) + let unknown = try JSONDecoder().decode(PrivacyProSubscription.Status.self, from: Data("\"something unexpected\"".utf8)) + XCTAssertEqual(unknown, PrivacyProSubscription.Status.unknown) } } -extension Subscription { +extension PrivacyProSubscription { - static func make(withStatus status: Subscription.Status) -> Subscription { - Subscription(productId: UUID().uuidString, - name: "Subscription test #1", - billingPeriod: .monthly, - startedAt: Date(), - expiresOrRenewsAt: Date().addingTimeInterval(TimeInterval.days(+30)), - platform: .apple, - status: status) + static func make(withStatus status: PrivacyProSubscription.Status) -> PrivacyProSubscription { + PrivacyProSubscription(productId: UUID().uuidString, + name: "Subscription test #1", + billingPeriod: .monthly, + startedAt: Date(), + expiresOrRenewsAt: Date().addingTimeInterval(TimeInterval.days(+30)), + platform: .apple, + status: status) } } diff --git a/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift index 1511af841..ab7caa191 100644 --- a/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift +++ b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift @@ -18,15 +18,250 @@ import XCTest @testable import Subscription +@testable import Networking import SubscriptionTestingUtilities +import NetworkingTestingUtils +import Common +final class SubscriptionEndpointServiceTests: XCTestCase { + private var apiService: MockAPIService! + private var endpointService: DefaultSubscriptionEndpointService! + private let baseURL = SubscriptionEnvironment.ServiceEnvironment.staging.url + private let disposableCache = UserDefaultsCache(key: UserDefaultsCacheKeyKest.subscriptionTest, + settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) + private enum UserDefaultsCacheKeyKest: String, UserDefaultsCacheKeyStore { + case subscriptionTest = "com.duckduckgo.bsk.subscription.info.testing" + } + private var encoder: JSONEncoder! + + override func setUp() { + super.setUp() + encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + apiService = MockAPIService() + endpointService = DefaultSubscriptionEndpointService(apiService: apiService, + baseURL: baseURL, + subscriptionCache: disposableCache) + } + + override func tearDown() { + disposableCache.reset() + apiService = nil + endpointService = nil + super.tearDown() + } + + // MARK: - Helpers + + private func createSubscriptionResponseData() -> Data { + let date = Date(timeIntervalSince1970: 123456789) + let subscription = PrivacyProSubscription( + productId: "prod123", + name: "Pro Plan", + billingPeriod: .yearly, + startedAt: date, + expiresOrRenewsAt: date.addingTimeInterval(30 * 24 * 60 * 60), + platform: .apple, + status: .autoRenewable + ) + return try! encoder.encode(subscription) + } + + private func createAPIResponse(statusCode: Int, data: Data?) -> APIResponseV2 { + let response = HTTPURLResponse( + url: baseURL, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + )! + return APIResponseV2(data: data, httpResponse: response) + } + + // MARK: - getSubscription Tests + + func testGetSubscriptionReturnsCachedSubscription() async throws { + let date = Date(timeIntervalSince1970: 123456789) + let cachedSubscription = PrivacyProSubscription( + productId: "prod123", + name: "Pro Plan", + billingPeriod: .monthly, + startedAt: date, + expiresOrRenewsAt: date.addingTimeInterval(30 * 24 * 60 * 60), + platform: .google, + status: .autoRenewable + ) + endpointService.updateCache(with: cachedSubscription) + + let subscription = try await endpointService.getSubscription(accessToken: "token", cachePolicy: .returnCacheDataDontLoad) + XCTAssertEqual(subscription, cachedSubscription) + } + + func testGetSubscriptionFetchesRemoteSubscriptionWhenNoCache() async throws { + // mock subscription response + let subscriptionData = createSubscriptionResponseData() + let apiResponse = createAPIResponse(statusCode: 200, data: subscriptionData) + let request = SubscriptionRequest.getSubscription(baseURL: baseURL, accessToken: "token")!.apiRequest + + // mock features + SubscriptionAPIMockResponseFactory.mockGetFeatures(destinationMockAPIService: apiService, success: true, subscriptionID: "prod123") + + apiService.set(response: apiResponse, forRequest: request) + + let subscription = try await endpointService.getSubscription(accessToken: "token", cachePolicy: .returnCacheDataElseLoad) + XCTAssertEqual(subscription.productId, "prod123") + XCTAssertEqual(subscription.name, "Pro Plan") + XCTAssertEqual(subscription.billingPeriod, .yearly) + XCTAssertEqual(subscription.platform, .apple) + XCTAssertEqual(subscription.status, .autoRenewable) + } + + func testGetSubscriptionThrowsNoDataWhenNoCacheAndFetchFails() async { + do { + _ = try await endpointService.getSubscription(accessToken: "token", cachePolicy: .returnCacheDataDontLoad) + XCTFail("Expected noData error") + } catch SubscriptionEndpointServiceError.noData { + // Success + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + // MARK: - getProducts Tests + + func testGetProductsReturnsListOfProducts() async throws { + let productItems = [ + GetProductsItem( + productId: "prod1", + productLabel: "Product 1", + billingPeriod: "Monthly", + price: "9.99", + currency: "USD" + ), + GetProductsItem( + productId: "prod2", + productLabel: "Product 2", + billingPeriod: "Yearly", + price: "99.99", + currency: "USD" + ) + ] + let productData = try encoder.encode(productItems) + let apiResponse = createAPIResponse(statusCode: 200, data: productData) + let request = SubscriptionRequest.getProducts(baseURL: baseURL)!.apiRequest + + apiService.set(response: apiResponse, forRequest: request) + + let products = try await endpointService.getProducts() + XCTAssertEqual(products, productItems) + } + + func testGetProductsThrowsInvalidResponse() async { + let request = SubscriptionRequest.getProducts(baseURL: baseURL)!.apiRequest + let apiResponse = createAPIResponse(statusCode: 200, data: nil) + apiService.set(response: apiResponse, forRequest: request) + do { + _ = try await endpointService.getProducts() + XCTFail("Expected invalidResponse error") + } catch Networking.APIRequestV2.Error.emptyResponseBody { + // Success + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + // MARK: - getCustomerPortalURL Tests + + func testGetCustomerPortalURLReturnsCorrectURL() async throws { + let portalResponse = GetCustomerPortalURLResponse(customerPortalUrl: "https://portal.example.com") + let portalData = try encoder.encode(portalResponse) + let apiResponse = createAPIResponse(statusCode: 200, data: portalData) + let request = SubscriptionRequest.getCustomerPortalURL(baseURL: baseURL, accessToken: "token", externalID: "id")!.apiRequest + + apiService.set(response: apiResponse, forRequest: request) + + let customerPortalURL = try await endpointService.getCustomerPortalURL(accessToken: "token", externalID: "id") + XCTAssertEqual(customerPortalURL, portalResponse) + } + + // MARK: - confirmPurchase Tests + + func testConfirmPurchaseReturnsCorrectResponse() async throws { + let date = Date(timeIntervalSince1970: 123456789) + let confirmResponse = ConfirmPurchaseResponseV2( + email: "user@example.com", +// entitlements: nil, + subscription: PrivacyProSubscription( + productId: "prod123", + name: "Pro Plan", + billingPeriod: .monthly, + startedAt: date, + expiresOrRenewsAt: date.addingTimeInterval(30 * 24 * 60 * 60), + platform: .stripe, + status: .gracePeriod + ) + ) + let confirmData = try encoder.encode(confirmResponse) + let apiResponse = createAPIResponse(statusCode: 200, data: confirmData) + let request = SubscriptionRequest.confirmPurchase(baseURL: baseURL, accessToken: "token", signature: "signature", additionalParams: nil)!.apiRequest + + apiService.set(response: apiResponse, forRequest: request) + + let purchaseResponse = try await endpointService.confirmPurchase(accessToken: "token", signature: "signature", additionalParams: nil) + XCTAssertEqual(purchaseResponse.email, confirmResponse.email) + XCTAssertEqual(purchaseResponse.subscription, confirmResponse.subscription) + } + + // MARK: - Cache Tests + + func testUpdateCacheStoresSubscription() async throws { + let date = Date(timeIntervalSince1970: 123456789) + let subscription = PrivacyProSubscription( + productId: "prod123", + name: "Pro Plan", + billingPeriod: .monthly, + startedAt: date, + expiresOrRenewsAt: date.addingTimeInterval(30 * 24 * 60 * 60), + platform: .google, + status: .autoRenewable + ) + endpointService.updateCache(with: subscription) + + let cachedSubscription = try await endpointService.getSubscription(accessToken: "token", cachePolicy: .returnCacheDataDontLoad) + XCTAssertEqual(cachedSubscription, subscription) + } + + func testClearSubscriptionRemovesCachedSubscription() async throws { + let date = Date(timeIntervalSince1970: 123456789) + let subscription = PrivacyProSubscription( + productId: "prod123", + name: "Pro Plan", + billingPeriod: .monthly, + startedAt: date, + expiresOrRenewsAt: date.addingTimeInterval(30 * 24 * 60 * 60), + platform: .apple, + status: .autoRenewable + ) + endpointService.updateCache(with: subscription) + + endpointService.clearSubscription() + do { + _ = try await endpointService.getSubscription(accessToken: "token", cachePolicy: .returnCacheDataDontLoad) + } catch SubscriptionEndpointServiceError.noData { + // Success + } catch { + XCTFail("Wrong error: \(error)") + } + } +} + +/* final class SubscriptionEndpointServiceTests: XCTestCase { private struct Constants { - static let authToken = UUID().uuidString - static let accessToken = UUID().uuidString - static let externalID = UUID().uuidString - static let email = "dax@duck.com" +// static let tokenContainer = OAuthTokensFactory.makeValidTokenContainer() +// static let accessToken = UUID().uuidString +// static let externalID = UUID().uuidString +// static let email = "dax@duck.com" static let mostRecentTransactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" @@ -36,15 +271,15 @@ final class SubscriptionEndpointServiceTests: XCTestCase { static let authorizationHeader = ["Authorization": "Bearer TOKEN"] - static let unknownServerError = APIServiceError.serverError(statusCode: 401, error: "unknown_error") +// static let unknownServerError = APIServiceError.serverError(statusCode: 401, error: "unknown_error") } - var apiService: APIServiceMock! - var subscriptionService: SubscriptionEndpointService! + var apiService: MockAPIService! + var subscriptionService: SubscriptionEndpointServiceV2! override func setUpWithError() throws { - apiService = APIServiceMock() - subscriptionService = DefaultSubscriptionEndpointService(currentServiceEnvironment: .staging, apiService: apiService) + apiService = MockAPIService() + subscriptionService = DefaultSubscriptionEndpointService(apiService: apiService, baseURL: URL(string: "https://something_tests.com")!) } override func tearDownWithError() throws { @@ -433,3 +668,4 @@ final class SubscriptionEndpointServiceTests: XCTestCase { } } } +*/ diff --git a/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift deleted file mode 100644 index e2c7f95c7..000000000 --- a/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift +++ /dev/null @@ -1,184 +0,0 @@ -// -// 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 { - - private struct Constants { - static let oldAuthToken = UUID().uuidString - static let newAuthToken = UUID().uuidString - - static let externalID = UUID ().uuidString - static let otherExternalID = UUID().uuidString - - static let email = "dax@duck.com" - - static let mostRecentTransactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" - - static let invalidTokenError = APIServiceError.serverError(statusCode: 401, error: "invalid_token") - - static let entitlements = [Entitlement(product: .dataBrokerProtection), - Entitlement(product: .identityTheftRestoration), - Entitlement(product: .networkProtection)] - } - - var accountManager: AccountManagerMock! - var authEndpointService: AuthEndpointServiceMock! - var storePurchaseManager: StorePurchaseManagerMock! - - var appStoreAccountManagementFlow: AppStoreAccountManagementFlow! - - override func setUpWithError() throws { - accountManager = AccountManagerMock() - authEndpointService = AuthEndpointServiceMock() - storePurchaseManager = StorePurchaseManagerMock() - - appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(authEndpointService: authEndpointService, - storePurchaseManager: storePurchaseManager, - accountManager: accountManager) - } - - override func tearDownWithError() throws { - accountManager = nil - authEndpointService = nil - storePurchaseManager = nil - - appStoreAccountManagementFlow = nil - } - - // MARK: - Tests for refreshAuthTokenIfNeeded - - func testRefreshAuthTokenIfNeededSuccess() async throws { - // Given - accountManager.authToken = Constants.oldAuthToken - accountManager.externalID = Constants.externalID - - authEndpointService.validateTokenResult = .failure(Constants.invalidTokenError) - - storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS - - authEndpointService.storeLoginResult = .success(StoreLoginResponse(authToken: Constants.newAuthToken, - email: "", - externalID: Constants.externalID, - id: 1, - status: "authenticated")) - - // When - switch await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() { - case .success(let success): - // Then - XCTAssertTrue(storePurchaseManager.mostRecentTransactionCalled) - XCTAssertEqual(success, Constants.newAuthToken) - XCTAssertEqual(accountManager.authToken, Constants.newAuthToken) - case .failure(let error): - XCTFail("Unexpected failure: \(String(reflecting: error))") - } - } - - func testRefreshAuthTokenIfNeededSuccessButNotRefreshedIfStillValid() async throws { - // Given - accountManager.authToken = Constants.oldAuthToken - - authEndpointService.validateTokenResult = .success(ValidateTokenResponse(account: .init(email: Constants.email, - entitlements: Constants.entitlements, - externalID: Constants.externalID))) - - // When - switch await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() { - case .success(let success): - // Then - XCTAssertEqual(success, Constants.oldAuthToken) - XCTAssertEqual(accountManager.authToken, Constants.oldAuthToken) - case .failure(let error): - XCTFail("Unexpected failure: \(String(reflecting: error))") - } - } - - func testRefreshAuthTokenIfNeededSuccessButNotRefreshedIfStoreLoginRetrievedDifferentAccount() async throws { - // Given - accountManager.authToken = Constants.oldAuthToken - accountManager.externalID = Constants.externalID - accountManager.email = Constants.email - - authEndpointService.validateTokenResult = .failure(Constants.invalidTokenError) - - storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS - - authEndpointService.storeLoginResult = .success(StoreLoginResponse(authToken: Constants.newAuthToken, - email: "", - externalID: Constants.otherExternalID, - id: 1, - status: "authenticated")) - - // When - switch await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() { - case .success(let success): - // Then - XCTAssertTrue(storePurchaseManager.mostRecentTransactionCalled) - XCTAssertEqual(success, Constants.oldAuthToken) - XCTAssertEqual(accountManager.authToken, Constants.oldAuthToken) - XCTAssertEqual(accountManager.externalID, Constants.externalID) - XCTAssertEqual(accountManager.email, Constants.email) - case .failure(let error): - XCTFail("Unexpected failure: \(String(reflecting: error))") - } - } - - func testRefreshAuthTokenIfNeededErrorDueToNoPastTransactions() async throws { - // Given - accountManager.authToken = Constants.oldAuthToken - - authEndpointService.validateTokenResult = .failure(Constants.invalidTokenError) - - storePurchaseManager.mostRecentTransactionResult = nil - - // When - switch await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() { - case .success: - XCTFail("Unexpected success") - case .failure(let error): - // Then - XCTAssertTrue(storePurchaseManager.mostRecentTransactionCalled) - XCTAssertEqual(error, .noPastTransaction) - } - } - - func testRefreshAuthTokenIfNeededErrorDueToStoreLoginFailure() async throws { - // Given - accountManager.authToken = Constants.oldAuthToken - - authEndpointService.validateTokenResult = .failure(Constants.invalidTokenError) - - storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS - - authEndpointService.storeLoginResult = .failure(.unknownServerError) - - // When - switch await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() { - case .success: - XCTFail("Unexpected success") - case .failure(let error): - // Then - XCTAssertTrue(storePurchaseManager.mostRecentTransactionCalled) - XCTAssertEqual(error, .authenticatingWithTransactionFailed) - } - } -} diff --git a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift index 1a7345442..aa19551a9 100644 --- a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift +++ b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift @@ -18,51 +18,184 @@ import XCTest @testable import Subscription +@testable import Networking import SubscriptionTestingUtilities +import NetworkingTestingUtils + +@available(macOS 12.0, iOS 15.0, *) +final class DefaultAppStorePurchaseFlowTests: XCTestCase { + + private var sut: DefaultAppStorePurchaseFlowV2! + private var subscriptionManagerMock: SubscriptionManagerMock! + private var storePurchaseManagerMock: StorePurchaseManagerMock! + private var appStoreRestoreFlowMock: AppStoreRestoreFlowMock! + + override func setUp() { + super.setUp() + subscriptionManagerMock = SubscriptionManagerMock() + storePurchaseManagerMock = StorePurchaseManagerMock() + appStoreRestoreFlowMock = AppStoreRestoreFlowMock() + sut = DefaultAppStorePurchaseFlowV2( + subscriptionManager: subscriptionManagerMock, + storePurchaseManager: storePurchaseManagerMock, + appStoreRestoreFlow: appStoreRestoreFlowMock + ) + } + + override func tearDown() { + sut = nil + subscriptionManagerMock = nil + storePurchaseManagerMock = nil + appStoreRestoreFlowMock = nil + super.tearDown() + } + + // MARK: - purchaseSubscription Tests + + func test_purchaseSubscription_withActiveSubscriptionAlreadyPresent_returnsError() async { + appStoreRestoreFlowMock.restoreAccountFromPastPurchaseResult = .success("someTransactionJWS") + + let result = await sut.purchaseSubscription(with: "testSubscriptionID") + + XCTAssertTrue(appStoreRestoreFlowMock.restoreAccountFromPastPurchaseCalled) + XCTAssertEqual(result, .failure(.activeSubscriptionAlreadyPresent)) + } + + func test_purchaseSubscription_withNoProductsFound_returnsError() async { + appStoreRestoreFlowMock.restoreAccountFromPastPurchaseResult = .failure(AppStoreRestoreFlowErrorV2.missingAccountOrTransactions) + + let result = await sut.purchaseSubscription(with: "testSubscriptionID") + + XCTAssertTrue(appStoreRestoreFlowMock.restoreAccountFromPastPurchaseCalled) + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + switch error { + case AppStorePurchaseFlowError.accountCreationFailed: + break + default: + XCTFail("Unexpected error: \(error)") + } + } + } + + func test_purchaseSubscription_successfulPurchase_returnsTransactionJWS() async { + appStoreRestoreFlowMock.restoreAccountFromPastPurchaseResult = .failure(AppStoreRestoreFlowErrorV2.missingAccountOrTransactions) + subscriptionManagerMock.resultCreateAccountTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + storePurchaseManagerMock.purchaseSubscriptionResult = .success("transactionJWS") + + let result = await sut.purchaseSubscription(with: "testSubscriptionID") + + XCTAssertTrue(storePurchaseManagerMock.purchaseSubscriptionCalled) + XCTAssertEqual(result, .success("transactionJWS")) + } + + func test_purchaseSubscription_purchaseCancelledByUser_returnsCancelledError() async { + appStoreRestoreFlowMock.restoreAccountFromPastPurchaseResult = .failure(AppStoreRestoreFlowErrorV2.missingAccountOrTransactions) + storePurchaseManagerMock.purchaseSubscriptionResult = .failure(StorePurchaseManagerError.purchaseCancelledByUser) + subscriptionManagerMock.resultCreateAccountTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + subscriptionManagerMock.resultSubscription = SubscriptionMockFactory.subscription + + let result = await sut.purchaseSubscription(with: "testSubscriptionID") + + XCTAssertEqual(result, .failure(.cancelledByUser)) + } + + func test_purchaseSubscription_purchaseFailed_returnsPurchaseFailedError() async { + appStoreRestoreFlowMock.restoreAccountFromPastPurchaseResult = .failure(AppStoreRestoreFlowErrorV2.missingAccountOrTransactions) + storePurchaseManagerMock.purchaseSubscriptionResult = .failure(StorePurchaseManagerError.purchaseFailed) + subscriptionManagerMock.resultCreateAccountTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + subscriptionManagerMock.resultSubscription = SubscriptionMockFactory.subscription + + let result = await sut.purchaseSubscription(with: "testSubscriptionID") + + XCTAssertEqual(result, .failure(.purchaseFailed(StorePurchaseManagerError.purchaseFailed))) + } + + // MARK: - completeSubscriptionPurchase Tests + + func test_completeSubscriptionPurchase_withActiveSubscription_returnsSuccess() async { + subscriptionManagerMock.resultTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + subscriptionManagerMock.resultSubscription = SubscriptionMockFactory.subscription + subscriptionManagerMock.confirmPurchaseResponse = .success(subscriptionManagerMock.resultSubscription!) + + let result = await sut.completeSubscriptionPurchase(with: "transactionJWS", additionalParams: nil) + + XCTAssertEqual(result, .success(.completed)) + } + func test_completeSubscriptionPurchase_withMissingEntitlements_returnsMissingEntitlementsError() async { + subscriptionManagerMock.resultTokenContainer = OAuthTokensFactory.makeValidTokenContainer() + subscriptionManagerMock.resultSubscription = SubscriptionMockFactory.subscription + subscriptionManagerMock.confirmPurchaseResponse = .success(subscriptionManagerMock.resultSubscription!) + + let result = await sut.completeSubscriptionPurchase(with: "transactionJWS", additionalParams: nil) + + XCTAssertEqual(result, .failure(.missingEntitlements)) + } + + func test_completeSubscriptionPurchase_withExpiredSubscription_returnsPurchaseFailedError() async { + subscriptionManagerMock.resultTokenContainer = OAuthTokensFactory.makeValidTokenContainer() + subscriptionManagerMock.resultSubscription = SubscriptionMockFactory.expiredSubscription + subscriptionManagerMock.confirmPurchaseResponse = .success(subscriptionManagerMock.resultSubscription!) + + let result = await sut.completeSubscriptionPurchase(with: "transactionJWS", additionalParams: nil) + + XCTAssertEqual(result, .failure(.purchaseFailed(AppStoreRestoreFlowErrorV2.subscriptionExpired))) + } + + func test_completeSubscriptionPurchase_withConfirmPurchaseError_returnsPurchaseFailedError() async { + subscriptionManagerMock.resultSubscription = SubscriptionMockFactory.subscription + subscriptionManagerMock.resultTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + subscriptionManagerMock.confirmPurchaseResponse = .failure(OAuthServiceError.invalidResponseCode(HTTPStatusCode.badRequest)) + + let result = await sut.completeSubscriptionPurchase(with: "transactionJWS", additionalParams: nil) + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + switch error { + case .purchaseFailed: + break + default: + XCTFail("Unexpected error: \(error)") + } + } + } +} + +/* final class AppStorePurchaseFlowTests: XCTestCase { private struct Constants { - static let authToken = UUID().uuidString - static let accessToken = UUID().uuidString static let externalID = UUID().uuidString static let email = "dax@duck.com" static let productID = UUID().uuidString static let transactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" - - static let unknownServerError = APIServiceError.serverError(statusCode: 401, error: "unknown_error") } - var accountManager: AccountManagerMock! - var subscriptionService: SubscriptionEndpointServiceMock! - var authService: AuthEndpointServiceMock! - var storePurchaseManager: StorePurchaseManagerMock! - var appStoreRestoreFlow: AppStoreRestoreFlowMock! + var mockSubscriptionManager: SubscriptionManagerMock! + var mockStorePurchaseManager: StorePurchaseManagerMock! + var mockAppStoreRestoreFlow: AppStoreRestoreFlowMock! - var appStorePurchaseFlow: AppStorePurchaseFlow! + var appStorePurchaseFlow: AppStorePurchaseFlowV2! override func setUpWithError() throws { - subscriptionService = SubscriptionEndpointServiceMock() - storePurchaseManager = StorePurchaseManagerMock() - accountManager = AccountManagerMock() - appStoreRestoreFlow = AppStoreRestoreFlowMock() - authService = AuthEndpointServiceMock() - - appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionEndpointService: subscriptionService, - storePurchaseManager: storePurchaseManager, - accountManager: accountManager, - appStoreRestoreFlow: appStoreRestoreFlow, - authEndpointService: authService) + mockSubscriptionManager = SubscriptionManagerMock() + mockStorePurchaseManager = StorePurchaseManagerMock() + mockAppStoreRestoreFlow = AppStoreRestoreFlowMock() + + appStorePurchaseFlow = DefaultAppStorePurchaseFlowV2(subscriptionManager: mockSubscriptionManager, + storePurchaseManager: mockStorePurchaseManager, + appStoreRestoreFlow: mockAppStoreRestoreFlow) } override func tearDownWithError() throws { - subscriptionService = nil - storePurchaseManager = nil - accountManager = nil - appStoreRestoreFlow = nil - authService = nil - + mockSubscriptionManager = nil + mockStorePurchaseManager = nil + mockAppStoreRestoreFlow = nil appStorePurchaseFlow = nil } @@ -70,27 +203,27 @@ final class AppStorePurchaseFlowTests: XCTestCase { func testPurchaseSubscriptionSuccess() async throws { // Given - XCTAssertFalse(accountManager.isUserAuthenticated) - appStoreRestoreFlow.restoreAccountFromPastPurchaseResult = .failure(.missingAccountOrTransactions) - authService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, - externalID: Constants.externalID, - status: "created")) - accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) - accountManager.fetchAccountDetailsResult = .success((email: "", externalID: Constants.externalID)) - storePurchaseManager.purchaseSubscriptionResult = .success(Constants.transactionJWS) + mockAppStoreRestoreFlow.restoreAccountFromPastPurchaseResult = .failure(.missingAccountOrTransactions) +// authService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, +// externalID: Constants.externalID, +// status: "created")) +// accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) +// accountManager.fetchAccountDetailsResult = .success((email: "", externalID: Constants.externalID)) +// storePurchaseManager.purchaseSubscriptionResult = .success(Constants.transactionJWS) // When - switch await appStorePurchaseFlow.purchaseSubscription(with: Constants.productID, emailAccessToken: nil) { + switch await appStorePurchaseFlow.purchaseSubscription(with: Constants.productID) { case .success(let success): - // Then - XCTAssertTrue(appStoreRestoreFlow.restoreAccountFromPastPurchaseCalled) - XCTAssertTrue(authService.createAccountCalled) - XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) - XCTAssertTrue(accountManager.storeAuthTokenCalled) - XCTAssertTrue(accountManager.storeAccountCalled) - XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) - XCTAssertEqual(success, Constants.transactionJWS) +// // Then +// XCTAssertTrue(appStoreRestoreFlow.restoreAccountFromPastPurchaseCalled) +// XCTAssertTrue(authService.createAccountCalled) +// XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) +// XCTAssertTrue(accountManager.storeAuthTokenCalled) +// XCTAssertTrue(accountManager.storeAccountCalled) +// XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) +// XCTAssertEqual(success, Constants.transactionJWS) + break case .failure(let error): XCTFail("Unexpected failure: \(String(reflecting: error))") } @@ -242,7 +375,7 @@ final class AppStorePurchaseFlowTests: XCTestCase { // Given accountManager.accessToken = Constants.accessToken subscriptionService.confirmPurchaseResult = .success( - ConfirmPurchaseResponse( + ConfirmPurchaseResponseV2( email: nil, entitlements: [], subscription: SubscriptionMockFactory.subscription @@ -280,7 +413,7 @@ final class AppStorePurchaseFlowTests: XCTestCase { // Given accountManager.accessToken = Constants.accessToken subscriptionService.confirmPurchaseResult = .success( - ConfirmPurchaseResponse( + ConfirmPurchaseResponseV2( email: nil, entitlements: [], subscription: SubscriptionMockFactory.subscription @@ -366,4 +499,5 @@ final class AppStorePurchaseFlowTests: XCTestCase { XCTAssertEqual(error, .missingEntitlements) } } -} + } +*/ diff --git a/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift index 3d065d1d7..de01c6d99 100644 --- a/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift +++ b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift @@ -19,314 +19,91 @@ import XCTest @testable import Subscription import SubscriptionTestingUtilities - -final class AppStoreRestoreFlowTests: XCTestCase { - - private struct Constants { - static let authToken = UUID().uuidString - static let accessToken = UUID().uuidString - static let externalID = UUID().uuidString - static let email = "dax@duck.com" - - static let mostRecentTransactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" - static let storeLoginResponse = StoreLoginResponse(authToken: Constants.authToken, - email: Constants.email, - externalID: Constants.externalID, - id: 1, - status: "authenticated") - - static let unknownServerError = APIServiceError.serverError(statusCode: 401, error: "unknown_error") - } - - var accountManager: AccountManagerMock! - var storePurchaseManager: StorePurchaseManagerMock! - var subscriptionService: SubscriptionEndpointServiceMock! - var authService: AuthEndpointServiceMock! - - var appStoreRestoreFlow: AppStoreRestoreFlow! - - override func setUpWithError() throws { - accountManager = AccountManagerMock() - storePurchaseManager = StorePurchaseManagerMock() - subscriptionService = SubscriptionEndpointServiceMock() - authService = AuthEndpointServiceMock() - - appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, - storePurchaseManager: storePurchaseManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService) +@testable import Networking +import NetworkingTestingUtils + +@available(macOS 12.0, iOS 15.0, *) +final class DefaultAppStoreRestoreFlowTests: XCTestCase { + + private var sut: DefaultAppStoreRestoreFlowV2! + private var subscriptionManagerMock: SubscriptionManagerMock! + private var storePurchaseManagerMock: StorePurchaseManagerMock! + + override func setUp() { + super.setUp() + subscriptionManagerMock = SubscriptionManagerMock() + storePurchaseManagerMock = StorePurchaseManagerMock() + sut = DefaultAppStoreRestoreFlowV2( + subscriptionManager: subscriptionManagerMock, + storePurchaseManager: storePurchaseManagerMock + ) } - override func tearDownWithError() throws { - accountManager = nil - subscriptionService = nil - authService = nil - storePurchaseManager = nil - - appStoreRestoreFlow = nil + override func tearDown() { + sut = nil + subscriptionManagerMock = nil + storePurchaseManagerMock = nil + super.tearDown() } - // MARK: - Tests for restoreAccountFromPastPurchase - - func testRestoreAccountFromPastPurchaseSuccess() async throws { - // Given - XCTAssertFalse(accountManager.isUserAuthenticated) - - storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS - - authService.storeLoginResult = .success(Constants.storeLoginResponse) - - accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) - - accountManager.fetchAccountDetailsResult = .success(AccountManager.AccountDetails(email: Constants.email, - externalID: Constants.externalID)) - accountManager.onFetchAccountDetails = { accessToken in - XCTAssertEqual(accessToken, Constants.accessToken) - } - - let subscription = SubscriptionMockFactory.subscription - subscriptionService.getSubscriptionResult = .success(subscription) - - XCTAssertTrue(subscription.isActive) - - accountManager.onStoreAuthToken = { authToken in - XCTAssertEqual(authToken, Constants.authToken) - } + // MARK: - restoreAccountFromPastPurchase Tests - accountManager.onStoreAccount = { accessToken, email, externalID in - XCTAssertEqual(accessToken, Constants.accessToken) - XCTAssertEqual(externalID, Constants.externalID) - } + func test_restoreAccountFromPastPurchase_withNoTransaction_returnsMissingAccountOrTransactionsError() async { + storePurchaseManagerMock.mostRecentTransactionResult = nil - // When - switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { - case .success: - // Then - XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) - XCTAssertTrue(accountManager.fetchAccountDetailsCalled) - XCTAssertTrue(accountManager.storeAuthTokenCalled) - XCTAssertTrue(accountManager.storeAccountCalled) + let result = await sut.restoreAccountFromPastPurchase() - XCTAssertTrue(accountManager.isUserAuthenticated) - XCTAssertEqual(accountManager.authToken, Constants.authToken) - XCTAssertEqual(accountManager.accessToken, Constants.accessToken) - XCTAssertEqual(accountManager.externalID, Constants.externalID) - XCTAssertEqual(accountManager.email, Constants.email) + XCTAssertTrue(storePurchaseManagerMock.mostRecentTransactionCalled) + switch result { case .failure(let error): - XCTFail("Unexpected failure: \(error)") - } - } - - func testRestoreAccountFromPastPurchaseErrorDueToSubscriptionBeingExpired() async throws { - // Given - XCTAssertFalse(accountManager.isUserAuthenticated) - - storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS - - authService.storeLoginResult = .success(Constants.storeLoginResponse) - - accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) - - accountManager.fetchAccountDetailsResult = .success(AccountManager.AccountDetails(email: nil, externalID: Constants.externalID)) - accountManager.onFetchAccountDetails = { accessToken in - XCTAssertEqual(accessToken, Constants.accessToken) - } - - let subscription = SubscriptionMockFactory.expiredSubscription - subscriptionService.getSubscriptionResult = .success(subscription) - - XCTAssertFalse(subscription.isActive) - - accountManager.onStoreAuthToken = { authToken in - XCTAssertEqual(authToken, Constants.authToken) - } - - accountManager.onStoreAccount = { accessToken, email, externalID in - XCTAssertEqual(accessToken, Constants.accessToken) - XCTAssertEqual(externalID, Constants.externalID) - } - - let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, - storePurchaseManager: storePurchaseManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService) - // When - switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + XCTAssertEqual(error, .missingAccountOrTransactions) case .success: XCTFail("Unexpected success") - case .failure(let error): - // Then - XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) - XCTAssertTrue(accountManager.fetchAccountDetailsCalled) - XCTAssertFalse(accountManager.storeAuthTokenCalled) - XCTAssertFalse(accountManager.storeAccountCalled) - - guard case .subscriptionExpired(let accountDetails) = error else { - XCTFail("Expected .subscriptionExpired error") - return - } - - XCTAssertEqual(accountDetails.authToken, Constants.authToken) - XCTAssertEqual(accountDetails.accessToken, Constants.accessToken) - XCTAssertEqual(accountDetails.externalID, Constants.externalID) - - XCTAssertFalse(accountManager.isUserAuthenticated) } } - func testRestoreAccountFromPastPurchaseErrorWhenNoRecentTransaction() async throws { - // Given - XCTAssertFalse(accountManager.isUserAuthenticated) + func test_restoreAccountFromPastPurchase_withExpiredSubscription_returnsSubscriptionExpiredError() async { + storePurchaseManagerMock.mostRecentTransactionResult = "lastTransactionJWS" + subscriptionManagerMock.resultSubscription = SubscriptionMockFactory.expiredSubscription - storePurchaseManager.mostRecentTransactionResult = nil + let result = await sut.restoreAccountFromPastPurchase() - let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, - storePurchaseManager: storePurchaseManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService) - // When - switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + XCTAssertTrue(storePurchaseManagerMock.mostRecentTransactionCalled) + switch result { + case .failure(let error): + XCTAssertEqual(error, .subscriptionExpired) case .success: XCTFail("Unexpected success") - case .failure(let error): - // Then - XCTAssertFalse(accountManager.exchangeAuthTokenToAccessTokenCalled) - XCTAssertFalse(accountManager.fetchAccountDetailsCalled) - XCTAssertFalse(accountManager.storeAuthTokenCalled) - XCTAssertFalse(accountManager.storeAccountCalled) - XCTAssertEqual(error, .missingAccountOrTransactions) - - XCTAssertFalse(accountManager.isUserAuthenticated) } } - func testRestoreAccountFromPastPurchaseErrorDueToStoreLoginFailure() async throws { - // Given - XCTAssertFalse(accountManager.isUserAuthenticated) - - storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + func test_restoreAccountFromPastPurchase_withPastTransactionAuthenticationError_returnsAuthenticationError() async { + storePurchaseManagerMock.mostRecentTransactionResult = "lastTransactionJWS" + subscriptionManagerMock.resultSubscription = nil // Triggers an error when calling getSubscriptionFrom() - authService.storeLoginResult = .failure(Constants.unknownServerError) + let result = await sut.restoreAccountFromPastPurchase() - let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, - storePurchaseManager: storePurchaseManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService) - // When - switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { - case .success: - XCTFail("Unexpected success") + XCTAssertTrue(storePurchaseManagerMock.mostRecentTransactionCalled) + switch result { case .failure(let error): - // Then - XCTAssertFalse(accountManager.exchangeAuthTokenToAccessTokenCalled) - XCTAssertFalse(accountManager.fetchAccountDetailsCalled) - XCTAssertFalse(accountManager.storeAuthTokenCalled) - XCTAssertFalse(accountManager.storeAccountCalled) XCTAssertEqual(error, .pastTransactionAuthenticationError) - - XCTAssertFalse(accountManager.isUserAuthenticated) - } - } - - func testRestoreAccountFromPastPurchaseErrorDueToStoreAuthTokenExchangeFailure() async throws { - // Given - XCTAssertFalse(accountManager.isUserAuthenticated) - - storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS - - authService.storeLoginResult = .success(Constants.storeLoginResponse) - - accountManager.exchangeAuthTokenToAccessTokenResult = .failure(Constants.unknownServerError) - - let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, - storePurchaseManager: storePurchaseManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService) - // When - switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { case .success: XCTFail("Unexpected success") - case .failure(let error): - // Then - XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) - XCTAssertFalse(accountManager.fetchAccountDetailsCalled) - XCTAssertFalse(accountManager.storeAuthTokenCalled) - XCTAssertFalse(accountManager.storeAccountCalled) - XCTAssertEqual(error, .failedToObtainAccessToken) - - XCTAssertFalse(accountManager.isUserAuthenticated) } } - func testRestoreAccountFromPastPurchaseErrorDueToAccountDetailsFetchFailure() async throws { - // Given - XCTAssertFalse(accountManager.isUserAuthenticated) - - storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + func test_restoreAccountFromPastPurchase_withActiveSubscription_returnsSuccess() async { + storePurchaseManagerMock.mostRecentTransactionResult = "lastTransactionJWS" + subscriptionManagerMock.resultSubscription = SubscriptionMockFactory.subscription - authService.storeLoginResult = .success(Constants.storeLoginResponse) + let result = await sut.restoreAccountFromPastPurchase() - accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) - - accountManager.fetchAccountDetailsResult = .failure(Constants.unknownServerError) - accountManager.onFetchAccountDetails = { accessToken in - XCTAssertEqual(accessToken, Constants.accessToken) - } - - let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, - storePurchaseManager: storePurchaseManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService) - // When - switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { - case .success: - XCTFail("Unexpected success") + XCTAssertTrue(storePurchaseManagerMock.mostRecentTransactionCalled) + switch result { case .failure(let error): - // Then - XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) - XCTAssertTrue(accountManager.fetchAccountDetailsCalled) - XCTAssertFalse(accountManager.storeAuthTokenCalled) - XCTAssertFalse(accountManager.storeAccountCalled) - XCTAssertEqual(error, .failedToFetchAccountDetails) - - XCTAssertFalse(accountManager.isUserAuthenticated) - } - } - - func testRestoreAccountFromPastPurchaseErrorDueToSubscriptionFetchFailure() async throws { - // Given - XCTAssertFalse(accountManager.isUserAuthenticated) - - storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS - - authService.storeLoginResult = .success(Constants.storeLoginResponse) - - accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) - - accountManager.fetchAccountDetailsResult = .success(AccountManager.AccountDetails(email: nil, externalID: Constants.externalID)) - accountManager.onFetchAccountDetails = { accessToken in - XCTAssertEqual(accessToken, Constants.accessToken) - } - - subscriptionService.getSubscriptionResult = .failure(.apiError(Constants.unknownServerError)) - - let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, - storePurchaseManager: storePurchaseManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService) - // When - switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + XCTFail("Unexpected error: \(error)") case .success: - XCTFail("Unexpected success") - case .failure(let error): - // Then - XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) - XCTAssertTrue(accountManager.fetchAccountDetailsCalled) - XCTAssertFalse(accountManager.storeAuthTokenCalled) - XCTAssertFalse(accountManager.storeAccountCalled) - XCTAssertEqual(error, .failedToFetchSubscriptionDetails) - - XCTAssertFalse(accountManager.isUserAuthenticated) + break } } } diff --git a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift index 4d012c0d1..9b2e23011 100644 --- a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift +++ b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift @@ -19,23 +19,24 @@ import XCTest @testable import Subscription import SubscriptionTestingUtilities +import Networking final class SubscriptionOptionsTests: XCTestCase { func testEncoding() throws { let monthlySubscriptionOffer = SubscriptionOptionOffer(type: .freeTrial, id: "1", durationInDays: 7, isUserEligible: true) let yearlySubscriptionOffer = SubscriptionOptionOffer(type: .freeTrial, id: "2", durationInDays: 7, isUserEligible: true) - let subscriptionOptions = SubscriptionOptions(platform: .macos, + let subscriptionOptions = SubscriptionOptionsV2(platform: .macos, options: [ - SubscriptionOption(id: "1", + SubscriptionOptionV2(id: "1", cost: SubscriptionOptionCost(displayPrice: "9 USD", recurrence: "monthly"), offer: monthlySubscriptionOffer), - SubscriptionOption(id: "2", + SubscriptionOptionV2(id: "2", cost: SubscriptionOptionCost(displayPrice: "99 USD", recurrence: "yearly"), offer: yearlySubscriptionOffer) ], - features: [ - SubscriptionFeature(name: .networkProtection), - SubscriptionFeature(name: .dataBrokerProtection), - SubscriptionFeature(name: .identityTheftRestoration) + availableEntitlements: [ + .networkProtection, + .dataBrokerProtection, + .identityTheftRestoration ]) let jsonEncoder = JSONEncoder() @@ -101,16 +102,16 @@ final class SubscriptionOptionsTests: XCTestCase { } func testSubscriptionFeatureEncoding() throws { - let subscriptionFeature = SubscriptionFeature(name: .identityTheftRestoration) + let subscriptionFeature: SubscriptionEntitlement = .identityTheftRestoration let data = try? JSONEncoder().encode(subscriptionFeature) let subscriptionFeatureString = String(data: data!, encoding: .utf8)! - XCTAssertEqual(subscriptionFeatureString, "{\"name\":\"Identity Theft Restoration\"}") + XCTAssertEqual(subscriptionFeatureString, "\"Identity Theft Restoration\"") } func testEmptySubscriptionOptions() throws { - let empty = SubscriptionOptions.empty + let empty = SubscriptionOptionsV2.empty let platform: SubscriptionPlatformName #if os(iOS) diff --git a/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift index e397805db..306e0d466 100644 --- a/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift +++ b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift @@ -15,12 +15,12 @@ // See the License for the specific language governing permissions and // limitations under the License. // +/* + import XCTest + @testable import Subscription + import SubscriptionTestingUtilities -import XCTest -@testable import Subscription -import SubscriptionTestingUtilities - -final class StripePurchaseFlowTests: XCTestCase { + final class StripePurchaseFlowTests: XCTestCase { private struct Constants { static let authToken = UUID().uuidString @@ -35,7 +35,7 @@ final class StripePurchaseFlowTests: XCTestCase { var subscriptionService: SubscriptionEndpointServiceMock! var authEndpointService: AuthEndpointServiceMock! - var stripePurchaseFlow: StripePurchaseFlow! + var stripePurchaseFlow: StripePurchaseFlowV2! override func setUpWithError() throws { accountManager = AccountManagerMock() @@ -252,4 +252,5 @@ final class StripePurchaseFlowTests: XCTestCase { XCTAssertEqual(accountManager.accessToken, Constants.accessToken) XCTAssertEqual(accountManager.externalID, Constants.externalID) } -} + } +*/ diff --git a/Tests/SubscriptionTests/Managers/AccountManagerTests.swift b/Tests/SubscriptionTests/Managers/AccountManagerTests.swift deleted file mode 100644 index 0a04a4cde..000000000 --- a/Tests/SubscriptionTests/Managers/AccountManagerTests.swift +++ /dev/null @@ -1,508 +0,0 @@ -// -// 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 { - - private struct Constants { - static let userDefaultsSuiteName = "AccountManagerTests" - - static let authToken = UUID().uuidString - static let accessToken = UUID().uuidString - static let externalID = UUID().uuidString - - static let email = "dax@duck.com" - - static let entitlements = [Entitlement(product: .dataBrokerProtection), - Entitlement(product: .identityTheftRestoration), - Entitlement(product: .networkProtection)] - - static let keychainError = AccountKeychainAccessError.keychainSaveFailure(1) - static let invalidTokenError = APIServiceError.serverError(statusCode: 401, error: "invalid_token") - static let unknownServerError = APIServiceError.serverError(statusCode: 401, error: "unknown_error") - } - - var userDefaults: UserDefaults! - var accountStorage: AccountKeychainStorageMock! - var accessTokenStorage: SubscriptionTokenKeychainStorageMock! - var entitlementsCache: UserDefaultsCache<[Entitlement]>! - var subscriptionService: SubscriptionEndpointServiceMock! - var authService: AuthEndpointServiceMock! - - var accountManager: AccountManager! - - override func setUpWithError() throws { - userDefaults = UserDefaults(suiteName: Constants.userDefaultsSuiteName)! - userDefaults.removePersistentDomain(forName: Constants.userDefaultsSuiteName) - - accountStorage = AccountKeychainStorageMock() - accessTokenStorage = SubscriptionTokenKeychainStorageMock() - entitlementsCache = UserDefaultsCache<[Entitlement]>(userDefaults: userDefaults, - key: UserDefaultsCacheKey.subscriptionEntitlements, - settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) - subscriptionService = SubscriptionEndpointServiceMock() - authService = AuthEndpointServiceMock() - - accountManager = DefaultAccountManager(storage: accountStorage, - accessTokenStorage: accessTokenStorage, - entitlementsCache: entitlementsCache, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService) - } - - override func tearDownWithError() throws { - accountStorage = nil - accessTokenStorage = nil - entitlementsCache = nil - subscriptionService = nil - authService = nil - - accountManager = nil - } - - // MARK: - Tests for storeAuthToken - - func testStoreAuthToken() throws { - // When - accountManager.storeAuthToken(token: Constants.authToken) - - XCTAssertEqual(accountManager.authToken, Constants.authToken) - XCTAssertEqual(accountStorage.authToken, Constants.authToken) - } - - func testStoreAuthTokenFailure() async throws { - // Given - let delegateCalled = expectation(description: "AccountManagerKeychainAccessDelegate called") - let keychainAccessDelegateMock = AccountManagerKeychainAccessDelegateMock { type, error in - delegateCalled.fulfill() - XCTAssertEqual(type, .storeAuthToken) - XCTAssertEqual(error, Constants.keychainError) - } - - accountStorage.mockedAccessError = Constants.keychainError - accountManager.delegate = keychainAccessDelegateMock - - // When - accountManager.storeAuthToken(token: Constants.authToken) - - // Then - await fulfillment(of: [delegateCalled], timeout: 0.5) - } - - // MARK: - Tests for storeAccount - - func testStoreAccount() async throws { - // Given - - let notificationExpectation = expectation(forNotification: .accountDidSignIn, object: accountManager, handler: nil) - - // When - accountManager.storeAccount(token: Constants.accessToken, email: Constants.email, externalID: Constants.externalID) - - // Then - XCTAssertEqual(accountManager.accessToken, Constants.accessToken) - XCTAssertEqual(accountManager.email, Constants.email) - XCTAssertEqual(accountManager.externalID, Constants.externalID) - - XCTAssertEqual(accessTokenStorage.accessToken, Constants.accessToken) - XCTAssertEqual(accountStorage.email, Constants.email) - XCTAssertEqual(accountStorage.externalID, Constants.externalID) - - await fulfillment(of: [notificationExpectation], timeout: 0.5) - } - - func testStoreAccountUpdatingEmailToNil() throws { - // When - accountManager.storeAccount(token: Constants.accessToken, email: Constants.email, externalID: Constants.externalID) - accountManager.storeAccount(token: Constants.accessToken, email: nil, externalID: Constants.externalID) - - // Then - XCTAssertEqual(accountManager.accessToken, Constants.accessToken) - XCTAssertEqual(accountManager.email, nil) - XCTAssertEqual(accountManager.externalID, Constants.externalID) - - XCTAssertEqual(accessTokenStorage.accessToken, Constants.accessToken) - XCTAssertEqual(accountStorage.email, nil) - XCTAssertEqual(accountStorage.externalID, Constants.externalID) - } - - // MARK: - Tests for signOut - - func testSignOut() async throws { - // Given - accountManager.storeAuthToken(token: Constants.authToken) - accountManager.storeAccount(token: Constants.accessToken, email: Constants.email, externalID: Constants.externalID) - - XCTAssertTrue(accountManager.isUserAuthenticated) - - let notificationExpectation = expectation(forNotification: .accountDidSignOut, object: accountManager, handler: nil) - - // When - accountManager.signOut() - - // Then - XCTAssertFalse(accountManager.isUserAuthenticated) - - XCTAssertTrue(accountStorage.clearAuthenticationStateCalled) - XCTAssertTrue(accessTokenStorage.removeAccessTokenCalled) - XCTAssertTrue(subscriptionService.signOutCalled) - XCTAssertNil(entitlementsCache.get()) - - await fulfillment(of: [notificationExpectation], timeout: 0.5) - } - - func testSignOutWithoutSendingNotification() async throws { - // Given - accountManager.storeAuthToken(token: Constants.authToken) - accountManager.storeAccount(token: Constants.accessToken, email: Constants.email, externalID: Constants.externalID) - - XCTAssertTrue(accountManager.isUserAuthenticated) - - let notificationExpectation = expectation(forNotification: .accountDidSignOut, object: accountManager, handler: nil) - notificationExpectation.isInverted = true - - // When - accountManager.signOut(skipNotification: true) - - // Then - XCTAssertFalse(accountManager.isUserAuthenticated) - await fulfillment(of: [notificationExpectation], timeout: 0.5) - } - - // MARK: - Tests for hasEntitlement - - func testHasEntitlementIgnoringLocalCacheData() async throws { - // Given - let productName = Entitlement.ProductName.networkProtection - - accessTokenStorage.accessToken = Constants.accessToken - entitlementsCache.set([]) - authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, - entitlements: Constants.entitlements, - externalID: Constants.externalID))) - XCTAssertTrue(Constants.entitlements.compactMap { $0.product }.contains(productName)) - - // When - let result = await accountManager.hasEntitlement(forProductName: productName, cachePolicy: .reloadIgnoringLocalCacheData) - - // Then - switch result { - case .success(let success): - XCTAssertTrue(success) - XCTAssertTrue(authService.validateTokenCalled) - XCTAssertEqual(entitlementsCache.get(), Constants.entitlements) - case .failure: - XCTFail("Unexpected failure") - } - } - - func testHasEntitlementWithoutParameterUseCacheData() async throws { - // Given - let productName = Entitlement.ProductName.networkProtection - - accessTokenStorage.accessToken = Constants.accessToken - entitlementsCache.set(Constants.entitlements) - - XCTAssertTrue(Constants.entitlements.compactMap { $0.product }.contains(productName)) - - // When - let result = await accountManager.hasEntitlement(forProductName: productName) - - // Then - switch result { - case .success(let success): - XCTAssertTrue(success) - XCTAssertFalse(authService.validateTokenCalled) - case .failure: - XCTFail("Unexpected failure") - } - } - - // MARK: - Tests for updateCache - - func testUpdateEntitlementsCache() async throws { - // Given - let updatedEntitlements = [Entitlement(product: .networkProtection)] - XCTAssertNotEqual(Constants.entitlements, updatedEntitlements) - - entitlementsCache.set(Constants.entitlements) - - let notificationExpectation = expectation(forNotification: .entitlementsDidChange, object: accountManager, handler: nil) - - // When - accountManager.updateCache(with: updatedEntitlements) - - // Then - XCTAssertEqual(entitlementsCache.get(), updatedEntitlements) - await fulfillment(of: [notificationExpectation], timeout: 0.5) - } - - func testUpdateEntitlementsCacheWithEmptyArray() async throws { - // Given - entitlementsCache.set(Constants.entitlements) - - let notificationExpectation = expectation(forNotification: .entitlementsDidChange, object: accountManager, handler: nil) - - // When - accountManager.updateCache(with: []) - - // Then - XCTAssertNil(entitlementsCache.get()) - await fulfillment(of: [notificationExpectation], timeout: 0.5) - } - - func testUpdateEntitlementsCacheWithSameEntitlements() async throws { - // Given - entitlementsCache.set(Constants.entitlements) - - let notificationNotFiredExpectation = expectation(forNotification: .entitlementsDidChange, object: accountManager, handler: nil) - notificationNotFiredExpectation.isInverted = true - - // When - accountManager.updateCache(with: Constants.entitlements) - - // Then - XCTAssertEqual(entitlementsCache.get(), Constants.entitlements) - await fulfillment(of: [notificationNotFiredExpectation], timeout: 0.5) - } - - // MARK: - Tests for fetchEntitlements - - func testFetchEntitlementsIgnoringLocalCacheData() async throws { - // Given - accessTokenStorage.accessToken = Constants.accessToken - entitlementsCache.set([]) - authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, - entitlements: Constants.entitlements, - externalID: Constants.externalID))) - - // When - let result = await accountManager.fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success, Constants.entitlements) - XCTAssertTrue(authService.validateTokenCalled) - XCTAssertEqual(entitlementsCache.get(), Constants.entitlements) - case .failure: - XCTFail("Unexpected failure") - } - } - - func testFetchEntitlementsReturnCachedData() async throws { - // Given - accessTokenStorage.accessToken = Constants.accessToken - entitlementsCache.set(Constants.entitlements) - - // When - let result = await accountManager.fetchEntitlements(cachePolicy: .returnCacheDataElseLoad) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success, Constants.entitlements) - XCTAssertFalse(authService.validateTokenCalled) - XCTAssertEqual(entitlementsCache.get(), Constants.entitlements) - case .failure: - XCTFail("Unexpected failure") - } - } - - func testFetchEntitlementsReturnCachedDataWhenCacheIsExpired() async throws { - // Given - let updatedEntitlements = [Entitlement(product: .networkProtection)] - - accessTokenStorage.accessToken = Constants.accessToken - entitlementsCache.set(Constants.entitlements, expires: Date.distantPast) - authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, - entitlements: updatedEntitlements, - externalID: Constants.externalID))) - - XCTAssertNotEqual(Constants.entitlements, updatedEntitlements) - - // When - let result = await accountManager.fetchEntitlements(cachePolicy: .returnCacheDataElseLoad) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success, updatedEntitlements) - XCTAssertTrue(authService.validateTokenCalled) - XCTAssertEqual(entitlementsCache.get(), updatedEntitlements) - case .failure: - XCTFail("Unexpected failure") - } - } - - func testFetchEntitlementsReturnCacheDataDontLoad() async throws { - // Given - accessTokenStorage.accessToken = Constants.accessToken - entitlementsCache.set(Constants.entitlements) - - // When - let result = await accountManager.fetchEntitlements(cachePolicy: .returnCacheDataDontLoad) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success, Constants.entitlements) - XCTAssertFalse(authService.validateTokenCalled) - XCTAssertEqual(entitlementsCache.get(), Constants.entitlements) - case .failure: - XCTFail("Unexpected failure") - } - } - - func testFetchEntitlementsReturnCacheDataDontLoadWhenCacheIsExpired() async throws { - // Given - accessTokenStorage.accessToken = Constants.accessToken - entitlementsCache.set(Constants.entitlements, expires: Date.distantPast) - - // When - let result = await accountManager.fetchEntitlements(cachePolicy: .returnCacheDataDontLoad) - - // Then - switch result { - case .success: - XCTFail("Unexpected success") - case .failure(let error): - guard let entitlementsError = error as? DefaultAccountManager.EntitlementsError else { - XCTFail("Incorrect error type") - return - } - - XCTAssertEqual(entitlementsError, .noCachedData) - } - } - - // MARK: - Tests for exchangeAuthTokenToAccessToken - - func testExchangeAuthTokenToAccessToken() async throws { - // Given - authService.getAccessTokenResult = .success(.init(accessToken: Constants.accessToken)) - - // When - let result = await accountManager.exchangeAuthTokenToAccessToken(Constants.authToken) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success, Constants.accessToken) - XCTAssertTrue(authService.getAccessTokenCalled) - case .failure: - XCTFail("Unexpected failure") - } - } - - // MARK: - Tests for fetchAccountDetails - - func testFetchAccountDetails() async throws { - // Given - authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, - entitlements: Constants.entitlements, - externalID: Constants.externalID))) - - // When - let result = await accountManager.fetchAccountDetails(with: Constants.accessToken) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success.email, Constants.email) - XCTAssertEqual(success.externalID, Constants.externalID) - XCTAssertTrue(authService.validateTokenCalled) - case .failure: - XCTFail("Unexpected failure") - } - } - - // MARK: - Tests for checkForEntitlements - - func testCheckForEntitlementsSuccess() async throws { - // Given - var callCount = 0 - - accessTokenStorage.accessToken = Constants.accessToken - - authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, - entitlements: Constants.entitlements, - externalID: Constants.externalID))) - authService.onValidateToken = { _ in - callCount += 1 - } - - // When - let result = await accountManager.checkForEntitlements(wait: 0.1, retry: 5) - - // Then - XCTAssertTrue(result) - XCTAssertTrue(authService.validateTokenCalled) - XCTAssertEqual(callCount, 1) - } - - func testCheckForEntitlementsFailure() async throws { - // Given - var callCount = 0 - - accessTokenStorage.accessToken = Constants.accessToken - - authService.validateTokenResult = .failure(Constants.unknownServerError) - authService.onValidateToken = { _ in - callCount += 1 - } - - // When - let result = await accountManager.checkForEntitlements(wait: 0.1, retry: 5) - - // Then - XCTAssertFalse(result) - XCTAssertTrue(authService.validateTokenCalled) - XCTAssertEqual(callCount, 5) - } - - func testCheckForEntitlementsSuccessAfterRetries() async throws { - // Given - var callCount = 0 - - accessTokenStorage.accessToken = Constants.accessToken - - authService.validateTokenResult = .failure(Constants.unknownServerError) - authService.onValidateToken = { _ in - callCount += 1 - - if callCount == 3 { - self.authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, - entitlements: Constants.entitlements, - externalID: Constants.externalID))) - } - } - - // When - let result = await accountManager.checkForEntitlements(wait: 0.1, retry: 5) - - // Then - XCTAssertTrue(result) - XCTAssertTrue(authService.validateTokenCalled) - XCTAssertEqual(callCount, 3) - } -} diff --git a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift index ef96f6b4b..d859139a6 100644 --- a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift +++ b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift @@ -23,7 +23,7 @@ import StoreKit final class StorePurchaseManagerTests: XCTestCase { - private var sut: StorePurchaseManager! + private var sut: StorePurchaseManagerV2! private var mockCache: SubscriptionFeatureMappingCacheMock! private var mockProductFetcher: MockProductFetcher! private var mockFeatureFlagger: MockFeatureFlagger! diff --git a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift index ac4985fcf..3ba421673 100644 --- a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift +++ b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift @@ -18,181 +18,191 @@ import XCTest @testable import Subscription +@testable import Networking import SubscriptionTestingUtilities +import NetworkingTestingUtils -final class SubscriptionManagerTests: XCTestCase { +class SubscriptionManagerTests: XCTestCase { - private struct Constants { - static let userDefaultsSuiteName = "SubscriptionManagerTests" + var subscriptionManager: DefaultSubscriptionManager! + var mockOAuthClient: MockOAuthClient! + var mockSubscriptionEndpointService: SubscriptionEndpointServiceMock! + var mockStorePurchaseManager: StorePurchaseManagerMock! - static let accessToken = UUID().uuidString + override func setUp() { + super.setUp() - static let invalidTokenError = APIServiceError.serverError(statusCode: 401, error: "invalid_token") - } - - var storePurchaseManager: StorePurchaseManagerMock! - var accountManager: AccountManagerMock! - var subscriptionService: SubscriptionEndpointServiceMock! - var authService: AuthEndpointServiceMock! - var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock! - var subscriptionEnvironment: SubscriptionEnvironment! - - var subscriptionManager: SubscriptionManager! - - override func setUpWithError() throws { - storePurchaseManager = StorePurchaseManagerMock() - accountManager = AccountManagerMock() - subscriptionService = SubscriptionEndpointServiceMock() - authService = AuthEndpointServiceMock() - subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() - subscriptionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, - purchasePlatform: .appStore) - - subscriptionManager = DefaultSubscriptionManager(storePurchaseManager: storePurchaseManager, - accountManager: accountManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService, - subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, - subscriptionEnvironment: subscriptionEnvironment) + mockOAuthClient = MockOAuthClient() + mockSubscriptionEndpointService = SubscriptionEndpointServiceMock() + mockStorePurchaseManager = StorePurchaseManagerMock() + subscriptionManager = DefaultSubscriptionManager( + storePurchaseManager: mockStorePurchaseManager, + oAuthClient: mockOAuthClient, + subscriptionEndpointService: mockSubscriptionEndpointService, + subscriptionEnvironment: SubscriptionEnvironment(serviceEnvironment: .staging, purchasePlatform: .stripe), + pixelHandler: { _ in } + ) } - override func tearDownWithError() throws { - storePurchaseManager = nil - accountManager = nil - subscriptionService = nil - authService = nil - subscriptionEnvironment = nil - + override func tearDown() { subscriptionManager = nil + mockOAuthClient = nil + mockSubscriptionEndpointService = nil + mockStorePurchaseManager = nil + super.tearDown() } - // MARK: - Tests for save and loadEnvironmentFrom - - func testLoadEnvironmentFromUserDefaults() async throws { - // Given - let userDefaults = UserDefaults(suiteName: Constants.userDefaultsSuiteName)! - userDefaults.removePersistentDomain(forName: Constants.userDefaultsSuiteName) + // MARK: - Token Retrieval Tests - var loadedEnvironment = DefaultSubscriptionManager.loadEnvironmentFrom(userDefaults: userDefaults) - XCTAssertNil(loadedEnvironment) + func testGetTokenContainer_Success() async throws { + let expectedTokenContainer = OAuthTokensFactory.makeValidTokenContainer() + mockOAuthClient.getTokensResponse = .success(expectedTokenContainer) - // When - DefaultSubscriptionManager.save(subscriptionEnvironment: subscriptionEnvironment, - userDefaults: userDefaults) - loadedEnvironment = DefaultSubscriptionManager.loadEnvironmentFrom(userDefaults: userDefaults) - - // Then - XCTAssertEqual(loadedEnvironment?.serviceEnvironment, subscriptionEnvironment.serviceEnvironment) - XCTAssertEqual(loadedEnvironment?.purchasePlatform, subscriptionEnvironment.purchasePlatform) + let result = try await subscriptionManager.getTokenContainer(policy: .localValid) + XCTAssertEqual(result, expectedTokenContainer) } - // MARK: - Tests for setup for App Store - - func testSetupForAppStore() async throws { - // Given - storePurchaseManager.onUpdateAvailableProducts = { - self.storePurchaseManager.areProductsAvailable = true + func testGetTokenContainer_ErrorHandlingDeadToken() async throws { + // Set up dead token error to trigger recovery attempt + mockOAuthClient.getTokensResponse = .failure(OAuthClientError.refreshTokenExpired) + let date = Date() + let expiredSubscription = PrivacyProSubscription( + productId: "testProduct", + name: "Test Subscription", + billingPeriod: .monthly, + startedAt: date.addingTimeInterval(-30 * 24 * 60 * 60), // 30 days ago + expiresOrRenewsAt: date.addingTimeInterval(-1), // expired + platform: .apple, + status: .expired + ) + mockSubscriptionEndpointService.getSubscriptionResult = .success(expiredSubscription) + let expectation = self.expectation(description: "Dead token pixel called") + subscriptionManager = DefaultSubscriptionManager( + storePurchaseManager: mockStorePurchaseManager, + oAuthClient: mockOAuthClient, + subscriptionEndpointService: mockSubscriptionEndpointService, + subscriptionEnvironment: SubscriptionEnvironment(serviceEnvironment: .staging, purchasePlatform: .stripe), + pixelHandler: { type in + XCTAssertEqual(type, .deadToken) + expectation.fulfill() + } + ) + + do { + _ = try await subscriptionManager.getTokenContainer(policy: .localValid) + XCTFail("Error expected") + } catch SubscriptionManagerError.tokenUnavailable { + // Expected error + } catch { + XCTFail("Unexpected error: \(error)") } - // When - // triggered on DefaultSubscriptionManager's init - try await Task.sleep(seconds: 0.5) - - // Then - XCTAssertTrue(storePurchaseManager.updateAvailableProductsCalled) - XCTAssertTrue(subscriptionManager.canPurchase) + await fulfillment(of: [expectation], timeout: 0.1) } - // MARK: - Tests for loadInitialData - - func testLoadInitialData() async throws { - // Given - accountManager.accessToken = Constants.accessToken - - subscriptionService.onGetSubscription = { _, cachePolicy in - XCTAssertEqual(cachePolicy, .reloadIgnoringLocalCacheData) - } - subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.subscription) + // MARK: - Subscription Status Tests + + func testRefreshCachedSubscription_ActiveSubscription() async { + let activeSubscription = PrivacyProSubscription( + productId: "testProduct", + name: "Test Subscription", + billingPeriod: .monthly, + startedAt: Date(), + expiresOrRenewsAt: Date().addingTimeInterval(30 * 24 * 60 * 60), // 30 days from now + platform: .stripe, + status: .autoRenewable + ) + mockSubscriptionEndpointService.getSubscriptionResult = .success(activeSubscription) + mockOAuthClient.getTokensResponse = .success(OAuthTokensFactory.makeValidTokenContainer()) + mockOAuthClient.isUserAuthenticated = true + + let subscription = try! await subscriptionManager.getSubscription(cachePolicy: .reloadIgnoringLocalCacheData) + XCTAssertTrue(subscription.isActive) + } - accountManager.onFetchEntitlements = { cachePolicy in - XCTAssertEqual(cachePolicy, .reloadIgnoringLocalCacheData) + func testRefreshCachedSubscription_ExpiredSubscription() async { + let expiredSubscription = PrivacyProSubscription( + productId: "testProduct", + name: "Test Subscription", + billingPeriod: .monthly, + startedAt: Date().addingTimeInterval(-30 * 24 * 60 * 60), // 30 days ago + expiresOrRenewsAt: Date().addingTimeInterval(-1), // expired + platform: .apple, + status: .expired + ) + mockSubscriptionEndpointService.getSubscriptionResult = .success(expiredSubscription) + + do { + try await subscriptionManager.getSubscription(cachePolicy: .reloadIgnoringLocalCacheData) + } catch { + XCTAssertEqual(error.localizedDescription, SubscriptionEndpointServiceError.noData.localizedDescription) } - - // When - subscriptionManager.loadInitialData() - - try await Task.sleep(seconds: 0.5) - - // Then - XCTAssertTrue(subscriptionService.getSubscriptionCalled) - XCTAssertTrue(accountManager.fetchEntitlementsCalled) } - func testLoadInitialDataNotCalledWhenUnauthenticated() async throws { - // Given - XCTAssertNil(accountManager.accessToken) - XCTAssertFalse(accountManager.isUserAuthenticated) + // MARK: - URL Generation Tests - // When - subscriptionManager.loadInitialData() + func testURLGeneration_ForCustomerPortal() async throws { + mockOAuthClient.isUserAuthenticated = true + mockOAuthClient.getTokensResponse = .success(OAuthTokensFactory.makeValidTokenContainer()) + let customerPortalURLString = "https://example.com/customer-portal" + mockSubscriptionEndpointService.getCustomerPortalURLResult = .success(GetCustomerPortalURLResponse(customerPortalUrl: customerPortalURLString)) - // Then - XCTAssertFalse(subscriptionService.getSubscriptionCalled) - XCTAssertFalse(accountManager.fetchEntitlementsCalled) + let url = try await subscriptionManager.getCustomerPortalURL() + XCTAssertEqual(url.absoluteString, customerPortalURLString) } - // MARK: - Tests for refreshCachedSubscriptionAndEntitlements - - func testForRefreshCachedSubscriptionAndEntitlements() async throws { - // Given - let subscription = SubscriptionMockFactory.subscription - - accountManager.accessToken = Constants.accessToken - - subscriptionService.onGetSubscription = { _, cachePolicy in - XCTAssertEqual(cachePolicy, .reloadIgnoringLocalCacheData) - } - subscriptionService.getSubscriptionResult = .success(subscription) + func testURLGeneration_ForSubscriptionTypes() { + let environment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore) + subscriptionManager = DefaultSubscriptionManager( + storePurchaseManager: mockStorePurchaseManager, + oAuthClient: mockOAuthClient, + subscriptionEndpointService: mockSubscriptionEndpointService, + subscriptionEnvironment: environment, + pixelHandler: { _ in } + ) + + let helpURL = subscriptionManager.url(for: .purchase) + XCTAssertEqual(helpURL.absoluteString, "https://duckduckgo.com/subscriptions") + } - accountManager.onFetchEntitlements = { cachePolicy in - XCTAssertEqual(cachePolicy, .reloadIgnoringLocalCacheData) + // MARK: - Purchase Confirmation Tests + + func testConfirmPurchase_ErrorHandling() async throws { + let testSignature = "invalidSignature" + mockSubscriptionEndpointService.confirmPurchaseResult = .failure(APIRequestV2.Error.invalidResponse) + mockOAuthClient.getTokensResponse = .success(OAuthTokensFactory.makeValidTokenContainer()) + do { + _ = try await subscriptionManager.confirmPurchase(signature: testSignature, additionalParams: nil) + XCTFail("Error expected") + } catch { + XCTAssertEqual(error as? APIRequestV2.Error, APIRequestV2.Error.invalidResponse) } + } - // When - let completionCalled = expectation(description: "completion called") - subscriptionManager.refreshCachedSubscriptionAndEntitlements { isSubscriptionActive in - completionCalled.fulfill() - XCTAssertEqual(isSubscriptionActive, subscription.isActive) - } + // MARK: - Tests for save and loadEnvironmentFrom - // Then - await fulfillment(of: [completionCalled], timeout: 0.5) - XCTAssertTrue(subscriptionService.getSubscriptionCalled) - XCTAssertTrue(accountManager.fetchEntitlementsCalled) - } + var subscriptionEnvironment: SubscriptionEnvironment! - func testForRefreshCachedSubscriptionAndEntitlementsSignOutUserOn401() async throws { + func testLoadEnvironmentFromUserDefaults() async throws { + subscriptionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, + purchasePlatform: .appStore) + let userDefaultsSuiteName = "SubscriptionManagerTests" // Given - accountManager.accessToken = Constants.accessToken + let userDefaults = UserDefaults(suiteName: userDefaultsSuiteName)! + userDefaults.removePersistentDomain(forName: userDefaultsSuiteName) - subscriptionService.onGetSubscription = { _, cachePolicy in - XCTAssertEqual(cachePolicy, .reloadIgnoringLocalCacheData) - } - subscriptionService.getSubscriptionResult = .failure(.apiError(Constants.invalidTokenError)) + var loadedEnvironment = DefaultSubscriptionManager.loadEnvironmentFrom(userDefaults: userDefaults) + XCTAssertNil(loadedEnvironment) // When - let completionCalled = expectation(description: "completion called") - subscriptionManager.refreshCachedSubscriptionAndEntitlements { isSubscriptionActive in - completionCalled.fulfill() - XCTAssertFalse(isSubscriptionActive) - } + DefaultSubscriptionManager.save(subscriptionEnvironment: subscriptionEnvironment, + userDefaults: userDefaults) + loadedEnvironment = DefaultSubscriptionManager.loadEnvironmentFrom(userDefaults: userDefaults) // Then - await fulfillment(of: [completionCalled], timeout: 0.5) - XCTAssertTrue(accountManager.signOutCalled) - XCTAssertTrue(subscriptionService.getSubscriptionCalled) - XCTAssertFalse(accountManager.fetchEntitlementsCalled) + XCTAssertEqual(loadedEnvironment?.serviceEnvironment, subscriptionEnvironment.serviceEnvironment) + XCTAssertEqual(loadedEnvironment?.purchasePlatform, subscriptionEnvironment.purchasePlatform) } // MARK: - Tests for url @@ -201,12 +211,13 @@ final class SubscriptionManagerTests: XCTestCase { // Given let productionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore) - let productionSubscriptionManager = DefaultSubscriptionManager(storePurchaseManager: storePurchaseManager, - accountManager: accountManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService, - subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, - subscriptionEnvironment: productionEnvironment) + let productionSubscriptionManager = DefaultSubscriptionManager( + storePurchaseManager: mockStorePurchaseManager, + oAuthClient: mockOAuthClient, + subscriptionEndpointService: mockSubscriptionEndpointService, + subscriptionEnvironment: productionEnvironment, + pixelHandler: { _ in } + ) // When let productionPurchaseURL = productionSubscriptionManager.url(for: .purchase) @@ -219,12 +230,13 @@ final class SubscriptionManagerTests: XCTestCase { // Given let stagingEnvironment = SubscriptionEnvironment(serviceEnvironment: .staging, purchasePlatform: .appStore) - let stagingSubscriptionManager = DefaultSubscriptionManager(storePurchaseManager: storePurchaseManager, - accountManager: accountManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService, - subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, - subscriptionEnvironment: stagingEnvironment) + let stagingSubscriptionManager = DefaultSubscriptionManager( + storePurchaseManager: mockStorePurchaseManager, + oAuthClient: mockOAuthClient, + subscriptionEndpointService: mockSubscriptionEndpointService, + subscriptionEnvironment: stagingEnvironment, + pixelHandler: { _ in } + ) // When let stagingPurchaseURL = stagingSubscriptionManager.url(for: .purchase) diff --git a/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift b/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift new file mode 100644 index 000000000..e3d9369cd --- /dev/null +++ b/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift @@ -0,0 +1,331 @@ +// +// PrivacyProSubscriptionIntegrationTests.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 +@testable import Networking +import NetworkingTestingUtils +import SubscriptionTestingUtilities +import JWTKit + +final class PrivacyProSubscriptionIntegrationTests: XCTestCase { + + var apiService: MockAPIService! + var tokenStorage: MockTokenStorage! + var legacyAccountStorage: MockLegacyTokenStorage! + var subscriptionManager: DefaultSubscriptionManager! + var appStorePurchaseFlow: DefaultAppStorePurchaseFlowV2! + var appStoreRestoreFlow: DefaultAppStoreRestoreFlowV2! + var stripePurchaseFlow: DefaultStripePurchaseFlow! + var storePurchaseManager: StorePurchaseManagerMock! + var subscriptionFeatureFlagger: FeatureFlaggerMapping! + + let subscriptionSelectionID = "ios.subscription.1month" + + override func setUpWithError() throws { + apiService = MockAPIService() + apiService.authorizationRefresherCallback = { _ in + return OAuthTokensFactory.makeValidTokenContainer().accessToken + } + let subscriptionEnvironment = SubscriptionEnvironment(serviceEnvironment: .staging, purchasePlatform: .appStore) + let authService = DefaultOAuthService(baseURL: OAuthEnvironment.staging.url, apiService: apiService) + // keychain storage + tokenStorage = MockTokenStorage() + legacyAccountStorage = MockLegacyTokenStorage() + + let authClient = DefaultOAuthClient(tokensStorage: tokenStorage, + legacyTokenStorage: legacyAccountStorage, + authService: authService) + storePurchaseManager = StorePurchaseManagerMock() + let subscriptionEndpointService = DefaultSubscriptionEndpointService(apiService: apiService, + baseURL: subscriptionEnvironment.serviceEnvironment.url) + let pixelHandler: SubscriptionManagerV2.PixelHandler = { type in + print("Pixel fired: \(type)") + } + subscriptionFeatureFlagger = FeatureFlaggerMapping(mapping: { $0.defaultState }) + + subscriptionManager = DefaultSubscriptionManager(storePurchaseManager: storePurchaseManager, + oAuthClient: authClient, + subscriptionEndpointService: subscriptionEndpointService, + subscriptionEnvironment: subscriptionEnvironment, + pixelHandler: pixelHandler) + + appStoreRestoreFlow = DefaultAppStoreRestoreFlowV2(subscriptionManager: subscriptionManager, + storePurchaseManager: storePurchaseManager) + appStorePurchaseFlow = DefaultAppStorePurchaseFlowV2(subscriptionManager: subscriptionManager, + storePurchaseManager: storePurchaseManager, + appStoreRestoreFlow: appStoreRestoreFlow) + stripePurchaseFlow = DefaultStripePurchaseFlow(subscriptionManager: subscriptionManager) + } + + override func tearDownWithError() throws { + apiService = nil + tokenStorage = nil + legacyAccountStorage = nil + subscriptionManager = nil + appStorePurchaseFlow = nil + appStoreRestoreFlow = nil + stripePurchaseFlow = nil + } + + // MARK: - Apple store + + func testAppStorePurchaseSuccess() async throws { + + // configure mock API responses + APIMockResponseFactory.mockAuthoriseResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockCreateAccountResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockGetAccessTokenResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockRefreshAccessTokenResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockGetJWKS(destinationMockAPIService: apiService, success: true) + SubscriptionAPIMockResponseFactory.mockConfirmPurchase(destinationMockAPIService: apiService, success: true) + SubscriptionAPIMockResponseFactory.mockGetProducts(destinationMockAPIService: apiService, success: true) + SubscriptionAPIMockResponseFactory.mockGetFeatures(destinationMockAPIService: apiService, success: true, subscriptionID: "ios.subscription.1month") + + (subscriptionManager.oAuthClient as! DefaultOAuthClient).testingDecodedTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + + // configure mock store purchase manager responses + storePurchaseManager.purchaseSubscriptionResult = .success("purchaseTransactionJWS") + + // Buy subscription + + var purchaseTransactionJWS: String? + switch await appStorePurchaseFlow.purchaseSubscription(with: subscriptionSelectionID) { + case .success(let transactionJWS): + purchaseTransactionJWS = transactionJWS + case .failure(let error): + XCTFail("Purchase failed with error: \(error)") + } + XCTAssertNotNil(purchaseTransactionJWS) + + switch await appStorePurchaseFlow.completeSubscriptionPurchase(with: purchaseTransactionJWS!, additionalParams: nil) { + case .success: + break + case .failure(let error): + XCTFail("Purchase failed with error: \(error)") + } + } + + func testAppStorePurchaseFailure_authorise() async throws { + APIMockResponseFactory.mockAuthoriseResponse(destinationMockAPIService: apiService, success: false) + + switch await appStorePurchaseFlow.purchaseSubscription(with: subscriptionSelectionID) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + switch error { + case .internalError(let innerError): + XCTAssertEqual(innerError as? SubscriptionManagerError, .tokenUnavailable(error: OAuthServiceError.authAPIError(code: .invalidAuthorizationRequest))) + default: + XCTFail("Unexpected error \(error)") + } + } + } + + func testAppStorePurchaseFailure_create_account() async throws { + // configure mock API responses + APIMockResponseFactory.mockAuthoriseResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockCreateAccountResponse(destinationMockAPIService: apiService, success: false) + + switch await appStorePurchaseFlow.purchaseSubscription(with: subscriptionSelectionID) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + switch error { + case .internalError(let innerError): + XCTAssertEqual(innerError as? SubscriptionManagerError, .tokenUnavailable(error: OAuthServiceError.authAPIError(code: .invalidAuthorizationRequest))) + default: + XCTFail("Unexpected error \(error)") + } + } + } + + func testAppStorePurchaseFailure_get_token() async throws { + // configure mock API responses + APIMockResponseFactory.mockAuthoriseResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockCreateAccountResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockGetAccessTokenResponse(destinationMockAPIService: apiService, success: false) + + switch await appStorePurchaseFlow.purchaseSubscription(with: subscriptionSelectionID) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + switch error { + case .internalError(let innerError): + XCTAssertEqual(innerError as? SubscriptionManagerError, .tokenUnavailable(error: OAuthServiceError.authAPIError(code: .invalidAuthorizationRequest))) + default: + XCTFail("Unexpected error \(error)") + } + } + } + + func testAppStorePurchaseFailure_get_JWKS() async throws { + // configure mock API responses + APIMockResponseFactory.mockAuthoriseResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockCreateAccountResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockGetAccessTokenResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockGetJWKS(destinationMockAPIService: apiService, success: false) + + switch await appStorePurchaseFlow.purchaseSubscription(with: subscriptionSelectionID) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + switch error { + case .internalError(let innerError): + XCTAssertEqual(innerError as? SubscriptionManagerError, .tokenUnavailable(error: OAuthServiceError.invalidResponseCode(.badRequest))) + default: + XCTFail("Unexpected error \(error)") + } + } + } + + func testAppStorePurchaseFailure_confirm_purchase() async throws { + // configure mock API responses + APIMockResponseFactory.mockAuthoriseResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockCreateAccountResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockGetAccessTokenResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockGetJWKS(destinationMockAPIService: apiService, success: true) + + (subscriptionManager.oAuthClient as! DefaultOAuthClient).testingDecodedTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + storePurchaseManager.purchaseSubscriptionResult = .success("purchaseTransactionJWS") + + SubscriptionAPIMockResponseFactory.mockConfirmPurchase(destinationMockAPIService: apiService, success: false) + + var purchaseTransactionJWS: String? + switch await appStorePurchaseFlow.purchaseSubscription(with: subscriptionSelectionID) { + case .success(let transactionJWS): + purchaseTransactionJWS = transactionJWS + case .failure(let error): + XCTFail("Purchase failed with error: \(error)") + } + XCTAssertNotNil(purchaseTransactionJWS) + + switch await appStorePurchaseFlow.completeSubscriptionPurchase(with: purchaseTransactionJWS!, additionalParams: nil) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + XCTAssertEqual(error, .purchaseFailed(SubscriptionEndpointServiceError.invalidResponseCode(.badRequest))) + } + } + + func testAppStorePurchaseFailure_get_features() async throws { + APIMockResponseFactory.mockAuthoriseResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockCreateAccountResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockGetAccessTokenResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockGetJWKS(destinationMockAPIService: apiService, success: true) + + (subscriptionManager.oAuthClient as! DefaultOAuthClient).testingDecodedTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + storePurchaseManager.purchaseSubscriptionResult = .success("purchaseTransactionJWS") + + SubscriptionAPIMockResponseFactory.mockConfirmPurchase(destinationMockAPIService: apiService, success: true) + SubscriptionAPIMockResponseFactory.mockGetFeatures(destinationMockAPIService: apiService, success: false, subscriptionID: "ios.subscription.1month") + + (subscriptionManager.oAuthClient as! DefaultOAuthClient).testingDecodedTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + + var purchaseTransactionJWS: String? + switch await appStorePurchaseFlow.purchaseSubscription(with: subscriptionSelectionID) { + case .success(let transactionJWS): + purchaseTransactionJWS = transactionJWS + case .failure(let error): + XCTFail("Purchase failed with error: \(error)") + } + XCTAssertNotNil(purchaseTransactionJWS) + + switch await appStorePurchaseFlow.completeSubscriptionPurchase(with: purchaseTransactionJWS!, additionalParams: nil) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + XCTAssertEqual(error, .purchaseFailed(SubscriptionEndpointServiceError.invalidResponseCode(.badRequest))) + } + } + + // MARK: - Stripe + + func testStripePurchaseSuccess() async throws { + // configure mock API responses + APIMockResponseFactory.mockAuthoriseResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockCreateAccountResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockGetAccessTokenResponse(destinationMockAPIService: apiService, success: true) + + (subscriptionManager.oAuthClient as! DefaultOAuthClient).testingDecodedTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + + // Buy subscription + let email = "test@duck.com" + let result = await stripePurchaseFlow.prepareSubscriptionPurchase(emailAccessToken: email) + switch result { + case .success(let success): + XCTAssertNotNil(success.type) + XCTAssertNotNil(success.token) + case .failure(let error): + XCTFail("Purchase failed with error: \(error)") + } + } + + func testStripePurchaseFailure_authorise() async throws { + // configure mock API responses + APIMockResponseFactory.mockAuthoriseResponse(destinationMockAPIService: apiService, success: false) + (subscriptionManager.oAuthClient as! DefaultOAuthClient).testingDecodedTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + + // Buy subscription + let email = "test@duck.com" + let result = await stripePurchaseFlow.prepareSubscriptionPurchase(emailAccessToken: email) + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + XCTAssertEqual(error, StripePurchaseFlowError.accountCreationFailed) + } + } + + func testStripePurchaseFailure_create_account() async throws { + // configure mock API responses + APIMockResponseFactory.mockAuthoriseResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockCreateAccountResponse(destinationMockAPIService: apiService, success: false) + + (subscriptionManager.oAuthClient as! DefaultOAuthClient).testingDecodedTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + + // Buy subscription + let email = "test@duck.com" + let result = await stripePurchaseFlow.prepareSubscriptionPurchase(emailAccessToken: email) + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + XCTAssertEqual(error, StripePurchaseFlowError.accountCreationFailed) + } + } + + func testStripePurchaseFailure_get_token() async throws { + // configure mock API responses + APIMockResponseFactory.mockAuthoriseResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockCreateAccountResponse(destinationMockAPIService: apiService, success: true) + APIMockResponseFactory.mockGetAccessTokenResponse(destinationMockAPIService: apiService, success: false) + + (subscriptionManager.oAuthClient as! DefaultOAuthClient).testingDecodedTokenContainer = OAuthTokensFactory.makeValidTokenContainerWithEntitlements() + + // Buy subscription + let email = "test@duck.com" + let result = await stripePurchaseFlow.prepareSubscriptionPurchase(emailAccessToken: email) + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + XCTAssertEqual(error, StripePurchaseFlowError.accountCreationFailed) + } + } +} diff --git a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift index 2a6a9d3d8..71c014b07 100644 --- a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift +++ b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift @@ -20,71 +20,41 @@ import XCTest import Common @testable import Subscription import SubscriptionTestingUtilities +import NetworkingTestingUtils final class SubscriptionCookieManagerTests: XCTestCase { - - private struct Constants { - static let authToken = UUID().uuidString - static let accessToken = UUID().uuidString - } - - var accountManager: AccountManagerMock! - var subscriptionService: SubscriptionEndpointServiceMock! - var authService: AuthEndpointServiceMock! - var storePurchaseManager: StorePurchaseManagerMock! - var subscriptionEnvironment: SubscriptionEnvironment! - var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock! var subscriptionManager: SubscriptionManagerMock! var cookieStore: HTTPCookieStore! - var subscriptionCookieManager: SubscriptionCookieManager! + var subscriptionCookieManager: SubscriptionCookieManagerV2! override func setUp() async throws { - accountManager = AccountManagerMock() - subscriptionService = SubscriptionEndpointServiceMock() - authService = AuthEndpointServiceMock() - storePurchaseManager = StorePurchaseManagerMock() - subscriptionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, - purchasePlatform: .appStore) - subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() - - subscriptionManager = SubscriptionManagerMock(accountManager: accountManager, - subscriptionEndpointService: subscriptionService, - authEndpointService: authService, - storePurchaseManager: storePurchaseManager, - currentEnvironment: subscriptionEnvironment, - canPurchase: true, - subscriptionFeatureMappingCache: subscriptionFeatureMappingCache) + subscriptionManager = SubscriptionManagerMock() cookieStore = MockHTTPCookieStore() - subscriptionCookieManager = SubscriptionCookieManager(subscriptionManager: subscriptionManager, + subscriptionCookieManager = SubscriptionCookieManagerV2(subscriptionManager: subscriptionManager, currentCookieStore: { self.cookieStore }, eventMapping: MockSubscriptionCookieManageEventPixelMapping(), refreshTimeInterval: .seconds(1)) } override func tearDown() async throws { - accountManager = nil - subscriptionService = nil - authService = nil - storePurchaseManager = nil - subscriptionEnvironment = nil - subscriptionManager = nil + subscriptionCookieManager = nil } func testSubscriptionCookieIsAddedWhenSigningInToSubscription() async throws { // Given await ensureNoSubscriptionCookieInTheCookieStore() - accountManager.accessToken = Constants.accessToken + subscriptionManager.resultTokenContainer = OAuthTokensFactory.makeValidTokenContainer() // When subscriptionCookieManager.enableSettingSubscriptionCookie() NotificationCenter.default.post(name: .accountDidSignIn, object: self, userInfo: nil) - try await Task.sleep(seconds: 0.1) + try await Task.sleep(interval: 0.1) // Then - await checkSubscriptionCookieIsPresent() + await checkSubscriptionCookieIsPresent(token: subscriptionManager.resultTokenContainer!.accessToken) } func testSubscriptionCookieIsDeletedWhenSigningInToSubscription() async throws { @@ -94,7 +64,7 @@ final class SubscriptionCookieManagerTests: XCTestCase { // When subscriptionCookieManager.enableSettingSubscriptionCookie() NotificationCenter.default.post(name: .accountDidSignOut, object: self, userInfo: nil) - try await Task.sleep(seconds: 0.1) + try await Task.sleep(interval: 0.1) // Then await checkSubscriptionCookieIsHasEmptyValue() @@ -102,27 +72,27 @@ final class SubscriptionCookieManagerTests: XCTestCase { func testRefreshWhenSignedInButCookieIsMissing() async throws { // Given - accountManager.accessToken = Constants.accessToken + subscriptionManager.resultTokenContainer = OAuthTokensFactory.makeValidTokenContainer() await ensureNoSubscriptionCookieInTheCookieStore() // When subscriptionCookieManager.enableSettingSubscriptionCookie() await subscriptionCookieManager.refreshSubscriptionCookie() - try await Task.sleep(seconds: 0.1) + try await Task.sleep(interval: 0.1) // Then - await checkSubscriptionCookieIsPresent() + await checkSubscriptionCookieIsPresent(token: subscriptionManager.resultTokenContainer!.accessToken) } func testRefreshWhenSignedOutButCookieIsPresent() async throws { // Given - accountManager.accessToken = nil + subscriptionManager.resultTokenContainer = nil await ensureSubscriptionCookieIsInTheCookieStore() // When subscriptionCookieManager.enableSettingSubscriptionCookie() await subscriptionCookieManager.refreshSubscriptionCookie() - try await Task.sleep(seconds: 0.1) + try await Task.sleep(interval: 0.1) // Then await checkSubscriptionCookieIsHasEmptyValue() @@ -138,7 +108,7 @@ final class SubscriptionCookieManagerTests: XCTestCase { await subscriptionCookieManager.refreshSubscriptionCookie() firstRefreshDate = subscriptionCookieManager.lastRefreshDate - try await Task.sleep(seconds: 0.5) + try await Task.sleep(interval: 0.5) await subscriptionCookieManager.refreshSubscriptionCookie() secondRefreshDate = subscriptionCookieManager.lastRefreshDate @@ -157,7 +127,7 @@ final class SubscriptionCookieManagerTests: XCTestCase { await subscriptionCookieManager.refreshSubscriptionCookie() firstRefreshDate = subscriptionCookieManager.lastRefreshDate - try await Task.sleep(seconds: 1.1) + try await Task.sleep(interval: 1.1) await subscriptionCookieManager.refreshSubscriptionCookie() secondRefreshDate = subscriptionCookieManager.lastRefreshDate @@ -167,12 +137,13 @@ final class SubscriptionCookieManagerTests: XCTestCase { } private func ensureSubscriptionCookieIsInTheCookieStore() async { + let validTokenContainer = OAuthTokensFactory.makeValidTokenContainer() let subscriptionCookie = HTTPCookie(properties: [ - .domain: SubscriptionCookieManager.cookieDomain, + .domain: SubscriptionCookieManagerV2.cookieDomain, .path: "/", .expires: Date().addingTimeInterval(.days(365)), - .name: SubscriptionCookieManager.cookieName, - .value: Constants.accessToken, + .name: SubscriptionCookieManagerV2.cookieName, + .value: validTokenContainer.accessToken, .secure: true, .init(rawValue: "HttpOnly"): true ])! @@ -187,12 +158,12 @@ final class SubscriptionCookieManagerTests: XCTestCase { XCTAssertTrue(cookieStoreCookies.isEmpty) } - private func checkSubscriptionCookieIsPresent() async { + private func checkSubscriptionCookieIsPresent(token: String) async { guard let subscriptionCookie = await cookieStore.fetchSubscriptionCookie() else { XCTFail("No subscription cookie in the store") return } - XCTAssertEqual(subscriptionCookie.value, Constants.accessToken) + XCTAssertEqual(subscriptionCookie.value, token) } private func checkSubscriptionCookieIsHasEmptyValue() async { @@ -208,7 +179,7 @@ final class SubscriptionCookieManagerTests: XCTestCase { private extension HTTPCookieStore { func fetchSubscriptionCookie() async -> HTTPCookie? { - await allCookies().first { $0.domain == SubscriptionCookieManager.cookieDomain && $0.name == SubscriptionCookieManager.cookieName } + await allCookies().first { $0.domain == SubscriptionCookieManagerV2.cookieDomain && $0.name == SubscriptionCookieManagerV2.cookieName } } } From 9c48278e5fcdd73c6b157cee87cdf3a75284db2e Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 11:52:43 +0000 Subject: [PATCH 14/28] porting other changes --- .../Extensions/Dictionary+URLQueryItem.swift | 35 +++++++++++++++++++ .../APIMockResponseFactory.swift | 20 +++++++++++ ...RemoteMessagingSurveyURLBuilderTests.swift | 14 ++++---- 3 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift diff --git a/Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift b/Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift new file mode 100644 index 000000000..004daf7ff --- /dev/null +++ b/Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift @@ -0,0 +1,35 @@ +// +// Dictionary+URLQueryItem.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 Common + +extension Dictionary where Key == String, Value == String { + + public func toURLQueryItems(allowedReservedCharacters: CharacterSet? = nil) -> [URLQueryItem] { + return self.sorted(by: <).map { + if let allowedReservedCharacters { + URLQueryItem(percentEncodingName: $0.key, + value: $0.value, + withAllowedCharacters: allowedReservedCharacters) + } else { + URLQueryItem(name: $0.key, value: $0.value) + } + } + } +} diff --git a/Sources/NetworkingTestingUtils/APIMockResponseFactory.swift b/Sources/NetworkingTestingUtils/APIMockResponseFactory.swift index d28495cc0..9cc83cfa4 100644 --- a/Sources/NetworkingTestingUtils/APIMockResponseFactory.swift +++ b/Sources/NetworkingTestingUtils/APIMockResponseFactory.swift @@ -87,6 +87,26 @@ public struct APIMockResponseFactory { } } + public static func mockRefreshAccessTokenResponse(destinationMockAPIService apiService: MockAPIService, success: Bool) { + let request = OAuthRequest.refreshAccessToken(baseURL: OAuthEnvironment.staging.url, + clientID: "clientID", + refreshToken: "someExpiredToken")! + if success { + let jsonString = """ +{"access_token":"eyJraWQiOiIzODJiNzQ5Yy1hNTc3LTRkOTMtOTU0My04NTI5MWZiYTM3MmEiLCJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJxWHk2TlRjeEI2UkQ0UUtSU05RYkNSM3ZxYU1SQU1RM1Q1UzVtTWdOWWtCOVZTVnR5SHdlb1R4bzcxVG1DYkJKZG1GWmlhUDVWbFVRQnd5V1dYMGNGUjo3ZjM4MTljZi0xNTBmLTRjYjEtOGNjNy1iNDkyMThiMDA2ZTgiLCJzY29wZSI6InByaXZhY3lwcm8iLCJhdWQiOiJQcml2YWN5UHJvIiwic3ViIjoiZTM3NmQ4YzQtY2FhOS00ZmNkLThlODYtMTlhNmQ2M2VlMzcxIiwiZXhwIjoxNzMwMzAxNTcyLCJlbWFpbCI6bnVsbCwiaWF0IjoxNzMwMjg3MTcyLCJpc3MiOiJodHRwczovL3F1YWNrZGV2LmR1Y2tkdWNrZ28uY29tIiwiZW50aXRsZW1lbnRzIjpbXSwiYXBpIjoidjIifQ.wOYgz02TXPJjDcEsp-889Xe1zh6qJG0P1UNHUnFBBELmiWGa91VQpqdl41EOOW3aE89KGvrD8YphRoZKiA3nHg", + "refresh_token":"eyJraWQiOiIzODJiNzQ5Yy1hNTc3LTRkOTMtOTU0My04NTI5MWZiYTM3MmEiLCJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJhcGkiOiJ2MiIsImlzcyI6Imh0dHBzOi8vcXVhY2tkZXYuZHVja2R1Y2tnby5jb20iLCJleHAiOjE3MzI4NzkxNzIsInN1YiI6ImUzNzZkOGM0LWNhYTktNGZjZC04ZTg2LTE5YTZkNjNlZTM3MSIsImF1ZCI6IkF1dGgiLCJpYXQiOjE3MzAyODcxNzIsInNjb3BlIjoicmVmcmVzaCIsImp0aSI6InFYeTZOVGN4QjZSRDRRS1JTTlFiQ1IzdnFhTVJBTVEzVDVTNW1NZ05Za0I5VlNWdHlId2VvVHhvNzFUbUNiQkpkbUZaaWFQNVZsVVFCd3lXV1gwY0ZSOmU2ODkwMDE5LWJmMDUtNGQxZC04OGFhLThlM2UyMDdjOGNkOSJ9.OQaGCmDBbDMM5XIpyY-WCmCLkZxt5Obp4YAmtFP8CerBSRexbUUp6SNwGDjlvCF0-an2REBsrX92ZmQe5ewqyQ","expires_in": 14400,"token_type": "Bearer"} +""" + let httpResponse = HTTPURLResponse(url: request.apiRequest.urlRequest.url!, + statusCode: request.httpSuccessCode.rawValue, + httpVersion: nil, + headerFields: [:])! + let response = APIResponseV2(data: jsonString.data(using: .utf8), httpResponse: httpResponse) + apiService.set(response: response, forRequest: request.apiRequest) + } else { + setErrorResponse(forRequest: request.apiRequest, apiService: apiService) + } + } + public static func mockGetJWKS(destinationMockAPIService apiService: MockAPIService, success: Bool) { let request = OAuthRequest.jwks(baseURL: OAuthEnvironment.staging.url)! if success { diff --git a/Tests/RemoteMessagingTests/Mappers/DefaultRemoteMessagingSurveyURLBuilderTests.swift b/Tests/RemoteMessagingTests/Mappers/DefaultRemoteMessagingSurveyURLBuilderTests.swift index c6a490eae..e4877cea3 100644 --- a/Tests/RemoteMessagingTests/Mappers/DefaultRemoteMessagingSurveyURLBuilderTests.swift +++ b/Tests/RemoteMessagingTests/Mappers/DefaultRemoteMessagingSurveyURLBuilderTests.swift @@ -89,13 +89,13 @@ class DefaultRemoteMessagingSurveyURLBuilderTests: XCTestCase { daysSinceLastActive: vpnDaysSinceLastActive ) - let subscription = DDGSubscription(productId: "product-id", - name: "product-name", - billingPeriod: .monthly, - startedAt: Date(timeIntervalSince1970: 1000), - expiresOrRenewsAt: Date(timeIntervalSince1970: 2000), - platform: .apple, - status: .autoRenewable) + let subscription = PrivacyProSubscription(productId: "product-id", + name: "product-name", + billingPeriod: .monthly, + startedAt: Date(timeIntervalSince1970: 1000), + expiresOrRenewsAt: Date(timeIntervalSince1970: 2000), + platform: .apple, + status: .autoRenewable) return DefaultRemoteMessagingSurveyURLBuilder( statisticsStore: mockStatisticsStore, From 86ba43b80585be62172ead077b964c8207a84070 Mon Sep 17 00:00:00 2001 From: Pete Smith <5278441+aataraxiaa@users.noreply.github.com> Date: Mon, 27 Jan 2025 12:04:21 +0000 Subject: [PATCH 15/28] Mobile Free Trials: Bug Fixes - Minor Change to Enable Testing (#1190) Please review the release process for BrowserServicesKit [here](https://app.asana.com/0/1200194497630846/1200837094583426). **Required**: Task/Issue URL: https://app.asana.com/0/0/1209119248168429/f iOS PR: https://github.com/duckduckgo/iOS/pull/3877 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3783 What kind of version bump will this require?: Patch **Description**: Minor change to enable testing **Steps to test this PR**: 1. Client PRs --- ###### Internal references: [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) --- .../Flows/AppStorePurchaseFlowMock.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift index 1f1cf83a0..ed9935ed1 100644 --- a/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift +++ b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift @@ -22,14 +22,18 @@ import Subscription public final class AppStorePurchaseFlowMock: AppStorePurchaseFlow { public var purchaseSubscriptionResult: Result? public var completeSubscriptionPurchaseResult: Result? + public var purchaseSubscriptionBlock: (() -> Void)? + public var completeSubscriptionAdditionalParams: [String: String]? public init() { } public func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result { - purchaseSubscriptionResult! + purchaseSubscriptionBlock?() + return purchaseSubscriptionResult! } public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS, additionalParams: [String: String]?) async -> Result { - completeSubscriptionPurchaseResult! + completeSubscriptionAdditionalParams = additionalParams + return completeSubscriptionPurchaseResult! } } From ace2294616446a490e2fdaa0aa0e2e8489a249b5 Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 12:17:55 +0000 Subject: [PATCH 16/28] v1 suffix removed and v2 suffix added --- .../API/SubscriptionEndpointService.swift | 2 +- .../API/SubscriptionEndpointServiceV2.swift | 2 +- ...rvice+SubscriptionFeatureMappingCache.swift | 4 ++-- .../Flows/AppStore/AppStorePurchaseFlow.swift | 2 +- .../StorePurchaseManager.swift | 2 +- .../StorePurchaseManagerV2.swift | 2 +- .../Managers/SubscriptionManager.swift | 2 +- .../Managers/SubscriptionManagerV2.swift | 4 ++-- .../SubscriptionCookieManager.swift | 5 ++--- .../Subscription/SubscriptionEnvironment.swift | 7 +++++++ .../SubscriptionCookieManagerMock.swift | 12 +++++++++++- .../API/SubscriptionEndpointServiceTests.swift | 4 ++-- .../Managers/StorePurchaseManagerTests.swift | 14 +++++++------- .../Managers/SubscriptionManagerTests.swift | 18 +++++++++--------- ...rivacyProSubscriptionIntegrationTests.swift | 6 +++--- 15 files changed, 51 insertions(+), 35 deletions(-) diff --git a/Sources/Subscription/API/SubscriptionEndpointService.swift b/Sources/Subscription/API/SubscriptionEndpointService.swift index 671483e9f..94de35455 100644 --- a/Sources/Subscription/API/SubscriptionEndpointService.swift +++ b/Sources/Subscription/API/SubscriptionEndpointService.swift @@ -79,7 +79,7 @@ extension SubscriptionEndpointService { } /// Communicates with our backend -public struct DefaultSubscriptionEndpointServiceV1: SubscriptionEndpointService { +public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService { private let currentServiceEnvironment: SubscriptionEnvironment.ServiceEnvironment private let apiService: SubscriptionAPIService private let subscriptionCache = UserDefaultsCache(key: UserDefaultsCacheKey.subscription, diff --git a/Sources/Subscription/API/SubscriptionEndpointServiceV2.swift b/Sources/Subscription/API/SubscriptionEndpointServiceV2.swift index 0c9a494f8..06558f15d 100644 --- a/Sources/Subscription/API/SubscriptionEndpointServiceV2.swift +++ b/Sources/Subscription/API/SubscriptionEndpointServiceV2.swift @@ -84,7 +84,7 @@ extension SubscriptionEndpointServiceV2 { } /// Communicates with our backend -public struct DefaultSubscriptionEndpointService: SubscriptionEndpointServiceV2 { +public struct DefaultSubscriptionEndpointServiceV2: SubscriptionEndpointServiceV2 { private let apiService: APIService private let baseURL: URL diff --git a/Sources/Subscription/DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCache.swift b/Sources/Subscription/DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCache.swift index edb3bb900..f6b1ecfaf 100644 --- a/Sources/Subscription/DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCache.swift +++ b/Sources/Subscription/DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCache.swift @@ -1,5 +1,5 @@ // -// DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCacheV2.swift +// DefaultSubscriptionEndpointServiceV2+SubscriptionFeatureMappingCacheV2.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -20,7 +20,7 @@ import Foundation import Networking import os.log -extension DefaultSubscriptionEndpointService: SubscriptionFeatureMappingCacheV2 { +extension DefaultSubscriptionEndpointServiceV2: SubscriptionFeatureMappingCacheV2 { public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [Networking.SubscriptionEntitlement] { do { diff --git a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift index 178d537a6..6500c878e 100644 --- a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift @@ -68,7 +68,7 @@ public protocol AppStorePurchaseFlow { } @available(macOS 12.0, iOS 15.0, *) -public final class DefaultAppStorePurchaseFlowV1: AppStorePurchaseFlow { +public final class DefaultAppStorePurchaseFlow: AppStorePurchaseFlow { private let subscriptionEndpointService: SubscriptionEndpointService private let storePurchaseManager: StorePurchaseManager private let accountManager: AccountManager diff --git a/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift index 61b13eeab..d153f74fa 100644 --- a/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift +++ b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift @@ -64,7 +64,7 @@ public protocol StorePurchaseManager { //@available(macOS 12.0, iOS 15.0, *) typealias Transaction = StoreKit.Transaction @available(macOS 12.0, iOS 15.0, *) -public final class DefaultStorePurchaseManagerV1: ObservableObject, StorePurchaseManager { +public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseManager { private let storeSubscriptionConfiguration: StoreSubscriptionConfiguration private let subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache diff --git a/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManagerV2.swift b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManagerV2.swift index af36f2046..baee2b75e 100644 --- a/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManagerV2.swift +++ b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManagerV2.swift @@ -65,7 +65,7 @@ public protocol StorePurchaseManagerV2 { @available(macOS 12.0, iOS 15.0, *) typealias Transaction = StoreKit.Transaction @available(macOS 12.0, iOS 15.0, *) -public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseManagerV2 { +public final class DefaultStorePurchaseManagerV2: ObservableObject, StorePurchaseManagerV2 { private let storeSubscriptionConfiguration: any StoreSubscriptionConfiguration private let subscriptionFeatureMappingCache: any SubscriptionFeatureMappingCacheV2 diff --git a/Sources/Subscription/Managers/SubscriptionManager.swift b/Sources/Subscription/Managers/SubscriptionManager.swift index 3ad8f4032..fc297ba38 100644 --- a/Sources/Subscription/Managers/SubscriptionManager.swift +++ b/Sources/Subscription/Managers/SubscriptionManager.swift @@ -40,7 +40,7 @@ public protocol SubscriptionManager { } /// 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. -public final class DefaultSubscriptionManagerV1: SubscriptionManager { +public final class DefaultSubscriptionManager: SubscriptionManager { private let _storePurchaseManager: StorePurchaseManager? public let accountManager: AccountManager public let subscriptionEndpointService: SubscriptionEndpointService diff --git a/Sources/Subscription/Managers/SubscriptionManagerV2.swift b/Sources/Subscription/Managers/SubscriptionManagerV2.swift index 7980e757f..0148c6589 100644 --- a/Sources/Subscription/Managers/SubscriptionManagerV2.swift +++ b/Sources/Subscription/Managers/SubscriptionManagerV2.swift @@ -120,7 +120,7 @@ public protocol SubscriptionManagerV2: SubscriptionTokenProvider { } /// 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. -public final class DefaultSubscriptionManager: SubscriptionManagerV2 { +public final class DefaultSubscriptionManagerV2: SubscriptionManagerV2 { var oAuthClient: any OAuthClient private let _storePurchaseManager: StorePurchaseManagerV2? @@ -412,7 +412,7 @@ Subscription features: \(result, privacy: .public) } } -extension DefaultSubscriptionManager: SubscriptionTokenProvider { +extension DefaultSubscriptionManagerV2: SubscriptionTokenProvider { public func getAccessToken() async throws -> String { try await getTokenContainer(policy: .localValid).accessToken } diff --git a/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift index 55e03ae47..7be3c8e24 100644 --- a/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift +++ b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift @@ -20,8 +20,7 @@ import Foundation import Common import os.log -public protocol SubscriptionCookieManagingV1 { - init(subscriptionManager: SubscriptionManager, currentCookieStore: @MainActor @escaping () -> HTTPCookieStore?, eventMapping: EventMapping) +public protocol SubscriptionCookieManaging { func enableSettingSubscriptionCookie() func disableSettingSubscriptionCookie() async @@ -31,7 +30,7 @@ public protocol SubscriptionCookieManagingV1 { var lastRefreshDate: Date? { get } } -public final class SubscriptionCookieManager: SubscriptionCookieManagingV1 { +public final class SubscriptionCookieManager: SubscriptionCookieManaging { public static let cookieDomain = "subscriptions.duckduckgo.com" public static let cookieName = "privacy_pro_access_token" diff --git a/Sources/Subscription/SubscriptionEnvironment.swift b/Sources/Subscription/SubscriptionEnvironment.swift index 95dd9910b..0a5f119de 100644 --- a/Sources/Subscription/SubscriptionEnvironment.swift +++ b/Sources/Subscription/SubscriptionEnvironment.swift @@ -32,6 +32,13 @@ public struct SubscriptionEnvironment: Codable { URL(string: "https://subscriptions-dev.duckduckgo.com/api")! } } + + public var description: String { + switch self { + case .production: return "Production" + case .staging: return "Staging" + } + } } public var authEnvironment: OAuthEnvironment { serviceEnvironment == .production ? .production : .staging } diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift index 93f587d99..ea34046b7 100644 --- a/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/SubscriptionCookie/SubscriptionCookieManagerMock.swift @@ -20,7 +20,17 @@ import Foundation import Common @testable import Subscription -public final class SubscriptionCookieManagerMock: SubscriptionCookieManagingV2 { +public final class SubscriptionCookieManagerMock: SubscriptionCookieManaging { + + public var lastRefreshDate: Date? + public init() {} + public func enableSettingSubscriptionCookie() { } + public func disableSettingSubscriptionCookie() async { } + public func refreshSubscriptionCookie() async { } + public func resetLastRefreshDate() { } +} + +public final class SubscriptionCookieManagerMockV2: SubscriptionCookieManagingV2 { public var lastRefreshDate: Date? public init() {} diff --git a/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift index ab7caa191..d9c524e0a 100644 --- a/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift +++ b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift @@ -25,7 +25,7 @@ import Common final class SubscriptionEndpointServiceTests: XCTestCase { private var apiService: MockAPIService! - private var endpointService: DefaultSubscriptionEndpointService! + private var endpointService: DefaultSubscriptionEndpointServiceV2! private let baseURL = SubscriptionEnvironment.ServiceEnvironment.staging.url private let disposableCache = UserDefaultsCache(key: UserDefaultsCacheKeyKest.subscriptionTest, settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) @@ -39,7 +39,7 @@ final class SubscriptionEndpointServiceTests: XCTestCase { encoder = JSONEncoder() encoder.dateEncodingStrategy = .millisecondsSince1970 apiService = MockAPIService() - endpointService = DefaultSubscriptionEndpointService(apiService: apiService, + endpointService = DefaultSubscriptionEndpointServiceV2(apiService: apiService, baseURL: baseURL, subscriptionCache: disposableCache) } diff --git a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift index d859139a6..7b4b8efc9 100644 --- a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift +++ b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift @@ -32,7 +32,7 @@ final class StorePurchaseManagerTests: XCTestCase { mockCache = SubscriptionFeatureMappingCacheMock() mockProductFetcher = MockProductFetcher() mockFeatureFlagger = MockFeatureFlagger() - sut = DefaultStorePurchaseManager(subscriptionFeatureMappingCache: mockCache, + sut = DefaultStorePurchaseManagerV2(subscriptionFeatureMappingCache: mockCache, subscriptionFeatureFlagger: mockFeatureFlagger, productFetcher: mockProductFetcher) } @@ -294,7 +294,7 @@ final class StorePurchaseManagerTests: XCTestCase { await sut.updateAvailableProducts() // Then - let products = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + let products = (sut as? DefaultStorePurchaseManagerV2)?.availableProducts ?? [] XCTAssertEqual(products.count, 2) XCTAssertTrue(products.contains(where: { $0.id == monthlyProduct.id })) XCTAssertTrue(products.contains(where: { $0.id == yearlyProduct.id })) @@ -308,7 +308,7 @@ final class StorePurchaseManagerTests: XCTestCase { await sut.updateAvailableProducts() // Then - let products = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + let products = (sut as? DefaultStorePurchaseManagerV2)?.availableProducts ?? [] XCTAssertTrue(products.isEmpty) } @@ -348,9 +348,9 @@ final class StorePurchaseManagerTests: XCTestCase { await sut.updateAvailableProducts() // Then - Verify USA products - let usaProducts = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + let usaProducts = (sut as? DefaultStorePurchaseManagerV2)?.availableProducts ?? [] XCTAssertEqual(usaProducts.count, 2) - XCTAssertEqual((sut as? DefaultStorePurchaseManager)?.currentStorefrontRegion, .usa) + XCTAssertEqual((sut as? DefaultStorePurchaseManagerV2)?.currentStorefrontRegion, .usa) XCTAssertTrue(usaProducts.contains(where: { $0.id == "com.test.usa.monthly" })) XCTAssertTrue(usaProducts.contains(where: { $0.id == "com.test.usa.yearly" })) @@ -360,9 +360,9 @@ final class StorePurchaseManagerTests: XCTestCase { await sut.updateAvailableProducts() // Then - Verify ROW products - let rowProducts = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + let rowProducts = (sut as? DefaultStorePurchaseManagerV2)?.availableProducts ?? [] XCTAssertEqual(rowProducts.count, 2) - XCTAssertEqual((sut as? DefaultStorePurchaseManager)?.currentStorefrontRegion, .restOfWorld) + XCTAssertEqual((sut as? DefaultStorePurchaseManagerV2)?.currentStorefrontRegion, .restOfWorld) XCTAssertTrue(rowProducts.contains(where: { $0.id == "com.test.row.monthly" })) XCTAssertTrue(rowProducts.contains(where: { $0.id == "com.test.row.yearly" })) diff --git a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift index 3ba421673..e769d0aac 100644 --- a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift +++ b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift @@ -24,7 +24,7 @@ import NetworkingTestingUtils class SubscriptionManagerTests: XCTestCase { - var subscriptionManager: DefaultSubscriptionManager! + var subscriptionManager: DefaultSubscriptionManagerV2! var mockOAuthClient: MockOAuthClient! var mockSubscriptionEndpointService: SubscriptionEndpointServiceMock! var mockStorePurchaseManager: StorePurchaseManagerMock! @@ -36,7 +36,7 @@ class SubscriptionManagerTests: XCTestCase { mockSubscriptionEndpointService = SubscriptionEndpointServiceMock() mockStorePurchaseManager = StorePurchaseManagerMock() - subscriptionManager = DefaultSubscriptionManager( + subscriptionManager = DefaultSubscriptionManagerV2( storePurchaseManager: mockStorePurchaseManager, oAuthClient: mockOAuthClient, subscriptionEndpointService: mockSubscriptionEndpointService, @@ -78,7 +78,7 @@ class SubscriptionManagerTests: XCTestCase { ) mockSubscriptionEndpointService.getSubscriptionResult = .success(expiredSubscription) let expectation = self.expectation(description: "Dead token pixel called") - subscriptionManager = DefaultSubscriptionManager( + subscriptionManager = DefaultSubscriptionManagerV2( storePurchaseManager: mockStorePurchaseManager, oAuthClient: mockOAuthClient, subscriptionEndpointService: mockSubscriptionEndpointService, @@ -154,7 +154,7 @@ class SubscriptionManagerTests: XCTestCase { func testURLGeneration_ForSubscriptionTypes() { let environment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore) - subscriptionManager = DefaultSubscriptionManager( + subscriptionManager = DefaultSubscriptionManagerV2( storePurchaseManager: mockStorePurchaseManager, oAuthClient: mockOAuthClient, subscriptionEndpointService: mockSubscriptionEndpointService, @@ -192,13 +192,13 @@ class SubscriptionManagerTests: XCTestCase { let userDefaults = UserDefaults(suiteName: userDefaultsSuiteName)! userDefaults.removePersistentDomain(forName: userDefaultsSuiteName) - var loadedEnvironment = DefaultSubscriptionManager.loadEnvironmentFrom(userDefaults: userDefaults) + var loadedEnvironment = DefaultSubscriptionManagerV2.loadEnvironmentFrom(userDefaults: userDefaults) XCTAssertNil(loadedEnvironment) // When - DefaultSubscriptionManager.save(subscriptionEnvironment: subscriptionEnvironment, + DefaultSubscriptionManagerV2.save(subscriptionEnvironment: subscriptionEnvironment, userDefaults: userDefaults) - loadedEnvironment = DefaultSubscriptionManager.loadEnvironmentFrom(userDefaults: userDefaults) + loadedEnvironment = DefaultSubscriptionManagerV2.loadEnvironmentFrom(userDefaults: userDefaults) // Then XCTAssertEqual(loadedEnvironment?.serviceEnvironment, subscriptionEnvironment.serviceEnvironment) @@ -211,7 +211,7 @@ class SubscriptionManagerTests: XCTestCase { // Given let productionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore) - let productionSubscriptionManager = DefaultSubscriptionManager( + let productionSubscriptionManager = DefaultSubscriptionManagerV2( storePurchaseManager: mockStorePurchaseManager, oAuthClient: mockOAuthClient, subscriptionEndpointService: mockSubscriptionEndpointService, @@ -230,7 +230,7 @@ class SubscriptionManagerTests: XCTestCase { // Given let stagingEnvironment = SubscriptionEnvironment(serviceEnvironment: .staging, purchasePlatform: .appStore) - let stagingSubscriptionManager = DefaultSubscriptionManager( + let stagingSubscriptionManager = DefaultSubscriptionManagerV2( storePurchaseManager: mockStorePurchaseManager, oAuthClient: mockOAuthClient, subscriptionEndpointService: mockSubscriptionEndpointService, diff --git a/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift b/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift index e3d9369cd..dcaacfcb9 100644 --- a/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift +++ b/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift @@ -28,7 +28,7 @@ final class PrivacyProSubscriptionIntegrationTests: XCTestCase { var apiService: MockAPIService! var tokenStorage: MockTokenStorage! var legacyAccountStorage: MockLegacyTokenStorage! - var subscriptionManager: DefaultSubscriptionManager! + var subscriptionManager: DefaultSubscriptionManagerV2! var appStorePurchaseFlow: DefaultAppStorePurchaseFlowV2! var appStoreRestoreFlow: DefaultAppStoreRestoreFlowV2! var stripePurchaseFlow: DefaultStripePurchaseFlow! @@ -52,14 +52,14 @@ final class PrivacyProSubscriptionIntegrationTests: XCTestCase { legacyTokenStorage: legacyAccountStorage, authService: authService) storePurchaseManager = StorePurchaseManagerMock() - let subscriptionEndpointService = DefaultSubscriptionEndpointService(apiService: apiService, + let subscriptionEndpointService = DefaultSubscriptionEndpointServiceV2(apiService: apiService, baseURL: subscriptionEnvironment.serviceEnvironment.url) let pixelHandler: SubscriptionManagerV2.PixelHandler = { type in print("Pixel fired: \(type)") } subscriptionFeatureFlagger = FeatureFlaggerMapping(mapping: { $0.defaultState }) - subscriptionManager = DefaultSubscriptionManager(storePurchaseManager: storePurchaseManager, + subscriptionManager = DefaultSubscriptionManagerV2(storePurchaseManager: storePurchaseManager, oAuthClient: authClient, subscriptionEndpointService: subscriptionEndpointService, subscriptionEnvironment: subscriptionEnvironment, From 5a37e3ac08af48666d697191118eebad8af424ed Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Mon, 27 Jan 2025 13:23:45 +0100 Subject: [PATCH 17/28] Sync: Fire accountRemoved KV store reason when no account (#1189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Please review the release process for BrowserServicesKit [here](https://app.asana.com/0/1200194497630846/1200837094583426). **Required**: Task/Issue URL: https://app.asana.com/0/1202926619870900/1209014391063056/f iOS PR: https://github.com/duckduckgo/iOS/pull/3875 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3782 What kind of version bump will this require?: Minor **Description**: As part of [✓ Send pixels for account removal + decoding issues](https://app.asana.com/0/1199230911884351/1208723035104886/f), pixels were added indicating what the reason for the Sync account being removed from the Keychain was. However, the pixel `sync_account_removed_reason_not-set-on-key-value-store` is being sent even when there was no account stored in the keychain in the first place. This could be obscuring a problem. To make this Pixel more useful, we should update it so that it's only sent when there was an account stored in the keychain. **Steps to test this PR**: Hard to test the pixel itself as reporting on a broken state, but just do a quick smoke test of Sync on both platforms. **OS Testing**: * [ ] iOS 14 * [ ] iOS 15 * [ ] iOS 16 * [ ] macOS 10.15 * [ ] macOS 11 * [ ] macOS 12 --- ###### Internal references: [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) --- Sources/DDGSync/DDGSync.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/DDGSync/DDGSync.swift b/Sources/DDGSync/DDGSync.swift index 9a2ba864e..2ddb07f57 100644 --- a/Sources/DDGSync/DDGSync.swift +++ b/Sources/DDGSync/DDGSync.swift @@ -226,8 +226,10 @@ public class DDGSync: DDGSyncing { let syncEnabled = dependencies.keyValueStore.object(forKey: Constants.syncEnabledKey) != nil guard syncEnabled else { + if account != nil { + dependencies.errorEvents.fire(.accountRemoved(.syncEnabledNotSetOnKeyValueStore)) + } try? dependencies.secureStore.removeAccount() - dependencies.errorEvents.fire(.accountRemoved(.syncEnabledNotSetOnKeyValueStore)) authState = .inactive return } From 36b269056ce472059f2644b36189d2a9182d8989 Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 12:37:32 +0000 Subject: [PATCH 18/28] V1 Testing utilities re-added --- .../API/SubscriptionEndpointService.swift | 8 +- ...2+SubscriptionFeatureMappingCacheV2.swift} | 0 .../Flows/AppStore/AppStorePurchaseFlow.swift | 4 +- .../AppStore/AppStorePurchaseFlowV2.swift | 2 +- .../Flows/AppStore/AppStoreRestoreFlow.swift | 2 +- .../Flows/Models/SubscriptionOptions.swift | 26 +-- ...eFlowV1.swift => StripePurchaseFlow.swift} | 20 +- .../Flows/Stripe/StripePurchaseFlowV2.swift | 2 +- .../StorePurchaseManager.swift | 20 +- .../Managers/SubscriptionManagerV2.swift | 6 +- .../SubscriptionCookieManager.swift | 4 +- ...ture.swift => SubscriptionFeatureV2.swift} | 2 +- .../APIs/APIServiceMock.swift | 63 ++++++ .../APIs/AuthEndpointServiceMock.swift | 57 ++++++ .../SubscriptionEndpointServiceMock.swift | 72 +++---- .../SubscriptionEndpointServiceMockV2.swift | 86 +++++++++ ...untManagerKeychainAccessDelegateMock.swift | 33 ++++ ...SubscriptionTokenKeychainStorageMock.swift | 44 +++++ .../AppStoreAccountManagementFlowMock.swift | 34 ++++ .../Flows/AppStorePurchaseFlowMock.swift | 5 +- .../Flows/AppStorePurchaseFlowMockV2.swift | 36 ++++ .../Flows/AppStoreRestoreFlowMock.swift | 6 +- .../Flows/AppStoreRestoreFlowMockV2.swift | 32 ++++ .../Flows/StripePurchaseFlowMock.swift | 8 +- .../Flows/StripePurchaseFlowMockV2.swift | 42 ++++ .../Managers/AccountManagerMock.swift | 110 +++++++++++ .../Managers/StorePurchaseManagerMock.swift | 10 +- .../Managers/StorePurchaseManagerMockV2.swift | 79 ++++++++ .../Managers/SubscriptionManagerMock.swift | 174 ++++------------- .../Managers/SubscriptionManagerMockV2.swift | 179 ++++++++++++++++++ .../SubscriptionFeatureMappingCacheMock.swift | 7 +- ...ubscriptionFeatureMappingCacheMockV2.swift | 37 ++++ .../Flows/AppStorePurchaseFlowTests.swift | 24 +-- .../Flows/AppStoreRestoreFlowTests.swift | 8 +- .../Flows/StripePurchaseFlowTests.swift | 6 +- .../Managers/StorePurchaseManagerTests.swift | 4 +- .../Managers/SubscriptionManagerTests.swift | 8 +- ...ivacyProSubscriptionIntegrationTests.swift | 8 +- .../SubscriptionCookieManagerTests.swift | 4 +- 39 files changed, 994 insertions(+), 278 deletions(-) rename Sources/Subscription/{DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCache.swift => DefaultSubscriptionEndpointServiceV2+SubscriptionFeatureMappingCacheV2.swift} (100%) rename Sources/Subscription/Flows/Stripe/{StripePurchaseFlowV1.swift => StripePurchaseFlow.swift} (90%) rename Sources/Subscription/{SubscriptionFeature.swift => SubscriptionFeatureV2.swift} (93%) create mode 100644 Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift create mode 100644 Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift create mode 100644 Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMockV2.swift create mode 100644 Sources/SubscriptionTestingUtilities/AccountStorage/AccountManagerKeychainAccessDelegateMock.swift create mode 100644 Sources/SubscriptionTestingUtilities/AccountStorage/SubscriptionTokenKeychainStorageMock.swift create mode 100644 Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift create mode 100644 Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMockV2.swift create mode 100644 Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMockV2.swift create mode 100644 Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMockV2.swift create mode 100644 Sources/SubscriptionTestingUtilities/Managers/AccountManagerMock.swift create mode 100644 Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMockV2.swift create mode 100644 Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMockV2.swift create mode 100644 Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMockV2.swift diff --git a/Sources/Subscription/API/SubscriptionEndpointService.swift b/Sources/Subscription/API/SubscriptionEndpointService.swift index 94de35455..2e1f8a282 100644 --- a/Sources/Subscription/API/SubscriptionEndpointService.swift +++ b/Sources/Subscription/API/SubscriptionEndpointService.swift @@ -19,21 +19,21 @@ import Common import Foundation // -//public struct GetProductsItem: Decodable { +// public struct GetProductsItem: Decodable { // public let productId: String // public let productLabel: String // public let billingPeriod: String // public let price: String // public let currency: String -//} +// } public struct GetSubscriptionFeaturesResponse: Decodable { public let features: [Entitlement.ProductName] } -//public struct GetCustomerPortalURLResponse: Decodable { +// public struct GetCustomerPortalURLResponse: Decodable { // public let customerPortalUrl: String -//} +// } public struct ConfirmPurchaseResponse: Decodable { public let email: String? diff --git a/Sources/Subscription/DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCache.swift b/Sources/Subscription/DefaultSubscriptionEndpointServiceV2+SubscriptionFeatureMappingCacheV2.swift similarity index 100% rename from Sources/Subscription/DefaultSubscriptionEndpointService+SubscriptionFeatureMappingCache.swift rename to Sources/Subscription/DefaultSubscriptionEndpointServiceV2+SubscriptionFeatureMappingCacheV2.swift diff --git a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift index 6500c878e..0788535ae 100644 --- a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift @@ -20,7 +20,7 @@ import Foundation import StoreKit import os.log -//public enum AppStorePurchaseFlowErrorV1: Swift.Error { +// public enum AppStorePurchaseFlowErrorV1: Swift.Error { // case noProductsFound // case activeSubscriptionAlreadyPresent // case authenticatingWithTransactionFailed @@ -29,7 +29,7 @@ import os.log // case cancelledByUser // case missingEntitlements // case internalError -//} +// } @available(macOS 12.0, iOS 15.0, *) public protocol AppStorePurchaseFlow { diff --git a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlowV2.swift b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlowV2.swift index 71b3f4ad1..28548c9e5 100644 --- a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlowV2.swift +++ b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlowV2.swift @@ -1,5 +1,5 @@ // -// AppStorePurchaseFlow.swift +// AppStorePurchaseFlowV2.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // diff --git a/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift index 94633ba0e..004b77f8f 100644 --- a/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStoreRestoreFlow.swift @@ -1,5 +1,5 @@ // -// AppStoreRestoreFlowV1.swift +// AppStoreRestoreFlow.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // diff --git a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift index ff9efebcf..e5548a5a1 100644 --- a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift +++ b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift @@ -20,13 +20,13 @@ import Foundation public struct SubscriptionOptions: Encodable, Equatable { let platform: SubscriptionPlatformName - let options: [SubscriptionOptionV1] - let features: [SubscriptionFeatureV1] + let options: [SubscriptionOption] + let features: [SubscriptionFeature] public static var empty: SubscriptionOptions { - let features = [SubscriptionFeatureV1(name: .networkProtection), - SubscriptionFeatureV1(name: .dataBrokerProtection), - SubscriptionFeatureV1(name: .identityTheftRestoration)] + let features = [SubscriptionFeature(name: .networkProtection), + SubscriptionFeature(name: .dataBrokerProtection), + SubscriptionFeature(name: .identityTheftRestoration)] let platform: SubscriptionPlatformName #if os(iOS) platform = .ios @@ -41,13 +41,13 @@ public struct SubscriptionOptions: Encodable, Equatable { } } -//public enum SubscriptionPlatformName: String, Encodable { +// public enum SubscriptionPlatformName: String, Encodable { // case ios // case macos // case stripe -//} +// } -public struct SubscriptionOptionV1: Encodable, Equatable { +public struct SubscriptionOption: Encodable, Equatable { let id: String let cost: SubscriptionOptionCost let offer: SubscriptionOptionOffer? @@ -59,17 +59,17 @@ public struct SubscriptionOptionV1: Encodable, Equatable { } } -//struct SubscriptionOptionCost: Encodable, Equatable { +// struct SubscriptionOptionCost: Encodable, Equatable { // let displayPrice: String // let recurrence: String -//} +// } -public struct SubscriptionFeatureV1: Encodable, Equatable { +public struct SubscriptionFeature: Encodable, Equatable { let name: Entitlement.ProductName } ///// A `SubscriptionOptionOffer` represents an offer (e.g Free Trials) associated with a Subscription -//public struct SubscriptionOptionOffer: Encodable, Equatable { +// public struct SubscriptionOptionOffer: Encodable, Equatable { // // public enum OfferType: String, Codable, CaseIterable { // case freeTrial @@ -79,4 +79,4 @@ public struct SubscriptionFeatureV1: Encodable, Equatable { // let id: String // let durationInDays: Int? // let isUserEligible: Bool -//} +// } diff --git a/Sources/Subscription/Flows/Stripe/StripePurchaseFlowV1.swift b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift similarity index 90% rename from Sources/Subscription/Flows/Stripe/StripePurchaseFlowV1.swift rename to Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift index 9233ab30c..46f37f074 100644 --- a/Sources/Subscription/Flows/Stripe/StripePurchaseFlowV1.swift +++ b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift @@ -1,5 +1,5 @@ // -// StripePurchaseFlowV1.swift +// StripePurchaseFlow.swift // // Copyright © 2023 DuckDuckGo. All rights reserved. // @@ -20,18 +20,18 @@ import Foundation import StoreKit import os.log -//public enum StripePurchaseFlowError: Swift.Error { +// public enum StripePurchaseFlowError: Swift.Error { // case noProductsFound // case accountCreationFailed -//} +// } -public protocol StripePurchaseFlowV1 { +public protocol StripePurchaseFlow { func subscriptionOptions() async -> Result func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result func completeSubscriptionPurchase() async } -public final class DefaultStripePurchaseFlowV1: StripePurchaseFlowV1 { +public final class DefaultStripePurchaseFlow: StripePurchaseFlow { private let subscriptionEndpointService: SubscriptionEndpointService private let authEndpointService: AuthEndpointService private let accountManager: AccountManager @@ -58,7 +58,7 @@ public final class DefaultStripePurchaseFlowV1: StripePurchaseFlowV1 { formatter.numberStyle = .currency formatter.locale = Locale(identifier: "en_US@currency=\(currency)") - let options: [SubscriptionOptionV1] = products.map { + let options: [SubscriptionOption] = products.map { var displayPrice = "\($0.price) \($0.currency)" if let price = Float($0.price), let formattedPrice = formatter.string(from: price as NSNumber) { @@ -67,13 +67,13 @@ public final class DefaultStripePurchaseFlowV1: StripePurchaseFlowV1 { let cost = SubscriptionOptionCost(displayPrice: displayPrice, recurrence: $0.billingPeriod.lowercased()) - return SubscriptionOptionV1(id: $0.productId, + return SubscriptionOption(id: $0.productId, cost: cost) } - let features = [SubscriptionFeatureV1(name: .networkProtection), - SubscriptionFeatureV1(name: .dataBrokerProtection), - SubscriptionFeatureV1(name: .identityTheftRestoration)] + let features = [SubscriptionFeature(name: .networkProtection), + SubscriptionFeature(name: .dataBrokerProtection), + SubscriptionFeature(name: .identityTheftRestoration)] return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe, options: options, diff --git a/Sources/Subscription/Flows/Stripe/StripePurchaseFlowV2.swift b/Sources/Subscription/Flows/Stripe/StripePurchaseFlowV2.swift index 9056281fd..f4d5e532b 100644 --- a/Sources/Subscription/Flows/Stripe/StripePurchaseFlowV2.swift +++ b/Sources/Subscription/Flows/Stripe/StripePurchaseFlowV2.swift @@ -32,7 +32,7 @@ public protocol StripePurchaseFlowV2 { func completeSubscriptionPurchase() async } -public final class DefaultStripePurchaseFlow: StripePurchaseFlowV2 { +public final class DefaultStripePurchaseFlowV2: StripePurchaseFlowV2 { private let subscriptionManager: any SubscriptionManagerV2 public init(subscriptionManager: any SubscriptionManagerV2) { diff --git a/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift index d153f74fa..a7fa4adf1 100644 --- a/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift +++ b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift @@ -20,11 +20,11 @@ import Foundation import StoreKit import os.log -//public enum StoreError: Error { +// public enum StoreError: Error { // case failedVerification -//} +// } // -//public enum StorePurchaseManagerError: Error { +// public enum StorePurchaseManagerError: Error { // case productNotFound // case externalIDisNotAValidUUID // case purchaseFailed @@ -32,7 +32,7 @@ import os.log // case transactionPendingAuthentication // case purchaseCancelledByUser // case unknownError -//} +// } public protocol StorePurchaseManager { typealias TransactionJWS = String @@ -61,7 +61,7 @@ public protocol StorePurchaseManager { @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 Transaction = StoreKit.Transaction @available(macOS 12.0, iOS 15.0, *) public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseManager { @@ -300,10 +300,10 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM #endif }() - let options: [SubscriptionOptionV1] = await [.init(from: monthly, withRecurrence: "monthly"), + let options: [SubscriptionOption] = await [.init(from: monthly, withRecurrence: "monthly"), .init(from: yearly, withRecurrence: "yearly")] - let features: [SubscriptionFeatureV1] = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeatureV1(name: $0) } + let features: [SubscriptionFeature] = await subscriptionFeatureMappingCache.subscriptionFeatures(for: monthly.id).compactMap { SubscriptionFeature(name: $0) } return SubscriptionOptions(platform: platform, options: options, @@ -350,7 +350,7 @@ public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseM } @available(macOS 12.0, iOS 15.0, *) -private extension SubscriptionOptionV1 { +private extension SubscriptionOption { init(from product: any SubscriptionProduct, withRecurrence recurrence: String) async { var offer: SubscriptionOptionOffer? @@ -367,7 +367,7 @@ private extension SubscriptionOptionV1 { } } -//public extension UserDefaults { +// public extension UserDefaults { // // enum Constants { // static let storefrontRegionOverrideKey = "Subscription.debug.storefrontRegionOverride" @@ -398,4 +398,4 @@ private extension SubscriptionOptionV1 { // } // } // } -//} +// } diff --git a/Sources/Subscription/Managers/SubscriptionManagerV2.swift b/Sources/Subscription/Managers/SubscriptionManagerV2.swift index 0148c6589..35546e861 100644 --- a/Sources/Subscription/Managers/SubscriptionManagerV2.swift +++ b/Sources/Subscription/Managers/SubscriptionManagerV2.swift @@ -93,7 +93,7 @@ public protocol SubscriptionManagerV2: SubscriptionTokenProvider { /// Get the current subscription features /// A feature is based on an entitlement and can be enabled or disabled /// A user cant have an entitlement without the feature, if a user is missing an entitlement the feature is disabled - func currentSubscriptionFeatures(forceRefresh: Bool) async -> [SubscriptionFeature] + func currentSubscriptionFeatures(forceRefresh: Bool) async -> [SubscriptionFeatureV2] /// True if the feature can be used by the user, false otherwise func isFeatureAvailableForUser(_ entitlement: SubscriptionEntitlement) async -> Bool @@ -375,7 +375,7 @@ public final class DefaultSubscriptionManagerV2: SubscriptionManagerV2 { /// Returns the features available for the current subscription, a feature is enabled only if the user has the corresponding entitlement /// - Parameter forceRefresh: ignore subscription and token cache and re-download everything /// - Returns: An Array of SubscriptionFeature where each feature is enabled or disabled based on the user entitlements - public func currentSubscriptionFeatures(forceRefresh: Bool) async -> [SubscriptionFeature] { + public func currentSubscriptionFeatures(forceRefresh: Bool) async -> [SubscriptionFeatureV2] { guard isUserAuthenticated else { return [] } do { @@ -388,7 +388,7 @@ public final class DefaultSubscriptionManagerV2: SubscriptionManagerV2 { // Filter out the features that are not available because the user doesn't have the right entitlements let result = availableFeatures.map({ featureEntitlement in let enabled = userEntitlements.contains(featureEntitlement) - return SubscriptionFeature(entitlement: featureEntitlement, availableForUser: enabled) + return SubscriptionFeatureV2(entitlement: featureEntitlement, availableForUser: enabled) }) Logger.subscription.log(""" User entitlements: \(userEntitlements, privacy: .public) diff --git a/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift index 7be3c8e24..5b8fe37ac 100644 --- a/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift +++ b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift @@ -165,9 +165,9 @@ public final class SubscriptionCookieManager: SubscriptionCookieManaging { } } -//enum SubscriptionCookieManagerError: Error { +// enum SubscriptionCookieManagerError: Error { // case failedToCreateSubscriptionCookie -//} +// } private extension HTTPCookieStore { diff --git a/Sources/Subscription/SubscriptionFeature.swift b/Sources/Subscription/SubscriptionFeatureV2.swift similarity index 93% rename from Sources/Subscription/SubscriptionFeature.swift rename to Sources/Subscription/SubscriptionFeatureV2.swift index 0b8eeb19e..706a41649 100644 --- a/Sources/Subscription/SubscriptionFeature.swift +++ b/Sources/Subscription/SubscriptionFeatureV2.swift @@ -21,7 +21,7 @@ import Networking /// A `SubscriptionFeature` is **available** if the specific feature is `on` for the specific subscription. Feature availability if decided based on the country and the local and remote feature flags. /// A `SubscriptionFeature` is **availableForUser** if the logged in user has the required entitlements. -public struct SubscriptionFeature: Equatable, CustomDebugStringConvertible { +public struct SubscriptionFeatureV2: Equatable, CustomDebugStringConvertible { public var entitlement: SubscriptionEntitlement public var availableForUser: Bool diff --git a/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift new file mode 100644 index 000000000..9557e1c80 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/APIs/APIServiceMock.swift @@ -0,0 +1,63 @@ +// +// 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 final class APIServiceMock: SubscriptionAPIService { + public var mockAuthHeaders: [String: String] = [String: String]() + + public var mockResponseJSONData: Data? + public var mockAPICallSuccessResult: Any? + public var mockAPICallError: APIServiceError? + + public var onExecuteAPICall: ((ExecuteAPICallParameters) -> Void)? + + public typealias ExecuteAPICallParameters = (method: String, endpoint: String, headers: [String: String]?, body: Data?) + + public init() { } + + // swiftlint:disable force_cast + public func executeAPICall(method: String, endpoint: String, headers: [String: String]?, body: Data?) async -> Result where T: Decodable { + + onExecuteAPICall?(ExecuteAPICallParameters(method, endpoint, headers, body)) + + if let data = mockResponseJSONData { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + decoder.dateDecodingStrategy = .millisecondsSince1970 + + if let decodedResponse = try? decoder.decode(T.self, from: data) { + return .success(decodedResponse) + } else { + return .failure(.decodingError) + } + } else 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..e36f32fee --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/APIs/AuthEndpointServiceMock.swift @@ -0,0 +1,57 @@ +// +// 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 final class AuthEndpointServiceMock: AuthEndpointService { + public var getAccessTokenResult: Result? + public var validateTokenResult: Result? + public var createAccountResult: Result? + public var storeLoginResult: Result? + + public var onValidateToken: ((String) -> Void)? + + public var getAccessTokenCalled: Bool = false + public var validateTokenCalled: Bool = false + public var createAccountCalled: Bool = false + public var storeLoginCalled: Bool = false + + public init() { } + + public func getAccessToken(token: String) async -> Result { + getAccessTokenCalled = true + return getAccessTokenResult! + } + + public func validateToken(accessToken: String) async -> Result { + validateTokenCalled = true + onValidateToken?(accessToken) + return validateTokenResult! + } + + public func createAccount(emailAccessToken: String?) async -> Result { + createAccountCalled = true + return createAccountResult! + } + + public func storeLogin(signature: String) async -> Result { + storeLoginCalled = true + return storeLoginResult! + } +} diff --git a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift index cfe10e611..0e91e1b78 100644 --- a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift +++ b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMock.swift @@ -18,69 +18,55 @@ import Foundation import Subscription -import Networking -public final class SubscriptionEndpointServiceMock: SubscriptionEndpointServiceV2 { +public final class SubscriptionEndpointServiceMock: SubscriptionEndpointService { + public var getSubscriptionResult: Result? + public var getProductsResult: Result<[GetProductsItem], APIServiceError>? + public var getSubscriptionFeaturesResult: Result? + public var getCustomerPortalURLResult: Result? + public var confirmPurchaseResult: Result? + public var onUpdateCache: ((PrivacyProSubscription) -> Void)? + public var onConfirmPurchase: ((String, String, [String: String]?) -> Void)? + public var onGetSubscription: ((String, APICachePolicy) -> Void)? public var onSignOut: (() -> Void)? + + public var updateCacheWithSubscriptionCalled: Bool = false + public var getSubscriptionCalled: Bool = false public var signOutCalled: Bool = false public init() { } - public var updateCacheWithSubscriptionCalled: Bool = false - public var onUpdateCache: ((PrivacyProSubscription) -> Void)? - public func updateCache(with subscription: Subscription.PrivacyProSubscription) { + public func updateCache(with subscription: PrivacyProSubscription) { onUpdateCache?(subscription) updateCacheWithSubscriptionCalled = true } - public func clearSubscription() {} - - public var getProductsResult: Result<[GetProductsItem], APIRequestV2.Error>? - public func getProducts() async throws -> [Subscription.GetProductsItem] { - switch getProductsResult! { - case .success(let result): return result - case .failure(let error): throw error - } - } - - public var getSubscriptionCalled: Bool = false - public var onGetSubscription: ((String, SubscriptionCachePolicy) -> Void)? - public var getSubscriptionResult: Result? - public func getSubscription(accessToken: String, cachePolicy: Subscription.SubscriptionCachePolicy) async throws -> Subscription.PrivacyProSubscription { + public func getSubscription(accessToken: String, cachePolicy: APICachePolicy) async -> Result { getSubscriptionCalled = true onGetSubscription?(accessToken, cachePolicy) - switch getSubscriptionResult! { - case .success(let subscription): return subscription - case .failure(let error): throw error - } + return getSubscriptionResult! + } + + public func signOut() { + signOutCalled = true + onSignOut?() } - public var getCustomerPortalURLResult: Result? - public func getCustomerPortalURL(accessToken: String, externalID: String) async throws -> Subscription.GetCustomerPortalURLResponse { - switch getCustomerPortalURLResult! { - case .success(let result): return result - case .failure(let error): throw error - } + public func getProducts() async -> Result<[GetProductsItem], APIServiceError> { + getProductsResult! } - public var confirmPurchaseResult: Result? - public func confirmPurchase(accessToken: String, signature: String, additionalParams: [String: String]?) async throws -> Subscription.ConfirmPurchaseResponseV2 { - switch confirmPurchaseResult! { - case .success(let result): return result - case .failure(let error): throw error - } + public func getSubscriptionFeatures(for subscriptionID: String) async -> Result { + getSubscriptionFeaturesResult! } - public var getSubscriptionFeaturesResult: Result? - public func getSubscriptionFeatures(for subscriptionID: String) async throws -> Subscription.GetSubscriptionFeaturesResponseV2 { - switch getSubscriptionFeaturesResult! { - case .success(let result): return result - case .failure(let error): throw error - } + public func getCustomerPortalURL(accessToken: String, externalID: String) async -> Result { + getCustomerPortalURLResult! } - public func ingestSubscription(_ subscription: Subscription.PrivacyProSubscription) async throws { - getSubscriptionResult = .success(subscription) + public func confirmPurchase(accessToken: String, signature: String, additionalParams: [String: String]?) async -> Result { + onConfirmPurchase?(accessToken, signature, additionalParams) + return confirmPurchaseResult! } } diff --git a/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMockV2.swift b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMockV2.swift new file mode 100644 index 000000000..7a4ecdb82 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/APIs/SubscriptionEndpointServiceMockV2.swift @@ -0,0 +1,86 @@ +// +// SubscriptionEndpointServiceMockV2.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 +import Networking + +public final class SubscriptionEndpointServiceMockV2: SubscriptionEndpointServiceV2 { + + public var onSignOut: (() -> Void)? + public var signOutCalled: Bool = false + + public init() { } + + public var updateCacheWithSubscriptionCalled: Bool = false + public var onUpdateCache: ((PrivacyProSubscription) -> Void)? + public func updateCache(with subscription: Subscription.PrivacyProSubscription) { + onUpdateCache?(subscription) + updateCacheWithSubscriptionCalled = true + } + + public func clearSubscription() {} + + public var getProductsResult: Result<[GetProductsItem], APIRequestV2.Error>? + public func getProducts() async throws -> [Subscription.GetProductsItem] { + switch getProductsResult! { + case .success(let result): return result + case .failure(let error): throw error + } + } + + public var getSubscriptionCalled: Bool = false + public var onGetSubscription: ((String, SubscriptionCachePolicy) -> Void)? + public var getSubscriptionResult: Result? + public func getSubscription(accessToken: String, cachePolicy: Subscription.SubscriptionCachePolicy) async throws -> Subscription.PrivacyProSubscription { + getSubscriptionCalled = true + onGetSubscription?(accessToken, cachePolicy) + switch getSubscriptionResult! { + case .success(let subscription): return subscription + case .failure(let error): throw error + } + } + + public var getCustomerPortalURLResult: Result? + public func getCustomerPortalURL(accessToken: String, externalID: String) async throws -> Subscription.GetCustomerPortalURLResponse { + switch getCustomerPortalURLResult! { + case .success(let result): return result + case .failure(let error): throw error + } + } + + public var confirmPurchaseResult: Result? + public func confirmPurchase(accessToken: String, signature: String, additionalParams: [String: String]?) async throws -> Subscription.ConfirmPurchaseResponseV2 { + switch confirmPurchaseResult! { + case .success(let result): return result + case .failure(let error): throw error + } + } + + public var getSubscriptionFeaturesResult: Result? + public func getSubscriptionFeatures(for subscriptionID: String) async throws -> Subscription.GetSubscriptionFeaturesResponseV2 { + switch getSubscriptionFeaturesResult! { + case .success(let result): return result + case .failure(let error): throw error + } + } + + public func ingestSubscription(_ subscription: Subscription.PrivacyProSubscription) async throws { + getSubscriptionResult = .success(subscription) + } +} diff --git a/Sources/SubscriptionTestingUtilities/AccountStorage/AccountManagerKeychainAccessDelegateMock.swift b/Sources/SubscriptionTestingUtilities/AccountStorage/AccountManagerKeychainAccessDelegateMock.swift new file mode 100644 index 000000000..0c15398b3 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/AccountStorage/AccountManagerKeychainAccessDelegateMock.swift @@ -0,0 +1,33 @@ +// +// AccountManagerKeychainAccessDelegateMock.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 final class AccountManagerKeychainAccessDelegateMock: AccountManagerKeychainAccessDelegate { + + public var onAccountManagerKeychainAccessFailed: ((AccountKeychainAccessType, AccountKeychainAccessError) -> Void)? + + public init(onAccountManagerKeychainAccessFailed: ( (AccountKeychainAccessType, AccountKeychainAccessError) -> Void)? = nil) { + self.onAccountManagerKeychainAccessFailed = onAccountManagerKeychainAccessFailed + } + + public func accountManagerKeychainAccessFailed(accessType: AccountKeychainAccessType, error: AccountKeychainAccessError) { + onAccountManagerKeychainAccessFailed?(accessType, error) + } +} diff --git a/Sources/SubscriptionTestingUtilities/AccountStorage/SubscriptionTokenKeychainStorageMock.swift b/Sources/SubscriptionTestingUtilities/AccountStorage/SubscriptionTokenKeychainStorageMock.swift new file mode 100644 index 000000000..7bb5b77a5 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/AccountStorage/SubscriptionTokenKeychainStorageMock.swift @@ -0,0 +1,44 @@ +// +// 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 final class SubscriptionTokenKeychainStorageMock: SubscriptionTokenStoring { + + public var accessToken: String? + + public var removeAccessTokenCalled: Bool = false + + 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 { + removeAccessTokenCalled = true + accessToken = nil + } +} diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift new file mode 100644 index 000000000..cff7d88e6 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/Flows/AppStoreAccountManagementFlowMock.swift @@ -0,0 +1,34 @@ +// +// 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 final class AppStoreAccountManagementFlowMock: AppStoreAccountManagementFlow { + public var refreshAuthTokenIfNeededResult: Result? + public var onRefreshAuthTokenIfNeeded: (() -> Void)? + public var refreshAuthTokenIfNeededCalled: Bool = false + + public init() { } + + public func refreshAuthTokenIfNeeded() async -> Result { + refreshAuthTokenIfNeededCalled = true + onRefreshAuthTokenIfNeeded?() + return refreshAuthTokenIfNeededResult! + } +} diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift index 8d8ca8aa1..1f1cf83a0 100644 --- a/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift +++ b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMock.swift @@ -19,17 +19,16 @@ import Foundation import Subscription -public final class AppStorePurchaseFlowMock: AppStorePurchaseFlowV2 { +public final class AppStorePurchaseFlowMock: AppStorePurchaseFlow { public var purchaseSubscriptionResult: Result? public var completeSubscriptionPurchaseResult: Result? public init() { } - public func purchaseSubscription(with subscriptionIdentifier: String) async -> Result { + public func purchaseSubscription(with subscriptionIdentifier: String, emailAccessToken: String?) async -> Result { purchaseSubscriptionResult! } - @discardableResult public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS, additionalParams: [String: String]?) async -> Result { completeSubscriptionPurchaseResult! } diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMockV2.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMockV2.swift new file mode 100644 index 000000000..ec6159b76 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/Flows/AppStorePurchaseFlowMockV2.swift @@ -0,0 +1,36 @@ +// +// AppStorePurchaseFlowMockV2.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 final class AppStorePurchaseFlowMockV2: AppStorePurchaseFlowV2 { + public var purchaseSubscriptionResult: Result? + public var completeSubscriptionPurchaseResult: Result? + + public init() { } + + public func purchaseSubscription(with subscriptionIdentifier: String) async -> Result { + purchaseSubscriptionResult! + } + + @discardableResult + public func completeSubscriptionPurchase(with transactionJWS: TransactionJWS, additionalParams: [String: String]?) async -> Result { + completeSubscriptionPurchaseResult! + } +} diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift index cdef2230e..6daea9c44 100644 --- a/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift +++ b/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMock.swift @@ -19,13 +19,13 @@ import Foundation import Subscription -public final class AppStoreRestoreFlowMock: AppStoreRestoreFlowV2 { - public var restoreAccountFromPastPurchaseResult: Result? +public final class AppStoreRestoreFlowMock: AppStoreRestoreFlow { + public var restoreAccountFromPastPurchaseResult: Result? public var restoreAccountFromPastPurchaseCalled: Bool = false public init() { } - @discardableResult public func restoreAccountFromPastPurchase() async -> Result { + public func restoreAccountFromPastPurchase() async -> Result { restoreAccountFromPastPurchaseCalled = true return restoreAccountFromPastPurchaseResult! } diff --git a/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMockV2.swift b/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMockV2.swift new file mode 100644 index 000000000..de8e9a4c1 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/Flows/AppStoreRestoreFlowMockV2.swift @@ -0,0 +1,32 @@ +// +// AppStoreRestoreFlowMockV2.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 final class AppStoreRestoreFlowMockV2: AppStoreRestoreFlowV2 { + public var restoreAccountFromPastPurchaseResult: Result? + public var restoreAccountFromPastPurchaseCalled: Bool = false + + public init() { } + + @discardableResult public func restoreAccountFromPastPurchase() async -> Result { + restoreAccountFromPastPurchaseCalled = true + return restoreAccountFromPastPurchaseResult! + } +} diff --git a/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift b/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift index 8a0932680..362e2758b 100644 --- a/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift +++ b/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMock.swift @@ -19,16 +19,16 @@ import Foundation import Subscription -public final class StripePurchaseFlowMock: StripePurchaseFlowV2 { - public var subscriptionOptionsResult: Result +public final class StripePurchaseFlowMock: StripePurchaseFlow { + public var subscriptionOptionsResult: Result public var prepareSubscriptionPurchaseResult: Result - public init(subscriptionOptionsResult: Result, prepareSubscriptionPurchaseResult: Result) { + public init(subscriptionOptionsResult: Result, prepareSubscriptionPurchaseResult: Result) { self.subscriptionOptionsResult = subscriptionOptionsResult self.prepareSubscriptionPurchaseResult = prepareSubscriptionPurchaseResult } - public func subscriptionOptions() async -> Result { + public func subscriptionOptions() async -> Result { subscriptionOptionsResult } diff --git a/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMockV2.swift b/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMockV2.swift new file mode 100644 index 000000000..92f839585 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/Flows/StripePurchaseFlowMockV2.swift @@ -0,0 +1,42 @@ +// +// StripePurchaseFlowMockV2.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 final class StripePurchaseFlowMockV2: StripePurchaseFlowV2 { + 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/Managers/AccountManagerMock.swift b/Sources/SubscriptionTestingUtilities/Managers/AccountManagerMock.swift new file mode 100644 index 000000000..2111700c8 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/Managers/AccountManagerMock.swift @@ -0,0 +1,110 @@ +// +// AccountManagerMock.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 final class AccountManagerMock: AccountManager { + public var delegate: AccountManagerKeychainAccessDelegate? + public var accessToken: String? + public var authToken: String? + public var email: String? + public var externalID: String? + + public var exchangeAuthTokenToAccessTokenResult: Result? + public var fetchAccountDetailsResult: Result? + + public var onStoreAuthToken: ((String) -> Void)? + public var onStoreAccount: ((String, String?, String?) -> Void)? + public var onFetchEntitlements: ((APICachePolicy) -> Void)? + public var onExchangeAuthTokenToAccessToken: ((String) -> Void)? + public var onFetchAccountDetails: ((String) -> Void)? + public var onCheckForEntitlements: ((Double, Int) -> Bool)? + + public var storeAuthTokenCalled: Bool = false + public var storeAccountCalled: Bool = false + public var signOutCalled: Bool = false + public var updateCacheWithEntitlementsCalled: Bool = false + public var fetchEntitlementsCalled: Bool = false + public var exchangeAuthTokenToAccessTokenCalled: Bool = false + public var fetchAccountDetailsCalled: Bool = false + public var checkForEntitlementsCalled: Bool = false + + public init() { } + + public func storeAuthToken(token: String) { + storeAuthTokenCalled = true + onStoreAuthToken?(token) + self.authToken = token + } + + public func storeAccount(token: String, email: String?, externalID: String?) { + storeAccountCalled = true + onStoreAccount?(token, email, externalID) + self.accessToken = token + self.email = email + self.externalID = externalID + } + + public func signOut(skipNotification: Bool) { + signOutCalled = true + self.authToken = nil + self.accessToken = nil + self.email = nil + self.externalID = nil + } + + public func signOut() { + signOutCalled = true + self.authToken = nil + self.accessToken = nil + self.email = nil + self.externalID = nil + } + + public func hasEntitlement(forProductName productName: Entitlement.ProductName, cachePolicy: APICachePolicy) async -> Result { + return .success(true) + } + + public func updateCache(with entitlements: [Entitlement]) { + updateCacheWithEntitlementsCalled = true + } + + public func fetchEntitlements(cachePolicy: APICachePolicy) async -> Result<[Entitlement], Error> { + fetchEntitlementsCalled = true + onFetchEntitlements?(cachePolicy) + return .success([]) + } + + public func exchangeAuthTokenToAccessToken(_ authToken: String) async -> Result { + exchangeAuthTokenToAccessTokenCalled = true + onExchangeAuthTokenToAccessToken?(authToken) + return exchangeAuthTokenToAccessTokenResult! + } + + public func fetchAccountDetails(with accessToken: String) async -> Result { + fetchAccountDetailsCalled = true + onFetchAccountDetails?(accessToken) + return fetchAccountDetailsResult! + } + + public func checkForEntitlements(wait waitTime: Double, retry retryCount: Int) async -> Bool { + checkForEntitlementsCalled = true + return onCheckForEntitlements!(waitTime, retryCount) + } +} diff --git a/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift index 329a30012..3d5c34f63 100644 --- a/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMock.swift @@ -19,15 +19,15 @@ import Foundation import Subscription -public final class StorePurchaseManagerMock: StorePurchaseManagerV2 { +public final class StorePurchaseManagerMock: StorePurchaseManager { public var purchasedProductIDs: [String] = [] public var purchaseQueue: [String] = [] public var areProductsAvailable: Bool = false public var currentStorefrontRegion: SubscriptionRegion = .usa - public var subscriptionOptionsResult: SubscriptionOptionsV2? - public var freeTrialSubscriptionOptionsResult: SubscriptionOptionsV2? + public var subscriptionOptionsResult: SubscriptionOptions? + public var freeTrialSubscriptionOptionsResult: SubscriptionOptions? public var syncAppleIDAccountResultError: Error? public var mostRecentTransactionResult: String? @@ -42,11 +42,11 @@ public final class StorePurchaseManagerMock: StorePurchaseManagerV2 { public init() { } - public func subscriptionOptions() async -> SubscriptionOptionsV2? { + public func subscriptionOptions() async -> SubscriptionOptions? { subscriptionOptionsResult } - public func freeTrialSubscriptionOptions() async -> SubscriptionOptionsV2? { + public func freeTrialSubscriptionOptions() async -> SubscriptionOptions? { freeTrialSubscriptionOptionsResult } diff --git a/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMockV2.swift b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMockV2.swift new file mode 100644 index 000000000..6a0ba4bae --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/Managers/StorePurchaseManagerMockV2.swift @@ -0,0 +1,79 @@ +// +// StorePurchaseManagerMockV2.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 final class StorePurchaseManagerMockV2: StorePurchaseManagerV2 { + + public var purchasedProductIDs: [String] = [] + public var purchaseQueue: [String] = [] + public var areProductsAvailable: Bool = false + public var currentStorefrontRegion: SubscriptionRegion = .usa + + public var subscriptionOptionsResult: SubscriptionOptionsV2? + public var freeTrialSubscriptionOptionsResult: SubscriptionOptionsV2? + public var syncAppleIDAccountResultError: Error? + + public var mostRecentTransactionResult: String? + public var hasActiveSubscriptionResult: Bool = false + public var purchaseSubscriptionResult: Result? + + public var onUpdateAvailableProducts: (() -> Void)? + + public var updateAvailableProductsCalled: Bool = false + public var mostRecentTransactionCalled: Bool = false + public var purchaseSubscriptionCalled: Bool = false + + public init() { } + + public func subscriptionOptions() async -> SubscriptionOptionsV2? { + subscriptionOptionsResult + } + + public func freeTrialSubscriptionOptions() async -> SubscriptionOptionsV2? { + freeTrialSubscriptionOptionsResult + } + + public func syncAppleIDAccount() async throws { + if let syncAppleIDAccountResultError { + throw syncAppleIDAccountResultError + } + } + + public func updateAvailableProducts() async { + updateAvailableProductsCalled = true + onUpdateAvailableProducts?() + } + + public func updatePurchasedProducts() async { } + + public func mostRecentTransaction() async -> String? { + mostRecentTransactionCalled = true + return mostRecentTransactionResult + } + + public func hasActiveSubscription() async -> Bool { + return hasActiveSubscriptionResult + } + + public func purchaseSubscription(with identifier: String, externalID: String) async -> Result { + purchaseSubscriptionCalled = true + return purchaseSubscriptionResult! + } +} diff --git a/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift b/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift index 6d5186bcf..3217eedbb 100644 --- a/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift +++ b/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMock.swift @@ -17,163 +17,63 @@ // import Foundation -@testable import Networking @testable import Subscription -public final class SubscriptionManagerMock: SubscriptionManagerV2 { - - public init() {} +public final class SubscriptionManagerMock: SubscriptionManager { + public var accountManager: AccountManager + public var subscriptionEndpointService: SubscriptionEndpointService + public var authEndpointService: AuthEndpointService + public var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache - public static var environment: Subscription.SubscriptionEnvironment? - public static func loadEnvironmentFrom(userDefaults: UserDefaults) -> Subscription.SubscriptionEnvironment? { - return environment + public static var storedEnvironment: SubscriptionEnvironment? + public static func loadEnvironmentFrom(userDefaults: UserDefaults) -> SubscriptionEnvironment? { + return storedEnvironment } - public static func save(subscriptionEnvironment: Subscription.SubscriptionEnvironment, userDefaults: UserDefaults) { - environment = subscriptionEnvironment + public static func save(subscriptionEnvironment: SubscriptionEnvironment, userDefaults: UserDefaults) { + storedEnvironment = subscriptionEnvironment } - public var currentEnvironment: Subscription.SubscriptionEnvironment = .init(serviceEnvironment: .staging, purchasePlatform: .appStore) + public var currentEnvironment: SubscriptionEnvironment + public var canPurchase: Bool - public func loadInitialData() {} - - public func refreshCachedSubscription(completion: @escaping (Bool) -> Void) {} - - public var resultSubscription: Subscription.PrivacyProSubscription? - - public func getSubscriptionFrom(lastTransactionJWSRepresentation: String) async throws -> Subscription.PrivacyProSubscription? { - guard let resultSubscription else { - throw OAuthClientError.missingTokens - } - return resultSubscription - } - - public var canPurchase: Bool = true - - public var resultStorePurchaseManager: (any Subscription.StorePurchaseManagerV2)? - public func storePurchaseManager() -> any Subscription.StorePurchaseManagerV2 { - return resultStorePurchaseManager! - } - - public var resultURL: URL! - public func url(for type: Subscription.SubscriptionURL) -> URL { - return resultURL - } - - public var customerPortalURL: URL? - public func getCustomerPortalURL() async throws -> URL { - guard let customerPortalURL else { - throw SubscriptionEndpointServiceError.noData - } - return customerPortalURL - } - - public var isUserAuthenticated: Bool { - resultTokenContainer != nil - } - - public var userEmail: String? { - resultTokenContainer?.decodedAccessToken.email - } - - public var resultTokenContainer: Networking.TokenContainer? - public var resultCreateAccountTokenContainer: Networking.TokenContainer? - public func getTokenContainer(policy: Networking.AuthTokensCachePolicy) async throws -> Networking.TokenContainer { - switch policy { - case .local, .localValid, .localForceRefresh: - guard let resultTokenContainer else { - throw OAuthClientError.missingTokens - } - return resultTokenContainer - case .createIfNeeded: - guard let resultCreateAccountTokenContainer else { - throw OAuthClientError.missingTokens - } - resultTokenContainer = resultCreateAccountTokenContainer - return resultCreateAccountTokenContainer - } + public func storePurchaseManager() -> StorePurchaseManager { + internalStorePurchaseManager } - public var resultExchangeTokenContainer: Networking.TokenContainer? - public func exchange(tokenV1: String) async throws -> Networking.TokenContainer { - guard let resultExchangeTokenContainer else { - throw OAuthClientError.missingTokens - } - resultTokenContainer = resultExchangeTokenContainer - return resultExchangeTokenContainer - } - - public func signOut(notifyUI: Bool) { - resultTokenContainer = nil - } - - public func removeTokenContainer() { - resultTokenContainer = nil - } - - public func clearSubscriptionCache() { - - } + public func loadInitialData() { - public var confirmPurchaseResponse: Result? - public func confirmPurchase(signature: String, additionalParams: [String: String]?) async throws -> Subscription.PrivacyProSubscription { - switch confirmPurchaseResponse! { - case .success(let result): - return result - case .failure(let error): - throw error - } } - public func refreshAccount() async {} - - public var confirmPurchaseError: Error? - public func confirmPurchase(signature: String) async throws { - if let confirmPurchaseError { - throw confirmPurchaseError - } - } - - public func getSubscription(cachePolicy: Subscription.SubscriptionCachePolicy) async throws -> Subscription.PrivacyProSubscription { - guard let resultSubscription else { - throw SubscriptionEndpointServiceError.noData - } - return resultSubscription - } - - public var productsResponse: Result<[Subscription.GetProductsItem], Error>? - public func getProducts() async throws -> [Subscription.GetProductsItem] { - switch productsResponse! { - case .success(let result): - return result - case .failure(let error): - throw error - } + public func refreshCachedSubscriptionAndEntitlements(completion: @escaping (Bool) -> Void) { + completion(true) } - public func adopt(tokenContainer: Networking.TokenContainer) { - self.resultTokenContainer = tokenContainer + public func url(for type: SubscriptionURL) -> URL { + type.subscriptionURL(environment: currentEnvironment.serviceEnvironment) } - public var resultFeatures: [Subscription.SubscriptionFeature] = [] - public func currentSubscriptionFeatures(forceRefresh: Bool) async -> [Subscription.SubscriptionFeature] { - resultFeatures + public func currentSubscriptionFeatures() async -> [Entitlement.ProductName] { + return [] } - public func isFeatureAvailableForUser(_ entitlement: Networking.SubscriptionEntitlement) async -> Bool { - resultFeatures.contains { $0.entitlement == entitlement } + public init(accountManager: AccountManager, + subscriptionEndpointService: SubscriptionEndpointService, + authEndpointService: AuthEndpointService, + storePurchaseManager: StorePurchaseManager, + currentEnvironment: SubscriptionEnvironment, + canPurchase: Bool, + subscriptionFeatureMappingCache: SubscriptionFeatureMappingCache) { + self.accountManager = accountManager + self.subscriptionEndpointService = subscriptionEndpointService + self.authEndpointService = authEndpointService + self.internalStorePurchaseManager = storePurchaseManager + self.currentEnvironment = currentEnvironment + self.canPurchase = canPurchase + self.subscriptionFeatureMappingCache = subscriptionFeatureMappingCache } - //MARK: - Subscription Token Provider - - public func getAccessToken() async throws -> String { - guard let accessToken = resultTokenContainer?.accessToken else { - throw SubscriptionManagerError.tokenUnavailable(error: nil) - } - return accessToken - } + // MARK: - - public func removeAccessToken() { - resultTokenContainer = nil - } + let internalStorePurchaseManager: StorePurchaseManager } diff --git a/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMockV2.swift b/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMockV2.swift new file mode 100644 index 000000000..cb388d554 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/Managers/SubscriptionManagerMockV2.swift @@ -0,0 +1,179 @@ +// +// SubscriptionManagerMockV2.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 Networking +@testable import Subscription + +public final class SubscriptionManagerMockV2: SubscriptionManagerV2 { + + public init() {} + + public static var environment: Subscription.SubscriptionEnvironment? + public static func loadEnvironmentFrom(userDefaults: UserDefaults) -> Subscription.SubscriptionEnvironment? { + return environment + } + + public static func save(subscriptionEnvironment: Subscription.SubscriptionEnvironment, userDefaults: UserDefaults) { + environment = subscriptionEnvironment + } + + public var currentEnvironment: Subscription.SubscriptionEnvironment = .init(serviceEnvironment: .staging, purchasePlatform: .appStore) + + public func loadInitialData() {} + + public func refreshCachedSubscription(completion: @escaping (Bool) -> Void) {} + + public var resultSubscription: Subscription.PrivacyProSubscription? + + public func getSubscriptionFrom(lastTransactionJWSRepresentation: String) async throws -> Subscription.PrivacyProSubscription? { + guard let resultSubscription else { + throw OAuthClientError.missingTokens + } + return resultSubscription + } + + public var canPurchase: Bool = true + + public var resultStorePurchaseManager: (any Subscription.StorePurchaseManagerV2)? + public func storePurchaseManager() -> any Subscription.StorePurchaseManagerV2 { + return resultStorePurchaseManager! + } + + public var resultURL: URL! + public func url(for type: Subscription.SubscriptionURL) -> URL { + return resultURL + } + + public var customerPortalURL: URL? + public func getCustomerPortalURL() async throws -> URL { + guard let customerPortalURL else { + throw SubscriptionEndpointServiceError.noData + } + return customerPortalURL + } + + public var isUserAuthenticated: Bool { + resultTokenContainer != nil + } + + public var userEmail: String? { + resultTokenContainer?.decodedAccessToken.email + } + + public var resultTokenContainer: Networking.TokenContainer? + public var resultCreateAccountTokenContainer: Networking.TokenContainer? + public func getTokenContainer(policy: Networking.AuthTokensCachePolicy) async throws -> Networking.TokenContainer { + switch policy { + case .local, .localValid, .localForceRefresh: + guard let resultTokenContainer else { + throw OAuthClientError.missingTokens + } + return resultTokenContainer + case .createIfNeeded: + guard let resultCreateAccountTokenContainer else { + throw OAuthClientError.missingTokens + } + resultTokenContainer = resultCreateAccountTokenContainer + return resultCreateAccountTokenContainer + } + } + + public var resultExchangeTokenContainer: Networking.TokenContainer? + public func exchange(tokenV1: String) async throws -> Networking.TokenContainer { + guard let resultExchangeTokenContainer else { + throw OAuthClientError.missingTokens + } + resultTokenContainer = resultExchangeTokenContainer + return resultExchangeTokenContainer + } + + public func signOut(notifyUI: Bool) { + resultTokenContainer = nil + } + + public func removeTokenContainer() { + resultTokenContainer = nil + } + + public func clearSubscriptionCache() { + + } + + public var confirmPurchaseResponse: Result? + public func confirmPurchase(signature: String, additionalParams: [String: String]?) async throws -> Subscription.PrivacyProSubscription { + switch confirmPurchaseResponse! { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + public func refreshAccount() async {} + + public var confirmPurchaseError: Error? + public func confirmPurchase(signature: String) async throws { + if let confirmPurchaseError { + throw confirmPurchaseError + } + } + + public func getSubscription(cachePolicy: Subscription.SubscriptionCachePolicy) async throws -> Subscription.PrivacyProSubscription { + guard let resultSubscription else { + throw SubscriptionEndpointServiceError.noData + } + return resultSubscription + } + + public var productsResponse: Result<[Subscription.GetProductsItem], Error>? + public func getProducts() async throws -> [Subscription.GetProductsItem] { + switch productsResponse! { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + public func adopt(tokenContainer: Networking.TokenContainer) { + self.resultTokenContainer = tokenContainer + } + + public var resultFeatures: [Subscription.SubscriptionFeatureV2] = [] + public func currentSubscriptionFeatures(forceRefresh: Bool) async -> [Subscription.SubscriptionFeatureV2] { + resultFeatures + } + + public func isFeatureAvailableForUser(_ entitlement: Networking.SubscriptionEntitlement) async -> Bool { + resultFeatures.contains { $0.entitlement == entitlement } + } + + // MARK: - Subscription Token Provider + + public func getAccessToken() async throws -> String { + guard let accessToken = resultTokenContainer?.accessToken else { + throw SubscriptionManagerError.tokenUnavailable(error: nil) + } + return accessToken + } + + public func removeAccessToken() { + resultTokenContainer = nil + } +} diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift index 1a4938144..ef39c4d04 100644 --- a/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift +++ b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMock.swift @@ -18,18 +18,17 @@ import Foundation import Subscription -import Networking -public final class SubscriptionFeatureMappingCacheMock: SubscriptionFeatureMappingCacheV2 { +public final class SubscriptionFeatureMappingCacheMock: SubscriptionFeatureMappingCache { public var didCallSubscriptionFeatures = false public var lastCalledSubscriptionId: String? - public var mapping: [String: [SubscriptionEntitlement]] = [:] + public var mapping: [String: [Entitlement.ProductName]] = [:] public init() { } - public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [SubscriptionEntitlement] { + public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [Entitlement.ProductName] { didCallSubscriptionFeatures = true lastCalledSubscriptionId = subscriptionIdentifier return mapping[subscriptionIdentifier] ?? [] diff --git a/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMockV2.swift b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMockV2.swift new file mode 100644 index 000000000..9e9c65f18 --- /dev/null +++ b/Sources/SubscriptionTestingUtilities/SubscriptionFeatureMappingCacheMockV2.swift @@ -0,0 +1,37 @@ +// +// SubscriptionFeatureMappingCacheMockV2.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 +import Networking + +public final class SubscriptionFeatureMappingCacheMockV2: SubscriptionFeatureMappingCacheV2 { + + public var didCallSubscriptionFeatures = false + public var lastCalledSubscriptionId: String? + + public var mapping: [String: [SubscriptionEntitlement]] = [:] + + public init() { } + + public func subscriptionFeatures(for subscriptionIdentifier: String) async -> [SubscriptionEntitlement] { + didCallSubscriptionFeatures = true + lastCalledSubscriptionId = subscriptionIdentifier + return mapping[subscriptionIdentifier] ?? [] + } +} diff --git a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift index aa19551a9..24f5950fc 100644 --- a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift +++ b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift @@ -26,15 +26,15 @@ import NetworkingTestingUtils final class DefaultAppStorePurchaseFlowTests: XCTestCase { private var sut: DefaultAppStorePurchaseFlowV2! - private var subscriptionManagerMock: SubscriptionManagerMock! - private var storePurchaseManagerMock: StorePurchaseManagerMock! - private var appStoreRestoreFlowMock: AppStoreRestoreFlowMock! + private var subscriptionManagerMock: SubscriptionManagerMockV2! + private var storePurchaseManagerMock: StorePurchaseManagerMockV2! + private var appStoreRestoreFlowMock: AppStoreRestoreFlowMockV2! override func setUp() { super.setUp() - subscriptionManagerMock = SubscriptionManagerMock() - storePurchaseManagerMock = StorePurchaseManagerMock() - appStoreRestoreFlowMock = AppStoreRestoreFlowMock() + subscriptionManagerMock = SubscriptionManagerMockV2() + storePurchaseManagerMock = StorePurchaseManagerMockV2() + appStoreRestoreFlowMock = AppStoreRestoreFlowMockV2() sut = DefaultAppStorePurchaseFlowV2( subscriptionManager: subscriptionManagerMock, storePurchaseManager: storePurchaseManagerMock, @@ -176,16 +176,16 @@ final class AppStorePurchaseFlowTests: XCTestCase { static let transactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" } - var mockSubscriptionManager: SubscriptionManagerMock! - var mockStorePurchaseManager: StorePurchaseManagerMock! - var mockAppStoreRestoreFlow: AppStoreRestoreFlowMock! + var mockSubscriptionManager: SubscriptionManagerMockV2! + var mockStorePurchaseManager: StorePurchaseManagerMockV2! + var mockAppStoreRestoreFlow: AppStoreRestoreFlowMockV2! var appStorePurchaseFlow: AppStorePurchaseFlowV2! override func setUpWithError() throws { - mockSubscriptionManager = SubscriptionManagerMock() - mockStorePurchaseManager = StorePurchaseManagerMock() - mockAppStoreRestoreFlow = AppStoreRestoreFlowMock() + mockSubscriptionManager = SubscriptionManagerMockV2() + mockStorePurchaseManager = StorePurchaseManagerMockV2() + mockAppStoreRestoreFlow = AppStoreRestoreFlowMockV2() appStorePurchaseFlow = DefaultAppStorePurchaseFlowV2(subscriptionManager: mockSubscriptionManager, storePurchaseManager: mockStorePurchaseManager, diff --git a/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift index de01c6d99..6fb0e9c88 100644 --- a/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift +++ b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift @@ -26,13 +26,13 @@ import NetworkingTestingUtils final class DefaultAppStoreRestoreFlowTests: XCTestCase { private var sut: DefaultAppStoreRestoreFlowV2! - private var subscriptionManagerMock: SubscriptionManagerMock! - private var storePurchaseManagerMock: StorePurchaseManagerMock! + private var subscriptionManagerMock: SubscriptionManagerMockV2! + private var storePurchaseManagerMock: StorePurchaseManagerMockV2! override func setUp() { super.setUp() - subscriptionManagerMock = SubscriptionManagerMock() - storePurchaseManagerMock = StorePurchaseManagerMock() + subscriptionManagerMock = SubscriptionManagerMockV2() + storePurchaseManagerMock = StorePurchaseManagerMockV2() sut = DefaultAppStoreRestoreFlowV2( subscriptionManager: subscriptionManagerMock, storePurchaseManager: storePurchaseManagerMock diff --git a/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift index 306e0d466..a4539ab2c 100644 --- a/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift +++ b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift @@ -32,17 +32,17 @@ } var accountManager: AccountManagerMock! - var subscriptionService: SubscriptionEndpointServiceMock! + var subscriptionService: SubscriptionEndpointServiceMockV2! var authEndpointService: AuthEndpointServiceMock! var stripePurchaseFlow: StripePurchaseFlowV2! override func setUpWithError() throws { accountManager = AccountManagerMock() - subscriptionService = SubscriptionEndpointServiceMock() + subscriptionService = SubscriptionEndpointServiceMockV2() authEndpointService = AuthEndpointServiceMock() - stripePurchaseFlow = DefaultStripePurchaseFlow(subscriptionEndpointService: subscriptionService, + stripePurchaseFlow = DefaultStripePurchaseFlowV2(subscriptionEndpointService: subscriptionService, authEndpointService: authEndpointService, accountManager: accountManager) } diff --git a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift index 7b4b8efc9..dfe4c22b6 100644 --- a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift +++ b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift @@ -24,12 +24,12 @@ import StoreKit final class StorePurchaseManagerTests: XCTestCase { private var sut: StorePurchaseManagerV2! - private var mockCache: SubscriptionFeatureMappingCacheMock! + private var mockCache: SubscriptionFeatureMappingCacheMockV2! private var mockProductFetcher: MockProductFetcher! private var mockFeatureFlagger: MockFeatureFlagger! override func setUpWithError() throws { - mockCache = SubscriptionFeatureMappingCacheMock() + mockCache = SubscriptionFeatureMappingCacheMockV2() mockProductFetcher = MockProductFetcher() mockFeatureFlagger = MockFeatureFlagger() sut = DefaultStorePurchaseManagerV2(subscriptionFeatureMappingCache: mockCache, diff --git a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift index e769d0aac..ed566ba63 100644 --- a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift +++ b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift @@ -26,15 +26,15 @@ class SubscriptionManagerTests: XCTestCase { var subscriptionManager: DefaultSubscriptionManagerV2! var mockOAuthClient: MockOAuthClient! - var mockSubscriptionEndpointService: SubscriptionEndpointServiceMock! - var mockStorePurchaseManager: StorePurchaseManagerMock! + var mockSubscriptionEndpointService: SubscriptionEndpointServiceMockV2! + var mockStorePurchaseManager: StorePurchaseManagerMockV2! override func setUp() { super.setUp() mockOAuthClient = MockOAuthClient() - mockSubscriptionEndpointService = SubscriptionEndpointServiceMock() - mockStorePurchaseManager = StorePurchaseManagerMock() + mockSubscriptionEndpointService = SubscriptionEndpointServiceMockV2() + mockStorePurchaseManager = StorePurchaseManagerMockV2() subscriptionManager = DefaultSubscriptionManagerV2( storePurchaseManager: mockStorePurchaseManager, diff --git a/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift b/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift index dcaacfcb9..89cdb4c51 100644 --- a/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift +++ b/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift @@ -31,8 +31,8 @@ final class PrivacyProSubscriptionIntegrationTests: XCTestCase { var subscriptionManager: DefaultSubscriptionManagerV2! var appStorePurchaseFlow: DefaultAppStorePurchaseFlowV2! var appStoreRestoreFlow: DefaultAppStoreRestoreFlowV2! - var stripePurchaseFlow: DefaultStripePurchaseFlow! - var storePurchaseManager: StorePurchaseManagerMock! + var stripePurchaseFlow: DefaultStripePurchaseFlowV2! + var storePurchaseManager: StorePurchaseManagerMockV2! var subscriptionFeatureFlagger: FeatureFlaggerMapping! let subscriptionSelectionID = "ios.subscription.1month" @@ -51,7 +51,7 @@ final class PrivacyProSubscriptionIntegrationTests: XCTestCase { let authClient = DefaultOAuthClient(tokensStorage: tokenStorage, legacyTokenStorage: legacyAccountStorage, authService: authService) - storePurchaseManager = StorePurchaseManagerMock() + storePurchaseManager = StorePurchaseManagerMockV2() let subscriptionEndpointService = DefaultSubscriptionEndpointServiceV2(apiService: apiService, baseURL: subscriptionEnvironment.serviceEnvironment.url) let pixelHandler: SubscriptionManagerV2.PixelHandler = { type in @@ -70,7 +70,7 @@ final class PrivacyProSubscriptionIntegrationTests: XCTestCase { appStorePurchaseFlow = DefaultAppStorePurchaseFlowV2(subscriptionManager: subscriptionManager, storePurchaseManager: storePurchaseManager, appStoreRestoreFlow: appStoreRestoreFlow) - stripePurchaseFlow = DefaultStripePurchaseFlow(subscriptionManager: subscriptionManager) + stripePurchaseFlow = DefaultStripePurchaseFlowV2(subscriptionManager: subscriptionManager) } override func tearDownWithError() throws { diff --git a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift index 71c014b07..735bdfb36 100644 --- a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift +++ b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift @@ -23,13 +23,13 @@ import SubscriptionTestingUtilities import NetworkingTestingUtils final class SubscriptionCookieManagerTests: XCTestCase { - var subscriptionManager: SubscriptionManagerMock! + var subscriptionManager: SubscriptionManagerMockV2! var cookieStore: HTTPCookieStore! var subscriptionCookieManager: SubscriptionCookieManagerV2! override func setUp() async throws { - subscriptionManager = SubscriptionManagerMock() + subscriptionManager = SubscriptionManagerMockV2() cookieStore = MockHTTPCookieStore() subscriptionCookieManager = SubscriptionCookieManagerV2(subscriptionManager: subscriptionManager, From 204a7de1bc67acd8c8b87a8f55b6f434f11b72f7 Mon Sep 17 00:00:00 2001 From: Graeme Arthur Date: Mon, 27 Jan 2025 13:39:25 +0100 Subject: [PATCH 19/28] Seamless account switching feature flag (#1188) Please review the release process for BrowserServicesKit [here](https://app.asana.com/0/1200194497630846/1200837094583426). **Required**: Task/Issue URL: https://app.asana.com/0/1142021229838617/1209000370243820/f iOS PR: https://github.com/duckduckgo/iOS/pull/3867 macOS PR: https://github.com/duckduckgo/macos-browser/pull/3779 What kind of version bump will this require?: Major **Description**: Just a feature flag. Please see platform PRs for testing. **OS Testing**: * [ ] iOS 14 * [ ] iOS 15 * [ ] iOS 16 * [ ] macOS 10.15 * [ ] macOS 11 * [ ] macOS 12 --- ###### Internal references: [Software Engineering Expectations](https://app.asana.com/0/59792373528535/199064865822552) [Technical Design Template](https://app.asana.com/0/59792373528535/184709971311943) --- .../PrivacyConfig/Features/PrivacyFeature.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift index ca81478bd..db897d0a1 100644 --- a/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift +++ b/Sources/BrowserServicesKit/PrivacyConfig/Features/PrivacyFeature.swift @@ -162,6 +162,7 @@ public enum SyncSubfeature: String, PrivacySubfeature { case level1AllowDataSyncing case level2AllowSetupFlows case level3AllowCreateAccount + case seamlessAccountSwitching } public enum AutoconsentSubfeature: String, PrivacySubfeature { From ac64aa380cf05dce6acabd33e78f3c487268121c Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 12:46:38 +0000 Subject: [PATCH 20/28] func documentation, renaming --- Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift | 2 ++ Sources/Subscription/API/AuthEndpointService.swift | 2 +- Sources/Subscription/API/SubscriptionAPIService.swift | 2 +- Sources/Subscription/API/SubscriptionEndpointService.swift | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift b/Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift index 004daf7ff..f8e90fb8e 100644 --- a/Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift +++ b/Sources/Networking/v2/Extensions/Dictionary+URLQueryItem.swift @@ -21,6 +21,8 @@ import Common extension Dictionary where Key == String, Value == String { + /// Convert a Dictionary key:String, value:String into an array of URLQueryItem ordering alphabetically the items by key + /// - Returns: An ordered array of URLQueryItem public func toURLQueryItems(allowedReservedCharacters: CharacterSet? = nil) -> [URLQueryItem] { return self.sorted(by: <).map { if let allowedReservedCharacters { diff --git a/Sources/Subscription/API/AuthEndpointService.swift b/Sources/Subscription/API/AuthEndpointService.swift index 6d45248fb..c2ddcdfd0 100644 --- a/Sources/Subscription/API/AuthEndpointService.swift +++ b/Sources/Subscription/API/AuthEndpointService.swift @@ -80,7 +80,7 @@ public struct DefaultAuthEndpointService: AuthEndpointService { 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 = DefaultAPIServiceV1(baseURL: baseURL, session: session) + self.apiService = DefaultSubscriptionAPIService(baseURL: baseURL, session: session) } public func getAccessToken(token: String) async -> Result { diff --git a/Sources/Subscription/API/SubscriptionAPIService.swift b/Sources/Subscription/API/SubscriptionAPIService.swift index d19ab79d9..1fa4b7b9c 100644 --- a/Sources/Subscription/API/SubscriptionAPIService.swift +++ b/Sources/Subscription/API/SubscriptionAPIService.swift @@ -43,7 +43,7 @@ public enum APICachePolicy { case returnCacheDataDontLoad } -public struct DefaultAPIServiceV1: SubscriptionAPIService { +public struct DefaultSubscriptionAPIService: SubscriptionAPIService { private let baseURL: URL private let session: URLSession diff --git a/Sources/Subscription/API/SubscriptionEndpointService.swift b/Sources/Subscription/API/SubscriptionEndpointService.swift index 2e1f8a282..9df6baae2 100644 --- a/Sources/Subscription/API/SubscriptionEndpointService.swift +++ b/Sources/Subscription/API/SubscriptionEndpointService.swift @@ -94,7 +94,7 @@ public struct DefaultSubscriptionEndpointService: SubscriptionEndpointService { 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 = DefaultAPIServiceV1(baseURL: baseURL, session: session) + self.apiService = DefaultSubscriptionAPIService(baseURL: baseURL, session: session) } // MARK: - Subscription fetching with caching From 91d64d01343f06d1d202c5879e645d71a2400aa1 Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 13:32:23 +0000 Subject: [PATCH 21/28] commented out code removed --- .../API/SubscriptionEndpointService.swift | 12 ----- .../API/SubscriptionEndpointServiceV2.swift | 1 - .../Flows/AppStore/AppStorePurchaseFlow.swift | 11 ----- .../Flows/Models/SubscriptionOptions.swift | 24 --------- .../Flows/Stripe/StripePurchaseFlow.swift | 5 -- .../StorePurchaseManager.swift | 49 ------------------- 6 files changed, 102 deletions(-) diff --git a/Sources/Subscription/API/SubscriptionEndpointService.swift b/Sources/Subscription/API/SubscriptionEndpointService.swift index 9df6baae2..5125c910d 100644 --- a/Sources/Subscription/API/SubscriptionEndpointService.swift +++ b/Sources/Subscription/API/SubscriptionEndpointService.swift @@ -18,23 +18,11 @@ import Common import Foundation -// -// public struct GetProductsItem: Decodable { -// public let productId: String -// public let productLabel: String -// public let billingPeriod: String -// public let price: String -// public let currency: String -// } public struct GetSubscriptionFeaturesResponse: Decodable { public let features: [Entitlement.ProductName] } -// public struct GetCustomerPortalURLResponse: Decodable { -// public let customerPortalUrl: String -// } - public struct ConfirmPurchaseResponse: Decodable { public let email: String? public let entitlements: [Entitlement] diff --git a/Sources/Subscription/API/SubscriptionEndpointServiceV2.swift b/Sources/Subscription/API/SubscriptionEndpointServiceV2.swift index 06558f15d..ea29c8de1 100644 --- a/Sources/Subscription/API/SubscriptionEndpointServiceV2.swift +++ b/Sources/Subscription/API/SubscriptionEndpointServiceV2.swift @@ -35,7 +35,6 @@ public struct GetCustomerPortalURLResponse: Codable, Equatable { public struct ConfirmPurchaseResponseV2: Codable, Equatable { public let email: String? -// public let entitlements: [Entitlement]? public let subscription: PrivacyProSubscription } diff --git a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift index 0788535ae..f733a6139 100644 --- a/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift +++ b/Sources/Subscription/Flows/AppStore/AppStorePurchaseFlow.swift @@ -20,17 +20,6 @@ import Foundation import StoreKit import os.log -// public enum AppStorePurchaseFlowErrorV1: Swift.Error { -// case noProductsFound -// case activeSubscriptionAlreadyPresent -// case authenticatingWithTransactionFailed -// case accountCreationFailed -// case purchaseFailed -// case cancelledByUser -// case missingEntitlements -// case internalError -// } - @available(macOS 12.0, iOS 15.0, *) public protocol AppStorePurchaseFlow { typealias TransactionJWS = String diff --git a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift index e5548a5a1..d77c8ac4a 100644 --- a/Sources/Subscription/Flows/Models/SubscriptionOptions.swift +++ b/Sources/Subscription/Flows/Models/SubscriptionOptions.swift @@ -41,12 +41,6 @@ public struct SubscriptionOptions: Encodable, Equatable { } } -// public enum SubscriptionPlatformName: String, Encodable { -// case ios -// case macos -// case stripe -// } - public struct SubscriptionOption: Encodable, Equatable { let id: String let cost: SubscriptionOptionCost @@ -59,24 +53,6 @@ public struct SubscriptionOption: Encodable, Equatable { } } -// struct SubscriptionOptionCost: Encodable, Equatable { -// let displayPrice: String -// let recurrence: String -// } - public struct SubscriptionFeature: Encodable, Equatable { let name: Entitlement.ProductName } - -///// A `SubscriptionOptionOffer` represents an offer (e.g Free Trials) associated with a Subscription -// public struct SubscriptionOptionOffer: Encodable, Equatable { -// -// public enum OfferType: String, Codable, CaseIterable { -// case freeTrial -// } -// -// let type: OfferType -// let id: String -// let durationInDays: Int? -// let isUserEligible: Bool -// } diff --git a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift index 46f37f074..151bb53b0 100644 --- a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift +++ b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift @@ -20,11 +20,6 @@ import Foundation import StoreKit import os.log -// public enum StripePurchaseFlowError: Swift.Error { -// case noProductsFound -// case accountCreationFailed -// } - public protocol StripePurchaseFlow { func subscriptionOptions() async -> Result func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result diff --git a/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift index a7fa4adf1..e313613f8 100644 --- a/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift +++ b/Sources/Subscription/Managers/StorePurchaseManager/StorePurchaseManager.swift @@ -20,20 +20,6 @@ import Foundation import StoreKit import os.log -// 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 @@ -61,8 +47,6 @@ public protocol StorePurchaseManager { @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, *) public final class DefaultStorePurchaseManager: ObservableObject, StorePurchaseManager { @@ -366,36 +350,3 @@ private extension SubscriptionOption { self.init(id: product.id, cost: .init(displayPrice: product.displayPrice, recurrence: recurrence), offer: offer) } } - -// public extension UserDefaults { -// -// enum Constants { -// static let storefrontRegionOverrideKey = "Subscription.debug.storefrontRegionOverride" -// static let usaValue = "usa" -// static let rowValue = "row" -// } -// -// dynamic var storefrontRegionOverride: SubscriptionRegion? { -// get { -// switch string(forKey: Constants.storefrontRegionOverrideKey) { -// case "usa": -// return .usa -// case "row": -// return .restOfWorld -// default: -// return nil -// } -// } -// -// set { -// switch newValue { -// case .usa: -// set(Constants.usaValue, forKey: Constants.storefrontRegionOverrideKey) -// case .restOfWorld: -// set(Constants.rowValue, forKey: Constants.storefrontRegionOverrideKey) -// default: -// removeObject(forKey: Constants.storefrontRegionOverrideKey) -// } -// } -// } -// } From 2dfe234be0a8493329bdf0eb5704ebaba8c9aea9 Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 14:36:06 +0000 Subject: [PATCH 22/28] lint --- Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift | 6 +++--- Sources/Subscription/SubscriptionFeatureV2.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift index 151bb53b0..106068621 100644 --- a/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift +++ b/Sources/Subscription/Flows/Stripe/StripePurchaseFlow.swift @@ -57,7 +57,7 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow { var displayPrice = "\($0.price) \($0.currency)" if let price = Float($0.price), let formattedPrice = formatter.string(from: price as NSNumber) { - displayPrice = formattedPrice + displayPrice = formattedPrice } let cost = SubscriptionOptionCost(displayPrice: displayPrice, recurrence: $0.billingPeriod.lowercased()) @@ -71,8 +71,8 @@ public final class DefaultStripePurchaseFlow: StripePurchaseFlow { SubscriptionFeature(name: .identityTheftRestoration)] return .success(SubscriptionOptions(platform: SubscriptionPlatformName.stripe, - options: options, - features: features)) + options: options, + features: features)) } public func prepareSubscriptionPurchase(emailAccessToken: String?) async -> Result { diff --git a/Sources/Subscription/SubscriptionFeatureV2.swift b/Sources/Subscription/SubscriptionFeatureV2.swift index 706a41649..1df9274e7 100644 --- a/Sources/Subscription/SubscriptionFeatureV2.swift +++ b/Sources/Subscription/SubscriptionFeatureV2.swift @@ -1,5 +1,5 @@ // -// SubscriptionFeature.swift +// SubscriptionFeatureV2.swift // // Copyright © 2025 DuckDuckGo. All rights reserved. // From b5f5759de1ed299b0fd0d1816eb9c9c94fb58103 Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 14:48:11 +0000 Subject: [PATCH 23/28] unit tests renamed v2 --- .../SubscriptionCookie/SubscriptionCookieManager.swift | 4 ---- ...ests.swift => SubscriptionEndpointServiceV2Tests.swift} | 7 +++---- ...seFlowTests.swift => AppStorePurchaseFlowV2Tests.swift} | 4 ++-- ...oreFlowTests.swift => AppStoreRestoreFlowV2Tests.swift} | 4 ++-- ...OptionsTests.swift => SubscriptionOptionsV2Tests.swift} | 4 ++-- ...haseFlowTests.swift => StripePurchaseFlowV2Tests.swift} | 4 ++-- ...anagerTests.swift => StorePurchaseManagerV2Tests.swift} | 6 +++--- ...ManagerTests.swift => SubscriptionManagerV2Tests.swift} | 4 ++-- ...wift => PrivacyProSubscriptionV2IntegrationTests.swift} | 4 ++-- ...rTests.swift => SubscriptionCookieManagerV2Tests.swift} | 4 ++-- 10 files changed, 20 insertions(+), 25 deletions(-) rename Tests/SubscriptionTests/API/{SubscriptionEndpointServiceTests.swift => SubscriptionEndpointServiceV2Tests.swift} (99%) rename Tests/SubscriptionTests/Flows/{AppStorePurchaseFlowTests.swift => AppStorePurchaseFlowV2Tests.swift} (99%) rename Tests/SubscriptionTests/Flows/{AppStoreRestoreFlowTests.swift => AppStoreRestoreFlowV2Tests.swift} (97%) rename Tests/SubscriptionTests/Flows/Models/{SubscriptionOptionsTests.swift => SubscriptionOptionsV2Tests.swift} (98%) rename Tests/SubscriptionTests/Flows/{StripePurchaseFlowTests.swift => StripePurchaseFlowV2Tests.swift} (99%) rename Tests/SubscriptionTests/Managers/{StorePurchaseManagerTests.swift => StorePurchaseManagerV2Tests.swift} (99%) rename Tests/SubscriptionTests/Managers/{SubscriptionManagerTests.swift => SubscriptionManagerV2Tests.swift} (99%) rename Tests/SubscriptionTests/{PrivacyProSubscriptionIntegrationTests.swift => PrivacyProSubscriptionV2IntegrationTests.swift} (99%) rename Tests/SubscriptionTests/SubscriptionCookie/{SubscriptionCookieManagerTests.swift => SubscriptionCookieManagerV2Tests.swift} (98%) diff --git a/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift index 5b8fe37ac..baada4608 100644 --- a/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift +++ b/Sources/Subscription/SubscriptionCookie/SubscriptionCookieManager.swift @@ -165,10 +165,6 @@ public final class SubscriptionCookieManager: SubscriptionCookieManaging { } } -// enum SubscriptionCookieManagerError: Error { -// case failedToCreateSubscriptionCookie -// } - private extension HTTPCookieStore { func fetchCurrentSubscriptionCookie() async -> HTTPCookie? { diff --git a/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceV2Tests.swift similarity index 99% rename from Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift rename to Tests/SubscriptionTests/API/SubscriptionEndpointServiceV2Tests.swift index d9c524e0a..a7ce572b0 100644 --- a/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift +++ b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceV2Tests.swift @@ -1,5 +1,5 @@ // -// SubscriptionEndpointServiceTests.swift +// SubscriptionEndpointServiceV2Tests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -23,7 +23,7 @@ import SubscriptionTestingUtilities import NetworkingTestingUtils import Common -final class SubscriptionEndpointServiceTests: XCTestCase { +final class SubscriptionEndpointServiceV2Tests: XCTestCase { private var apiService: MockAPIService! private var endpointService: DefaultSubscriptionEndpointServiceV2! private let baseURL = SubscriptionEnvironment.ServiceEnvironment.staging.url @@ -189,7 +189,6 @@ final class SubscriptionEndpointServiceTests: XCTestCase { let date = Date(timeIntervalSince1970: 123456789) let confirmResponse = ConfirmPurchaseResponseV2( email: "user@example.com", -// entitlements: nil, subscription: PrivacyProSubscription( productId: "prod123", name: "Pro Plan", @@ -255,7 +254,7 @@ final class SubscriptionEndpointServiceTests: XCTestCase { } /* -final class SubscriptionEndpointServiceTests: XCTestCase { +final class SubscriptionEndpointServiceV2Tests: XCTestCase { private struct Constants { // static let tokenContainer = OAuthTokensFactory.makeValidTokenContainer() diff --git a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowV2Tests.swift similarity index 99% rename from Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift rename to Tests/SubscriptionTests/Flows/AppStorePurchaseFlowV2Tests.swift index 24f5950fc..fa8f6b024 100644 --- a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift +++ b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowV2Tests.swift @@ -1,5 +1,5 @@ // -// AppStorePurchaseFlowTests.swift +// DefaultAppStorePurchaseFlowV2Tests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -23,7 +23,7 @@ import SubscriptionTestingUtilities import NetworkingTestingUtils @available(macOS 12.0, iOS 15.0, *) -final class DefaultAppStorePurchaseFlowTests: XCTestCase { +final class DefaultAppStorePurchaseFlowV2Tests: XCTestCase { private var sut: DefaultAppStorePurchaseFlowV2! private var subscriptionManagerMock: SubscriptionManagerMockV2! diff --git a/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowV2Tests.swift similarity index 97% rename from Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift rename to Tests/SubscriptionTests/Flows/AppStoreRestoreFlowV2Tests.swift index 6fb0e9c88..33da73e1f 100644 --- a/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift +++ b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowV2Tests.swift @@ -1,5 +1,5 @@ // -// AppStoreRestoreFlowTests.swift +// AppStoreRestoreFlowV2Tests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -23,7 +23,7 @@ import SubscriptionTestingUtilities import NetworkingTestingUtils @available(macOS 12.0, iOS 15.0, *) -final class DefaultAppStoreRestoreFlowTests: XCTestCase { +final class DefaultAppStoreRestoreV2FlowTests: XCTestCase { private var sut: DefaultAppStoreRestoreFlowV2! private var subscriptionManagerMock: SubscriptionManagerMockV2! diff --git a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsV2Tests.swift similarity index 98% rename from Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift rename to Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsV2Tests.swift index 9b2e23011..40a98763a 100644 --- a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift +++ b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsV2Tests.swift @@ -1,5 +1,5 @@ // -// SubscriptionOptionsTests.swift +// SubscriptionOptionsV2Tests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -21,7 +21,7 @@ import XCTest import SubscriptionTestingUtilities import Networking -final class SubscriptionOptionsTests: XCTestCase { +final class SubscriptionOptionsV2Tests: XCTestCase { func testEncoding() throws { let monthlySubscriptionOffer = SubscriptionOptionOffer(type: .freeTrial, id: "1", durationInDays: 7, isUserEligible: true) diff --git a/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/StripePurchaseFlowV2Tests.swift similarity index 99% rename from Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift rename to Tests/SubscriptionTests/Flows/StripePurchaseFlowV2Tests.swift index a4539ab2c..3d1bb7ded 100644 --- a/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift +++ b/Tests/SubscriptionTests/Flows/StripePurchaseFlowV2Tests.swift @@ -1,5 +1,5 @@ // -// StripePurchaseFlowTests.swift +// StripePurchaseFlowV2Tests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -20,7 +20,7 @@ @testable import Subscription import SubscriptionTestingUtilities - final class StripePurchaseFlowTests: XCTestCase { + final class StripePurchaseFlowV2Tests: XCTestCase { private struct Constants { static let authToken = UUID().uuidString diff --git a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift b/Tests/SubscriptionTests/Managers/StorePurchaseManagerV2Tests.swift similarity index 99% rename from Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift rename to Tests/SubscriptionTests/Managers/StorePurchaseManagerV2Tests.swift index dfe4c22b6..9016b0ff3 100644 --- a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift +++ b/Tests/SubscriptionTests/Managers/StorePurchaseManagerV2Tests.swift @@ -1,5 +1,5 @@ // -// StorePurchaseManagerTests.swift +// StorePurchaseManagerV2Tests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -21,7 +21,7 @@ import XCTest import SubscriptionTestingUtilities import StoreKit -final class StorePurchaseManagerTests: XCTestCase { +final class StorePurchaseManagerV2Tests: XCTestCase { private var sut: StorePurchaseManagerV2! private var mockCache: SubscriptionFeatureMappingCacheMockV2! @@ -406,7 +406,7 @@ private enum MockProductError: Error { case fetchFailed } -private extension StorePurchaseManagerTests { +private extension StorePurchaseManagerV2Tests { func createMonthlyProduct(withTrial: Bool = false) -> MockSubscriptionProduct { MockSubscriptionProduct( id: "com.test.monthly\(withTrial ? ".trial" : "")", diff --git a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift b/Tests/SubscriptionTests/Managers/SubscriptionManagerV2Tests.swift similarity index 99% rename from Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift rename to Tests/SubscriptionTests/Managers/SubscriptionManagerV2Tests.swift index ed566ba63..be3a1155c 100644 --- a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift +++ b/Tests/SubscriptionTests/Managers/SubscriptionManagerV2Tests.swift @@ -1,5 +1,5 @@ // -// SubscriptionManagerTests.swift +// SubscriptionManagerV2Tests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -22,7 +22,7 @@ import XCTest import SubscriptionTestingUtilities import NetworkingTestingUtils -class SubscriptionManagerTests: XCTestCase { +class SubscriptionManagerV2Tests: XCTestCase { var subscriptionManager: DefaultSubscriptionManagerV2! var mockOAuthClient: MockOAuthClient! diff --git a/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift b/Tests/SubscriptionTests/PrivacyProSubscriptionV2IntegrationTests.swift similarity index 99% rename from Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift rename to Tests/SubscriptionTests/PrivacyProSubscriptionV2IntegrationTests.swift index 89cdb4c51..cc16a2f2b 100644 --- a/Tests/SubscriptionTests/PrivacyProSubscriptionIntegrationTests.swift +++ b/Tests/SubscriptionTests/PrivacyProSubscriptionV2IntegrationTests.swift @@ -1,5 +1,5 @@ // -// PrivacyProSubscriptionIntegrationTests.swift +// PrivacyProSubscriptionV2IntegrationTests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -23,7 +23,7 @@ import NetworkingTestingUtils import SubscriptionTestingUtilities import JWTKit -final class PrivacyProSubscriptionIntegrationTests: XCTestCase { +final class PrivacyProSubscriptionV2IntegrationTests: XCTestCase { var apiService: MockAPIService! var tokenStorage: MockTokenStorage! diff --git a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerV2Tests.swift similarity index 98% rename from Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift rename to Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerV2Tests.swift index 735bdfb36..3fb709384 100644 --- a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift +++ b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerV2Tests.swift @@ -1,5 +1,5 @@ // -// SubscriptionCookieManagerTests.swift +// SubscriptionCookieManagerV2Tests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -22,7 +22,7 @@ import Common import SubscriptionTestingUtilities import NetworkingTestingUtils -final class SubscriptionCookieManagerTests: XCTestCase { +final class SubscriptionCookieManagerV2Tests: XCTestCase { var subscriptionManager: SubscriptionManagerMockV2! var cookieStore: HTTPCookieStore! From e17b52c288e71aff96d7a140c9a0a024a67f1767 Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 14:50:31 +0000 Subject: [PATCH 24/28] v1 tests restored --- .../API/AuthEndpointServiceTests.swift | 319 +++++++++++ .../API/Models/EntitlementTests.swift | 47 ++ .../SubscriptionEndpointServiceTests.swift | 435 ++++++++++++++ .../AppStoreAccountManagementFlowTests.swift | 184 ++++++ .../Flows/AppStorePurchaseFlowTests.swift | 369 ++++++++++++ .../Flows/AppStoreRestoreFlowTests.swift | 332 +++++++++++ .../Models/SubscriptionOptionsTests.swift | 126 ++++ .../Flows/StripePurchaseFlowTests.swift | 255 +++++++++ .../Managers/AccountManagerTests.swift | 508 ++++++++++++++++ .../Managers/StorePurchaseManagerTests.swift | 540 ++++++++++++++++++ .../Managers/SubscriptionManagerTests.swift | 235 ++++++++ .../SubscriptionCookieManagerTests.swift | 249 ++++++++ 12 files changed, 3599 insertions(+) create mode 100644 Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift create mode 100644 Tests/SubscriptionTests/API/Models/EntitlementTests.swift create mode 100644 Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift create mode 100644 Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift create mode 100644 Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift create mode 100644 Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift create mode 100644 Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift create mode 100644 Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift create mode 100644 Tests/SubscriptionTests/Managers/AccountManagerTests.swift create mode 100644 Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift create mode 100644 Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift create mode 100644 Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift diff --git a/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift b/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift new file mode 100644 index 000000000..3fdae38a4 --- /dev/null +++ b/Tests/SubscriptionTests/API/AuthEndpointServiceTests.swift @@ -0,0 +1,319 @@ +// +// 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 { + + private struct Constants { + static let authToken = UUID().uuidString + static let accessToken = UUID().uuidString + static let externalID = UUID().uuidString + static let email = "dax@duck.com" + + static let mostRecentTransactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" + + static let authorizationHeader = ["Authorization": "Bearer TOKEN"] + + static let invalidTokenError = APIServiceError.serverError(statusCode: 401, error: "invalid_token") + } + + var apiService: APIServiceMock! + var authService: AuthEndpointService! + + override func setUpWithError() throws { + apiService = APIServiceMock() + authService = DefaultAuthEndpointService(currentServiceEnvironment: .staging, apiService: apiService) + } + + override func tearDownWithError() throws { + apiService = nil + authService = nil + } + + // MARK: - Tests for getAccessToken + + func testGetAccessTokenCall() async throws { + // Given + let apiServiceCalledExpectation = expectation(description: "apiService") + + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.onExecuteAPICall = { parameters in + let (method, endpoint, headers, _) = parameters + + apiServiceCalledExpectation.fulfill() + XCTAssertEqual(method, "GET") + XCTAssertEqual(endpoint, "access-token") + XCTAssertEqual(headers, Constants.authorizationHeader) + } + + // When + _ = await authService.getAccessToken(token: Constants.authToken) + + // Then + await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) + } + + func testGetAccessTokenSuccess() async throws { + // Given + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.mockResponseJSONData = """ + { + "accessToken": "\(Constants.accessToken)", + } + """.data(using: .utf8)! + + // When + let result = await authService.getAccessToken(token: Constants.authToken) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.accessToken, Constants.accessToken) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testGetAccessTokenError() async throws { + // Given + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.mockAPICallError = Constants.invalidTokenError + + // When + let result = await authService.getAccessToken(token: Constants.authToken) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure: + break + } + } + + // MARK: - Tests for validateToken + + func testValidateTokenCall() async throws { + // Given + let apiServiceCalledExpectation = expectation(description: "apiService") + + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.onExecuteAPICall = { parameters in + let (method, endpoint, headers, _) = parameters + + apiServiceCalledExpectation.fulfill() + XCTAssertEqual(method, "GET") + XCTAssertEqual(endpoint, "validate-token") + XCTAssertEqual(headers, Constants.authorizationHeader) + } + + // When + _ = await authService.validateToken(accessToken: Constants.accessToken) + + // Then + await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) + } + + func testValidateTokenSuccess() async throws { + // Given + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.mockResponseJSONData = """ + { + "account": { + "id": 149718, + "external_id": "\(Constants.externalID)", + "email": "\(Constants.email)", + "entitlements": [ + {"id":24, "name":"subscriber", "product":"Network Protection"}, + {"id":25, "name":"subscriber", "product":"Data Broker Protection"}, + {"id":26, "name":"subscriber", "product":"Identity Theft Restoration"} + ] + } + } + """.data(using: .utf8)! + + // When + let result = await authService.validateToken(accessToken: Constants.accessToken) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.account.externalID, Constants.externalID) + XCTAssertEqual(success.account.email, Constants.email) + XCTAssertEqual(success.account.entitlements.count, 3) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testValidateTokenError() async throws { + // Given + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.mockAPICallError = Constants.invalidTokenError + + // When + let result = await authService.validateToken(accessToken: Constants.accessToken) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure: + break + } + } + + // MARK: - Tests for createAccount + + func testCreateAccountCall() async throws { + // Given + let apiServiceCalledExpectation = expectation(description: "apiService") + + apiService.onExecuteAPICall = { parameters in + let (method, endpoint, headers, _) = parameters + + apiServiceCalledExpectation.fulfill() + XCTAssertEqual(method, "POST") + XCTAssertEqual(endpoint, "account/create") + XCTAssertNil(headers) + } + + // When + _ = await authService.createAccount(emailAccessToken: nil) + + // Then + await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) + } + + func testCreateAccountSuccess() async throws { + // Given + apiService.mockResponseJSONData = """ + { + "auth_token": "\(Constants.authToken)", + "external_id": "\(Constants.externalID)", + "status": "created" + } + """.data(using: .utf8)! + + // When + let result = await authService.createAccount(emailAccessToken: nil) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.authToken, Constants.authToken) + XCTAssertEqual(success.externalID, Constants.externalID) + XCTAssertEqual(success.status, "created") + case .failure: + XCTFail("Unexpected failure") + } + } + + func testCreateAccountError() async throws { + // Given + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.mockAPICallError = Constants.invalidTokenError + + // When + let result = await authService.createAccount(emailAccessToken: nil) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure: + break + } + } + + // MARK: - Tests for storeLogin + + func testStoreLoginCall() async throws { + // Given + let apiServiceCalledExpectation = expectation(description: "apiService") + + apiService.onExecuteAPICall = { parameters in + let (method, endpoint, headers, body) = parameters + + apiServiceCalledExpectation.fulfill() + XCTAssertEqual(method, "POST") + XCTAssertEqual(endpoint, "store-login") + XCTAssertNil(headers) + + if let bodyDict = try? JSONDecoder().decode([String: String].self, from: body!) { + XCTAssertEqual(bodyDict["signature"], Constants.mostRecentTransactionJWS) + XCTAssertEqual(bodyDict["store"], "apple_app_store") + } else { + XCTFail("Failed to decode body") + } + } + + // When + _ = await authService.storeLogin(signature: Constants.mostRecentTransactionJWS) + + // Then + await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) + } + + func testStoreLoginSuccess() async throws { + // Given + apiService.mockResponseJSONData = """ + { + "auth_token": "\(Constants.authToken)", + "email": "\(Constants.email)", + "external_id": "\(Constants.externalID)", + "id": 1, + "status": "ok" + } + """.data(using: .utf8)! + + // When + let result = await authService.storeLogin(signature: Constants.mostRecentTransactionJWS) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.authToken, Constants.authToken) + XCTAssertEqual(success.email, Constants.email) + XCTAssertEqual(success.externalID, Constants.externalID) + XCTAssertEqual(success.id, 1) + XCTAssertEqual(success.status, "ok") + case .failure: + XCTFail("Unexpected failure") + } + } + + func testStoreLoginError() async throws { + // Given + apiService.mockAPICallError = Constants.invalidTokenError + + // When + let result = await authService.storeLogin(signature: Constants.mostRecentTransactionJWS) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure: + break + } + } +} diff --git a/Tests/SubscriptionTests/API/Models/EntitlementTests.swift b/Tests/SubscriptionTests/API/Models/EntitlementTests.swift new file mode 100644 index 000000000..25409abce --- /dev/null +++ b/Tests/SubscriptionTests/API/Models/EntitlementTests.swift @@ -0,0 +1,47 @@ +// +// 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 { + + func testEquality() throws { + XCTAssertEqual(Entitlement(product: .dataBrokerProtection), Entitlement(product: .dataBrokerProtection)) + XCTAssertNotEqual(Entitlement(product: .dataBrokerProtection), Entitlement(product: .networkProtection)) + } + + func testDecoding() throws { + let rawNetPEntitlement = "{\"id\":24,\"name\":\"subscriber\",\"product\":\"Network Protection\"}" + let netPEntitlement = try JSONDecoder().decode(Entitlement.self, from: Data(rawNetPEntitlement.utf8)) + XCTAssertEqual(netPEntitlement, Entitlement(product: .networkProtection)) + + let rawDBPEntitlement = "{\"id\":25,\"name\":\"subscriber\",\"product\":\"Data Broker Protection\"}" + let dbpEntitlement = try JSONDecoder().decode(Entitlement.self, from: Data(rawDBPEntitlement.utf8)) + XCTAssertEqual(dbpEntitlement, Entitlement(product: .dataBrokerProtection)) + + let rawITREntitlement = "{\"id\":26,\"name\":\"subscriber\",\"product\":\"Identity Theft Restoration\"}" + let itrEntitlement = try JSONDecoder().decode(Entitlement.self, from: Data(rawITREntitlement.utf8)) + XCTAssertEqual(itrEntitlement, Entitlement(product: .identityTheftRestoration)) + + let rawUnexpectedEntitlement = "{\"id\":27,\"name\":\"subscriber\",\"product\":\"something unexpected\"}" + let unexpectedEntitlement = try JSONDecoder().decode(Entitlement.self, from: Data(rawUnexpectedEntitlement.utf8)) + XCTAssertEqual(unexpectedEntitlement, Entitlement(product: .unknown)) + } +} diff --git a/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift new file mode 100644 index 000000000..1511af841 --- /dev/null +++ b/Tests/SubscriptionTests/API/SubscriptionEndpointServiceTests.swift @@ -0,0 +1,435 @@ +// +// 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 { + + private struct Constants { + static let authToken = UUID().uuidString + static let accessToken = UUID().uuidString + static let externalID = UUID().uuidString + static let email = "dax@duck.com" + + static let mostRecentTransactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" + + static let subscription = SubscriptionMockFactory.subscription + + static let customerPortalURL = "https://billing.stripe.com/p/session/test_ABC" + + static let authorizationHeader = ["Authorization": "Bearer TOKEN"] + + static let unknownServerError = APIServiceError.serverError(statusCode: 401, error: "unknown_error") + } + + var apiService: APIServiceMock! + var subscriptionService: SubscriptionEndpointService! + + override func setUpWithError() throws { + apiService = APIServiceMock() + subscriptionService = DefaultSubscriptionEndpointService(currentServiceEnvironment: .staging, apiService: apiService) + } + + override func tearDownWithError() throws { + apiService = nil + subscriptionService = nil + } + + // MARK: - Tests for + + func testGetSubscriptionCall() async throws { + // Given + let apiServiceCalledExpectation = expectation(description: "apiService") + + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.onExecuteAPICall = { parameters in + let (method, endpoint, headers, _) = parameters + + apiServiceCalledExpectation.fulfill() + XCTAssertEqual(method, "GET") + XCTAssertEqual(endpoint, "subscription") + XCTAssertEqual(headers, Constants.authorizationHeader) + } + + // When + _ = await subscriptionService.getSubscription(accessToken: Constants.accessToken, cachePolicy: .reloadIgnoringLocalCacheData) + + // Then + await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) + } + + func testGetSubscriptionSuccess() async throws { + // Given + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.mockResponseJSONData = """ + { + "productId": "\(Constants.subscription.productId)", + "name": "\(Constants.subscription.name)", + "billingPeriod": "\(Constants.subscription.billingPeriod.rawValue)", + "startedAt": \(Constants.subscription.startedAt.timeIntervalSince1970*1000), + "expiresOrRenewsAt": \(Constants.subscription.expiresOrRenewsAt.timeIntervalSince1970*1000), + "platform": "\(Constants.subscription.platform.rawValue)", + "status": "\(Constants.subscription.status.rawValue)" + } + """.data(using: .utf8)! + + // When + let result = await subscriptionService.getSubscription(accessToken: Constants.accessToken, cachePolicy: .reloadIgnoringLocalCacheData) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.productId, Constants.subscription.productId) + XCTAssertEqual(success.name, Constants.subscription.name) + XCTAssertEqual(success.billingPeriod, Constants.subscription.billingPeriod) + XCTAssertEqual(success.startedAt.timeIntervalSince1970, + Constants.subscription.startedAt.timeIntervalSince1970, + accuracy: 0.001) + XCTAssertEqual(success.expiresOrRenewsAt.timeIntervalSince1970, + Constants.subscription.expiresOrRenewsAt.timeIntervalSince1970, + accuracy: 0.001) + XCTAssertEqual(success.platform, Constants.subscription.platform) + XCTAssertEqual(success.status, Constants.subscription.status) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testGetSubscriptionError() async throws { + // Given + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.mockAPICallError = Constants.unknownServerError + + // When + let result = await subscriptionService.getSubscription(accessToken: Constants.accessToken, cachePolicy: .reloadIgnoringLocalCacheData) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure: + break + } + } + + // MARK: - Tests for getProducts + + func testGetProductsCall() async throws { + // Given + let apiServiceCalledExpectation = expectation(description: "apiService") + + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.onExecuteAPICall = { parameters in + let (method, endpoint, headers, _) = parameters + + apiServiceCalledExpectation.fulfill() + XCTAssertEqual(method, "GET") + XCTAssertEqual(endpoint, "products") + XCTAssertNil(headers) + } + + // When + _ = await subscriptionService.getProducts() + + // Then + await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) + } + + func testGetProductsSuccess() async throws { + // Given + apiService.mockResponseJSONData = """ + [ + { + "productId":"ddg-privacy-pro-sandbox-monthly-renews-us", + "productLabel":"Monthly Subscription", + "billingPeriod":"Monthly", + "price":"9.99", + "currency":"USD" + }, + { + "productId":"ddg-privacy-pro-sandbox-yearly-renews-us", + "productLabel":"Yearly Subscription", + "billingPeriod":"Yearly", + "price":"99.99", + "currency":"USD" + } + ] + """.data(using: .utf8)! + + // When + let result = await subscriptionService.getProducts() + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.count, 2) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testGetProductsError() async throws { + // Given + apiService.mockAPICallError = Constants.unknownServerError + + // When + let result = await subscriptionService.getProducts() + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure: + break + } + } + + // MARK: - Tests for getCustomerPortalURL + + func testGetCustomerPortalURLCall() async throws { + // Given + let apiServiceCalledExpectation = expectation(description: "apiService") + + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.onExecuteAPICall = { parameters in + let (method, endpoint, headers, _) = parameters + + apiServiceCalledExpectation.fulfill() + XCTAssertEqual(method, "GET") + XCTAssertEqual(endpoint, "checkout/portal") + XCTAssertEqual(headers?.count, 2) + XCTAssertEqual(headers?["externalAccountId"], Constants.externalID) + + if let (authorizationHeaderKey, authorizationHeaderValue) = Constants.authorizationHeader.first { + XCTAssertEqual(headers?[authorizationHeaderKey], authorizationHeaderValue) + } + } + + // When + _ = await subscriptionService.getCustomerPortalURL(accessToken: Constants.accessToken, externalID: Constants.externalID) + + // Then + await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) + } + + func testGetCustomerPortalURLSuccess() async throws { + // Given + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.mockResponseJSONData = """ + { + "customerPortalUrl":"\(Constants.customerPortalURL)" + } + """.data(using: .utf8)! + + // When + let result = await subscriptionService.getCustomerPortalURL(accessToken: Constants.accessToken, externalID: Constants.externalID) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.customerPortalUrl, Constants.customerPortalURL) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testGetCustomerPortalURLError() async throws { + // Given + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.mockAPICallError = Constants.unknownServerError + + // When + let result = await subscriptionService.getCustomerPortalURL(accessToken: Constants.accessToken, externalID: Constants.externalID) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure: + break + } + } + + // MARK: - Tests for confirmPurchase + + func testConfirmPurchaseCall() async throws { + // Given + let apiServiceCalledExpectation = expectation(description: "apiService") + + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.onExecuteAPICall = { parameters in + let (method, endpoint, headers, body) = parameters + + apiServiceCalledExpectation.fulfill() + XCTAssertEqual(method, "POST") + XCTAssertEqual(endpoint, "purchase/confirm/apple") + XCTAssertEqual(headers, Constants.authorizationHeader) + XCTAssertNotNil(body) + + if let bodyDict = try? JSONDecoder().decode([String: String].self, from: body!) { + XCTAssertEqual(bodyDict["signedTransactionInfo"], Constants.mostRecentTransactionJWS) + XCTAssertEqual(bodyDict["extraParamKey"], "extraParamValue") + } else { + XCTFail("Failed to decode body") + } + } + + // When + let additionalParams = ["extraParamKey": "extraParamValue"] + _ = await subscriptionService.confirmPurchase( + accessToken: Constants.accessToken, + signature: Constants.mostRecentTransactionJWS, + additionalParams: additionalParams + ) + + // Then + await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) + } + + func testConfirmPurchaseSuccess() async throws { + // Given + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.mockResponseJSONData = """ + { + "email":"", + "entitlements": + [ + {"product":"Data Broker Protection","name":"subscriber"}, + {"product":"Identity Theft Restoration","name":"subscriber"}, + {"product":"Network Protection","name":"subscriber"} + ], + "subscription": + { + "productId": "\(Constants.subscription.productId)", + "name": "\(Constants.subscription.name)", + "billingPeriod": "\(Constants.subscription.billingPeriod.rawValue)", + "startedAt": \(Constants.subscription.startedAt.timeIntervalSince1970*1000), + "expiresOrRenewsAt": \(Constants.subscription.expiresOrRenewsAt.timeIntervalSince1970*1000), + "platform": "\(Constants.subscription.platform.rawValue)", + "status": "\(Constants.subscription.status.rawValue)" + } + } + """.data(using: .utf8)! + + // When + let result = await subscriptionService.confirmPurchase(accessToken: Constants.accessToken, signature: Constants.mostRecentTransactionJWS, additionalParams: nil) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.entitlements.count, 3) + XCTAssertEqual(success.subscription.productId, Constants.subscription.productId) + XCTAssertEqual(success.subscription.name, Constants.subscription.name) + XCTAssertEqual(success.subscription.billingPeriod, Constants.subscription.billingPeriod) + XCTAssertEqual(success.subscription.startedAt.timeIntervalSince1970, + Constants.subscription.startedAt.timeIntervalSince1970, + accuracy: 0.001) + XCTAssertEqual(success.subscription.expiresOrRenewsAt.timeIntervalSince1970, + Constants.subscription.expiresOrRenewsAt.timeIntervalSince1970, + accuracy: 0.001) + XCTAssertEqual(success.subscription.platform, Constants.subscription.platform) + XCTAssertEqual(success.subscription.status, Constants.subscription.status) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testConfirmPurchaseWithAdditionalParams() async throws { + // Given + let apiServiceCalledExpectation = expectation(description: "apiService") + + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.onExecuteAPICall = { parameters in + let (_, _, _, body) = parameters + + apiServiceCalledExpectation.fulfill() + if let bodyDict = try? JSONDecoder().decode([String: String].self, from: body!) { + XCTAssertEqual(bodyDict["signedTransactionInfo"], Constants.mostRecentTransactionJWS) + XCTAssertEqual(bodyDict["extraParamKey1"], "extraValue1") + XCTAssertEqual(bodyDict["extraParamKey2"], "extraValue2") + } else { + XCTFail("Failed to decode body") + } + } + + // When + let additionalParams = [ + "extraParamKey1": "extraValue1", + "extraParamKey2": "extraValue2" + ] + _ = await subscriptionService.confirmPurchase( + accessToken: Constants.accessToken, + signature: Constants.mostRecentTransactionJWS, + additionalParams: additionalParams + ) + + // Then + await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) + } + + func testConfirmPurchaseWithConflictingKeys() async throws { + // Given + let apiServiceCalledExpectation = expectation(description: "apiService") + + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.onExecuteAPICall = { parameters in + let (_, _, _, body) = parameters + + apiServiceCalledExpectation.fulfill() + if let bodyDict = try? JSONDecoder().decode([String: String].self, from: body!) { + XCTAssertEqual(bodyDict["signedTransactionInfo"], Constants.mostRecentTransactionJWS) + XCTAssertEqual(bodyDict["extraParamKey"], "extraValue") + } else { + XCTFail("Failed to decode body") + } + } + + // When + let additionalParams = [ + "signedTransactionInfo": "overriddenValue", + "extraParamKey": "extraValue" + ] + _ = await subscriptionService.confirmPurchase( + accessToken: Constants.accessToken, + signature: Constants.mostRecentTransactionJWS, + additionalParams: additionalParams + ) + + // Then + await fulfillment(of: [apiServiceCalledExpectation], timeout: 0.1) + } + + func testConfirmPurchaseError() async throws { + // Given + apiService.mockAuthHeaders = Constants.authorizationHeader + apiService.mockAPICallError = Constants.unknownServerError + + // When + let result = await subscriptionService.confirmPurchase(accessToken: Constants.accessToken, signature: Constants.mostRecentTransactionJWS, additionalParams: nil) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure: + break + } + } +} diff --git a/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift new file mode 100644 index 000000000..e2c7f95c7 --- /dev/null +++ b/Tests/SubscriptionTests/Flows/AppStoreAccountManagementFlowTests.swift @@ -0,0 +1,184 @@ +// +// 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 { + + private struct Constants { + static let oldAuthToken = UUID().uuidString + static let newAuthToken = UUID().uuidString + + static let externalID = UUID ().uuidString + static let otherExternalID = UUID().uuidString + + static let email = "dax@duck.com" + + static let mostRecentTransactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" + + static let invalidTokenError = APIServiceError.serverError(statusCode: 401, error: "invalid_token") + + static let entitlements = [Entitlement(product: .dataBrokerProtection), + Entitlement(product: .identityTheftRestoration), + Entitlement(product: .networkProtection)] + } + + var accountManager: AccountManagerMock! + var authEndpointService: AuthEndpointServiceMock! + var storePurchaseManager: StorePurchaseManagerMock! + + var appStoreAccountManagementFlow: AppStoreAccountManagementFlow! + + override func setUpWithError() throws { + accountManager = AccountManagerMock() + authEndpointService = AuthEndpointServiceMock() + storePurchaseManager = StorePurchaseManagerMock() + + appStoreAccountManagementFlow = DefaultAppStoreAccountManagementFlow(authEndpointService: authEndpointService, + storePurchaseManager: storePurchaseManager, + accountManager: accountManager) + } + + override func tearDownWithError() throws { + accountManager = nil + authEndpointService = nil + storePurchaseManager = nil + + appStoreAccountManagementFlow = nil + } + + // MARK: - Tests for refreshAuthTokenIfNeeded + + func testRefreshAuthTokenIfNeededSuccess() async throws { + // Given + accountManager.authToken = Constants.oldAuthToken + accountManager.externalID = Constants.externalID + + authEndpointService.validateTokenResult = .failure(Constants.invalidTokenError) + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + + authEndpointService.storeLoginResult = .success(StoreLoginResponse(authToken: Constants.newAuthToken, + email: "", + externalID: Constants.externalID, + id: 1, + status: "authenticated")) + + // When + switch await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() { + case .success(let success): + // Then + XCTAssertTrue(storePurchaseManager.mostRecentTransactionCalled) + XCTAssertEqual(success, Constants.newAuthToken) + XCTAssertEqual(accountManager.authToken, Constants.newAuthToken) + case .failure(let error): + XCTFail("Unexpected failure: \(String(reflecting: error))") + } + } + + func testRefreshAuthTokenIfNeededSuccessButNotRefreshedIfStillValid() async throws { + // Given + accountManager.authToken = Constants.oldAuthToken + + authEndpointService.validateTokenResult = .success(ValidateTokenResponse(account: .init(email: Constants.email, + entitlements: Constants.entitlements, + externalID: Constants.externalID))) + + // When + switch await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() { + case .success(let success): + // Then + XCTAssertEqual(success, Constants.oldAuthToken) + XCTAssertEqual(accountManager.authToken, Constants.oldAuthToken) + case .failure(let error): + XCTFail("Unexpected failure: \(String(reflecting: error))") + } + } + + func testRefreshAuthTokenIfNeededSuccessButNotRefreshedIfStoreLoginRetrievedDifferentAccount() async throws { + // Given + accountManager.authToken = Constants.oldAuthToken + accountManager.externalID = Constants.externalID + accountManager.email = Constants.email + + authEndpointService.validateTokenResult = .failure(Constants.invalidTokenError) + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + + authEndpointService.storeLoginResult = .success(StoreLoginResponse(authToken: Constants.newAuthToken, + email: "", + externalID: Constants.otherExternalID, + id: 1, + status: "authenticated")) + + // When + switch await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() { + case .success(let success): + // Then + XCTAssertTrue(storePurchaseManager.mostRecentTransactionCalled) + XCTAssertEqual(success, Constants.oldAuthToken) + XCTAssertEqual(accountManager.authToken, Constants.oldAuthToken) + XCTAssertEqual(accountManager.externalID, Constants.externalID) + XCTAssertEqual(accountManager.email, Constants.email) + case .failure(let error): + XCTFail("Unexpected failure: \(String(reflecting: error))") + } + } + + func testRefreshAuthTokenIfNeededErrorDueToNoPastTransactions() async throws { + // Given + accountManager.authToken = Constants.oldAuthToken + + authEndpointService.validateTokenResult = .failure(Constants.invalidTokenError) + + storePurchaseManager.mostRecentTransactionResult = nil + + // When + switch await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertTrue(storePurchaseManager.mostRecentTransactionCalled) + XCTAssertEqual(error, .noPastTransaction) + } + } + + func testRefreshAuthTokenIfNeededErrorDueToStoreLoginFailure() async throws { + // Given + accountManager.authToken = Constants.oldAuthToken + + authEndpointService.validateTokenResult = .failure(Constants.invalidTokenError) + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + + authEndpointService.storeLoginResult = .failure(.unknownServerError) + + // When + switch await appStoreAccountManagementFlow.refreshAuthTokenIfNeeded() { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertTrue(storePurchaseManager.mostRecentTransactionCalled) + XCTAssertEqual(error, .authenticatingWithTransactionFailed) + } + } +} diff --git a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift new file mode 100644 index 000000000..1a7345442 --- /dev/null +++ b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift @@ -0,0 +1,369 @@ +// +// 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 { + + private struct Constants { + static let authToken = UUID().uuidString + static let accessToken = UUID().uuidString + static let externalID = UUID().uuidString + static let email = "dax@duck.com" + + static let productID = UUID().uuidString + static let transactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" + + static let unknownServerError = APIServiceError.serverError(statusCode: 401, error: "unknown_error") + } + + var accountManager: AccountManagerMock! + var subscriptionService: SubscriptionEndpointServiceMock! + var authService: AuthEndpointServiceMock! + var storePurchaseManager: StorePurchaseManagerMock! + var appStoreRestoreFlow: AppStoreRestoreFlowMock! + + var appStorePurchaseFlow: AppStorePurchaseFlow! + + override func setUpWithError() throws { + subscriptionService = SubscriptionEndpointServiceMock() + storePurchaseManager = StorePurchaseManagerMock() + accountManager = AccountManagerMock() + appStoreRestoreFlow = AppStoreRestoreFlowMock() + authService = AuthEndpointServiceMock() + + appStorePurchaseFlow = DefaultAppStorePurchaseFlow(subscriptionEndpointService: subscriptionService, + storePurchaseManager: storePurchaseManager, + accountManager: accountManager, + appStoreRestoreFlow: appStoreRestoreFlow, + authEndpointService: authService) + } + + override func tearDownWithError() throws { + subscriptionService = nil + storePurchaseManager = nil + accountManager = nil + appStoreRestoreFlow = nil + authService = nil + + appStorePurchaseFlow = nil + } + + // MARK: - Tests for purchaseSubscription + + func testPurchaseSubscriptionSuccess() async throws { + // Given + XCTAssertFalse(accountManager.isUserAuthenticated) + + appStoreRestoreFlow.restoreAccountFromPastPurchaseResult = .failure(.missingAccountOrTransactions) + authService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, + externalID: Constants.externalID, + status: "created")) + accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) + accountManager.fetchAccountDetailsResult = .success((email: "", externalID: Constants.externalID)) + storePurchaseManager.purchaseSubscriptionResult = .success(Constants.transactionJWS) + + // When + switch await appStorePurchaseFlow.purchaseSubscription(with: Constants.productID, emailAccessToken: nil) { + case .success(let success): + // Then + XCTAssertTrue(appStoreRestoreFlow.restoreAccountFromPastPurchaseCalled) + XCTAssertTrue(authService.createAccountCalled) + XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) + XCTAssertTrue(accountManager.storeAuthTokenCalled) + XCTAssertTrue(accountManager.storeAccountCalled) + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + XCTAssertEqual(success, Constants.transactionJWS) + case .failure(let error): + XCTFail("Unexpected failure: \(String(reflecting: error))") + } + } + + func testPurchaseSubscriptionSuccessRepurchaseForAppStoreSubscription() async throws { + // Given + accountManager.authToken = Constants.authToken + accountManager.accessToken = Constants.accessToken + accountManager.externalID = Constants.externalID + accountManager.email = Constants.email + + let expiredSubscription = SubscriptionMockFactory.expiredSubscription + + XCTAssertFalse(expiredSubscription.isActive) + XCTAssertEqual(expiredSubscription.platform, .apple) + XCTAssertTrue(accountManager.isUserAuthenticated) + + subscriptionService.getSubscriptionResult = .success(expiredSubscription) + appStoreRestoreFlow.restoreAccountFromPastPurchaseResult = .failure(.subscriptionExpired(accountDetails: .init(authToken: Constants.authToken, + accessToken: Constants.accessToken, + externalID: Constants.externalID, + email: Constants.email))) + storePurchaseManager.purchaseSubscriptionResult = .success(Constants.transactionJWS) + + // When + switch await appStorePurchaseFlow.purchaseSubscription(with: Constants.productID, emailAccessToken: nil) { + case .success(let success): + // Then + XCTAssertTrue(appStoreRestoreFlow.restoreAccountFromPastPurchaseCalled) + XCTAssertFalse(authService.createAccountCalled) + XCTAssertFalse(accountManager.exchangeAuthTokenToAccessTokenCalled) + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + XCTAssertEqual(success, Constants.transactionJWS) + XCTAssertEqual(accountManager.externalID, Constants.externalID) + XCTAssertEqual(accountManager.email, Constants.email) + case .failure(let error): + XCTFail("Unexpected failure: \(String(reflecting: error))") + } + } + + func testPurchaseSubscriptionSuccessRepurchaseForNonAppStoreSubscription() async throws { + // Given + accountManager.authToken = Constants.authToken + accountManager.accessToken = Constants.accessToken + accountManager.externalID = Constants.externalID + + let subscription = SubscriptionMockFactory.expiredStripeSubscription + + XCTAssertFalse(subscription.isActive) + XCTAssertNotEqual(subscription.platform, .apple) + XCTAssertTrue(accountManager.isUserAuthenticated) + + subscriptionService.getSubscriptionResult = .success(subscription) + storePurchaseManager.purchaseSubscriptionResult = .success(Constants.transactionJWS) + + // When + switch await appStorePurchaseFlow.purchaseSubscription(with: Constants.productID, emailAccessToken: nil) { + case .success: + // Then + XCTAssertFalse(appStoreRestoreFlow.restoreAccountFromPastPurchaseCalled) + XCTAssertFalse(authService.createAccountCalled) + XCTAssertEqual(accountManager.externalID, Constants.externalID) + case .failure(let error): + XCTFail("Unexpected failure: \(String(reflecting: error))") + } + } + + func testPurchaseSubscriptionErrorWhenActiveSubscriptionRestoredFromAppStore() async throws { + // Given + appStoreRestoreFlow.restoreAccountFromPastPurchaseResult = .success(Void()) + + // When + switch await appStorePurchaseFlow.purchaseSubscription(with: Constants.productID, emailAccessToken: nil) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertFalse(authService.createAccountCalled) + XCTAssertFalse(storePurchaseManager.purchaseSubscriptionCalled) + XCTAssertEqual(error, .activeSubscriptionAlreadyPresent) + } + } + + func testPurchaseSubscriptionErrorWhenAccountCreationFails() async throws { + // Given + appStoreRestoreFlow.restoreAccountFromPastPurchaseResult = .failure(.missingAccountOrTransactions) + authService.createAccountResult = .failure(.unknownServerError) + + // When + switch await appStorePurchaseFlow.purchaseSubscription(with: Constants.productID, emailAccessToken: nil) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertTrue(authService.createAccountCalled) + XCTAssertFalse(storePurchaseManager.purchaseSubscriptionCalled) + XCTAssertEqual(error, .accountCreationFailed) + } + } + + func testPurchaseSubscriptionErrorWhenAppStorePurchaseFails() async throws { + // Given + appStoreRestoreFlow.restoreAccountFromPastPurchaseResult = .failure(.missingAccountOrTransactions) + authService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, + externalID: Constants.externalID, + status: "created")) + accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) + accountManager.fetchAccountDetailsResult = .success((email: "", externalID: Constants.externalID)) + storePurchaseManager.purchaseSubscriptionResult = .failure(.productNotFound) + + // When + switch await appStorePurchaseFlow.purchaseSubscription(with: Constants.productID, emailAccessToken: nil) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertTrue(authService.createAccountCalled) + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + XCTAssertEqual(error, .purchaseFailed) + } + } + + func testPurchaseSubscriptionErrorWhenAppStorePurchaseCancelledByUser() async throws { + // Given + appStoreRestoreFlow.restoreAccountFromPastPurchaseResult = .failure(.missingAccountOrTransactions) + authService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, + externalID: Constants.externalID, + status: "created")) + accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) + accountManager.fetchAccountDetailsResult = .success((email: "", externalID: Constants.externalID)) + storePurchaseManager.purchaseSubscriptionResult = .failure(.purchaseCancelledByUser) + + // When + switch await appStorePurchaseFlow.purchaseSubscription(with: Constants.productID, emailAccessToken: nil) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertTrue(authService.createAccountCalled) + XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) + XCTAssertEqual(error, .cancelledByUser) + } + } + + // MARK: - Tests for completeSubscriptionPurchase + + func testCompleteSubscriptionPurchaseSuccess() async throws { + // Given + accountManager.accessToken = Constants.accessToken + subscriptionService.confirmPurchaseResult = .success( + ConfirmPurchaseResponse( + email: nil, + entitlements: [], + subscription: SubscriptionMockFactory.subscription + ) + ) + + let expectedAdditionalParams = ["key1": "value1", "key2": "value2"] + + subscriptionService.onConfirmPurchase = { accessToken, signature, additionalParams in + XCTAssertEqual(accessToken, Constants.accessToken) + XCTAssertEqual(signature, Constants.transactionJWS) + XCTAssertEqual(additionalParams, expectedAdditionalParams) + } + + subscriptionService.onUpdateCache = { subscription in + XCTAssertEqual(subscription, SubscriptionMockFactory.subscription) + } + + // When + switch await appStorePurchaseFlow.completeSubscriptionPurchase( + with: Constants.transactionJWS, + additionalParams: expectedAdditionalParams + ) { + case .success(let success): + // Then + XCTAssertTrue(subscriptionService.updateCacheWithSubscriptionCalled) + XCTAssertTrue(accountManager.updateCacheWithEntitlementsCalled) + XCTAssertEqual(success.type, "completed") + case .failure(let error): + XCTFail("Unexpected failure: \(String(reflecting: error))") + } + } + + func testCompleteSubscriptionPurchaseWithNilAdditionalParams() async throws { + // Given + accountManager.accessToken = Constants.accessToken + subscriptionService.confirmPurchaseResult = .success( + ConfirmPurchaseResponse( + email: nil, + entitlements: [], + subscription: SubscriptionMockFactory.subscription + ) + ) + + subscriptionService.onConfirmPurchase = { accessToken, signature, additionalParams in + XCTAssertEqual(accessToken, Constants.accessToken) + XCTAssertEqual(signature, Constants.transactionJWS) + XCTAssertNil(additionalParams) + } + + subscriptionService.onUpdateCache = { subscription in + XCTAssertEqual(subscription, SubscriptionMockFactory.subscription) + } + + // When + switch await appStorePurchaseFlow.completeSubscriptionPurchase( + with: Constants.transactionJWS, + additionalParams: nil + ) { + case .success(let success): + // Then + XCTAssertTrue(subscriptionService.updateCacheWithSubscriptionCalled) + XCTAssertTrue(accountManager.updateCacheWithEntitlementsCalled) + XCTAssertEqual(success.type, "completed") + case .failure(let error): + XCTFail("Unexpected failure: \(String(reflecting: error))") + } + } + + func testCompleteSubscriptionPurchaseErrorDueToMissingAccessToken() async throws { + // Given + XCTAssertNil(accountManager.accessToken) + + // When + switch await appStorePurchaseFlow.completeSubscriptionPurchase(with: Constants.transactionJWS, additionalParams: nil) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertEqual(error, .missingEntitlements) + } + } + + func testCompleteSubscriptionPurchaseErrorWithAdditionalParams() async throws { + // Given + accountManager.accessToken = Constants.accessToken + subscriptionService.confirmPurchaseResult = .failure(Constants.unknownServerError) + + let additionalParams = ["key1": "value1"] + + subscriptionService.onConfirmPurchase = { accessToken, signature, additionalParams in + XCTAssertEqual(accessToken, Constants.accessToken) + XCTAssertEqual(signature, Constants.transactionJWS) + XCTAssertEqual(additionalParams, additionalParams) + } + + // When + switch await appStorePurchaseFlow.completeSubscriptionPurchase( + with: Constants.transactionJWS, + additionalParams: additionalParams + ) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertEqual(error, .missingEntitlements) + } + } + + func testCompleteSubscriptionPurchaseErrorDueToFailedPurchaseConfirmation() async throws { + // Given + accountManager.accessToken = Constants.accessToken + subscriptionService.confirmPurchaseResult = .failure(Constants.unknownServerError) + + // When + switch await appStorePurchaseFlow.completeSubscriptionPurchase(with: Constants.transactionJWS, additionalParams: nil) { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertEqual(error, .missingEntitlements) + } + } +} diff --git a/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift new file mode 100644 index 000000000..3d065d1d7 --- /dev/null +++ b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowTests.swift @@ -0,0 +1,332 @@ +// +// 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 { + + private struct Constants { + static let authToken = UUID().uuidString + static let accessToken = UUID().uuidString + static let externalID = UUID().uuidString + static let email = "dax@duck.com" + + static let mostRecentTransactionJWS = "dGhpcyBpcyBub3QgYSByZWFsIEFw(...)cCBTdG9yZSB0cmFuc2FjdGlvbiBKV1M=" + static let storeLoginResponse = StoreLoginResponse(authToken: Constants.authToken, + email: Constants.email, + externalID: Constants.externalID, + id: 1, + status: "authenticated") + + static let unknownServerError = APIServiceError.serverError(statusCode: 401, error: "unknown_error") + } + + var accountManager: AccountManagerMock! + var storePurchaseManager: StorePurchaseManagerMock! + var subscriptionService: SubscriptionEndpointServiceMock! + var authService: AuthEndpointServiceMock! + + var appStoreRestoreFlow: AppStoreRestoreFlow! + + override func setUpWithError() throws { + accountManager = AccountManagerMock() + storePurchaseManager = StorePurchaseManagerMock() + subscriptionService = SubscriptionEndpointServiceMock() + authService = AuthEndpointServiceMock() + + appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, + storePurchaseManager: storePurchaseManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService) + } + + override func tearDownWithError() throws { + accountManager = nil + subscriptionService = nil + authService = nil + storePurchaseManager = nil + + appStoreRestoreFlow = nil + } + + // MARK: - Tests for restoreAccountFromPastPurchase + + func testRestoreAccountFromPastPurchaseSuccess() async throws { + // Given + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + + authService.storeLoginResult = .success(Constants.storeLoginResponse) + + accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) + + accountManager.fetchAccountDetailsResult = .success(AccountManager.AccountDetails(email: Constants.email, + externalID: Constants.externalID)) + accountManager.onFetchAccountDetails = { accessToken in + XCTAssertEqual(accessToken, Constants.accessToken) + } + + let subscription = SubscriptionMockFactory.subscription + subscriptionService.getSubscriptionResult = .success(subscription) + + XCTAssertTrue(subscription.isActive) + + accountManager.onStoreAuthToken = { authToken in + XCTAssertEqual(authToken, Constants.authToken) + } + + accountManager.onStoreAccount = { accessToken, email, externalID in + XCTAssertEqual(accessToken, Constants.accessToken) + XCTAssertEqual(externalID, Constants.externalID) + } + + // When + switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + // Then + XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) + XCTAssertTrue(accountManager.fetchAccountDetailsCalled) + XCTAssertTrue(accountManager.storeAuthTokenCalled) + XCTAssertTrue(accountManager.storeAccountCalled) + + XCTAssertTrue(accountManager.isUserAuthenticated) + XCTAssertEqual(accountManager.authToken, Constants.authToken) + XCTAssertEqual(accountManager.accessToken, Constants.accessToken) + XCTAssertEqual(accountManager.externalID, Constants.externalID) + XCTAssertEqual(accountManager.email, Constants.email) + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + } + } + + func testRestoreAccountFromPastPurchaseErrorDueToSubscriptionBeingExpired() async throws { + // Given + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + + authService.storeLoginResult = .success(Constants.storeLoginResponse) + + accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) + + accountManager.fetchAccountDetailsResult = .success(AccountManager.AccountDetails(email: nil, externalID: Constants.externalID)) + accountManager.onFetchAccountDetails = { accessToken in + XCTAssertEqual(accessToken, Constants.accessToken) + } + + let subscription = SubscriptionMockFactory.expiredSubscription + subscriptionService.getSubscriptionResult = .success(subscription) + + XCTAssertFalse(subscription.isActive) + + accountManager.onStoreAuthToken = { authToken in + XCTAssertEqual(authToken, Constants.authToken) + } + + accountManager.onStoreAccount = { accessToken, email, externalID in + XCTAssertEqual(accessToken, Constants.accessToken) + XCTAssertEqual(externalID, Constants.externalID) + } + + let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, + storePurchaseManager: storePurchaseManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService) + // When + switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) + XCTAssertTrue(accountManager.fetchAccountDetailsCalled) + XCTAssertFalse(accountManager.storeAuthTokenCalled) + XCTAssertFalse(accountManager.storeAccountCalled) + + guard case .subscriptionExpired(let accountDetails) = error else { + XCTFail("Expected .subscriptionExpired error") + return + } + + XCTAssertEqual(accountDetails.authToken, Constants.authToken) + XCTAssertEqual(accountDetails.accessToken, Constants.accessToken) + XCTAssertEqual(accountDetails.externalID, Constants.externalID) + + XCTAssertFalse(accountManager.isUserAuthenticated) + } + } + + func testRestoreAccountFromPastPurchaseErrorWhenNoRecentTransaction() async throws { + // Given + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.mostRecentTransactionResult = nil + + let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, + storePurchaseManager: storePurchaseManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService) + // When + switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertFalse(accountManager.exchangeAuthTokenToAccessTokenCalled) + XCTAssertFalse(accountManager.fetchAccountDetailsCalled) + XCTAssertFalse(accountManager.storeAuthTokenCalled) + XCTAssertFalse(accountManager.storeAccountCalled) + XCTAssertEqual(error, .missingAccountOrTransactions) + + XCTAssertFalse(accountManager.isUserAuthenticated) + } + } + + func testRestoreAccountFromPastPurchaseErrorDueToStoreLoginFailure() async throws { + // Given + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + + authService.storeLoginResult = .failure(Constants.unknownServerError) + + let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, + storePurchaseManager: storePurchaseManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService) + // When + switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertFalse(accountManager.exchangeAuthTokenToAccessTokenCalled) + XCTAssertFalse(accountManager.fetchAccountDetailsCalled) + XCTAssertFalse(accountManager.storeAuthTokenCalled) + XCTAssertFalse(accountManager.storeAccountCalled) + XCTAssertEqual(error, .pastTransactionAuthenticationError) + + XCTAssertFalse(accountManager.isUserAuthenticated) + } + } + + func testRestoreAccountFromPastPurchaseErrorDueToStoreAuthTokenExchangeFailure() async throws { + // Given + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + + authService.storeLoginResult = .success(Constants.storeLoginResponse) + + accountManager.exchangeAuthTokenToAccessTokenResult = .failure(Constants.unknownServerError) + + let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, + storePurchaseManager: storePurchaseManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService) + // When + switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) + XCTAssertFalse(accountManager.fetchAccountDetailsCalled) + XCTAssertFalse(accountManager.storeAuthTokenCalled) + XCTAssertFalse(accountManager.storeAccountCalled) + XCTAssertEqual(error, .failedToObtainAccessToken) + + XCTAssertFalse(accountManager.isUserAuthenticated) + } + } + + func testRestoreAccountFromPastPurchaseErrorDueToAccountDetailsFetchFailure() async throws { + // Given + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + + authService.storeLoginResult = .success(Constants.storeLoginResponse) + + accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) + + accountManager.fetchAccountDetailsResult = .failure(Constants.unknownServerError) + accountManager.onFetchAccountDetails = { accessToken in + XCTAssertEqual(accessToken, Constants.accessToken) + } + + let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, + storePurchaseManager: storePurchaseManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService) + // When + switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) + XCTAssertTrue(accountManager.fetchAccountDetailsCalled) + XCTAssertFalse(accountManager.storeAuthTokenCalled) + XCTAssertFalse(accountManager.storeAccountCalled) + XCTAssertEqual(error, .failedToFetchAccountDetails) + + XCTAssertFalse(accountManager.isUserAuthenticated) + } + } + + func testRestoreAccountFromPastPurchaseErrorDueToSubscriptionFetchFailure() async throws { + // Given + XCTAssertFalse(accountManager.isUserAuthenticated) + + storePurchaseManager.mostRecentTransactionResult = Constants.mostRecentTransactionJWS + + authService.storeLoginResult = .success(Constants.storeLoginResponse) + + accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) + + accountManager.fetchAccountDetailsResult = .success(AccountManager.AccountDetails(email: nil, externalID: Constants.externalID)) + accountManager.onFetchAccountDetails = { accessToken in + XCTAssertEqual(accessToken, Constants.accessToken) + } + + subscriptionService.getSubscriptionResult = .failure(.apiError(Constants.unknownServerError)) + + let appStoreRestoreFlow = DefaultAppStoreRestoreFlow(accountManager: accountManager, + storePurchaseManager: storePurchaseManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService) + // When + switch await appStoreRestoreFlow.restoreAccountFromPastPurchase() { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + // Then + XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) + XCTAssertTrue(accountManager.fetchAccountDetailsCalled) + XCTAssertFalse(accountManager.storeAuthTokenCalled) + XCTAssertFalse(accountManager.storeAccountCalled) + XCTAssertEqual(error, .failedToFetchSubscriptionDetails) + + XCTAssertFalse(accountManager.isUserAuthenticated) + } + } +} diff --git a/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift new file mode 100644 index 000000000..4d012c0d1 --- /dev/null +++ b/Tests/SubscriptionTests/Flows/Models/SubscriptionOptionsTests.swift @@ -0,0 +1,126 @@ +// +// 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 { + + func testEncoding() throws { + let monthlySubscriptionOffer = SubscriptionOptionOffer(type: .freeTrial, id: "1", durationInDays: 7, isUserEligible: true) + let yearlySubscriptionOffer = SubscriptionOptionOffer(type: .freeTrial, id: "2", durationInDays: 7, isUserEligible: true) + let subscriptionOptions = SubscriptionOptions(platform: .macos, + options: [ + SubscriptionOption(id: "1", + cost: SubscriptionOptionCost(displayPrice: "9 USD", recurrence: "monthly"), offer: monthlySubscriptionOffer), + SubscriptionOption(id: "2", + cost: SubscriptionOptionCost(displayPrice: "99 USD", recurrence: "yearly"), offer: yearlySubscriptionOffer) + ], + features: [ + SubscriptionFeature(name: .networkProtection), + SubscriptionFeature(name: .dataBrokerProtection), + SubscriptionFeature(name: .identityTheftRestoration) + ]) + + let jsonEncoder = JSONEncoder() + jsonEncoder.outputFormatting = [.sortedKeys, .prettyPrinted] + let data = try? jsonEncoder.encode(subscriptionOptions) + let subscriptionOptionsString = String(data: data!, encoding: .utf8)! + + XCTAssertEqual(subscriptionOptionsString, """ +{ + "features" : [ + { + "name" : "Network Protection" + }, + { + "name" : "Data Broker Protection" + }, + { + "name" : "Identity Theft Restoration" + } + ], + "options" : [ + { + "cost" : { + "displayPrice" : "9 USD", + "recurrence" : "monthly" + }, + "id" : "1", + "offer" : { + "durationInDays" : 7, + "id" : "1", + "isUserEligible" : true, + "type" : "freeTrial" + } + }, + { + "cost" : { + "displayPrice" : "99 USD", + "recurrence" : "yearly" + }, + "id" : "2", + "offer" : { + "durationInDays" : 7, + "id" : "2", + "isUserEligible" : true, + "type" : "freeTrial" + } + } + ], + "platform" : "macos" +} +""") + } + + func testSubscriptionOptionCostEncoding() throws { + let subscriptionOptionCost = SubscriptionOptionCost(displayPrice: "9 USD", recurrence: "monthly") + + let jsonEncoder = JSONEncoder() + jsonEncoder.outputFormatting = [.sortedKeys] + let data = try? jsonEncoder.encode(subscriptionOptionCost) + let subscriptionOptionCostString = String(data: data!, encoding: .utf8)! + + XCTAssertEqual(subscriptionOptionCostString, "{\"displayPrice\":\"9 USD\",\"recurrence\":\"monthly\"}") + } + + func testSubscriptionFeatureEncoding() throws { + let subscriptionFeature = SubscriptionFeature(name: .identityTheftRestoration) + + let data = try? JSONEncoder().encode(subscriptionFeature) + let subscriptionFeatureString = String(data: data!, encoding: .utf8)! + + XCTAssertEqual(subscriptionFeatureString, "{\"name\":\"Identity Theft Restoration\"}") + } + + func testEmptySubscriptionOptions() throws { + let empty = SubscriptionOptions.empty + + let platform: SubscriptionPlatformName +#if os(iOS) + platform = .ios +#else + platform = .macos +#endif + + XCTAssertEqual(empty.platform, platform) + XCTAssertTrue(empty.options.isEmpty) + XCTAssertEqual(empty.features.count, 3) + } +} diff --git a/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift new file mode 100644 index 000000000..e397805db --- /dev/null +++ b/Tests/SubscriptionTests/Flows/StripePurchaseFlowTests.swift @@ -0,0 +1,255 @@ +// +// 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 { + + private struct Constants { + static let authToken = UUID().uuidString + static let accessToken = UUID().uuidString + static let externalID = UUID().uuidString + static let email = "dax@duck.com" + + static let unknownServerError = APIServiceError.serverError(statusCode: 401, error: "unknown_error") + } + + var accountManager: AccountManagerMock! + var subscriptionService: SubscriptionEndpointServiceMock! + var authEndpointService: AuthEndpointServiceMock! + + var stripePurchaseFlow: StripePurchaseFlow! + + override func setUpWithError() throws { + accountManager = AccountManagerMock() + subscriptionService = SubscriptionEndpointServiceMock() + authEndpointService = AuthEndpointServiceMock() + + stripePurchaseFlow = DefaultStripePurchaseFlow(subscriptionEndpointService: subscriptionService, + authEndpointService: authEndpointService, + accountManager: accountManager) + } + + override func tearDownWithError() throws { + accountManager = nil + subscriptionService = nil + authEndpointService = nil + + stripePurchaseFlow = nil + } + + // MARK: - Tests for subscriptionOptions + + func testSubscriptionOptionsSuccess() async throws { + // Given + subscriptionService .getProductsResult = .success(SubscriptionMockFactory.productsItems) + + // When + let result = await stripePurchaseFlow.subscriptionOptions() + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.platform, SubscriptionPlatformName.stripe) + XCTAssertEqual(success.options.count, SubscriptionMockFactory.productsItems.count) + XCTAssertEqual(success.features.count, 3) + let allFeatures = [Entitlement.ProductName.networkProtection, Entitlement.ProductName.dataBrokerProtection, Entitlement.ProductName.identityTheftRestoration] + let allNames = success.features.compactMap({ feature in feature.name}) + + for feature in allFeatures { + XCTAssertTrue(allNames.contains(feature)) + } + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + } + } + + func testSubscriptionOptionsErrorWhenNoProductsAreFetched() async throws { + // Given + subscriptionService.getProductsResult = .failure(.unknownServerError) + + // When + let result = await stripePurchaseFlow.subscriptionOptions() + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + XCTAssertEqual(error, .noProductsFound) + } + } + + // MARK: - Tests for prepareSubscriptionPurchase + + func testPrepareSubscriptionPurchaseSuccess() async throws { + // Given + authEndpointService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, + externalID: Constants.externalID, + status: "created")) + XCTAssertFalse(accountManager.isUserAuthenticated) + + // When + let result = await stripePurchaseFlow.prepareSubscriptionPurchase(emailAccessToken: nil) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.type, "redirect") + XCTAssertEqual(success.token, Constants.authToken) + + XCTAssertTrue(authEndpointService.createAccountCalled) + XCTAssertEqual(accountManager.authToken, Constants.authToken) + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + } + } + + func testPrepareSubscriptionPurchaseSuccessWhenSignedInAndSubscriptionExpired() async throws { + // Given + let subscription = SubscriptionMockFactory.expiredSubscription + + accountManager.accessToken = Constants.accessToken + + subscriptionService.getSubscriptionResult = .success(subscription) + subscriptionService.getProductsResult = .success(SubscriptionMockFactory.productsItems) + + XCTAssertTrue(accountManager.isUserAuthenticated) + XCTAssertFalse(subscription.isActive) + + // When + let result = await stripePurchaseFlow.prepareSubscriptionPurchase(emailAccessToken: nil) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.type, "redirect") + XCTAssertEqual(success.token, Constants.accessToken) + + XCTAssertTrue(subscriptionService.signOutCalled) + XCTAssertFalse(authEndpointService.createAccountCalled) + case .failure(let error): + XCTFail("Unexpected failure: \(error)") + } + } + + func testPrepareSubscriptionPurchaseErrorWhenAccountCreationFailed() async throws { + // Given + authEndpointService.createAccountResult = .failure(Constants.unknownServerError) + XCTAssertFalse(accountManager.isUserAuthenticated) + + // When + let result = await stripePurchaseFlow.prepareSubscriptionPurchase(emailAccessToken: nil) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + XCTAssertEqual(error, .accountCreationFailed) + } + } + + // MARK: - Tests for completeSubscriptionPurchase + + func testCompleteSubscriptionPurchaseSuccessOnInitialPurchase() async throws { + // Given + // Initial purchase flow: authToken is present but no accessToken yet + accountManager.authToken = Constants.authToken + XCTAssertNil(accountManager.accessToken) + + accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) + accountManager.onExchangeAuthTokenToAccessToken = { authToken in + XCTAssertEqual(authToken, Constants.authToken) + } + + accountManager.fetchAccountDetailsResult = .success(AccountManager.AccountDetails(email: nil, externalID: Constants.externalID)) + accountManager.onFetchAccountDetails = { accessToken in + XCTAssertEqual(accessToken, Constants.accessToken) + } + + accountManager.onStoreAuthToken = { authToken in + XCTAssertEqual(authToken, Constants.authToken) + } + + accountManager.onStoreAccount = { accessToken, email, externalID in + XCTAssertEqual(accessToken, Constants.accessToken) + XCTAssertEqual(externalID, Constants.externalID) + XCTAssertNil(email) + } + + accountManager.onCheckForEntitlements = { wait, retry in + XCTAssertEqual(wait, 2.0) + XCTAssertEqual(retry, 5) + return true + } + + XCTAssertFalse(accountManager.isUserAuthenticated) + XCTAssertNotNil(accountManager.authToken) + + // When + await stripePurchaseFlow.completeSubscriptionPurchase() + + // Then + XCTAssertTrue(subscriptionService.signOutCalled) + XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) + XCTAssertTrue(accountManager.fetchAccountDetailsCalled) + XCTAssertTrue(accountManager.storeAuthTokenCalled) + XCTAssertTrue(accountManager.storeAccountCalled) + XCTAssertTrue(accountManager.checkForEntitlementsCalled) + + XCTAssertTrue(accountManager.isUserAuthenticated) + XCTAssertEqual(accountManager.accessToken, Constants.accessToken) + XCTAssertEqual(accountManager.externalID, Constants.externalID) + } + + func testCompleteSubscriptionPurchaseSuccessOnRepurchase() async throws { + // Given + // Repurchase flow: authToken, accessToken and externalID are present + accountManager.authToken = Constants.authToken + accountManager.accessToken = Constants.accessToken + accountManager.externalID = Constants.externalID + + accountManager.fetchAccountDetailsResult = .success(AccountManager.AccountDetails(email: Constants.email, externalID: Constants.externalID)) + + accountManager.onCheckForEntitlements = { wait, retry in + XCTAssertEqual(wait, 2.0) + XCTAssertEqual(retry, 5) + return true + } + + XCTAssertTrue(accountManager.isUserAuthenticated) + + // When + await stripePurchaseFlow.completeSubscriptionPurchase() + + // Then + XCTAssertTrue(subscriptionService.signOutCalled) + XCTAssertFalse(accountManager.exchangeAuthTokenToAccessTokenCalled) + XCTAssertFalse(accountManager.fetchAccountDetailsCalled) + XCTAssertFalse(accountManager.storeAuthTokenCalled) + XCTAssertFalse(accountManager.storeAccountCalled) + XCTAssertTrue(accountManager.checkForEntitlementsCalled) + + XCTAssertTrue(accountManager.isUserAuthenticated) + XCTAssertEqual(accountManager.accessToken, Constants.accessToken) + XCTAssertEqual(accountManager.externalID, Constants.externalID) + } +} diff --git a/Tests/SubscriptionTests/Managers/AccountManagerTests.swift b/Tests/SubscriptionTests/Managers/AccountManagerTests.swift new file mode 100644 index 000000000..0a04a4cde --- /dev/null +++ b/Tests/SubscriptionTests/Managers/AccountManagerTests.swift @@ -0,0 +1,508 @@ +// +// 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 { + + private struct Constants { + static let userDefaultsSuiteName = "AccountManagerTests" + + static let authToken = UUID().uuidString + static let accessToken = UUID().uuidString + static let externalID = UUID().uuidString + + static let email = "dax@duck.com" + + static let entitlements = [Entitlement(product: .dataBrokerProtection), + Entitlement(product: .identityTheftRestoration), + Entitlement(product: .networkProtection)] + + static let keychainError = AccountKeychainAccessError.keychainSaveFailure(1) + static let invalidTokenError = APIServiceError.serverError(statusCode: 401, error: "invalid_token") + static let unknownServerError = APIServiceError.serverError(statusCode: 401, error: "unknown_error") + } + + var userDefaults: UserDefaults! + var accountStorage: AccountKeychainStorageMock! + var accessTokenStorage: SubscriptionTokenKeychainStorageMock! + var entitlementsCache: UserDefaultsCache<[Entitlement]>! + var subscriptionService: SubscriptionEndpointServiceMock! + var authService: AuthEndpointServiceMock! + + var accountManager: AccountManager! + + override func setUpWithError() throws { + userDefaults = UserDefaults(suiteName: Constants.userDefaultsSuiteName)! + userDefaults.removePersistentDomain(forName: Constants.userDefaultsSuiteName) + + accountStorage = AccountKeychainStorageMock() + accessTokenStorage = SubscriptionTokenKeychainStorageMock() + entitlementsCache = UserDefaultsCache<[Entitlement]>(userDefaults: userDefaults, + key: UserDefaultsCacheKey.subscriptionEntitlements, + settings: UserDefaultsCacheSettings(defaultExpirationInterval: .minutes(20))) + subscriptionService = SubscriptionEndpointServiceMock() + authService = AuthEndpointServiceMock() + + accountManager = DefaultAccountManager(storage: accountStorage, + accessTokenStorage: accessTokenStorage, + entitlementsCache: entitlementsCache, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService) + } + + override func tearDownWithError() throws { + accountStorage = nil + accessTokenStorage = nil + entitlementsCache = nil + subscriptionService = nil + authService = nil + + accountManager = nil + } + + // MARK: - Tests for storeAuthToken + + func testStoreAuthToken() throws { + // When + accountManager.storeAuthToken(token: Constants.authToken) + + XCTAssertEqual(accountManager.authToken, Constants.authToken) + XCTAssertEqual(accountStorage.authToken, Constants.authToken) + } + + func testStoreAuthTokenFailure() async throws { + // Given + let delegateCalled = expectation(description: "AccountManagerKeychainAccessDelegate called") + let keychainAccessDelegateMock = AccountManagerKeychainAccessDelegateMock { type, error in + delegateCalled.fulfill() + XCTAssertEqual(type, .storeAuthToken) + XCTAssertEqual(error, Constants.keychainError) + } + + accountStorage.mockedAccessError = Constants.keychainError + accountManager.delegate = keychainAccessDelegateMock + + // When + accountManager.storeAuthToken(token: Constants.authToken) + + // Then + await fulfillment(of: [delegateCalled], timeout: 0.5) + } + + // MARK: - Tests for storeAccount + + func testStoreAccount() async throws { + // Given + + let notificationExpectation = expectation(forNotification: .accountDidSignIn, object: accountManager, handler: nil) + + // When + accountManager.storeAccount(token: Constants.accessToken, email: Constants.email, externalID: Constants.externalID) + + // Then + XCTAssertEqual(accountManager.accessToken, Constants.accessToken) + XCTAssertEqual(accountManager.email, Constants.email) + XCTAssertEqual(accountManager.externalID, Constants.externalID) + + XCTAssertEqual(accessTokenStorage.accessToken, Constants.accessToken) + XCTAssertEqual(accountStorage.email, Constants.email) + XCTAssertEqual(accountStorage.externalID, Constants.externalID) + + await fulfillment(of: [notificationExpectation], timeout: 0.5) + } + + func testStoreAccountUpdatingEmailToNil() throws { + // When + accountManager.storeAccount(token: Constants.accessToken, email: Constants.email, externalID: Constants.externalID) + accountManager.storeAccount(token: Constants.accessToken, email: nil, externalID: Constants.externalID) + + // Then + XCTAssertEqual(accountManager.accessToken, Constants.accessToken) + XCTAssertEqual(accountManager.email, nil) + XCTAssertEqual(accountManager.externalID, Constants.externalID) + + XCTAssertEqual(accessTokenStorage.accessToken, Constants.accessToken) + XCTAssertEqual(accountStorage.email, nil) + XCTAssertEqual(accountStorage.externalID, Constants.externalID) + } + + // MARK: - Tests for signOut + + func testSignOut() async throws { + // Given + accountManager.storeAuthToken(token: Constants.authToken) + accountManager.storeAccount(token: Constants.accessToken, email: Constants.email, externalID: Constants.externalID) + + XCTAssertTrue(accountManager.isUserAuthenticated) + + let notificationExpectation = expectation(forNotification: .accountDidSignOut, object: accountManager, handler: nil) + + // When + accountManager.signOut() + + // Then + XCTAssertFalse(accountManager.isUserAuthenticated) + + XCTAssertTrue(accountStorage.clearAuthenticationStateCalled) + XCTAssertTrue(accessTokenStorage.removeAccessTokenCalled) + XCTAssertTrue(subscriptionService.signOutCalled) + XCTAssertNil(entitlementsCache.get()) + + await fulfillment(of: [notificationExpectation], timeout: 0.5) + } + + func testSignOutWithoutSendingNotification() async throws { + // Given + accountManager.storeAuthToken(token: Constants.authToken) + accountManager.storeAccount(token: Constants.accessToken, email: Constants.email, externalID: Constants.externalID) + + XCTAssertTrue(accountManager.isUserAuthenticated) + + let notificationExpectation = expectation(forNotification: .accountDidSignOut, object: accountManager, handler: nil) + notificationExpectation.isInverted = true + + // When + accountManager.signOut(skipNotification: true) + + // Then + XCTAssertFalse(accountManager.isUserAuthenticated) + await fulfillment(of: [notificationExpectation], timeout: 0.5) + } + + // MARK: - Tests for hasEntitlement + + func testHasEntitlementIgnoringLocalCacheData() async throws { + // Given + let productName = Entitlement.ProductName.networkProtection + + accessTokenStorage.accessToken = Constants.accessToken + entitlementsCache.set([]) + authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, + entitlements: Constants.entitlements, + externalID: Constants.externalID))) + XCTAssertTrue(Constants.entitlements.compactMap { $0.product }.contains(productName)) + + // When + let result = await accountManager.hasEntitlement(forProductName: productName, cachePolicy: .reloadIgnoringLocalCacheData) + + // Then + switch result { + case .success(let success): + XCTAssertTrue(success) + XCTAssertTrue(authService.validateTokenCalled) + XCTAssertEqual(entitlementsCache.get(), Constants.entitlements) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testHasEntitlementWithoutParameterUseCacheData() async throws { + // Given + let productName = Entitlement.ProductName.networkProtection + + accessTokenStorage.accessToken = Constants.accessToken + entitlementsCache.set(Constants.entitlements) + + XCTAssertTrue(Constants.entitlements.compactMap { $0.product }.contains(productName)) + + // When + let result = await accountManager.hasEntitlement(forProductName: productName) + + // Then + switch result { + case .success(let success): + XCTAssertTrue(success) + XCTAssertFalse(authService.validateTokenCalled) + case .failure: + XCTFail("Unexpected failure") + } + } + + // MARK: - Tests for updateCache + + func testUpdateEntitlementsCache() async throws { + // Given + let updatedEntitlements = [Entitlement(product: .networkProtection)] + XCTAssertNotEqual(Constants.entitlements, updatedEntitlements) + + entitlementsCache.set(Constants.entitlements) + + let notificationExpectation = expectation(forNotification: .entitlementsDidChange, object: accountManager, handler: nil) + + // When + accountManager.updateCache(with: updatedEntitlements) + + // Then + XCTAssertEqual(entitlementsCache.get(), updatedEntitlements) + await fulfillment(of: [notificationExpectation], timeout: 0.5) + } + + func testUpdateEntitlementsCacheWithEmptyArray() async throws { + // Given + entitlementsCache.set(Constants.entitlements) + + let notificationExpectation = expectation(forNotification: .entitlementsDidChange, object: accountManager, handler: nil) + + // When + accountManager.updateCache(with: []) + + // Then + XCTAssertNil(entitlementsCache.get()) + await fulfillment(of: [notificationExpectation], timeout: 0.5) + } + + func testUpdateEntitlementsCacheWithSameEntitlements() async throws { + // Given + entitlementsCache.set(Constants.entitlements) + + let notificationNotFiredExpectation = expectation(forNotification: .entitlementsDidChange, object: accountManager, handler: nil) + notificationNotFiredExpectation.isInverted = true + + // When + accountManager.updateCache(with: Constants.entitlements) + + // Then + XCTAssertEqual(entitlementsCache.get(), Constants.entitlements) + await fulfillment(of: [notificationNotFiredExpectation], timeout: 0.5) + } + + // MARK: - Tests for fetchEntitlements + + func testFetchEntitlementsIgnoringLocalCacheData() async throws { + // Given + accessTokenStorage.accessToken = Constants.accessToken + entitlementsCache.set([]) + authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, + entitlements: Constants.entitlements, + externalID: Constants.externalID))) + + // When + let result = await accountManager.fetchEntitlements(cachePolicy: .reloadIgnoringLocalCacheData) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success, Constants.entitlements) + XCTAssertTrue(authService.validateTokenCalled) + XCTAssertEqual(entitlementsCache.get(), Constants.entitlements) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testFetchEntitlementsReturnCachedData() async throws { + // Given + accessTokenStorage.accessToken = Constants.accessToken + entitlementsCache.set(Constants.entitlements) + + // When + let result = await accountManager.fetchEntitlements(cachePolicy: .returnCacheDataElseLoad) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success, Constants.entitlements) + XCTAssertFalse(authService.validateTokenCalled) + XCTAssertEqual(entitlementsCache.get(), Constants.entitlements) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testFetchEntitlementsReturnCachedDataWhenCacheIsExpired() async throws { + // Given + let updatedEntitlements = [Entitlement(product: .networkProtection)] + + accessTokenStorage.accessToken = Constants.accessToken + entitlementsCache.set(Constants.entitlements, expires: Date.distantPast) + authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, + entitlements: updatedEntitlements, + externalID: Constants.externalID))) + + XCTAssertNotEqual(Constants.entitlements, updatedEntitlements) + + // When + let result = await accountManager.fetchEntitlements(cachePolicy: .returnCacheDataElseLoad) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success, updatedEntitlements) + XCTAssertTrue(authService.validateTokenCalled) + XCTAssertEqual(entitlementsCache.get(), updatedEntitlements) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testFetchEntitlementsReturnCacheDataDontLoad() async throws { + // Given + accessTokenStorage.accessToken = Constants.accessToken + entitlementsCache.set(Constants.entitlements) + + // When + let result = await accountManager.fetchEntitlements(cachePolicy: .returnCacheDataDontLoad) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success, Constants.entitlements) + XCTAssertFalse(authService.validateTokenCalled) + XCTAssertEqual(entitlementsCache.get(), Constants.entitlements) + case .failure: + XCTFail("Unexpected failure") + } + } + + func testFetchEntitlementsReturnCacheDataDontLoadWhenCacheIsExpired() async throws { + // Given + accessTokenStorage.accessToken = Constants.accessToken + entitlementsCache.set(Constants.entitlements, expires: Date.distantPast) + + // When + let result = await accountManager.fetchEntitlements(cachePolicy: .returnCacheDataDontLoad) + + // Then + switch result { + case .success: + XCTFail("Unexpected success") + case .failure(let error): + guard let entitlementsError = error as? DefaultAccountManager.EntitlementsError else { + XCTFail("Incorrect error type") + return + } + + XCTAssertEqual(entitlementsError, .noCachedData) + } + } + + // MARK: - Tests for exchangeAuthTokenToAccessToken + + func testExchangeAuthTokenToAccessToken() async throws { + // Given + authService.getAccessTokenResult = .success(.init(accessToken: Constants.accessToken)) + + // When + let result = await accountManager.exchangeAuthTokenToAccessToken(Constants.authToken) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success, Constants.accessToken) + XCTAssertTrue(authService.getAccessTokenCalled) + case .failure: + XCTFail("Unexpected failure") + } + } + + // MARK: - Tests for fetchAccountDetails + + func testFetchAccountDetails() async throws { + // Given + authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, + entitlements: Constants.entitlements, + externalID: Constants.externalID))) + + // When + let result = await accountManager.fetchAccountDetails(with: Constants.accessToken) + + // Then + switch result { + case .success(let success): + XCTAssertEqual(success.email, Constants.email) + XCTAssertEqual(success.externalID, Constants.externalID) + XCTAssertTrue(authService.validateTokenCalled) + case .failure: + XCTFail("Unexpected failure") + } + } + + // MARK: - Tests for checkForEntitlements + + func testCheckForEntitlementsSuccess() async throws { + // Given + var callCount = 0 + + accessTokenStorage.accessToken = Constants.accessToken + + authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, + entitlements: Constants.entitlements, + externalID: Constants.externalID))) + authService.onValidateToken = { _ in + callCount += 1 + } + + // When + let result = await accountManager.checkForEntitlements(wait: 0.1, retry: 5) + + // Then + XCTAssertTrue(result) + XCTAssertTrue(authService.validateTokenCalled) + XCTAssertEqual(callCount, 1) + } + + func testCheckForEntitlementsFailure() async throws { + // Given + var callCount = 0 + + accessTokenStorage.accessToken = Constants.accessToken + + authService.validateTokenResult = .failure(Constants.unknownServerError) + authService.onValidateToken = { _ in + callCount += 1 + } + + // When + let result = await accountManager.checkForEntitlements(wait: 0.1, retry: 5) + + // Then + XCTAssertFalse(result) + XCTAssertTrue(authService.validateTokenCalled) + XCTAssertEqual(callCount, 5) + } + + func testCheckForEntitlementsSuccessAfterRetries() async throws { + // Given + var callCount = 0 + + accessTokenStorage.accessToken = Constants.accessToken + + authService.validateTokenResult = .failure(Constants.unknownServerError) + authService.onValidateToken = { _ in + callCount += 1 + + if callCount == 3 { + self.authService.validateTokenResult = .success(ValidateTokenResponse(account: ValidateTokenResponse.Account(email: Constants.email, + entitlements: Constants.entitlements, + externalID: Constants.externalID))) + } + } + + // When + let result = await accountManager.checkForEntitlements(wait: 0.1, retry: 5) + + // Then + XCTAssertTrue(result) + XCTAssertTrue(authService.validateTokenCalled) + XCTAssertEqual(callCount, 3) + } +} diff --git a/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift new file mode 100644 index 000000000..ef96f6b4b --- /dev/null +++ b/Tests/SubscriptionTests/Managers/StorePurchaseManagerTests.swift @@ -0,0 +1,540 @@ +// +// 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 + +final class StorePurchaseManagerTests: XCTestCase { + + private var sut: StorePurchaseManager! + private var mockCache: SubscriptionFeatureMappingCacheMock! + private var mockProductFetcher: MockProductFetcher! + private var mockFeatureFlagger: MockFeatureFlagger! + + override func setUpWithError() throws { + mockCache = SubscriptionFeatureMappingCacheMock() + mockProductFetcher = MockProductFetcher() + mockFeatureFlagger = MockFeatureFlagger() + sut = DefaultStorePurchaseManager(subscriptionFeatureMappingCache: mockCache, + subscriptionFeatureFlagger: mockFeatureFlagger, + productFetcher: mockProductFetcher) + } + + func testSubscriptionOptionsReturnsOnlyNonTrialProducts() async { + // Given + let monthlyProduct = MockSubscriptionProduct( + id: "com.test.monthly", + displayName: "Monthly Plan", + displayPrice: "$9.99", + isMonthly: true, + isFreeTrialProduct: false + ) + + let yearlyProduct = MockSubscriptionProduct( + id: "com.test.yearly", + displayName: "Yearly Plan", + displayPrice: "$99.99", + isYearly: true, + isFreeTrialProduct: false + ) + + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + isFreeTrialProduct: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ) + ) + + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct, monthlyTrialProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.subscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + XCTAssertEqual(subscriptionOptions?.options.count, 2) + + let productIds = subscriptionOptions?.options.map { $0.id } ?? [] + XCTAssertTrue(productIds.contains("com.test.monthly")) + XCTAssertTrue(productIds.contains("com.test.yearly")) + XCTAssertFalse(productIds.contains("com.test.monthly.trial")) + } + + func testFreeTrialSubscriptionOptionsReturnsOnlyTrialProducts() async { + // Given + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + isFreeTrialProduct: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + let yearlyTrialProduct = MockSubscriptionProduct( + id: "com.test.yearly.trial", + displayName: "Yearly Plan with Trial", + displayPrice: "$99.99", + isYearly: true, + isFreeTrialProduct: true, + introOffer: MockIntroductoryOffer( + id: "trial2", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + let regularProduct = MockSubscriptionProduct( + id: "com.test.regular", + displayName: "Regular Plan", + displayPrice: "$9.99", + isMonthly: true, + isFreeTrialProduct: false + ) + + mockProductFetcher.mockProducts = [monthlyTrialProduct, yearlyTrialProduct, regularProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.freeTrialSubscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + XCTAssertEqual(subscriptionOptions?.options.count, 2) + + let productIds = subscriptionOptions?.options.map { $0.id } ?? [] + XCTAssertTrue(productIds.contains("com.test.monthly.trial")) + XCTAssertTrue(productIds.contains("com.test.yearly.trial")) + XCTAssertFalse(productIds.contains("com.test.regular")) + } + + func testSubscriptionOptionsReturnsNilWhenNoValidProductPairExists() async { + // Given + let monthlyProduct = MockSubscriptionProduct( + id: "com.test.monthly", + displayName: "Monthly Plan", + displayPrice: "$9.99", + isMonthly: true, + isFreeTrialProduct: false + ) + + mockProductFetcher.mockProducts = [monthlyProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.subscriptionOptions() + + // Then + XCTAssertNil(subscriptionOptions) + } + + func testFreeTrialSubscriptionOptionsReturnsNilWhenNoValidProductPairExists() async { + // Given + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + isFreeTrialProduct: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ) + ) + + mockProductFetcher.mockProducts = [monthlyTrialProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.freeTrialSubscriptionOptions() + + // Then + XCTAssertNil(subscriptionOptions) + } + + func testSubscriptionOptionsIncludesCorrectDetails() async { + // Given + let monthlyProduct = MockSubscriptionProduct( + id: "com.test.monthly", + displayName: "Monthly Plan", + displayPrice: "$9.99", + isMonthly: true, + isFreeTrialProduct: false + ) + + let yearlyProduct = MockSubscriptionProduct( + id: "com.test.yearly", + displayName: "Yearly Plan", + displayPrice: "$99.99", + isYearly: true, + isFreeTrialProduct: false + ) + + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.subscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + XCTAssertEqual(subscriptionOptions?.options.count, 2) + + let monthlyOption = subscriptionOptions?.options.first { $0.id == "com.test.monthly" } + XCTAssertNotNil(monthlyOption) + XCTAssertEqual(monthlyOption?.cost.displayPrice, "$9.99") + XCTAssertEqual(monthlyOption?.cost.recurrence, "monthly") + XCTAssertNil(monthlyOption?.offer) + + let yearlyOption = subscriptionOptions?.options.first { $0.id == "com.test.yearly" } + XCTAssertNotNil(yearlyOption) + XCTAssertEqual(yearlyOption?.cost.displayPrice, "$99.99") + XCTAssertEqual(yearlyOption?.cost.recurrence, "yearly") + XCTAssertNil(yearlyOption?.offer) + } + + func testFreeTrialSubscriptionOptionsIncludesCorrectTrialDetails() async { + // Given + let monthlyTrialProduct = MockSubscriptionProduct( + id: "com.test.monthly.trial", + displayName: "Monthly Plan with Trial", + displayPrice: "$9.99", + isMonthly: true, + isFreeTrialProduct: true, + introOffer: MockIntroductoryOffer( + id: "trial1", + displayPrice: "$0.00", + periodInDays: 7, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + let yearlyTrialProduct = MockSubscriptionProduct( + id: "com.test.yearly.trial", + displayName: "Yearly Plan with Trial", + displayPrice: "$99.99", + isYearly: true, + isFreeTrialProduct: true, + introOffer: MockIntroductoryOffer( + id: "trial2", + displayPrice: "$0.00", + periodInDays: 14, + isFreeTrial: true + ), + isEligibleForIntroOffer: true + ) + + mockProductFetcher.mockProducts = [monthlyTrialProduct, yearlyTrialProduct] + await sut.updateAvailableProducts() + + // When + let subscriptionOptions = await sut.freeTrialSubscriptionOptions() + + // Then + XCTAssertNotNil(subscriptionOptions) + + let monthlyOption = subscriptionOptions?.options.first { $0.id == "com.test.monthly.trial" } + XCTAssertNotNil(monthlyOption) + XCTAssertNotNil(monthlyOption?.offer) + XCTAssertEqual(monthlyOption?.offer?.type, .freeTrial) + XCTAssertEqual(monthlyOption?.offer?.durationInDays, 7) + XCTAssertTrue(monthlyOption?.offer?.isUserEligible ?? false) + + let yearlyOption = subscriptionOptions?.options.first { $0.id == "com.test.yearly.trial" } + XCTAssertNotNil(yearlyOption) + XCTAssertNotNil(yearlyOption?.offer) + XCTAssertEqual(yearlyOption?.offer?.type, .freeTrial) + XCTAssertEqual(yearlyOption?.offer?.durationInDays, 14) + XCTAssertTrue(yearlyOption?.offer?.isUserEligible ?? false) + } + + func testUpdateAvailableProductsSuccessfully() async { + // Given + let monthlyProduct = createMonthlyProduct() + let yearlyProduct = createYearlyProduct() + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct] + + // When + await sut.updateAvailableProducts() + + // Then + let products = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertEqual(products.count, 2) + XCTAssertTrue(products.contains(where: { $0.id == monthlyProduct.id })) + XCTAssertTrue(products.contains(where: { $0.id == yearlyProduct.id })) + } + + func testUpdateAvailableProductsWithError() async { + // Given + mockProductFetcher.fetchError = MockProductError.fetchFailed + + // When + await sut.updateAvailableProducts() + + // Then + let products = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertTrue(products.isEmpty) + } + + func testUpdateAvailableProductsWithDifferentRegions() async { + // Given + let usaMonthlyProduct = MockSubscriptionProduct( + id: "com.test.usa.monthly", + displayName: "USA Monthly Plan", + displayPrice: "$9.99", + isMonthly: true + ) + let usaYearlyProduct = MockSubscriptionProduct( + id: "com.test.usa.yearly", + displayName: "USA Yearly Plan", + displayPrice: "$99.99", + isYearly: true + ) + + let rowMonthlyProduct = MockSubscriptionProduct( + id: "com.test.row.monthly", + displayName: "ROW Monthly Plan", + displayPrice: "€8.99", + isMonthly: true + ) + let rowYearlyProduct = MockSubscriptionProduct( + id: "com.test.row.yearly", + displayName: "ROW Yearly Plan", + displayPrice: "€89.99", + isYearly: true + ) + + // Set USA products initially + mockProductFetcher.mockProducts = [usaMonthlyProduct, usaYearlyProduct] + mockFeatureFlagger.enabledFeatures = [] // No ROW features enabled - defaults to USA + + // When - Update for USA region + await sut.updateAvailableProducts() + + // Then - Verify USA products + let usaProducts = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertEqual(usaProducts.count, 2) + XCTAssertEqual((sut as? DefaultStorePurchaseManager)?.currentStorefrontRegion, .usa) + XCTAssertTrue(usaProducts.contains(where: { $0.id == "com.test.usa.monthly" })) + XCTAssertTrue(usaProducts.contains(where: { $0.id == "com.test.usa.yearly" })) + + // When - Switch to ROW region + mockProductFetcher.mockProducts = [rowMonthlyProduct, rowYearlyProduct] + mockFeatureFlagger.enabledFeatures = [.usePrivacyProROWRegionOverride] + await sut.updateAvailableProducts() + + // Then - Verify ROW products + let rowProducts = (sut as? DefaultStorePurchaseManager)?.availableProducts ?? [] + XCTAssertEqual(rowProducts.count, 2) + XCTAssertEqual((sut as? DefaultStorePurchaseManager)?.currentStorefrontRegion, .restOfWorld) + XCTAssertTrue(rowProducts.contains(where: { $0.id == "com.test.row.monthly" })) + XCTAssertTrue(rowProducts.contains(where: { $0.id == "com.test.row.yearly" })) + + // Verify pricing differences + let usaMonthlyPrice = usaProducts.first(where: { $0.isMonthly })?.displayPrice + let rowMonthlyPrice = rowProducts.first(where: { $0.isMonthly })?.displayPrice + XCTAssertEqual(usaMonthlyPrice, "$9.99") + XCTAssertEqual(rowMonthlyPrice, "€8.99") + } + + func testUpdateAvailableProductsUpdatesFeatureMapping() async { + // Given + let monthlyProduct = createMonthlyProduct() + let yearlyProduct = createYearlyProduct() + mockProductFetcher.mockProducts = [monthlyProduct, yearlyProduct] + + // When + await sut.updateAvailableProducts() + + // Then + XCTAssertTrue(mockCache.didCallSubscriptionFeatures) + XCTAssertEqual(mockCache.lastCalledSubscriptionId, yearlyProduct.id) + } +} + +private final class MockProductFetcher: ProductFetching { + var mockProducts: [any SubscriptionProduct] = [] + var fetchError: Error? + var fetchCount: Int = 0 + + public func products(for identifiers: [String]) async throws -> [any SubscriptionProduct] { + fetchCount += 1 + if let error = fetchError { + throw error + } + return mockProducts + } +} + +private enum MockProductError: Error { + case fetchFailed +} + +private extension StorePurchaseManagerTests { + func createMonthlyProduct(withTrial: Bool = false) -> MockSubscriptionProduct { + MockSubscriptionProduct( + id: "com.test.monthly\(withTrial ? ".trial" : "")", + displayName: "Monthly Plan\(withTrial ? " with Trial" : "")", + displayPrice: "$9.99", + isMonthly: true, + isFreeTrialProduct: withTrial, + introOffer: withTrial ? MockIntroductoryOffer( + id: "trial1", + displayPrice: "Free", + periodInDays: 7, + isFreeTrial: true + ) : nil, + isEligibleForIntroOffer: withTrial + ) + } + + func createYearlyProduct(withTrial: Bool = false) -> MockSubscriptionProduct { + MockSubscriptionProduct( + id: "com.test.yearly\(withTrial ? ".trial" : "")", + displayName: "Yearly Plan\(withTrial ? " with Trial" : "")", + displayPrice: "$99.99", + isYearly: true, + isFreeTrialProduct: withTrial, + introOffer: withTrial ? MockIntroductoryOffer( + id: "trial2", + displayPrice: "Free", + periodInDays: 14, + isFreeTrial: true + ) : nil, + isEligibleForIntroOffer: withTrial + ) + } +} + +private class MockSubscriptionProduct: SubscriptionProduct { + let id: String + let displayName: String + let displayPrice: String + let description: String + let isMonthly: Bool + let isYearly: Bool + let isFreeTrialProduct: Bool + private let mockIntroOffer: MockIntroductoryOffer? + private let mockIsEligibleForIntroOffer: Bool + + init(id: String, + displayName: String = "Mock Product", + displayPrice: String = "$4.99", + description: String = "Mock Description", + isMonthly: Bool = false, + isYearly: Bool = false, + isFreeTrialProduct: Bool = false, + introOffer: MockIntroductoryOffer? = nil, + isEligibleForIntroOffer: Bool = false) { + self.id = id + self.displayName = displayName + self.displayPrice = displayPrice + self.description = description + self.isMonthly = isMonthly + self.isYearly = isYearly + self.isFreeTrialProduct = isFreeTrialProduct + self.mockIntroOffer = introOffer + self.mockIsEligibleForIntroOffer = isEligibleForIntroOffer + } + + var introductoryOffer: SubscriptionProductIntroductoryOffer? { + return mockIntroOffer + } + + var isEligibleForIntroOffer: Bool { + get async { + return mockIsEligibleForIntroOffer + } + } + + func purchase(options: Set) async throws -> Product.PurchaseResult { + fatalError("Not implemented for tests") + } + + static func == (lhs: MockSubscriptionProduct, rhs: MockSubscriptionProduct) -> Bool { + return lhs.id == rhs.id + } +} + +private struct MockIntroductoryOffer: SubscriptionProductIntroductoryOffer { + var id: String? + var displayPrice: String + var periodInDays: Int + var isFreeTrial: Bool +} + +private class MockFeatureFlagger: FeatureFlaggerMapping { + var enabledFeatures: Set = [] + + init(enabledFeatures: Set = []) { + self.enabledFeatures = enabledFeatures + super.init(mapping: {_ in true}) + } + + override func isFeatureOn(_ feature: SubscriptionFeatureFlags) -> Bool { + return enabledFeatures.contains(feature) + } +} + +private class MockStoreSubscriptionConfiguration: StoreSubscriptionConfiguration { + let usaIdentifiers = ["com.test.usa.monthly", "com.test.usa.yearly"] + let rowIdentifiers = ["com.test.row.monthly", "com.test.row.yearly"] + + var allSubscriptionIdentifiers: [String] { + usaIdentifiers + rowIdentifiers + } + + func subscriptionIdentifiers(for region: SubscriptionRegion) -> [String] { + switch region { + case .usa: + return usaIdentifiers + case .restOfWorld: + return rowIdentifiers + } + } + + func subscriptionIdentifiers(for country: String) -> [String] { + switch country.uppercased() { + case "USA": + return usaIdentifiers + default: + return rowIdentifiers + } + } +} diff --git a/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift new file mode 100644 index 000000000..ac4985fcf --- /dev/null +++ b/Tests/SubscriptionTests/Managers/SubscriptionManagerTests.swift @@ -0,0 +1,235 @@ +// +// 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 { + + private struct Constants { + static let userDefaultsSuiteName = "SubscriptionManagerTests" + + static let accessToken = UUID().uuidString + + static let invalidTokenError = APIServiceError.serverError(statusCode: 401, error: "invalid_token") + } + + var storePurchaseManager: StorePurchaseManagerMock! + var accountManager: AccountManagerMock! + var subscriptionService: SubscriptionEndpointServiceMock! + var authService: AuthEndpointServiceMock! + var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock! + var subscriptionEnvironment: SubscriptionEnvironment! + + var subscriptionManager: SubscriptionManager! + + override func setUpWithError() throws { + storePurchaseManager = StorePurchaseManagerMock() + accountManager = AccountManagerMock() + subscriptionService = SubscriptionEndpointServiceMock() + authService = AuthEndpointServiceMock() + subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() + subscriptionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, + purchasePlatform: .appStore) + + subscriptionManager = DefaultSubscriptionManager(storePurchaseManager: storePurchaseManager, + accountManager: accountManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService, + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: subscriptionEnvironment) + + } + + override func tearDownWithError() throws { + storePurchaseManager = nil + accountManager = nil + subscriptionService = nil + authService = nil + subscriptionEnvironment = nil + + subscriptionManager = nil + } + + // MARK: - Tests for save and loadEnvironmentFrom + + func testLoadEnvironmentFromUserDefaults() async throws { + // Given + let userDefaults = UserDefaults(suiteName: Constants.userDefaultsSuiteName)! + userDefaults.removePersistentDomain(forName: Constants.userDefaultsSuiteName) + + var loadedEnvironment = DefaultSubscriptionManager.loadEnvironmentFrom(userDefaults: userDefaults) + XCTAssertNil(loadedEnvironment) + + // When + DefaultSubscriptionManager.save(subscriptionEnvironment: subscriptionEnvironment, + userDefaults: userDefaults) + loadedEnvironment = DefaultSubscriptionManager.loadEnvironmentFrom(userDefaults: userDefaults) + + // Then + XCTAssertEqual(loadedEnvironment?.serviceEnvironment, subscriptionEnvironment.serviceEnvironment) + XCTAssertEqual(loadedEnvironment?.purchasePlatform, subscriptionEnvironment.purchasePlatform) + } + + // MARK: - Tests for setup for App Store + + func testSetupForAppStore() async throws { + // Given + storePurchaseManager.onUpdateAvailableProducts = { + self.storePurchaseManager.areProductsAvailable = true + } + + // When + // triggered on DefaultSubscriptionManager's init + try await Task.sleep(seconds: 0.5) + + // Then + XCTAssertTrue(storePurchaseManager.updateAvailableProductsCalled) + XCTAssertTrue(subscriptionManager.canPurchase) + } + + // MARK: - Tests for loadInitialData + + func testLoadInitialData() async throws { + // Given + accountManager.accessToken = Constants.accessToken + + subscriptionService.onGetSubscription = { _, cachePolicy in + XCTAssertEqual(cachePolicy, .reloadIgnoringLocalCacheData) + } + subscriptionService.getSubscriptionResult = .success(SubscriptionMockFactory.subscription) + + accountManager.onFetchEntitlements = { cachePolicy in + XCTAssertEqual(cachePolicy, .reloadIgnoringLocalCacheData) + } + + // When + subscriptionManager.loadInitialData() + + try await Task.sleep(seconds: 0.5) + + // Then + XCTAssertTrue(subscriptionService.getSubscriptionCalled) + XCTAssertTrue(accountManager.fetchEntitlementsCalled) + } + + func testLoadInitialDataNotCalledWhenUnauthenticated() async throws { + // Given + XCTAssertNil(accountManager.accessToken) + XCTAssertFalse(accountManager.isUserAuthenticated) + + // When + subscriptionManager.loadInitialData() + + // Then + XCTAssertFalse(subscriptionService.getSubscriptionCalled) + XCTAssertFalse(accountManager.fetchEntitlementsCalled) + } + + // MARK: - Tests for refreshCachedSubscriptionAndEntitlements + + func testForRefreshCachedSubscriptionAndEntitlements() async throws { + // Given + let subscription = SubscriptionMockFactory.subscription + + accountManager.accessToken = Constants.accessToken + + subscriptionService.onGetSubscription = { _, cachePolicy in + XCTAssertEqual(cachePolicy, .reloadIgnoringLocalCacheData) + } + subscriptionService.getSubscriptionResult = .success(subscription) + + accountManager.onFetchEntitlements = { cachePolicy in + XCTAssertEqual(cachePolicy, .reloadIgnoringLocalCacheData) + } + + // When + let completionCalled = expectation(description: "completion called") + subscriptionManager.refreshCachedSubscriptionAndEntitlements { isSubscriptionActive in + completionCalled.fulfill() + XCTAssertEqual(isSubscriptionActive, subscription.isActive) + } + + // Then + await fulfillment(of: [completionCalled], timeout: 0.5) + XCTAssertTrue(subscriptionService.getSubscriptionCalled) + XCTAssertTrue(accountManager.fetchEntitlementsCalled) + } + + func testForRefreshCachedSubscriptionAndEntitlementsSignOutUserOn401() async throws { + // Given + accountManager.accessToken = Constants.accessToken + + subscriptionService.onGetSubscription = { _, cachePolicy in + XCTAssertEqual(cachePolicy, .reloadIgnoringLocalCacheData) + } + subscriptionService.getSubscriptionResult = .failure(.apiError(Constants.invalidTokenError)) + + // When + let completionCalled = expectation(description: "completion called") + subscriptionManager.refreshCachedSubscriptionAndEntitlements { isSubscriptionActive in + completionCalled.fulfill() + XCTAssertFalse(isSubscriptionActive) + } + + // Then + await fulfillment(of: [completionCalled], timeout: 0.5) + XCTAssertTrue(accountManager.signOutCalled) + XCTAssertTrue(subscriptionService.getSubscriptionCalled) + XCTAssertFalse(accountManager.fetchEntitlementsCalled) + } + + // MARK: - Tests for url + + func testForProductionURL() throws { + // Given + let productionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, purchasePlatform: .appStore) + + let productionSubscriptionManager = DefaultSubscriptionManager(storePurchaseManager: storePurchaseManager, + accountManager: accountManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService, + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: productionEnvironment) + + // When + let productionPurchaseURL = productionSubscriptionManager.url(for: .purchase) + + // Then + XCTAssertEqual(productionPurchaseURL, SubscriptionURL.purchase.subscriptionURL(environment: .production)) + } + + func testForStagingURL() throws { + // Given + let stagingEnvironment = SubscriptionEnvironment(serviceEnvironment: .staging, purchasePlatform: .appStore) + + let stagingSubscriptionManager = DefaultSubscriptionManager(storePurchaseManager: storePurchaseManager, + accountManager: accountManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService, + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache, + subscriptionEnvironment: stagingEnvironment) + + // When + let stagingPurchaseURL = stagingSubscriptionManager.url(for: .purchase) + + // Then + XCTAssertEqual(stagingPurchaseURL, SubscriptionURL.purchase.subscriptionURL(environment: .staging)) + } +} diff --git a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift new file mode 100644 index 000000000..2a6a9d3d8 --- /dev/null +++ b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift @@ -0,0 +1,249 @@ +// +// SubscriptionCookieManagerTests.swift +// +// Copyright © 2024 DuckDuckGo. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Common +@testable import Subscription +import SubscriptionTestingUtilities + +final class SubscriptionCookieManagerTests: XCTestCase { + + private struct Constants { + static let authToken = UUID().uuidString + static let accessToken = UUID().uuidString + } + + var accountManager: AccountManagerMock! + var subscriptionService: SubscriptionEndpointServiceMock! + var authService: AuthEndpointServiceMock! + var storePurchaseManager: StorePurchaseManagerMock! + var subscriptionEnvironment: SubscriptionEnvironment! + var subscriptionFeatureMappingCache: SubscriptionFeatureMappingCacheMock! + var subscriptionManager: SubscriptionManagerMock! + + var cookieStore: HTTPCookieStore! + var subscriptionCookieManager: SubscriptionCookieManager! + + override func setUp() async throws { + accountManager = AccountManagerMock() + subscriptionService = SubscriptionEndpointServiceMock() + authService = AuthEndpointServiceMock() + storePurchaseManager = StorePurchaseManagerMock() + subscriptionEnvironment = SubscriptionEnvironment(serviceEnvironment: .production, + purchasePlatform: .appStore) + subscriptionFeatureMappingCache = SubscriptionFeatureMappingCacheMock() + + subscriptionManager = SubscriptionManagerMock(accountManager: accountManager, + subscriptionEndpointService: subscriptionService, + authEndpointService: authService, + storePurchaseManager: storePurchaseManager, + currentEnvironment: subscriptionEnvironment, + canPurchase: true, + subscriptionFeatureMappingCache: subscriptionFeatureMappingCache) + cookieStore = MockHTTPCookieStore() + + subscriptionCookieManager = SubscriptionCookieManager(subscriptionManager: subscriptionManager, + currentCookieStore: { self.cookieStore }, + eventMapping: MockSubscriptionCookieManageEventPixelMapping(), + refreshTimeInterval: .seconds(1)) + } + + override func tearDown() async throws { + accountManager = nil + subscriptionService = nil + authService = nil + storePurchaseManager = nil + subscriptionEnvironment = nil + + subscriptionManager = nil + } + + func testSubscriptionCookieIsAddedWhenSigningInToSubscription() async throws { + // Given + await ensureNoSubscriptionCookieInTheCookieStore() + accountManager.accessToken = Constants.accessToken + + // When + subscriptionCookieManager.enableSettingSubscriptionCookie() + NotificationCenter.default.post(name: .accountDidSignIn, object: self, userInfo: nil) + try await Task.sleep(seconds: 0.1) + + // Then + await checkSubscriptionCookieIsPresent() + } + + func testSubscriptionCookieIsDeletedWhenSigningInToSubscription() async throws { + // Given + await ensureSubscriptionCookieIsInTheCookieStore() + + // When + subscriptionCookieManager.enableSettingSubscriptionCookie() + NotificationCenter.default.post(name: .accountDidSignOut, object: self, userInfo: nil) + try await Task.sleep(seconds: 0.1) + + // Then + await checkSubscriptionCookieIsHasEmptyValue() + } + + func testRefreshWhenSignedInButCookieIsMissing() async throws { + // Given + accountManager.accessToken = Constants.accessToken + await ensureNoSubscriptionCookieInTheCookieStore() + + // When + subscriptionCookieManager.enableSettingSubscriptionCookie() + await subscriptionCookieManager.refreshSubscriptionCookie() + try await Task.sleep(seconds: 0.1) + + // Then + await checkSubscriptionCookieIsPresent() + } + + func testRefreshWhenSignedOutButCookieIsPresent() async throws { + // Given + accountManager.accessToken = nil + await ensureSubscriptionCookieIsInTheCookieStore() + + // When + subscriptionCookieManager.enableSettingSubscriptionCookie() + await subscriptionCookieManager.refreshSubscriptionCookie() + try await Task.sleep(seconds: 0.1) + + // Then + await checkSubscriptionCookieIsHasEmptyValue() + } + + func testRefreshNotTriggeredTwiceWithinSetRefreshInterval() async throws { + // Given + let firstRefreshDate: Date? + let secondRefreshDate: Date? + + // When + subscriptionCookieManager.enableSettingSubscriptionCookie() + await subscriptionCookieManager.refreshSubscriptionCookie() + firstRefreshDate = subscriptionCookieManager.lastRefreshDate + + try await Task.sleep(seconds: 0.5) + + await subscriptionCookieManager.refreshSubscriptionCookie() + secondRefreshDate = subscriptionCookieManager.lastRefreshDate + + // Then + XCTAssertEqual(firstRefreshDate!, secondRefreshDate!) + } + + func testRefreshNotTriggeredSecondTimeAfterSetRefreshInterval() async throws { + // Given + let firstRefreshDate: Date? + let secondRefreshDate: Date? + + // When + subscriptionCookieManager.enableSettingSubscriptionCookie() + await subscriptionCookieManager.refreshSubscriptionCookie() + firstRefreshDate = subscriptionCookieManager.lastRefreshDate + + try await Task.sleep(seconds: 1.1) + + await subscriptionCookieManager.refreshSubscriptionCookie() + secondRefreshDate = subscriptionCookieManager.lastRefreshDate + + // Then + XCTAssertTrue(firstRefreshDate! < secondRefreshDate!) + } + + private func ensureSubscriptionCookieIsInTheCookieStore() async { + let subscriptionCookie = HTTPCookie(properties: [ + .domain: SubscriptionCookieManager.cookieDomain, + .path: "/", + .expires: Date().addingTimeInterval(.days(365)), + .name: SubscriptionCookieManager.cookieName, + .value: Constants.accessToken, + .secure: true, + .init(rawValue: "HttpOnly"): true + ])! + await cookieStore.setCookie(subscriptionCookie) + + let cookieStoreCookies = await cookieStore.allCookies() + XCTAssertEqual(cookieStoreCookies.count, 1) + } + + private func ensureNoSubscriptionCookieInTheCookieStore() async { + let cookieStoreCookies = await cookieStore.allCookies() + XCTAssertTrue(cookieStoreCookies.isEmpty) + } + + private func checkSubscriptionCookieIsPresent() async { + guard let subscriptionCookie = await cookieStore.fetchSubscriptionCookie() else { + XCTFail("No subscription cookie in the store") + return + } + XCTAssertEqual(subscriptionCookie.value, Constants.accessToken) + } + + private func checkSubscriptionCookieIsHasEmptyValue() async { + guard let subscriptionCookie = await cookieStore.fetchSubscriptionCookie() else { + XCTFail("No subscription cookie in the store") + return + } + XCTAssertEqual(subscriptionCookie.value, "") + } + +} + +private extension HTTPCookieStore { + + func fetchSubscriptionCookie() async -> HTTPCookie? { + await allCookies().first { $0.domain == SubscriptionCookieManager.cookieDomain && $0.name == SubscriptionCookieManager.cookieName } + } +} + +class MockHTTPCookieStore: HTTPCookieStore { + + var cookies: [HTTPCookie] + + init(cookies: [HTTPCookie] = []) { + self.cookies = cookies + } + + func allCookies() async -> [HTTPCookie] { + return cookies + } + + func setCookie(_ cookie: HTTPCookie) async { + cookies.removeAll { $0.domain == cookie.domain } + cookies.append(cookie) + } + + func deleteCookie(_ cookie: HTTPCookie) async { + cookies.removeAll { $0.domain == cookie.domain } + } + +} + +class MockSubscriptionCookieManageEventPixelMapping: EventMapping { + + public init() { + super.init { event, _, _, _ in + + } + } + + override init(mapping: @escaping EventMapping.Mapping) { + fatalError("Use init()") + } +} From fc3151483a28cfbaf7dd4e764818d128401eee38 Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 14:55:57 +0000 Subject: [PATCH 25/28] lint --- .../Flows/AppStorePurchaseFlowV2Tests.swift | 4 +- .../Flows/AppStoreRestoreFlowV2Tests.swift | 2 +- .../Flows/StripePurchaseFlowV2Tests.swift | 242 +----------------- 3 files changed, 8 insertions(+), 240 deletions(-) diff --git a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowV2Tests.swift b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowV2Tests.swift index fa8f6b024..5739c1d5e 100644 --- a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowV2Tests.swift +++ b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowV2Tests.swift @@ -1,5 +1,5 @@ // -// DefaultAppStorePurchaseFlowV2Tests.swift +// AppStorePurchaseFlowV2Tests.swift // // Copyright © 2024 DuckDuckGo. All rights reserved. // @@ -23,7 +23,7 @@ import SubscriptionTestingUtilities import NetworkingTestingUtils @available(macOS 12.0, iOS 15.0, *) -final class DefaultAppStorePurchaseFlowV2Tests: XCTestCase { +final class AppStorePurchaseFlowV2Tests: XCTestCase { private var sut: DefaultAppStorePurchaseFlowV2! private var subscriptionManagerMock: SubscriptionManagerMockV2! diff --git a/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowV2Tests.swift b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowV2Tests.swift index 33da73e1f..07df67da0 100644 --- a/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowV2Tests.swift +++ b/Tests/SubscriptionTests/Flows/AppStoreRestoreFlowV2Tests.swift @@ -23,7 +23,7 @@ import SubscriptionTestingUtilities import NetworkingTestingUtils @available(macOS 12.0, iOS 15.0, *) -final class DefaultAppStoreRestoreV2FlowTests: XCTestCase { +final class AppStoreRestoreFlowV2Tests: XCTestCase { private var sut: DefaultAppStoreRestoreFlowV2! private var subscriptionManagerMock: SubscriptionManagerMockV2! diff --git a/Tests/SubscriptionTests/Flows/StripePurchaseFlowV2Tests.swift b/Tests/SubscriptionTests/Flows/StripePurchaseFlowV2Tests.swift index 3d1bb7ded..ecd8492d3 100644 --- a/Tests/SubscriptionTests/Flows/StripePurchaseFlowV2Tests.swift +++ b/Tests/SubscriptionTests/Flows/StripePurchaseFlowV2Tests.swift @@ -15,242 +15,10 @@ // See the License for the specific language governing permissions and // limitations under the License. // -/* - import XCTest - @testable import Subscription - import SubscriptionTestingUtilities +import XCTest +@testable import Subscription +import SubscriptionTestingUtilities - final class StripePurchaseFlowV2Tests: XCTestCase { +final class StripePurchaseFlowV2Tests: XCTestCase { - private struct Constants { - static let authToken = UUID().uuidString - static let accessToken = UUID().uuidString - static let externalID = UUID().uuidString - static let email = "dax@duck.com" - - static let unknownServerError = APIServiceError.serverError(statusCode: 401, error: "unknown_error") - } - - var accountManager: AccountManagerMock! - var subscriptionService: SubscriptionEndpointServiceMockV2! - var authEndpointService: AuthEndpointServiceMock! - - var stripePurchaseFlow: StripePurchaseFlowV2! - - override func setUpWithError() throws { - accountManager = AccountManagerMock() - subscriptionService = SubscriptionEndpointServiceMockV2() - authEndpointService = AuthEndpointServiceMock() - - stripePurchaseFlow = DefaultStripePurchaseFlowV2(subscriptionEndpointService: subscriptionService, - authEndpointService: authEndpointService, - accountManager: accountManager) - } - - override func tearDownWithError() throws { - accountManager = nil - subscriptionService = nil - authEndpointService = nil - - stripePurchaseFlow = nil - } - - // MARK: - Tests for subscriptionOptions - - func testSubscriptionOptionsSuccess() async throws { - // Given - subscriptionService .getProductsResult = .success(SubscriptionMockFactory.productsItems) - - // When - let result = await stripePurchaseFlow.subscriptionOptions() - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success.platform, SubscriptionPlatformName.stripe) - XCTAssertEqual(success.options.count, SubscriptionMockFactory.productsItems.count) - XCTAssertEqual(success.features.count, 3) - let allFeatures = [Entitlement.ProductName.networkProtection, Entitlement.ProductName.dataBrokerProtection, Entitlement.ProductName.identityTheftRestoration] - let allNames = success.features.compactMap({ feature in feature.name}) - - for feature in allFeatures { - XCTAssertTrue(allNames.contains(feature)) - } - case .failure(let error): - XCTFail("Unexpected failure: \(error)") - } - } - - func testSubscriptionOptionsErrorWhenNoProductsAreFetched() async throws { - // Given - subscriptionService.getProductsResult = .failure(.unknownServerError) - - // When - let result = await stripePurchaseFlow.subscriptionOptions() - - // Then - switch result { - case .success: - XCTFail("Unexpected success") - case .failure(let error): - XCTAssertEqual(error, .noProductsFound) - } - } - - // MARK: - Tests for prepareSubscriptionPurchase - - func testPrepareSubscriptionPurchaseSuccess() async throws { - // Given - authEndpointService.createAccountResult = .success(CreateAccountResponse(authToken: Constants.authToken, - externalID: Constants.externalID, - status: "created")) - XCTAssertFalse(accountManager.isUserAuthenticated) - - // When - let result = await stripePurchaseFlow.prepareSubscriptionPurchase(emailAccessToken: nil) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success.type, "redirect") - XCTAssertEqual(success.token, Constants.authToken) - - XCTAssertTrue(authEndpointService.createAccountCalled) - XCTAssertEqual(accountManager.authToken, Constants.authToken) - case .failure(let error): - XCTFail("Unexpected failure: \(error)") - } - } - - func testPrepareSubscriptionPurchaseSuccessWhenSignedInAndSubscriptionExpired() async throws { - // Given - let subscription = SubscriptionMockFactory.expiredSubscription - - accountManager.accessToken = Constants.accessToken - - subscriptionService.getSubscriptionResult = .success(subscription) - subscriptionService.getProductsResult = .success(SubscriptionMockFactory.productsItems) - - XCTAssertTrue(accountManager.isUserAuthenticated) - XCTAssertFalse(subscription.isActive) - - // When - let result = await stripePurchaseFlow.prepareSubscriptionPurchase(emailAccessToken: nil) - - // Then - switch result { - case .success(let success): - XCTAssertEqual(success.type, "redirect") - XCTAssertEqual(success.token, Constants.accessToken) - - XCTAssertTrue(subscriptionService.signOutCalled) - XCTAssertFalse(authEndpointService.createAccountCalled) - case .failure(let error): - XCTFail("Unexpected failure: \(error)") - } - } - - func testPrepareSubscriptionPurchaseErrorWhenAccountCreationFailed() async throws { - // Given - authEndpointService.createAccountResult = .failure(Constants.unknownServerError) - XCTAssertFalse(accountManager.isUserAuthenticated) - - // When - let result = await stripePurchaseFlow.prepareSubscriptionPurchase(emailAccessToken: nil) - - // Then - switch result { - case .success: - XCTFail("Unexpected success") - case .failure(let error): - XCTAssertEqual(error, .accountCreationFailed) - } - } - - // MARK: - Tests for completeSubscriptionPurchase - - func testCompleteSubscriptionPurchaseSuccessOnInitialPurchase() async throws { - // Given - // Initial purchase flow: authToken is present but no accessToken yet - accountManager.authToken = Constants.authToken - XCTAssertNil(accountManager.accessToken) - - accountManager.exchangeAuthTokenToAccessTokenResult = .success(Constants.accessToken) - accountManager.onExchangeAuthTokenToAccessToken = { authToken in - XCTAssertEqual(authToken, Constants.authToken) - } - - accountManager.fetchAccountDetailsResult = .success(AccountManager.AccountDetails(email: nil, externalID: Constants.externalID)) - accountManager.onFetchAccountDetails = { accessToken in - XCTAssertEqual(accessToken, Constants.accessToken) - } - - accountManager.onStoreAuthToken = { authToken in - XCTAssertEqual(authToken, Constants.authToken) - } - - accountManager.onStoreAccount = { accessToken, email, externalID in - XCTAssertEqual(accessToken, Constants.accessToken) - XCTAssertEqual(externalID, Constants.externalID) - XCTAssertNil(email) - } - - accountManager.onCheckForEntitlements = { wait, retry in - XCTAssertEqual(wait, 2.0) - XCTAssertEqual(retry, 5) - return true - } - - XCTAssertFalse(accountManager.isUserAuthenticated) - XCTAssertNotNil(accountManager.authToken) - - // When - await stripePurchaseFlow.completeSubscriptionPurchase() - - // Then - XCTAssertTrue(subscriptionService.signOutCalled) - XCTAssertTrue(accountManager.exchangeAuthTokenToAccessTokenCalled) - XCTAssertTrue(accountManager.fetchAccountDetailsCalled) - XCTAssertTrue(accountManager.storeAuthTokenCalled) - XCTAssertTrue(accountManager.storeAccountCalled) - XCTAssertTrue(accountManager.checkForEntitlementsCalled) - - XCTAssertTrue(accountManager.isUserAuthenticated) - XCTAssertEqual(accountManager.accessToken, Constants.accessToken) - XCTAssertEqual(accountManager.externalID, Constants.externalID) - } - - func testCompleteSubscriptionPurchaseSuccessOnRepurchase() async throws { - // Given - // Repurchase flow: authToken, accessToken and externalID are present - accountManager.authToken = Constants.authToken - accountManager.accessToken = Constants.accessToken - accountManager.externalID = Constants.externalID - - accountManager.fetchAccountDetailsResult = .success(AccountManager.AccountDetails(email: Constants.email, externalID: Constants.externalID)) - - accountManager.onCheckForEntitlements = { wait, retry in - XCTAssertEqual(wait, 2.0) - XCTAssertEqual(retry, 5) - return true - } - - XCTAssertTrue(accountManager.isUserAuthenticated) - - // When - await stripePurchaseFlow.completeSubscriptionPurchase() - - // Then - XCTAssertTrue(subscriptionService.signOutCalled) - XCTAssertFalse(accountManager.exchangeAuthTokenToAccessTokenCalled) - XCTAssertFalse(accountManager.fetchAccountDetailsCalled) - XCTAssertFalse(accountManager.storeAuthTokenCalled) - XCTAssertFalse(accountManager.storeAccountCalled) - XCTAssertTrue(accountManager.checkForEntitlementsCalled) - - XCTAssertTrue(accountManager.isUserAuthenticated) - XCTAssertEqual(accountManager.accessToken, Constants.accessToken) - XCTAssertEqual(accountManager.externalID, Constants.externalID) - } - } -*/ +} From f77e067ff1c284179caf0036b674df103e34a901 Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 15:11:28 +0000 Subject: [PATCH 26/28] tests fixed --- .../Flows/AppStorePurchaseFlowTests.swift | 14 ++++++-- .../SubscriptionCookieManagerTests.swift | 36 ------------------- 2 files changed, 12 insertions(+), 38 deletions(-) diff --git a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift index 1a7345442..dd5b3e3f3 100644 --- a/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift +++ b/Tests/SubscriptionTests/Flows/AppStorePurchaseFlowTests.swift @@ -188,7 +188,12 @@ final class AppStorePurchaseFlowTests: XCTestCase { // Then XCTAssertTrue(authService.createAccountCalled) XCTAssertFalse(storePurchaseManager.purchaseSubscriptionCalled) - XCTAssertEqual(error, .accountCreationFailed) + switch error { + case .accountCreationFailed: + break + default: + XCTFail("Unexpected error: \(error)") + } } } @@ -210,7 +215,12 @@ final class AppStorePurchaseFlowTests: XCTestCase { // Then XCTAssertTrue(authService.createAccountCalled) XCTAssertTrue(storePurchaseManager.purchaseSubscriptionCalled) - XCTAssertEqual(error, .purchaseFailed) + switch error { + case .purchaseFailed: + break + default: + XCTFail("Unexpected error: \(error)") + } } } diff --git a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift index 2a6a9d3d8..efe5ade17 100644 --- a/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift +++ b/Tests/SubscriptionTests/SubscriptionCookie/SubscriptionCookieManagerTests.swift @@ -211,39 +211,3 @@ private extension HTTPCookieStore { await allCookies().first { $0.domain == SubscriptionCookieManager.cookieDomain && $0.name == SubscriptionCookieManager.cookieName } } } - -class MockHTTPCookieStore: HTTPCookieStore { - - var cookies: [HTTPCookie] - - init(cookies: [HTTPCookie] = []) { - self.cookies = cookies - } - - func allCookies() async -> [HTTPCookie] { - return cookies - } - - func setCookie(_ cookie: HTTPCookie) async { - cookies.removeAll { $0.domain == cookie.domain } - cookies.append(cookie) - } - - func deleteCookie(_ cookie: HTTPCookie) async { - cookies.removeAll { $0.domain == cookie.domain } - } - -} - -class MockSubscriptionCookieManageEventPixelMapping: EventMapping { - - public init() { - super.init { event, _, _, _ in - - } - } - - override init(mapping: @escaping EventMapping.Mapping) { - fatalError("Use init()") - } -} From d2d68527da33eed2221d296192a1ddfaae9b50a3 Mon Sep 17 00:00:00 2001 From: Federico Cappelli Date: Mon, 27 Jan 2025 16:39:44 +0000 Subject: [PATCH 27/28] tests timeouts increased --- .../MaliciousSiteProtectionUpdateManagerTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift index 564e47529..cb15b1ca8 100644 --- a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift +++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift @@ -290,12 +290,12 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { // Advance the clock by 1 seconds await self.clock.advance(by: .seconds(1)) // expect to receive v.2 update for hashPrefixes - await fulfillment(of: [hashPrefixUpdateExpectations[1], hashPrefixSleepExpectations[1]], timeout: 1) + await fulfillment(of: [hashPrefixUpdateExpectations[1], hashPrefixSleepExpectations[1]], timeout: 3) // Advance the clock by 1 seconds await self.clock.advance(by: .seconds(1)) // expect to receive v.3 update for hashPrefixes and v.2 update for filterSet - await fulfillment(of: [hashPrefixUpdateExpectations[2], hashPrefixSleepExpectations[2], filterSetUpdateExpectations[1], filterSetSleepExpectations[1]], timeout: 1) // + await fulfillment(of: [hashPrefixUpdateExpectations[2], hashPrefixSleepExpectations[2], filterSetUpdateExpectations[1], filterSetSleepExpectations[1]], timeout: 2) // // Advance the clock by 1 seconds await self.clock.advance(by: .seconds(2)) From c81c5f86db49b9b42680b4205cd454724fa5ef64 Mon Sep 17 00:00:00 2001 From: Alexey Martemyanov Date: Tue, 28 Jan 2025 19:20:14 +0600 Subject: [PATCH 28/28] fix MaliciousSiteProtectionUpdateManagerTests --- ...iousSiteProtectionUpdateManagerTests.swift | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift index cb15b1ca8..fe2d162e7 100644 --- a/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift +++ b/Tests/MaliciousSiteProtectionTests/MaliciousSiteProtectionUpdateManagerTests.swift @@ -215,7 +215,7 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { } func testWhenPeriodicUpdatesStart_dataSetsAreUpdated() async throws { - self.updateIntervalProvider = { _ in 1 } + self.updateIntervalProvider = { _ in 0.9 } let eHashPrefixesUpdated = expectation(description: "Hash prefixes updated") let c1 = await dataManager.publisher(for: .hashPrefixes(threatKind: .phishing)).dropFirst().sink { data in @@ -239,8 +239,9 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { // Start periodic updates self.updateIntervalProvider = { dataType in switch dataType { - case .filterSet: return 2 - case .hashPrefixSet: return 1 + case .hashPrefixSet(.init(threatKind: .phishing)): 1 * 0.99 // update hash prefixes every second + case .filterSet(.init(threatKind: .phishing)): 2 * 0.99 // update filter set every 2 seconds + default: nil // only update phishing data } } @@ -274,12 +275,14 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { var hashPrefixSleepIndex = 0 var filterSetSleepIndex = 0 self.willSleep = { interval in - if interval == 1 { - hashPrefixSleepExpectations[safe: hashPrefixSleepIndex]?.fulfill() - hashPrefixSleepIndex += 1 - } else { - filterSetSleepExpectations[safe: filterSetSleepIndex]?.fulfill() - filterSetSleepIndex += 1 + DispatchQueue.main.async { + if interval <= 1 { + hashPrefixSleepExpectations[safe: hashPrefixSleepIndex]?.fulfill() + hashPrefixSleepIndex += 1 + } else { + filterSetSleepExpectations[safe: filterSetSleepIndex]?.fulfill() + filterSetSleepIndex += 1 + } } } @@ -289,18 +292,18 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { // Advance the clock by 1 seconds await self.clock.advance(by: .seconds(1)) - // expect to receive v.2 update for hashPrefixes - await fulfillment(of: [hashPrefixUpdateExpectations[1], hashPrefixSleepExpectations[1]], timeout: 3) + // expect to receive v.2 update for hashPrefixes (1s. interval) + await fulfillment(of: [hashPrefixUpdateExpectations[1], hashPrefixSleepExpectations[1]], timeout: 1) // Advance the clock by 1 seconds await self.clock.advance(by: .seconds(1)) - // expect to receive v.3 update for hashPrefixes and v.2 update for filterSet - await fulfillment(of: [hashPrefixUpdateExpectations[2], hashPrefixSleepExpectations[2], filterSetUpdateExpectations[1], filterSetSleepExpectations[1]], timeout: 2) // + // expect to receive v.3 update for hashPrefixes (1s. interval) and v.2 update for filterSet (2s. interval) + await fulfillment(of: [hashPrefixUpdateExpectations[2], hashPrefixSleepExpectations[2], filterSetUpdateExpectations[1], filterSetSleepExpectations[1]], timeout: 1) // Advance the clock by 1 seconds await self.clock.advance(by: .seconds(2)) - // expect to receive v.3 update for filterSet and no update for hashPrefixes (no v.3 updates in the mock) - await fulfillment(of: [filterSetUpdateExpectations[2], filterSetSleepExpectations[2]], timeout: 1) // + // expect to receive v.3 update for filterSet (2s. interval) and no update for hashPrefixes (no v.3->v.4 update in MockMaliciousSiteProtectionAPIClient) + await fulfillment(of: [filterSetSleepExpectations[2], filterSetUpdateExpectations[2]], timeout: 1) withExtendedLifetime((c1, c2)) {} } @@ -309,8 +312,8 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { // Start periodic updates self.updateIntervalProvider = { dataType in switch dataType { - case .filterSet: return nil // Set update interval to nil for FilterSet - case .hashPrefixSet: return 1 + case .hashPrefixSet(.init(threatKind: .phishing)): return 0.9 + default: return nil // Set update interval to nil for FilterSet or other threat kinds } } @@ -335,8 +338,10 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { XCTestExpectation(description: "Will Sleep 3"), ] self.willSleep = { _ in - sleepExpectations[safe: sleepIndex]?.fulfill() - sleepIndex += 1 + DispatchQueue.main.async { + sleepExpectations[safe: sleepIndex]?.fulfill() + sleepIndex += 1 + } } // expect initial hashPrefixes update run instantly @@ -344,12 +349,12 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { await fulfillment(of: [expectations[0], sleepExpectations[0]], timeout: 1) // Advance the clock by 1 seconds - await self.clock.advance(by: .seconds(2)) + await self.clock.advance(by: .seconds(1)) // expect to receive v.2 update for hashPrefixes await fulfillment(of: [expectations[1], sleepExpectations[1]], timeout: 1) // Advance the clock by 1 seconds - await self.clock.advance(by: .seconds(2)) + await self.clock.advance(by: .seconds(1)) // expect to receive v.3 update for hashPrefixes await fulfillment(of: [expectations[2], sleepExpectations[2]], timeout: 1) @@ -358,12 +363,12 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { func testWhenPeriodicUpdatesAreCancelled_noFurtherUpdatesReceived() async throws { // Start periodic updates - self.updateIntervalProvider = { _ in 1 } + self.updateIntervalProvider = { _ in 0.99 } updateTask = updateManager.startPeriodicUpdates() // Wait for the initial update try await withTimeout(1) { [self] in - for await _ in await dataManager.publisher(for: .filterSet(threatKind: .phishing)).first(where: { $0.revision == 1 }).values {} + for await _ in await dataManager.publisher(for: .hashPrefixes(threatKind: .phishing)).first(where: { $0.revision == 1 }).values {} for await _ in await dataManager.publisher(for: .filterSet(threatKind: .phishing)).first(where: { $0.revision == 1 }).values {} } @@ -377,7 +382,7 @@ class MaliciousSiteProtectionUpdateManagerTests: XCTestCase { } // Advance the clock to check for further updates - await self.clock.advance(by: .seconds(2)) + await self.clock.advance(by: .seconds(1)) await clock.run() await Task.megaYield(count: 10)