diff --git a/classes/Visualizer/ActionScheduler/Store.php b/classes/Visualizer/ActionScheduler/Store.php new file mode 100644 index 000000000..f080dbf89 --- /dev/null +++ b/classes/Visualizer/ActionScheduler/Store.php @@ -0,0 +1,49 @@ +update( + $wpdb->actionscheduler_actions, + array( 'status' => self::STATUS_FAILED ), + array( 'action_id' => $action_id ), + array( '%s' ), + array( '%d' ) + ); + if ( false === $updated ) { + /* translators: %d is the action ID */ + throw new InvalidArgumentException( sprintf( __( 'Unable to mark action %d as failed.', 'visualizer' ), $action_id ) ); + } + } +} diff --git a/classes/Visualizer/Module.php b/classes/Visualizer/Module.php index ae7993917..ff9fa2c33 100644 --- a/classes/Visualizer/Module.php +++ b/classes/Visualizer/Module.php @@ -285,11 +285,11 @@ private function _getCSV( $rows, $filename, $enclose ) { // support for MS Excel fprintf( $fp, $bom ); foreach ( $rows as $row ) { - fputcsv( $fp, $row ); + fputcsv( $fp, $row, ',', '"', VISUALIZER_CSV_ESCAPE ); } rewind( $fp ); $csv = ''; - $array = fgetcsv( $fp ); + $array = fgetcsv( $fp, 0, ',', '"', VISUALIZER_CSV_ESCAPE ); while ( $array !== false ) { if ( strlen( $csv ) > 0 ) { $csv .= PHP_EOL; @@ -307,7 +307,7 @@ private function _getCSV( $rows, $filename, $enclose ) { $array = $temp_array; } $csv .= implode( ',', $array ); - $array = fgetcsv( $fp ); + $array = fgetcsv( $fp, 0, ',', '"', VISUALIZER_CSV_ESCAPE ); } fclose( $fp ); diff --git a/classes/Visualizer/Module/Abilities.php b/classes/Visualizer/Module/Abilities.php index 54bb221a1..95c2cb430 100644 --- a/classes/Visualizer/Module/Abilities.php +++ b/classes/Visualizer/Module/Abilities.php @@ -1209,7 +1209,7 @@ private function buildManualSource( $input ) { return new WP_Error( 'visualizer_tmp_file', __( 'A temporary file could not be created.', 'visualizer' ) ); } foreach ( $lines as $line ) { - fputcsv( $handle, $line, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE ); + fputcsv( $handle, $line, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE, VISUALIZER_CSV_ESCAPE ); } fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose diff --git a/classes/Visualizer/Module/Chart.php b/classes/Visualizer/Module/Chart.php index 4ca5cd06f..bbe126e0a 100644 --- a/classes/Visualizer/Module/Chart.php +++ b/classes/Visualizer/Module/Chart.php @@ -346,10 +346,10 @@ public function getCharts() { if ( ! current_user_can( 'edit_others_posts' ) ) { $query_args['author'] = get_current_user_id(); } - $filter = filter_input( INPUT_GET, 's', FILTER_SANITIZE_STRING ); + $filter = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; if ( empty( $filter ) ) { // 'filter' is from the modal from the add media button. - $filter = filter_input( INPUT_GET, 'filter', FILTER_SANITIZE_STRING ); + $filter = isset( $_GET['filter'] ) ? sanitize_text_field( wp_unslash( $_GET['filter'] ) ) : ''; } if ( $filter && in_array( $filter, Visualizer_Plugin::getChartTypes(), true ) ) { @@ -1259,7 +1259,7 @@ private function handleTabularData() { foreach ( $exclude as $j ) { unset( $row[ $j ] ); } - fputcsv( $handle, $row ); + fputcsv( $handle, $row, ',', '"', VISUALIZER_CSV_ESCAPE ); } } $source = new Visualizer_Source_Csv( $tmpfile ); diff --git a/classes/Visualizer/Source/Csv.php b/classes/Visualizer/Source/Csv.php index 679714e58..f4fb5b65a 100644 --- a/classes/Visualizer/Source/Csv.php +++ b/classes/Visualizer/Source/Csv.php @@ -61,7 +61,7 @@ public function __construct( $filename = '' ) { */ private function _fetchSeries( &$handle ) { // read column titles - $labels = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE ); + $labels = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE, VISUALIZER_CSV_ESCAPE ); $types = null; if ( false !== strpos( $this->_filename, 'tqx=out:csv' ) ) { @@ -74,7 +74,7 @@ private function _fetchSeries( &$handle ) { if ( is_null( $types ) ) { // read series types - $types = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE ); + $types = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE, VISUALIZER_CSV_ESCAPE ); } if ( ! is_array( $labels ) || ! is_array( $types ) ) { @@ -155,10 +155,10 @@ public function fetch() { } // fetch data - $data = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE ); + $data = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE, VISUALIZER_CSV_ESCAPE ); while ( $data !== false ) { $this->_data[] = $this->_normalizeData( $data ); - $data = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE ); + $data = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE, VISUALIZER_CSV_ESCAPE ); } // close file handle diff --git a/composer.lock b/composer.lock index aef72b519..47f7fb9d5 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.65", + "version": "3.3.67", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8" + "reference": "e1dda44811660a65c3da6994349c054a46686d69" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/f650fe856d52ce4e5754557d89ba2f3127ad54d8", - "reference": "f650fe856d52ce4e5754557d89ba2f3127ad54d8", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/e1dda44811660a65c3da6994349c054a46686d69", + "reference": "e1dda44811660a65c3da6994349c054a46686d69", "shasum": "" }, "require-dev": { @@ -43,9 +43,9 @@ ], "support": { "issues": "https://github.com/Codeinwp/themeisle-sdk/issues", - "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.65" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.67" }, - "time": "2026-09-29T09:35:47+00:00" + "time": "2026-10-13T12:16:34+00:00" }, { "name": "neitanod/forceutf8", @@ -1066,11 +1066,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.14", + "version": "2.3.1", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", - "reference": "9c672e7a8e791dfc3d30e55f683e73fc0b63a3ac", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/6155239b488e95d26d1ebd95c6b4bb294d7f09db", + "reference": "6155239b488e95d26d1ebd95c6b4bb294d7f09db", "shasum": "" }, "require": { @@ -1126,7 +1126,7 @@ "type": "github" } ], - "time": "2026-09-12T21:39:33+00:00" + "time": "2026-10-08T21:09:11+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/index.php b/index.php index 92530b82a..177bebe7b 100644 --- a/index.php +++ b/index.php @@ -81,6 +81,9 @@ function visualizer_launch() { if ( ! defined( 'VISUALIZER_CSV_ENCLOSURE' ) ) { define( 'VISUALIZER_CSV_ENCLOSURE', '"' ); } + if ( ! defined( 'VISUALIZER_CSV_ESCAPE' ) ) { + define( 'VISUALIZER_CSV_ESCAPE', '\\' ); + } if ( ! defined( 'VISUALIZER_DEBUG' ) ) { define( 'VISUALIZER_DEBUG', false ); } @@ -159,6 +162,9 @@ function () { require_once $action_scheduler_file; } + // After Action Scheduler's own data controller, which sets the class at 100. + add_filter( 'action_scheduler_store_class', 'visualizer_action_scheduler_store_class', 200 ); + add_filter( 'themeisle_sdk_products', 'visualizer_register_sdk', 10, 1 ); add_filter( 'pirate_parrot_log', 'visualizer_register_parrot', 10, 1 ); add_filter( @@ -254,6 +260,20 @@ function visualizer_can_use_action_scheduler() { return isset( $wpdb ) && is_callable( array( $wpdb, 'db_server_info' ) ); } +/** + * Use a store that survives a lost race when marking an action failed. + * + * Only replaces Action Scheduler's own database store. Another plugin's store + * and the legacy post store, which does not have the problem, are left alone. + * + * @param string $class_name Store class Action Scheduler resolved. + * + * @return string + */ +function visualizer_action_scheduler_store_class( $class_name ) { + return 'ActionScheduler_DBStore' === $class_name ? 'Visualizer_ActionScheduler_Store' : $class_name; +} + /** * Registers with the SDK * diff --git a/phpstan.neon b/phpstan.neon index 021d02592..477bdeff4 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -12,6 +12,7 @@ parameters: - %currentWorkingDirectory%/vendor/neitanod/forceutf8 - %currentWorkingDirectory%/vendor/openspout/openspout - %currentWorkingDirectory%/vendor/codeinwp/themeisle-sdk + - %currentWorkingDirectory%/vendor/woocommerce/action-scheduler excludePaths: - classes/Visualizer/Gutenberg/build (?) - classes/Visualizer/GutenChartBuilder/build (?) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 230071c9a..ef7f6d385 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -32,6 +32,14 @@ function _manually_load_plugin() { tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' ); // Start up the WP testing environment. require $_tests_dir . '/includes/bootstrap.php'; + +// The framework snapshots hooks at the first test and restores that snapshot +// after every test. WP_Ajax_UnitTestCase removes these once per class, which +// only holds when an AJAX class runs first. Remove them here so no test file +// order makes AJAX tests call api.wordpress.org. +remove_action( 'admin_init', '_maybe_update_core' ); +remove_action( 'admin_init', '_maybe_update_plugins' ); +remove_action( 'admin_init', '_maybe_update_themes' ); activate_plugin( 'visualizer/index.php' ); global $current_user; $current_user = new WP_User( 1 ); diff --git a/tests/php/static-analysis-stubs/symbols.php b/tests/php/static-analysis-stubs/symbols.php index 4a32429d1..0e17329a9 100644 --- a/tests/php/static-analysis-stubs/symbols.php +++ b/tests/php/static-analysis-stubs/symbols.php @@ -18,6 +18,9 @@ if ( ! defined( 'VISUALIZER_CSV_ENCLOSURE' ) ) { define( 'VISUALIZER_CSV_ENCLOSURE', '"' ); } +if ( ! defined( 'VISUALIZER_CSV_ESCAPE' ) ) { + define( 'VISUALIZER_CSV_ESCAPE', '\\' ); +} if ( ! defined( 'VISUALIZER_DEBUG' ) ) { define( 'VISUALIZER_DEBUG', false ); } diff --git a/tests/test-action-scheduler-mark-failure.php b/tests/test-action-scheduler-mark-failure.php new file mode 100644 index 000000000..8b40e8b8f --- /dev/null +++ b/tests/test-action-scheduler-mark-failure.php @@ -0,0 +1,241 @@ + + */ + private $filters_to_remove = array(); + + /** + * Skip when Action Scheduler is not loaded. + */ + public function set_up() { + parent::set_up(); + + if ( ! class_exists( 'ActionScheduler_DBStore' ) ) { + $this->markTestSkipped( 'Action Scheduler is not loaded.' ); + } + + $this->store = new Visualizer_ActionScheduler_Store(); + $this->store->init(); + } + + /** + * Remove the query filters even when a test throws. + */ + public function tear_down() { + foreach ( $this->filters_to_remove as $filter ) { + remove_filter( 'query', $filter ); + } + $this->filters_to_remove = array(); + parent::tear_down(); + } + + /** + * Save an action, then make it a stale in-progress one (last attempt two hours ago). + * + * @return int Action id. + */ + private function seed_stale_running_action() { + global $wpdb; + $action_id = $this->store->save_action( new ActionScheduler_Action( 'visualizer_schedule_refresh_db', array(), new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-2 hours' ) ) ) ); + $gmt = gmdate( 'Y-m-d H:i:s', time() - 2 * HOUR_IN_SECONDS ); + $wpdb->update( + $wpdb->actionscheduler_actions, + array( + 'status' => ActionScheduler_Store::STATUS_RUNNING, + 'last_attempt_gmt' => $gmt, + 'last_attempt_local' => $gmt, + ), + array( 'action_id' => $action_id ) + ); + return (int) $action_id; + } + + /** + * Status column of one action, or null when the row is gone. + * + * @param int $action_id Action id. + * @return string|null + */ + private function status_of( $action_id ) { + global $wpdb; + return $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$wpdb->actionscheduler_actions} WHERE action_id = %d", $action_id ) ); + } + + /** + * Run `$intercept` once, on the UPDATE that marks `$action_id` failed, and + * use its return value as the SQL to execute. + * + * @param int $action_id Action whose UPDATE is intercepted. + * @param callable(string): string $intercept Receives the SQL, returns the SQL to run. + */ + private function intercept_mark_failure_update( $action_id, callable $intercept ) { + global $wpdb; + $table = $wpdb->actionscheduler_actions; + $done = false; + // Queries issued inside $intercept re-enter this filter: run it once only. + $filter = function ( $sql ) use ( $action_id, $table, $intercept, &$done ) { + if ( $done || ! $this->is_mark_failed_update( $sql, $table, $action_id ) ) { + return $sql; + } + $done = true; + return $intercept( $sql ); + }; + add_filter( 'query', $filter ); + $this->filters_to_remove[] = $filter; + } + + /** + * Whether `$sql` is the UPDATE that marks `$action_id` in `$table` failed. + * Matches the SQL `wpdb::update()` builds, with or without backticks, and a bare or quoted action id. + * + * @param string $sql SQL about to run. + * @param string $table Actions table name. + * @param int $action_id Action id. + * @return bool + */ + private function is_mark_failed_update( $sql, $table, $action_id ) { + if ( 0 !== stripos( ltrim( $sql ), 'UPDATE' ) || false === strpos( $sql, $table ) ) { + return false; + } + if ( ! preg_match( '/status`?\s*=\s*\'' . preg_quote( ActionScheduler_Store::STATUS_FAILED, '/' ) . '\'/', $sql ) ) { + return false; + } + return preg_match( '/action_id`?\s*=\s*\'?(\d+)/', $sql, $m ) && (int) $m[1] === $action_id; + } + + /** + * Visualizer replaces Action Scheduler's own database store, and nothing else. + */ + public function test_filter_replaces_only_the_default_database_store() { + $this->assertSame( 'Visualizer_ActionScheduler_Store', visualizer_action_scheduler_store_class( 'ActionScheduler_DBStore' ) ); + $this->assertSame( 'Another_Plugin_Store', visualizer_action_scheduler_store_class( 'Another_Plugin_Store' ) ); + $this->assertSame( 'ActionScheduler_HybridStore', visualizer_action_scheduler_store_class( 'ActionScheduler_HybridStore' ) ); + } + + /** + * The filter runs after Action Scheduler's data controller, so its class wins. + */ + public function test_filter_runs_after_the_data_controller() { + update_option( 'action_scheduler_migration_status', 'complete' ); + + $this->assertSame( 'Visualizer_ActionScheduler_Store', apply_filters( 'action_scheduler_store_class', ActionScheduler_Store::DEFAULT_CLASS ) ); + } + + /** + * Another process deleted the action: nothing left to mark. + */ + public function test_mark_failure_tolerates_a_deleted_action() { + global $wpdb; + $action_id = $this->seed_stale_running_action(); + $wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $action_id ) ); + + $this->store->mark_failure( $action_id ); + + $this->assertNull( $this->status_of( $action_id ) ); + } + + /** + * An overlapping cleaner already marked it failed: the UPDATE changes nothing. + */ + public function test_mark_failure_tolerates_an_already_failed_action() { + global $wpdb; + $action_id = $this->seed_stale_running_action(); + $wpdb->update( $wpdb->actionscheduler_actions, array( 'status' => ActionScheduler_Store::STATUS_FAILED ), array( 'action_id' => $action_id ) ); + + $this->store->mark_failure( $action_id ); + + $this->assertSame( ActionScheduler_Store::STATUS_FAILED, $this->status_of( $action_id ) ); + } + + /** + * A real database error still surfaces. + */ + public function test_mark_failure_still_throws_on_a_database_error() { + global $wpdb; + $action_id = $this->seed_stale_running_action(); + + // Break the UPDATE itself: the store gets `false`, not zero rows. + $this->intercept_mark_failure_update( + $action_id, + static function ( $sql ) use ( $wpdb ) { + return str_replace( $wpdb->actionscheduler_actions, 'no_such_table', $sql ); + } + ); + $suppressed = $wpdb->suppress_errors( true ); + + $this->expectException( InvalidArgumentException::class ); + try { + $this->store->mark_failure( $action_id ); + } finally { + $wpdb->suppress_errors( $suppressed ); + } + } + + /** + * Queue cleanup keeps going when an action vanishes between its query and its update. + */ + public function test_mark_failures_continues_past_an_action_deleted_by_another_process() { + global $wpdb; + $vanishing = $this->seed_stale_running_action(); + $survivor = $this->seed_stale_running_action(); + + $this->intercept_mark_failure_update( + $vanishing, + static function ( $sql ) use ( $wpdb, $vanishing ) { + $wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $vanishing ) ); + return $sql; + } + ); + + ( new ActionScheduler_QueueCleaner( $this->store ) )->mark_failures( 60 ); + + $this->assertNull( $this->status_of( $vanishing ), 'the concurrently deleted action stays gone' ); + $this->assertSame( ActionScheduler_Store::STATUS_FAILED, $this->status_of( $survivor ), 'cleanup continues and marks the remaining stale action failed' ); + } + + /** + * Runner path (the trace in upstream #970): the action is deleted while it + * runs, then it throws, and the runner marks it failed. + */ + public function test_process_action_survives_marking_a_deleted_action() { + global $wpdb; + $hook = 'visualizer_test_throwing_action'; + $action_id = $this->store->save_action( new ActionScheduler_Action( $hook, array(), new ActionScheduler_SimpleSchedule( as_get_datetime_object( '-1 minute' ) ) ) ); + + add_action( + $hook, + static function () use ( $wpdb, $action_id ) { + $wpdb->delete( $wpdb->actionscheduler_actions, array( 'action_id' => $action_id ) ); + throw new RuntimeException( 'refresh failed' ); + } + ); + + ( new ActionScheduler_QueueRunner( $this->store ) )->process_action( $action_id, 'test' ); + + $this->assertNull( $this->status_of( $action_id ), 'the deleted action stays gone and the run survives' ); + } +} diff --git a/tests/test-ajax.php b/tests/test-ajax.php index 386734002..20a0ed8c4 100644 --- a/tests/test-ajax.php +++ b/tests/test-ajax.php @@ -988,6 +988,45 @@ public function test_get_charts_only_lists_editable_scope_for_contributor() { $this->assertNotContains( $other_chart, $chart_ids ); } + /** + * Request params the chart library reads the chart type filter from. + * + * @return array + */ + public function chart_type_filter_param_provider(): array { + return array( + 'search box' => array( 's' ), + 'media modal' => array( 'filter' ), + ); + } + + /** + * The chart library filters by the requested chart type. + * + * @dataProvider chart_type_filter_param_provider + * + * @param string $param Request param carrying the chart type. + */ + public function test_get_charts_filters_by_chart_type( string $param ): void { + $this->create_chart_for_user( $this->admin_user_id, 'publish' ); + $pie_chart = $this->create_chart_for_user( $this->admin_user_id, 'publish' ); + update_post_meta( $pie_chart, Visualizer_Plugin::CF_CHART_TYPE, 'pie' ); + $_GET = array( + 'nonce' => wp_create_nonce( Visualizer_Plugin::ACTION_GET_CHARTS ), + $param => 'pie', + ); + + try { + $this->_handleAjax( Visualizer_Plugin::ACTION_GET_CHARTS ); + } catch ( WPAjaxDieContinueException $e ) { + // Expected after the JSON response. + } + + $response = json_decode( $this->_last_response, true ); + $this->assertTrue( $response['success'] ); + $this->assertSame( array( $pie_chart ), wp_list_pluck( $response['data'], 'id' ) ); + } + /** * A valid nonce does not permit deleting another user's chart. */ diff --git a/tests/test-csv-escape.php b/tests/test-csv-escape.php new file mode 100644 index 000000000..a033f65be --- /dev/null +++ b/tests/test-csv-escape.php @@ -0,0 +1,57 @@ +file ) { + wp_delete_file( $this->file ); + } + parent::tearDown(); + } + + /** + * CSV import keeps the backslash escape and logs no deprecation. + */ + public function test_csv_import_uses_backslash_escape(): void { + $this->file = wp_tempnam( 'visualizer' ); + file_put_contents( $this->file, "Label,Value\nstring,number\n\"say \\\"hi\\\"\",1\n" ); + + $deprecations = array(); + set_error_handler( + static function ( int $errno, string $errstr ) use ( &$deprecations ): bool { + $deprecations[] = $errstr; + return true; + }, + E_DEPRECATED + ); + try { + $source = new Visualizer_Source_Csv( $this->file ); + $this->assertTrue( $source->fetch() ); + } finally { + restore_error_handler(); + } + + $this->assertSame( array(), $deprecations ); + $this->assertSame( 'say "hi"', $source->getRawData()[0][0] ); + } +}