diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index db2fb9a2951..6ae70ef3510 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -714,6 +714,9 @@ DEC51AF92769A212009F3DF4 /* SystemStatus+Settings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC51AF82769A212009F3DF4 /* SystemStatus+Settings.swift */; }; 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 */; }; + 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 */; }; E137619B2915222100FD098F /* WordPressApiValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E137619A2915222100FD098F /* WordPressApiValidatorTests.swift */; }; @@ -1482,6 +1485,9 @@ DEC51AF82769A212009F3DF4 /* SystemStatus+Settings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SystemStatus+Settings.swift"; sourceTree = ""; }; 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 = ""; }; + 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 = ""; }; E137619A2915222100FD098F /* WordPressApiValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordPressApiValidatorTests.swift; sourceTree = ""; }; @@ -1716,6 +1722,7 @@ B518662320A099BF00037A38 /* AlamofireNetwork.swift */, B518662620A09BCC00037A38 /* MockNetwork.swift */, D87F6150226591E10031A13B /* NullNetwork.swift */, + DEFBA7532949CE6600C35BA9 /* RequestAuthenticator.swift */, ); path = Network; sourceTree = ""; @@ -1927,6 +1934,7 @@ B557D9FF209754FF005962F4 /* JetpackRequest.swift */, DE34051228BDCA5100CF0D97 /* WordPressOrgRequest.swift */, 029C9E5B291507A40013E5EE /* UnauthenticatedRequest.swift */, + DEFBA74D29485A7600C35BA9 /* RESTRequest.swift */, ); path = Requests; sourceTree = ""; @@ -2400,6 +2408,7 @@ isa = PBXGroup; children = ( B57B1E6621C916850046E764 /* NetworkErrorTests.swift */, + DEFBA7552949D17300C35BA9 /* RequestAuthenticatorTests.swift */, ); path = Network; sourceTree = ""; @@ -3095,6 +3104,7 @@ CE132BBC223859710029DB6C /* ProductTag.swift in Sources */, 26650332261FFA1A0079A159 /* ProductAddOnEnvelope.swift in Sources */, D88D5A47230BC838007B6E01 /* ProductReview.swift in Sources */, + DEFBA74E29485A7600C35BA9 /* RESTRequest.swift in Sources */, 456930A9264EB576009ED69D /* ShippingLabelCarriersAndRates.swift in Sources */, 741B950120EBC8A700DD6E2D /* OrderCouponLine.swift in Sources */, 020D07BA23D8542000FD9580 /* UploadableMedia.swift in Sources */, @@ -3108,6 +3118,7 @@ 020D07B823D852BB00FD9580 /* Media.swift in Sources */, B5BB1D0C20A2050300112D92 /* DateFormatter+Woo.swift in Sources */, 743E84EE2217244C00FAC9D7 /* ShipmentTrackingListMapper.swift in Sources */, + DEFBA7542949CE6600C35BA9 /* RequestAuthenticator.swift in Sources */, 451A97E5260B631E0059D135 /* ShippingLabelPredefinedPackage.swift in Sources */, BAB373722795A1FB00837B4A /* OrderTaxLine.swift in Sources */, EE54C89F2947782E00A9BF61 /* ApplicationPasswordUseCase.swift in Sources */, @@ -3514,6 +3525,7 @@ 0212683524C046CB00F8A892 /* MockNetwork+Path.swift in Sources */, 68BD37B328D9B8BD00C2A517 /* CustomerRemoteTests.swift in Sources */, B554FA932180C17200C54DFF /* NoteHashListMapperTests.swift in Sources */, + DEFBA7562949D17400C35BA9 /* RequestAuthenticatorTests.swift in Sources */, CC07866526790B1100BA9AC1 /* ShippingLabelPurchaseMapperTests.swift in Sources */, 74002D6A2118B26100A63C19 /* SiteVisitStatsMapperTests.swift in Sources */, 743E84FA221742E300FAC9D7 /* ShipmentsRemoteTests.swift in Sources */, diff --git a/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift b/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift index 5cfcb0f8552..a1b19a12303 100644 --- a/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift +++ b/Networking/Networking/ApplicationPassword/ApplicationPasswordUseCase.swift @@ -63,11 +63,10 @@ final class DefaultApplicationPasswordUseCase: ApplicationPasswordUseCase { } } - @MainActor init(username: String, password: String, siteAddress: String, - network: Network? = nil) async throws { + network: Network? = nil) throws { self.siteAddress = siteAddress self.username = username diff --git a/Networking/Networking/Network/AlamofireNetwork.swift b/Networking/Networking/Network/AlamofireNetwork.swift index 84a99d128d7..4c45d4b8980 100644 --- a/Networking/Networking/Network/AlamofireNetwork.swift +++ b/Networking/Networking/Network/AlamofireNetwork.swift @@ -2,18 +2,20 @@ import Combine import Foundation import Alamofire - extension Alamofire.MultipartFormData: MultipartFormData {} /// AlamofireWrapper: Encapsulates all of the Alamofire OP's /// public class AlamofireNetwork: Network { + /// WordPress.com Credentials. + /// + private let credentials: Credentials? private let backgroundSessionManager: Alamofire.SessionManager - /// WordPress.com Credentials. + /// Authenticator to update requests authorization header if possible. /// - private let credentials: Credentials? + private let requestAuthenticator: RequestAuthenticator public var session: URLSession { SessionManager.default.session } @@ -21,6 +23,7 @@ public class AlamofireNetwork: Network { /// 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). @@ -45,12 +48,17 @@ 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) { - let request = createRequest(wrapping: request) - - Alamofire.request(request) - .responseData { response in - completion(response.value, response.networkingError) + 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) } + } } /// Executes the specified Network Request. Upon completion, the payload will be sent back to the caller as a Data instance. @@ -63,10 +71,15 @@ public class AlamofireNetwork: Network { /// - completion: Closure to be executed upon completion. /// public func responseData(for request: URLRequestConvertible, completion: @escaping (Swift.Result) -> Void) { - let request = createRequest(wrapping: request) - - Alamofire.request(request).responseData { response in - completion(response.result.toSwiftResult()) + 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)) + } } } @@ -79,12 +92,17 @@ public class AlamofireNetwork: Network { /// - Parameter request: Request that should be performed. /// - Returns: A publisher that emits the result of the given request. public func responseDataPublisher(for request: URLRequestConvertible) -> AnyPublisher, Never> { - let request = createRequest(wrapping: request) - return Future() { promise in - Alamofire.request(request).responseData { response in - let result = response.result.toSwiftResult() - promise(Swift.Result.success(result)) + 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))) + } } }.eraseToAnyPublisher() } @@ -92,13 +110,21 @@ public class AlamofireNetwork: Network { public func uploadMultipartFormData(multipartFormData: @escaping (MultipartFormData) -> Void, to request: URLRequestConvertible, completion: @escaping (Data?, Error?) -> Void) { - let request = createRequest(wrapping: request) - - backgroundSessionManager.upload(multipartFormData: multipartFormData, with: request) { (encodingResult) in - switch encodingResult { - case .success(let upload, _, _): - upload.responseData { response in - completion(response.value, response.error) + 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) + } } case .failure(let error): completion(nil, error) @@ -107,14 +133,6 @@ public class AlamofireNetwork: Network { } } -private extension AlamofireNetwork { - func createRequest(wrapping request: URLRequestConvertible) -> URLRequestConvertible { - credentials.map { AuthenticatedRequest(credentials: $0, request: request) } ?? - UnauthenticatedRequest(request: request) - } -} - - // MARK: - Alamofire.DataResponse: Helper Methods // extension Alamofire.DataResponse { diff --git a/Networking/Networking/Network/RequestAuthenticator.swift b/Networking/Networking/Network/RequestAuthenticator.swift new file mode 100644 index 00000000000..d931202cc73 --- /dev/null +++ b/Networking/Networking/Network/RequestAuthenticator.swift @@ -0,0 +1,79 @@ +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 { + credentials.map { AuthenticatedRequest(credentials: $0, request: request) } ?? + UnauthenticatedRequest(request: request) + } +} diff --git a/Networking/Networking/Requests/JetpackRequest.swift b/Networking/Networking/Requests/JetpackRequest.swift index 4eb9ac6227d..f7a119a766c 100644 --- a/Networking/Networking/Requests/JetpackRequest.swift +++ b/Networking/Networking/Requests/JetpackRequest.swift @@ -34,6 +34,10 @@ struct JetpackRequest: Request { /// let parameters: [String: Any] + /// Whether this request should be transformed to a REST request if application password is available. + /// + private let availableAsRESTRequest: Bool + /// Designated Initializer. /// @@ -43,8 +47,15 @@ struct JetpackRequest: Request { /// - siteID: Identifier of the Jetpack-Connected site we'll query. /// - path: RPC that should be called. /// - parameters: Collection of Key/Value parameters, to be forwarded to the Jetpack Connected site. - /// - init(wooApiVersion: WooAPIVersion, method: HTTPMethod, siteID: Int64, locale: String? = nil, path: String, parameters: [String: Any]? = nil) { + /// - availableAsRESTRequest: Whether the request should be transformed to a REST request if application password is available. + /// + init(wooApiVersion: WooAPIVersion, + method: HTTPMethod, + siteID: Int64, + locale: String? = nil, + path: String, + parameters: [String: Any]? = nil, + availableAsRESTRequest: Bool = false) { if [.mark1, .mark2].contains(wooApiVersion) { DDLogWarn("⚠️ You are using an older version of the Woo REST API: \(wooApiVersion.rawValue), for path: \(path)") } @@ -54,6 +65,7 @@ struct JetpackRequest: Request { self.locale = locale self.path = path self.parameters = parameters ?? [:] + self.availableAsRESTRequest = availableAsRESTRequest } @@ -69,6 +81,13 @@ struct JetpackRequest: Request { func responseDataValidator() -> ResponseDataValidator { return DotcomValidator() } + + func asRESTRequest(with siteURL: String) -> RESTRequest? { + guard availableAsRESTRequest else { + return nil + } + return RESTRequest(siteURL: siteURL, method: method, path: path, parameters: parameters) + } } diff --git a/Networking/Networking/Requests/RESTRequest.swift b/Networking/Networking/Requests/RESTRequest.swift new file mode 100644 index 00000000000..b06d30180f9 --- /dev/null +++ b/Networking/Networking/Requests/RESTRequest.swift @@ -0,0 +1,72 @@ +import Foundation +import Alamofire + +/// Wraps up a URLRequestConvertible Instance, and injects the Authorization + User Agent whenever the actual Request is required. +/// +struct RESTRequest: URLRequestConvertible { + /// URL of the site to make the request with + /// + let siteURL: String + + /// HTTP Request Method + /// + let method: HTTPMethod + + /// RPC + /// + let path: String + + /// Parameters + /// + let parameters: [String: Any]? + + /// Designated Initializer. + /// + /// - Parameters: + /// - siteURL: URL of the site to send the REST request to. + /// - method: HTTP Method we should use. + /// - path: path to the target endpoint. + /// - 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, + method: HTTPMethod, + path: String, + parameters: [String: Any] = [:]) { + self.siteURL = siteURL + self.method = method + self.path = path + self.parameters = parameters + } + + /// Returns a URLRequest instance representing the current REST API Request. + /// + func asURLRequest() throws -> URLRequest { + let url = try (siteURL + path).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.wpOrgUsername + let loginString = "\(username):\(password)" + guard let loginData = loginString.data(using: .utf8) else { + return request + } + let base64LoginString = loginData.base64EncodedString() + + request.setValue("Basic \(base64LoginString)", forHTTPHeaderField: "Authorization") + return request + } +} diff --git a/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift b/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift new file mode 100644 index 00000000000..78bfa3898f1 --- /dev/null +++ b/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift @@ -0,0 +1,141 @@ +import XCTest +import Alamofire +@testable import Networking + +final class RequestAuthenticatorTests: XCTestCase { + + func test_authenticateRequest_returns_unauthenticated_request_for_non_REST_request_without_WPCOM_credentials() { + // Given + let authenticator = RequestAuthenticator(credentials: nil) + let request = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + + // When + var updatedRequest: URLRequestConvertible? + authenticator.authenticateRequest(request) { result in + updatedRequest = try? result.get() + } + + // Then + XCTAssertTrue(updatedRequest is UnauthenticatedRequest) + } + + func test_authenticatedRequest_returns_authenticated_request_for_non_REST_request_with_WPCOM_credentials() { + // Given + let credentials = Credentials(authToken: "secret") + let authenticator = RequestAuthenticator(credentials: credentials) + let request = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: false) + + // When + var updatedRequest: URLRequestConvertible? + authenticator.authenticateRequest(request) { result in + updatedRequest = try? result.get() + } + + // Then + XCTAssertTrue(updatedRequest is AuthenticatedRequest) + } + + func test_authenticatedRequest_returns_REST_request_with_authorization_header_if_application_password_is_available() 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(mockApplicationPassword: applicationPassword) + let authenticator = RequestAuthenticator(credentials: credentials, applicationPasswordUseCase: useCase) + let jetpackRequest = JetpackRequest(wooApiVersion: .mark1, 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() + } + } + + // Then + let request = try XCTUnwrap(updatedRequest as? URLRequest) + let expectedURL = "https://test.com/test" + assertEqual(expectedURL, request.url?.absoluteString) + let authorizationValue = try XCTUnwrap(request.allHTTPHeaderFields?["Authorization"]) + XCTAssertTrue(authorizationValue.hasPrefix("Basic")) + } + + func test_authenticatedRequest_returns_REST_request_with_authorization_header_if_application_password_generation_succeeds() 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 jetpackRequest = JetpackRequest(wooApiVersion: .mark1, 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() + } + } + + // Then + let request = try XCTUnwrap(updatedRequest as? URLRequest) + let expectedURL = "https://test.com/test" + assertEqual(expectedURL, request.url?.absoluteString) + let authorizationValue = try XCTUnwrap(request.allHTTPHeaderFields?["Authorization"]) + XCTAssertTrue(authorizationValue.hasPrefix("Basic")) + } + + func test_authenticatedRequest_returns_error_if_generating_application_password_fails_for_REST_request() 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 jetpackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test", availableAsRESTRequest: true) + + // When + var error: Error? + waitForExpectation { expectation in + authenticator.authenticateRequest(jetpackRequest) { result in + error = result.failure + expectation.fulfill() + } + } + + // Then + let networkError = try XCTUnwrap(error as? NetworkError) + XCTAssertEqual(networkError, NetworkError.timeout) + } +} + +/// MOCK: application password use case +/// +private final class MockApplicationPasswordUseCase: ApplicationPasswordUseCase { + let mockApplicationPassword: ApplicationPassword? + let mockGeneratedPassword: ApplicationPassword? + let mockGenerationError: Error? + let mockDeletionError: Error? + init(mockApplicationPassword: ApplicationPassword? = nil, + mockGeneratedPassword: ApplicationPassword? = nil, + mockGenerationError: Error? = nil, + mockDeletionError: Error? = nil) { + self.mockApplicationPassword = mockApplicationPassword + self.mockGeneratedPassword = mockGeneratedPassword + self.mockGenerationError = mockGenerationError + self.mockDeletionError = mockDeletionError + } + + var applicationPassword: Networking.ApplicationPassword? { + mockApplicationPassword + } + + func generateNewPassword() async throws -> Networking.ApplicationPassword { + if let mockGeneratedPassword { + return mockGeneratedPassword + } + throw mockGenerationError ?? NetworkError.notFound + } + + func deletePassword() async throws { + throw mockDeletionError ?? NetworkError.notFound + } +} diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index 1b0f171af0a..81b392ea9ff 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -19,7 +19,6 @@ class AuthenticatedState: StoresManagerState { /// private var errorObserverToken: NSObjectProtocol? - /// Designated Initializer /// init(credentials: Credentials) {