From de3247711c55311e85cb4bf784c1331fd515f1a1 Mon Sep 17 00:00:00 2001 From: lucadobrescu Date: Fri, 10 Jul 2026 13:39:30 +0300 Subject: [PATCH 01/17] fix: persist database results before saving charts Save the active database query before the chart form closes so one-time imports cannot retain stale preview data. Co-authored-by: Cursor --- .wp-env.json | 3 + js/frame.js | 14 ++ tests/e2e/config/enable-database-source.php | 34 ++++ tests/e2e/specs/database-source.spec.js | 182 ++++++++++++++++++++ 4 files changed, 233 insertions(+) create mode 100644 tests/e2e/config/enable-database-source.php create mode 100644 tests/e2e/specs/database-source.spec.js diff --git a/.wp-env.json b/.wp-env.json index 9d19ec37e..6dae32805 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -5,6 +5,9 @@ "." ], "themes": [], + "mappings": { + "wp-content/mu-plugins/visualizer-e2e-enable-database-source.php": "./tests/e2e/config/enable-database-source.php" + }, "config": { "WP_DEBUG": true, "WP_DEBUG_LOG": true, diff --git a/js/frame.js b/js/frame.js index e8596dca9..4d2d368f7 100644 --- a/js/frame.js +++ b/js/frame.js @@ -479,6 +479,20 @@ init_db_import_component(); + $( '#settings-button' ).on( 'click', function( event ){ + $('body').trigger('visualizer:db:query:update', {}); + if( $( '#db-chart-button' ).attr( 'data-current' ) !== 'filter' || $( '.visualizer-db-query' ).val().length === 0 ){ + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + $( '#thehole' ).one( 'load', function(){ + $( '#settings-button' ).trigger( 'click' ); + } ); + $( '#db-chart-button' ).trigger( 'click' ); + } ); + $('#visualizer-query-fetch').on('click', function(e){ $('body').trigger('visualizer:db:query:update', {}); diff --git a/tests/e2e/config/enable-database-source.php b/tests/e2e/config/enable-database-source.php new file mode 100644 index 000000000..6e8aed261 --- /dev/null +++ b/tests/e2e/config/enable-database-source.php @@ -0,0 +1,34 @@ + { + const request = candidate.request(); + + return ( + candidate.url().includes( '/wp-admin/admin-ajax.php' ) && + request.method() === 'POST' && + request + .postData() + ?.includes( 'action=visualizer-fetch-db-data' ) + ); + } ), + chartEditor.getByRole( 'button', { name: 'Show Results' } ).click(), + ] ); + + expect( response.ok() ).toBe( true ); + await expect( chartEditor.locator( '.db-wizard-results' ) ).toContainText( + expectedValue + ); +} + +test.describe( 'Database source', () => { + const postIds = []; + + test.beforeEach( async ( { page, requestUtils } ) => { + await deleteAllCharts( requestUtils ); + page.setDefaultTimeout( 20000 ); + } ); + + test.afterEach( async ( { requestUtils } ) => { + await Promise.all( + postIds.splice( 0 ).map( ( postId ) => + requestUtils + .rest( { + method: 'DELETE', + path: `/wp/v2/posts/${ postId }`, + params: { force: true }, + } ) + .catch( () => {} ) + ) + ); + } ); + + test( 'saves previewed one-time data when saving the chart', async ( { + admin, + context, + page, + requestUtils, + }, testInfo ) => { + test.setTimeout( 180000 ); + + await context.addCookies( [ + { + name: 'visualizer_e2e_database_source', + value: '1', + url: testInfo.project.use.baseURL || 'http://localhost:8889', + }, + ] ); + + const sourcePost = await requestUtils.createPost( { + title: 'ISSUE_1329_OLD', + status: 'publish', + } ); + postIds.push( sourcePost.id ); + + await admin.visitAdminPage( + 'admin.php?page=visualizer&vaction=addnew' + ); + await page + .getByRole( 'button', { name: 'Classic Builder Step-by-step' } ) + .click(); + + const chartEditor = page.frameLocator( 'iframe' ); + await chartEditor + .locator( 'div#type-picker' ) + .waitFor( { state: 'visible', timeout: 20000 } ); + await selectChartAdmin( chartEditor, CHART_JS_LABELS.table ); + + await chartEditor + .locator( '#visualizer-chart-id' ) + .waitFor( { state: 'attached', timeout: 20000 } ); + const chartId = await chartEditor + .locator( '#visualizer-chart-id' ) + .getAttribute( 'data-id' ); + expect( chartId ).toMatch( /^\d+$/ ); + + await chartEditor + .getByRole( 'button', { name: /Import from database/ } ) + .click(); + await chartEditor + .locator( '#db-chart-button' ) + .evaluate( ( button ) => button.click() ); + + const query = `SELECT post_title AS value FROM wp_posts WHERE ID = ${ sourcePost.id }`; + await chartEditor + .locator( '.CodeMirror' ) + .evaluate( + ( editor, sql ) => editor.CodeMirror.setValue( sql ), + query + ); + + await showQueryResults( page, chartEditor, 'ISSUE_1329_OLD' ); + + const [ saveResponse ] = await Promise.all( [ + page.waitForResponse( + ( candidate ) => + candidate + .url() + .includes( 'action=visualizer-save-db-query' ) && + candidate.request().method() === 'POST' + ), + chartEditor + .locator( '#db-chart-button' ) + .evaluate( ( button ) => button.click() ), + ] ); + expect( saveResponse.ok() ).toBe( true ); + await expect( chartEditor.locator( '#canvas' ) ).toContainText( + 'ISSUE_1329_OLD', + { timeout: 20000 } + ); + await chartEditor + .getByRole( 'button', { name: 'Create Chart' } ) + .click(); + await expect( + page.getByRole( 'heading', { name: 'Visualizer Library' } ) + ).toBeVisible( { timeout: 20000 } ); + + const chartPost = await requestUtils.createPost( { + title: 'Issue 1329 chart', + content: `[visualizer id="${ chartId }"]`, + status: 'publish', + } ); + postIds.push( chartPost.id ); + + await page.goto( chartPost.link ); + await page.mouse.wheel( 0, 500 ); + await expect( + page.getByRole( 'cell', { name: 'ISSUE_1329_OLD' } ) + ).toBeVisible(); + + await requestUtils.rest( { + method: 'POST', + path: `/wp/v2/posts/${ sourcePost.id }`, + data: { title: 'ISSUE_1329_NEW' }, + } ); + + await admin.visitAdminPage( 'admin.php?page=visualizer' ); + await page + .locator( `.visualizer-chart-edit[data-chart="${ chartId }"]` ) + .click(); + await chartEditor + .locator( '#db-chart-button' ) + .evaluate( ( button ) => button.click() ); + await showQueryResults( page, chartEditor, 'ISSUE_1329_NEW' ); + + await chartEditor.getByRole( 'button', { name: 'Save Chart' } ).click(); + await expect( + page.getByRole( 'dialog', { name: 'Visualizer' } ) + ).toHaveCount( 0, { timeout: 30000 } ); + + await page.goto( chartPost.link ); + await page.mouse.wheel( 0, 500 ); + await expect( + page.getByRole( 'cell', { name: 'ISSUE_1329_NEW' } ) + ).toBeVisible(); + } ); +} ); From 58130b51cf0f49e52ce2a9b578e1c2a17ff05bf6 Mon Sep 17 00:00:00 2001 From: lucadobrescu Date: Fri, 10 Jul 2026 14:41:04 +0300 Subject: [PATCH 02/17] fix: wait for database save before chart save Co-authored-by: Cursor --- js/frame.js | 38 +++++++++++------ tests/e2e/config/enable-database-source.php | 42 ++++++++----------- tests/e2e/specs/database-source.spec.js | 45 +++++++++++++++++---- 3 files changed, 80 insertions(+), 45 deletions(-) diff --git a/js/frame.js b/js/frame.js index 4d2d368f7..6fd62e269 100644 --- a/js/frame.js +++ b/js/frame.js @@ -479,19 +479,33 @@ init_db_import_component(); - $( '#settings-button' ).on( 'click', function( event ){ - $('body').trigger('visualizer:db:query:update', {}); - if( $( '#db-chart-button' ).attr( 'data-current' ) !== 'filter' || $( '.visualizer-db-query' ).val().length === 0 ){ - return; - } + var settings_button = document.querySelector( '#settings-button' ); + if ( settings_button ) { + settings_button.addEventListener( 'click', function( event ){ + $('body').trigger('visualizer:db:query:update', {}); + if( $( '#db-chart-button' ).attr( 'data-current' ) !== 'filter' || $( '.visualizer-db-query' ).val().length === 0 ){ + return; + } - event.preventDefault(); - event.stopImmediatePropagation(); - $( '#thehole' ).one( 'load', function(){ - $( '#settings-button' ).trigger( 'click' ); - } ); - $( '#db-chart-button' ).trigger( 'click' ); - } ); + event.preventDefault(); + event.stopImmediatePropagation(); + + var query_saved = false; + var resume_save = function(){ + query_saved = true; + settings_button.click(); + }; + + $('body').one( 'visualizer:render:currentchart:update', resume_save ); + $( '#thehole' ).one( 'load', function(){ + $('body').off( 'visualizer:render:currentchart:update', resume_save ); + if ( ! query_saved ) { + $( '#db-chart-button' ).trigger( 'click' ); + } + } ); + $( '#db-chart-button' ).trigger( 'click' ); + }, true ); + } $('#visualizer-query-fetch').on('click', function(e){ diff --git a/tests/e2e/config/enable-database-source.php b/tests/e2e/config/enable-database-source.php index 6e8aed261..ecc22bb77 100644 --- a/tests/e2e/config/enable-database-source.php +++ b/tests/e2e/config/enable-database-source.php @@ -3,32 +3,24 @@ * Enable database-source E2E tests for requests carrying the test cookie. */ -defined( 'VISUALIZER_PRO_VERSION' ) || define( 'VISUALIZER_PRO_VERSION', '2.0.1' ); +if ( defined( 'TI_E2E_TESTING' ) && isset( $_COOKIE['visualizer_e2e_database_source'] ) ) { + defined( 'VISUALIZER_PRO_VERSION' ) || define( 'VISUALIZER_PRO_VERSION', '2.0.1' ); -if ( ! class_exists( 'Visualizer_Pro' ) ) { - class Visualizer_Pro { - const ACTION_FETCH_DATA = 'visualizer-fetch-data'; - const CF_PERMISSIONS = 'visualizer-permissions'; + if ( ! class_exists( 'Visualizer_Pro' ) ) { + class Visualizer_Pro { + const ACTION_FETCH_DATA = 'visualizer-fetch-data'; + const CF_PERMISSIONS = 'visualizer-permissions'; + } } -} - -function visualizer_e2e_database_source_enabled() { - return defined( 'TI_E2E_TESTING' ) && isset( $_COOKIE['visualizer_e2e_database_source'] ); -} -add_filter( - 'visualizer_is_pro', - function ( $enabled ) { - return visualizer_e2e_database_source_enabled() ? true : $enabled; - }, - PHP_INT_MAX -); + add_filter( 'visualizer_is_pro', '__return_true', PHP_INT_MAX ); -add_filter( - 'visualizer_pro_upsell_class', - function ( $class, $feature = '' ) { - return visualizer_e2e_database_source_enabled() && 'db-query' === $feature ? '' : $class; - }, - PHP_INT_MAX, - 2 -); + add_filter( + 'visualizer_pro_upsell_class', + function ( $class, $feature = '' ) { + return 'db-query' === $feature ? '' : $class; + }, + PHP_INT_MAX, + 2 + ); +} diff --git a/tests/e2e/specs/database-source.spec.js b/tests/e2e/specs/database-source.spec.js index 3ddd4a7e4..1512d519a 100644 --- a/tests/e2e/specs/database-source.spec.js +++ b/tests/e2e/specs/database-source.spec.js @@ -12,6 +12,15 @@ const { selectChartAdmin, } = require( '../utils/common' ); +async function setQuery( chartEditor, query ) { + await chartEditor + .locator( '#visualizer-db-query .CodeMirror' ) + .evaluate( + ( editor, sql ) => editor.CodeMirror.setValue( sql ), + query + ); +} + async function showQueryResults( page, chartEditor, expectedValue ) { const [ response ] = await Promise.all( [ page.waitForResponse( ( candidate ) => { @@ -61,14 +70,15 @@ test.describe( 'Database source', () => { context, page, requestUtils, - }, testInfo ) => { + } ) => { test.setTimeout( 180000 ); + await page.goto( '/' ); await context.addCookies( [ { name: 'visualizer_e2e_database_source', value: '1', - url: testInfo.project.use.baseURL || 'http://localhost:8889', + url: page.url(), }, ] ); @@ -107,12 +117,7 @@ test.describe( 'Database source', () => { .evaluate( ( button ) => button.click() ); const query = `SELECT post_title AS value FROM wp_posts WHERE ID = ${ sourcePost.id }`; - await chartEditor - .locator( '.CodeMirror' ) - .evaluate( - ( editor, sql ) => editor.CodeMirror.setValue( sql ), - query - ); + await setQuery( chartEditor, query ); await showQueryResults( page, chartEditor, 'ISSUE_1329_OLD' ); @@ -178,5 +183,29 @@ test.describe( 'Database source', () => { await expect( page.getByRole( 'cell', { name: 'ISSUE_1329_NEW' } ) ).toBeVisible(); + + await admin.visitAdminPage( 'admin.php?page=visualizer' ); + await page + .locator( `.visualizer-chart-edit[data-chart="${ chartId }"]` ) + .click(); + await chartEditor + .locator( '#db-chart-button' ) + .evaluate( ( button ) => button.click() ); + await setQuery( + chartEditor, + 'SELECT visualizer_missing_column FROM wp_posts' + ); + + const errorDialogPromise = page.waitForEvent( 'dialog' ); + await chartEditor.getByRole( 'button', { name: 'Save Chart' } ).click(); + const errorDialog = await errorDialogPromise; + await errorDialog.dismiss(); + + await expect( + chartEditor.locator( '#visualizer-db-query' ) + ).toBeVisible(); + await expect( + page.getByRole( 'dialog', { name: 'Visualizer' } ) + ).toBeVisible(); } ); } ); From ce677c4540ef0b9ae23eae5bf542094f146bc375 Mon Sep 17 00:00:00 2001 From: lucadobrescu Date: Mon, 13 Jul 2026 10:53:12 +0300 Subject: [PATCH 03/17] test: stabilize database-source e2e spec Wait for the query editor to initialize before opening it, navigate to the chart post via ?p= so the check works under any permalink structure, and retry the front-end chart assertion since chart scripts lazy-load on user interaction. Co-Authored-By: Claude Fable 5 --- tests/e2e/specs/database-source.spec.js | 66 +++++++++++++++---------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/tests/e2e/specs/database-source.spec.js b/tests/e2e/specs/database-source.spec.js index 1512d519a..8378d3ea6 100644 --- a/tests/e2e/specs/database-source.spec.js +++ b/tests/e2e/specs/database-source.spec.js @@ -12,6 +12,29 @@ const { selectChartAdmin, } = require( '../utils/common' ); +async function openQueryPanel( chartEditor ) { + // The CodeMirror node is created by the same synchronous init that binds + // the #db-chart-button handler, so its presence proves the click will work. + await chartEditor + .locator( '#visualizer-db-query .CodeMirror' ) + .waitFor( { state: 'attached', timeout: 30000 } ); + await chartEditor + .locator( '#db-chart-button' ) + .evaluate( ( button ) => button.click() ); + await expect( chartEditor.locator( '#visualizer-db-query' ) ).toBeVisible(); +} + +async function expectChartCell( page, value ) { + // Chart scripts lazy-load on user interaction and Google Charts can miss + // the first trigger, so keep re-scrolling until the table renders. + await expect( async () => { + await page.mouse.wheel( 0, 100 ); + await expect( page.getByRole( 'cell', { name: value } ) ).toBeVisible( { + timeout: 5000, + } ); + } ).toPass( { timeout: 60000 } ); +} + async function setQuery( chartEditor, query ) { await chartEditor .locator( '#visualizer-db-query .CodeMirror' ) @@ -48,7 +71,8 @@ test.describe( 'Database source', () => { test.beforeEach( async ( { page, requestUtils } ) => { await deleteAllCharts( requestUtils ); - page.setDefaultTimeout( 20000 ); + page.setDefaultTimeout( 30000 ); + page.setDefaultNavigationTimeout( 60000 ); } ); test.afterEach( async ( { requestUtils } ) => { @@ -67,18 +91,18 @@ test.describe( 'Database source', () => { test( 'saves previewed one-time data when saving the chart', async ( { admin, + baseURL, context, page, requestUtils, } ) => { - test.setTimeout( 180000 ); + test.setTimeout( 300000 ); - await page.goto( '/' ); await context.addCookies( [ { name: 'visualizer_e2e_database_source', value: '1', - url: page.url(), + url: baseURL, }, ] ); @@ -98,12 +122,12 @@ test.describe( 'Database source', () => { const chartEditor = page.frameLocator( 'iframe' ); await chartEditor .locator( 'div#type-picker' ) - .waitFor( { state: 'visible', timeout: 20000 } ); + .waitFor( { state: 'visible', timeout: 30000 } ); await selectChartAdmin( chartEditor, CHART_JS_LABELS.table ); await chartEditor .locator( '#visualizer-chart-id' ) - .waitFor( { state: 'attached', timeout: 20000 } ); + .waitFor( { state: 'attached', timeout: 30000 } ); const chartId = await chartEditor .locator( '#visualizer-chart-id' ) .getAttribute( 'data-id' ); @@ -112,9 +136,7 @@ test.describe( 'Database source', () => { await chartEditor .getByRole( 'button', { name: /Import from database/ } ) .click(); - await chartEditor - .locator( '#db-chart-button' ) - .evaluate( ( button ) => button.click() ); + await openQueryPanel( chartEditor ); const query = `SELECT post_title AS value FROM wp_posts WHERE ID = ${ sourcePost.id }`; await setQuery( chartEditor, query ); @@ -136,14 +158,14 @@ test.describe( 'Database source', () => { expect( saveResponse.ok() ).toBe( true ); await expect( chartEditor.locator( '#canvas' ) ).toContainText( 'ISSUE_1329_OLD', - { timeout: 20000 } + { timeout: 30000 } ); await chartEditor .getByRole( 'button', { name: 'Create Chart' } ) .click(); await expect( page.getByRole( 'heading', { name: 'Visualizer Library' } ) - ).toBeVisible( { timeout: 20000 } ); + ).toBeVisible( { timeout: 30000 } ); const chartPost = await requestUtils.createPost( { title: 'Issue 1329 chart', @@ -152,11 +174,8 @@ test.describe( 'Database source', () => { } ); postIds.push( chartPost.id ); - await page.goto( chartPost.link ); - await page.mouse.wheel( 0, 500 ); - await expect( - page.getByRole( 'cell', { name: 'ISSUE_1329_OLD' } ) - ).toBeVisible(); + await page.goto( `/?p=${ chartPost.id }` ); + await expectChartCell( page, 'ISSUE_1329_OLD' ); await requestUtils.rest( { method: 'POST', @@ -168,9 +187,7 @@ test.describe( 'Database source', () => { await page .locator( `.visualizer-chart-edit[data-chart="${ chartId }"]` ) .click(); - await chartEditor - .locator( '#db-chart-button' ) - .evaluate( ( button ) => button.click() ); + await openQueryPanel( chartEditor ); await showQueryResults( page, chartEditor, 'ISSUE_1329_NEW' ); await chartEditor.getByRole( 'button', { name: 'Save Chart' } ).click(); @@ -178,19 +195,14 @@ test.describe( 'Database source', () => { page.getByRole( 'dialog', { name: 'Visualizer' } ) ).toHaveCount( 0, { timeout: 30000 } ); - await page.goto( chartPost.link ); - await page.mouse.wheel( 0, 500 ); - await expect( - page.getByRole( 'cell', { name: 'ISSUE_1329_NEW' } ) - ).toBeVisible(); + await page.goto( `/?p=${ chartPost.id }` ); + await expectChartCell( page, 'ISSUE_1329_NEW' ); await admin.visitAdminPage( 'admin.php?page=visualizer' ); await page .locator( `.visualizer-chart-edit[data-chart="${ chartId }"]` ) .click(); - await chartEditor - .locator( '#db-chart-button' ) - .evaluate( ( button ) => button.click() ); + await openQueryPanel( chartEditor ); await setQuery( chartEditor, 'SELECT visualizer_missing_column FROM wp_posts' From 2bcb43ef7296b679943eead2e0ab35d6204bb2e0 Mon Sep 17 00:00:00 2001 From: lucadobrescu <252785083+lucadobrescu@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:21:49 +0300 Subject: [PATCH 04/17] fix: check per-post edit_post capability in chart_data REST field The chart_data REST field callback only checked the generic edit_posts capability, so any contributor could read the full backend configuration of any chart (data source query, settings, JSON endpoint auth headers) via GET /wp-json/wp/v2/visualizer/{id}. Check edit_post on the requested chart instead, so access follows the standard post capability map (author or edit_others_posts). Fixes Codeinwp/visualizer-pro#605 Co-Authored-By: Claude Fable 5 --- classes/Visualizer/Gutenberg/Block.php | 2 +- tests/test-chart-data-permissions.php | 60 ++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 tests/test-chart-data-permissions.php diff --git a/classes/Visualizer/Gutenberg/Block.php b/classes/Visualizer/Gutenberg/Block.php index 1111c86bf..93a0cb58a 100644 --- a/classes/Visualizer/Gutenberg/Block.php +++ b/classes/Visualizer/Gutenberg/Block.php @@ -276,7 +276,7 @@ public function register_rest_endpoints() { * Get Post Meta Fields */ public function get_visualizer_data( $post ) { - if ( ! current_user_can( 'edit_posts' ) ) { + if ( ! current_user_can( 'edit_post', $post['id'] ) ) { return false; } diff --git a/tests/test-chart-data-permissions.php b/tests/test-chart-data-permissions.php new file mode 100644 index 000000000..aa2391bb4 --- /dev/null +++ b/tests/test-chart-data-permissions.php @@ -0,0 +1,60 @@ +post->create( + array( + 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, + 'post_author' => $author_id, + '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, array() ); + update_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, array( array( 'label' => 'Label', 'type' => 'string' ) ) ); + return $chart_id; + } + + /** + * A contributor must not read another user's chart configuration. + */ + public function test_contributor_cannot_read_others_chart_data() { + $author_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + $chart_id = $this->create_chart( $author_id ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'contributor' ) ) ); + + $result = Visualizer_Gutenberg_Block::get_instance()->get_visualizer_data( array( 'id' => $chart_id ) ); + $this->assertFalse( $result ); + } + + /** + * The chart author can still read their own chart configuration. + */ + public function test_author_can_read_own_chart_data() { + $author_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + $chart_id = $this->create_chart( $author_id ); + + wp_set_current_user( $author_id ); + + $result = Visualizer_Gutenberg_Block::get_instance()->get_visualizer_data( array( 'id' => $chart_id ) ); + $this->assertIsArray( $result ); + } +} From 6b05e982b1937459673220c7fc540f2326ed884b Mon Sep 17 00:00:00 2001 From: lucadobrescu <252785083+lucadobrescu@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:39:26 +0300 Subject: [PATCH 05/17] fix: use can_edit_chart so contributor authors keep access to own charts Charts are force-published on save, and Contributors lack edit_published_posts, so a bare edit_post check locks chart authors out of their own charts in the Gutenberg block. Use the existing Visualizer_Module::can_edit_chart() helper, which allows the chart owner with edit_posts while still rejecting other users' charts. Covers Copilot review feedback on #1348. Co-Authored-By: Claude Fable 5 --- classes/Visualizer/Gutenberg/Block.php | 2 +- tests/test-chart-data-permissions.php | 20 ++++++++++++++++++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/classes/Visualizer/Gutenberg/Block.php b/classes/Visualizer/Gutenberg/Block.php index 93a0cb58a..be55c6236 100644 --- a/classes/Visualizer/Gutenberg/Block.php +++ b/classes/Visualizer/Gutenberg/Block.php @@ -276,7 +276,7 @@ public function register_rest_endpoints() { * Get Post Meta Fields */ public function get_visualizer_data( $post ) { - if ( ! current_user_can( 'edit_post', $post['id'] ) ) { + if ( ! Visualizer_Module::can_edit_chart( $post['id'] ) ) { return false; } diff --git a/tests/test-chart-data-permissions.php b/tests/test-chart-data-permissions.php index aa2391bb4..7867f4964 100644 --- a/tests/test-chart-data-permissions.php +++ b/tests/test-chart-data-permissions.php @@ -22,6 +22,7 @@ private function create_chart( $author_id ) { $chart_id = self::factory()->post->create( array( 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, + 'post_status' => 'publish', 'post_author' => $author_id, 'post_content' => wp_slash( serialize( array( array( 'Label' ), array( 'Value' ) ) ) ), ) @@ -47,9 +48,12 @@ public function test_contributor_cannot_read_others_chart_data() { /** * The chart author can still read their own chart configuration. + * + * Charts are always published on save, so a Contributor author must pass + * the check even without the edit_published_posts capability. */ - public function test_author_can_read_own_chart_data() { - $author_id = self::factory()->user->create( array( 'role' => 'editor' ) ); + public function test_contributor_author_can_read_own_chart_data() { + $author_id = self::factory()->user->create( array( 'role' => 'contributor' ) ); $chart_id = $this->create_chart( $author_id ); wp_set_current_user( $author_id ); @@ -57,4 +61,16 @@ public function test_author_can_read_own_chart_data() { $result = Visualizer_Gutenberg_Block::get_instance()->get_visualizer_data( array( 'id' => $chart_id ) ); $this->assertIsArray( $result ); } + + /** + * An editor can read any chart configuration. + */ + public function test_editor_can_read_others_chart_data() { + $chart_id = $this->create_chart( self::factory()->user->create( array( 'role' => 'contributor' ) ) ); + + wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) ); + + $result = Visualizer_Gutenberg_Block::get_instance()->get_visualizer_data( array( 'id' => $chart_id ) ); + $this->assertIsArray( $result ); + } } From 8e1f634dffda6b5df88dac5b7e2f2cdb2c6e36b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:02:15 +0300 Subject: [PATCH 06/17] chore(deps-dev): bump phpstan/phpstan from 2.1.54 to 2.2.6 (#1351) Bumps [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source) from 2.1.54 to 2.2.6. - [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits) --- updated-dependencies: - dependency-name: phpstan/phpstan dependency-version: 2.2.6 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 106a4329b..890d6b54a 100644 --- a/composer.lock +++ b/composer.lock @@ -1066,11 +1066,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.54", + "version": "2.2.6", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", - "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a6e9b5a9420f6109c091e87d82683bd1a80b87ed", + "reference": "a6e9b5a9420f6109c091e87d82683bd1a80b87ed", "shasum": "" }, "require": { @@ -1093,6 +1093,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -1115,7 +1126,7 @@ "type": "github" } ], - "time": "2026-04-29T13:31:09+00:00" + "time": "2026-07-26T21:22:49+00:00" }, { "name": "phpunit/php-code-coverage", From 022ed18712251f2a8021105f4b343b6c12b4637f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:02:43 +0300 Subject: [PATCH 07/17] chore(deps-dev): bump wp-coding-standards/wpcs from 3.3.0 to 3.4.1 (#1350) Bumps [wp-coding-standards/wpcs](https://github.com/WordPress/WordPress-Coding-Standards) from 3.3.0 to 3.4.1. - [Release notes](https://github.com/WordPress/WordPress-Coding-Standards/releases) - [Changelog](https://github.com/WordPress/WordPress-Coding-Standards/blob/develop/CHANGELOG.md) - [Commits](https://github.com/WordPress/WordPress-Coding-Standards/compare/3.3.0...3.4.1) --- updated-dependencies: - dependency-name: wp-coding-standards/wpcs dependency-version: 3.4.1 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/composer.lock b/composer.lock index 890d6b54a..7f687b125 100644 --- a/composer.lock +++ b/composer.lock @@ -224,16 +224,16 @@ "packages-dev": [ { "name": "dealerdirect/phpcodesniffer-composer-installer", - "version": "v1.2.0", + "version": "v1.2.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/composer-installer.git", - "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1" + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/845eb62303d2ca9b289ef216356568ccc075ffd1", - "reference": "845eb62303d2ca9b289ef216356568ccc075ffd1", + "url": "https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/963f0c67bffde0eac41b56be71ac0e8ba132f0bd", + "reference": "963f0c67bffde0eac41b56be71ac0e8ba132f0bd", "shasum": "" }, "require": { @@ -316,7 +316,7 @@ "type": "thanks_dev" } ], - "time": "2025-11-11T04:32:07+00:00" + "time": "2026-05-06T08:26:05+00:00" }, { "name": "doctrine/instantiator", @@ -891,21 +891,21 @@ }, { "name": "phpcsstandards/phpcsextra", - "version": "1.5.0", + "version": "1.5.1", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSExtra.git", - "reference": "b598aa890815b8df16363271b659d73280129101" + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101", - "reference": "b598aa890815b8df16363271b659d73280129101", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/39467533fdb742446d68c1d10ac33d625ee0311c", + "reference": "39467533fdb742446d68c1d10ac33d625ee0311c", "shasum": "" }, "require": { "php": ">=5.4", - "phpcsstandards/phpcsutils": "^1.2.0", + "phpcsstandards/phpcsutils": "^1.2.3", "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1" }, "require-dev": { @@ -969,20 +969,20 @@ "type": "thanks_dev" } ], - "time": "2025-11-12T23:06:57+00:00" + "time": "2026-07-27T11:13:17+00:00" }, { "name": "phpcsstandards/phpcsutils", - "version": "1.2.2", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/PHPCSStandards/PHPCSUtils.git", - "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55" + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", - "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55", + "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/5f35d9408c54d7b529501f3c688b6eae562aea1f", + "reference": "5f35d9408c54d7b529501f3c688b6eae562aea1f", "shasum": "" }, "require": { @@ -1062,7 +1062,7 @@ "type": "thanks_dev" } ], - "time": "2025-12-08T14:27:58+00:00" + "time": "2026-07-27T10:28:41+00:00" }, { "name": "phpstan/phpstan", @@ -2763,16 +2763,16 @@ }, { "name": "wp-coding-standards/wpcs", - "version": "3.3.0", + "version": "3.4.1", "source": { "type": "git", "url": "https://github.com/WordPress/WordPress-Coding-Standards.git", - "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6" + "reference": "ec2ff942335f33683a5957a85d138753876a05cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", - "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6", + "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/ec2ff942335f33683a5957a85d138753876a05cf", + "reference": "ec2ff942335f33683a5957a85d138753876a05cf", "shasum": "" }, "require": { @@ -2781,9 +2781,9 @@ "ext-tokenizer": "*", "ext-xmlreader": "*", "php": ">=7.2", - "phpcsstandards/phpcsextra": "^1.5.0", - "phpcsstandards/phpcsutils": "^1.1.0", - "squizlabs/php_codesniffer": "^3.13.4" + "phpcsstandards/phpcsextra": "^1.5.1", + "phpcsstandards/phpcsutils": "^1.2.3", + "squizlabs/php_codesniffer": "^3.13.5" }, "require-dev": { "php-parallel-lint/php-console-highlighter": "^1.0.0", @@ -2825,7 +2825,7 @@ "type": "custom" } ], - "time": "2025-11-25T12:08:04+00:00" + "time": "2026-07-27T11:53:23+00:00" }, { "name": "yoast/phpunit-polyfills", From f20d399d46ff61bf638e32ecad374c1b49e62ed5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:03:02 +0300 Subject: [PATCH 08/17] chore(deps): bump codeinwp/themeisle-sdk from 3.3.55 to 3.3.57 (#1349) Bumps [codeinwp/themeisle-sdk](https://github.com/Codeinwp/themeisle-sdk) from 3.3.55 to 3.3.57. - [Release notes](https://github.com/Codeinwp/themeisle-sdk/releases) - [Changelog](https://github.com/Codeinwp/themeisle-sdk/blob/v3.3.57/CHANGELOG.md) - [Commits](https://github.com/Codeinwp/themeisle-sdk/compare/v3.3.55...v3.3.57) --- updated-dependencies: - dependency-name: codeinwp/themeisle-sdk dependency-version: 3.3.57 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- composer.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.lock b/composer.lock index 7f687b125..df2b7b77d 100644 --- a/composer.lock +++ b/composer.lock @@ -8,16 +8,16 @@ "packages": [ { "name": "codeinwp/themeisle-sdk", - "version": "3.3.55", + "version": "3.3.57", "source": { "type": "git", "url": "https://github.com/Codeinwp/themeisle-sdk.git", - "reference": "bd601798d209a4bc5962d2a19a22dc6dddf341cc" + "reference": "3c761b0bddda8d5963a47d14a40811869131030b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/bd601798d209a4bc5962d2a19a22dc6dddf341cc", - "reference": "bd601798d209a4bc5962d2a19a22dc6dddf341cc", + "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/3c761b0bddda8d5963a47d14a40811869131030b", + "reference": "3c761b0bddda8d5963a47d14a40811869131030b", "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.55" + "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.57" }, - "time": "2026-07-20T10:57:27+00:00" + "time": "2026-07-23T13:31:25+00:00" }, { "name": "neitanod/forceutf8", From c7d2255ca5b4a9539952eb94679fa5c5217fee8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:27:54 +0300 Subject: [PATCH 09/17] chore(deps-dev): bump dealerdirect/phpcodesniffer-composer-installer (#1314) Bumps [dealerdirect/phpcodesniffer-composer-installer](https://github.com/PHPCSStandards/composer-installer) from 1.2.0 to 1.2.1. - [Release notes](https://github.com/PHPCSStandards/composer-installer/releases) - [Changelog](https://github.com/PHPCSStandards/composer-installer/blob/main/CHANGELOG.md) - [Commits](https://github.com/PHPCSStandards/composer-installer/compare/v1.2.0...v1.2.1) --- updated-dependencies: - dependency-name: dealerdirect/phpcodesniffer-composer-installer dependency-version: 1.2.1 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> From 0f3d00065f6c00f1779f3ca6e71f9a14a2f451be Mon Sep 17 00:00:00 2001 From: kushh23 Date: Thu, 30 Jul 2026 12:10:39 +0530 Subject: [PATCH 10/17] fix: gate AI Builder db_query behind super-admin + Pro to match classic path --- classes/Visualizer/Module/AIBuilder.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/Visualizer/Module/AIBuilder.php b/classes/Visualizer/Module/AIBuilder.php index 104c647f3..41c24a19b 100644 --- a/classes/Visualizer/Module/AIBuilder.php +++ b/classes/Visualizer/Module/AIBuilder.php @@ -369,7 +369,7 @@ public function uploadData(): void { // ── Database query ──────────────────────────────────────────────── case 'db_query': - if ( ! current_user_can( 'manage_options' ) && ! is_super_admin() ) { + if ( ! current_user_can( 'manage_options' ) || ! is_super_admin() || ! Visualizer_Module::is_pro() ) { wp_send_json_error( array( 'message' => __( 'Action not allowed for this user.', 'visualizer' ) ), 403 ); } if ( empty( $_POST['db_query'] ) ) { From 827e278048fdd58f85786f8338de0b81a2ff1892 Mon Sep 17 00:00:00 2001 From: Soare Robert Daniel Date: Thu, 30 Jul 2026 10:44:15 +0300 Subject: [PATCH 11/17] fix(e2e): make the suite target the actual wp-env instance (#1358) --- .gitignore | 3 +++ .wp-env.json | 3 +-- .../config/{ => mu-plugins}/enable-database-source.php | 0 .../e2e/config/{ => mu-plugins}/force-lazy-render.php | 0 tests/e2e/playwright.config.js | 10 ++++++++++ tests/e2e/specs/lazy-render.spec.js | 7 +++++-- 6 files changed, 19 insertions(+), 4 deletions(-) rename tests/e2e/config/{ => mu-plugins}/enable-database-source.php (100%) rename tests/e2e/config/{ => mu-plugins}/force-lazy-render.php (100%) diff --git a/.gitignore b/.gitignore index 7ff179d3f..a890db7e5 100755 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ artifacts classes/Visualizer/Gutenberg/build classes/Visualizer/ChartBuilder/build classes/Visualizer/D3Renderer/build + +# Local wp-env port pinning (per-checkout). +.wp-env.override.json diff --git a/.wp-env.json b/.wp-env.json index 23d552f6d..dc0e3fc83 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -6,8 +6,7 @@ ], "themes": [], "mappings": { - "wp-content/mu-plugins/visualizer-e2e-enable-database-source.php": "./tests/e2e/config/enable-database-source.php", - "wp-content/mu-plugins/visualizer-e2e-force-lazy-render.php": "./tests/e2e/config/force-lazy-render.php" + "wp-content/mu-plugins": "./tests/e2e/config/mu-plugins" }, "config": { "WP_DEBUG": true, diff --git a/tests/e2e/config/enable-database-source.php b/tests/e2e/config/mu-plugins/enable-database-source.php similarity index 100% rename from tests/e2e/config/enable-database-source.php rename to tests/e2e/config/mu-plugins/enable-database-source.php diff --git a/tests/e2e/config/force-lazy-render.php b/tests/e2e/config/mu-plugins/force-lazy-render.php similarity index 100% rename from tests/e2e/config/force-lazy-render.php rename to tests/e2e/config/mu-plugins/force-lazy-render.php diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index a39cddd1f..d0838fd10 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -7,6 +7,15 @@ import os from 'os'; import { fileURLToPath } from 'url'; import { defineConfig, devices } from '@playwright/test'; +// The tests instance may be pinned to a non-default port in .wp-env.override.json +// (8889 is often taken by another checkout). Target whatever wp-env actually bound. +let testsPort = process.env.WP_ENV_TESTS_PORT; +try { + testsPort ??= require( '../../.wp-env.override.json' ).testsPort; +} catch ( e ) {} // No override file, fall back to the wp-env default. +testsPort = Number( testsPort ) || 8889; +process.env.WP_BASE_URL ??= `http://localhost:${ testsPort }`; + /** * WordPress dependencies */ @@ -14,6 +23,7 @@ const baseConfig = require( '@wordpress/scripts/config/playwright.config' ); const config = defineConfig( { ...baseConfig, + webServer: { ...baseConfig.webServer, port: testsPort }, reporter: process.env.CI ? [ [ 'github' ], [ './config/flaky-tests-reporter.js' ] ] : 'list', diff --git a/tests/e2e/specs/lazy-render.spec.js b/tests/e2e/specs/lazy-render.spec.js index 55b6b8db7..df743b450 100644 --- a/tests/e2e/specs/lazy-render.spec.js +++ b/tests/e2e/specs/lazy-render.spec.js @@ -38,8 +38,11 @@ test.describe( 'Lazy rendered charts (frontend)', () => { } async function triggerLazyLoader( page ) { - // Scripts are swapped to `data-visualizer-script` placeholders. - await expect( page.locator( 'script[data-visualizer-script]' ).first() ).toBeAttached(); + // Assert lazy rendering is on via the container class, not the + // `data-visualizer-script` placeholders: those are swapped away by the + // first user interaction, and Chromium fires a real `mouseover` right + // after navigation whenever the cursor sits over the page. + await expect( page.locator( '.visualizer-lazy-render' ).first() ).toBeAttached(); // The loader starts on the first user interaction. await page.evaluate( () => window.dispatchEvent( new Event( 'scroll' ) ) ); From 229a0a4e79e8ec2d7ff4388c4484cfb47dc6522e Mon Sep 17 00:00:00 2001 From: lucadobrescu <252785083+lucadobrescu@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:32:26 +0300 Subject: [PATCH 12/17] fix: enforce per-chart authorization in AI Builder endpoints Co-authored-by: Cursor --- classes/Visualizer/Module/AIBuilder.php | 10 +- tests/e2e/specs/ai-builder-auth.spec.js | 138 ++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/specs/ai-builder-auth.spec.js diff --git a/classes/Visualizer/Module/AIBuilder.php b/classes/Visualizer/Module/AIBuilder.php index 41c24a19b..c97be184f 100644 --- a/classes/Visualizer/Module/AIBuilder.php +++ b/classes/Visualizer/Module/AIBuilder.php @@ -100,7 +100,7 @@ private function _verify_create_nonce(): void { * @param int $chart_id Chart ID. */ private function _verify_chart_access( $chart_id ): void { - if ( ! current_user_can( 'edit_post', $chart_id ) ) { + if ( ! self::can_edit_chart( $chart_id ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'visualizer' ) ), 403 ); } } @@ -502,6 +502,10 @@ public function generateChart(): void { } } + if ( ! empty( $workflow_id ) ) { + set_transient( 'viz_ai_wf_' . $workflow_id, get_current_user_id(), 6 * HOUR_IN_SECONDS ); + } + wp_send_json_success( array( 'workflow_id' => $workflow_id, @@ -524,6 +528,10 @@ public function chartStatus(): void { wp_send_json_error( array( 'message' => __( 'Missing workflow ID.', 'visualizer' ) ) ); } + if ( (int) get_transient( 'viz_ai_wf_' . $workflow_id ) !== get_current_user_id() ) { + wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'visualizer' ) ), 403 ); + } + $agents_url = VISUALIZER_AGENTS_URL; $workflow_slug = $this->_get_workflow_slug(); $headers = $this->_get_agents_headers(); diff --git a/tests/e2e/specs/ai-builder-auth.spec.js b/tests/e2e/specs/ai-builder-auth.spec.js new file mode 100644 index 000000000..93f040c02 --- /dev/null +++ b/tests/e2e/specs/ai-builder-auth.spec.js @@ -0,0 +1,138 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const CONTRIBUTOR = { username: 'viz_contributor', password: 'viz-contributor-pass', email: 'viz-contributor@example.com' }; +const EDITOR = { username: 'viz_editor', password: 'viz-editor-pass', email: 'viz-editor@example.com' }; + +let adminChartId; +let contributorId; +let editorId; + +/** + * Log in via wp-login in a fresh context and return the page. + */ +async function loginAs( browser, baseURL, credentials ) { + const context = await browser.newContext( { baseURL } ); + const page = await context.newPage(); + await page.goto( '/wp-login.php' ); + await page.fill( '#user_login', credentials.username ); + await page.fill( '#user_pass', credentials.password ); + await page.click( '#wp-submit' ); + await page.waitForURL( '**/wp-admin/**' ); + return { context, page }; +} + +/** + * Read the AI Builder nonce localized on the Visualizer library page. + */ +async function getAiNonce( page ) { + await page.goto( '/wp-admin/admin.php?page=visualizer' ); + return page.evaluate( () => { + if ( window.vizAIBuilder && window.vizAIBuilder.nonce ) { + return window.vizAIBuilder.nonce; + } + const match = document.documentElement.innerHTML.match( /"nonce":"([a-f0-9]+)"/ ); + return match ? match[ 1 ] : null; + } ); +} + +/** + * Call an admin-ajax action using the page's session cookies. + */ +async function aiAjax( page, action, data ) { + const response = await page.request.post( '/wp-admin/admin-ajax.php', { + form: { action, ...data }, + } ); + return { status: response.status(), body: await response.json() }; +} + +test.describe( 'AI Builder authorization', () => { + test.beforeAll( async ( { requestUtils } ) => { + const contributor = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/users', + data: { ...CONTRIBUTOR, roles: [ 'contributor' ] }, + } ); + contributorId = contributor.id; + + const editor = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/users', + data: { ...EDITOR, roles: [ 'editor' ] }, + } ); + editorId = editor.id; + + const chart = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/visualizer', + data: { title: 'Admin chart', status: 'publish' }, + } ); + adminChartId = chart.id; + } ); + + test.afterAll( async ( { requestUtils } ) => { + if ( adminChartId ) { + await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/visualizer/${ adminChartId }`, params: { force: true } } ); + } + for ( const [ id, user ] of [ [ contributorId, CONTRIBUTOR ], [ editorId, EDITOR ] ] ) { + if ( id ) { + await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/users/${ id }`, params: { force: true, reassign: 1 } } ); + } + } + } ); + + test( 'denies read/write/nonce endpoints on another user\'s chart', async ( { browser } ) => { + const baseURL = test.info().project.use.baseURL; + const { context, page } = await loginAs( browser, baseURL, CONTRIBUTOR ); + const nonce = await getAiNonce( page ); + expect( nonce ).toBeTruthy(); + + for ( const action of [ 'visualizer-ai-fetch', 'visualizer-ai-chart-nonce', 'visualizer-ai-save' ] ) { + const { status, body } = await aiAjax( page, action, { nonce, chart_id: adminChartId, code: 'x' } ); + expect( status, action ).toBe( 403 ); + expect( body.data.message, action ).toBe( 'Unauthorized.' ); + } + + await context.close(); + } ); + + test( 'denies polling a workflow owned by someone else', async ( { browser } ) => { + const baseURL = test.info().project.use.baseURL; + const { context, page } = await loginAs( browser, baseURL, CONTRIBUTOR ); + const nonce = await getAiNonce( page ); + + const { status, body } = await aiAjax( page, 'visualizer-ai-status', { nonce, workflow_id: 'foreign-workflow-id' } ); + expect( status ).toBe( 403 ); + expect( body.data.message ).toBe( 'Unauthorized.' ); + + await context.close(); + } ); + + test( 'allows a contributor to use their own chart', async ( { browser } ) => { + const baseURL = test.info().project.use.baseURL; + const { context, page } = await loginAs( browser, baseURL, CONTRIBUTOR ); + const nonce = await getAiNonce( page ); + + const created = await aiAjax( page, 'visualizer-ai-create', { nonce } ); + expect( created.body.success ).toBe( true ); + const ownChartId = created.body.data.chart_id; + + const fetched = await aiAjax( page, 'visualizer-ai-fetch', { nonce, chart_id: ownChartId } ); + expect( fetched.body.success ).toBe( true ); + + await context.close(); + } ); + + test( 'allows an editor to read another user\'s chart', async ( { browser } ) => { + const baseURL = test.info().project.use.baseURL; + const { context, page } = await loginAs( browser, baseURL, EDITOR ); + const nonce = await getAiNonce( page ); + + const { body } = await aiAjax( page, 'visualizer-ai-fetch', { nonce, chart_id: adminChartId } ); + expect( body.success ).toBe( true ); + + await context.close(); + } ); +} ); From 09e61d001f8057367623f3aee86e3aea029c7ee8 Mon Sep 17 00:00:00 2001 From: lucadobrescu <252785083+lucadobrescu@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:49:03 +0300 Subject: [PATCH 13/17] test: log in via wp-login POST instead of dashboard navigation Co-authored-by: Cursor --- tests/e2e/specs/ai-builder-auth.spec.js | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/e2e/specs/ai-builder-auth.spec.js b/tests/e2e/specs/ai-builder-auth.spec.js index 93f040c02..c991080c0 100644 --- a/tests/e2e/specs/ai-builder-auth.spec.js +++ b/tests/e2e/specs/ai-builder-auth.spec.js @@ -12,15 +12,26 @@ let editorId; /** * Log in via wp-login in a fresh context and return the page. + * + * Uses a raw POST with redirects off: the auth cookie is set by the 302 + * response, so we never have to load the wp-admin dashboard (which can + * stall on external feed widgets in CI). */ async function loginAs( browser, baseURL, credentials ) { const context = await browser.newContext( { baseURL } ); + const response = await context.request.post( '/wp-login.php', { + form: { + log: credentials.username, + pwd: credentials.password, + 'wp-submit': 'Log In', + testcookie: '1', + }, + maxRedirects: 0, + } ); + if ( response.status() !== 302 ) { + throw new Error( `Login as ${ credentials.username } failed with status ${ response.status() }` ); + } const page = await context.newPage(); - await page.goto( '/wp-login.php' ); - await page.fill( '#user_login', credentials.username ); - await page.fill( '#user_pass', credentials.password ); - await page.click( '#wp-submit' ); - await page.waitForURL( '**/wp-admin/**' ); return { context, page }; } From f3a4d5b62ffe13e519d02d420537e4236adbdf7c Mon Sep 17 00:00:00 2001 From: lucadobrescu <252785083+lucadobrescu@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:36:48 +0300 Subject: [PATCH 14/17] fix: gate setup wizard endpoints behind manage_options and nonces Co-authored-by: Cursor --- classes/Visualizer/Module/Wizard.php | 9 ++++++ templates/setup-wizard.php | 13 +++++--- tests/e2e/specs/wizard-auth.spec.js | 46 ++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/specs/wizard-auth.spec.js diff --git a/classes/Visualizer/Module/Wizard.php b/classes/Visualizer/Module/Wizard.php index 3a1007521..c3256e61d 100644 --- a/classes/Visualizer/Module/Wizard.php +++ b/classes/Visualizer/Module/Wizard.php @@ -153,6 +153,12 @@ public function visualizer_enqueue_setup_wizard_scripts() { * @return bool|void */ public function dismissWizard( $redirect_to_dashboard = true ) { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to perform this action.', 'visualizer' ), '', array( 'response' => 403 ) ); + } + if ( false !== $redirect_to_dashboard ) { + check_admin_referer( 'visualizer_dismiss_wizard' ); + } // phpcs:ignore WordPress.Security.NonceVerification.Recommended $status = isset( $_REQUEST['status'] ) ? (int) $_REQUEST['status'] : 0; update_option( 'visualizer_fresh_install', $status ); @@ -169,6 +175,9 @@ public function dismissWizard( $redirect_to_dashboard = true ) { */ public function visualizer_wizard_step_process() { check_ajax_referer( VISUALIZER_ABSPATH, 'security' ); + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json( array( 'status' => 0 ), 403 ); + } $step = ! empty( $_POST['step'] ) ? sanitize_text_field( wp_unslash( $_POST['step'] ) ) : 1; switch ( $step ) { case 'step_2': diff --git a/templates/setup-wizard.php b/templates/setup-wizard.php index e78178027..eccd04163 100644 --- a/templates/setup-wizard.php +++ b/templates/setup-wizard.php @@ -6,12 +6,15 @@ * @package Templates */ -$dashboard_url = add_query_arg( - array( - 'action' => 'visualizer_dismiss_wizard', - 'status' => 0, +$dashboard_url = wp_nonce_url( + add_query_arg( + array( + 'action' => 'visualizer_dismiss_wizard', + 'status' => 0, + ), + admin_url( 'admin.php' ) ), - admin_url( 'admin.php' ) + 'visualizer_dismiss_wizard' ); $chart_id = ! empty( $this->wizard_data['chart_id'] ) ? (int) $this->wizard_data['chart_id'] : ''; diff --git a/tests/e2e/specs/wizard-auth.spec.js b/tests/e2e/specs/wizard-auth.spec.js new file mode 100644 index 000000000..4ca3599f2 --- /dev/null +++ b/tests/e2e/specs/wizard-auth.spec.js @@ -0,0 +1,46 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +const CONTRIBUTOR = { username: 'viz_wiz_contributor', password: 'viz-wiz-contributor-pass', email: 'viz-wiz-contributor@example.com' }; + +let contributorId; + +test.describe( 'Setup wizard authorization', () => { + test.beforeAll( async ( { requestUtils } ) => { + const contributor = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/users', + data: { ...CONTRIBUTOR, roles: [ 'contributor' ] }, + } ); + contributorId = contributor.id; + } ); + + test.afterAll( async ( { requestUtils } ) => { + if ( contributorId ) { + await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/users/${ contributorId }`, params: { force: true, reassign: 1 } } ); + } + } ); + + test( 'denies wizard dismissal for non-admin users', async ( { browser } ) => { + const context = await browser.newContext( { baseURL: test.info().project.use.baseURL } ); + const page = await context.newPage(); + await page.goto( '/wp-login.php' ); + await page.fill( '#user_login', CONTRIBUTOR.username ); + await page.fill( '#user_pass', CONTRIBUTOR.password ); + await page.click( '#wp-submit' ); + await page.waitForURL( '**/wp-admin/**' ); + + const response = await page.goto( '/wp-admin/admin.php?action=visualizer_dismiss_wizard&status=1' ); + expect( response.status() ).toBe( 403 ); + + await context.close(); + } ); + + test( 'requires a nonce for wizard dismissal', async ( { page } ) => { + // Logged in as admin (default storage state) but without a nonce. + const response = await page.goto( '/wp-admin/admin.php?action=visualizer_dismiss_wizard&status=1' ); + expect( response.status() ).toBe( 403 ); + } ); +} ); From 7b3005428bc69b52ad5ee595ca34ccf530107d05 Mon Sep 17 00:00:00 2001 From: lucadobrescu <252785083+lucadobrescu@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:53:01 +0300 Subject: [PATCH 15/17] test: log in via wp-login POST instead of dashboard navigation Co-authored-by: Cursor --- tests/e2e/specs/wizard-auth.spec.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/e2e/specs/wizard-auth.spec.js b/tests/e2e/specs/wizard-auth.spec.js index 4ca3599f2..d232675a8 100644 --- a/tests/e2e/specs/wizard-auth.spec.js +++ b/tests/e2e/specs/wizard-auth.spec.js @@ -25,13 +25,20 @@ test.describe( 'Setup wizard authorization', () => { test( 'denies wizard dismissal for non-admin users', async ( { browser } ) => { const context = await browser.newContext( { baseURL: test.info().project.use.baseURL } ); - const page = await context.newPage(); - await page.goto( '/wp-login.php' ); - await page.fill( '#user_login', CONTRIBUTOR.username ); - await page.fill( '#user_pass', CONTRIBUTOR.password ); - await page.click( '#wp-submit' ); - await page.waitForURL( '**/wp-admin/**' ); + // POST wp-login with redirects off: the auth cookie is set by the 302 + // response, so we never load the wp-admin dashboard (can stall in CI). + const loginResponse = await context.request.post( '/wp-login.php', { + form: { + log: CONTRIBUTOR.username, + pwd: CONTRIBUTOR.password, + 'wp-submit': 'Log In', + testcookie: '1', + }, + maxRedirects: 0, + } ); + expect( loginResponse.status() ).toBe( 302 ); + const page = await context.newPage(); const response = await page.goto( '/wp-admin/admin.php?action=visualizer_dismiss_wizard&status=1' ); expect( response.status() ).toBe( 403 ); From 889afa90cc0bafd364b38c6eb671dd06e28c6353 Mon Sep 17 00:00:00 2001 From: lucadobrescu Date: Thu, 30 Jul 2026 11:15:25 +0300 Subject: [PATCH 16/17] fix: strip tags from chart custom CSS at render time (#1355) --- classes/Visualizer/Module.php | 2 +- .../mu-plugins/plant-chart-settings.php | 42 +++++++++++++++ tests/e2e/specs/custom-css.spec.js | 51 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/config/mu-plugins/plant-chart-settings.php create mode 100644 tests/e2e/specs/custom-css.spec.js diff --git a/classes/Visualizer/Module.php b/classes/Visualizer/Module.php index 13d7cbfc1..ae7993917 100644 --- a/classes/Visualizer/Module.php +++ b/classes/Visualizer/Module.php @@ -650,7 +650,7 @@ protected function get_inline_custom_css( $id, $settings ) { $class_name = $id . $name; $properties = implode( ' !important; ', array_filter( $attributes ) ); if ( ! empty( $properties ) ) { - $css .= '.' . $class_name . ' {' . $properties . ' !important;}'; + $css .= wp_strip_all_tags( '.' . $class_name . ' {' . $properties . ' !important;}' ); $classes[ $name ] = $class_name; } } diff --git a/tests/e2e/config/mu-plugins/plant-chart-settings.php b/tests/e2e/config/mu-plugins/plant-chart-settings.php new file mode 100644 index 000000000..5222e9de8 --- /dev/null +++ b/tests/e2e/config/mu-plugins/plant-chart-settings.php @@ -0,0 +1,42 @@ +\d+)', + array( + 'methods' => 'POST', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'callback' => function ( WP_REST_Request $request ) { + $chart_id = (int) $request['id']; + $body = $request->get_json_params(); + if ( isset( $body['settings'] ) ) { + update_post_meta( $chart_id, 'visualizer-settings', $body['settings'] ); + } + if ( isset( $body['series'] ) ) { + update_post_meta( $chart_id, 'visualizer-series', $body['series'] ); + } + if ( isset( $body['content'] ) ) { + wp_update_post( + array( + 'ID' => $chart_id, + 'post_content' => maybe_serialize( $body['content'] ), + ) + ); + } + return array( 'ok' => true ); + }, + ) + ); + } +); diff --git a/tests/e2e/specs/custom-css.spec.js b/tests/e2e/specs/custom-css.spec.js new file mode 100644 index 000000000..1b2fb22f5 --- /dev/null +++ b/tests/e2e/specs/custom-css.spec.js @@ -0,0 +1,51 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +let chartId; + +test.describe( 'Custom CSS sanitization', () => { + test.beforeAll( async ( { requestUtils } ) => { + const chart = await requestUtils.rest( { + method: 'POST', + path: '/wp/v2/visualizer', + data: { title: 'Custom CSS payload chart', status: 'publish' }, + } ); + chartId = chart.id; + + await requestUtils.rest( { + method: 'POST', + path: `/visualizer-e2e/v1/chart-settings/${ chartId }`, + data: { + settings: { + customcss: { + title: { + color: 'red