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 );
?>
@@ -242,7 +247,7 @@ class="visualizer-input json-form-element"
type="text"
id="vz-import-json-username"
name="username"
- value=""
+ value=""
placeholder=""
class="json-form-element">
&
@@ -250,7 +255,7 @@ class="json-form-element">
type="password"
id="vz-import-json-password"
name="password"
- value=""
+ value=""
placeholder=""
class="json-form-element">
@@ -266,7 +271,7 @@ class="json-form-element">
type="text"
id="vz-import-json-auth"
name="auth"
- value=""
+ value=""
placeholder=":
', 'visualizer' ); ?>"
class="visualizer-input json-form-element">
@@ -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'] );
+ }
+}