diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..f7517b0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,53 @@ +name: Abilities tests + +on: + # Run on pushes to select branches and on all pull requests. + push: + branches: + - main + - develop + - 'release/[0-9]+.[0-9]+*' + - 'hotfix/[0-9]+.[0-9]+*' + paths: + - '**.php' + - 'composer.json' + - 'composer.lock' + - 'phpunit.xml.dist' + - '.github/workflows/tests.yml' + pull_request: + workflow_dispatch: + +# Cancels all previous workflow runs for the same branch that have not yet completed. +concurrency: + # The concurrency group contains the workflow name and the branch name. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + abilities: + runs-on: ubuntu-latest + strategy: + matrix: + wordpress: ['6.9', 'latest'] + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + - uses: ramsey/composer-install@v2 + - name: Download WordPress core libraries + env: + WORDPRESS_VERSION: ${{ matrix.wordpress }} + run: | + if [ "$WORDPRESS_VERSION" = "latest" ]; then + archive="latest.tar.gz" + else + archive="wordpress-${WORDPRESS_VERSION}.tar.gz" + fi + curl --fail --silent --show-error --location "https://wordpress.org/${archive}" --output "$RUNNER_TEMP/wordpress.tar.gz" + tar -xzf "$RUNNER_TEMP/wordpress.tar.gz" -C "$RUNNER_TEMP" + - name: Test abilities against WordPress + env: + WP_CORE_DIR: ${{ runner.temp }}/wordpress + run: composer test diff --git a/README.md b/README.md index 60fce2f..89e248b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,43 @@ -# cloudflare-utils -Make clearing and setting Cloudflare cache easier. +# Cloudflare Utils + +A WordPress plugin for Cloudflare cache purging, cache headers and tags, cache-safe comment forms, and exposed credential protection. + +## WordPress abilities + +When the WordPress Abilities API is available, the plugin registers the `cloudflare-utils` category and these abilities. Every ability requires `manage_options`, is exposed through the Abilities REST API, and is marked for discovery by the WordPress MCP Adapter. The plugin continues to work without the Abilities API. + +| Ability | Functionality | Input | +| --- | --- | --- | +| `cloudflare-utils/get-settings` | Effective zone ID, email, token presence, configuration presence and constant-controlled fields. Never returns the token. | None | +| `cloudflare-utils/update-settings` | Partially update connection settings; omitted fields stay intact. | One or more of `zone-id`, `email`, `api-token` | +| `cloudflare-utils/get-behavior` | Describe cache headers and tags, private response exclusions, redirects, automatic purges, comment cookies and credential protection. | None | +| `cloudflare-utils/purge-url` | Clear one URL belonging to this site's hostname. | `url`: absolute HTTP/HTTPS URL without credentials or fragment | +| `cloudflare-utils/purge-all` | Clear all cached content in the configured zone, including other sites sharing the zone. | `confirm`: `true` | + +Settings controlled by `CLOUDFLARE_ZONE_ID`, `CLOUDFLARE_EMAIL`, or `CLOUDFLARE_API_TOKEN` cannot be changed by an ability. Empty strings clear editable settings. Zone IDs must contain 32 hexadecimal characters; nonempty email addresses must be valid. Tokens are write-only. Settings reads report local configuration, not verified connectivity. + +Purge abilities reuse the same Cloudflare service as the toolbar and automatic hooks. Success requires HTTP 200 and Cloudflare's JSON `success: true`. Failures return `WP_Error`; responses never include credentials or raw provider errors. Use a token with Cache Purge permission for the configured zone. Requests and credentials are no longer written to `cloudflare-api.log`. + +Cache headers, tags, comment handling and credential protection run automatically in their original WordPress request context. The behavior ability describes these hooks; it does not emit headers, simulate logins, change comments, or introduce new configuration switches. Cloudflare caching rules and custom cache keys remain Cloudflare configuration. + +```php +$ability = wp_get_ability( 'cloudflare-utils/purge-url' ); +$result = $ability->execute( [ 'url' => home_url( '/example/' ) ] ); +if ( is_wp_error( $result ) ) { + // Handle the failure. +} +``` + +REST discovery and execution use WordPress authentication and the same administrator permission checks; an MCP transport is provided by a separate adapter, not by this plugin. Because every ability is marked MCP-public, any MCP client authenticated as an administrator can also call `update-settings` and replace the zone ID or API token. See the [WordPress Abilities API reference](https://developer.wordpress.org/apis/abilities-api/php-reference/) and [Cloudflare purge API](https://developers.cloudflare.com/api/resources/cache/methods/purge/). + +## Development checks + +```sh +composer install +composer lint +composer check-cs +composer phpstan +WP_CORE_DIR=/path/to/wordpress composer test +``` + +Tests load the actual WordPress core Abilities API and JSON Schema validator from a WordPress 6.9+ source directory. Only environment services such as options, authorization and Cloudflare HTTP requests are mocked. No WordPress database, site configuration, or live Cloudflare cache is changed. CI checks WordPress 6.9 and the latest release. diff --git a/classes/class-abilities.php b/classes/class-abilities.php new file mode 100644 index 0000000..187dd83 --- /dev/null +++ b/classes/class-abilities.php @@ -0,0 +1,396 @@ +base = $base; + \add_action( 'wp_abilities_api_categories_init', [ $this, 'register_category' ] ); + \add_action( 'wp_abilities_api_init', [ $this, 'register_abilities' ] ); + } + + /** + * Register the plugin's ability category. + * + * @return void + */ + public function register_category() { + if ( function_exists( 'wp_register_ability_category' ) ) { + \wp_register_ability_category( + 'cloudflare-utils', + [ + 'label' => \__( 'Cloudflare Utils', 'pp-cf-utils' ), + 'description' => \__( 'Manage Cloudflare cache and connection settings, and inspect automatic plugin behavior.', 'pp-cf-utils' ), + ] + ); + } + } + + /** + * Register all externally useful plugin functionality. + * + * @return void + */ + public function register_abilities() { + if ( ! function_exists( 'wp_register_ability' ) ) { + return; + } + + $settings_schema = $this->settings_schema(); + $this->register( + 'get-settings', + \__( 'Get Cloudflare settings', 'pp-cf-utils' ), + \__( 'Read effective zone and email settings, token presence, and fields controlled by constants. Never returns the API token. Configuration presence does not prove Cloudflare connectivity.', 'pp-cf-utils' ), + 'get_settings', + null, + $settings_schema, + true, + false, + true + ); + $this->register( + 'update-settings', + \__( 'Update Cloudflare settings', 'pp-cf-utils' ), + \__( 'Update supplied connection fields; omitted fields are preserved. Empty strings clear fields. Fields defined by constants cannot be changed. The token is write-only.', 'pp-cf-utils' ), + 'update_settings', + [ + 'type' => 'object', + 'properties' => [ + 'zone-id' => [ + 'type' => 'string', + 'pattern' => '^([a-fA-F0-9]{32})?$', + ], + 'email' => [ 'type' => 'string' ], + 'api-token' => [ 'type' => 'string' ], + ], + 'minProperties' => 1, + 'additionalProperties' => false, + ], + $settings_schema, + false, + true, + true + ); + $this->register( + 'get-behavior', + \__( 'Get Cloudflare plugin behavior', 'pp-cf-utils' ), + \__( 'Describe cache headers and tags, private response exclusions, automatic post and comment purges, comment cookie handling, and exposed credential protection. These are automatic hooks, not configurable switches or a live Cloudflare audit.', 'pp-cf-utils' ), + 'get_behavior', + null, + [ + 'type' => 'object', + 'properties' => [ + 'public_cache_seconds' => [ 'type' => 'integer' ], + 'cache_tags' => [ 'type' => 'string' ], + 'private_responses' => [ + 'type' => 'array', + 'items' => [ 'type' => 'string' ], + ], + 'redirects' => [ 'type' => 'string' ], + 'automatic_purges' => [ + 'type' => 'array', + 'items' => [ 'type' => 'string' ], + ], + 'comments' => [ 'type' => 'string' ], + 'credentials' => [ 'type' => 'string' ], + ], + 'required' => [ 'public_cache_seconds', 'cache_tags', 'private_responses', 'redirects', 'automatic_purges', 'comments', 'credentials' ], + 'additionalProperties' => false, + ], + true, + false, + true + ); + + $purge_schema = [ + 'type' => 'object', + 'properties' => [ + 'success' => [ 'type' => 'boolean' ], + 'scope' => [ + 'type' => 'string', + 'enum' => [ 'url', 'zone' ], + ], + 'url' => [ 'type' => 'string' ], + ], + 'required' => [ 'success', 'scope', 'url' ], + 'additionalProperties' => false, + ]; + $this->register( + 'purge-url', + \__( 'Purge Cloudflare cache for a URL', 'pp-cf-utils' ), + \__( 'Purge one absolute HTTP or HTTPS URL on this WordPress site. Cloudflare must confirm success. Custom cache keys may require additional purges outside this plugin.', 'pp-cf-utils' ), + 'purge_url', + [ + 'type' => 'object', + 'properties' => [ + 'url' => [ + 'type' => 'string', + 'format' => 'uri', + 'minLength' => 1, + ], + ], + 'required' => [ 'url' ], + 'additionalProperties' => false, + ], + $purge_schema, + false, + false, + true + ); + $this->register( + 'purge-all', + \__( 'Purge the entire Cloudflare zone cache', 'pp-cf-utils' ), + \__( 'Clear ALL cached content in the configured Cloudflare zone, including other sites sharing it. Requires confirm=true. Prefer purge-url when possible.', 'pp-cf-utils' ), + 'purge_all', + [ + 'type' => 'object', + 'properties' => [ + 'confirm' => [ + 'type' => 'boolean', + 'enum' => [ true ], + ], + ], + 'required' => [ 'confirm' ], + 'additionalProperties' => false, + ], + $purge_schema, + false, + true, + true + ); + } + + /** + * Register an ability with shared permissions and metadata. + * + * @param string $name Ability suffix. + * @param string $label Ability label. + * @param string $description Ability description. + * @param string $callback Callback method. + * @param array|null $input Input schema. + * @param array $output Output schema. + * @param bool $read_only Whether the ability only reads. + * @param bool $destructive Whether the ability may cause hard to undo changes, so clients should confirm first. + * @param bool $idempotent Whether repeat execution has no additional effect. + * + * @return void + */ + private function register( $name, $label, $description, $callback, $input, $output, $read_only, $destructive, $idempotent ) { + \wp_register_ability( + 'cloudflare-utils/' . $name, + [ + 'label' => $label, + 'description' => $description, + 'category' => 'cloudflare-utils', + 'input_schema' => $input ?? [], + 'output_schema' => $output, + 'execute_callback' => [ $this, $callback ], + 'permission_callback' => [ $this, 'can_manage' ], + 'meta' => [ + 'show_in_rest' => true, + 'mcp' => [ 'public' => true ], + 'annotations' => [ + 'readonly' => $read_only, + 'destructive' => $destructive, + 'idempotent' => $idempotent, + ], + ], + ] + ); + } + + /** + * Require the same capability as the settings and toolbar UI. + * + * @return bool + */ + public function can_manage() { + return \current_user_can( 'manage_options' ); + } + + /** + * Get the redacted settings output schema. + * + * @return array + */ + private function settings_schema() { + return [ + 'type' => 'object', + 'properties' => [ + 'zone-id' => [ 'type' => 'string' ], + 'email' => [ 'type' => 'string' ], + 'has-api-token' => [ 'type' => 'boolean' ], + 'configured' => [ 'type' => 'boolean' ], + 'locked-fields' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => [ 'zone-id', 'email', 'api-token' ], + ], + ], + ], + 'required' => [ 'zone-id', 'email', 'has-api-token', 'configured', 'locked-fields' ], + 'additionalProperties' => false, + ]; + } + + /** + * Read effective settings without revealing credentials. + * + * @return array + */ + public function get_settings() { + $locked = []; + foreach ( [ 'zone-id', 'email', 'api-token' ] as $field ) { + if ( defined( $this->constant_name( $field ) ) ) { + $locked[] = $field; + } + } + return [ + 'zone-id' => (string) $this->base->get_cloudflare_zone_id(), + 'email' => (string) $this->base->get_cloudflare_email(), + 'has-api-token' => (bool) $this->base->get_cloudflare_api_token(), + 'configured' => (bool) ( $this->base->get_cloudflare_zone_id() && $this->base->get_cloudflare_api_token() ), + 'locked-fields' => $locked, + ]; + } + + /** + * Partially update settings, validating everything before saving. + * + * @param array $input Fields to update. + * + * @return array|\WP_Error + */ + public function update_settings( $input ) { + foreach ( $input as $field => $value ) { + if ( defined( $this->constant_name( $field ) ) ) { + return new \WP_Error( 'cloudflare_settings_locked', \__( 'A supplied field is controlled by a constant and cannot be changed.', 'pp-cf-utils' ) ); + } + if ( $field === 'email' && $value !== '' && ! \is_email( $value ) ) { + return new \WP_Error( 'cloudflare_invalid_email', \__( 'Supply a valid email address or an empty string.', 'pp-cf-utils' ) ); + } + } + $settings = \get_option( 'pp_cf_utils_settings', [] ); + $settings = array_merge( + [ + 'zone-id' => '', + 'email' => '', + 'api-token' => '', + ], + $settings, + $input + ); + $settings = $this->base->sanitize_settings( $settings ); + \update_option( 'pp_cf_utils_settings', $settings ); + if ( \get_option( 'pp_cf_utils_settings' ) !== $settings ) { + return new \WP_Error( 'cloudflare_settings_save_failed', \__( 'Cloudflare settings could not be saved.', 'pp-cf-utils' ) ); + } + return $this->get_settings(); + } + + /** + * Get the constant corresponding to a settings field. + * + * @param string $field Settings field. + * + * @return string + */ + private function constant_name( $field ) { + return 'CLOUDFLARE_' . strtoupper( str_replace( '-', '_', $field ) ); + } + + /** + * Describe existing automatic behavior without running request hooks. + * + * @return array + */ + public function get_behavior() { + return [ + 'public_cache_seconds' => 365 * DAY_IN_SECONDS, + 'cache_tags' => 'WordPress body classes; PP-Cache-Tag is also emitted when WP_DEBUG is enabled.', + 'private_responses' => [ 'Logged-in users', 'Admin and login requests', 'EDD checkout', 'WooCommerce cart, checkout, account and endpoints', 'Explicit send_no_cache_headers action' ], + 'redirects' => '301 redirects receive one-year public cache headers and have Content-Type and cache tags removed.', + 'automatic_purges' => [ 'Published post updates purge the post URL and home URL', 'Immediately approved new comments purge the post URL', 'Comment approval purges the post URL' ], + 'comments' => 'Commenter values are blank server-side; browser JavaScript stores and fills comment form cookies. Held comments redirect to the post permalink.', + 'credentials' => 'An Exposed-Credential-Check request header at login logs the user out and redirects to password reset with a breach notice.', + ]; + } + + /** + * Purge one URL belonging to this site. + * + * @param array $input URL input. + * + * @return array|\WP_Error + */ + public function purge_url( $input ) { + $url = $input['url']; + $parts = \wp_parse_url( $url ); + $home = \wp_parse_url( \home_url() ); + if ( ! is_array( $parts ) || ! is_array( $home ) || + ! in_array( $parts['scheme'] ?? '', [ 'http', 'https' ], true ) || + strtolower( $parts['host'] ?? '' ) !== strtolower( $home['host'] ?? '' ) || + isset( $parts['user'] ) || isset( $parts['pass'] ) || isset( $parts['fragment'] ) || + ( $parts['port'] ?? null ) !== ( $home['port'] ?? null ) + ) { + return new \WP_Error( 'cloudflare_invalid_url', \__( 'Supply an HTTP or HTTPS URL on this site without credentials or a fragment.', 'pp-cf-utils' ) ); + } + return $this->purge( $url ); + } + + /** + * Purge the entire configured zone after explicit confirmation. + * + * @param array $input Confirmation input. + * + * @return array|\WP_Error + */ + public function purge_all( $input ) { + if ( ( $input['confirm'] ?? false ) !== true ) { + return new \WP_Error( 'cloudflare_confirmation_required', \__( 'Confirm the full zone purge with confirm=true.', 'pp-cf-utils' ) ); + } + return $this->purge(); + } + + /** + * Execute a purge using the shared service and return a structured result. + * + * @param string|null $url URL or null for the whole zone. + * + * @return array|\WP_Error + */ + private function purge( $url = null ) { + if ( ! $this->base->get_cloudflare_zone_id() || ! $this->base->get_cloudflare_api_token() ) { + return new \WP_Error( 'cloudflare_not_configured', \__( 'Set a Cloudflare zone ID and API token before purging.', 'pp-cf-utils' ) ); + } + if ( $this->base->clear_cloudflare_cache( $url ) !== 200 ) { + return new \WP_Error( 'cloudflare_purge_failed', \__( 'Cloudflare did not confirm the cache purge. Check connection settings and token permissions.', 'pp-cf-utils' ) ); + } + return [ + 'success' => true, + 'scope' => $url === null ? 'zone' : 'url', + 'url' => $url ?? '', + ]; + } +} diff --git a/classes/class-base.php b/classes/class-base.php index 22399d7..d9848cb 100644 --- a/classes/class-base.php +++ b/classes/class-base.php @@ -16,6 +16,7 @@ class Base { */ public function __construct() { \add_action( 'plugins_loaded', [ $this, 'init' ] ); + new Abilities( $this ); } /** @@ -325,30 +326,51 @@ public function clear_cloudflare_cache( $url_to_purge = null ) { ] ); - // Log the request and response. - $log_entry = sprintf( - "[%s] Request: %s\nHeaders: %s\nBody: %s\nResponse: %s\n\n", - gmdate( 'Y-m-d H:i:s' ), - $url, - \wp_json_encode( $headers, JSON_UNESCAPED_SLASHES ), - $body, - \wp_remote_retrieve_body( $response ) - ); - file_put_contents( WP_CONTENT_DIR . '/cloudflare-api.log', $log_entry, FILE_APPEND ); // phpcs:ignore - // Check for errors in the response. if ( \is_wp_error( $response ) ) { error_log( 'Cloudflare cache purge failed: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log return $response->get_error_message(); } else { $response_code = \wp_remote_retrieve_response_code( $response ); + $response_body = json_decode( \wp_remote_retrieve_body( $response ), true ); if ( $response_code !== 200 ) { - error_log( 'Cloudflare cache purge failed with response code: ' . $response_code ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( 'Cloudflare cache purge failed with response code: ' . $response_code . $this->format_cloudflare_errors( $response_body ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return $response_code; + } + if ( ! is_array( $response_body ) || ( $response_body['success'] ?? false ) !== true ) { + error_log( 'Cloudflare cache purge was not confirmed by Cloudflare' . $this->format_cloudflare_errors( $response_body ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + return \__( 'Cloudflare did not confirm the cache purge.', 'pp-cf-utils' ); } return $response_code; } } + /** + * Formats the errors array of a Cloudflare API response for the error log. + * + * Only the error codes and messages are used, these never contain credentials. + * + * @param mixed $response_body The decoded response body. + * + * @return string The formatted errors, prefixed with a separator, or an empty string. + */ + private function format_cloudflare_errors( $response_body ) { + if ( ! is_array( $response_body ) ) { + return ' (response body is not valid JSON)'; + } + if ( empty( $response_body['errors'] ) || ! is_array( $response_body['errors'] ) ) { + return ''; + } + + $errors = []; + foreach ( $response_body['errors'] as $error ) { + if ( is_array( $error ) ) { + $errors[] = sprintf( '[%s] %s', $error['code'] ?? '?', $error['message'] ?? '' ); + } + } + return $errors ? ' - ' . implode( '; ', $errors ) : ''; + } + /** * Adds Cache-Control header to 301 redirects. * diff --git a/cloudflare-utils.php b/cloudflare-utils.php index 942e5cc..291dcf4 100644 --- a/cloudflare-utils.php +++ b/cloudflare-utils.php @@ -21,4 +21,5 @@ * Load the plugin. */ require_once PP_CF_UTILS_DIR . '/classes/class-base.php'; +require_once PP_CF_UTILS_DIR . '/classes/class-abilities.php'; new PP_Cloudflare_Utils\Base(); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 5346d96..4eb2faa 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,6 +4,8 @@ parameters: paths: - ./cloudflare-utils.php - ./classes + scanFiles: + - ./tests/phpstan-stubs.php bootstrapFiles: - ./cloudflare-utils.php ignoreErrors: \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..dfe6ac5 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,8 @@ + + + + + tests + + + diff --git a/tests/AbilitiesTest.php b/tests/AbilitiesTest.php new file mode 100644 index 0000000..f692c01 --- /dev/null +++ b/tests/AbilitiesTest.php @@ -0,0 +1,283 @@ + + */ + private $settings; + + /** + * Mock HTTP response. + * + * @var array|WP_Error + */ + private $response; + + /** + * Captured HTTP request bodies. + * + * @var array + */ + private $requests; + + /** + * Administrator flag. + * + * @var bool + */ + private $admin; + + /** + * Set up core hooks, API registries and mocked environment services. + * + * @return void + */ + protected function setUp(): void { + parent::setUp(); + Monkey\setUp(); + $GLOBALS['wp_filter'] = []; + $GLOBALS['wp_actions'] = []; + $GLOBALS['wp_current_filter'] = []; + foreach ( [ WP_Abilities_Registry::class, WP_Ability_Categories_Registry::class ] as $class ) { + $property = new ReflectionProperty( $class, 'instance' ); + $property->setAccessible( true ); + $property->setValue( null, null ); + } + $this->settings = [ + 'zone-id' => str_repeat( 'a', 32 ), + 'email' => 'admin@example.com', + 'api-token' => 'test-secret', + ]; + $this->response = [ + 'response' => [ 'code' => 200 ], + 'body' => '{"success":true}', + ]; + $this->requests = []; + $this->admin = true; + Functions\when( '__' )->returnArg( 1 ); + Functions\when( '_n' )->returnArg( 1 ); + Functions\when( '_doing_it_wrong' )->alias( + function ( $function_name, $message ) { + throw new RuntimeException( esc_html( $function_name . ': ' . $message ) ); + } + ); + Functions\when( 'current_user_can' )->alias( + function ( $capability ) { + return $capability === 'manage_options' && $this->admin; + } + ); + Functions\when( 'get_option' )->alias( + function () { + return $this->settings; + } + ); + Functions\when( 'update_option' )->alias( + function ( $name, $value ) { + $this->settings = $value; + return true; + } + ); + Functions\when( 'home_url' )->justReturn( 'https://example.com' ); + Functions\when( 'sanitize_text_field' )->alias( 'trim' ); + Functions\when( 'sanitize_email' )->alias( 'trim' ); + Functions\when( 'esc_html' )->returnArg( 1 ); + Functions\when( 'wp_remote_post' )->alias( + function ( $url, $args ) { + $this->requests[] = [ + 'endpoint' => $url, + 'body' => json_decode( $args['body'], true ), + ]; + return $this->response; + } + ); + Functions\when( 'wp_remote_retrieve_body' )->alias( + function ( $response ) { + return $response['body']; + } + ); + Functions\when( 'wp_remote_retrieve_response_code' )->alias( + function ( $response ) { + return $response['response']['code']; + } + ); + new Base(); + do_action( 'init' ); + } + + /** + * Restore mocked functions. + * + * @return void + */ + protected function tearDown(): void { + Monkey\tearDown(); + parent::tearDown(); + } + + /** + * Check lazy registration, discovery metadata and administrator enforcement. + * + * @return void + */ + public function test_registration_and_permissions(): void { + $abilities = wp_get_abilities(); + $this->assertCount( 5, $abilities ); + foreach ( $abilities as $ability ) { + $this->assertSame( 'cloudflare-utils', $ability->get_category() ); + $this->assertTrue( $ability->get_meta_item( 'show_in_rest' ) ); + $this->assertTrue( $ability->get_meta_item( 'mcp' )['public'] ); + $this->assertTrue( $ability->check_permissions() ); + $this->admin = false; + $this->assertFalse( $ability->check_permissions() ); + $this->admin = true; + } + // Annotation order: readonly, destructive, idempotent. + $expected_annotations = [ + 'get-settings' => [ true, false, true ], + 'update-settings' => [ false, true, true ], + 'get-behavior' => [ true, false, true ], + 'purge-url' => [ false, false, true ], + 'purge-all' => [ false, true, true ], + ]; + foreach ( $expected_annotations as $name => $expected ) { + $annotations = wp_get_ability( 'cloudflare-utils/' . $name )->get_meta_item( 'annotations' ); + $this->assertSame( $expected, [ $annotations['readonly'], $annotations['destructive'], $annotations['idempotent'] ], $name ); + } + $this->admin = false; + $result = wp_get_ability( 'cloudflare-utils/purge-all' )->execute( [ 'confirm' => true ] ); + $this->assertSame( 'ability_invalid_permissions', $result->get_error_code() ); + $this->assertSame( [], $this->requests ); + } + + /** + * Check read outputs, partial settings updates and schema validation. + * + * @return void + */ + public function test_settings_and_behavior(): void { + $read = wp_get_ability( 'cloudflare-utils/get-settings' ); + $update = wp_get_ability( 'cloudflare-utils/update-settings' ); + $this->assertTrue( $read->execute()['configured'] ); + $this->assertStringNotContainsString( 'test-secret', wp_json_encode( $read->execute() ) ); + $result = $update->execute( [ 'email' => 'new@example.com' ] ); + $this->assertSame( 'new@example.com', $result['email'] ); + $this->assertSame( 'test-secret', $this->settings['api-token'] ); + $this->assertSame( $result, $update->execute( [ 'email' => 'new@example.com' ] ) ); + $this->assertSame( 'cloudflare_invalid_email', $update->execute( [ 'email' => 'bad' ] )->get_error_code() ); + $this->assertSame( 'ability_invalid_input', $update->execute( [ 'zone-id' => 'bad' ] )->get_error_code() ); + $this->assertSame( 'ability_invalid_input', $update->execute( [ 'unknown' => 'bad' ] )->get_error_code() ); + $this->assertSame( 'ability_invalid_input', $update->execute( [] )->get_error_code() ); + $this->assertFalse( $update->execute( [ 'api-token' => '' ] )['configured'] ); + $this->assertSame( 'cloudflare_not_configured', wp_get_ability( 'cloudflare-utils/purge-all' )->execute( [ 'confirm' => true ] )->get_error_code() ); + $this->assertSame( 31536000, wp_get_ability( 'cloudflare-utils/get-behavior' )->execute()['public_cache_seconds'] ); + $this->assertSame( [], $this->requests ); + } + + /** + * Check purge scopes, validation and errors returned by Cloudflare. + * + * @return void + */ + public function test_purges(): void { + $url = wp_get_ability( 'cloudflare-utils/purge-url' ); + $all = wp_get_ability( 'cloudflare-utils/purge-all' ); + foreach ( [ '', 'https://other.example/post', 'ftp://example.com/post', 'https://user:pass@example.com/post', 'https://example.com/post#fragment', 'https://example.com:1234/post' ] as $invalid ) { + $this->assertInstanceOf( WP_Error::class, $url->execute( [ 'url' => $invalid ] ) ); + } + foreach ( [ + [], + [ 'confirm' => false ], + [ 'confirm' => 'true' ], + [ + 'confirm' => true, + 'extra' => true, + ], + ] as $invalid ) { + $this->assertInstanceOf( WP_Error::class, $all->execute( $invalid ) ); + } + $this->assertSame( [], $this->requests ); + $this->assertSame( 'url', $url->execute( [ 'url' => 'https://example.com/post?x=1' ] )['scope'] ); + $this->assertSame( [ 'files' => [ 'https://example.com/post?x=1' ] ], $this->requests[0]['body'] ); + $this->assertSame( 'zone', $all->execute( [ 'confirm' => true ] )['scope'] ); + $this->assertSame( [ 'purge_everything' => true ], $this->requests[1]['body'] ); + foreach ( [ '{"success":false}', 'invalid', '{}' ] as $body ) { + $this->response['body'] = $body; + $this->assertSame( 'cloudflare_purge_failed', $all->execute( [ 'confirm' => true ] )->get_error_code() ); + } + $this->response['response']['code'] = 403; + $this->assertSame( 'cloudflare_purge_failed', $all->execute( [ 'confirm' => true ] )->get_error_code() ); + $this->response = new WP_Error( 'http_request_failed', 'Connection failed' ); + $this->assertSame( 'cloudflare_purge_failed', $all->execute( [ 'confirm' => true ] )->get_error_code() ); + } + + /** + * Check Cloudflare's error details reach the error log without credentials. + * + * @return void + */ + public function test_purge_errors_are_logged(): void { + $all = wp_get_ability( 'cloudflare-utils/purge-all' ); + $log_file = (string) tempnam( sys_get_temp_dir(), 'cf-utils-log' ); + $previous = ini_set( 'error_log', $log_file ); // phpcs:ignore WordPress.PHP.IniSet.Risky + + $this->response = [ + 'response' => [ 'code' => 403 ], + 'body' => '{"success":false,"errors":[{"code":10000,"message":"Authentication error"}]}', + ]; + $all->execute( [ 'confirm' => true ] ); + $this->response['response']['code'] = 200; + $this->response['body'] = '{"success":false,"errors":[{"code":1012,"message":"Request must contain one of purge_everything or files"}]}'; + $all->execute( [ 'confirm' => true ] ); + $this->response['body'] = 'invalid'; + $all->execute( [ 'confirm' => true ] ); + + ini_set( 'error_log', (string) $previous ); // phpcs:ignore WordPress.PHP.IniSet.Risky + $log = (string) file_get_contents( $log_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + unlink( $log_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink + + $this->assertStringContainsString( 'response code: 403 - [10000] Authentication error', $log ); + $this->assertStringContainsString( 'not confirmed by Cloudflare - [1012] Request must contain', $log ); + $this->assertStringContainsString( 'not confirmed by Cloudflare (response body is not valid JSON)', $log ); + $this->assertStringNotContainsString( 'test-secret', $log ); + $this->assertStringNotContainsString( 'admin@example.com', $log ); + } + + /** + * Check effective constant precedence and reject a locked update atomically. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_constant_settings(): void { + define( 'CLOUDFLARE_ZONE_ID', str_repeat( 'b', 32 ) ); + define( 'CLOUDFLARE_API_TOKEN', 'constant-secret' ); + $read = wp_get_ability( 'cloudflare-utils/get-settings' )->execute(); + $this->assertSame( CLOUDFLARE_ZONE_ID, $read['zone-id'] ); + $this->assertSame( [ 'zone-id', 'api-token' ], $read['locked-fields'] ); + $this->assertStringNotContainsString( 'constant-secret', wp_json_encode( $read ) ); + $result = wp_get_ability( 'cloudflare-utils/update-settings' )->execute( + [ + 'email' => 'new@example.com', + 'api-token' => 'replacement', + ] + ); + $this->assertSame( 'cloudflare_settings_locked', $result->get_error_code() ); + $this->assertSame( 'admin@example.com', $this->settings['email'] ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..04bf4e0 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,35 @@ + $args Ability definition. + * + * @return object|null + */ +function wp_register_ability( string $name, array $args ): ?object { + return null; +}