From c46e50b0921dc69c9a0b8f52111108f5fcaadf0d Mon Sep 17 00:00:00 2001 From: Mukesh Panchal Date: Fri, 4 Sep 2026 11:38:19 +0530 Subject: [PATCH] Tests: Add test coverage for `media_sideload_image()`. Adds unit tests for `media_sideload_image()` in the existing `Tests_Admin_IncludesMedia` test case, covering: * The `_source_url` post meta introduced in [47251], including URLs with a query string. * Each `$return_type`: the default image tag, `src`, `id`, and an unrecognized value falling back to the image tag. * The description being used as escaped alt text. * The attachment being attached to the given post. * URLs without an allowed extension being rejected before any HTTP request is made, plus an empty URL. * The `image_sideload_extensions` filter adding and removing extensions, and receiving the URL being sideloaded. * Download failures (transport errors and non-200 responses) being returned to the caller without creating an attachment. The HTTP request made by `download_url()` is short-circuited through `pre_http_request`, which returns a complete response array and writes a local fixture into the temporary file the request would have streamed to. Props killua99, joemcgill, mukesh27. See #49631, #48164. Co-Authored-By: Claude Opus 5 (1M context) --- tests/phpunit/tests/admin/includesMedia.php | 442 ++++++++++++++++++++ 1 file changed, 442 insertions(+) diff --git a/tests/phpunit/tests/admin/includesMedia.php b/tests/phpunit/tests/admin/includesMedia.php index 35c542009db8d..01ea72e68d269 100644 --- a/tests/phpunit/tests/admin/includesMedia.php +++ b/tests/phpunit/tests/admin/includesMedia.php @@ -6,10 +6,100 @@ */ class Tests_Admin_IncludesMedia extends WP_UnitTestCase { + /** + * The local fixture that the mocked HTTP download writes into the temporary file. + * + * @var string + */ + protected $mock_download_fixture = 'canola.jpg'; + + /** + * The response that the mocked HTTP download returns, or null for a 200 response. + * + * @var array|WP_Error|null + */ + protected $mock_download_response = null; + + /** + * The URLs requested through the mocked HTTP download, in request order. + * + * @var string[] + */ + protected $mock_download_urls = array(); + public function set_up() { parent::set_up(); require_once ABSPATH . 'wp-admin/includes/media.php'; + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + } + + public function tear_down() { + $this->remove_added_uploads(); + + parent::tear_down(); + } + + /** + * Short-circuits the HTTP request made by download_url(). + * + * download_url() streams the response to the temporary file named in the request + * arguments, so a preempted response must write that file itself for the rest of + * media_sideload_image() to have something to work with. + * + * @param false|array|WP_Error $response Whether to preempt an HTTP request's return value. + * @param array $parsed_args HTTP request arguments. + * @param string $url The request URL. + * @return array|WP_Error The faked response. + */ + public function mock_image_download( $response, $parsed_args, $url ) { + $this->mock_download_urls[] = $url; + + if ( null !== $this->mock_download_response ) { + return $this->mock_download_response; + } + + if ( ! empty( $parsed_args['filename'] ) ) { + copy( DIR_TESTDATA . '/images/' . $this->mock_download_fixture, $parsed_args['filename'] ); + } + + return array( + 'headers' => array(), + 'body' => '', + 'response' => array( + 'code' => 200, + 'message' => 'OK', + ), + 'cookies' => array(), + 'filename' => isset( $parsed_args['filename'] ) ? $parsed_args['filename'] : null, + ); + } + + /** + * Registers the mocked HTTP download for the duration of a test. + */ + protected function mock_image_downloads() { + add_filter( 'pre_http_request', array( $this, 'mock_image_download' ), 10, 3 ); + } + + /** + * Returns the ID of the attachment sideloaded onto a post. + * + * @param int $post_id The parent post ID. + * @return int The attachment ID, or 0 if no attachment was created. + */ + protected function get_sideloaded_attachment_id( $post_id ) { + $attachments = get_children( + array( + 'post_parent' => $post_id, + 'post_type' => 'attachment', + 'numberposts' => -1, + 'fields' => 'ids', + ) + ); + + return $attachments ? (int) reset( $attachments ) : 0; } /** @@ -174,4 +264,356 @@ public function data_attachment_submitbox_metadata_filesize_falls_back_to_the_fi 'true' => array( 'filesize' => true ), ); } + + /** + * Tests that the URL an image was sideloaded from is stored in the `_source_url` post meta. + * + * @ticket 49631 + * @ticket 48164 + * + * @covers ::media_sideload_image + */ + public function test_media_sideload_image_stores_the_source_url_in_post_meta() { + $this->mock_image_downloads(); + + $post_id = self::factory()->post->create(); + $url = 'http://' . WP_TESTS_DOMAIN . '/external/source/canola.jpg'; + + $attachment_id = media_sideload_image( $url, $post_id, null, 'id' ); + + $this->assertNotWPError( $attachment_id, 'The image should have been sideloaded.' ); + $this->assertSame( + $url, + get_post_meta( $attachment_id, '_source_url', true ), + 'The original URL should have been stored in the `_source_url` post meta.' + ); + } + + /** + * Tests that the `_source_url` post meta records the URL that was requested, query string included. + * + * The file name is derived from the path, but the meta should store the URL as it was passed in. + * + * @ticket 49631 + * + * @covers ::media_sideload_image + */ + public function test_media_sideload_image_stores_the_source_url_including_the_query_string() { + $this->mock_image_downloads(); + + $post_id = self::factory()->post->create(); + $url = 'http://' . WP_TESTS_DOMAIN . '/external/source/canola.jpg?ver=1.2.3'; + + $attachment_id = media_sideload_image( $url, $post_id, null, 'id' ); + + $this->assertNotWPError( $attachment_id, 'The image should have been sideloaded.' ); + $this->assertSame( + $url, + get_post_meta( $attachment_id, '_source_url', true ), + 'The `_source_url` post meta should store the URL as passed, query string included.' + ); + $this->assertSame( + 'canola.jpg', + wp_basename( get_attached_file( $attachment_id ) ), + 'The query string should have been stripped from the file name.' + ); + } + + /** + * Tests that a sideloaded image is attached to the given post. + * + * @ticket 49631 + * + * @covers ::media_sideload_image + */ + public function test_media_sideload_image_attaches_the_attachment_to_the_post() { + $this->mock_image_downloads(); + + $post_id = self::factory()->post->create(); + + $attachment_id = media_sideload_image( 'http://' . WP_TESTS_DOMAIN . '/external/source/canola.jpg', $post_id, null, 'id' ); + + $this->assertNotWPError( $attachment_id, 'The image should have been sideloaded.' ); + $this->assertSame( + $post_id, + (int) get_post_field( 'post_parent', $attachment_id ), + 'The attachment should have been attached to the post.' + ); + } + + /** + * Tests that the requested return type determines what is returned on success. + * + * @ticket 49631 + * + * @covers ::media_sideload_image + * + * @dataProvider data_media_sideload_image_return_types + * + * @param string|null $return_type The `$return_type` passed to media_sideload_image(). + */ + public function test_media_sideload_image_return_types( $return_type ) { + $this->mock_image_downloads(); + + $post_id = self::factory()->post->create(); + $url = 'http://' . WP_TESTS_DOMAIN . '/external/source/canola.jpg'; + + if ( null === $return_type ) { + $actual = media_sideload_image( $url, $post_id ); + } else { + $actual = media_sideload_image( $url, $post_id, null, $return_type ); + } + + $this->assertNotWPError( $actual, 'The image should have been sideloaded.' ); + + $attachment_id = $this->get_sideloaded_attachment_id( $post_id ); + $this->assertNotSame( 0, $attachment_id, 'An attachment should have been created.' ); + + if ( 'id' === $return_type ) { + $this->assertSame( $attachment_id, $actual, 'The attachment ID should have been returned.' ); + } elseif ( 'src' === $return_type ) { + $this->assertSame( wp_get_attachment_url( $attachment_id ), $actual, 'The attachment URL should have been returned.' ); + } else { + $this->assertSame( + sprintf( "", wp_get_attachment_url( $attachment_id ) ), + $actual, + 'The image tag should have been returned.' + ); + } + } + + /** + * Data provider. + * + * @return array + */ + public function data_media_sideload_image_return_types() { + return array( + 'no return type' => array( 'return_type' => null ), + '"html"' => array( 'return_type' => 'html' ), + 'an unknown value' => array( 'return_type' => 'not-a-return-type' ), + '"src"' => array( 'return_type' => 'src' ), + '"id"' => array( 'return_type' => 'id' ), + ); + } + + /** + * Tests that the description is used as the alt attribute of the returned image tag. + * + * @ticket 49631 + * + * @covers ::media_sideload_image + */ + public function test_media_sideload_image_uses_the_description_as_alt_text() { + $this->mock_image_downloads(); + + $post_id = self::factory()->post->create(); + + $html = media_sideload_image( 'http://' . WP_TESTS_DOMAIN . '/external/source/canola.jpg', $post_id, 'A "field" of canola' ); + + $this->assertNotWPError( $html, 'The image should have been sideloaded.' ); + + $attachment_id = $this->get_sideloaded_attachment_id( $post_id ); + $this->assertSame( + sprintf( + "%s", + wp_get_attachment_url( $attachment_id ), + esc_attr( 'A "field" of canola' ) + ), + $html, + 'The description should have been used as escaped alt text.' + ); + } + + /** + * Tests that a URL without an allowed image extension is rejected before any HTTP request is made. + * + * @ticket 49631 + * + * @covers ::media_sideload_image + * + * @dataProvider data_media_sideload_image_invalid_urls + * + * @param string $url The URL to sideload. + */ + public function test_media_sideload_image_rejects_urls_without_an_allowed_extension( $url ) { + $this->mock_image_downloads(); + + $actual = media_sideload_image( $url, self::factory()->post->create() ); + + $this->assertWPError( $actual, 'A WP_Error should have been returned.' ); + $this->assertSame( 'image_sideload_failed', $actual->get_error_code(), 'The error code did not match.' ); + $this->assertSame( 'Invalid image URL.', $actual->get_error_message(), 'The error message did not match.' ); + $this->assertSame( array(), $this->mock_download_urls, 'No HTTP request should have been made.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_media_sideload_image_invalid_urls() { + return array( + 'a disallowed extension' => array( 'url' => 'http://' . WP_TESTS_DOMAIN . '/external/source/canola.txt' ), + 'an executable file' => array( 'url' => 'http://' . WP_TESTS_DOMAIN . '/external/source/canola.php' ), + 'no extension' => array( 'url' => 'http://' . WP_TESTS_DOMAIN . '/external/source/canola' ), + 'a partial extension' => array( 'url' => 'http://' . WP_TESTS_DOMAIN . '/external/source/canola.jpgx' ), + ); + } + + /** + * Tests that an empty URL is rejected. + * + * @ticket 49631 + * + * @covers ::media_sideload_image + */ + public function test_media_sideload_image_rejects_an_empty_url() { + $this->mock_image_downloads(); + + $actual = media_sideload_image( '' ); + + $this->assertWPError( $actual, 'A WP_Error should have been returned.' ); + $this->assertSame( 'image_sideload_failed', $actual->get_error_code(), 'The error code did not match.' ); + $this->assertSame( array(), $this->mock_download_urls, 'No HTTP request should have been made.' ); + } + + /** + * Tests that extensions added through the `image_sideload_extensions` filter are allowed. + * + * @ticket 49631 + * + * @covers ::media_sideload_image + */ + public function test_media_sideload_image_allows_extensions_added_by_filter() { + $this->mock_download_fixture = 'test-image.bmp'; + $this->mock_image_downloads(); + + add_filter( + 'image_sideload_extensions', + static function ( $allowed_extensions ) { + $allowed_extensions[] = 'bmp'; + + return $allowed_extensions; + } + ); + + $post_id = self::factory()->post->create(); + $url = 'http://' . WP_TESTS_DOMAIN . '/external/source/test-image.bmp'; + + $attachment_id = media_sideload_image( $url, $post_id, null, 'id' ); + + $this->assertNotWPError( $attachment_id, 'The image should have been sideloaded.' ); + $this->assertSame( 'image/bmp', get_post_mime_type( $attachment_id ), 'The attachment mime type did not match.' ); + $this->assertSame( + $url, + get_post_meta( $attachment_id, '_source_url', true ), + 'The original URL should have been stored in the `_source_url` post meta.' + ); + } + + /** + * Tests that extensions removed through the `image_sideload_extensions` filter are rejected. + * + * @ticket 49631 + * + * @covers ::media_sideload_image + */ + public function test_media_sideload_image_rejects_extensions_removed_by_filter() { + $this->mock_image_downloads(); + + add_filter( + 'image_sideload_extensions', + static function ( $allowed_extensions ) { + return array_values( array_diff( $allowed_extensions, array( 'jpg', 'jpeg', 'jpe' ) ) ); + } + ); + + $actual = media_sideload_image( 'http://' . WP_TESTS_DOMAIN . '/external/source/canola.jpg' ); + + $this->assertWPError( $actual, 'A WP_Error should have been returned.' ); + $this->assertSame( 'image_sideload_failed', $actual->get_error_code(), 'The error code did not match.' ); + $this->assertSame( array(), $this->mock_download_urls, 'No HTTP request should have been made.' ); + } + + /** + * Tests that the filtered list of allowed extensions is passed the URL being sideloaded. + * + * @ticket 49631 + * + * @covers ::media_sideload_image + */ + public function test_media_sideload_image_passes_the_url_to_the_extensions_filter() { + $this->mock_image_downloads(); + + $url = 'http://' . WP_TESTS_DOMAIN . '/external/source/canola.jpg'; + $filtered = array(); + + add_filter( + 'image_sideload_extensions', + static function ( $allowed_extensions, $file ) use ( &$filtered ) { + $filtered[] = $file; + + return $allowed_extensions; + }, + 10, + 2 + ); + + media_sideload_image( $url, self::factory()->post->create(), null, 'id' ); + + $this->assertSame( array( $url ), $filtered, 'The URL should have been passed to the filter.' ); + } + + /** + * Tests that a download failure is returned to the caller. + * + * @ticket 49631 + * + * @covers ::media_sideload_image + * + * @dataProvider data_media_sideload_image_download_failures + * + * @param array|WP_Error $response The response returned by the mocked HTTP request. + * @param string $error_code The expected error code. + */ + public function test_media_sideload_image_returns_download_errors( $response, $error_code ) { + $this->mock_download_response = $response; + $this->mock_image_downloads(); + + $post_id = self::factory()->post->create(); + + $actual = media_sideload_image( 'http://' . WP_TESTS_DOMAIN . '/external/source/canola.jpg', $post_id ); + + $this->assertWPError( $actual, 'A WP_Error should have been returned.' ); + $this->assertSame( $error_code, $actual->get_error_code(), 'The error code did not match.' ); + $this->assertSame( 0, $this->get_sideloaded_attachment_id( $post_id ), 'No attachment should have been created.' ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_media_sideload_image_download_failures() { + return array( + 'a transport error' => array( + 'response' => new WP_Error( 'http_request_failed', 'A valid URL was not provided.' ), + 'error_code' => 'http_request_failed', + ), + 'a 404 response' => array( + 'response' => array( + 'headers' => array(), + 'body' => '', + 'response' => array( + 'code' => 404, + 'message' => 'Not Found', + ), + 'cookies' => array(), + 'filename' => null, + ), + 'error_code' => 'http_404', + ), + ); + } }