diff --git a/Modules/Sources/Networking/Mapper/JetpackConnectionProvisionMapper.swift b/Modules/Sources/Networking/Mapper/JetpackConnectionProvisionMapper.swift new file mode 100644 index 00000000000..8f8000deb90 --- /dev/null +++ b/Modules/Sources/Networking/Mapper/JetpackConnectionProvisionMapper.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Mapper: Jetpack connection registration +/// +/// periphery: ignore - used in `JetpackConnectionRemote` +struct JetpackConnectionProvisionMapper: Mapper { + + /// (Attempts) to extract the updated `currentUser` field from a given JSON Encoded response. + /// + func map(response: Data) throws -> JetpackConnectionProvisionResponse { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(JetpackConnectionProvisionResponse.self, from: response) + } +} + +public struct JetpackConnectionProvisionResponse: Decodable { + public let userId: Int64 + public let scope: String + public let secret: String +} diff --git a/Modules/Sources/Networking/Mapper/JetpackConnectionRegistrationMapper.swift b/Modules/Sources/Networking/Mapper/JetpackConnectionRegistrationMapper.swift new file mode 100644 index 00000000000..d98aaa399b7 --- /dev/null +++ b/Modules/Sources/Networking/Mapper/JetpackConnectionRegistrationMapper.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Mapper: Jetpack connection registration +/// +/// periphery: ignore - used in `JetpackConnectionRemote` +struct JetpackConnectionRegistrationMapper: Mapper { + + /// (Attempts) to extract the updated `currentUser` field from a given JSON Encoded response. + /// + func map(response: Data) throws -> JetpackConnectionRegistration { + let decoder = JSONDecoder() + return try decoder.decode(JetpackConnectionRegistration.self, from: response) + } +} + +struct JetpackConnectionRegistration: Decodable { + let authorizeUrl: String +} diff --git a/Modules/Sources/Networking/Mapper/JetpackConnectionURLMapper.swift b/Modules/Sources/Networking/Mapper/JetpackConnectionURLMapper.swift index e1ba251fe0b..c312ed702c8 100644 --- a/Modules/Sources/Networking/Mapper/JetpackConnectionURLMapper.swift +++ b/Modules/Sources/Networking/Mapper/JetpackConnectionURLMapper.swift @@ -8,7 +8,7 @@ struct JetpackConnectionURLMapper: Mapper { /// func map(response: Data) throws -> URL { guard let escapedString = String(data: response, encoding: .utf8) else { - throw JetpackConnectionRemote.ConnectionError.malformedURL + throw JetpackConnectionError.malformedURL } // The API returns an escaped string with double quotes, so we need to clean it up. let urlString = escapedString diff --git a/Modules/Sources/Networking/Remote/JetpackConnectionRemote.swift b/Modules/Sources/Networking/Remote/JetpackConnectionRemote.swift index 30b3979ed47..08179c5fd44 100644 --- a/Modules/Sources/Networking/Remote/JetpackConnectionRemote.swift +++ b/Modules/Sources/Networking/Remote/JetpackConnectionRemote.swift @@ -56,19 +56,49 @@ public final class JetpackConnectionRemote: Remote { let mapper = JetpackConnectionDataMapper() enqueue(request, mapper: mapper, completion: completion) } -} -public extension JetpackConnectionRemote { - enum ConnectionError: Int, Error { - case malformedURL - case accountConnectionURLNotFound + /// Establishes a site-level connection between the site and WordPress.com using Jetpack. + /// Returns WPCom `blogID` of the connected site. + /// periphery: ignore - used in `JetpackConnectionStore` later + /// + public func registerSite() async throws -> Int64 { + let request = RESTRequest(siteURL: siteURL, method: .post, path: Path.jetpackConnectionRegister) + let mapper = JetpackConnectionRegistrationMapper() + let authorizationURL = try await enqueue(request, mapper: mapper).authorizeUrl + guard let components = URLComponents(string: authorizationURL), + let blogID = components.queryItems?.first(where: { $0.name == Constants.clientID })?.value as? String, + let numericID = Int64(blogID) else { + throw JetpackConnectionError.invalidAuthorizationURL + } + return numericID } + + /// Provisions the connection between the site and WordPress.com using Jetpack. + /// Returns a response containing scope and secret to be sent for finalizing the connection. + /// periphery: ignore - used in `JetpackConnectionStore` later + /// + public func provisionConnection() async throws -> JetpackConnectionProvisionResponse { + let request = RESTRequest(siteURL: siteURL, method: .post, path: Path.jetpackConnectionProvision) + let mapper = JetpackConnectionProvisionMapper() + return try await enqueue(request, mapper: mapper) + } +} + +/// periphery: ignore - used in test module and on the UI layer +public enum JetpackConnectionError: Error, Equatable { + case malformedURL + case accountConnectionURLNotFound + case invalidAuthorizationURL + case alreadyConnected + case connectionRequestFailed(message: String) } private extension JetpackConnectionRemote { enum Path { static let jetpackConnectionURL = "/jetpack/v4/connection/url" static let jetpackConnectionData = "/jetpack/v4/connection/data" + static let jetpackConnectionRegister = "/jetpack/v4/connection/register" + static let jetpackConnectionProvision = "/jetpack/v4/remote_provision" static let plugins = "/wp/v2/plugins" static let jetpackModule = "/jetpack/v4/module" } @@ -83,5 +113,6 @@ private extension JetpackConnectionRemote { static let jetpackPluginName = "jetpack/jetpack" static let jetpackPluginSlug = "jetpack" static let activeStatus = "active" + static let clientID = "client_id" } } diff --git a/Modules/Sources/Networking/Remote/SiteRemote.swift b/Modules/Sources/Networking/Remote/SiteRemote.swift index d1b302da921..25c5cc5de89 100644 --- a/Modules/Sources/Networking/Remote/SiteRemote.swift +++ b/Modules/Sources/Networking/Remote/SiteRemote.swift @@ -37,6 +37,17 @@ public protocol SiteRemoteProtocol { /// - title: The new title to be set for the site /// func updateSiteTitle(siteID: Int64, title: String) async throws + + /// Finalizes the Jetpack connection by sending a request to WPCom. + /// - Parameters: + /// - siteID: Remote ID of the site + /// - siteURL: URL of the site + /// - provisionResponse: Response from the provision connection call + /// periphery: ignore - used in test module + /// + func finalizeJetpackConnection(siteID: Int64, + siteURL: String, + provisionResponse: JetpackConnectionProvisionResponse) async throws } /// Site: Remote Endpoints @@ -161,6 +172,41 @@ public class SiteRemote: Remote, SiteRemoteProtocol { availableAsRESTRequest: true) try await enqueue(request) } + + /// Finalizes the Jetpack connection by sending a request to WPCom. + /// periphery: ignore - used in `JetpackConnectionStore` later + /// + public func finalizeJetpackConnection(siteID: Int64, + siteURL: String, + provisionResponse: JetpackConnectionProvisionResponse) async throws { + let parameters: [String: Any] = [ + ParameterKey.secret: provisionResponse.secret, + ParameterKey.scope: provisionResponse.scope, + ParameterKey.externalUserID: provisionResponse.userId, + ParameterKey.redirectURI: siteURL + ] + let request = DotcomRequest(wordpressApiVersion: .wpcomMark2, + method: .post, + path: String(format: Path.jetpackConnection, siteID), + parameters: parameters) + do { + try await enqueue(request) + } catch let error as WordPressApiError { + switch error { + case let .unknown(code, message): + if code == Constants.success { + return + } else if code == Constants.alreadyConnected { + throw JetpackConnectionError.alreadyConnected + } + throw JetpackConnectionError.connectionRequestFailed(message: message) + default: + throw error + } + } catch { + throw error + } + } } /// Possible Site Creation Flows @@ -329,8 +375,22 @@ private extension SiteRemote { static func siteSettings(siteID: Int64) -> String { "sites/\(siteID)/settings" } + + static let jetpackConnection = "sites/%d/jetpack-remote-connect-user" } enum Fields { static let title = "title" } + + enum Constants { + static let success = "success" + static let alreadyConnected = "already_connected" + } + + enum ParameterKey { + static let secret = "secret" + static let externalUserID = "external_user_id" + static let redirectURI = "redirect_uri" + static let scope = "scope" + } } diff --git a/Modules/Tests/NetworkingTests/Remote/JetpackConnectionRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/JetpackConnectionRemoteTests.swift index 29e20c9b4b6..f012fec2bee 100644 --- a/Modules/Tests/NetworkingTests/Remote/JetpackConnectionRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/JetpackConnectionRemoteTests.swift @@ -214,4 +214,80 @@ final class JetpackConnectionRemoteTests: XCTestCase { XCTAssertTrue(result.isFailure) XCTAssertEqual(result.failure as? NetworkError, error) } + + func test_registerSite_correctly_returns_blogID() async throws { + // Given + let remote = JetpackConnectionRemote(siteURL: siteURL, network: network) + let urlSuffix = "/jetpack/v4/connection/register" + network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-registration") + + // When + let blogID = try await remote.registerSite() + + // Then + XCTAssertEqual(blogID, 1234567890) + } + + func test_registerSite_properly_relays_errors() async { + // Given + let remote = JetpackConnectionRemote(siteURL: siteURL, network: network) + let urlSuffix = "/jetpack/v4/connection/register" + let expectedError = NetworkError.unacceptableStatusCode(statusCode: 500) + network.simulateError(requestUrlSuffix: urlSuffix, error: expectedError) + + do { + // When + _ = try await remote.registerSite() + } catch { + // Then + XCTAssertEqual(error as? NetworkError, expectedError) + } + } + + func test_registerSite_throws_invalidAuthorizationURL_error_for_malformed_URL() async { + // Given + let remote = JetpackConnectionRemote(siteURL: siteURL, network: network) + let urlSuffix = "/jetpack/v4/connection/register" + network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-registration-invalid") + + do { + // When + _ = try await remote.registerSite() + } catch { + // Then + XCTAssertEqual(error as? JetpackConnectionError, .invalidAuthorizationURL) + } + } + + func test_provisionConnection_correctly_returns_provision_response() async throws { + // Given + let remote = JetpackConnectionRemote(siteURL: siteURL, network: network) + let urlSuffix = "/jetpack/v4/remote_provision" + network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-provision") + + // When + let response = try await remote.provisionConnection() + + // Then + XCTAssertEqual(response.userId, 123456789) + XCTAssertEqual(response.scope, "administrator") + XCTAssertEqual(response.secret, "secret_token_12345") + } + + func test_provisionConnection_properly_relays_errors() async { + // Given + let remote = JetpackConnectionRemote(siteURL: siteURL, network: network) + let urlSuffix = "/jetpack/v4/remote_provision" + let expectedError = NetworkError.unacceptableStatusCode(statusCode: 500) + network.simulateError(requestUrlSuffix: urlSuffix, error: expectedError) + + do { + // When + _ = try await remote.provisionConnection() + } catch { + // Then + XCTAssertEqual(error as? NetworkError, expectedError) + } + } + } diff --git a/Modules/Tests/NetworkingTests/Remote/SiteRemoteTests.swift b/Modules/Tests/NetworkingTests/Remote/SiteRemoteTests.swift index 0b19a2e7521..85587f799cd 100644 --- a/Modules/Tests/NetworkingTests/Remote/SiteRemoteTests.swift +++ b/Modules/Tests/NetworkingTests/Remote/SiteRemoteTests.swift @@ -323,4 +323,39 @@ final class SiteRemoteTests: XCTestCase { message: "The Jetpack site is inaccessible or returned an error: parse error (local). not well formed [-32710]" )}) } + + // MARK: - `finalizeJetpackConnection` + + func test_finalizeJetpackConnection_successfully_completes() async { + // Given + let siteID: Int64 = 12345 + let siteURL = "http://test.com" + let provisionResponse = JetpackConnectionProvisionResponse(userId: 123456789, scope: "administrator", secret: "secret_token_12345") + let urlSuffix = "sites/\(siteID)/jetpack-remote-connect-user" + network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-finalize-success") + + // When & Then + do { + try await remote.finalizeJetpackConnection(siteID: siteID, siteURL: siteURL, provisionResponse: provisionResponse) + } catch { + XCTFail("Unexpected failure: \(error)") + } + } + + func test_finalizeJetpackConnection_properly_relays_errors() async { + // Given + let siteID: Int64 = 12345 + let siteURL = "http://test.com" + let provisionResponse = JetpackConnectionProvisionResponse(userId: 123456789, scope: "administrator", secret: "secret_token_12345") + let urlSuffix = "sites/\(siteID)/jetpack-remote-connect-user" + network.simulateResponse(requestUrlSuffix: urlSuffix, filename: "jetpack-connection-finalize-error") + + do { + // When + try await remote.finalizeJetpackConnection(siteID: siteID, siteURL: siteURL, provisionResponse: provisionResponse) + } catch { + // Then + XCTAssertEqual(error as? JetpackConnectionError, .alreadyConnected) + } + } } diff --git a/Modules/Tests/NetworkingTests/Responses/jetpack-connection-finalize-error.json b/Modules/Tests/NetworkingTests/Responses/jetpack-connection-finalize-error.json new file mode 100644 index 00000000000..8f8f2210646 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/jetpack-connection-finalize-error.json @@ -0,0 +1,4 @@ +{ + "code": "already_connected", + "message": "Remote endpoint returned an error." +} \ No newline at end of file diff --git a/Modules/Tests/NetworkingTests/Responses/jetpack-connection-finalize-success.json b/Modules/Tests/NetworkingTests/Responses/jetpack-connection-finalize-success.json new file mode 100644 index 00000000000..ab43fedf128 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/jetpack-connection-finalize-success.json @@ -0,0 +1,4 @@ +{ + "message": "Successfully connected user.", + "code": "success" +} \ No newline at end of file diff --git a/Modules/Tests/NetworkingTests/Responses/jetpack-connection-provision.json b/Modules/Tests/NetworkingTests/Responses/jetpack-connection-provision.json new file mode 100644 index 00000000000..16a2efae44c --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/jetpack-connection-provision.json @@ -0,0 +1,5 @@ +{ + "user_id": 123456789, + "scope": "administrator", + "secret": "secret_token_12345" +} \ No newline at end of file diff --git a/Modules/Tests/NetworkingTests/Responses/jetpack-connection-registration-invalid.json b/Modules/Tests/NetworkingTests/Responses/jetpack-connection-registration-invalid.json new file mode 100644 index 00000000000..e01ab40c218 --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/jetpack-connection-registration-invalid.json @@ -0,0 +1,3 @@ +{ + "authorizeUrl": "https://jetpack.wordpress.com/jetpack.authorize/1/?response_type=code&client_id=12345&redirect_uri=http://test.com" +} diff --git a/Modules/Tests/NetworkingTests/Responses/jetpack-connection-registration.json b/Modules/Tests/NetworkingTests/Responses/jetpack-connection-registration.json new file mode 100644 index 00000000000..6fd646c31ae --- /dev/null +++ b/Modules/Tests/NetworkingTests/Responses/jetpack-connection-registration.json @@ -0,0 +1,4 @@ +{ + "alternateAuthorizeUrl":"", + "authorizeUrl":"https://jetpack.wordpress.com/jetpack.authorize/1/?response_type=code&client_id=1234567890&redirect_uri=https://example.com" +} diff --git a/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockSiteRemote.swift b/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockSiteRemote.swift index 5691d9356d0..ff24fd3eb0d 100644 --- a/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockSiteRemote.swift +++ b/Modules/Tests/YosemiteTests/Mocks/Networking/Remote/MockSiteRemote.swift @@ -53,6 +53,10 @@ final class MockSiteRemote { } extension MockSiteRemote: SiteRemoteProtocol { + func finalizeJetpackConnection(siteID: Int64, siteURL: String, provisionResponse: JetpackConnectionProvisionResponse) async throws { + // no-op + } + func createSite(name: String, flow: SiteCreationFlow) async throws -> SiteCreationResponse { guard let result = createSiteResult else { XCTFail("Could not find result for creating a site.")