diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index a267e9c2..d195658d 100755 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -13,6 +13,9 @@ jobs: strategy: fail-fast: false runs-on: ubuntu-22.04 + env: + # PHP 7.4 images depend on the expired Debian Bullseye package index. + WP_ENV_PHP_VERSION: "8.2" steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/.wp-env.json b/.wp-env.json index dc0e3fc8..e4717e87 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -1,6 +1,6 @@ { "core": null, - "phpVersion": "7.4", + "phpVersion": "8.1", "plugins": [ "." ], diff --git a/classes/Visualizer/Gutenberg/Block.php b/classes/Visualizer/Gutenberg/Block.php index be55c623..5a152351 100644 --- a/classes/Visualizer/Gutenberg/Block.php +++ b/classes/Visualizer/Gutenberg/Block.php @@ -202,8 +202,25 @@ public function enqueue_gutenberg_scripts() { * Hook server side rendering into render callback */ public function register_block_type() { + $asset_path = VISUALIZER_ABSPATH . '/classes/Visualizer/Gutenberg/build/index.asset.php'; + $version = $this->version; + if ( file_exists( $asset_path ) ) { + // @phpstan-ignore-next-line + $asset = require $asset_path; + $version = isset( $asset['version'] ) ? $asset['version'] : $version; + } + if ( ! wp_style_is( 'visualizer-datatables', 'registered' ) ) { + wp_register_style( 'visualizer-datatables', VISUALIZER_ABSURL . 'css/lib/datatables.min.css', array(), Visualizer_Plugin::VERSION ); + } + if ( ! wp_style_is( 'visualizer-gutenberg-block', 'registered' ) ) { + wp_register_style( 'visualizer-gutenberg-block', VISUALIZER_ABSURL . 'classes/Visualizer/Gutenberg/build/style-index.css', array( 'visualizer-datatables' ), $version ); + } register_block_type( 'visualizer/chart', array( + // The editor_style registration is what gets the stylesheet into the + // iframed editor canvas; styles enqueued via enqueue_block_editor_assets + // only reach the parent document. + 'editor_style' => 'visualizer-gutenberg-block', 'render_callback' => array( $this, 'gutenberg_block_callback' ), 'attributes' => array( 'id' => array( @@ -297,6 +314,9 @@ public function get_visualizer_data( $post ) { // faetch and update settings $data['visualizer-settings'] = get_post_meta( $post_id, Visualizer_Plugin::CF_SETTINGS, true ); + if ( ! is_array( $data['visualizer-settings'] ) ) { + $data['visualizer-settings'] = array(); + } if ( empty( $data['visualizer-settings']['pagination'] ) ) { $data['visualizer-settings']['pageSize'] = ''; } diff --git a/classes/Visualizer/Module/Admin.php b/classes/Visualizer/Module/Admin.php index e003f2e6..e7ef36e5 100644 --- a/classes/Visualizer/Module/Admin.php +++ b/classes/Visualizer/Module/Admin.php @@ -81,7 +81,6 @@ public function __construct( Visualizer_Plugin $plugin ) { $this->_addFilter( 'media_view_strings', 'setupMediaViewStrings' ); $this->_addFilter( 'plugin_action_links', 'getPluginActionLinks', 10, 2 ); $this->_addFilter( 'plugin_row_meta', 'getPluginMetaLinks', 10, 2 ); - $this->_addFilter( 'visualizer_logger_data', 'getLoggerData' ); $this->_addFilter( 'visualizer_feedback_review_trigger', 'feedbackReviewTrigger' ); $this->_addFilter( 'themeisle_sdk_blackfriday_data', 'add_black_friday_data' ); diff --git a/classes/Visualizer/Module/Frontend.php b/classes/Visualizer/Module/Frontend.php index eca006a9..9d589da5 100644 --- a/classes/Visualizer/Module/Frontend.php +++ b/classes/Visualizer/Module/Frontend.php @@ -750,7 +750,7 @@ private function getChartData( $cache_key = '', $chart_id = 0 ) { $series = get_post_meta( $chart->ID, Visualizer_Plugin::CF_SERIES, true ); $is_woocommerce_report = get_post_meta( $chart->ID, Visualizer_Plugin::CF_IS_WOOCOMMERCE_SOURCE, true ); - if ( isset( $settings['series'] ) && ! ( count( $settings['series'] ) - count( $series ) > 1 ) ) { + if ( isset( $settings['series'] ) && is_array( $settings['series'] ) && is_array( $series ) && ! ( count( $settings['series'] ) - count( $series ) > 1 ) ) { $diff_total_series = abs( count( $settings['series'] ) - count( $series ) ); if ( $diff_total_series ) { foreach ( range( 1, $diff_total_series ) as $k => $diff_series ) { diff --git a/classes/Visualizer/Module/Setup.php b/classes/Visualizer/Module/Setup.php index f0cc81f4..2ef61c60 100644 --- a/classes/Visualizer/Module/Setup.php +++ b/classes/Visualizer/Module/Setup.php @@ -113,7 +113,7 @@ public function getUsage( $data, $meta_keys = array() ) { $lib = get_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, true ); $charts['library'][ $lib ] = isset( $charts['library'][ $lib ] ) ? $charts['library'][ $lib ] + 1 : 1; $settings = get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, true ); - if ( array_key_exists( 'manual', $settings ) && ! empty( $settings['manual'] ) ) { + if ( is_array( $settings ) && ! empty( $settings['manual'] ) ) { $charts['manual_config'] = $charts['manual_config'] + 1; } @@ -124,7 +124,7 @@ public function getUsage( $data, $meta_keys = array() ) { if ( Visualizer_Module::is_pro() ) { $permissions = get_post_meta( $chart_id, Visualizer_Pro::CF_PERMISSIONS, true ); - if ( empty( $permissions ) ) { + if ( ! is_array( $permissions ) || empty( $permissions['permissions'] ) || ! is_array( $permissions['permissions'] ) ) { continue; } $permissions = $permissions['permissions']; @@ -132,7 +132,7 @@ public function getUsage( $data, $meta_keys = array() ) { foreach ( $default_perms as $key => $val ) { if ( ! is_array( $val ) && ! is_null( $val ) && isset( $permissions[ $key ] ) && $permissions[ $key ] !== $val ) { $customized = true; - } elseif ( is_array( $val ) && ! is_null( $val ) && isset( $permissions[ $key ] ) && count( $permissions[ $key ] ) !== count( $val ) ) { + } elseif ( is_array( $val ) && ! is_null( $val ) && isset( $permissions[ $key ] ) && is_array( $permissions[ $key ] ) && count( $permissions[ $key ] ) !== count( $val ) ) { $customized = true; } } diff --git a/classes/Visualizer/Remote/Fetch.php b/classes/Visualizer/Remote/Fetch.php index 03040c4c..b1923cae 100644 --- a/classes/Visualizer/Remote/Fetch.php +++ b/classes/Visualizer/Remote/Fetch.php @@ -257,6 +257,15 @@ private static function validate_url( $url, &$ips = array() ) { $ips = array(); $validated_url = wp_http_validate_url( $url ); if ( false === $validated_url ) { + // WordPress 7.1+ rejects non-public IP literals inside wp_http_validate_url() + // itself; older cores let them through to our is_global_ip() check below. Keep + // the distinct "unsafe destination" error on every core version so callers can + // tell a policy block from a malformed URL. + $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) ); + $host = (string) wp_parse_url( $url, PHP_URL_HOST ); + if ( in_array( $scheme, array( 'http', 'https' ), true ) && filter_var( $host, FILTER_VALIDATE_IP ) && ! self::is_global_ip( $host ) ) { + return new WP_Error( 'visualizer_unsafe_remote_url', 'The remote URL resolves to a non-public address.' ); + } return new WP_Error( 'visualizer_invalid_remote_url', 'The remote URL is not allowed.' ); } diff --git a/classes/Visualizer/Render/Layout.php b/classes/Visualizer/Render/Layout.php index 0f81035d..dedce503 100644 --- a/classes/Visualizer/Render/Layout.php +++ b/classes/Visualizer/Render/Layout.php @@ -188,8 +188,13 @@ public static function _renderJsonScreen( $args ) { } $methods = apply_filters( 'visualizer_json_request_methods', array( 'GET', 'POST' ) ); + $auth = isset( $headers['auth'] ) ? $headers['auth'] : ''; + $auth_username = is_array( $auth ) && isset( $auth['username'] ) ? $auth['username'] : ''; + $auth_password = is_array( $auth ) && isset( $auth['password'] ) ? $auth['password'] : ''; + $auth_string = is_string( $auth ) ? $auth : ''; + // open the headers by default? - $headers_open = $headers && array_key_exists( 'auth', $headers ) && ( array_key_exists( 'username', $headers['auth'] ) && ! empty( $headers['auth']['username'] ) ) || ( ! empty( $headers['auth'] ) && is_string( $headers['auth'] ) ); + $headers_open = ! empty( $auth_username ) || ! empty( $auth_string ); ?> @@ -274,7 +279,7 @@ class="visualizer-input json-form-element">
- +
@@ -289,7 +294,7 @@ class="visualizer-input json-form-element"> - + @@ -307,7 +312,7 @@ class="visualizer-input json-form-element"> - + diff --git a/composer.lock b/composer.lock index df2b7b77..79c20cde 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.57", + "version": "3.3.61", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "3c761b0bddda8d5963a47d14a40811869131030b" + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/3c761b0bddda8d5963a47d14a40811869131030b", - "reference": "3c761b0bddda8d5963a47d14a40811869131030b", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/9fe698b52dec768a0dd8b500fb51efe40962ee99", + "reference": "9fe698b52dec768a0dd8b500fb51efe40962ee99", "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.57" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.61" }, - "time": "2026-07-23T13:31:25+00:00" + "time": "2026-08-24T15:59:27+00:00" }, { "name": "neitanod/forceutf8", @@ -1066,11 +1066,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.2.6", + "version": "2.2.13", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a6e9b5a9420f6109c091e87d82683bd1a80b87ed", - "reference": "a6e9b5a9420f6109c091e87d82683bd1a80b87ed", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", + "reference": "9ba9ac76ee9c5cf5b56d58eb5deec6315b7a0260", "shasum": "" }, "require": { @@ -1126,7 +1126,7 @@ "type": "github" } ], - "time": "2026-07-26T21:22:49+00:00" + "time": "2026-09-03T20:38:19+00:00" }, { "name": "phpunit/php-code-coverage", diff --git a/tests/e2e/config/mu-plugins/plant-chart-settings.php b/tests/e2e/config/mu-plugins/plant-chart-settings.php index 5222e9de..5a07d4a0 100644 --- a/tests/e2e/config/mu-plugins/plant-chart-settings.php +++ b/tests/e2e/config/mu-plugins/plant-chart-settings.php @@ -38,5 +38,21 @@ function () { }, ) ); + + // Runs the SDK usage logger on demand, so specs can verify it + // tolerates whatever chart meta they planted (issue #1359). + register_rest_route( + 'visualizer-e2e/v1', + '/usage', + array( + 'methods' => 'GET', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'callback' => function () { + return apply_filters( 'visualizer_logger_data', array() ); + }, + ) + ); } ); diff --git a/tests/e2e/specs/gutenberg-editor.spec.js b/tests/e2e/specs/gutenberg-editor.spec.js index c5ad0157..efb91693 100644 --- a/tests/e2e/specs/gutenberg-editor.spec.js +++ b/tests/e2e/specs/gutenberg-editor.spec.js @@ -18,34 +18,37 @@ test.describe( 'Charts with Gutenberg Editor', () => { page.setDefaultTimeout( 5000 ); } ); - test('check available action on block creation', async ( { admin, editor, page } ) => { + test('check available action on block creation', async ( { admin, editor } ) => { await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - // Check chart selection options are available. - await expect( page.getByText('Make a new chart or display') ).toBeVisible(); - await expect( page.getByLabel('Editor content').locator('a') ).toBeVisible(); - await expect( page.locator('div').filter({ hasText: /^Display an existing chart$/ }) ).toBeVisible(); + // The block renders inside the editor canvas, which WordPress serves in + // an iframe, so every locator must go through editor.canvas. + await expect( editor.canvas.getByText('Make a new chart or display') ).toBeVisible(); + await expect( editor.canvas.locator('a.visualizer-settings__content-option').filter({ hasText: 'Create a new chart' }) ).toBeVisible(); + await expect( editor.canvas.locator('div').filter({ hasText: /^Display an existing chart$/ }) ).toBeVisible(); } ); test('new chart creation', async ( { admin, editor, page } ) => { await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - await expect( page.getByText('Make a new chart or display') ).toBeVisible(); - await expect( page.getByLabel('Editor content').locator('a') ).toBeVisible(); + await expect( editor.canvas.getByText('Make a new chart or display') ).toBeVisible(); + const createOption = editor.canvas.locator('a.visualizer-settings__content-option').filter({ hasText: 'Create a new chart' }); + await expect( createOption ).toBeVisible(); - await page.getByLabel('Editor content').locator('a').click({ force: true}); + await createOption.click({ force: true }); - // Create chart via popup. - await page.frameLocator('iframe').getByRole('button', { name: 'Next' }).click(); - await page.frameLocator('iframe').getByRole('button', { name: 'Create Chart' }).click(); + // Create chart via popup; target the wizard frame, not the canvas iframe. + const wizard = page.frameLocator('iframe[src*="visualizer-create-chart"]'); + await wizard.getByRole('button', { name: 'Next' }).click(); + await wizard.getByRole('button', { name: 'Create Chart' }).click(); - await expect( page.getByRole('button', { name: 'Done' }) ).toBeVisible(); - await page.getByRole('button', { name: 'Done' }).click(); + await expect( editor.canvas.getByRole('button', { name: 'Done' }) ).toBeVisible(); + await editor.canvas.getByRole('button', { name: 'Done' }).click(); - await expect( page.locator('.wp-block-visualizer-chart').count() ).resolves.toBe( 1 ); - await expect( page.getByRole('button', { name: 'Done' }) ).toBeHidden(); + await expect( editor.canvas.locator('.wp-block-visualizer-chart') ).toHaveCount( 1 ); + await expect( editor.canvas.getByRole('button', { name: 'Done' }) ).toBeHidden(); } ); @@ -55,15 +58,15 @@ test.describe( 'Charts with Gutenberg Editor', () => { // Create a new post and insert the first available chart. await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - await page.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); - await page.locator('.visualizer-settings__charts-controls').first().click(); + await editor.canvas.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); + await editor.canvas.locator('.visualizer-settings__charts-controls').first().click(); // Check if it was inserted correctly then enter view mode for the block. - expect( page.getByLabel('Block: Visualizer Chart').getByText('Visualizer') ).not.toBeNull(); - await page.getByRole('button', { name: 'Done' }).click(); + await expect( editor.canvas.getByLabel('Block: Visualizer Chart') ).toBeVisible(); + await editor.canvas.getByRole('button', { name: 'Done' }).click(); // Check if the Chart did not crash the editor. - expect( page.locator('.wp-block-visualizer-chart').count() ).resolves.toBe( 1 ); + await expect( editor.canvas.locator('.wp-block-visualizer-chart') ).toHaveCount( 1 ); } ); test( 'check block Edit new button', async ( { admin, editor, page } ) => { @@ -73,13 +76,15 @@ test.describe( 'Charts with Gutenberg Editor', () => { await admin.createNewPost(); await editor.insertBlock( { name: 'visualizer/chart'} ); - await page.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); - await page.locator('.visualizer-settings__charts-controls').first().click(); + await editor.canvas.locator('div').filter({ hasText: /^Display an existing chart$/ }).click(); + await editor.canvas.locator('.visualizer-settings__charts-controls').first().click(); - expect( page.getByLabel('Block: Visualizer Chart').getByText('Visualizer') ).not.toBeNull(); + await expect( editor.canvas.getByLabel('Block: Visualizer Chart') ).toBeVisible(); - await expect(page.getByRole('button', { name: 'Edit Chart' })).toBeVisible(); - await page.getByRole('button', { name: 'Edit Chart' }).click(); + // The Edit Chart button lives in the block toolbar / inspector, which + // render in the parent document, not the canvas iframe. + await expect( page.getByRole('button', { name: 'Edit Chart' }).first() ).toBeVisible(); + await page.getByRole('button', { name: 'Edit Chart' }).first().click(); //await page.goto('http://localhost:8889/wp-admin/post.php?post=29&action=edit'); await expect(page.getByLabel('Visualizer', { exact: true }).locator('h1')).toContainText('Visualizer'); await page.getByRole('button', { name: 'Close dialog' }).click(); diff --git a/tests/e2e/specs/usage-logger.spec.js b/tests/e2e/specs/usage-logger.spec.js new file mode 100644 index 00000000..d502e15b --- /dev/null +++ b/tests/e2e/specs/usage-logger.spec.js @@ -0,0 +1,69 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { deleteAllCharts } = require( '../utils/common' ); + +/** + * Regression tests for https://github.com/Codeinwp/visualizer/issues/1359 + * + * A published chart whose `visualizer-settings` meta is a string (instead of + * the sanitized settings array) crashed `Visualizer_Module_Setup::getUsage()` + * with a PHP 8 TypeError, aborting the whole SDK usage collection request. + * The logger must tolerate such charts and still report the others. + */ +test.describe( 'Usage logger', () => { + let corruptedId; + let manualId; + + test.beforeAll( async ( { requestUtils } ) => { + // The assertions below count charts, so start from a clean library. + await deleteAllCharts( requestUtils ); + + // A chart whose settings meta is a corrupted string value. + const corrupted = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/visualizer', + data: { title: 'Corrupted settings chart', status: 'publish' }, + } ); + corruptedId = corrupted.id; + await requestUtils.rest( { + method: 'POST', + path: `/visualizer-e2e/v1/chart-settings/${ corruptedId }`, + data: { settings: 'corrupted string settings' }, + } ); + + // A healthy chart with a manual configuration, which must still be counted. + const manual = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/visualizer', + data: { title: 'Manual config chart', status: 'publish' }, + } ); + manualId = manual.id; + await requestUtils.rest( { + method: 'POST', + path: `/visualizer-e2e/v1/chart-settings/${ manualId }`, + data: { settings: { manual: '{"colors": ["#000"]}' } }, + } ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + for ( const id of [ corruptedId, manualId ] ) { + if ( id ) { + await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/visualizer/${ id }`, params: { force: true } } ); + } + } + } ); + + test( 'survives a chart whose settings meta is a string', async ( { requestUtils } ) => { + // Before the fix this request died with a TypeError (HTTP 500). + const usage = await requestUtils.rest( { method: 'GET', path: '/visualizer-e2e/v1/usage' } ); + + expect( usage.manual_config ).toBe( 1 ); + expect( Object.values( usage.types ).reduce( ( a, b ) => a + b, 0 ) ).toBe( 2 ); + } ); +} ); diff --git a/tests/test-ajax.php b/tests/test-ajax.php index a2bb2fdf..38673400 100644 --- a/tests/test-ajax.php +++ b/tests/test-ajax.php @@ -1109,6 +1109,254 @@ public function test_json_set_data_denied_for_chart_user_cannot_edit() { $this->assertSame( '', get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_URL, true ) ); } + /** + * Mock the JSON endpoint so the set-data handler does not hit the network. + * + * @return callable The filter callback, for removal. + */ + private function mock_json_endpoint() { + $filter = function () { + return array( + 'headers' => array(), + 'body' => wp_json_encode( array( array( 'name' => 'a', 'value' => 1 ) ) ), + 'response' => array( 'code' => 200, 'message' => '' ), + 'cookies' => array(), + 'filename' => null, + ); + }; + add_filter( 'pre_http_request', $filter ); + return $filter; + } + + /** + * Run the JSON set-data handler as the current user. + * + * @param int $chart_id The chart being saved. + * @param array $post The POST fields to send on top of the defaults. + */ + private function handle_json_set_data( $chart_id, array $post ) { + $_GET = array( + 'chart' => $chart_id, + 'security' => wp_create_nonce( Visualizer_Plugin::ACTION_JSON_SET_DATA . Visualizer_Plugin::VERSION ), + ); + // empty header/type keeps the editable-table parsing out of the assertions. + $_POST = array_merge( + array( + 'url' => 'https://example.com/data.json', + 'method' => 'get', + 'root' => 'items', + 'header' => array(), + 'type' => array(), + ), + $post + ); + + $filter = $this->mock_json_endpoint(); + try { + $this->_handleAjax( Visualizer_Plugin::ACTION_JSON_SET_DATA ); + } catch ( WPAjaxDieContinueException $e ) { + // Expected once the update page has rendered. + } catch ( WPAjaxDieStopException $e ) { + // Expected when the handler produced no output. + } finally { + remove_filter( 'pre_http_request', $filter ); + } + } + + /** + * Saving a JSON data source must store the credential bytes exactly as sent. + * + * The credentials are base64-encoded into the Authorization header, so any + * transform on the way into the meta breaks authentication. sanitize_text_field() + * strips %XX octets, which silently turns abc%2Fdef into abcdef. + */ + public function test_json_set_data_preserves_credential_bytes_in_meta() { + wp_set_current_user( $this->admin_user_id ); + $chart_id = $this->create_chart_for_user( $this->admin_user_id ); + + $this->handle_json_set_data( + $chart_id, + array( + 'root' => 'data%2Fresults', + 'username' => 'AKIA%2FEXAMPLE%2BKEY', + 'password' => 'p%40ssw0rd!#$^&*()_+=[]{};:,.?/|~', + ) + ); + + $headers = get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS, true ); + $this->assertIsArray( $headers ); + $this->assertSame( 'AKIA%2FEXAMPLE%2BKEY', $headers['auth']['username'] ); + $this->assertSame( 'p%40ssw0rd!#$^&*()_+=[]{};:,.?/|~', $headers['auth']['password'] ); + $this->assertSame( 'data%2Fresults', get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_ROOT, true ) ); + } + + /** + * A percent-encoded authorization string is stored byte for byte. + */ + public function test_json_set_data_preserves_authorization_string_bytes() { + wp_set_current_user( $this->admin_user_id ); + $chart_id = $this->create_chart_for_user( $this->admin_user_id ); + + $this->handle_json_set_data( $chart_id, array( 'auth' => 'SharedKey acct:aGVsbG8%3D' ) ); + + $headers = get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS, true ); + $this->assertSame( 'SharedKey acct:aGVsbG8%3D', $headers['auth'] ); + } + + /** + * A markup payload is stored unmodified; escaping belongs to each output context. + * + * Guards against re-introducing write-time sanitization: the editor escaping is + * covered by Test_Visualizer_Json_Headers_Xss. + */ + public function test_json_set_data_stores_markup_payload_unmodified() { + wp_set_current_user( $this->admin_user_id ); + $chart_id = $this->create_chart_for_user( $this->admin_user_id ); + + $payload = 'admin'; + $this->handle_json_set_data( + $chart_id, + array( + 'username' => $payload, + 'password' => $payload, + ) + ); + + $headers = get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS, true ); + $this->assertSame( $payload, $headers['auth']['username'] ); + $this->assertSame( $payload, $headers['auth']['password'] ); + } + + /** + * Render the JSON parameters screen for a chart. + * + * The upsell markup calls into the themeisle SDK, which the test bootstrap does + * not load, so it is unhooked for the render and restored afterwards. + * + * @param int $chart_id The chart to render. + * @return string The rendered markup. + */ + private function render_json_screen( $chart_id ) { + global $wp_filter; + $callbacks = isset( $wp_filter['visualizer_pro_upsell'] ) ? $wp_filter['visualizer_pro_upsell']->callbacks : array(); + remove_all_filters( 'visualizer_pro_upsell' ); + + ob_start(); + Visualizer_Render_Layout::show( 'json-screen', $chart_id ); + $markup = ob_get_clean(); + + if ( ! empty( $callbacks ) ) { + $wp_filter['visualizer_pro_upsell'] = new WP_Hook(); + $wp_filter['visualizer_pro_upsell']->callbacks = $callbacks; + } + + return $markup; + } + + /** + * Assert that an input carries the value as inert text and no injected behaviour. + * + * @param DOMXPath $xpath The parsed markup. + * @param string $input_id The input to check. + * @param string $expected The value the browser should read back. + */ + private function assertInputIsInert( DOMXPath $xpath, $input_id, $expected ) { + $input = $xpath->query( '//input[@id="' . $input_id . '"]' )->item( 0 ); + $this->assertNotNull( $input, $input_id . ' is missing from the rendered markup.' ); + + // the browser reads the payload back as one literal string, not as markup. + $this->assertSame( $expected, $input->getAttribute( 'value' ) ); + + $attributes = array(); + foreach ( $input->attributes as $attribute ) { + $attributes[] = strtolower( $attribute->nodeName ); + } + + $handlers = array_values( + array_filter( + $attributes, + function ( $name ) { + return 0 === strpos( $name, 'on' ); + } + ) + ); + + $this->assertSame( array(), $handlers, 'Event handler attributes were injected into ' . $input_id . '.' ); + $this->assertNotContains( 'autofocus', $attributes, 'An autofocus attribute was injected into ' . $input_id . '.' ); + } + + /** + * A script payload saved through the handler is inert once the editor renders it. + * + * The bytes are stored verbatim, so this is what proves the payload cannot run: + * the rendered markup carries no script element and the value stays a single + * attribute string with no event handler broken out of it. + * + * @requires extension dom + */ + public function test_json_set_data_payload_is_inert_when_rendered() { + wp_set_current_user( $this->admin_user_id ); + $chart_id = $this->create_chart_for_user( $this->admin_user_id ); + + $username_payload = 'x" autofocus onfocus="alert(document.domain)" x="'; + $password_payload = '">'; + + $this->handle_json_set_data( + $chart_id, + array( + 'username' => $username_payload, + 'password' => $password_payload, + ) + ); + + $headers = get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS, true ); + $this->assertSame( $username_payload, $headers['auth']['username'] ); + $this->assertSame( $password_payload, $headers['auth']['password'] ); + + $markup = $this->render_json_screen( $chart_id ); + + $dom = new DOMDocument(); + libxml_use_internal_errors( true ); + $dom->loadHTML( '' . $markup . '' ); + libxml_clear_errors(); + libxml_use_internal_errors( false ); + + $this->assertSame( 0, $dom->getElementsByTagName( 'script' )->length, 'The payload created a script element.' ); + $this->assertSame( 0, $dom->getElementsByTagName( 'img' )->length, 'The payload created an img element.' ); + + $xpath = new DOMXPath( $dom ); + $this->assertInputIsInert( $xpath, 'vz-import-json-username', $username_payload ); + $this->assertInputIsInert( $xpath, 'vz-import-json-password', $password_payload ); + } + + /** + * An authorization-string script payload is inert once the editor renders it. + * + * @requires extension dom + */ + public function test_json_set_data_authorization_payload_is_inert_when_rendered() { + wp_set_current_user( $this->admin_user_id ); + $chart_id = $this->create_chart_for_user( $this->admin_user_id ); + + $payload = '" onmouseover="alert(1)" data-x="'; + $this->handle_json_set_data( $chart_id, array( 'auth' => $payload ) ); + + $this->assertSame( $payload, get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS, true )['auth'] ); + + $markup = $this->render_json_screen( $chart_id ); + + $dom = new DOMDocument(); + libxml_use_internal_errors( true ); + $dom->loadHTML( '' . $markup . '' ); + libxml_clear_errors(); + libxml_use_internal_errors( false ); + + $this->assertSame( 0, $dom->getElementsByTagName( 'script' )->length, 'The payload created a script element.' ); + + $xpath = new DOMXPath( $dom ); + $this->assertInputIsInert( $xpath, 'vz-import-json-auth', $payload ); + } + /** * A user cannot save filters on another user's chart. */ diff --git a/tests/test-chart-data-permissions.php b/tests/test-chart-data-permissions.php index 7867f496..715b784f 100644 --- a/tests/test-chart-data-permissions.php +++ b/tests/test-chart-data-permissions.php @@ -12,6 +12,20 @@ */ class Test_Visualizer_Chart_Data_Permissions extends WP_UnitTestCase { + /** + * A newly created REST chart has no settings metadata yet. + */ + public function test_rest_can_create_chart_without_settings_metadata() { + wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) ); + $request = new WP_REST_Request( 'POST', '/wp/v2/visualizer' ); + $request->set_param( 'title', 'Empty chart' ); + $request->set_param( 'status', 'publish' ); + $response = rest_get_server()->dispatch( $request ); + $this->assertSame( 201, $response->get_status() ); + $data = $response->get_data(); + $this->assertIsArray( $data['chart_data']['visualizer-settings'] ); + } + /** * Create a chart owned by the given user. * diff --git a/tests/test-frontend-series.php b/tests/test-frontend-series.php new file mode 100644 index 00000000..1b176df5 --- /dev/null +++ b/tests/test-frontend-series.php @@ -0,0 +1,53 @@ +post->create( array( 'post_type' => Visualizer_Plugin::CPT_VISUALIZER ) ); + update_post_meta( $id, Visualizer_Plugin::CF_SETTINGS, array( 'series' => $settings_series ) ); + if ( is_bool( $series ) ) { + // WordPress persists scalar booleans as empty strings, so a stored boolean can only be + // observed by short-circuiting the lookup. WP_UnitTestCase removes the filter on tear down. + add_filter( + 'get_post_metadata', + function ( $value, $object_id, $meta_key ) use ( $id, $series ) { + return (int) $object_id === $id && Visualizer_Plugin::CF_SERIES === $meta_key ? $series : $value; + }, + 10, + 3 + ); + } else { + update_post_meta( $id, Visualizer_Plugin::CF_SERIES, $series ); + } + $frontend = ( new ReflectionClass( 'Visualizer_Module_Frontend' ) )->newInstanceWithoutConstructor(); + $method = new ReflectionMethod( $frontend, 'getChartData' ); + $method->setAccessible( true ); + $data = $method->invoke( $frontend, 'series-test', $id ); + $this->assertSame( $expected, $data['settings']['series'] ); + $this->assertEquals( $data, get_transient( 'series-test_' . $id ) ); + } + + /** + * Settings-side values are stored inside a serialized array, so booleans and empty strings + * survive as-is. Series-side booleans are injected through the metadata filter. + */ + public function series_values() { + return array( + 'boolean settings' => array( false, array( array() ), false ), + 'empty settings' => array( '', array( array() ), '' ), + 'boolean columns' => array( array( array( 'color' => 'red' ) ), false, array( array( 'color' => 'red' ) ) ), + 'empty columns' => array( array( array( 'color' => 'red' ) ), '', array( array( 'color' => 'red' ) ) ), + 'missing both' => array( array(), '', array() ), + 'valid padding' => array( array( array( 'color' => 'red' ) ), array( array(), array() ), array( array( 'color' => 'red' ), array( 'color' => 'red' ) ) ), + 'equal lengths' => array( array( array() ), array( array() ), array( array() ) ), + ); + } +} diff --git a/tests/test-json-headers-xss.php b/tests/test-json-headers-xss.php new file mode 100644 index 00000000..2906b004 --- /dev/null +++ b/tests/test-json-headers-xss.php @@ -0,0 +1,233 @@ +upsell_callbacks = $wp_filter['visualizer_pro_upsell']->callbacks; + remove_all_filters( 'visualizer_pro_upsell' ); + } + } + + /** + * Restore the upsell filter so no state leaks into other tests. + */ + public function tear_down() { + if ( ! empty( $this->upsell_callbacks ) ) { + global $wp_filter; + $wp_filter['visualizer_pro_upsell'] = new WP_Hook(); + $wp_filter['visualizer_pro_upsell']->callbacks = $this->upsell_callbacks; + $this->upsell_callbacks = array(); + } + parent::tear_down(); + } + + /** + * Create a chart carrying the given JSON headers meta. + * + * @param array $headers The headers to store. + * @return int The chart id. + */ + private function create_chart_with_headers( array $headers ) { + $chart_id = $this->factory->post->create( + array( + 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, + 'post_status' => 'publish', + ) + ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_URL, 'https://example.com/api' ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_HEADERS, $headers ); + return $chart_id; + } + + /** + * Render the JSON parameters screen for a chart. + * + * @param int $chart_id The chart id. + * @return string The rendered markup. + */ + private function render_json_screen( $chart_id ) { + ob_start(); + Visualizer_Render_Layout::show( 'json-screen', $chart_id ); + return ob_get_clean(); + } + + /** + * Percent-encoded octets in a stored credential survive to the rendered value. + * + * Credentials are base64-encoded into the Authorization header, so the stored + * bytes must be exact. sanitize_text_field() strips %XX octets and would turn + * abc%2Fdef into abcdef, breaking authentication. + */ + public function test_percent_encoded_credentials_render_intact() { + $chart_id = $this->create_chart_with_headers( + array( + 'method' => 'get', + 'auth' => array( + 'username' => 'AKIA%2FEXAMPLE%2BKEY', + 'password' => 'abc%2Fdef', + ), + ) + ); + + $markup = $this->render_json_screen( $chart_id ); + + $this->assertStringContainsString( 'value="AKIA%2FEXAMPLE%2BKEY"', $markup ); + $this->assertStringContainsString( 'value="abc%2Fdef"', $markup ); + } + + /** + * A percent-encoded authorization string survives to the rendered value. + */ + public function test_percent_encoded_authorization_renders_intact() { + $chart_id = $this->create_chart_with_headers( + array( + 'method' => 'get', + 'auth' => 'SharedKey acct:aGVsbG8%3D', + ) + ); + + $markup = $this->render_json_screen( $chart_id ); + + $this->assertStringContainsString( 'value="SharedKey acct:aGVsbG8%3D"', $markup ); + } + + /** + * Payloads already stored in the meta are escaped when the editor renders them. + * + * This covers the sites that stored a payload before the sanitizer existed, so it + * must keep passing independently of the write-side fix. + */ + public function test_stored_credential_payload_is_escaped_on_render() { + $chart_id = $this->create_chart_with_headers( + array( + 'method' => 'get', + 'auth' => array( + 'username' => self::PAYLOAD, + 'password' => self::PAYLOAD, + ), + ) + ); + + $markup = $this->render_json_screen( $chart_id ); + + $this->assertStringNotContainsString( self::PAYLOAD, $markup ); + $this->assertStringContainsString( 'x" autofocus onfocus=', $markup ); + } + + /** + * A stored authorization string payload is escaped when the editor renders it. + */ + public function test_stored_authorization_payload_is_escaped_on_render() { + $chart_id = $this->create_chart_with_headers( + array( + 'method' => 'get', + 'auth' => '">', + ) + ); + + $markup = $this->render_json_screen( $chart_id ); + + $this->assertStringNotContainsString( 'assertStringContainsString( '<img src=x', $markup ); + } + + /** + * The editor renders without fatalling when auth is stored as a plain string. + * + * The string form used to reach array_key_exists(), which is a TypeError on + * PHP 8 and took the whole chart editor page down. + */ + public function test_authorization_string_renders_without_fatal() { + $chart_id = $this->create_chart_with_headers( + array( + 'method' => 'get', + 'auth' => 'SharedKey myaccount:aGVsbG8=', + ) + ); + + $markup = $this->render_json_screen( $chart_id ); + + $this->assertStringContainsString( 'value="SharedKey myaccount:aGVsbG8="', $markup ); + $this->assertStringContainsString( 'id="vz-import-json-username"', $markup ); + } + + /** + * A stored additional_headers payload is escaped in the textarea. + */ + public function test_stored_additional_headers_payload_is_escaped_on_render() { + $chart_id = $this->create_chart_with_headers( + array( + 'method' => 'get', + 'additional_headers' => '', + ) + ); + + $markup = $this->render_json_screen( $chart_id ); + + $this->assertStringNotContainsString( 'assertStringContainsString( '</textarea>', $markup ); + } + + /** + * A stored JSON root payload is escaped in the root dropdown label. + */ + public function test_stored_json_root_payload_is_escaped_on_render() { + $chart_id = $this->create_chart_with_headers( array( 'method' => 'get' ) ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_ROOT, '' ); + + $markup = $this->render_json_screen( $chart_id ); + + $this->assertStringNotContainsString( 'assertStringContainsString( '<img src=x', $markup ); + } + + /** + * Legitimate credentials still render as usable values in the editor. + */ + public function test_legitimate_credentials_render_intact() { + $chart_id = $this->create_chart_with_headers( + array( + 'method' => 'get', + 'auth' => array( + 'username' => 'api_user', + 'password' => 'secret123', + ), + ) + ); + + $markup = $this->render_json_screen( $chart_id ); + + $this->assertStringContainsString( 'value="api_user"', $markup ); + $this->assertStringContainsString( 'value="secret123"', $markup ); + } +} diff --git a/tests/test-usage-logger.php b/tests/test-usage-logger.php new file mode 100644 index 00000000..2abf25a9 --- /dev/null +++ b/tests/test-usage-logger.php @@ -0,0 +1,107 @@ +post->create( + array( + 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, + 'post_status' => 'publish', + 'post_content' => wp_slash( serialize( array( array( 'Label' ), array( 'Value' ) ) ) ), + ) + ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, 'line' ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, $settings ); + return $chart_id; + } + + /** + * A chart whose settings meta is a string must not abort usage collection. + */ + public function test_string_settings_meta_does_not_crash_logger() { + $this->create_chart( 'corrupted string settings' ); + + $usage = apply_filters( 'visualizer_logger_data', array() ); + + $this->assertIsArray( $usage ); + $this->assertSame( 0, $usage['manual_config'] ); + } + + /** + * A chart with no settings meta at all must not abort usage collection. + */ + public function test_missing_settings_meta_does_not_crash_logger() { + $chart_id = $this->create_chart( array() ); + delete_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS ); + + $usage = apply_filters( 'visualizer_logger_data', array() ); + + $this->assertIsArray( $usage ); + $this->assertSame( 0, $usage['manual_config'] ); + } + + /** + * On pro, permission meta that is not shaped like a map of arrays must not + * abort usage collection — a string entry fatals on count(), and a nested + * object fatals on the array offset read. + */ + public function test_malformed_permissions_meta_does_not_crash_logger() { + // The stub stays defined for the rest of the PHPUnit process. That only + // affects code gating on class_exists( 'Visualizer_Pro' ) — the legacy + // license fallback in proFeaturesEnabled() — which no test exercises. + if ( ! class_exists( 'Visualizer_Pro' ) ) { + eval( 'class Visualizer_Pro { const CF_PERMISSIONS = "visualizer-permissions"; }' ); + } + + $chart_id = $this->create_chart( array() ); + update_post_meta( + $chart_id, + Visualizer_Pro::CF_PERMISSIONS, + array( 'permissions' => array( 'edit-specific' => 'administrator' ) ) + ); + + $object_chart_id = $this->create_chart( array() ); + update_post_meta( $object_chart_id, Visualizer_Pro::CF_PERMISSIONS, array( 'permissions' => new stdClass() ) ); + + add_filter( 'visualizer_is_pro', '__return_true' ); + $usage = apply_filters( 'visualizer_logger_data', array() ); + remove_filter( 'visualizer_is_pro', '__return_true' ); + + $this->assertIsArray( $usage ); + $this->assertSame( 0, $usage['permissions'] ); + } + + /** + * Valid array settings still count manual configurations. + */ + public function test_manual_config_still_counted_for_array_settings() { + $this->create_chart( array( 'manual' => '{"colors": ["#000"]}' ) ); + $this->create_chart( 'corrupted string settings' ); + + $usage = apply_filters( 'visualizer_logger_data', array() ); + + $this->assertSame( 1, $usage['manual_config'] ); + $this->assertSame( 2, $usage['types']['line'] ); + } +}