From e1277846616522790b5e8aa47a6294093754f0b5 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 2 Jul 2026 11:44:04 -0500 Subject: [PATCH 1/4] Theme Directory: Guard against repopackage posts missing _status meta. A hard failure between post creation and meta writes in import() can leave a repopackage post without _status/_ticket_id meta. WP_Post::__get() returns an empty string for missing meta, which fatals on PHP 8 where array_keys() and string offset access reject non-array values. Guards populate_post_with_meta(), create_or_update_trac_ticket(), and the themes API versions field so such posts no longer fatal the upload page or the API. No behavior change when the meta is a valid array. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RuMZkdkeAiLcsXjCXjVxpZ --- .../wp-content/plugins/theme-directory/class-themes-api.php | 3 ++- .../plugins/theme-directory/class-wporg-themes-upload.php | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-themes-api.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-themes-api.php index 2a0f36b6c6..52ccaa37a4 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-themes-api.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-themes-api.php @@ -866,7 +866,8 @@ public function fill_theme( $theme ) { if ( $this->fields['versions'] ) { $phil->versions = array(); - foreach ( array_keys( get_post_meta( $theme->ID, '_status', true ) ) as $version ) { + $status = get_post_meta( $theme->ID, '_status', true ); + foreach ( is_array( $status ) ? array_keys( $status ) : array() as $version ) { $phil->versions[ $version ] = $repo_package->download_url( $version ); } } diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php index 7e9fdd3b7d..fe849c2dfb 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php @@ -1315,7 +1315,7 @@ public function create_or_update_trac_ticket() { * the superseded version is simply demoted to `old`. */ if ( in_array( $prev_status, [ 'new', 'approved' ], true ) ) { - $ticket_id = (int) $this->theme_post->_ticket_id[ $this->theme_post->max_version ]; + $ticket_id = (int) ( $this->theme_post->_ticket_id[ $this->theme_post->max_version ] ?? 0 ); $ticket = $this->trac->ticket_get( $ticket_id ); // Make sure the ticket has not yet been resolved. @@ -1752,7 +1752,7 @@ public function get_all_files( $dir ) { * @return WP_Theme */ public function populate_post_with_meta( $theme ) { - foreach ( get_post_custom_keys( $theme->ID ) as $meta_key ) { + foreach ( (array) get_post_custom_keys( $theme->ID ) as $meta_key ) { $theme->$meta_key = get_post_meta( $theme->ID, $meta_key, true ); if ( is_array( $theme->$meta_key ) ) { @@ -1761,7 +1761,7 @@ public function populate_post_with_meta( $theme ) { } // Save the highest recorded version number. - $uploaded_versions = array_keys( $theme->_status ); + $uploaded_versions = is_array( $theme->_status ) ? array_keys( $theme->_status ) : array(); $theme->max_version = end( $uploaded_versions ); return $theme; From 7636e59fb3f8132d8757e917b487d2b6ace292f4 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 2 Jul 2026 12:01:15 -0500 Subject: [PATCH 2/4] Theme Directory: Add unit tests for posts missing the _status meta. Adds a PHPUnit setup for the theme-directory plugin, mirroring the plugin-directory one: a wp-env test environment, a WordPress-backed test bootstrap, and a CI matrix entry. The initial tests cover populate_post_with_meta() and the themes API versions field for repopackage posts missing the _status post meta. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RuMZkdkeAiLcsXjCXjVxpZ --- .github/workflows/unit-tests.yml | 5 + .../theme-directory/.wp-env.test.json | 10 ++ .../theme-directory/bin/after-start-test.sh | 12 ++ .../plugins/theme-directory/phpunit.xml | 12 ++ .../tests/Missing_Status_Meta_Test.php | 162 ++++++++++++++++++ .../theme-directory/tests/bootstrap.php | 61 +++++++ 6 files changed, 262 insertions(+) create mode 100644 environments/theme-directory/.wp-env.test.json create mode 100755 environments/theme-directory/bin/after-start-test.sh create mode 100644 wordpress.org/public_html/wp-content/plugins/theme-directory/phpunit.xml create mode 100644 wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php create mode 100644 wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index be228a1885..74c65fe541 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -79,6 +79,11 @@ jobs: wp-env-args: "--config plugin-directory/.wp-env.test.json" container: tests-cli plugin-name: plugin-directory + - name: Theme Directory + working-directory: environments + wp-env-args: "--config theme-directory/.wp-env.test.json" + container: tests-cli + plugin-name: theme-directory steps: - uses: actions/checkout@v4 diff --git a/environments/theme-directory/.wp-env.test.json b/environments/theme-directory/.wp-env.test.json new file mode 100644 index 0000000000..1d7f9ca999 --- /dev/null +++ b/environments/theme-directory/.wp-env.test.json @@ -0,0 +1,10 @@ +{ + "core": "WordPress/WordPress#master", + "phpVersion": "8.4", + "plugins": [ + "../wordpress.org/public_html/wp-content/plugins/theme-directory" + ], + "lifecycleScripts": { + "afterStart": "bash theme-directory/bin/after-start-test.sh" + } +} diff --git a/environments/theme-directory/bin/after-start-test.sh b/environments/theme-directory/bin/after-start-test.sh new file mode 100755 index 0000000000..889be08e46 --- /dev/null +++ b/environments/theme-directory/bin/after-start-test.sh @@ -0,0 +1,12 @@ +#!/bin/bash +# +# Runs after wp-env start for the test environment. +# Installs PHPUnit 11 and Yoast polyfills in the test container. +# + +CONFIG="--config theme-directory/.wp-env.test.json" +RUN="npx wp-env $CONFIG run tests-cli" + +echo "Installing PHPUnit 11 and polyfills..." +$RUN composer global require -W phpunit/phpunit:^11.0 2>&1 +$RUN composer require --dev yoast/phpunit-polyfills:^4.0 --working-dir=/wordpress-phpunit 2>&1 diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/phpunit.xml b/wordpress.org/public_html/wp-content/plugins/theme-directory/phpunit.xml new file mode 100644 index 0000000000..71b6007751 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/phpunit.xml @@ -0,0 +1,12 @@ + + + + tests/ + tests/bootstrap.php + + + diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php new file mode 100644 index 0000000000..c819c84d91 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php @@ -0,0 +1,162 @@ +post_ids as $post_id ) { + wp_delete_post( $post_id, true ); + } + add_filter( 'before_delete_post', 'wporg_theme_no_delete_repopackage' ); + + $this->post_ids = array(); + + parent::tearDown(); + } + + /** + * Creates a repopackage post. + * + * @param array $meta Optional. Post meta to add to the post. + * @return WP_Post The created post. + */ + protected function create_repopackage( $meta = array() ) { + $post_id = wp_insert_post( array( + 'post_type' => 'repopackage', + 'post_status' => 'publish', + 'post_title' => 'Test Theme', + 'post_name' => 'test-theme', + 'post_author' => 1, + ) ); + + $this->post_ids[] = $post_id; + + foreach ( $meta as $key => $value ) { + add_post_meta( $post_id, $key, $value ); + } + + return get_post( $post_id ); + } + + /** + * A post with no meta at all should not fatal and yield no max_version. + */ + public function test_populate_post_with_meta_without_any_meta() { + $post = $this->create_repopackage(); + $upload = new WPORG_Themes_Upload(); + + $theme = $upload->populate_post_with_meta( $post ); + + $this->assertFalse( $theme->max_version ); + } + + /** + * A post with versioned meta but no `_status` (an orphan from a failed + * import) should not fatal and yield no max_version. + */ + public function test_populate_post_with_meta_without_status_meta() { + $post = $this->create_repopackage( array( + '_requires' => array( '1.0.0' => '5.0' ), + '_author' => array( '1.0.0' => 'Test Author' ), + ) ); + $upload = new WPORG_Themes_Upload(); + + $theme = $upload->populate_post_with_meta( $post ); + + $this->assertFalse( $theme->max_version ); + $this->assertSame( array( '1.0.0' => '5.0' ), $theme->_requires ); + } + + /** + * A post with valid `_status` meta should yield the highest version. + */ + public function test_populate_post_with_meta_with_status_meta() { + $post = $this->create_repopackage( array( + '_status' => array( + '1.1' => 'old', + '1.0' => 'old', + '1.10' => 'live', + ), + ) ); + $upload = new WPORG_Themes_Upload(); + + $theme = $upload->populate_post_with_meta( $post ); + + $this->assertSame( '1.10', $theme->max_version ); + } + + /** + * The themes API should return an empty `versions` array for a published + * theme missing the `_status` meta, rather than fataling. + */ + public function test_theme_information_versions_without_status_meta() { + $this->create_repopackage(); + + $api = new Themes_API( 'theme_information', array( + 'slug' => 'test-theme', + 'fields' => array( + 'versions' => true, + 'downloaded' => false, + 'screenshot_url' => false, + ), + ) ); + + $this->assertObjectNotHasProperty( 'error', $api->response ); + $this->assertSame( array(), $api->response->versions ); + } + + /** + * The themes API should return all versions for a theme with valid + * `_status` meta. + */ + public function test_theme_information_versions_with_status_meta() { + $this->create_repopackage( array( + '_status' => array( + '1.0' => 'old', + '1.1' => 'live', + ), + ) ); + + $api = new Themes_API( 'theme_information', array( + 'slug' => 'test-theme', + 'fields' => array( + 'versions' => true, + 'downloaded' => false, + 'screenshot_url' => false, + ), + ) ); + + $this->assertObjectNotHasProperty( 'error', $api->response ); + $this->assertSame( array( '1.0', '1.1' ), array_keys( $api->response->versions ) ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php new file mode 100644 index 0000000000..88a9f2bd38 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php @@ -0,0 +1,61 @@ + Date: Thu, 2 Jul 2026 12:04:04 -0500 Subject: [PATCH 3/4] Theme Directory: Tests: Fix coding standards violations. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RuMZkdkeAiLcsXjCXjVxpZ --- .../tests/Missing_Status_Meta_Test.php | 38 +++++++++++-------- .../theme-directory/tests/bootstrap.php | 29 +++++++------- 2 files changed, 36 insertions(+), 31 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php index c819c84d91..158ef0aacf 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php @@ -122,14 +122,17 @@ public function test_populate_post_with_meta_with_status_meta() { public function test_theme_information_versions_without_status_meta() { $this->create_repopackage(); - $api = new Themes_API( 'theme_information', array( - 'slug' => 'test-theme', - 'fields' => array( - 'versions' => true, - 'downloaded' => false, - 'screenshot_url' => false, - ), - ) ); + $api = new Themes_API( + 'theme_information', + array( + 'slug' => 'test-theme', + 'fields' => array( + 'versions' => true, + 'downloaded' => false, + 'screenshot_url' => false, + ), + ) + ); $this->assertObjectNotHasProperty( 'error', $api->response ); $this->assertSame( array(), $api->response->versions ); @@ -147,14 +150,17 @@ public function test_theme_information_versions_with_status_meta() { ), ) ); - $api = new Themes_API( 'theme_information', array( - 'slug' => 'test-theme', - 'fields' => array( - 'versions' => true, - 'downloaded' => false, - 'screenshot_url' => false, - ), - ) ); + $api = new Themes_API( + 'theme_information', + array( + 'slug' => 'test-theme', + 'fields' => array( + 'versions' => true, + 'downloaded' => false, + 'screenshot_url' => false, + ), + ) + ); $this->assertObjectNotHasProperty( 'error', $api->response ); $this->assertSame( array( '1.0', '1.1' ), array_keys( $api->response->versions ) ); diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php index 88a9f2bd38..e232641bc1 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/bootstrap.php @@ -11,26 +11,25 @@ return; } -ini_set( 'display_errors', 'on' ); -error_reporting( E_ALL ); - $_tests_dir = getenv( 'WP_TESTS_DIR' ); -// Check if installed in a src checkout. -if ( ! $_tests_dir && false !== ( $pos = stripos( __FILE__, '/src/wp-content/plugins/' ) ) ) { - $_tests_dir = substr( __FILE__, 0, $pos ) . '/tests/phpunit/'; -} -// Check for wp-env test directory. -elseif ( ! $_tests_dir && file_exists( '/wordpress-phpunit/includes/functions.php' ) ) { - $_tests_dir = '/wordpress-phpunit/'; -} -// Elseif no path yet, assume a temp directory path. -elseif ( ! $_tests_dir ) { - $_tests_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/wordpress-tests-lib/tests/phpunit/'; +if ( ! $_tests_dir ) { + $pos = stripos( __FILE__, '/src/wp-content/plugins/' ); + + if ( false !== $pos ) { + // Installed in a src checkout. + $_tests_dir = substr( __FILE__, 0, $pos ) . '/tests/phpunit/'; + } elseif ( file_exists( '/wordpress-phpunit/includes/functions.php' ) ) { + // wp-env test directory. + $_tests_dir = '/wordpress-phpunit/'; + } else { + // Assume a temp directory path. + $_tests_dir = rtrim( sys_get_temp_dir(), '/\\' ) . '/wordpress-tests-lib/tests/phpunit/'; + } } if ( ! file_exists( $_tests_dir . '/includes/functions.php' ) ) { - echo "Could not find $_tests_dir/includes/functions.php\n"; + fwrite( STDERR, "Could not find {$_tests_dir}/includes/functions.php\n" ); exit( 1 ); } From aedd22eafd316718d568edb0b042ef4523258979 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 3 Jul 2026 09:13:29 -0500 Subject: [PATCH 4/4] Theme Directory: Fix cleanup of the theme post when a new upload fails. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Trac ticket creation fails for a new upload, the freshly created repopackage post is deleted again to allow a clean re-upload. That deletion was caught by the fail-safe preventing repopackage deletions, which wp_die()s before the post is removed — leaving an orphaned post without versioned meta behind. Extracts the deletion into a delete_theme_post() method that detaches the fail-safe for the duration of the deletion and restores it after. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RuMZkdkeAiLcsXjCXjVxpZ --- .../class-wporg-themes-upload.php | 20 ++++++++++++++++++- .../tests/Missing_Status_Meta_Test.php | 17 ++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php index fe849c2dfb..5526d4d553 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/class-wporg-themes-upload.php @@ -787,7 +787,7 @@ protected function import( $args = array() ) { } if ( $is_new_upload && $this->theme_post ) { - wp_delete_post( $this->theme_post->ID, true ); + $this->delete_theme_post(); } return new WP_Error( @@ -1449,6 +1449,24 @@ public function create_or_update_theme_post() { } } + /** + * Deletes the theme post. + * + * Used to clean up a freshly created post when a new upload fails. + * Temporarily detaches the fail-safe that prevents repopackages from + * being deleted, which would otherwise wp_die() before the post is + * removed, leaving an orphaned post without versioned meta behind. + * + * @return WP_Post|false|null Post data on success, false or null on failure. + */ + public function delete_theme_post() { + remove_filter( 'before_delete_post', 'wporg_theme_no_delete_repopackage' ); + $result = wp_delete_post( $this->theme_post->ID, true ); + add_filter( 'before_delete_post', 'wporg_theme_no_delete_repopackage' ); + + return $result; + } + /** * Add theme files to SVN. * diff --git a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php index 158ef0aacf..a5e20261dc 100644 --- a/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/theme-directory/tests/Missing_Status_Meta_Test.php @@ -115,6 +115,23 @@ public function test_populate_post_with_meta_with_status_meta() { $this->assertSame( '1.10', $theme->max_version ); } + /** + * Cleaning up a failed new upload should delete the theme post despite + * the fail-safe against repopackage deletion, and restore the fail-safe + * afterwards. + */ + public function test_delete_theme_post_bypasses_deletion_fail_safe() { + $post = $this->create_repopackage(); + $upload = new WPORG_Themes_Upload(); + + $upload->theme_post = $post; + $result = $upload->delete_theme_post(); + + $this->assertNotEmpty( $result ); + $this->assertNull( get_post( $post->ID ) ); + $this->assertSame( 10, has_filter( 'before_delete_post', 'wporg_theme_no_delete_repopackage' ) ); + } + /** * The themes API should return an empty `versions` array for a published * theme missing the `_status` meta, rather than fataling.