diff --git a/docker-compose.yml b/docker-compose.yml index 053dd15a..c177778b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,3 +2,11 @@ include: - path: vendor/johnbillion/plugin-infrastructure/tests/docker-compose.yml project_directory: . + +services: + httpbin: + image: mccutchen/go-httpbin:latest + container_name: ${COMPOSE_PROJECT_NAME}-httpbin + restart: always + environment: + PORT: 80 diff --git a/phpcs.xml.dist b/phpcs.xml.dist index d9c25e5a..3ac587b9 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -39,6 +39,9 @@ + + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6aec27c6..4aa56bed 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,6 +11,7 @@ convertErrorsToExceptions="true" convertNoticesToExceptions="true" convertWarningsToExceptions="true" + failOnRisky="true" > diff --git a/src/Event/Event.php b/src/Event/Event.php index ba4b0bd2..28309001 100644 --- a/src/Event/Event.php +++ b/src/Event/Event.php @@ -10,6 +10,7 @@ use Crontrol\Event\CoreCronEvent; use Crontrol\Event\ActionSchedulerEvent; use Crontrol\Event\StandardEvent; +use Crontrol\Exception\UnknownScheduleException; /** * Base class for cron events. @@ -278,7 +279,7 @@ public function is_persistent_core_hook(): bool { * Get the schedule name for this event. * * @return string The schedule display name. - * @throws \RuntimeException If schedule is unknown. + * @throws UnknownScheduleException If schedule is unknown. */ public function get_schedule_name(): string { if ( ! $this->is_recurring() ) { @@ -291,13 +292,11 @@ public function get_schedule_name(): string { return isset( $schedules[ $this->schedule ]['display'] ) ? $schedules[ $this->schedule ]['display'] : $schedules[ $this->schedule ]['name']; } - throw new \RuntimeException( - esc_html( - sprintf( - /* translators: %s: Schedule name */ - __( 'Unknown (%s)', 'wp-crontrol' ), - $this->schedule - ) + throw new UnknownScheduleException( + sprintf( + /* translators: %s: Schedule name */ + __( 'Unknown (%s)', 'wp-crontrol' ), + $this->schedule ) ); } diff --git a/src/Exception/CrontrolRuntimeException.php b/src/Exception/CrontrolRuntimeException.php new file mode 100644 index 00000000..522b8613 --- /dev/null +++ b/src/Exception/CrontrolRuntimeException.php @@ -0,0 +1,8 @@ +get_schedule_name(); - } catch ( \RuntimeException $e ) { + } catch ( UnknownScheduleException $e ) { $schedule_name = $e->getMessage(); } @@ -2317,7 +2326,7 @@ function populate_callback( array $callback ) { $file, $line ); - } catch ( \ReflectionException $e ) { + } catch ( ReflectionException $e ) { $name = 'Closure'; } @@ -2671,52 +2680,48 @@ function json_output( $input, $pretty = true ) { * * @link https://wp-crontrol.com/docs/url-cron-events/ * - * @throws Exception If the request fails. + * @throws \Crontrol\Exception\MissingURLException + * @throws \Crontrol\Exception\MissingHashException + * @throws \Crontrol\Exception\InvalidHashException + * @throws \Crontrol\Exception\HTTPFailedException + * @throws \Crontrol\Exception\UnexpectedHTTPCodeException * - * @param array $args The event args array. - * @phpstan-param array{ - * url: string, - * name: string, - * method: string, - * hash: string, - * } $args + * @param ?string $url The URL to fetch. + * @param string $method The HTTP method to use, defaults to 'GET'. + * @param ?string $hash The stored hash to check the integrity of the URL. */ -function action_url_cron_event( array $args ): void { - if ( ! url_cron_events_enabled() ) { - return; +function handle_url_cron_event( $url, $method, $hash ): void { + if ( empty( $url ) ) { + throw new MissingURLException( + sprintf( + 'The URL is missing for a URL cron event; for more information see %s', + esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=url' ) ), + ) + ); } - list( - 'url' => $url, - 'method' => $method, - 'hash' => $hash, - ) = $args; - if ( empty( $hash ) ) { - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error - trigger_error( + throw new MissingHashException( sprintf( - 'WP Crontrol: The stored hash is missing for a URL cron event; for more information see %s', + 'The stored hash is missing for a URL cron event; for more information see %s', esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=url' ) ), - ), - E_USER_WARNING + ) ); - return; } // Check the integrity of the URL. if ( ! check_integrity( $url, $hash ) ) { - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error - trigger_error( + throw new InvalidHashException( sprintf( - 'WP Crontrol: The stored hash for a URL cron event is not valid; for more information see %s', + 'The stored hash for a URL cron event is not valid; for more information see %s', esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=url' ) ), - ), - E_USER_WARNING + ) ); - return; } + // Ensure the URL is valid before making the request. + validate_url( $url ); + $request_args = array( 'timeout' => 30, 'method' => $method, @@ -2728,13 +2733,11 @@ function action_url_cron_event( array $args ): void { $response = wp_safe_remote_request( $url, $request_args ); if ( is_wp_error( $response ) ) { - throw new Exception( - esc_html( - sprintf( - 'WP Crontrol: Failed to fetch URL %s: %s', - $url, - $response->get_error_message() - ) + throw new HTTPFailedException( + sprintf( + 'Failed to fetch URL %s: %s', + $url, + $response->get_error_message() ) ); } @@ -2743,19 +2746,55 @@ function action_url_cron_event( array $args ): void { $message = wp_remote_retrieve_response_message( $response ); if ( $code < 200 || $code >= 300 ) { - throw new Exception( - esc_html( - sprintf( - 'WP Crontrol: Unexpected response code for URL %s: HTTP %s %s', - $url, - $code, - $message - ) + throw new UnexpectedHTTPCodeException( + sprintf( + 'Unexpected response code for URL %s: HTTP %s %s', + $url, + $code, + $message ) ); } } +/** + * Action handler for URL cron events. + * + * @see \Crontrol\handle_url_cron_event() + * + * @param array $args The event args array. + * @phpstan-param array{ + * url?: string, + * name?: string, + * method?: string, + * hash?: string, + * } $args + */ +function action_url_cron_event( array $args ): void { + if ( ! url_cron_events_enabled() ) { + return; + } + + $url = $args['url'] ?? null; + $method = $args['method'] ?? 'GET'; + $hash = $args['hash'] ?? null; + + try { + handle_url_cron_event( $url, $method, $hash ); + } catch ( CrontrolRuntimeException $e ) { + // phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error,WordPress.Security.EscapeOutput.OutputNotEscaped + trigger_error( + sprintf( + /* translators: %s: Message text. */ + __( 'WP Crontrol: %s', 'wp-crontrol' ), + $e->getMessage() + ), + E_USER_WARNING + ); + // phpcs:enable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error,WordPress.Security.EscapeOutput.OutputNotEscaped + } +} + /** * Evaluates the code in a PHP cron event using eval. * @@ -2785,6 +2824,42 @@ function action_url_cron_event( array $args ): void { * * @link https://wp-crontrol.com/docs/php-cron-events/ * + * @throws \Crontrol\Exception\MissingHashException + * @throws \Crontrol\Exception\InvalidHashException + * + * @param ?string $code The PHP code to evaluate. + * @param ?string $hash The stored hash to check the integrity of the PHP code. + */ +function handle_php_cron_event( $code, $hash ): void { + if ( empty( $hash ) ) { + throw new MissingHashException( + sprintf( + 'The stored hash is missing for a PHP cron event; for more information see %s', + esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=php' ) ), + ), + ); + } + + // Check the integrity of the PHP code. + if ( ! check_integrity( $code, $hash ) ) { + throw new InvalidHashException( + sprintf( + 'The stored hash for a PHP cron event is not valid; for more information see %s', + esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=php' ) ), + ), + ); + } + + // Please see the function description above for information about the safety of this code. + // phpcs:ignore Squiz.PHP.Eval.Discouraged + eval( $code ); +} + +/** + * Action handler for PHP cron events. + * + * @see \Crontrol\handle_php_cron_event() + * * @param array|string $args The event args array, or a string containing the PHP code to evaluate. * @phpstan-param array{ * code?: string, @@ -2810,34 +2885,20 @@ function action_php_cron_event( $args ): void { $hash = $args['hash'] ?? null; } - if ( empty( $hash ) ) { - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error - trigger_error( - sprintf( - 'WP Crontrol: The stored hash is missing for a PHP cron event; for more information see %s', - esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=php' ) ), - ), - E_USER_WARNING - ); - return; - } - - // Check the integrity of the PHP code. - if ( ! check_integrity( $code, $hash ) ) { - // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + try { + handle_php_cron_event( $code, $hash ); + } catch ( CrontrolRuntimeException $e ) { + // phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error,WordPress.Security.EscapeOutput.OutputNotEscaped trigger_error( sprintf( - 'WP Crontrol: The stored hash for a PHP cron event is not valid; for more information see %s', - esc_url_raw( admin_url( 'tools.php?page=wp-crontrol&crontrol_hooks_type=php' ) ), + /* translators: %s: Message text. */ + __( 'WP Crontrol: %s', 'wp-crontrol' ), + $e->getMessage() ), E_USER_WARNING ); - return; + // phpcs:enable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error,WordPress.Security.EscapeOutput.OutputNotEscaped } - - // Please see the function description above for information about the safety of this code. - // phpcs:ignore Squiz.PHP.Eval.Discouraged - eval( $code ); } /** diff --git a/src/event-list-table.php b/src/event-list-table.php index 20ea6703..e129f6cf 100644 --- a/src/event-list-table.php +++ b/src/event-list-table.php @@ -5,6 +5,7 @@ namespace Crontrol\Event; +use Crontrol\Exception\UnknownScheduleException; use DateTimeImmutable; use function Crontrol\php_cron_events_enabled; @@ -362,7 +363,7 @@ public function single_row( $event ) { try { $schedule_name = $event->get_schedule_name(); - } catch ( \RuntimeException $e ) { + } catch ( UnknownScheduleException $e ) { $classes[] = 'crontrol-error'; } @@ -888,7 +889,7 @@ protected function column_crontrol_next( Event $event ): string { protected function column_crontrol_schedule( Event $event ): string { try { $schedule_name = $event->get_schedule_name(); - } catch ( \RuntimeException $e ) { + } catch ( UnknownScheduleException $e ) { return sprintf( ' %s', esc_html( $e->getMessage() ) diff --git a/src/event.php b/src/event.php index aeb5e030..4a7abca2 100644 --- a/src/event.php +++ b/src/event.php @@ -5,7 +5,9 @@ namespace Crontrol\Event; +use Crontrol\Exception\InvalidURLException; use WP_Error; +use InvalidArgumentException; use const Crontrol\PAUSED_OPTION; @@ -158,7 +160,7 @@ function add( $next_run_local, $schedule, $hook, array $args ) { if ( URLCronEvent::HOOK_NAME === $hook && ! empty( $args[0]['url'] ) ) { try { validate_url( $args[0]['url'] ); - } catch ( \InvalidArgumentException $e ) { + } catch ( InvalidURLException $e ) { $args[0]['url_error_message'] = $e->getMessage(); $error = $e; } @@ -496,7 +498,7 @@ function get_core_cron_array() { * * @see https://github.com/WordPress/wordpress-develop/blob/197f0a71ad27d0688b6380c869aeaf92addd1451/src/wp-includes/class-wp-http.php#L283-L299 * - * @throws \InvalidArgumentException If the URL is not valid or contains an invalid protocol. + * @throws \Crontrol\Exception\InvalidURLException If the URL is invalid or not allowed. * * @param string $url The URL to validate. */ @@ -504,27 +506,11 @@ function validate_url( string $url ): void { $valid = wp_http_validate_url( $url ); if ( $valid === false ) { - throw new \InvalidArgumentException( - esc_html( - sprintf( - /* translators: %s: The URL that failed validation. */ - __( 'The URL "%s" is not allowed', 'wp-crontrol' ), - $url, - ) - ) - ); - } - - $filtered = wp_kses_bad_protocol( $url, array( 'http', 'https', 'ssl' ) ); - - if ( $filtered === '' ) { - throw new \InvalidArgumentException( - esc_html( - sprintf( - /* translators: %s: The URL that failed validation. */ - __( 'The URL "%s" contains an invalid protocol', 'wp-crontrol' ), - $url, - ) + throw new InvalidURLException( + sprintf( + /* translators: %s: The URL that failed validation. */ + __( 'The URL "%s" is not allowed', 'wp-crontrol' ), + $url, ) ); } diff --git a/tests/integration/EventScheduleTest.php b/tests/integration/EventScheduleTest.php index 510ee8d2..69831b18 100644 --- a/tests/integration/EventScheduleTest.php +++ b/tests/integration/EventScheduleTest.php @@ -3,13 +3,14 @@ namespace Crontrol\Tests; use Crontrol\Event\Event; +use Crontrol\Exception\UnknownScheduleException; class EventScheduleTest extends Test { /** * @covers \Crontrol\Event\Event::get_schedule_name */ public function testGetScheduleNameHandlesUnknownSchedule(): void { - self::expectException(\RuntimeException::class); + self::expectException( UnknownScheduleException::class ); $timestamp = time() + 3600; diff --git a/tests/integration/EventTimeTest.php b/tests/integration/EventTimeTest.php index f1cf1586..115926ee 100644 --- a/tests/integration/EventTimeTest.php +++ b/tests/integration/EventTimeTest.php @@ -59,8 +59,8 @@ public function testNextRunLocalAndUTC( string $timezone_string, ?float $gmt_off $utc_time = $event->get_next_run_utc( 'Y-m-d H:i:s' ); $local_time = $event->get_next_run_local( 'Y-m-d H:i:s' ); - self::assertEquals( $expected_utc, $utc_time, 'UTC time should be correct' ); - self::assertEquals( $expected_local, $local_time, 'Local time should be correct' ); + self::assertSame( $expected_utc, $utc_time, 'UTC time should be correct' ); + self::assertSame( $expected_local, $local_time, 'Local time should be correct' ); } /** diff --git a/tests/integration/PHPEventTest.php b/tests/integration/PHPEventTest.php new file mode 100644 index 00000000..6f961ca7 --- /dev/null +++ b/tests/integration/PHPEventTest.php @@ -0,0 +1,40 @@ +expectException( MissingHashException::class ); + + $code = 'echo "Hello, World!";'; + + handle_php_cron_event( $code, null ); + } + + public function testInvalidHashTriggersException(): void { + $this->expectException( InvalidHashException::class ); + + $code = 'echo "Hello, World!";'; + $hash = 'invalidhash'; + + handle_php_cron_event( $code, $hash ); + } + + public function testValidPHPIsExecuted(): void { + $code = 'echo "Hello, World!";'; + $hash = wp_hash( $code ); + + ob_start(); + + handle_php_cron_event( $code, $hash ); + + $output = ob_get_clean(); + + self::assertSame( 'Hello, World!', $output ); + } +} diff --git a/tests/integration/Test.php b/tests/integration/Test.php index 49e999e9..4c3687ca 100644 --- a/tests/integration/Test.php +++ b/tests/integration/Test.php @@ -2,4 +2,28 @@ namespace Crontrol\Tests; -abstract class Test extends \WP_UnitTestCase {} +abstract class Test extends \WP_UnitTestCase { + /** + * Runs the routine before each test is executed. + */ + public function set_up() { + parent::set_up(); + + /** + * Allow local httpbin requests to pass through. + * + * @param bool $external Whether HTTP request is external. + * @param string $host Host name of the requested URL. + * @param string $url Requested URL. + * @return bool Whether HTTP request is external. + */ + add_filter( 'http_request_host_is_external', static function( bool $external, string $host, string $url ): bool { + // Allow httpbin requests. + if ( 'httpbin' === $host ) { + return true; + } + + return $external; + }, 10, 3 ); + } +} diff --git a/tests/integration/URLEventTest.php b/tests/integration/URLEventTest.php new file mode 100644 index 00000000..a19bbc14 --- /dev/null +++ b/tests/integration/URLEventTest.php @@ -0,0 +1,77 @@ +expectException( MissingURLException::class ); + + $url = ''; + $hash = null; + + handle_url_cron_event( $url, 'GET', $hash ); + } + + public function testMissingHashTriggersException(): void { + $this->expectException( MissingHashException::class ); + + $url = 'http://example.com'; + $hash = null; + + handle_url_cron_event( $url, 'GET', $hash ); + } + + public function testInvalidHashTriggersException(): void { + $this->expectException( InvalidHashException::class ); + + $url = 'http://example.com'; + $hash = 'invalidhash'; + + handle_url_cron_event( $url, 'GET', $hash ); + } + + public function testInvalidURLTriggersException(): void { + $this->expectException( InvalidURLException::class ); + + $url = 'http://localhost:22'; + $hash = wp_hash( $url ); + + handle_url_cron_event( $url, 'GET', $hash ); + } + + public function test404ResponseTriggersException(): void { + $this->expectException( UnexpectedHTTPCodeException::class ); + + $url = 'http://httpbin/status/404'; + $hash = wp_hash( $url ); + + handle_url_cron_event( $url, 'GET', $hash ); + } + + public function testSuccesfulRequestWorksAsExpected(): void { + $url = 'http://httpbin/status/200'; + $hash = wp_hash( $url ); + + /** + * @param array|WP_Error $response HTTP response or WP_Error object. + * @param string $context Context under which the hook is fired. + * @param string $class HTTP transport used. + * @param array $parsed_args HTTP request arguments. + * @param string $url The request URL. + */ + add_action( 'http_api_debug', function( $response, $context, $class, $args, $url_called ) use ( $url ) { + self::assertSame( $url, $url_called ); + self::assertNotWPError( $response ); + }, 10, 5 ); + + handle_url_cron_event( $url, 'GET', $hash ); + } +}