diff --git a/Networking/Networking.xcodeproj/project.pbxproj b/Networking/Networking.xcodeproj/project.pbxproj index c28b3307a40..4d94794a9b0 100644 --- a/Networking/Networking.xcodeproj/project.pbxproj +++ b/Networking/Networking.xcodeproj/project.pbxproj @@ -709,6 +709,11 @@ 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 */; }; + DEFBA759294AC3C200C35BA9 /* coupon-without-data.json in Resources */ = {isa = PBXBuildFile; fileRef = DEFBA757294AC3C200C35BA9 /* coupon-without-data.json */; }; + DEFBA75A294AC3C200C35BA9 /* coupons-all-without-data.json in Resources */ = {isa = PBXBuildFile; fileRef = DEFBA758294AC3C200C35BA9 /* coupons-all-without-data.json */; }; 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 */; }; @@ -1467,6 +1472,11 @@ 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 = ""; }; + DEFBA757294AC3C200C35BA9 /* coupon-without-data.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "coupon-without-data.json"; sourceTree = ""; }; + DEFBA758294AC3C200C35BA9 /* coupons-all-without-data.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = "coupons-all-without-data.json"; 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 = ""; }; @@ -1695,6 +1705,7 @@ B518662320A099BF00037A38 /* AlamofireNetwork.swift */, B518662620A09BCC00037A38 /* MockNetwork.swift */, D87F6150226591E10031A13B /* NullNetwork.swift */, + DEFBA7532949CE6600C35BA9 /* RequestAuthenticator.swift */, ); path = Network; sourceTree = ""; @@ -1905,6 +1916,7 @@ B557D9FF209754FF005962F4 /* JetpackRequest.swift */, DE34051228BDCA5100CF0D97 /* WordPressOrgRequest.swift */, 029C9E5B291507A40013E5EE /* UnauthenticatedRequest.swift */, + DEFBA74D29485A7600C35BA9 /* RESTRequest.swift */, ); path = Requests; sourceTree = ""; @@ -2061,6 +2073,8 @@ 74A7B4BD217A841400E85A8B /* broken-settings-general.json */, 03DCB7512624B3BE00C8953D /* coupons-all.json */, 03DCB77D262738E200C8953D /* coupon.json */, + DEFBA757294AC3C200C35BA9 /* coupon-without-data.json */, + DEFBA758294AC3C200C35BA9 /* coupons-all-without-data.json */, DE2095C027966EC800171F1C /* coupon-reports.json */, B58D10C72114D21C00107ED4 /* generic_error.json */, B53EF53521813681003E146F /* generic_success.json */, @@ -2373,6 +2387,7 @@ isa = PBXGroup; children = ( B57B1E6621C916850046E764 /* NetworkErrorTests.swift */, + DEFBA7552949D17300C35BA9 /* RequestAuthenticatorTests.swift */, ); path = Network; sourceTree = ""; @@ -2754,6 +2769,7 @@ 31054728262E2FEE00C5C02B /* wcpay-payment-intent-canceled.json in Resources */, 31A451CC27863A2E00FE81AA /* stripe-account-rejected-fraud.json in Resources */, 31A451D827863A2E00FE81AA /* stripe-account-restricted-overdue.json in Resources */, + DEFBA75A294AC3C200C35BA9 /* coupons-all-without-data.json in Resources */, D865CE69278CA245002C8520 /* stripe-payment-intent-unknown-status.json in Resources */, 0205021C27C86B9700FB1C6B /* inbox-note-without-isRead.json in Resources */, 24F98C622502EFF600F49B68 /* feature-flags-load-all.json in Resources */, @@ -2834,6 +2850,7 @@ 45CCFCEA27A2E59B0012E8CB /* inbox-note-list.json in Resources */, 025CA2C8238F4FF400B05C81 /* product-shipping-classes-load-all.json in Resources */, 74046E21217A73D0007DD7BF /* settings-general.json in Resources */, + DEFBA759294AC3C200C35BA9 /* coupon-without-data.json in Resources */, 03E8FEC427B40E3F00F5FC7D /* wcpay-charge-card-present-minimal.json in Resources */, 31054724262E2FC600C5C02B /* wcpay-payment-intent-requires-capture.json in Resources */, 7492FAE3217FBDBC00ED2C69 /* settings-general-alt.json in Resources */, @@ -3044,6 +3061,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 */, @@ -3057,6 +3075,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 */, EE54C8942947229800A9BF61 /* ApplicationPasswordUseCase.swift in Sources */, @@ -3458,6 +3477,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/Mapper/CouponListMapper.swift b/Networking/Networking/Mapper/CouponListMapper.swift index 4401492f65e..e4aa44e3102 100644 --- a/Networking/Networking/Mapper/CouponListMapper.swift +++ b/Networking/Networking/Mapper/CouponListMapper.swift @@ -11,8 +11,14 @@ struct CouponListMapper: Mapper { /// (Attempts) to convert a dictionary into `[Coupon]`. /// func map(response: Data) throws -> [Coupon] { - let coupons = try Coupon.decoder.decode(CouponListEnvelope.self, from: response).coupons - return coupons.map { $0.copy(siteID: siteID) } + let decoder = Coupon.decoder + do { + let coupons = try decoder.decode(CouponListEnvelope.self, from: response).coupons + return coupons.map { $0.copy(siteID: siteID) } + } catch { + return try decoder.decode([Coupon].self, from: response) + .map { $0.copy(siteID: siteID) } + } } } diff --git a/Networking/Networking/Mapper/CouponMapper.swift b/Networking/Networking/Mapper/CouponMapper.swift index 96fc5e8eea6..1b82b486e7c 100644 --- a/Networking/Networking/Mapper/CouponMapper.swift +++ b/Networking/Networking/Mapper/CouponMapper.swift @@ -11,8 +11,13 @@ struct CouponMapper: Mapper { /// (Attempts) to convert a dictionary into `Coupon`. /// func map(response: Data) throws -> Coupon { - let coupon = try Coupon.decoder.decode(CouponEnvelope.self, from: response).coupon - return coupon.copy(siteID: siteID) + let decoder = Coupon.decoder + do { + let coupon = try decoder.decode(CouponEnvelope.self, from: response).coupon + return coupon.copy(siteID: siteID) + } catch { + return try decoder.decode(Coupon.self, from: response).copy(siteID: siteID) + } } } diff --git a/Networking/Networking/Network/AlamofireNetwork.swift b/Networking/Networking/Network/AlamofireNetwork.swift index 84a99d128d7..f66f58f502a 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,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) { - let request = createRequest(wrapping: request) - - Alamofire.request(request) - .responseData { response in - completion(response.value, response.networkingError) - } + requestAuthenticator.authenticateRequest(request) { request in + Alamofire.request(request) + .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. @@ -63,10 +66,10 @@ 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) { request in + Alamofire.request(request).responseData { response in + completion(response.result.toSwiftResult()) + } } } @@ -79,12 +82,12 @@ 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) { request in + Alamofire.request(request).responseData { response in + let result = response.result.toSwiftResult() + promise(Swift.Result.success(result)) + } } }.eraseToAnyPublisher() } @@ -92,25 +95,31 @@ 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] request in + guard let self else { return } + 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) } } } } -private extension AlamofireNetwork { - func createRequest(wrapping request: URLRequestConvertible) -> URLRequestConvertible { - credentials.map { AuthenticatedRequest(credentials: $0, request: request) } ?? - UnauthenticatedRequest(request: request) +public extension AlamofireNetwork { + /// Updates the application password use case with a new site ID. + /// + func configureApplicationPasswordHandler(with siteID: Int64) { + guard let credentials else { + return + } + let applicationPasswordUseCase = TemporaryApplicationPasswordUseCase(siteID: siteID, credentials: credentials) + requestAuthenticator.updateApplicationPasswordHandler(with: applicationPasswordUseCase) } } diff --git a/Networking/Networking/Network/RequestAuthenticator.swift b/Networking/Networking/Network/RequestAuthenticator.swift new file mode 100644 index 00000000000..41c2353070b --- /dev/null +++ b/Networking/Networking/Network/RequestAuthenticator.swift @@ -0,0 +1,88 @@ +import Alamofire +import Foundation + +// TODO: Replace with actual implementation. +final class TemporaryApplicationPasswordUseCase: ApplicationPasswordUseCase { + init(siteID: Int64, credentials: Credentials) { + // no-op + } + + var applicationPassword: ApplicationPassword? { + return nil + } + + func generateNewPassword() async throws -> ApplicationPassword { + return .init(wpOrgUsername: "test", password: .init("12345")) + } + + func deletePassword() async throws { + // no-op + } +} + +/// 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 var applicationPasswordUseCase: ApplicationPasswordUseCase? + + init(credentials: Credentials?) { + self.credentials = credentials + } + + /// Updates the application password use case with a new site ID. + /// + func updateApplicationPasswordHandler(with useCase: ApplicationPasswordUseCase) { + applicationPasswordUseCase = useCase + } + + /// Updates a request with application password or WPCOM token if possible. + /// + func authenticateRequest(_ request: URLRequestConvertible, completion: @escaping (URLRequestConvertible) -> Void) { + guard let restRequest = request as? RESTRequest, + let useCase = applicationPasswordUseCase else { + // Handle non-REST requests as before + return completion(authenticateUsingWPCOMTokenIfPossible(request)) + } + Task(priority: .medium) { + let result = await authenticateUsingApplicationPassword(restRequest, useCase: useCase) + await MainActor.run { + completion(result) + } + } + } + + /// Attempts authenticating a request with application password. + /// + private func authenticateUsingApplicationPassword(_ restRequest: RESTRequest, useCase: ApplicationPasswordUseCase) async -> URLRequestConvertible { + do { + 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) + } + } catch { + DDLogWarn("⚠️ Error generating application password and update request: \(error)") + // TODO: add Tracks + // Get the fallback Jetpack request to handle if possible. + let fallbackRequest: URLRequestConvertible = restRequest.fallbackRequest ?? restRequest + return authenticateUsingWPCOMTokenIfPossible(fallbackRequest) + } + } + + /// 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/RESTRequest.swift b/Networking/Networking/Requests/RESTRequest.swift new file mode 100644 index 00000000000..d7ad5b6964c --- /dev/null +++ b/Networking/Networking/Requests/RESTRequest.swift @@ -0,0 +1,82 @@ +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]? + + /// HTTP Headers + let headers: [String: String] + + /// A fallback JetpackRequest if the REST request cannot be made with an application password. + let fallbackRequest: JetpackRequest? + + /// 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. + /// - 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] = [:], + headers: [String: String] = [:], + fallbackRequest: JetpackRequest?) { + self.siteURL = siteURL + self.method = method + self.path = path + self.parameters = parameters + self.headers = headers + self.fallbackRequest = fallbackRequest + } + + /// 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, headers: headers) + + 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/Mapper/CouponListMapperTests.swift b/Networking/NetworkingTests/Mapper/CouponListMapperTests.swift index 4415f8d236c..30fddabf150 100644 --- a/Networking/NetworkingTests/Mapper/CouponListMapperTests.swift +++ b/Networking/NetworkingTests/Mapper/CouponListMapperTests.swift @@ -9,15 +9,22 @@ class CouponListMapperTests: XCTestCase { /// Verifies that the whole list is parsed, minus the items with non-default discount type. /// - func test_CouponsList_map_parses_all_coupons_in_response() throws { - let coupons = try mapLoadAllCouponsResponse() + func test_CouponsList_map_parses_all_coupons_in_response_with_data_envelope() throws { + let coupons = try mapLoadAllCouponsResponseWithDataEnvelope() + XCTAssertEqual(coupons.count, 4) + } + + /// Verifies that the whole list is parsed, minus the items with non-default discount type. + /// + func test_CouponsList_map_parses_all_coupons_in_response_without_data_envelope() throws { + let coupons = try mapLoadAllCouponsResponseWithoutDataEnvelope() XCTAssertEqual(coupons.count, 4) } /// Verifies that the `siteID` is added in the mapper, to all results, because it's not provided by the API endpoint /// func test_CouponsList_map_includes_siteID_in_parsed_results() throws { - let coupons = try mapLoadAllCouponsResponse() + let coupons = try mapLoadAllCouponsResponseWithDataEnvelope() XCTAssertTrue(coupons.count > 0) for coupon in coupons { @@ -28,7 +35,7 @@ class CouponListMapperTests: XCTestCase { /// Verifies that the fields are all parsed correctly /// func test_CouponsList_map_parses_all_fields_in_result() throws { - let coupons = try mapLoadAllCouponsResponse() + let coupons = try mapLoadAllCouponsResponseWithDataEnvelope() let coupon = coupons[0] let dateFormatter = DateFormatter.Defaults.dateTimeFormatter @@ -63,7 +70,7 @@ class CouponListMapperTests: XCTestCase { /// Verifies that nulls in optional fields are parsed correctly /// func test_CouponsList_map_accepts_nulls_in_expected_optional_fields() throws { - let coupons = try mapLoadAllCouponsResponse() + let coupons = try mapLoadAllCouponsResponseWithDataEnvelope() let coupon = coupons[2] let dateFormatter = DateFormatter.Defaults.dateTimeFormatter @@ -113,7 +120,13 @@ private extension CouponListMapperTests { /// Returns the CouponsMapper output from `coupons-all.json` /// - func mapLoadAllCouponsResponse() throws -> [Coupon] { + func mapLoadAllCouponsResponseWithDataEnvelope() throws -> [Coupon] { return try mapCoupons(from: "coupons-all") } + + /// Returns the CouponsMapper output from `coupons-all-without-data.json` + /// + func mapLoadAllCouponsResponseWithoutDataEnvelope() throws -> [Coupon] { + return try mapCoupons(from: "coupons-all-without-data") + } } diff --git a/Networking/NetworkingTests/Mapper/CouponMapperTests.swift b/Networking/NetworkingTests/Mapper/CouponMapperTests.swift index 25f1c565c43..d8042b1507b 100644 --- a/Networking/NetworkingTests/Mapper/CouponMapperTests.swift +++ b/Networking/NetworkingTests/Mapper/CouponMapperTests.swift @@ -9,21 +9,28 @@ final class CouponMapperTests: XCTestCase { /// Verifies that the coupon is parsed. /// - func test_Coupon_map_parses_all_coupons_in_response() throws { + func test_Coupon_map_parses_coupon_in_response() throws { let coupon = try mapRetrieveCouponResponse() XCTAssertNotNil(coupon) } + /// Verifies that the coupon is parsed. + /// + func test_Coupon_map_parses_coupon_in_response_without_data_envelope() throws { + let coupon = try mapRetrieveCouponResponseWithoutDataEnvelope() + XCTAssertNotNil(coupon) + } + /// Verifies that the `siteID` is added in the mapper, because it's not provided by the API endpoint /// - func test_CouponsList_map_includes_siteID_in_parsed_results() throws { + func test_coupon_map_includes_siteID_in_parsed_results() throws { let coupon = try mapRetrieveCouponResponse() XCTAssertEqual(coupon.siteID, dummySiteID) } /// Verifies that the fields are all parsed correctly /// - func test_CouponsList_map_parses_all_fields_in_result() throws { + func test_coupon_map_parses_all_fields_in_result() throws { let coupon = try mapRetrieveCouponResponse() let dateFormatter = DateFormatter.Defaults.dateTimeFormatter @@ -78,5 +85,11 @@ private extension CouponMapperTests { return try mapCoupon(from: "coupon") } + /// Returns the CouponMapper output from `coupon-without-data.json` + /// + func mapRetrieveCouponResponseWithoutDataEnvelope() throws -> Coupon { + return try mapCoupon(from: "coupon-without-data") + } + struct FileNotFoundError: Error {} } diff --git a/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift b/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift new file mode 100644 index 00000000000..f2441faff19 --- /dev/null +++ b/Networking/NetworkingTests/Network/RequestAuthenticatorTests.swift @@ -0,0 +1,148 @@ +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") + + // When + var result: URLRequestConvertible? + authenticator.authenticateRequest(request) { updatedRequest in + result = updatedRequest + } + + // Then + XCTAssertTrue(result 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") + + // When + var result: URLRequestConvertible? + authenticator.authenticateRequest(request) { updatedRequest in + result = updatedRequest + } + + // Then + XCTAssertTrue(result is AuthenticatedRequest) + } + + func test_authenticatedRequest_returns_REST_request_with_authorization_header_if_application_password_is_available() throws { + // Given + let credentials = Credentials(authToken: "secret") + let applicationPassword = ApplicationPassword(wpOrgUsername: "admin", password: .init("supersecret")) + let authenticator = RequestAuthenticator(credentials: credentials) + let useCase = MockApplicationPasswordUseCase(mockApplicationPassword: applicationPassword) + let fallbackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test") + let restRequest = RESTRequest(siteURL: "https://test.com", method: .get, path: "/test", fallbackRequest: fallbackRequest) + + // When + var result: URLRequestConvertible? + authenticator.updateApplicationPasswordHandler(with: useCase) + waitForExpectation { expectation in + authenticator.authenticateRequest(restRequest) { updatedRequest in + result = updatedRequest + expectation.fulfill() + } + } + + // Then + let request = try XCTUnwrap(result 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(authToken: "secret") + let applicationPassword = ApplicationPassword(wpOrgUsername: "admin", password: .init("supersecret")) + let authenticator = RequestAuthenticator(credentials: credentials) + let useCase = MockApplicationPasswordUseCase(mockGeneratedPassword: applicationPassword) + let fallbackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test") + let restRequest = RESTRequest(siteURL: "https://test.com", method: .get, path: "/test", fallbackRequest: fallbackRequest) + + // When + var result: URLRequestConvertible? + authenticator.updateApplicationPasswordHandler(with: useCase) + waitForExpectation { expectation in + authenticator.authenticateRequest(restRequest) { updatedRequest in + result = updatedRequest + expectation.fulfill() + } + } + + // Then + let request = try XCTUnwrap(result 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_fallback_request_if_generating_application_password_fails_for_REST_request() { + // Given + let credentials = Credentials(authToken: "secret") + let authenticator = RequestAuthenticator(credentials: credentials) + let useCase = MockApplicationPasswordUseCase(mockGenerationError: NetworkError.timeout) + let fallbackRequest = JetpackRequest(wooApiVersion: .mark1, method: .get, siteID: 123, path: "test") + let restRequest = RESTRequest(siteURL: "https://test.com", method: .get, path: "/test", fallbackRequest: fallbackRequest) + + // When + var result: URLRequestConvertible? + authenticator.updateApplicationPasswordHandler(with: useCase) + waitForExpectation { expectation in + authenticator.authenticateRequest(restRequest) { updatedRequest in + result = updatedRequest + expectation.fulfill() + } + } + + // Then + XCTAssertTrue(result is AuthenticatedRequest) + let expectedURL = "https://public-api.wordpress.com/rest/v1.1/jetpack-blogs/123/rest-api/?json=true&path=/wc/v1/test%26_method%3Dget" + assertEqual(expectedURL, result?.urlRequest?.url?.absoluteString) + } +} + +/// 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/Networking/NetworkingTests/Responses/coupon-without-data.json b/Networking/NetworkingTests/Responses/coupon-without-data.json new file mode 100644 index 00000000000..0c096ef14ba --- /dev/null +++ b/Networking/NetworkingTests/Responses/coupon-without-data.json @@ -0,0 +1,41 @@ +{ + "id": 720, + "code": "free shipping", + "amount": "10.00", + "date_created": "2017-03-21T15:25:02", + "date_created_gmt": "2017-03-21T18:25:02", + "date_modified": "2017-03-21T15:25:02", + "date_modified_gmt": "2017-03-21T18:25:02", + "discount_type": "fixed_cart", + "description": "Coupon description", + "date_expires": "2017-03-31T15:25:02", + "date_expires_gmt": "2017-03-31T18:25:02", + "usage_count": 10, + "individual_use": true, + "product_ids": [12893712, 12389], + "excluded_product_ids": [12213], + "usage_limit": 1200, + "usage_limit_per_user": 3, + "limit_usage_to_x_items": 10, + "free_shipping": true, + "product_categories": [123, 435, 232], + "excluded_product_categories": [908], + "exclude_sale_items": false, + "minimum_amount": "5.00", + "maximum_amount": "500.00", + "email_restrictions": ["*@a8c.com", "someone.else@example.com"], + "used_by": ["someone.else@example.com", "person@a8c.com"], + "meta_data": [], + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/coupons/720" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/coupons" + } + ] + } +} diff --git a/Networking/NetworkingTests/Responses/coupons-all-without-data.json b/Networking/NetworkingTests/Responses/coupons-all-without-data.json new file mode 100644 index 00000000000..02031d2d025 --- /dev/null +++ b/Networking/NetworkingTests/Responses/coupons-all-without-data.json @@ -0,0 +1,166 @@ +[ + { + "id": 720, + "code": "free shipping", + "amount": "10.00", + "date_created": "2017-03-21T15:25:02", + "date_created_gmt": "2017-03-21T18:25:02", + "date_modified": "2017-03-21T15:25:02", + "date_modified_gmt": "2017-03-21T18:25:02", + "discount_type": "fixed_cart", + "description": "Coupon description", + "date_expires": "2017-03-31T15:25:02", + "date_expires_gmt": "2017-03-31T18:25:02", + "usage_count": 10, + "individual_use": true, + "product_ids": [12893712, 12389], + "excluded_product_ids": [12213], + "usage_limit": 1200, + "usage_limit_per_user": 3, + "limit_usage_to_x_items": 10, + "free_shipping": true, + "product_categories": [123, 435, 232], + "excluded_product_categories": [908], + "exclude_sale_items": false, + "minimum_amount": "5.00", + "maximum_amount": "500.00", + "email_restrictions": ["*@a8c.com", "someone.else@example.com"], + "used_by": ["someone.else@example.com", "person@a8c.com"], + "meta_data": [], + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/coupons/720" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/coupons" + } + ] + } + }, + { + "id": 719, + "code": "10off", + "amount": "10.00", + "date_created": "2017-03-21T15:23:00", + "date_created_gmt": "2017-03-21T18:23:00", + "date_modified": "2017-03-21T15:23:00", + "date_modified_gmt": "2017-03-21T18:23:00", + "discount_type": "fixed_cart", + "description": "", + "date_expires": null, + "date_expires_gmt": null, + "usage_count": 0, + "individual_use": true, + "product_ids": [], + "excluded_product_ids": [], + "usage_limit": null, + "usage_limit_per_user": null, + "limit_usage_to_x_items": null, + "free_shipping": false, + "product_categories": [], + "excluded_product_categories": [], + "exclude_sale_items": true, + "minimum_amount": "100.00", + "maximum_amount": "0.00", + "email_restrictions": [], + "used_by": [], + "meta_data": [], + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/coupons/719" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/coupons" + } + ] + } + }, + { + "id": 10714, + "code": "test", + "amount": "0.00", + "date_created": "2021-04-13T08:26:25", + "date_created_gmt": "2021-04-13T08:26:25", + "date_modified": "2021-04-13T08:26:25", + "date_modified_gmt": "2021-04-13T08:26:25", + "discount_type": "percent", + "description": "", + "date_expires": null, + "date_expires_gmt": null, + "usage_count": 0, + "individual_use": false, + "product_ids": [], + "excluded_product_ids": [], + "usage_limit": null, + "usage_limit_per_user": null, + "limit_usage_to_x_items": null, + "free_shipping": false, + "product_categories": [], + "excluded_product_categories": [], + "exclude_sale_items": false, + "minimum_amount": "0.00", + "maximum_amount": "0.00", + "email_restrictions": [], + "used_by": [], + "meta_data": [], + "_links": { + "self": [ + { + "href": "https://example.com/wp-json/wc/v3/coupons/720" + } + ], + "collection": [ + { + "href": "https://example.com/wp-json/wc/v3/coupons" + } + ] + } + }, + { + "id": 581, + "code": "usrufb6q", + "amount": "30.00", + "date_created": "2022-01-07T10:59:47", + "date_created_gmt": "2022-01-07T03:59:47", + "date_modified": "2022-01-07T10:59:56", + "date_modified_gmt": "2022-01-07T03:59:56", + "discount_type": "sign_up_fee_percent", + "description": "", + "date_expires": null, + "date_expires_gmt": null, + "usage_count": 0, + "individual_use": false, + "product_ids": [], + "excluded_product_ids": [], + "usage_limit": null, + "usage_limit_per_user": null, + "limit_usage_to_x_items": null, + "free_shipping": false, + "product_categories": [], + "excluded_product_categories": [], + "exclude_sale_items": false, + "minimum_amount": "0.00", + "maximum_amount": "0.00", + "email_restrictions": [], + "used_by": [], + "meta_data": [], + "_links": { + "self": [ + { + "href": "https://huongdotests1.blog/wp-json/wc/v3/coupons/581" + } + ], + "collection": [ + { + "href": "https://huongdotests1.blog/wp-json/wc/v3/coupons" + } + ] + } + } +] diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index ec0452429c0..362671e5e85 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -19,12 +19,16 @@ class AuthenticatedState: StoresManagerState { /// private var errorObserverToken: NSObjectProtocol? + /// The network to handle network requests. + /// + private let network: AlamofireNetwork + /// Designated Initializer /// init(credentials: Credentials) { let storageManager = ServiceLocator.storageManager - let network = AlamofireNetwork(credentials: credentials) + network = AlamofireNetwork(credentials: credentials) services = [ AccountStore(dispatcher: dispatcher, storageManager: storageManager, network: network, dotcomAuthToken: credentials.authToken), @@ -124,6 +128,12 @@ class AuthenticatedState: StoresManagerState { func onAction(_ action: Action) { dispatcher.dispatch(action) } + + /// Updates the network with the currently selected site. + /// + func updateCurrentSite(siteID: Int64) { + network.configureApplicationPasswordHandler(with: siteID) + } } diff --git a/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift b/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift index e93e637a686..36b82e5340e 100644 --- a/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift +++ b/WooCommerce/Classes/Yosemite/DefaultStoresManager.swift @@ -195,6 +195,10 @@ class DefaultStoresManager: StoresManager { restoreSessionSiteIfPossible() ServiceLocator.pushNotesManager.reloadBadgeCount() + if let state = self.state as? AuthenticatedState { + state.updateCurrentSite(siteID: storeID) + } + NotificationCenter.default.post(name: .StoresManagerDidUpdateDefaultSite, object: nil) }