diff --git a/src/class-convertkit-app-server.php b/src/class-convertkit-app-server.php new file mode 100644 index 0000000..8f865fd --- /dev/null +++ b/src/class-convertkit-app-server.php @@ -0,0 +1,261 @@ +client_id = $client_id; + $this->url = untrailingslashit( $url ); + $this->versions = $versions; + + } + + /** + * Returns the OAuth redirect URI to use for Kit OAuth. + * + * @since 2.9.0 + * + * @return string + */ + public function get_redirect_uri() { + + return $this->url . '/kit/api/callback'; + + } + + /** + * Returns the URL to start connecting to Kit, which installs the Kit app + * if needed and then continues the given Kit OAuth URL. + * + * @since 2.9.0 + * + * @param string $authorize_url Kit OAuth URL. + * @return string + */ + public function get_connect_url( $authorize_url ) { + + return add_query_arg( + array( + 'authorize_url' => rawurlencode( $authorize_url ), + ), + $this->url . '/connect/start' + ); + + } + + /** + * Returns the URL to redirect to once connected, which completes the Kit App Store + * install and then redirects to the Plugin's settings. + * + * @since 2.9.0 + * + * @return string + */ + public function get_complete_url() { + + return $this->url . '/kit/complete'; + + } + + /** + * Permits wp_safe_redirect() to redirect to the App Server. + * + * @since 2.9.0 + * + * @param array $hosts Allowed redirect hosts. + * @return array + */ + public function allowed_redirect_hosts( $hosts ) { + + $hosts[] = wp_parse_url( $this->url, PHP_URL_HOST ); + return array_unique( $hosts ); + + } + + /** + * Registers this site with the App Server. + * + * @since 2.9.0 + * + * @param string $access_token Kit access token. + * @param string $registration_token One-time registration token the App Server sent with the authorization code. + * @return WP_Error|array Response, including the site_secret + */ + public function register_site( $access_token, $registration_token = '' ) { + + $body = array( 'access_token' => $access_token ); + if ( ! empty( $registration_token ) ) { + $body['registration_token'] = $registration_token; + } + + return $this->request( 'POST', '/api/sites', $body, 200 ); + + } + + /** + * Unregisters this site from the App Server. + * + * @since 2.9.0 + * + * @param string $access_token Kit access token. + * @return WP_Error|array Response, including remaining_sites + */ + public function unregister_site( $access_token ) { + + return $this->request( 'DELETE', '/api/sites', array( 'access_token' => $access_token ), 200 ); + + } + + /** + * Sends this site's daily heartbeat to the App Server, with its versions. + * + * @since 2.9.0 + * + * @param string $site_secret Site secret returned by register_site(). + * @return WP_Error|array + */ + public function heartbeat( $site_secret ) { + + return $this->request( 'POST', '/api/sites/heartbeat', array(), 200, $site_secret ); + + } + + /** + * Sends an automation event to the App Server, which Kit polls for the app's event nodes. + * + * @since 2.9.0 + * + * @param string $site_secret Site secret returned by register_site(). + * @param string $type Event type (e.g. abandoned_checkout). + * @param int $subscriber_id Kit Subscriber ID. + * @param array $context Event data, available in Kit emails as Liquid. + * @return WP_Error|array + */ + public function send_automation_event( $site_secret, $type, $subscriber_id, $context = array() ) { + + return $this->request( + 'POST', + '/api/automation-events', + array( + 'type' => $type, + 'subscriber_id' => absint( $subscriber_id ), + 'context' => ! empty( $context ) ? $context : null, + ), + 201, + $site_secret + ); + + } + + /** + * Sends a request to the App Server. + * + * @since 2.9.0 + * + * @param string $method HTTP method. + * @param string $path Path. + * @param array $body Body, merged with this site's URL and client ID. + * @param int $expected_code Expected HTTP response code. + * @param bool|string $site_secret Site secret, sent as a Bearer token. + * @return WP_Error|array + */ + private function request( $method, $path, $body, $expected_code, $site_secret = false ) { + + $headers = array( + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ); + if ( $site_secret ) { + $headers['Authorization'] = 'Bearer ' . $site_secret; + } + + $response = wp_remote_request( + $this->url . $path, + array( + 'method' => $method, + 'timeout' => 15, + 'headers' => $headers, + 'body' => wp_json_encode( + array_merge( + array( + 'site_url' => get_site_url(), + 'client_id' => $this->client_id, + ), + ! empty( $this->versions ) ? array( 'versions' => $this->versions ) : array(), + $body + ) + ), + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + if ( wp_remote_retrieve_response_code( $response ) !== $expected_code ) { + return new WP_Error( + 'convertkit_app_server_error', + sprintf( + 'Kit App Server returned HTTP %s', + wp_remote_retrieve_response_code( $response ) + ), + array( + 'status' => wp_remote_retrieve_response_code( $response ), + ) + ); + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + return is_array( $data ) ? $data : array(); + + } + +} diff --git a/tests/Integration/AppServerTest.php b/tests/Integration/AppServerTest.php new file mode 100644 index 0000000..608ace5 --- /dev/null +++ b/tests/Integration/AppServerTest.php @@ -0,0 +1,322 @@ + 200, + 'body' => array(), + ); + + /** + * Performs actions before each test. + * + * @since 2.9.0 + */ + public function setUp(): void + { + parent::setUp(); + + // Include class from /src to test. + require_once 'src/class-convertkit-app-server.php'; + + // Initialize the class we want to test. + $this->app_server = new \ConvertKit_App_Server('client-id', 'https://app-server.test/'); + + // Record requests and return a mocked response. + add_filter( + 'pre_http_request', + function ($response, $args, $url) { + $this->request = array( + 'url' => $url, + 'args' => $args, + 'body' => json_decode($args['body'], true), + ); + + return array( + 'headers' => array(), + 'body' => wp_json_encode($this->response['body']), + 'response' => array( + 'code' => $this->response['code'], + 'message' => '', + ), + 'cookies' => array(), + ); + }, + 10, + 3 + ); + } + + /** + * Performs actions after each test. + * + * @since 2.9.0 + */ + public function tearDown(): void + { + remove_all_filters('pre_http_request'); + unset($this->app_server); + + parent::tearDown(); + } + + /** + * Test that the URLs are built from the App Server URL. + * + * @since 2.9.0 + */ + public function testURLs() + { + $this->assertEquals('https://app-server.test/kit/api/callback', $this->app_server->get_redirect_uri()); + $this->assertEquals('https://app-server.test/kit/complete', $this->app_server->get_complete_url()); + $this->assertEquals( + 'https://app-server.test/connect/start?authorize_url=https%3A%2F%2Fapp.kit.com%2Foauth%2Fauthorize%3Fclient_id%3Dclient-id%26state%3Dabc', + $this->app_server->get_connect_url('https://app.kit.com/oauth/authorize?client_id=client-id&state=abc') + ); + } + + /** + * Test that the App Server's host is added to the allowed redirect hosts once. + * + * @since 2.9.0 + */ + public function testAllowedRedirectHosts() + { + $this->assertEquals( + array( 'example.com', 'app-server.test' ), + $this->app_server->allowed_redirect_hosts(array( 'example.com', 'app-server.test' )) + ); + $this->assertEquals( + array( 'example.com', 'app-server.test' ), + $this->app_server->allowed_redirect_hosts(array( 'example.com' )) + ); + } + + /** + * Test that register_site() sends the site URL, client ID and access token, + * and returns the site secret. + * + * @since 2.9.0 + */ + public function testRegisterSite() + { + $this->response['body'] = array( 'site_secret' => 'secret' ); + + $result = $this->app_server->register_site('access-token'); + + $this->assertEquals(array( 'site_secret' => 'secret' ), $result); + $this->assertEquals('https://app-server.test/api/sites', $this->request['url']); + $this->assertEquals('POST', $this->request['args']['method']); + $this->assertArrayNotHasKey('Authorization', $this->request['args']['headers']); + $this->assertEquals( + array( + 'site_url' => get_site_url(), + 'client_id' => 'client-id', + 'access_token' => 'access-token', + ), + $this->request['body'] + ); + } + + /** + * Test that register_site() sends the registration token, if provided. + * + * @since 2.9.0 + */ + public function testRegisterSiteWithRegistrationToken() + { + $this->response['body'] = array( 'site_secret' => 'secret' ); + + $this->app_server->register_site('access-token', 'registration-token'); + + $this->assertEquals( + array( + 'site_url' => get_site_url(), + 'client_id' => 'client-id', + 'access_token' => 'access-token', + 'registration_token' => 'registration-token', + ), + $this->request['body'] + ); + } + + /** + * Test that unregister_site() sends a DELETE request, and returns the remaining sites. + * + * @since 2.9.0 + */ + public function testUnregisterSite() + { + $this->response['body'] = array( 'remaining_sites' => 1 ); + + $result = $this->app_server->unregister_site('access-token'); + + $this->assertEquals(array( 'remaining_sites' => 1 ), $result); + $this->assertEquals('https://app-server.test/api/sites', $this->request['url']); + $this->assertEquals('DELETE', $this->request['args']['method']); + $this->assertEquals('access-token', $this->request['body']['access_token']); + } + + /** + * Test that send_automation_event() authenticates with the site secret and sends the event. + * + * @since 2.9.0 + */ + public function testSendAutomationEvent() + { + $this->response = array( + 'code' => 201, + 'body' => array( 'id' => 1 ), + ); + + $result = $this->app_server->send_automation_event('secret', 'abandoned_checkout', '123', array( 'total' => '$10.00' )); + + $this->assertEquals(array( 'id' => 1 ), $result); + $this->assertEquals('https://app-server.test/api/automation-events', $this->request['url']); + $this->assertEquals('POST', $this->request['args']['method']); + $this->assertEquals('Bearer secret', $this->request['args']['headers']['Authorization']); + $this->assertEquals( + array( + 'site_url' => get_site_url(), + 'client_id' => 'client-id', + 'type' => 'abandoned_checkout', + 'subscriber_id' => 123, + 'context' => array( 'total' => '$10.00' ), + ), + $this->request['body'] + ); + } + + /** + * Test that versions passed to the constructor are sent with each request. + * + * @since 2.9.0 + */ + public function testSendsVersions() + { + $app_server = new \ConvertKit_App_Server( + 'client-id', + 'https://app-server.test/', + array( + 'plugin' => '2.3.0', + 'wordpress' => '6.9', + ) + ); + + $this->response['code'] = 201; + $app_server->send_automation_event('secret', 'abandoned_checkout', 123); + $this->assertEquals( + array( + 'plugin' => '2.3.0', + 'wordpress' => '6.9', + ), + $this->request['body']['versions'] + ); + + $this->response['code'] = 200; + $app_server->register_site('access-token'); + $this->assertEquals( + array( + 'plugin' => '2.3.0', + 'wordpress' => '6.9', + ), + $this->request['body']['versions'] + ); + + // Not sent when none are given. + $this->app_server->register_site('access-token'); + $this->assertArrayNotHasKey('versions', $this->request['body']); + } + + /** + * Test that send_automation_event() sends a null context when none is given. + * + * @since 2.9.0 + */ + public function testSendAutomationEventWithoutContext() + { + $this->response['code'] = 201; + + $this->app_server->send_automation_event('secret', 'product_purchased', 123); + + $this->assertNull($this->request['body']['context']); + } + + /** + * Test that a WP_Error is returned when the App Server returns an unexpected response code. + * + * @since 2.9.0 + */ + public function testUnexpectedResponseCode() + { + $this->response['code'] = 401; + + $result = $this->app_server->send_automation_event('invalid-secret', 'abandoned_checkout', 123); + + $this->assertInstanceOf(\WP_Error::class, $result); + $this->assertEquals('convertkit_app_server_error', $result->get_error_code()); + $this->assertEquals('Kit App Server returned HTTP 401', $result->get_error_message()); + $this->assertEquals(array( 'status' => 401 ), $result->get_error_data()); + } + + /** + * Test that heartbeat() authenticates with the site secret and sends the versions. + * + * @since 2.9.0 + */ + public function testHeartbeat() + { + $app_server = new \ConvertKit_App_Server('client-id', 'https://app-server.test/', array( 'plugin' => '2.3.0' )); + + $this->response['body'] = array( 'success' => true ); + + $result = $app_server->heartbeat('secret'); + + $this->assertEquals(array( 'success' => true ), $result); + $this->assertEquals('https://app-server.test/api/sites/heartbeat', $this->request['url']); + $this->assertEquals('POST', $this->request['args']['method']); + $this->assertEquals('Bearer secret', $this->request['args']['headers']['Authorization']); + $this->assertEquals(array( 'plugin' => '2.3.0' ), $this->request['body']['versions']); + } +}