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 61758738a..dc0e3fc83 100644
--- a/.wp-env.json
+++ b/.wp-env.json
@@ -6,7 +6,7 @@
],
"themes": [],
"mappings": {
- "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/classes/Visualizer/Gutenberg/Block.php b/classes/Visualizer/Gutenberg/Block.php
index 1111c86bf..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_posts' ) ) {
+ if ( ! Visualizer_Module::can_edit_chart( $post['id'] ) ) {
return false;
}
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/classes/Visualizer/Module/AIBuilder.php b/classes/Visualizer/Module/AIBuilder.php
index 104c647f3..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 );
}
}
@@ -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'] ) ) {
@@ -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/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/composer.lock b/composer.lock
index 106a4329b..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",
@@ -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,15 +1062,15 @@
"type": "thanks_dev"
}
],
- "time": "2025-12-08T14:27:58+00:00"
+ "time": "2026-07-27T10:28:41+00:00"
},
{
"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",
@@ -2752,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": {
@@ -2770,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",
@@ -2814,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",
diff --git a/js/frame.js b/js/frame.js
index e8596dca9..6fd62e269 100644
--- a/js/frame.js
+++ b/js/frame.js
@@ -479,6 +479,34 @@
init_db_import_component();
+ 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();
+
+ 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){
$('body').trigger('visualizer:db:query:update', {});
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/config/mu-plugins/enable-database-source.php b/tests/e2e/config/mu-plugins/enable-database-source.php
new file mode 100644
index 000000000..ecc22bb77
--- /dev/null
+++ b/tests/e2e/config/mu-plugins/enable-database-source.php
@@ -0,0 +1,26 @@
+\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/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/ai-builder-auth.spec.js b/tests/e2e/specs/ai-builder-auth.spec.js
new file mode 100644
index 000000000..2c1082cde
--- /dev/null
+++ b/tests/e2e/specs/ai-builder-auth.spec.js
@@ -0,0 +1,159 @@
+/**
+ * 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 contributorChartId;
+let contributorId;
+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).
+ *
+ * The context starts with an empty storage state: browser.newContext()
+ * otherwise inherits the project's admin cookies, so a login that silently
+ * failed would leave these requests running as admin. That also means no
+ * wordpress_test_cookie, hence no `testcookie` field in the form.
+ */
+async function loginAs( browser, baseURL, credentials ) {
+ const context = await browser.newContext( {
+ baseURL,
+ storageState: { cookies: [], origins: [] },
+ } );
+ const response = await context.request.post( '/wp-login.php', {
+ form: {
+ log: credentials.username,
+ pwd: credentials.password,
+ 'wp-submit': 'Log In',
+ },
+ maxRedirects: 0,
+ } );
+ if ( response.status() !== 302 ) {
+ throw new Error( `Login as ${ credentials.username } failed with status ${ response.status() }` );
+ }
+ const page = await context.newPage();
+ 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 } ) => {
+ for ( const id of [ adminChartId, contributorChartId ] ) {
+ if ( id ) {
+ await requestUtils.rest( { method: 'DELETE', path: `/wp/v2/visualizer/${ id }`, 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 );
+ contributorChartId = created.body.data.chart_id;
+
+ const fetched = await aiAjax( page, 'visualizer-ai-fetch', { nonce, chart_id: contributorChartId } );
+ 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();
+ } );
+} );
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