diff --git a/src/Admin/Module.php b/src/Admin/Module.php index 3bc61427..b383b8bd 100644 --- a/src/Admin/Module.php +++ b/src/Admin/Module.php @@ -43,9 +43,12 @@ private function init() { * */ public function maybe_install_module( $old_settings, $settings ) { - if ( $settings['proxy_enabled'] === 'on' && $old_settings['proxy_enabled'] !== 'on' ) { + $settings_proxy = ( is_array( $settings ) && isset( $settings['proxy_enabled'] ) ) ? $settings['proxy_enabled'] : ''; + $old_proxy = ( is_array( $old_settings ) && isset( $old_settings['proxy_enabled'] ) ) ? $old_settings['proxy_enabled'] : ''; + + if ( $settings_proxy === 'on' && $old_proxy !== 'on' ) { $this->install(); - } elseif ( $settings['proxy_enabled'] === '' && $old_settings['proxy_enabled'] === 'on' ) { + } elseif ( $settings_proxy === '' && $old_proxy === 'on' ) { $this->uninstall(); } } @@ -196,7 +199,10 @@ public function maybe_enable_proxy( $settings, $old_settings ) { /** * No need to run this on each update run, or when the proxy is disabled. */ - if ( empty( $settings['proxy_enabled'] ) || ( $settings['proxy_enabled'] === 'on' && $old_settings['proxy_enabled'] === 'on' ) ) { + $new_proxy_setting = ( is_array( $settings ) && isset( $settings['proxy_enabled'] ) ) ? $settings['proxy_enabled'] : ''; + $old_proxy_setting = ( is_array( $old_settings ) && isset( $old_settings['proxy_enabled'] ) ) ? $old_settings['proxy_enabled'] : ''; + + if ( empty( $new_proxy_setting ) || ( $new_proxy_setting === 'on' && $old_proxy_setting === 'on' ) ) { return $settings; } @@ -259,6 +265,11 @@ private function is_ssl() { * @since 1.3.0 */ private function test_proxy( $run = true ) { + // Always succeed if this is a CI environment. + if ( defined( 'PLAUSIBLE_CI' ) ) { + return true; + } + // Should we run the test? if ( ! apply_filters( 'plausible_analytics_module_run_test_proxy', $run ) ) { return false; // @codeCoverageIgnore diff --git a/src/Ajax.php b/src/Ajax.php index e72aea49..197cca48 100644 --- a/src/Ajax.php +++ b/src/Ajax.php @@ -76,7 +76,7 @@ public function fetch_messages() { public function quit_wizard() { $request_data = $this->clean( $_REQUEST ); - if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $request_data[ '_nonce' ], 'plausible_analytics_quit_wizard' ) < 1 ) { + if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $request_data['_nonce'], 'plausible_analytics_quit_wizard' ) < 1 ) { Messages::set_error( __( 'Not allowed', 'plausible-analytics' ) ); wp_send_json_error( null, 403 ); @@ -84,7 +84,7 @@ public function quit_wizard() { update_option( 'plausible_analytics_wizard_done', true ); - $this->maybe_handle_redirect( $request_data[ 'redirect' ] ); + $this->maybe_handle_redirect( $request_data['redirect'] ); wp_send_json_success(); } @@ -93,26 +93,41 @@ public function quit_wizard() { * Clean variables using `sanitize_text_field`. * Arrays are cleaned recursively. Non-scalar values are ignored. * - * @since 1.3.0 - * @access public - * * @param string|array $var Sanitize the variable. * * @return string|array + * @since 1.3.0 + * @access public + * */ - private function clean( $var ) { + private function clean( $var, $key = '' ) { // If the variable is an array, recursively apply the function to each element of the array. if ( is_array( $var ) ) { - return array_map( [ $this, 'clean' ], $var ); + $cleaned = []; + + foreach ( $var as $k => $v ) { + $cleaned[ $k ] = $this->clean( $v, $k ); + } + + return $cleaned; } // If the variable is a scalar value (string, integer, float, boolean). if ( is_scalar( $var ) ) { + /** + * If the variable is the options object, we only unslash it, but don't sanitize it yet. + * Sanitization will happen after json_decode. + */ + if ( $key === 'options' ) { + return wp_unslash( $var ); + } + // Parse the variable using the wp_parse_url function. $parsed = wp_parse_url( $var ); + // If the variable has a scheme (e.g. http:// or https://), sanitize the variable using the esc_url_raw function. - if ( isset( $parsed[ 'scheme' ] ) ) { - return esc_url_raw( wp_unslash( $var ), [ $parsed[ 'scheme' ] ] ); + if ( isset( $parsed['scheme'] ) ) { + return esc_url_raw( wp_unslash( $var ) ); } // If the variable does not have a scheme, sanitize the variable using the sanitize_text_field function. @@ -157,7 +172,7 @@ private function maybe_handle_redirect( $direction ) { public function show_wizard() { $request_data = $this->clean( $_REQUEST ); - if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $request_data[ '_nonce' ], 'plausible_analytics_show_wizard' ) < 1 ) { + if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $request_data['_nonce'], 'plausible_analytics_show_wizard' ) < 1 ) { Messages::set_error( __( 'Not allowed.', 'plausible-analytics' ) ); wp_send_json_error( null, 403 ); @@ -165,7 +180,7 @@ public function show_wizard() { delete_option( 'plausible_analytics_wizard_done' ); - $this->maybe_handle_redirect( $request_data[ 'redirect' ] ); + $this->maybe_handle_redirect( $request_data['redirect'] ); wp_send_json_success(); } @@ -173,38 +188,38 @@ public function show_wizard() { /** * Save Admin Settings * - * @since 1.0.0 * @return void + * @since 1.0.0 */ public function toggle_option() { // Sanitize all the post data before using. $post_data = $this->clean( $_POST ); $settings = Helpers::get_settings(); - if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $post_data[ '_nonce' ], 'plausible_analytics_toggle_option' ) < 1 ) { + if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $post_data['_nonce'], 'plausible_analytics_toggle_option' ) < 1 ) { wp_send_json_error( __( 'Not allowed.', 'plausible-analytics' ), 403 ); } - if ( $post_data[ 'is_list' ] ) { + if ( $post_data['is_list'] ) { /** * Toggle lists. */ - if ( $post_data[ 'toggle_status' ] === 'on' ) { + if ( $post_data['toggle_status'] === 'on' ) { // If toggle is on, store the value under a new key. - if ( ! in_array( $post_data[ 'option_value' ], $settings[ $post_data[ 'option_name' ] ] ) ) { - $settings[ $post_data[ 'option_name' ] ][] = $post_data[ 'option_value' ]; + if ( ! in_array( $post_data['option_value'], $settings[ $post_data['option_name'] ] ) ) { + $settings[ $post_data['option_name'] ][] = $post_data['option_value']; } } else { // If toggle is off, find the key by its value and unset it. - if ( ( $key = array_search( $post_data[ 'option_value' ], $settings[ $post_data[ 'option_name' ] ] ) ) !== false ) { - unset( $settings[ $post_data[ 'option_name' ] ][ $key ] ); + if ( ( $key = array_search( $post_data['option_value'], $settings[ $post_data['option_name'] ] ) ) !== false ) { + unset( $settings[ $post_data['option_name'] ][ $key ] ); } } } else { /** * Single toggles. */ - $settings[ $post_data[ 'option_name' ] ] = $post_data[ 'toggle_status' ]; + $settings[ $post_data['option_name'] ] = $post_data['toggle_status']; } // Update all the options to plausible settings. @@ -213,22 +228,22 @@ public function toggle_option() { /** * Allow devs to perform additional actions. */ - do_action( 'plausible_analytics_settings_saved', $settings, $post_data[ 'option_name' ], $post_data[ 'toggle_status' ] ); + do_action( 'plausible_analytics_settings_saved', $settings, $post_data['option_name'], $post_data['toggle_status'] ); - $option_label = $post_data[ 'option_label' ]; - $toggle_status = $post_data[ 'toggle_status' ] === 'on' ? __( 'enabled', 'plausible-analytics' ) : __( 'disabled', 'plausible-analytics' ); + $option_label = $post_data['option_label']; + $toggle_status = $post_data['toggle_status'] === 'on' ? __( 'enabled', 'plausible-analytics' ) : __( 'disabled', 'plausible-analytics' ); $message = apply_filters( 'plausible_analytics_toggle_option_success_message', sprintf( '%s %s.', $option_label, $toggle_status ), - $post_data[ 'option_name' ], - $post_data[ 'toggle_status' ] + $post_data['option_name'], + $post_data['toggle_status'] ); Messages::set_success( $message ); - $additional = $this->maybe_render_additional_message( $post_data[ 'option_name' ], $post_data[ 'toggle_status' ] ); + $additional = $this->maybe_render_additional_message( $post_data['option_name'], $post_data['toggle_status'] ); - Messages::set_additional( $additional, $post_data[ 'option_name' ] ); + Messages::set_additional( $additional, $post_data['option_name'] ); wp_send_json_success( null, 200 ); } @@ -271,17 +286,21 @@ public function save_options() { $post_data = $this->clean( $_POST ); $settings = Helpers::get_settings(); - if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $post_data[ '_nonce' ], 'plausible_analytics_toggle_option' ) < 1 ) { + if ( ! current_user_can( 'manage_options' ) || wp_verify_nonce( $post_data['_nonce'], 'plausible_analytics_toggle_option' ) < 1 ) { Messages::set_error( __( 'Not allowed.', 'plausible-analytics' ) ); wp_send_json_error( null, 403 ); } - $options = json_decode( $post_data[ 'options' ] ); + $options = json_decode( $post_data['options'] ); if ( empty( $options ) ) { Messages::set_error( __( 'No options found to save.', 'plausible-analytics' ) ); + if ( defined( 'PLAUSIBLE_CI' ) ) { + return; + } + wp_send_json_error( null, 400 ); } @@ -298,38 +317,42 @@ function ( $option ) { ); if ( count( $input_array_elements ) > 0 ) { - $options = []; - $array_name = preg_replace( '/\[[0-9]+]/', '', $input_array_elements[ 0 ]->name ); - $options[ 0 ] = (object) []; - $options[ 0 ]->name = $array_name; + $options = []; + $array_name = preg_replace( '/\[[0-9]+]/', '', $input_array_elements[0]->name ); + $options[0] = (object) []; + $options[0]->name = $array_name; + $options[0]->value = []; foreach ( $input_array_elements as $input_array_element ) { if ( $input_array_element->value ) { - $options[ 0 ]->value[] = $input_array_element->value; + $options[0]->value[] = $input_array_element->value; } } } foreach ( $options as $option ) { + $name = sanitize_text_field( $option->name ); + $value = $this->clean( $option->value ); + // Clean spaces - if ( is_string( $option->value ) ) { - $settings[ $option->name ] = trim( $option->value ); + if ( is_string( $value ) ) { + $settings[ $name ] = trim( $value ); } else { - $settings[ $option->name ] = $option->value; + $settings[ $name ] = $value; } // Validate Plugin Token if this is the Plugin Token field. - if ( $option->name === 'api_token' ) { - $this->validate_api_token( $option->value ); + if ( $name === 'api_token' ) { + $this->validate_api_token( $value ); - $additional = $this->maybe_render_additional_message( $option->name, $option->value ); + $additional = $this->maybe_render_additional_message( $name, $value ); - Messages::set_additional( $additional, $option->name ); + Messages::set_additional( $additional, $name ); } // Refresh Tracker ID if Domain Name has changed (e.g. after migration from staging to production) - if ($option->name === 'domain_name') { - delete_option('plausible_analytics_tracker_id'); + if ( $name === 'domain_name' ) { + delete_option( 'plausible_analytics_tracker_id' ); } } @@ -337,7 +360,9 @@ function ( $option ) { Messages::set_success( __( 'Settings saved.', 'plausible-analytics' ) ); - wp_send_json_success( null, 200 ); + if ( ! defined( 'PLAUSIBLE_CI' ) ) { + wp_send_json_success( null, 200 ); + } } /** diff --git a/src/Assets.php b/src/Assets.php index bf532868..aa9d7ba2 100644 --- a/src/Assets.php +++ b/src/Assets.php @@ -21,7 +21,7 @@ public function __construct() { * @return void */ private function init() { - add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_main_script' ] ); + add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_main_script' ], 1 ); add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_cloaked_affiliate_links_assets' ], 11 ); add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_four_o_four_script' ], 11 ); add_action( 'wp_enqueue_scripts', [ $this, 'maybe_enqueue_query_params_script' ], 11 ); @@ -41,6 +41,11 @@ public function maybe_enqueue_main_script() { $settings = Helpers::get_settings(); $user_role = Helpers::get_user_role(); + /** + * This is a dummy script that will allow us to attach inline scripts further down the line. + */ + wp_register_script( 'plausible-analytics', '' ); + /** * Bail if tracked_user_roles is empty (which means no roles should be tracked) or if the current role should not be tracked. */ @@ -48,10 +53,6 @@ public function maybe_enqueue_main_script() { return; // @codeCoverageIgnore } - /** - * This is a dummy script that will allow us to attach inline scripts further down the line. - */ - wp_register_script( 'plausible-analytics', '' ); wp_enqueue_script( 'plausible-analytics', '', diff --git a/src/Helpers.php b/src/Helpers.php index 5662ae47..edc647d8 100644 --- a/src/Helpers.php +++ b/src/Helpers.php @@ -116,17 +116,6 @@ public static function get_settings() { $settings = get_option( 'plausible_analytics_settings', [] ); - /** - * If this is an AJAX request, make sure the latest settings are used. - */ - if ( isset( $_POST['action'] ) && $_POST['action'] === 'plausible_analytics_save_options' ) { - $options = json_decode( str_replace( '\\', '', $_POST['options'] ) ); - - foreach ( $options as $option ) { - $settings[ $option->name ] = $option->value; - } - } - return apply_filters( 'plausible_analytics_settings', wp_parse_args( $settings, $defaults ) ); } diff --git a/tests/TestCase.php b/tests/TestCase.php index b4f96717..ed4d4d30 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -21,6 +21,10 @@ public function __construct() { define( 'PLAUSIBLE_TESTS_ROOT', __DIR__ . '/' ); } + if ( ! defined( 'PLAUSIBLE_CI' ) ) { + define( 'PLAUSIBLE_CI', true ); + } + parent::__construct(); } @@ -241,6 +245,12 @@ public function setQueryParams( $settings ) { return $settings; } + public function enableAdministratorTracking( $settings ) { + $settings['tracked_user_roles'][] = 'administrator'; + + return $settings; + } + /** * Checks an array for a (partial) match with $string. * diff --git a/tests/TestableHelpers.php b/tests/TestableHelpers.php index b07c1a49..f2287e89 100644 --- a/tests/TestableHelpers.php +++ b/tests/TestableHelpers.php @@ -5,6 +5,7 @@ namespace Plausible\Analytics\Tests; +use Plausible\Analytics\WP\Client; use Plausible\Analytics\WP\Helpers; /** @@ -15,7 +16,7 @@ class TestableHelpers extends Helpers { * @return */ protected static function get_client() { - return new class { + return new class extends Client { public function get_tracker_id() { return 'pa-test-tracker-id'; } diff --git a/tests/integration/AdminBarTest.php b/tests/integration/AdminBarTest.php index 1578052d..82b0bb8b 100644 --- a/tests/integration/AdminBarTest.php +++ b/tests/integration/AdminBarTest.php @@ -21,6 +21,8 @@ public function testAdminBarNode() { } wp_set_current_user( 1 ); + $user = wp_get_current_user(); + $user->add_role( 'administrator' ); $admin_bar = new WP_Admin_Bar(); $class->admin_bar_node( $admin_bar ); $this->assertNotEmpty( $admin_bar->get_node( 'plausible-analytics' ) ); diff --git a/tests/integration/AjaxTest.php b/tests/integration/AjaxTest.php new file mode 100644 index 00000000..a8870c94 --- /dev/null +++ b/tests/integration/AjaxTest.php @@ -0,0 +1,111 @@ +ajax = new Ajax(); + + // Ensure we are an admin for these tests. + $this->addUserCap( 'manage_options' ); + + // Mock nonce verification + add_filter( 'nonce_user_logged_out', '__return_true' ); + } + + /** + * Clean up after each test. + * + * @return void + */ + public function tearDown(): void { + parent::tearDown(); + + $_POST = []; + + remove_filter( 'nonce_user_logged_out', '__return_true' ); + } + + /** + * Test save_options with normal JSON data. + */ + public function testSaveOptionsSuccess() { + $options = [ + [ 'name' => 'domain_name', 'value' => 'example.com' ], + [ 'name' => 'proxy_enabled', 'value' => 'on' ], + ]; + + $_POST['_nonce'] = wp_create_nonce( 'plausible_analytics_toggle_option' ); + $_POST['options'] = wp_json_encode( $options ); + + // We use catch because wp_send_json_success calls die() + try { + $this->ajax->save_options(); + } catch ( \Exception $e ) { + // Catching any unexpected exceptions + } + + $settings = Helpers::get_settings(); + $this->assertEquals( 'example.com', $settings['domain_name'] ); + $this->assertEquals( 'on', $settings['proxy_enabled'] ); + } + + /** + * Test save_options with escaped JSON data (simulating WordPress's $_POST behavior). + * This specifically tests the fix with stripslashes(). + */ + public function testSaveOptionsWithEscapedJson() { + $options = [ + [ 'name' => 'domain_name', 'value' => 'escaped.com' ], + ]; + + $json = wp_json_encode( $options ); + $escaped_json = addslashes( $json ); + + $_POST['_nonce'] = wp_create_nonce( 'plausible_analytics_toggle_option' ); + $_POST['options'] = $escaped_json; + + try { + $this->ajax->save_options(); + } catch ( \Exception $e ) { + } + + $settings = Helpers::get_settings(); + $this->assertEquals( 'escaped.com', $settings['domain_name'] ); + } + + /** + * Test save_options with invalid JSON. + */ + public function testSaveOptionsInvalidJson() { + $_POST['_nonce'] = wp_create_nonce( 'plausible_analytics_toggle_option' ); + $_POST['options'] = 'invalid-json'; + + // wp_send_json_error will be called, which we expect. + // In a real WP environment it would exit. + try { + $this->ajax->save_options(); + } catch ( \Exception $e ) { + } + + // Verify that settings were NOT updated to something weird. + $settings = Helpers::get_settings(); + $this->assertNotEquals( 'invalid-json', $settings['domain_name'] ); + } +} diff --git a/tests/integration/AssetsTest.php b/tests/integration/AssetsTest.php index fc4c017d..5efd16fe 100644 --- a/tests/integration/AssetsTest.php +++ b/tests/integration/AssetsTest.php @@ -15,27 +15,35 @@ class AssetsTest extends TestCase { * @see Assets::maybe_enqueue_main_script() */ public function testEnqueueMainScript() { - $class = $this->getMockBuilder( Assets::class ) - ->disableOriginalConstructor() - ->onlyMethods( [ 'get_js_url' ] ) - ->getMock(); + try { + add_filter( 'plausible_analytics_settings', [ $this, 'enableAdministratorTracking' ] ); - $this->removeAction( 'wp_enqueue_scripts', 'maybe_enqueue' ); - $this->removeAction( 'wp_enqueue_scripts', 'maybe_enqueue', 11 ); + $class = $this->getMockBuilder( Assets::class ) + ->disableOriginalConstructor() + ->onlyMethods( [ 'get_js_url' ] ) + ->getMock(); - $class->method( 'get_js_url' ) - ->willReturn( 'https://plausible.test/js/plausible.js' ); + $this->removeAction( 'wp_enqueue_scripts', 'maybe_enqueue' ); + $this->removeAction( 'wp_enqueue_scripts', 'maybe_enqueue', 11 ); - ob_start(); + $class->method( 'get_js_url' ) + ->willReturn( 'https://plausible.test/js/plausible.js' ); - $class->maybe_enqueue_main_script(); + wp_set_current_user( 1 ); + $user = wp_get_current_user(); + $user->add_role( 'administrator' ); - do_action( 'wp_head' ); + $class->maybe_enqueue_main_script(); - $output = ob_get_clean(); + global $wp_scripts; + $data = $wp_scripts->get_data( 'plausible-analytics', 'after' ); - $this->assertStringContainsString( 'window.plausible', $output ); - $this->assertStringContainsString( 'plausible.init', $output ); + $this->assertStringContainsString( 'window.plausible', implode( '', $data ) ); + $this->assertStringContainsString( 'plausible.init', implode( '', $data ) ); + } finally { + remove_filter( 'plausible_analytics_settings', [ $this, 'enableAdministratorTracking' ] ); + wp_set_current_user( null ); + } } /** diff --git a/tests/integration/HelpersTest.php b/tests/integration/HelpersTest.php index 37e1eb02..96acdf25 100644 --- a/tests/integration/HelpersTest.php +++ b/tests/integration/HelpersTest.php @@ -103,7 +103,7 @@ public function testGetPostSettings() { $settings = Helpers::get_settings(); - $this->assertArrayHasKey( 'post_test', $settings ); + $this->assertArrayNotHasKey( 'post_test', $settings ); } /** @@ -164,6 +164,7 @@ public function testGetJsPath() { */ public function testGetDomain() { try { + delete_option( 'plausible_analytics_settings' ); $domain = Helpers::get_domain(); $this->assertEquals( 'example.org', $domain ); @@ -183,6 +184,7 @@ public function testGetDomain() { * @see Helpers::get_endpoint_url() */ public function testGetDataApiUrl() { + delete_option( 'plausible_analytics_settings' ); $url = Helpers::get_endpoint_url(); $this->assertEquals( 'https://plausible.io/api/event', $url );