diff --git a/composer.lock b/composer.lock index 6cac7270..2cc90509 100644 --- a/composer.lock +++ b/composer.lock @@ -64,16 +64,16 @@ }, { "name": "codeinwp/themeisle-sdk", - "version": "3.3.58", + "version": "3.3.61", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82" + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/d6807c0b7308e323bd77cced667dee3f2d5e6a82", - "reference": "d6807c0b7308e323bd77cced667dee3f2d5e6a82", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/9fe698b52dec768a0dd8b500fb51efe40962ee99", + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99", "shasum": "" }, "require-dev": { @@ -99,9 +99,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.58" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.61" }, - "time": "2026-07-29T08:38:52+00:00" + "time": "2026-08-24T15:59:27+00:00" }, { "name": "enshrined/svg-sanitize", diff --git a/inc/compatibilities/jetpack_photon_compatibility.php b/inc/compatibilities/jetpack_photon_compatibility.php new file mode 100644 index 00000000..1dfefff3 --- /dev/null +++ b/inc/compatibilities/jetpack_photon_compatibility.php @@ -0,0 +1,57 @@ + $plugins Conflicting plugin definitions. + * @return array + */ + public function add_conflicting_plugin( $plugins ) { + $plugins[ self::CONFLICT_KEY ] = self::PLUGIN_FILE; + + return $plugins; + } + + /** + * Register before the generic conflict notice is evaluated. + * + * @return bool + */ + public function should_load_early() { + return true; + } +} diff --git a/inc/conflicts/conflicting_plugins.php b/inc/conflicts/conflicting_plugins.php index 721a3a13..4b13a755 100644 --- a/inc/conflicts/conflicting_plugins.php +++ b/inc/conflicts/conflicting_plugins.php @@ -1,4 +1,5 @@ 'litespeed-cache/litespeed-cache.php', 'autoptimize' => 'autoptimize/autoptimize.php', 'perfmatters' => 'perfmatters/perfmatters.php', - 'jetpack_Photon' => 'jetpack/jetpack.php', // 'plugin-slug' => 'plugin-folder/plugin-file.php' ]; @@ -73,6 +73,7 @@ private function get_active_plugins() { $conflicting_plugins = $this->defined_plugins(); $conflicting_plugins = array_filter( $conflicting_plugins, 'is_plugin_active' ); + return apply_filters( 'optml_conflicting_active_plugins', $conflicting_plugins ); } diff --git a/inc/conflicts/jetpack_photon.php b/inc/conflicts/jetpack_photon.php index 533b81e2..b545c986 100644 --- a/inc/conflicts/jetpack_photon.php +++ b/inc/conflicts/jetpack_photon.php @@ -34,14 +34,8 @@ public function define_message() { * @access public */ public function is_conflict_valid() { + $compatibility = new Optml_jetpack_photon_compatibility(); - if ( ! is_plugin_active( 'jetpack/jetpack.php' ) ) { - return false; - } - if ( ! class_exists( 'Jetpack', false ) ) { - return false; - } - - return Jetpack::is_module_active( 'photon' ); + return $compatibility->should_load(); } } diff --git a/inc/manager.php b/inc/manager.php index ffb713c7..cb3cea3f 100644 --- a/inc/manager.php +++ b/inc/manager.php @@ -104,6 +104,7 @@ final class Optml_Manager { 'wpsp', 'jetengine', 'jetpack', + 'jetpack_photon_compatibility', 'wp_rocket', 'wp_super_cache', 'breeze', @@ -122,6 +123,23 @@ final class Optml_Manager { * @var boolean Buffer state. */ private static $ob_started = false; + /** + * The output-buffer nesting level of our capture buffer. + * + * Used to make sure we only ever capture or close our own buffer and not + * one started by a third party. + * + * @var int Buffer nesting level, 0 when no capture buffer is armed. + */ + private static $ob_level = 0; + /** + * Whether the captured buffer was already processed at shutdown. + * + * When true, the fallback output handler passes content through untouched. + * + * @var boolean Processed state. + */ + private static $ob_processed = false; /** * Class instance method. @@ -408,6 +426,7 @@ public function register_hooks() { add_action( 'template_redirect', [ $this, 'register_after_setup' ] ); add_action( 'rest_api_init', [ $this, 'process_template_redirect_content' ], PHP_INT_MIN ); add_action( 'shutdown', [ $this, 'close_buffer' ], PHP_INT_MIN ); + add_action( 'shutdown', [ $this, 'close_final_buffer' ], PHP_INT_MAX ); foreach ( self::$loaded_compatibilities as $registered_compatibility ) { $registered_compatibility->register(); } @@ -430,6 +449,48 @@ public function register_after_setup() { public static function should_load_profiler( $default_value = false ) { return ! $default_value && apply_filters( 'optml_page_profiler_disable', false ) === false; } + + /** + * Decide if the temporary Cache-Control header can be sent while page profiling is pending. + * + * The header must never be sent on non-cacheable pages: it would replace a + * Cache-Control header already set by WordPress or another plugin (PHP's + * header() replaces same-name headers by default), e.g. WooCommerce's + * no-cache header on cart and checkout, letting proxies cache user-specific + * pages. We back off when DONOTCACHEPAGE is set or when any Cache-Control + * header exists already, and let developers override the decision. + * + * @param array|null $sent_headers Headers already set for the response; defaults to headers_list(). + * @param bool|null $do_not_cache Whether the page is flagged as non-cacheable; defaults to the DONOTCACHEPAGE constant. + * + * @return bool Whether the header can be sent. + */ + public function should_send_temporary_cache_header( $sent_headers = null, $do_not_cache = null ) { + if ( null === $do_not_cache ) { + $do_not_cache = defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE; + } + $send = ! $do_not_cache; + + if ( $send ) { + if ( null === $sent_headers ) { + $sent_headers = headers_list(); + } + foreach ( $sent_headers as $header ) { + if ( stripos( $header, 'cache-control:' ) === 0 ) { + $send = false; + break; + } + } + } + + /** + * Filters whether the temporary `Cache-Control: max-age=300` header is sent + * while page profiling is pending for the current page. + * + * @param bool $send Computed decision: false when DONOTCACHEPAGE is set or a Cache-Control header exists already. + */ + return apply_filters( 'optml_send_temporary_cache_header', $send ) === true; + } /** * Filter raw HTML content for urls. * @@ -461,7 +522,7 @@ public function replace_content( $html, $partial = false ) { $js_optimizer ); $html = str_replace( Optml_Admin::get_optimizer_script( true ), $js_optimizer, $html ); - if ( ! headers_sent() ) { + if ( ! headers_sent() && $this->should_send_temporary_cache_header() ) { header( 'Cache-Control: max-age=300' ); // Attempt to cache the page just for 5 mins until the optimizer is done. Once the optimizer is done, the page will load optimized. } } else { @@ -780,13 +841,61 @@ function ( $url ) use ( $upload_resource ) { $urls ); + /* + * Replace all URLs in a single pass per chunk instead of one full-page + * preg_replace() per URL, which scanned and rebuilt the whole page for + * every replaced URL. Chunks are bounded by pattern size, not only + * count, so the compiled regex stays within PCRE's ~64KB limit even + * for very long URLs (e.g. signed CDN URLs with kilobyte-sized query + * strings). Each chunk is applied as soon as it fills, so only one + * chunk's bookkeeping is in memory at a time. + */ + $chunk = []; + $quoted = []; + $quoted_size = 0; foreach ( $urls as $origin => $replace ) { - $html = preg_replace( '/(?= 200 || $quoted_size + strlen( $quoted_origin ) > 24000 ) ) { + $html = $this->replace_urls_chunk( $html, $chunk, $quoted ); + $chunk = []; + $quoted = []; + $quoted_size = 0; + } + $chunk[ $origin ] = $replace; + $quoted[] = $quoted_origin; + $quoted_size += strlen( $quoted_origin ) + 1; + } + if ( ! empty( $chunk ) ) { + $html = $this->replace_urls_chunk( $html, $chunk, $quoted ); } return $html; } + /** + * Replace one chunk of URLs in the content with a single combined pattern. + * + * @param string $html Content to process. + * @param array $chunk Map of origin => replacement URLs. + * @param string[] $quoted The preg_quote()d origins, in the same order. + * + * @return string Processed content, unchanged when the pattern fails. + */ + private function replace_urls_chunk( $html, $chunk, $quoted ) { + $result = preg_replace_callback( + '/(?start_capture_buffer(); + } + + /** + * Start an output buffer that captures the page HTML. + * + * On normal requests the buffer is captured and processed by close_buffer() + * at shutdown, outside of PHP's display-handler context, so callbacks hooked + * into our filters are free to use output buffering themselves and fatal + * errors raised during processing keep their real message instead of being + * masked by "Cannot use output buffering in output buffering display handlers". + * + * The attached handler is only a fallback for buffers flushed outside of + * close_buffer() — third-party force-flush loops, ob_flush() streaming, or + * core's wp_ob_end_flush_all() reaching the re-armed buffer. A named method + * is used instead of a closure so the buffer can be identified as ours via + * ob_get_status()['name']. + * + * @return void + */ + private function start_capture_buffer() { + self::$ob_processed = false; + ob_start( [ $this, 'handle_buffer_fallback' ] ); + self::$ob_level = ob_get_level(); + } + + /** + * The handler name PHP reports for our capture buffer in ob_get_status(). + */ + const OB_HANDLER_NAME = 'Optml_Manager::handle_buffer_fallback'; + + /** + * Output-buffer handler attached to our capture buffer. + * + * Runs only when the buffer is flushed outside of close_buffer(). Content is + * passed through UNPROCESSED here: running the replacement filter graph + * inside a PHP display handler would turn any third-party ob_*() call into + * an uncatchable fatal ("Cannot use output buffering in output buffering + * display handlers") — the very crash this rework removes. The only + * exception is the legacy mode selected via the optml_capture_at_shutdown + * filter, which explicitly restores the previous in-handler processing. + * + * @param string $content The buffered content. + * @param int $phase PHP's output-handler phase bitmask (unused; keeps replace_content()'s $partial parameter shielded from it). + * + * @return string The content to output. + */ + public function handle_buffer_fallback( $content, $phase = 0 ) { + if ( self::$ob_processed || $content === '' ) { + return $content; + } + if ( apply_filters( 'optml_capture_at_shutdown', true ) === false ) { + try { return $this->replace_content( $content, self::is_ajax_request() ); + } catch ( Throwable $t ) { + // Never break the page from inside a display handler. + do_action( 'optml_log', 'replace_content failed inside the output handler: ' . $t->getMessage() ); + return $content; } - ); + } + do_action( 'optml_log', 'Optimole buffer was flushed outside close_buffer(); content passed through unprocessed.' ); + + return $content; } /** * Close the buffer and flush the content. */ public function close_buffer() { - if ( self::$ob_started && ob_get_length() ) { - ob_end_flush(); + if ( ! self::$ob_started ) { + return; + } + + /** + * Filters whether the captured page is processed at shutdown, outside of + * PHP's display-handler context. Return false to restore the legacy + * behavior of processing inside the output-buffer handler. + * + * @param bool $capture_at_shutdown Whether to process the buffer at shutdown. + */ + if ( apply_filters( 'optml_capture_at_shutdown', true ) === false ) { + if ( ob_get_length() ) { + ob_end_flush(); + } + return; + } + + /* + * Flush the buffers other plugins stacked on top of ours so their + * handlers still transform the page before we process it, preserving + * the same order as a full top-down flush at request shutdown. + */ + while ( ob_get_level() > self::$ob_level ) { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a non-flushable buffer must not raise a notice; we stop on failure. + if ( ! @ob_end_flush() ) { + break; + } + } + + if ( ! $this->capture_and_process_buffer() ) { + do_action( 'optml_log', 'Optimole buffer was closed earlier by third-party code.' ); + return; + } + + /* + * Re-arm the capture so output echoed by later shutdown callbacks is + * still processed and unguarded third-party flush calls find a buffer + * to close instead of raising a notice. + */ + $this->start_capture_buffer(); + } + + /** + * Close the re-armed buffer at the very end of shutdown. + * + * @return void + */ + public function close_final_buffer() { + if ( ! self::$ob_started ) { + return; } + $this->capture_and_process_buffer(); + } + + /** + * Capture our buffer, process it outside the display-handler context and echo the result. + * + * Ownership is verified by both nesting level and handler identity, so a + * buffer another plugin opened at the same level after ours was closed is + * never captured or closed by us. + * + * @return bool Whether our buffer was found and consumed. + */ + private function capture_and_process_buffer() { + if ( self::$ob_level === 0 || ob_get_level() !== self::$ob_level ) { + return false; + } + $status = ob_get_status(); + if ( ( $status['name'] ?? '' ) !== self::OB_HANDLER_NAME ) { + return false; + } + $html = ob_get_contents(); + // Set before ob_end_clean() so our handler no-ops during buffer cleanup. + self::$ob_processed = true; + ob_end_clean(); + if ( $html !== false && $html !== '' ) { + echo $this->replace_content( $html, self::is_ajax_request() ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- full page HTML, escaping would break the page. + } + return true; } /** * Throw error on object clone diff --git a/inc/media_rename/attachment_replace.php b/inc/media_rename/attachment_replace.php index c5b4c9fa..d4b19733 100644 --- a/inc/media_rename/attachment_replace.php +++ b/inc/media_rename/attachment_replace.php @@ -83,7 +83,7 @@ public function replace() { return new WP_Error( 'file_error', __( 'Could not move file.', 'optimole-wp' ) ); } - $wp_filesystem->chmod( $original_file, FS_CHMOD_FILE ); + $permissions_normalized = $this->normalize_file_permissions( $original_file ); $this->remove_all_image_sizes(); @@ -102,9 +102,74 @@ public function replace() { do_action( 'optml_attachment_replaced', $this->attachment_id ); + if ( ! $permissions_normalized ) { + return new WP_Error( 'file_permissions_error', __( 'Error replacing file', 'optimole-wp' ) ); + } + return true; } + /** + * Normalize the permissions of the replaced file. + * + * @param string $file File path. + * + * @return bool Whether the file ended up with the expected permissions. + */ + private function normalize_file_permissions( $file ) { + global $wp_filesystem; + + $mode = defined( 'FS_CHMOD_FILE' ) ? FS_CHMOD_FILE : 0644; + + $applied = $wp_filesystem->chmod( $file, $mode ); + + $reason = ''; + + if ( ! $applied || ! $this->has_permissions( $file, $mode ) ) { + // Fallback for transports where the filesystem abstraction can't chmod. + set_error_handler( + function ( $errno, $errstr ) use ( &$reason ) { + $reason = $errstr; + + return true; + } + ); + + $applied = chmod( $file, $mode ); + + restore_error_handler(); + } + + if ( $applied && $this->has_permissions( $file, $mode ) ) { + return true; + } + + if ( OPTML_DEBUG ) { + do_action( + 'optml_log', + sprintf( 'Could not normalize permissions to %o for replaced file %s. %s', $mode, $file, $reason ) + ); + } + + return false; + } + + /** + * Check the current permissions of a file against an expected mode. + * + * @param string $file File path. + * @param int $mode Expected mode. + * + * @return bool + */ + private function has_permissions( $file, $mode ) { + clearstatcache( true, $file ); + + $perms = fileperms( $file ); + + return false !== $perms && ( $perms & 0777 ) === ( $mode & 0777 ); + } + /** * Remove all image sizes files. * diff --git a/inc/rest.php b/inc/rest.php index 6e70298c..59d8be1c 100644 --- a/inc/rest.php +++ b/inc/rest.php @@ -709,7 +709,7 @@ public function poll_optimized_images( WP_REST_Request $request ) { $api_key = $request->get_param( 'api_key' ); $request = new Optml_Api(); $images = $request->get_optimized_images( $api_key ); - if ( ! isset( $images['list'] ) || empty( $images['list'] ) ) { + if ( is_wp_error( $images ) || ! is_array( $images ) || empty( $images['list'] ) ) { return $this->response( [] ); } diff --git a/inc/v2/PageProfiler/Profile.php b/inc/v2/PageProfiler/Profile.php index 31fa3634..5cd8fa3e 100644 --- a/inc/v2/PageProfiler/Profile.php +++ b/inc/v2/PageProfiler/Profile.php @@ -384,7 +384,7 @@ public function is_in_all_viewports( int $image_id ): bool { */ public function is_lcp_image_in_all_viewports( int $image_id ): bool { foreach ( self::get_active_devices() as $device ) { - if ( ( ( self::$current_profile_data[ $device ]['lcp']['type'] ?? '' ) === 'img' ) && ( self::$current_profile_data[ $device ]['lcp']['imageId'] === $image_id ) ) { + if ( ( ( self::$current_profile_data[ $device ]['lcp']['type'] ?? '' ) === 'img' ) && ( ( self::$current_profile_data[ $device ]['lcp']['imageId'] ?? null ) === $image_id ) ) { return true; } } diff --git a/inc/v2/PageProfiler/Storage/Base.php b/inc/v2/PageProfiler/Storage/Base.php index 750684ab..82011a12 100644 --- a/inc/v2/PageProfiler/Storage/Base.php +++ b/inc/v2/PageProfiler/Storage/Base.php @@ -22,10 +22,42 @@ abstract public function store( string $key, array $data ); * Retrieve data by key. * * @param string $key The unique identifier for the data to retrieve. - * @return array|false The stored data or null if not found. + * @return array|false The stored data or false if not found. */ abstract public function get( string $key ); + /** + * Coerce a stored profiler payload to an array. + * + * Object-cache backends that JSON-decode without associative arrays return stdClass. + * Nested objects (e.g. `af`, `bg`, `lcp`) are converted recursively. + * + * @param mixed $value Raw storage value. + * @return array|false + */ + public static function normalize_value( $value ) { + if ( false === $value || null === $value ) { + return false; + } + + if ( is_object( $value ) ) { + $value = get_object_vars( $value ); + } + + if ( ! is_array( $value ) ) { + return false; + } + + foreach ( $value as $key => $item ) { + if ( is_object( $item ) || is_array( $item ) ) { + $normalized_item = self::normalize_value( $item ); + $value[ $key ] = ( false !== $normalized_item ) ? $normalized_item : []; + } + } + + return $value; + } + /** * Delete data by key. * diff --git a/inc/v2/PageProfiler/Storage/ObjectCache.php b/inc/v2/PageProfiler/Storage/ObjectCache.php index 23075c4c..9170a1bc 100644 --- a/inc/v2/PageProfiler/Storage/ObjectCache.php +++ b/inc/v2/PageProfiler/Storage/ObjectCache.php @@ -55,10 +55,10 @@ public function store( string $key, array $data ) { * Retrieve data from the object cache. * * @param string $key The unique identifier for the data to retrieve. - * @return array|false The stored data or false if not found. + * @return array|false The stored data or false if not found. */ public function get( string $key ) { - return wp_cache_get( $key, self::GROUP ); + return self::normalize_value( wp_cache_get( $key, self::GROUP ) ); } /** diff --git a/inc/v2/PageProfiler/Storage/Transients.php b/inc/v2/PageProfiler/Storage/Transients.php index f1d139eb..13c56a27 100644 --- a/inc/v2/PageProfiler/Storage/Transients.php +++ b/inc/v2/PageProfiler/Storage/Transients.php @@ -65,10 +65,10 @@ public function store( string $key, array $data ) { * Retrieves data from a transient. * * @param string $key The key to retrieve data for. - * @return mixed The stored data or false if the transient doesn't exist or has expired. + * @return array|false The stored data or false if the transient doesn't exist or has expired. */ public function get( string $key ) { - return get_transient( $this->get_key( $key ) ); + return self::normalize_value( get_transient( $this->get_key( $key ) ) ); } /** diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3141d694..ba4bee0c 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -3072,12 +3072,6 @@ parameters: count: 1 path: inc/v2/PageProfiler/Storage/Base.php - - - message: '#^Method OptimoleWP\\PageProfiler\\Storage\\Base\:\:get\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: inc/v2/PageProfiler/Storage/Base.php - - message: '#^Method OptimoleWP\\PageProfiler\\Storage\\Base\:\:store\(\) has no return type specified\.$#' identifier: missingType.return @@ -3090,12 +3084,6 @@ parameters: count: 1 path: inc/v2/PageProfiler/Storage/Base.php - - - message: '#^Method OptimoleWP\\PageProfiler\\Storage\\ObjectCache\:\:get\(\) return type has no value type specified in iterable type array\.$#' - identifier: missingType.iterableValue - count: 1 - path: inc/v2/PageProfiler/Storage/ObjectCache.php - - message: '#^Method OptimoleWP\\PageProfiler\\Storage\\ObjectCache\:\:store\(\) has parameter \$data with no value type specified in iterable type array\.$#' identifier: missingType.iterableValue diff --git a/tests/media_rename/test-attachment-replace.php b/tests/media_rename/test-attachment-replace.php index fa170670..e643e97f 100644 --- a/tests/media_rename/test-attachment-replace.php +++ b/tests/media_rename/test-attachment-replace.php @@ -109,6 +109,165 @@ private function test_replace_unscaled_to_unscaled() { $this->do_replace_test( self::$unscaled_unscaled_id, $replace_file, false, false ); } + /** + * A 0600 upload tmp file must not leave the replaced attachment unreadable to the web server. + */ + public function test_replace_normalizes_permissions_of_restricted_tmp_file() { + global $wp_filesystem; + + $id = self::factory()->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); + + $tmp_file = self::FILESTASH . 'replace-restricted.jpg'; + $wp_filesystem->copy( OPTML_PATH . 'tests/assets/small-1.jpg', $tmp_file, true ); + chmod( $tmp_file, 0600 ); + + $model = new Optml_Attachment_Model( $id ); + $file_path = $model->get_source_file_path(); + + $replacer = new Optml_Attachment_Replace( + $id, + [ + 'name' => 'replace-restricted.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => $tmp_file, + ] + ); + + $result = $replacer->replace(); + + clearstatcache( true, $file_path ); + + $this->assertTrue( $result, 'Replacement operation failed.' ); + $this->assertSame( FS_CHMOD_FILE & 0777, fileperms( $file_path ) & 0777, 'Replaced file kept the restrictive tmp file permissions.' ); + + wp_delete_post( $id, true ); + } + + /** + * When the filesystem abstraction can't chmod, the native fallback must still fix the file. + */ + public function test_replace_falls_back_to_native_chmod() { + global $wp_filesystem; + + $real_filesystem = $wp_filesystem; + + $id = self::factory()->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); + $model = new Optml_Attachment_Model( $id ); + $file_path = $model->get_source_file_path(); + + $tmp_file = self::FILESTASH . 'replace-fallback.jpg'; + $wp_filesystem->copy( OPTML_PATH . 'tests/assets/small-1.jpg', $tmp_file, true ); + chmod( $tmp_file, 0600 ); + + $replacer = new Optml_Attachment_Replace( + $id, + [ + 'name' => 'replace-fallback.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => $tmp_file, + ] + ); + + // After the constructor: it calls WP_Filesystem(), which reassigns the global. + $wp_filesystem = self::failing_chmod_filesystem(); + + try { + $result = $replacer->replace(); + } finally { + $wp_filesystem = $real_filesystem; + } + + clearstatcache( true, $file_path ); + + $this->assertTrue( $result, 'Replacement operation failed.' ); + $this->assertSame( FS_CHMOD_FILE & 0777, fileperms( $file_path ) & 0777, 'The native chmod fallback did not normalize the permissions.' ); + + wp_delete_post( $id, true ); + } + + /** + * With both chmod attempts failing, the replacement must not be reported as a plain success. + */ + public function test_replace_reports_error_when_permissions_cannot_be_normalized() { + global $wp_filesystem; + + $real_filesystem = $wp_filesystem; + + $id = self::factory()->attachment->create_upload_object( OPTML_PATH . 'tests/assets/sample-test.jpg' ); + $model = new Optml_Attachment_Model( $id ); + $file_path = $model->get_source_file_path(); + + $tmp_file = self::FILESTASH . 'replace-unfixable.jpg'; + $wp_filesystem->copy( OPTML_PATH . 'tests/assets/small-1.jpg', $tmp_file, true ); + chmod( $tmp_file, 0600 ); + + $replacer = new Optml_Attachment_Replace( + $id, + [ + 'name' => 'replace-unfixable.jpg', + 'type' => 'image/jpeg', + 'tmp_name' => $tmp_file, + ] + ); + + // After the constructor: it calls WP_Filesystem(), which reassigns the global. + $wp_filesystem = self::unfixable_filesystem(); + + // The metadata step warns about the intentionally missing file; PHPUnit turns that into an error. + set_error_handler( '__return_true' ); + + try { + $result = $replacer->replace(); + } finally { + restore_error_handler(); + $wp_filesystem = $real_filesystem; + } + + clearstatcache( true, $file_path ); + + $this->assertWPError( $result, 'Replacement was reported as a success.' ); + $this->assertSame( 'file_permissions_error', $result->get_error_code() ); + $this->assertFileDoesNotExist( $file_path, 'The test did not exercise an unfixable file.' ); + + wp_delete_post( $id, true ); + } + + /** + * A direct filesystem whose chmod always fails, as an FTP/SSH transport can. + * + * @return WP_Filesystem_Direct + */ + private static function failing_chmod_filesystem() { + return new class( null ) extends WP_Filesystem_Direct { + public function chmod( $file, $mode = false, $recursive = false ) { + return false; + } + }; + } + + /** + * A filesystem that reports a successful move but leaves nothing at the destination. + * + * Both chmod attempts then fail with ENOENT, which is the only way to reach the failure + * branch without root: a file the test user owns can always be chmod'ed natively. + * + * @return WP_Filesystem_Direct + */ + private static function unfixable_filesystem() { + return new class( null ) extends WP_Filesystem_Direct { + public function move( $source, $destination, $overwrite = false ) { + @unlink( $source ); + @unlink( $destination ); + + return true; + } + + public function chmod( $file, $mode = false, $recursive = false ) { + return false; + } + }; + } + private function do_replace_test( $id_to_replace, $replace_file, $source_scaled, $result_scaled ) { // Removed var_dump diff --git a/tests/test-cache-header.php b/tests/test-cache-header.php new file mode 100644 index 00000000..d64e071a --- /dev/null +++ b/tests/test-cache-header.php @@ -0,0 +1,98 @@ +assertTrue( $manager->should_send_temporary_cache_header( [], false ) ); + } + + /** + * Unrelated headers do not block it. + */ + public function test_unrelated_headers_do_not_block() { + $manager = Optml_Manager::instance(); + $headers = [ + 'Content-Type: text/html; charset=UTF-8', + 'X-Pingback: http://example.org/xmlrpc.php', + 'Pragma: no-cache', + ]; + $this->assertTrue( $manager->should_send_temporary_cache_header( $headers, false ) ); + } + + /** + * An existing Cache-Control header is never overridden, e.g. WooCommerce's + * nocache_headers() output on cart and checkout pages. + */ + public function test_existing_cache_control_blocks() { + $manager = Optml_Manager::instance(); + $headers = [ + 'Expires: Wed, 11 Jan 1984 05:00:00 GMT', + 'Cache-Control: no-cache, must-revalidate, max-age=0', + ]; + $this->assertFalse( $manager->should_send_temporary_cache_header( $headers, false ) ); + } + + /** + * The Cache-Control match is case-insensitive and value-agnostic — a longer + * max-age set by a cache plugin must not be downgraded either. + */ + public function test_existing_cache_control_case_and_value_agnostic() { + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [ 'cache-control: public, max-age=31536000' ], false ) ); + } + + /** + * DONOTCACHEPAGE blocks the header. + */ + public function test_donotcachepage_blocks() { + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [], true ) ); + } + + /** + * The filter can force the header off. + */ + public function test_filter_can_disable() { + add_filter( 'optml_send_temporary_cache_header', '__return_false' ); + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [], false ) ); + } + + /** + * The filter can force the header on despite blocking signals. + */ + public function test_filter_can_force_enable() { + add_filter( 'optml_send_temporary_cache_header', '__return_true' ); + $manager = Optml_Manager::instance(); + $this->assertTrue( $manager->should_send_temporary_cache_header( [ 'Cache-Control: no-cache' ], true ) ); + } + + /** + * A non-boolean filter return does not accidentally enable the header. + */ + public function test_non_boolean_filter_return_is_not_true() { + add_filter( + 'optml_send_temporary_cache_header', + function () { + return 'yes'; + } + ); + $manager = Optml_Manager::instance(); + $this->assertFalse( $manager->should_send_temporary_cache_header( [], false ) ); + } +} diff --git a/tests/test-jetpack-conflicts.php b/tests/test-jetpack-conflicts.php new file mode 100644 index 00000000..fb0bbecf --- /dev/null +++ b/tests/test-jetpack-conflicts.php @@ -0,0 +1,101 @@ +active_plugins = get_option( 'active_plugins', [] ); + $this->compatibility = new Optml_jetpack_photon_compatibility(); + update_option( + 'active_plugins', + array_merge( $this->active_plugins, [ 'jetpack/jetpack.php' ] ) + ); + } + + /** + * Restore active plugins after each test. + */ + public function tear_down() { + remove_filter( 'optml_conflicting_defined_plugins', [ $this->compatibility, 'add_conflicting_plugin' ] ); + Jetpack::$photon_active = false; + update_option( 'active_plugins', $this->active_plugins ); + + parent::tear_down(); + } + + /** + * Jetpack without Photon should not be a generic conflict. + */ + public function test_jetpack_without_photon_is_not_a_generic_conflict() { + $conflicts = new Optml_Conflicting_Plugins(); + $conflict = new Optml_Jetpack_Photon(); + + $this->assertFalse( $this->compatibility->should_load() ); + $this->assertFalse( $conflict->is_conflict_valid() ); + $this->assertNotContains( 'jetpack/jetpack.php', $conflicts->get_conflicting_plugins( true ) ); + } + + /** + * Jetpack with Photon should remain a generic conflict. + */ + public function test_jetpack_with_photon_is_a_generic_conflict() { + Jetpack::$photon_active = true; + $this->compatibility->register(); + $conflicts = new Optml_Conflicting_Plugins(); + $conflict = new Optml_Jetpack_Photon(); + + $this->assertTrue( $this->compatibility->should_load() ); + $this->assertTrue( $conflict->is_conflict_valid() ); + $this->assertContains( 'jetpack/jetpack.php', $conflicts->get_conflicting_plugins( true ) ); + } +} + +if ( ! class_exists( 'Jetpack', false ) ) { + /** + * Minimal Jetpack test double. + */ + class Jetpack { + /** + * Whether Photon is active. + * + * @var bool + */ + public static $photon_active = false; + + /** + * Check whether a module is active. + * + * @param string $module Module slug. + * @return bool + */ + public static function is_module_active( $module ) { + return 'photon' === $module && self::$photon_active; + } + } +} diff --git a/tests/test-page-profiler-shape.php b/tests/test-page-profiler-shape.php new file mode 100644 index 00000000..15dcf133 --- /dev/null +++ b/tests/test-page-profiler-shape.php @@ -0,0 +1,437 @@ +update( + 'service_data', + [ + 'cdn_key' => 'test123', + 'cdn_secret' => '12345', + 'whitelist' => [ 'example.com' ], + ] + ); + $settings->update( 'lazyload', 'enabled' ); + $settings->update( 'lazyload_type', 'viewport' ); + Optml_Url_Replacer::instance()->init(); + Optml_Tag_Replacer::instance()->init(); + Optml_Lazyload_Replacer::instance()->init(); + Optml_Manager::instance()->init(); + Profile::reset_current_profile(); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + parent::tearDown(); + Profile::reset_current_profile(); + wp_cache_flush(); + } + + /** + * Build a valid device profile payload as an array. + * + * @param array $overrides Optional overrides. + * @return array + */ + private function arrayProfile( $overrides = [] ) { + return array_merge( + [ + 'af' => [ self::ABOVE_FOLD_IMAGE_ID => true ], + 'bg' => [ + '[style*="background-image:url("]' => [ + '.hero' => [ 'https://example.com/bg.jpg' ], + ], + ], + 'lcp' => [ + 'type' => 'img', + 'imageId' => self::ABOVE_FOLD_IMAGE_ID, + ], + ], + $overrides + ); + } + + /** + * JSON-decode a payload without associative arrays (Redis/JSON object-cache shape). + * + * @param array $payload Array payload. + * @return object + */ + private function objectProfile( $payload = null ) { + if ( null === $payload ) { + $payload = $this->arrayProfile(); + } + return json_decode( wp_json_encode( $payload ) ); + } + + /** + * Seed transients with object-shaped payloads and load them through storage. + * + * @param mixed $mobile Mobile payload, or false to skip. + * @param mixed $desktop Desktop payload, or false to skip. + * @param mixed $global Global payload, or false to skip. + * @return string Profile ID. + */ + private function loadProfileFromStorage( $mobile, $desktop, $global = false ) { + $profile_id = 'shape_' . wp_generate_uuid4(); + if ( false !== $mobile ) { + set_transient( Transients::PREFIX . $profile_id . '_' . Profile::DEVICE_TYPE_MOBILE, $mobile, HOUR_IN_SECONDS ); + } + if ( false !== $desktop ) { + set_transient( Transients::PREFIX . $profile_id . '_' . Profile::DEVICE_TYPE_DESKTOP, $desktop, HOUR_IN_SECONDS ); + } + if ( false !== $global ) { + set_transient( Transients::PREFIX . $profile_id, $global, HOUR_IN_SECONDS ); + } + Profile::reset_current_profile(); + Profile::set_current_profile_id( $profile_id ); + Optml_Manager::instance()->page_profiler->set_current_profile_data(); + return $profile_id; + } + + /** + * @dataProvider normalizeValueProvider + * @param mixed $input Raw value. + * @param mixed $expected Expected normalize result. + */ + public function test_normalize_value( $input, $expected ) { + $this->assertSame( $expected, ProfilerStorage::normalize_value( $input ) ); + } + + /** + * Data provider for normalize_value. + * + * @return array + */ + public function normalizeValueProvider() { + return [ + 'false' => [ false, false ], + 'null' => [ null, false ], + 'string' => [ 'corrupt', false ], + 'integer' => [ 0, false ], + 'empty_array' => [ [], [] ], + 'flat_array' => [ + [ 'af' => [ 1 => true ] ], + [ 'af' => [ 1 => true ] ], + ], + ]; + } + + /** + * Nested stdClass trees become associative arrays, including numeric keys. + */ + public function test_normalize_value_converts_nested_stdclass() { + $object = $this->objectProfile(); + $normalized = ProfilerStorage::normalize_value( $object ); + + $this->assertIsArray( $normalized ); + $this->assertIsArray( $normalized['af'] ); + $this->assertTrue( $normalized['af'][ self::ABOVE_FOLD_IMAGE_ID ] ); + $this->assertIsArray( $normalized['bg'] ); + $this->assertIsArray( $normalized['lcp'] ); + $this->assertSame( 'img', $normalized['lcp']['type'] ); + $this->assertSame( self::ABOVE_FOLD_IMAGE_ID, $normalized['lcp']['imageId'] ); + } + + /** + * Top-level array with object-shaped members is still coerced. + */ + public function test_normalize_value_converts_object_members_inside_array() { + $payload = [ + 'af' => (object) [ (string) self::ABOVE_FOLD_IMAGE_ID => true ], + 'bg' => (object) [], + 'lcp' => (object) [ 'type' => 'img', 'imageId' => self::ABOVE_FOLD_IMAGE_ID ], + ]; + $normalized = ProfilerStorage::normalize_value( $payload ); + + $this->assertIsArray( $normalized['af'] ); + $this->assertTrue( ! empty( $normalized['af'][ self::ABOVE_FOLD_IMAGE_ID ] ) ); + $this->assertIsArray( $normalized['lcp'] ); + $this->assertSame( 'img', $normalized['lcp']['type'] ); + } + + /** + * Object cache get() returns arrays when the backend stored stdClass. + */ + public function test_object_cache_get_normalizes_stdclass() { + $storage = new ObjectCache(); + $key = 'shape_oc_' . wp_generate_uuid4(); + wp_cache_set( $key, $this->objectProfile(), ObjectCache::GROUP ); + + $retrieved = $storage->get( $key ); + $this->assertIsArray( $retrieved ); + $this->assertTrue( $retrieved['af'][ self::ABOVE_FOLD_IMAGE_ID ] ); + } + + /** + * Object cache miss stays false. + */ + public function test_object_cache_get_miss_returns_false() { + $storage = new ObjectCache(); + $this->assertFalse( $storage->get( 'missing_profiler_key_' . wp_generate_uuid4() ) ); + } + + /** + * Object cache still returns stored arrays unchanged. + */ + public function test_object_cache_get_preserves_arrays() { + $storage = new ObjectCache(); + $key = 'shape_oc_array_' . wp_generate_uuid4(); + $payload = $this->arrayProfile(); + $storage->store( $key, $payload ); + + $this->assertSame( $payload, $storage->get( $key ) ); + } + + /** + * Corrupt object-cache values are treated as a miss. + */ + public function test_object_cache_get_rejects_scalars() { + $storage = new ObjectCache(); + $key = 'shape_oc_bad_' . wp_generate_uuid4(); + wp_cache_set( $key, 'not-a-profile', ObjectCache::GROUP ); + + $this->assertFalse( $storage->get( $key ) ); + } + + /** + * Transient get() returns arrays when the stored value is stdClass. + */ + public function test_transients_get_normalizes_stdclass() { + $storage = new Transients(); + $key = 'shape_tr_' . wp_generate_uuid4(); + set_transient( Transients::PREFIX . $key, $this->objectProfile(), HOUR_IN_SECONDS ); + + $retrieved = $storage->get( $key ); + $this->assertIsArray( $retrieved ); + $this->assertTrue( $retrieved['af'][ self::ABOVE_FOLD_IMAGE_ID ] ); + } + + /** + * Transient miss stays false. + */ + public function test_transients_get_miss_returns_false() { + $storage = new Transients(); + $this->assertFalse( $storage->get( 'missing_transient_' . wp_generate_uuid4() ) ); + } + + /** + * The reported crash: indexing object-shaped device data as an array. + */ + public function test_is_in_all_viewports_does_not_fatal_on_stdclass() { + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertTrue( $profiler->is_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertFalse( $profiler->is_in_all_viewports( self::OTHER_IMAGE_ID ) ); + } + + /** + * Object-shaped above-fold map only (array wrapper, object `af`). + */ + public function test_is_in_all_viewports_with_object_shaped_af_member() { + $mobile = $this->arrayProfile(); + $desktop = $this->arrayProfile(); + $mobile['af'] = (object) [ (string) self::ABOVE_FOLD_IMAGE_ID => true ]; + $desktop['af'] = (object) [ (string) self::ABOVE_FOLD_IMAGE_ID => true ]; + $this->loadProfileFromStorage( $mobile, $desktop ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertTrue( $profiler->is_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + } + + /** + * Missing or empty object-shaped device data is treated as unavailable. + */ + public function test_is_in_all_viewports_empty_object_is_unavailable() { + $this->loadProfileFromStorage( new stdClass(), $this->objectProfile() ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertFalse( $profiler->is_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertFalse( $profiler->is_data_available() ); + } + + /** + * is_in_any_viewport must not fatal on object-shaped data. + */ + public function test_is_in_any_viewport_does_not_fatal_on_stdclass() { + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertSame( Profile::DEVICE_TYPE_MOBILE, $profiler->is_in_any_viewport( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertFalse( $profiler->is_in_any_viewport( self::OTHER_IMAGE_ID ) ); + } + + /** + * LCP lookup must not fatal on object-shaped `lcp`. + */ + public function test_is_lcp_image_in_all_viewports_does_not_fatal_on_stdclass() { + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertTrue( $profiler->is_lcp_image_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertFalse( $profiler->is_lcp_image_in_all_viewports( self::OTHER_IMAGE_ID ) ); + } + + /** + * Missing LCP imageId must not warn or fatal. + */ + public function test_is_lcp_image_handles_missing_image_id() { + $payload = $this->arrayProfile(); + $payload['lcp'] = [ 'type' => 'img' ]; + $this->loadProfileFromStorage( $payload, $payload ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertFalse( $profiler->is_lcp_image_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + } + + /** + * Global missing-dimension lookups must not fatal on object-shaped global data. + */ + public function test_global_lookups_do_not_fatal_on_stdclass() { + $global = (object) [ + 'm' => (object) [ + (string) self::ABOVE_FOLD_IMAGE_ID => (object) [ 'w' => 100, 'h' => 80 ], + ], + 's' => (object) [ + (string) self::ABOVE_FOLD_IMAGE_ID => (object) [ + '200' => (object) [ + 'w' => 200, + 'h' => 160, + 'd' => 1, + 's' => 'https://example.com/i.jpg', + 'b' => 1, + ], + ], + ], + 'c' => (object) [ (string) self::ABOVE_FOLD_IMAGE_ID => true ], + ]; + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile(), $global ); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertSame( [ 'w' => 100, 'h' => 80 ], $profiler->get_missing_dimensions( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertSame( [], $profiler->get_missing_dimensions( self::OTHER_IMAGE_ID ) ); + $this->assertNotEmpty( $profiler->get_missing_srcsets( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertTrue( $profiler->get_crop_status( self::ABOVE_FOLD_IMAGE_ID ) ); + $this->assertFalse( $profiler->get_crop_status( self::OTHER_IMAGE_ID ) ); + } + + /** + * Loading current profile data from object-shaped transients yields arrays. + */ + public function test_set_current_profile_data_normalizes_transients() { + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + $data = Profile::get_current_profile_data(); + $profiler = Optml_Manager::instance()->page_profiler; + + $this->assertIsArray( $data[ Profile::DEVICE_TYPE_MOBILE ] ); + $this->assertIsArray( $data[ Profile::DEVICE_TYPE_DESKTOP ] ); + $this->assertTrue( $data[ Profile::DEVICE_TYPE_MOBILE ]['af'][ self::ABOVE_FOLD_IMAGE_ID ] ); + $this->assertTrue( $profiler->is_in_all_viewports( self::ABOVE_FOLD_IMAGE_ID ) ); + } + + /** + * get_profile_data() also normalizes object-shaped storage. + */ + public function test_get_profile_data_normalizes_stdclass() { + $profile_id = 'shape_get_' . wp_generate_uuid4(); + set_transient( + Transients::PREFIX . $profile_id . '_' . Profile::DEVICE_TYPE_DESKTOP, + $this->objectProfile(), + HOUR_IN_SECONDS + ); + + $data = Optml_Manager::instance()->page_profiler->get_profile_data( $profile_id ); + $this->assertIsArray( $data[ Profile::DEVICE_TYPE_DESKTOP ] ); + $this->assertTrue( $data[ Profile::DEVICE_TYPE_DESKTOP ]['af'][ self::ABOVE_FOLD_IMAGE_ID ] ); + } + + /** + * exists() is true after object-shaped data is normalized, false for scalars. + */ + public function test_exists_with_object_shaped_and_corrupt_storage() { + $profiler = Optml_Manager::instance()->page_profiler; + $good_id = 'shape_exists_good_' . wp_generate_uuid4(); + $bad_id = 'shape_exists_bad_' . wp_generate_uuid4(); + + set_transient( + Transients::PREFIX . $good_id . '_' . Profile::DEVICE_TYPE_DESKTOP, + $this->objectProfile(), + HOUR_IN_SECONDS + ); + set_transient( + Transients::PREFIX . $bad_id . '_' . Profile::DEVICE_TYPE_DESKTOP, + 'nope', + HOUR_IN_SECONDS + ); + + $this->assertTrue( $profiler->exists( $good_id, Profile::DEVICE_TYPE_DESKTOP ) ); + $this->assertFalse( $profiler->exists( $bad_id, Profile::DEVICE_TYPE_DESKTOP ) ); + $this->assertFalse( $profiler->exists_all( $good_id ) ); + } + + /** + * Personalized background CSS must not fatal when current profile came from stdClass storage. + */ + public function test_personalized_css_does_not_fatal_on_stdclass() { + $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + + $css = Lazyload::get_current_personalized_css(); + $this->assertIsString( $css ); + } + + /** + * Frontend HTML replacement must not fatal when transients hold stdClass profiles. + */ + public function test_replace_content_does_not_fatal_on_object_shaped_profile() { + $profile_id = $this->loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + add_filter( + 'optml_page_profile_id', + function () use ( $profile_id ) { + return $profile_id; + } + ); + + $html = Optml_Manager::instance()->replace_content( Test_Lazyload_Viewport::get_sample_html() ); + $this->assertNotEmpty( $html ); + $this->assertStringContainsString( 'loadProfileFromStorage( $this->objectProfile(), $this->objectProfile() ); + + $replacer = Optml_Lazyload_Replacer::instance(); + $url = 'https://example.com/test-image.jpg'; + $tag = 'test'; + + $this->assertIsBool( $replacer->can_lazyload_for( $url, $tag ) ); + } +} diff --git a/tests/test-poll-optimized-images.php b/tests/test-poll-optimized-images.php new file mode 100644 index 00000000..929d7e56 --- /dev/null +++ b/tests/test-poll-optimized-images.php @@ -0,0 +1,219 @@ +update( + 'service_data', + [ + 'cdn_key' => 'test123', + 'cdn_secret' => '12345', + 'whitelist' => [ 'example.com' ], + ] + ); + add_filter( 'pre_http_request', [ $this, 'filter_pre_http_request' ], 10, 3 ); + } + + /** + * Clean up after each test. + */ + public function tearDown(): void { + parent::tearDown(); + remove_filter( 'pre_http_request', [ $this, 'filter_pre_http_request' ], 10 ); + $this->http_mock = null; + } + + /** + * Route HTTP mocks for dashboard stats/images requests. + * + * @param false|array|WP_Error $preempt Whether to preempt. + * @param array $args Request args. + * @param string $url Request URL. + * @return false|array|WP_Error + */ + public function filter_pre_http_request( $preempt, $args, $url ) { + if ( strpos( $url, 'stats/images' ) === false ) { + return $preempt; + } + if ( is_callable( $this->http_mock ) ) { + return call_user_func( $this->http_mock, $preempt, $args, $url ); + } + return $preempt; + } + + /** + * Call poll_optimized_images and return decoded REST payload. + * + * @return array{data: mixed, code: string|int} + */ + private function poll() { + $rest = new Optml_Rest(); + $request = new WP_REST_Request( 'GET' ); + $request->set_param( 'api_key', 'test-key' ); + $response = $rest->poll_optimized_images( $request ); + $this->assertInstanceOf( WP_REST_Response::class, $response ); + return $response->get_data(); + } + + /** + * Transport WP_Error must not fatal; polling returns an empty list. + */ + public function test_poll_returns_empty_list_on_transport_wp_error() { + $this->http_mock = function () { + return new WP_Error( 'http_request_failed', 'Could not connect' ); + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertSame( [], $payload['data'] ); + } + + /** + * Non-200 API payload with an error field becomes WP_Error in Optml_Api::request(). + */ + public function test_poll_returns_empty_list_on_api_error_payload() { + $this->http_mock = function () { + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'code' => 500, + 'error' => 'upstream failed', + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertSame( [], $payload['data'] ); + } + + /** + * Empty API body is documented as false and must not fatal. + */ + public function test_poll_returns_empty_list_on_false_api_result() { + $this->http_mock = function () { + return [ + 'headers' => [], + 'body' => '', + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertSame( [], $payload['data'] ); + } + + /** + * Successful payload without a list is treated as empty. + */ + public function test_poll_returns_empty_list_when_list_missing() { + $this->http_mock = function () { + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'code' => 200, + 'data' => [ 'count' => 0 ], + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertSame( [], $payload['data'] ); + } + + /** + * Successful payload with an empty list is treated as empty. + */ + public function test_poll_returns_empty_list_when_list_empty() { + $this->http_mock = function () { + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'code' => 200, + 'data' => [ 'list' => [] ], + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertSame( [], $payload['data'] ); + } + + /** + * A valid list is returned (URLs rewritten through Optimole). + */ + public function test_poll_returns_images_from_list() { + $this->http_mock = function () { + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'code' => 200, + 'data' => [ + 'list' => [ + [ + 'url' => 'https://example.com/photo.jpg', + 'key' => 'AbC', + ], + ], + ], + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + + $payload = $this->poll(); + $this->assertSame( 'success', $payload['code'] ); + $this->assertCount( 1, $payload['data'] ); + $this->assertArrayHasKey( 'url', $payload['data'][0] ); + $this->assertArrayNotHasKey( 'key', $payload['data'][0] ); + $this->assertStringContainsString( 'example.com/photo.jpg', $payload['data'][0]['url'] ); + } +} diff --git a/tests/test-zz-buffer.php b/tests/test-zz-buffer.php new file mode 100644 index 00000000..15425d5c --- /dev/null +++ b/tests/test-zz-buffer.php @@ -0,0 +1,293 @@ +Test '; + + /** + * The output-buffer nesting level before each test. + * + * @var int + */ + private $base_level = 0; + + public function setUp(): void { + parent::setUp(); + $settings = new Optml_Settings(); + $settings->update( 'service_data', [ + 'cdn_key' => 'test123', + 'cdn_secret' => '12345', + 'whitelist' => [ 'example.com', 'example.org' ], + ] ); + $settings->update( 'lazyload', 'disabled' ); + $settings->update( 'cdn', 'enabled' ); + Optml_Url_Replacer::instance()->init(); + Optml_Tag_Replacer::instance()->init(); + Optml_Manager::instance()->init(); + + $this->reset_buffer_state(); + $this->base_level = ob_get_level(); + } + + public function tearDown(): void { + // Make any leftover capture handler a pass-through before cleaning up. + $this->reset_buffer_state( true ); + while ( ob_get_level() > $this->base_level ) { + // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged + if ( ! @ob_end_clean() ) { + break; + } + } + $this->reset_buffer_state(); + parent::tearDown(); + } + + /** + * Reset Optml_Manager buffer statics between tests. + * + * @param bool $processed Value for the processed flag. + */ + private function reset_buffer_state( $processed = false ) { + $reflection = new ReflectionClass( Optml_Manager::class ); + foreach ( [ 'ob_started' => false, 'ob_level' => 0, 'ob_processed' => $processed ] as $property => $value ) { + $prop = $reflection->getProperty( $property ); + $prop->setAccessible( true ); + $prop->setValue( null, $value ); + } + } + + /** + * Callbacks on our filters may use output buffering without fataling. + * + * Before processing moved outside the display handler, the nested + * ob_start() below crashed with "Cannot use output buffering in output + * buffering display handlers". + */ + public function test_filter_callbacks_can_use_output_buffering() { + $manager = Optml_Manager::instance(); + $probed = 0; + add_filter( + 'optml_url_pre_process', + function ( $html ) use ( &$probed ) { + ob_start(); + echo 'probe'; + ob_get_clean(); + $probed ++; + return $html; + } + ); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertSame( 1, $probed ); + $this->assertStringContainsString( 'i.optimole.com', $out ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * A buffer another plugin stacks on top of ours is flushed through its own + * handler first, and we process its transformed output — never swallow it. + */ + public function test_foreign_buffer_above_is_flushed_first() { + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + // Third-party handler started after ours, e.g. a minifier. + ob_start( + function ( $content ) { + return $content . ''; + } + ); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + // Both the page image and the one appended by the foreign handler are optimized. + $this->assertSame( 2, substr_count( $out, 'i.optimole.com' ) ); + $this->assertStringNotContainsString( '"http://example.org/wp-content/uploads/foreign.jpg', $out ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * When third-party code force-flushes our buffer before shutdown, the + * content is passed through UNPROCESSED: running the filter graph inside a + * display handler would make any third-party ob_*() call an uncatchable + * fatal. close_buffer() detects the loss without side effects. + */ + public function test_third_party_flush_passes_content_through() { + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ob_end_flush(); // Third-party force flush of our buffer. + $this->assertSame( $this->base_level + 1, ob_get_level() ); + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertStringNotContainsString( 'i.optimole.com', $out ); + $this->assertStringContainsString( 'themes/twentyseventeen/assets/images/header.jpg', $out ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * A third-party flush combined with an output-buffering filter callback + * must not fatal. Processing inside the handler would terminate PHP with + * "Cannot use output buffering in output buffering display handlers", + * which catch ( Throwable ) cannot intercept. + */ + public function test_third_party_flush_with_ob_filter_does_not_fatal() { + $manager = Optml_Manager::instance(); + add_filter( + 'optml_url_pre_process', + function ( $html ) { + ob_start(); + echo 'probe'; + ob_get_clean(); + return $html; + } + ); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ob_end_flush(); // Would exit(255) if the handler ran the filter graph. + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertStringContainsString( 'themes/twentyseventeen/assets/images/header.jpg', $out ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * A foreign buffer that ends up at our recorded nesting level is never + * captured or closed: ownership requires our handler identity, not just + * the level. + */ + public function test_foreign_buffer_at_same_level_is_not_consumed() { + $manager = Optml_Manager::instance(); + $manager->process_template_redirect_content(); + ob_end_clean(); // Third party discards our buffer... + ob_start(); // ...and opens its own at the same level. + echo 'FOREIGN'; + $manager->close_buffer(); + $manager->close_final_buffer(); + + $this->assertSame( $this->base_level + 1, ob_get_level() ); + $this->assertSame( 'default output handler', ob_get_status()['name'] ); + $this->assertStringContainsString( 'FOREIGN', ob_get_contents() ); + ob_end_clean(); + } + + /** + * Calling process_template_redirect_content() twice must not stack a + * second buffer, and the page is processed exactly once. + */ + public function test_buffer_started_once() { + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + $level = ob_get_level(); + $manager->process_template_redirect_content(); + $this->assertSame( $level, ob_get_level() ); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertSame( 1, substr_count( $out, 'i.optimole.com' ) ); + } + + /** + * An empty buffer closes without output or errors. + */ + public function test_empty_buffer_no_output() { + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + $manager->close_buffer(); + $manager->close_final_buffer(); + + $this->assertSame( '', ob_get_clean() ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * Output echoed by shutdown callbacks running after close_buffer() is + * captured by the re-armed buffer and still processed. + */ + public function test_late_shutdown_output_is_processed() { + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertSame( 2, substr_count( $out, 'i.optimole.com' ) ); + $this->assertSame( $this->base_level, ob_get_level() ); + } + + /** + * Very long URLs (e.g. signed CDN URLs) must not push a replacement + * chunk's compiled pattern over PCRE's size limit. + */ + public function test_long_url_replacement_stays_within_pcre_limits() { + $manager = Optml_Manager::instance(); + add_filter( + 'optml_content_url', + function ( $url ) { + return 'https://replaced.test/marker'; + } + ); + $urls = []; + $html = ''; + for ( $i = 0; $i < 250; $i ++ ) { + $url = 'https://example.org/image-' . $i . '.jpg?X-Signature=' . str_repeat( 'a1b2c3d4', 180 ) . '&i=' . $i; + $urls[] = $url; + $html .= ''; + } + $out = $manager->do_url_replacement( $html, $urls ); + + $this->assertSame( 250, substr_count( $out, 'https://replaced.test/marker' ) ); + $this->assertStringNotContainsString( 'X-Signature', $out ); + } + + /** + * The optml_capture_at_shutdown filter restores the legacy in-handler flow. + */ + public function test_legacy_in_handler_mode() { + add_filter( 'optml_capture_at_shutdown', '__return_false' ); + $manager = Optml_Manager::instance(); + ob_start(); + $manager->process_template_redirect_content(); + echo self::IMG_TAGS; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $manager->close_buffer(); + $manager->close_final_buffer(); + $out = ob_get_clean(); + + $this->assertSame( 1, substr_count( $out, 'i.optimole.com' ) ); + $this->assertSame( $this->base_level, ob_get_level() ); + } +}