diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index 7714f414782..9d7684bfd25 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -724,7 +724,7 @@ DEC51AFB2769C66B009F3DF4 /* SystemStatusMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AFA2769C66B009F3DF4 /* SystemStatusMapperTests.swift */; }; DEC51B02276AFB35009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51B01276AFB34009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift */; }; DEFBA74E29485A7600C35BA9 /* RESTRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFBA74D29485A7600C35BA9 /* RESTRequest.swift */; }; - DEFBA7542949CE6600C35BA9 /* RequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFBA7532949CE6600C35BA9 /* RequestAuthenticator.swift */; }; + DEFBA7542949CE6600C35BA9 /* RequestProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFBA7532949CE6600C35BA9 /* RequestProcessor.swift */; }; DEFBA7562949D17400C35BA9 /* RequestAuthenticatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEFBA7552949D17300C35BA9 /* RequestAuthenticatorTests.swift */; }; E12552C526385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = E12552C426385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift */; }; E137619929151C7400FD098F /* error-wp-rest-forbidden.json in Resources */ = {isa = PBXBuildFile; fileRef = E137619829151C7400FD098F /* error-wp-rest-forbidden.json */; }; @@ -745,6 +745,9 @@ EE338A0E294AF9BD00183934 /* ApplicationPasswordMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE338A0D294AF9BD00183934 /* ApplicationPasswordMapperTests.swift */; }; EE54C89F2947782E00A9BF61 /* ApplicationPasswordUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */; }; EE54C8A729486B6800A9BF61 /* ApplicationPasswordMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */; }; + EE62EE61295ACF8D009C965B /* RequestConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE62EE60295ACF8D009C965B /* RequestConverterTests.swift */; }; + EE62EE63295AD45E009C965B /* String+URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE62EE62295AD45E009C965B /* String+URL.swift */; }; + EE62EE65295AD46D009C965B /* String+URLTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE62EE64295AD46D009C965B /* String+URLTests.swift */; }; EE71CC3D2951A8EA0074D908 /* ApplicationPasswordStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE71CC3C2951A8EA0074D908 /* ApplicationPasswordStorage.swift */; }; EE71CC412951CE700074D908 /* generate-application-password-using-wporg-creds-success.json in Resources */ = {isa = PBXBuildFile; fileRef = EE71CC402951CE700074D908 /* generate-application-password-using-wporg-creds-success.json */; }; EE80A24729547F8B003591E4 /* coupons-all-without-data.json in Resources */ = {isa = PBXBuildFile; fileRef = EE80A24529547F8B003591E4 /* coupons-all-without-data.json */; }; @@ -752,6 +755,8 @@ EE80A25029556FBD003591E4 /* coupon-reports-without-data.json in Resources */ = {isa = PBXBuildFile; fileRef = EE80A24F29556FBD003591E4 /* coupon-reports-without-data.json */; }; EE8A86F1286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json in Resources */ = {isa = PBXBuildFile; fileRef = EE8A86F0286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json */; }; EE8DE432294B17CD005054E7 /* DefaultApplicationPasswordUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE8DE431294B17CD005054E7 /* DefaultApplicationPasswordUseCaseTests.swift */; }; + EE99814E295AA7430074AE68 /* RequestAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE99814D295AA7430074AE68 /* RequestAuthenticator.swift */; }; + EE998150295AACE10074AE68 /* RequestConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE99814F295AACE10074AE68 /* RequestConverter.swift */; }; EECB7EE8286555180028C888 /* media-update-product-id.json in Resources */ = {isa = PBXBuildFile; fileRef = EECB7EE7286555180028C888 /* media-update-product-id.json */; }; FE28F6E226840DED004465C7 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE28F6E126840DED004465C7 /* User.swift */; }; FE28F6E426842848004465C7 /* UserMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE28F6E326842848004465C7 /* UserMapper.swift */; }; @@ -1507,7 +1512,7 @@ DEC51AFA2769C66B009F3DF4 /* SystemStatusMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemStatusMapperTests.swift; sourceTree = ""; }; DEC51B01276AFB34009F3DF4 /* SystemStatus+DropinMustUsePlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatus+DropinMustUsePlugin.swift"; sourceTree = ""; }; DEFBA74D29485A7600C35BA9 /* RESTRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RESTRequest.swift; sourceTree = ""; }; - DEFBA7532949CE6600C35BA9 /* RequestAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestAuthenticator.swift; sourceTree = ""; }; + DEFBA7532949CE6600C35BA9 /* RequestProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestProcessor.swift; sourceTree = ""; }; DEFBA7552949D17300C35BA9 /* RequestAuthenticatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestAuthenticatorTests.swift; sourceTree = ""; }; E12552C426385B05001CEE70 /* ShippingLabelAddressValidationSuccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelAddressValidationSuccess.swift; sourceTree = ""; }; E137619829151C7400FD098F /* error-wp-rest-forbidden.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "error-wp-rest-forbidden.json"; sourceTree = ""; }; @@ -1528,6 +1533,9 @@ EE338A0D294AF9BD00183934 /* ApplicationPasswordMapperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordMapperTests.swift; sourceTree = ""; }; EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordUseCase.swift; sourceTree = ""; }; EE54C8A629486B6800A9BF61 /* ApplicationPasswordMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordMapper.swift; sourceTree = ""; }; + EE62EE60295ACF8D009C965B /* RequestConverterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestConverterTests.swift; sourceTree = ""; }; + EE62EE62295AD45E009C965B /* String+URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+URL.swift"; sourceTree = ""; }; + EE62EE64295AD46D009C965B /* String+URLTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+URLTests.swift"; sourceTree = ""; }; EE71CC3C2951A8EA0074D908 /* ApplicationPasswordStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationPasswordStorage.swift; sourceTree = ""; }; EE71CC402951CE700074D908 /* generate-application-password-using-wporg-creds-success.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "generate-application-password-using-wporg-creds-success.json"; sourceTree = ""; }; EE80A24529547F8B003591E4 /* coupons-all-without-data.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "coupons-all-without-data.json"; sourceTree = ""; }; @@ -1535,6 +1543,8 @@ EE80A24F29556FBD003591E4 /* coupon-reports-without-data.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "coupon-reports-without-data.json"; sourceTree = ""; }; EE8A86F0286C5226003E8AA4 /* media-update-product-id-in-wordpress-site.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-update-product-id-in-wordpress-site.json"; sourceTree = ""; }; EE8DE431294B17CD005054E7 /* DefaultApplicationPasswordUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultApplicationPasswordUseCaseTests.swift; sourceTree = ""; }; + EE99814D295AA7430074AE68 /* RequestAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestAuthenticator.swift; sourceTree = ""; }; + EE99814F295AACE10074AE68 /* RequestConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestConverter.swift; sourceTree = ""; }; EECB7EE7286555180028C888 /* media-update-product-id.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "media-update-product-id.json"; sourceTree = ""; }; F3F25DC15EC1D7C631169CB5 /* Pods_Networking.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Networking.framework; sourceTree = BUILT_PRODUCTS_DIR; }; F6CEE1CA2AD376C0C28AE9F6 /* Pods-NetworkingTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NetworkingTests.release.xcconfig"; path = "../Pods/Target Support Files/Pods-NetworkingTests/Pods-NetworkingTests.release.xcconfig"; sourceTree = ""; }; @@ -1746,7 +1756,6 @@ B518662320A099BF00037A38 /* AlamofireNetwork.swift */, B518662620A09BCC00037A38 /* MockNetwork.swift */, D87F6150226591E10031A13B /* NullNetwork.swift */, - DEFBA7532949CE6600C35BA9 /* RequestAuthenticator.swift */, ); path = Network; sourceTree = ""; @@ -2438,6 +2447,7 @@ children = ( B57B1E6621C916850046E764 /* NetworkErrorTests.swift */, DEFBA7552949D17300C35BA9 /* RequestAuthenticatorTests.swift */, + EE62EE60295ACF8D009C965B /* RequestConverterTests.swift */, ); path = Network; sourceTree = ""; @@ -2474,6 +2484,7 @@ 57E8FED2246616AC0057CD68 /* Result+Extensions.swift */, 265EFBDB285257950033BD33 /* Order+Fallbacks.swift */, DE2E8EB0295464C5002E4B14 /* URLRequest+Request.swift */, + EE62EE62295AD45E009C965B /* String+URL.swift */, ); path = Extensions; sourceTree = ""; @@ -2487,6 +2498,7 @@ 02BDB83623EA9C4D00BCC63E /* String+HTMLTests.swift */, 0212683424C046CB00F8A892 /* MockNetwork+Path.swift */, CC851D1325E52AB500249E9C /* Decimal+ExtensionsTests.swift */, + EE62EE64295AD46D009C965B /* String+URLTests.swift */, ); path = Extensions; sourceTree = ""; @@ -2646,8 +2658,11 @@ EE54C899294777D000A9BF61 /* ApplicationPassword */ = { isa = PBXGroup; children = ( + DEFBA7532949CE6600C35BA9 /* RequestProcessor.swift */, EE54C89E2947782E00A9BF61 /* ApplicationPasswordUseCase.swift */, EE71CC3C2951A8EA0074D908 /* ApplicationPasswordStorage.swift */, + EE99814D295AA7430074AE68 /* RequestAuthenticator.swift */, + EE99814F295AACE10074AE68 /* RequestConverter.swift */, ); path = ApplicationPassword; sourceTree = ""; @@ -3160,6 +3175,7 @@ 457A574025D1817E000797AD /* ShippingLabelAddressVerification.swift in Sources */, 74ABA1D1213F22CA00FFAD30 /* TopEarnersStatsRemote.swift in Sources */, DEC51AF127699E7A009F3DF4 /* SystemStatus+Page.swift in Sources */, + EE99814E295AA7430074AE68 /* RequestAuthenticator.swift in Sources */, 025CA2C0238EB8CB00B05C81 /* ProductShippingClass.swift in Sources */, 02C1CEF424C6A02B00703EBA /* ProductVariationMapper.swift in Sources */, 3105470C262E27F000C5C02B /* WCPayPaymentIntentStatusEnum.swift in Sources */, @@ -3190,7 +3206,7 @@ 020D07B823D852BB00FD9580 /* Media.swift in Sources */, B5BB1D0C20A2050300112D92 /* DateFormatter+Woo.swift in Sources */, 743E84EE2217244C00FAC9D7 /* ShipmentTrackingListMapper.swift in Sources */, - DEFBA7542949CE6600C35BA9 /* RequestAuthenticator.swift in Sources */, + DEFBA7542949CE6600C35BA9 /* RequestProcessor.swift in Sources */, 451A97E5260B631E0059D135 /* ShippingLabelPredefinedPackage.swift in Sources */, BAB373722795A1FB00837B4A /* OrderTaxLine.swift in Sources */, EE54C89F2947782E00A9BF61 /* ApplicationPasswordUseCase.swift in Sources */, @@ -3214,6 +3230,7 @@ 7452387221124B7700A973CD /* AnyEncodable.swift in Sources */, 740CF89921937A030023ED3A /* CommentRemote.swift in Sources */, 31054702262E04F700C5C02B /* RemotePaymentIntentMapper.swift in Sources */, + EE62EE63295AD45E009C965B /* String+URL.swift in Sources */, 025CA2C2238EBBAA00B05C81 /* ProductShippingClassListMapper.swift in Sources */, 74ABA1CD213F1B6B00FFAD30 /* TopEarnerStats.swift in Sources */, CCAAD10F2683974000909664 /* ShippingLabelPackagePurchase.swift in Sources */, @@ -3364,6 +3381,7 @@ CE583A0E2109154500D73C1C /* OrderNoteMapper.swift in Sources */, D8FBFF0D22D3AF4A006E3336 /* StatsGranularityV4.swift in Sources */, 261870782540A252006522A1 /* ShippingLineTax.swift in Sources */, + EE998150295AACE10074AE68 /* RequestConverter.swift in Sources */, 74046E1B217A684D007DD7BF /* SiteSettingsRemote.swift in Sources */, 0359EA1D27AADE000048DE2D /* WCPayChargeMapper.swift in Sources */, B5C6FCCF20A3592900A4F8E4 /* OrderItem.swift in Sources */, @@ -3505,6 +3523,8 @@ 45150AA026837357006922EA /* CountryListMapperTests.swift in Sources */, 74D5BECE217E0F98007B0348 /* SiteSettingsRemoteTests.swift in Sources */, D8FBFF1C22D51C34006E3336 /* OrderStatsRemoteV4Tests.swift in Sources */, + EE62EE61295ACF8D009C965B /* RequestConverterTests.swift in Sources */, + EE62EE65295AD46D009C965B /* String+URLTests.swift in Sources */, CE6D666F2379E82A007835A1 /* ArrayWooTests.swift in Sources */, DE2E8EAD295418D8002E4B14 /* WordPressSiteRemoteTests.swift in Sources */, 45D685FC23D0C739005F87D0 /* ProductSkuMapperTests.swift in Sources */, diff --git a/Networking/Networking/ApplicationPassword/RequestAuthenticator.swift b/Networking/Networking/ApplicationPassword/RequestAuthenticator.swift new file mode 100644 index 00000000000..a8b41da9c22 --- /dev/null +++ b/Networking/Networking/ApplicationPassword/RequestAuthenticator.swift @@ -0,0 +1,91 @@ +enum RequestAuthenticatorError: Error { + case applicationPasswordUseCaseNotAvailable + case applicationPasswordNotAvailable +} + +/// Authenticates request +/// +public struct RequestAuthenticator { + /// Credentials. + /// + let credentials: Credentials? + + /// The use case to handle authentication with application passwords. + /// + private let applicationPasswordUseCase: ApplicationPasswordUseCase? + + /// Sets up the authenticator with optional credentials and application password use case. + /// `applicationPasswordUseCase` can be injected for unit tests. + /// + init(credentials: Credentials?, applicationPasswordUseCase: ApplicationPasswordUseCase? = nil) { + self.credentials = credentials + let useCase: ApplicationPasswordUseCase? = { + if let applicationPasswordUseCase { + return applicationPasswordUseCase + } else if case let .wporg(username, password, siteAddress) = credentials { + return try? DefaultApplicationPasswordUseCase(username: username, + password: password, + siteAddress: siteAddress) + } else { + return nil + } + }() + self.applicationPasswordUseCase = useCase + } + + func authenticate(_ urlRequest: URLRequest) throws -> URLRequest { + guard case let .wporg(_, _, siteAddress) = credentials, + let url = urlRequest.url, + url.absoluteString.hasPrefix(siteAddress.trimSlashes() + "/" + RESTRequest.Settings.basePath) else { + // Handle non-REST requests as before + return try authenticateUsingWPCOMTokenIfPossible(urlRequest) + } + + return try authenticateUsingApplicationPasswordIfPossible(urlRequest) + } + + func generateApplicationPassword() async throws { + guard let applicationPasswordUseCase = applicationPasswordUseCase else { + throw RequestAuthenticatorError.applicationPasswordUseCaseNotAvailable + } + let _ = try await applicationPasswordUseCase.generateNewPassword() + return + } +} + +private extension RequestAuthenticator { + /// Attempts creating a request with WPCOM token if possible. + /// + func authenticateUsingWPCOMTokenIfPossible(_ urlRequest: URLRequest) throws -> URLRequest { + if let credentials, case .wpcom = credentials { + return try AuthenticatedRequest(credentials: credentials, request: urlRequest).asURLRequest() + } + return UnauthenticatedRequest(request: urlRequest).asURLRequest() + } + + /// Attempts creating a request with application password if possible. + /// + func authenticateUsingApplicationPasswordIfPossible(_ urlRequest: URLRequest) throws -> URLRequest { + guard let applicationPassword = applicationPasswordUseCase?.applicationPassword else { + throw RequestAuthenticatorError.applicationPasswordNotAvailable + } + + var request = urlRequest + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(UserAgent.defaultUserAgent, forHTTPHeaderField: "User-Agent") + + let username = applicationPassword.wpOrgUsername + let password = applicationPassword.password.secretValue + let loginString = "\(username):\(password)" + guard let loginData = loginString.data(using: .utf8) else { + return request + } + let base64LoginString = loginData.base64EncodedString() + request.setValue("Basic \(base64LoginString)", forHTTPHeaderField: "Authorization") + + // Cookies from `CookieNonceAuthenticator` should be skipped + request.httpShouldHandleCookies = false + + return request + } +} diff --git a/Networking/Networking/ApplicationPassword/RequestConverter.swift b/Networking/Networking/ApplicationPassword/RequestConverter.swift new file mode 100644 index 00000000000..e00eccd01e9 --- /dev/null +++ b/Networking/Networking/ApplicationPassword/RequestConverter.swift @@ -0,0 +1,17 @@ +import Alamofire + +/// Converter to convert Jetpack tunnel requests into REST API requests if needed +/// +struct RequestConverter { + let credentials: Credentials? + + func convert(_ request: URLRequestConvertible) -> URLRequestConvertible { + guard let jetpackRequest = request as? JetpackRequest, + case let .wporg(_, _, siteAddress) = credentials, + let restRequest = jetpackRequest.asRESTRequest(with: siteAddress) else { + return request + } + + return restRequest + } +} diff --git a/Networking/Networking/ApplicationPassword/RequestProcessor.swift b/Networking/Networking/ApplicationPassword/RequestProcessor.swift new file mode 100644 index 00000000000..d030442d9b7 --- /dev/null +++ b/Networking/Networking/ApplicationPassword/RequestProcessor.swift @@ -0,0 +1,96 @@ +import Alamofire +import Foundation + +/// Authenticates and retries requests +/// +final class RequestProcessor { + private var requestsToRetry = [RequestRetryCompletion]() + + private var isAuthenticating = false + + private let requestAuthenticator: RequestAuthenticator + + init(credentials: Credentials?) { + requestAuthenticator = RequestAuthenticator(credentials: credentials) + } +} + +// MARK: Request Authentication +// +extension RequestProcessor: RequestAdapter { + func adapt(_ urlRequest: URLRequest) throws -> URLRequest { + return try requestAuthenticator.authenticate(urlRequest) + } +} + +// MARK: Retrying Request +// +extension RequestProcessor: RequestRetrier { + func should(_ manager: Alamofire.SessionManager, + retry request: Alamofire.Request, + with error: Error, + completion: @escaping Alamofire.RequestRetryCompletion) { + guard + request.retryCount == 0, // Only retry once + shouldRetry(request), // Retry only REST API requests that use application password + shouldRetry(error) // Retry only specific errors + else { + return completion(false, 0.0) + } + + requestsToRetry.append(completion) + if !isAuthenticating { + generateApplicationPassword() + } + } +} + +// MARK: Helpers +// +private extension RequestProcessor { + func generateApplicationPassword() { + Task(priority: .medium) { + isAuthenticating = true + + do { + let _ = try await requestAuthenticator.generateApplicationPassword() + isAuthenticating = false + completeRequests(true) + } catch { + isAuthenticating = false + completeRequests(false) + } + } + } + + func shouldRetry(_ request: Alamofire.Request) -> Bool { + guard case let .wporg(_, _, siteAddress) = requestAuthenticator.credentials, + let url = request.request?.url, + url.absoluteString.hasPrefix(siteAddress + "/" + RESTRequest.Settings.basePath) else { + return false + } + + return true + } + + func shouldRetry(_ error: Error) -> Bool { + // Need to generate application password + if .applicationPasswordNotAvailable == error as? RequestAuthenticatorError { + return true + } + + // Failed authorization + if case .responseValidationFailed(reason: .unacceptableStatusCode(code: 401)) = error as? AFError { + return true + } + + return false + } + + func completeRequests(_ shouldRetry: Bool) { + requestsToRetry.forEach { (completion) in + completion(shouldRetry, 0.0) + } + requestsToRetry.removeAll() + } +} diff --git a/Networking/Networking/Extensions/String+URL.swift b/Networking/Networking/Extensions/String+URL.swift new file mode 100644 index 00000000000..3656b81c72b --- /dev/null +++ b/Networking/Networking/Extensions/String+URL.swift @@ -0,0 +1,11 @@ +import Foundation + +extension String { + /// Trims front slash + /// + /// - Returns: String after removing prefix and suffix "/" + /// + func trimSlashes() -> String { + removingPrefix("/").removingSuffix("/") + } +} diff --git a/Networking/Networking/Network/AlamofireNetwork.swift b/Networking/Networking/Network/AlamofireNetwork.swift index 4c45d4b8980..8ccfa4d0b40 100644 --- a/Networking/Networking/Network/AlamofireNetwork.swift +++ b/Networking/Networking/Network/AlamofireNetwork.swift @@ -7,29 +7,36 @@ extension Alamofire.MultipartFormData: MultipartFormData {} /// AlamofireWrapper: Encapsulates all of the Alamofire OP's /// public class AlamofireNetwork: Network { - /// WordPress.com Credentials. - /// - private let credentials: Credentials? + private lazy var backgroundSessionManager: Alamofire.SessionManager = { + // A unique ID is included in the background session identifier so that the session does not get invalidated when the initializer is called multiple + // times (e.g. when logging in). + let uniqueID = UUID().uuidString + let sessionConfiguration = URLSessionConfiguration.background(withIdentifier: "com.automattic.woocommerce.backgroundsession.\(uniqueID)") + let sessionManager = makeSessionManager(configuration: sessionConfiguration) + return sessionManager + }() + + private lazy var sessionManager: Alamofire.SessionManager = { + let sessionConfiguration = URLSessionConfiguration.default + let sessionManager = makeSessionManager(configuration: sessionConfiguration) + return sessionManager + }() - private let backgroundSessionManager: Alamofire.SessionManager + /// Converter to convert Jetpack tunnel requests into REST API requests if applicable + /// + private let requestConverter: RequestConverter /// Authenticator to update requests authorization header if possible. /// - private let requestAuthenticator: RequestAuthenticator + private let requestAuthenticator: RequestProcessor public var session: URLSession { SessionManager.default.session } /// Public Initializer /// public required init(credentials: Credentials?) { - self.credentials = credentials - self.requestAuthenticator = RequestAuthenticator(credentials: credentials) - - // A unique ID is included in the background session identifier so that the session does not get invalidated when the initializer is called multiple - // times (e.g. when logging in). - let uniqueID = UUID().uuidString - let configuration = URLSessionConfiguration.background(withIdentifier: "com.automattic.woocommerce.backgroundsession.\(uniqueID)") - self.backgroundSessionManager = Alamofire.SessionManager(configuration: configuration) + self.requestConverter = RequestConverter(credentials: credentials) + self.requestAuthenticator = RequestProcessor(credentials: credentials) } /// Executes the specified Network Request. Upon completion, the payload will be sent back to the caller as a Data instance. @@ -48,17 +55,12 @@ public class AlamofireNetwork: Network { /// - Yes. We do the above because the Jetpack Tunnel endpoint doesn't properly relay the correct statusCode. /// public func responseData(for request: URLRequestConvertible, completion: @escaping (Data?, Error?) -> Void) { - requestAuthenticator.authenticateRequest(request) { result in - switch result { - case .success(let request): - Alamofire.request(request) - .responseData { response in - completion(response.value, response.networkingError) - } - case .failure(let error): - completion(nil, error) + let request = requestConverter.convert(request) + sessionManager.request(request) + .validate() + .responseData { response in + completion(response.value, response.networkingError) } - } } /// Executes the specified Network Request. Upon completion, the payload will be sent back to the caller as a Data instance. @@ -71,16 +73,12 @@ public class AlamofireNetwork: Network { /// - completion: Closure to be executed upon completion. /// public func responseData(for request: URLRequestConvertible, completion: @escaping (Swift.Result) -> Void) { - requestAuthenticator.authenticateRequest(request) { result in - switch result { - case .success(let request): - Alamofire.request(request).responseData { response in - completion(response.result.toSwiftResult()) - } - case .failure(let error): - completion(.failure(error)) + let request = requestConverter.convert(request) + sessionManager.request(request) + .validate() + .responseData { response in + completion(response.result.toSwiftResult()) } - } } /// Executes the specified Network Request. Upon completion, the payload or error will be emitted to the publisher. @@ -93,38 +91,26 @@ public class AlamofireNetwork: Network { /// - Returns: A publisher that emits the result of the given request. public func responseDataPublisher(for request: URLRequestConvertible) -> AnyPublisher, Never> { return Future() { promise in - self.requestAuthenticator.authenticateRequest(request) { result in - switch result { - case .success(let request): - Alamofire.request(request).responseData { response in - let result = response.result.toSwiftResult() - promise(.success(result)) - } - case .failure(let error): - promise(.success(.failure(error))) + let request = self.requestConverter.convert(request) + self.sessionManager + .request(request) + .validate() + .responseData { response in + let result = response.result.toSwiftResult() + promise(.success(result)) } - } }.eraseToAnyPublisher() } public func uploadMultipartFormData(multipartFormData: @escaping (MultipartFormData) -> Void, to request: URLRequestConvertible, completion: @escaping (Data?, Error?) -> Void) { - requestAuthenticator.authenticateRequest(request) { [weak self] result in - guard let self else { - return completion(nil, nil) - } - switch result { - case .success(let request): - self.backgroundSessionManager.upload(multipartFormData: multipartFormData, with: request) { (encodingResult) in - switch encodingResult { - case .success(let upload, _, _): - upload.responseData { response in - completion(response.value, response.error) - } - case .failure(let error): - completion(nil, error) - } + let request = requestConverter.convert(request) + backgroundSessionManager.upload(multipartFormData: multipartFormData, with: request) { (encodingResult) in + switch encodingResult { + case .success(let upload, _, _): + upload.responseData { response in + completion(response.value, response.error) } case .failure(let error): completion(nil, error) @@ -133,6 +119,17 @@ public class AlamofireNetwork: Network { } } +private extension AlamofireNetwork { + /// Creates a session manager with request retrier and adapter + /// + func makeSessionManager(configuration sessionConfiguration: URLSessionConfiguration) -> Alamofire.SessionManager { + let sessionManager = Alamofire.SessionManager(configuration: sessionConfiguration) + sessionManager.retrier = requestAuthenticator + sessionManager.adapter = requestAuthenticator + return sessionManager + } +} + // MARK: - Alamofire.DataResponse: Helper Methods // extension Alamofire.DataResponse { diff --git a/Networking/Networking/Network/RequestAuthenticator.swift b/Networking/Networking/Network/RequestAuthenticator.swift deleted file mode 100644 index 989436754aa..00000000000 --- a/Networking/Networking/Network/RequestAuthenticator.swift +++ /dev/null @@ -1,81 +0,0 @@ -import Alamofire -import Foundation - -/// Helper class to update requests with authorization header if possible. -/// -final class RequestAuthenticator { - /// WordPress.com Credentials. - /// - private let credentials: Credentials? - - /// The use case to handle authentication with application passwords. - /// - private let applicationPasswordUseCase: ApplicationPasswordUseCase? - - /// Sets up the authenticator with optional credentials and application password use case. - /// `applicationPasswordUseCase` can be injected for unit tests. - /// - init(credentials: Credentials?, applicationPasswordUseCase: ApplicationPasswordUseCase? = nil) { - self.credentials = credentials - let useCase: ApplicationPasswordUseCase? = { - if let applicationPasswordUseCase { - return applicationPasswordUseCase - } else if case let .wporg(username, password, siteAddress) = credentials { - return try? DefaultApplicationPasswordUseCase(username: username, - password: password, - siteAddress: siteAddress) - } else { - return nil - } - }() - self.applicationPasswordUseCase = useCase - } - - /// Updates a request with application password or WPCOM token if possible. - /// - func authenticateRequest(_ request: URLRequestConvertible, completion: @escaping (Swift.Result) -> Void) { - guard let jetpackRequest = request as? JetpackRequest, - let useCase = applicationPasswordUseCase, - case let .wporg(_, _, siteAddress) = credentials, - let restRequest = jetpackRequest.asRESTRequest(with: siteAddress) else { - // Handle non-REST requests as before - return completion(.success(authenticateUsingWPCOMTokenIfPossible(request))) - } - - Task(priority: .medium) { - let result: Swift.Result - do { - let authenticatedRequest = try await authenticateUsingApplicationPassword(restRequest, useCase: useCase) - result = .success(authenticatedRequest) - } catch { - result = .failure(error) - } - await MainActor.run { - completion(result) - } - } - } - - /// Attempts authenticating a request with application password. - /// - private func authenticateUsingApplicationPassword(_ restRequest: RESTRequest, useCase: ApplicationPasswordUseCase) async throws -> URLRequestConvertible { - let applicationPassword: ApplicationPassword = try await { - if let password = useCase.applicationPassword { - return password - } - return try await useCase.generateNewPassword() - }() - return try await MainActor.run { - return try restRequest.authenticateRequest(with: applicationPassword) - } - } - - /// Attempts creating a request with WPCOM token if possible. - /// - private func authenticateUsingWPCOMTokenIfPossible(_ request: URLRequestConvertible) -> URLRequestConvertible { - if let credentials, case .wpcom = credentials { - return AuthenticatedRequest(credentials: credentials, request: request) - } - return UnauthenticatedRequest(request: request) - } -} diff --git a/Networking/Networking/Remote/CouponsRemote.swift b/Networking/Networking/Remote/CouponsRemote.swift index 680cef6f2e4..3e46618718e 100644 --- a/Networking/Networking/Remote/CouponsRemote.swift +++ b/Networking/Networking/Remote/CouponsRemote.swift @@ -65,7 +65,8 @@ public final class CouponsRemote: Remote, CouponsRemoteProtocol { method: .get, siteID: siteID, path: Path.coupons, - parameters: parameters) + parameters: parameters, + availableAsRESTRequest: true) let mapper = CouponListMapper(siteID: siteID) @@ -87,7 +88,8 @@ public final class CouponsRemote: Remote, CouponsRemoteProtocol { method: .get, siteID: siteID, path: Path.coupons, - parameters: parameters) + parameters: parameters, + availableAsRESTRequest: true) let mapper = CouponListMapper(siteID: siteID) @@ -107,7 +109,8 @@ public final class CouponsRemote: Remote, CouponsRemoteProtocol { let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, - path: Path.coupons + "/\(couponID)") + path: Path.coupons + "/\(couponID)", + availableAsRESTRequest: true) let mapper = CouponMapper(siteID: siteID) @@ -130,7 +133,8 @@ public final class CouponsRemote: Remote, CouponsRemoteProtocol { method: .delete, siteID: siteID, path: Path.coupons + "/\(couponID)", - parameters: [ParameterKey.force: true]) + parameters: [ParameterKey.force: true], + availableAsRESTRequest: true) let mapper = CouponMapper(siteID: siteID) @@ -159,7 +163,7 @@ public final class CouponsRemote: Remote, CouponsRemoteProtocol { let couponID = coupon.couponID let siteID = coupon.siteID let path = Path.coupons + "/\(couponID)" - let request = JetpackRequest(wooApiVersion: .mark3, method: .put, siteID: siteID, path: path, parameters: parameters) + let request = JetpackRequest(wooApiVersion: .mark3, method: .put, siteID: siteID, path: path, parameters: parameters, availableAsRESTRequest: true) let mapper = CouponMapper(siteID: siteID) enqueue(request, mapper: mapper, completion: completion) @@ -189,7 +193,7 @@ public final class CouponsRemote: Remote, CouponsRemoteProtocol { let parameters = try coupon.toDictionary(keyEncodingStrategy: .convertToSnakeCase, dateFormatter: dateFormatter) let siteID = coupon.siteID let path = Path.coupons - let request = JetpackRequest(wooApiVersion: .mark3, method: .post, siteID: siteID, path: path, parameters: parameters) + let request = JetpackRequest(wooApiVersion: .mark3, method: .post, siteID: siteID, path: path, parameters: parameters, availableAsRESTRequest: true) let mapper = CouponMapper(siteID: siteID) enqueue(request, mapper: mapper, completion: completion) @@ -223,7 +227,8 @@ public final class CouponsRemote: Remote, CouponsRemoteProtocol { method: .get, siteID: siteID, path: Path.couponReports, - parameters: parameters) + parameters: parameters, + availableAsRESTRequest: true) let mapper = CouponReportListMapper() diff --git a/Networking/Networking/Requests/AuthenticatedRequest.swift b/Networking/Networking/Requests/AuthenticatedRequest.swift index e6b55f2b4fc..95935270ef2 100644 --- a/Networking/Networking/Requests/AuthenticatedRequest.swift +++ b/Networking/Networking/Requests/AuthenticatedRequest.swift @@ -15,8 +15,7 @@ struct AuthenticatedRequest: URLRequestConvertible { /// Request that should be authenticated. /// - let request: URLRequestConvertible - + let request: URLRequest /// Returns the Wrapped Request, but with a WordPress.com Bearer Token set up. /// @@ -25,7 +24,7 @@ struct AuthenticatedRequest: URLRequestConvertible { throw AuthenticatedRequestError.invalidCredentials } - var authenticated = try request.asURLRequest() + var authenticated = request authenticated.setValue("Bearer " + authToken, forHTTPHeaderField: "Authorization") authenticated.setValue("application/json", forHTTPHeaderField: "Accept") diff --git a/Networking/Networking/Requests/RESTRequest.swift b/Networking/Networking/Requests/RESTRequest.swift index f7d669b4d0c..9f3dcde7667 100644 --- a/Networking/Networking/Requests/RESTRequest.swift +++ b/Networking/Networking/Requests/RESTRequest.swift @@ -33,7 +33,6 @@ struct RESTRequest: URLRequestConvertible { /// - parameters: Collection of String parameters to be passed over to our target endpoint. /// This can be encoded to the URL request query if the HTTP method is `.get`. /// - headers: Headers to be added to the request. - /// - fallbackRequest: A fallback Jetpack request to trigger if the REST request cannot be made. /// init(siteURL: String, wooApiVersion: WooAPIVersion, @@ -53,43 +52,17 @@ struct RESTRequest: URLRequestConvertible { let components = [siteURL, Settings.basePath, wooApiVersion.path, path].map { $0.trimSlashes() } let url = try components.joined(separator: "/").asURL() let request = try URLRequest(url: url, method: method) - return try URLEncoding.default.encode(request, with: parameters) - } -} - -extension RESTRequest { - /// Updates the request headers with authentication information. - /// - func authenticateRequest(with applicationPassword: ApplicationPassword) throws -> URLRequest { - var request = try asURLRequest() - request.setValue("application/json", forHTTPHeaderField: "Accept") - request.setValue(UserAgent.defaultUserAgent, forHTTPHeaderField: "User-Agent") - - let username = applicationPassword.wpOrgUsername - let password = applicationPassword.password.secretValue - let loginString = "\(username):\(password)" - guard let loginData = loginString.data(using: .utf8) else { - return request + switch method { + case .post, .put: + return try JSONEncoding.default.encode(request, with: parameters) + default: + return try URLEncoding.default.encode(request, with: parameters) } - let base64LoginString = loginData.base64EncodedString() - - request.setValue("Basic \(base64LoginString)", forHTTPHeaderField: "Authorization") - return request } } -private extension RESTRequest { +extension RESTRequest { enum Settings { static let basePath = "wp-json" } } - -private extension String { - /// Trims front slash - /// - /// - Returns: String after removing prefix and suffix "/" - /// - func trimSlashes() -> String { - removingPrefix("/").removingSuffix("/") - } -} diff --git a/Networking/Networking/Requests/UnauthenticatedRequest.swift b/Networking/Networking/Requests/UnauthenticatedRequest.swift index 64f3276226e..6995a27134e 100644 --- a/Networking/Networking/Requests/UnauthenticatedRequest.swift +++ b/Networking/Networking/Requests/UnauthenticatedRequest.swift @@ -1,23 +1,22 @@ import Foundation import protocol Alamofire.URLRequestConvertible - /// Wraps up a `URLRequestConvertible` instance, and injects the `UserAgent.defaultUserAgent`. /// struct UnauthenticatedRequest: URLRequestConvertible { /// Request that does not require WPCOM authentication. /// - let request: URLRequestConvertible + let request: URLRequest /// Returns the wrapped request, with a custom user-agent header. /// - func asURLRequest() throws -> URLRequest { - var authenticated = try request.asURLRequest() + func asURLRequest() -> URLRequest { + var unauthenticated = request - authenticated.setValue("application/json", forHTTPHeaderField: "Accept") - authenticated.setValue(UserAgent.defaultUserAgent, forHTTPHeaderField: "User-Agent") + unauthenticated.setValue("application/json", forHTTPHeaderField: "Accept") + unauthenticated.setValue(UserAgent.defaultUserAgent, forHTTPHeaderField: "User-Agent") - return authenticated + return unauthenticated } } diff --git a/Networking/NetworkingTests/Extensions/String+URLTests.swift b/Networking/NetworkingTests/Extensions/String+URLTests.swift new file mode 100644 index 00000000000..9b5da7046f4 --- /dev/null +++ b/Networking/NetworkingTests/Extensions/String+URLTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import Networking + +final class String_URLTests: XCTestCase { + func test_prefix_slash() { + XCTAssertEqual("/test".trimSlashes(), "test") + } + + func test_suffix_slash() { + XCTAssertEqual("test/".trimSlashes(), "test") + } + + func test_prefix_and_suffix_slashes() { + XCTAssertEqual("/test/".trimSlashes(), "test") + } + + func test_prefix_suffix_and_extra_slashes() { + XCTAssertEqual("/te/st/".trimSlashes(), "te/st") + } +} diff --git a/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift b/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift index 33d3509583f..8c3b08242c7 100644 --- a/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift +++ b/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift @@ -4,35 +4,34 @@ import Alamofire final class RequestAuthenticatorTests: XCTestCase { - func test_authenticateRequest_returns_unauthenticated_request_for_non_REST_request_without_WPCOM_credentials() { + func test_authenticateRequest_returns_unauthenticated_request_for_non_REST_request_without_WPCOM_credentials() throws { // Given let authenticator = RequestAuthenticator(credentials: nil) - let request = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + let converter = RequestConverter(credentials: nil) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) // When - var updatedRequest: URLRequestConvertible? - authenticator.authenticateRequest(request) { result in - updatedRequest = try? result.get() - } + let request = try converter.convert(jetpackRequest).asURLRequest() + let updatedRequest = try authenticator.authenticate(request) // Then - XCTAssertTrue(updatedRequest is UnauthenticatedRequest) + XCTAssertNil(updatedRequest.allHTTPHeaderFields?["Authorization"]) } - func test_authenticatedRequest_returns_authenticated_request_for_non_REST_request_with_WPCOM_credentials() { + func test_authenticatedRequest_returns_authenticated_request_for_non_REST_request_with_WPCOM_credentials() throws { // Given let credentials = Credentials(authToken: "secret") let authenticator = RequestAuthenticator(credentials: credentials) - let request = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + let converter = RequestConverter(credentials: credentials) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) // When - var updatedRequest: URLRequestConvertible? - authenticator.authenticateRequest(request) { result in - updatedRequest = try? result.get() - } + let request = try converter.convert(jetpackRequest).asURLRequest() + let updatedRequest = try authenticator.authenticate(request) // Then - XCTAssertTrue(updatedRequest is AuthenticatedRequest) + let authorizationValue = try XCTUnwrap(updatedRequest.allHTTPHeaderFields?["Authorization"]) + XCTAssertTrue(authorizationValue.hasPrefix("Bearer")) } func test_authenticatedRequest_returns_REST_request_with_authorization_header_if_application_password_is_available() throws { @@ -41,80 +40,81 @@ final class RequestAuthenticatorTests: XCTestCase { let applicationPassword = ApplicationPassword(wpOrgUsername: credentials.username, password: .init(credentials.secret)) let useCase = MockApplicationPasswordUseCase(mockApplicationPassword: applicationPassword) let authenticator = RequestAuthenticator(credentials: credentials, applicationPasswordUseCase: useCase) + let converter = RequestConverter(credentials: credentials) let wooAPIVersion = WooAPIVersion.mark1 - let basePath = "wp-json" + let basePath = RESTRequest.Settings.basePath let jetpackRequest = JetpackRequest(wooApiVersion: wooAPIVersion, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) // When - var updatedRequest: URLRequestConvertible? - waitForExpectation { expectation in - authenticator.authenticateRequest(jetpackRequest) { result in - updatedRequest = try? result.get() - expectation.fulfill() - } - } + let request = try converter.convert(jetpackRequest).asURLRequest() + let updatedRequest = try authenticator.authenticate(request) // Then - let request = try XCTUnwrap(updatedRequest as? URLRequest) let expectedURL = "https://test.com/\(basePath)\(wooAPIVersion.path)test" - assertEqual(expectedURL, request.url?.absoluteString) - let authorizationValue = try XCTUnwrap(request.allHTTPHeaderFields?["Authorization"]) + assertEqual(expectedURL, updatedRequest.url?.absoluteString) + let authorizationValue = try XCTUnwrap(updatedRequest.allHTTPHeaderFields?["Authorization"]) XCTAssertTrue(authorizationValue.hasPrefix("Basic")) } - func test_authenticatedRequest_returns_REST_request_with_authorization_header_if_application_password_generation_succeeds() throws { + func test_authenticatedRequest_returns_REST_request_with_authorization_header_if_application_password_generation_succeeds() async throws { // Given let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: "https://test.com/") let applicationPassword = ApplicationPassword(wpOrgUsername: credentials.username, password: .init(credentials.secret)) let useCase = MockApplicationPasswordUseCase(mockGeneratedPassword: applicationPassword) let authenticator = RequestAuthenticator(credentials: credentials, applicationPasswordUseCase: useCase) + let converter = RequestConverter(credentials: credentials) let wooAPIVersion = WooAPIVersion.mark1 - let basePath = "wp-json" + let basePath = RESTRequest.Settings.basePath let jetpackRequest = JetpackRequest(wooApiVersion: wooAPIVersion, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) // When - var updatedRequest: URLRequestConvertible? - waitForExpectation { expectation in - authenticator.authenticateRequest(jetpackRequest) { result in - updatedRequest = try? result.get() - expectation.fulfill() - } + let request = try converter.convert(jetpackRequest).asURLRequest() + do { + let _ = try authenticator.authenticate(request) + } catch RequestAuthenticatorError.applicationPasswordNotAvailable { + try await authenticator.generateApplicationPassword() } + let updatedRequest = try authenticator.authenticate(request) + // Then - let request = try XCTUnwrap(updatedRequest as? URLRequest) let expectedURL = "https://test.com/\(basePath)\(wooAPIVersion.path)test" - assertEqual(expectedURL, request.url?.absoluteString) - let authorizationValue = try XCTUnwrap(request.allHTTPHeaderFields?["Authorization"]) + assertEqual(expectedURL, updatedRequest.url?.absoluteString) + let authorizationValue = try XCTUnwrap(updatedRequest.allHTTPHeaderFields?["Authorization"]) XCTAssertTrue(authorizationValue.hasPrefix("Basic")) } - func test_authenticatedRequest_returns_error_if_generating_application_password_fails_for_REST_request() throws { + func test_authenticatedRequest_returns_error_if_generating_application_password_fails_for_REST_request() async throws { // Given let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: "https://test.com/") let useCase = MockApplicationPasswordUseCase(mockGenerationError: NetworkError.timeout) let authenticator = RequestAuthenticator(credentials: credentials, applicationPasswordUseCase: useCase) + let converter = RequestConverter(credentials: credentials) let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) + let exp = expectation(description: "Failed with `NetworkError.timeout` error") + // When - var error: Error? - waitForExpectation { expectation in - authenticator.authenticateRequest(jetpackRequest) { result in - error = result.failure - expectation.fulfill() + let request = try converter.convert(jetpackRequest).asURLRequest() + do { + let _ = try authenticator.authenticate(request) + } catch RequestAuthenticatorError.applicationPasswordNotAvailable { + // Then + do { + try await authenticator.generateApplicationPassword() + let _ = try authenticator.authenticate(request) + } catch NetworkError.timeout { + exp.fulfill() } } - - // Then - let networkError = try XCTUnwrap(error as? NetworkError) - XCTAssertEqual(networkError, NetworkError.timeout) + await waitForExpectations(timeout: Constants.expectationTimeout, handler: nil) } } /// MOCK: application password use case /// private final class MockApplicationPasswordUseCase: ApplicationPasswordUseCase { - let mockApplicationPassword: ApplicationPassword? + var mockApplicationPassword: ApplicationPassword? let mockGeneratedPassword: ApplicationPassword? let mockGenerationError: Error? let mockDeletionError: Error? @@ -134,6 +134,8 @@ private final class MockApplicationPasswordUseCase: ApplicationPasswordUseCase { func generateNewPassword() async throws -> Networking.ApplicationPassword { if let mockGeneratedPassword { + // Store the newly generated password + mockApplicationPassword = mockGeneratedPassword return mockGeneratedPassword } throw mockGenerationError ?? NetworkError.notFound diff --git a/Networking/NetworkingTests/Network/RequestConverterTests.swift b/Networking/NetworkingTests/Network/RequestConverterTests.swift new file mode 100644 index 00000000000..f27c56cd67c --- /dev/null +++ b/Networking/NetworkingTests/Network/RequestConverterTests.swift @@ -0,0 +1,69 @@ +import XCTest +import Alamofire +@testable import Networking + +final class RequestConvertorTests: XCTestCase { + func test_jetpack_request_is_returned_when_credentials_not_available() { + // Given + let converter = RequestConverter(credentials: nil) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + + // When + let request = converter.convert(jetpackRequest) + + // Then + XCTAssertTrue(request is JetpackRequest) + } + + func test_jetpack_request_is_returned_for_WPCOM_credentials_when_available_as_REST_request() { + // Given + let credentials = Credentials(authToken: "secret") + let converter = RequestConverter(credentials: credentials) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) + + // When + let request = converter.convert(jetpackRequest) + + // Then + XCTAssertTrue(request is JetpackRequest) + } + + func test_jetpack_request_is_returned_for_WPCOM_credentials_when_not_available_as_REST_request() { + // Given + let credentials = Credentials(authToken: "secret") + let converter = RequestConverter(credentials: credentials) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + + // When + let request = converter.convert(jetpackRequest) + + // Then + XCTAssertTrue(request is JetpackRequest) + } + + func test_REST_request_is_returned_for_WPOrg_credentials_when_available_as_REST_request() { + // Given + let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: "https://test.com/") + let converter = RequestConverter(credentials: credentials) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) + + // When + let request = converter.convert(jetpackRequest) + + // Then + XCTAssertTrue(request is RESTRequest) + } + + func test_jetpack_request_is_returned_for_WPOrg_credentials_when_not_available_as_REST_request() { + // Given + let credentials: Credentials = .wporg(username: "admin", password: "supersecret", siteAddress: "https://test.com/") + let converter = RequestConverter(credentials: credentials) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + + // When + let request = converter.convert(jetpackRequest) + + // Then + XCTAssertTrue(request is JetpackRequest) + } +}