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 );
+ }
+}