diff --git a/admin/class-enqueue-admin.php b/admin/class-enqueue-admin.php index 137374432..4419613b0 100644 --- a/admin/class-enqueue-admin.php +++ b/admin/class-enqueue-admin.php @@ -87,7 +87,8 @@ public static function maybe_enqueue_admin_and_editor_app_scripts() { [ 'postID' => $post_id, 'nonce' => wp_create_nonce( 'ajax-nonce' ), - 'edacApiUrl' => esc_url_raw( rest_url() . 'accessibility-checker/v1' ), + 'edacApiUrl' => esc_url_raw( rest_url( 'accessibility-checker/v1' ) ), + 'fixesRestUrl' => esc_url_raw( rest_url( 'edac/v1' ) ), 'restNonce' => wp_create_nonce( 'wp_rest' ), 'proUrl' => esc_url_raw( edac_generate_link_type( [ 'utm_content' => '__name__' ] ) ), 'hasDismissEndpoint' => method_exists( \EDAC\Inc\REST_Api::class, 'dismiss_issue' ), diff --git a/includes/classes/class-enqueue-frontend.php b/includes/classes/class-enqueue-frontend.php index 64f349526..f1b39017e 100644 --- a/includes/classes/class-enqueue-frontend.php +++ b/includes/classes/class-enqueue-frontend.php @@ -156,6 +156,8 @@ public static function maybe_enqueue_frontend_highlighter() { 'isPro' => edac_is_pro(), 'userCanEdit' => current_user_can( 'edit_post', $post_id ), 'edacUrl' => esc_url_raw( get_site_url() ), + 'restUrl' => esc_url_raw( rest_url( 'accessibility-checker/v1' ) ), + 'fixesRestUrl' => esc_url_raw( rest_url( 'edac/v1' ) ), 'ajaxurl' => admin_url( 'admin-ajax.php' ), 'loggedIn' => is_user_logged_in(), 'appCssUrl' => EDAC_PLUGIN_URL . 'build/css/frontendHighlighterApp.css?ver=' . EDAC_VERSION, diff --git a/src/common/saveFixSettingsRest.js b/src/common/saveFixSettingsRest.js index 2394133a6..d9ee46002 100644 --- a/src/common/saveFixSettingsRest.js +++ b/src/common/saveFixSettingsRest.js @@ -41,8 +41,22 @@ export const saveFixSettings = ( fixSettingsContainer ) => { liveRegion.innerText = __( 'Saving...', 'accessibility-checker' ); } + const fixesRestUrl = window.edacFrontendHighlighterApp?.fixesRestUrl ?? window.edac_script_vars?.fixesRestUrl; + + if ( ! fixesRestUrl ) { + fixSettingsContainer.classList.remove( 'edac-fix-settings--saving' ); + fixButtons.forEach( ( button ) => { + button.disabled = false; + } ); + fixSettingsContainer.classList.add( 'edac-fix-settings--saved--error' ); + if ( liveRegion ) { + liveRegion.innerText = __( 'Saving failed: Missing REST API URL.', 'accessibility-checker' ); + } + return; + } + // make a rest call to save the settings - fetch( '/wp-json/edac/v1/fixes/update/', { + fetch( `${ fixesRestUrl }/fixes/update/`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/src/frontendHighlighterApp/index.js b/src/frontendHighlighterApp/index.js index 8a091c3e6..4ce317cb4 100644 --- a/src/frontendHighlighterApp/index.js +++ b/src/frontendHighlighterApp/index.js @@ -1863,7 +1863,11 @@ class AccessibilityCheckerHighlight { saveScanResults( postId, nonce, violations, densityMetrics ) { const self = this; - return fetch( '/wp-json/accessibility-checker/v1/post-scan-results/' + postId, { + const restUrl = window.edacFrontendHighlighterApp?.restUrl; + if ( ! restUrl ) { + return Promise.reject( new Error( 'Missing REST API URL.' ) ); + } + return fetch( `${ restUrl }/post-scan-results/${ postId }`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -1943,7 +1947,7 @@ class AccessibilityCheckerHighlight { } // Validate required parameters - if ( ! edacFrontendHighlighterApp?.edacUrl || ! edacFrontendHighlighterApp?.postID ) { + if ( ! window.edacFrontendHighlighterApp?.restUrl || ! window.edacFrontendHighlighterApp?.postID ) { const summary = document.querySelector( '.edac-highlight-panel-controls-summary' ); if ( summary ) { summary.textContent = __( 'Error: Missing required parameters.', 'accessibility-checker' ); @@ -1956,7 +1960,7 @@ class AccessibilityCheckerHighlight { this.clearIssuesButton.textContent = __( 'Clearing...', 'accessibility-checker' ); const summary = document.querySelector( '.edac-highlight-panel-controls-summary' ); - fetch( `${ edacFrontendHighlighterApp.edacUrl }/wp-json/accessibility-checker/v1/clear-issues/${ edacFrontendHighlighterApp.postID }`, { + fetch( `${ window.edacFrontendHighlighterApp.restUrl }/clear-issues/${ window.edacFrontendHighlighterApp.postID }`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/tests/phpunit/Admin/EnqueueAdminTest.php b/tests/phpunit/Admin/EnqueueAdminTest.php index 946529035..1bd6de06a 100644 --- a/tests/phpunit/Admin/EnqueueAdminTest.php +++ b/tests/phpunit/Admin/EnqueueAdminTest.php @@ -111,6 +111,76 @@ public function testLocalizedProUrlUsesUnderscoreUtmContentKey() { $this->assertStringNotContainsString( 'utm-content=__name__', $localized_data ); } + /** + * FixesRestUrl is present in the edac_script_vars localized to the admin script. + */ + public function testLocalizedAdminDataIncludesFixesRestUrl(): void { + global $wp_scripts; + + $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts(); + + $localized_data = (string) $wp_scripts->get_data( 'edac', 'data' ); + + $this->assertNotEmpty( $localized_data ); + $this->assertStringContainsString( 'fixesRestUrl', $localized_data ); + } + + /** + * FixesRestUrl uses the edac/v1 namespace and matches rest_url(). + */ + public function testAdminFixesRestUrlContainsEdacV1Namespace(): void { + global $wp_scripts; + + $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts(); + + $localized_data = (string) $wp_scripts->get_data( 'edac', 'data' ); + $expected = rest_url( 'edac/v1' ); + + $this->assertStringContainsString( 'edac', $localized_data ); + $this->assertStringContainsString( (string) wp_parse_url( $expected, PHP_URL_HOST ), $localized_data ); + } + + /** + * FixesRestUrl must be an absolute URL — a root-relative /wp-json path would break + * subdomain multisite installs by resolving to the main site instead of the subsite. + */ + public function testAdminFixesRestUrlIsAbsolute(): void { + global $wp_scripts; + + $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts(); + + $localized_data = (string) $wp_scripts->get_data( 'edac', 'data' ); + + $this->assertDoesNotMatchRegularExpression( '/"fixesRestUrl"\s*:\s*"\\\\?\/wp-json/', $localized_data ); + $this->assertMatchesRegularExpression( '/"fixesRestUrl"\s*:\s*"https?/', $localized_data ); + } + + /** + * FixesRestUrl in edac_script_vars must follow a custom REST base prefix set via + * the rest_url_prefix filter. Verifies the URL is built with rest_url() rather than + * a hardcoded /wp-json/ string. + * Pretty permalinks are required for the prefix filter to be applied. + */ + public function testAdminFixesRestUrlRespectsCustomRestPrefix(): void { + global $wp_scripts; + + update_option( 'permalink_structure', '/%postname%/' ); + flush_rewrite_rules(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules + + $prefix_callback = static fn() => 'custom-api'; + add_filter( 'rest_url_prefix', $prefix_callback ); + + $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts(); + + remove_filter( 'rest_url_prefix', $prefix_callback ); + delete_option( 'permalink_structure' ); + + $localized_data = (string) $wp_scripts->get_data( 'edac', 'data' ); + + $this->assertStringContainsString( 'custom-api', $localized_data ); + $this->assertStringNotContainsString( 'wp-json', $localized_data ); + } + /** * Test that the base script and editor script is enqueued in the editor for an existing page. * diff --git a/tests/phpunit/includes/classes/EnqueueFrontendTest.php b/tests/phpunit/includes/classes/EnqueueFrontendTest.php index c28c34c23..0e73e0857 100644 --- a/tests/phpunit/includes/classes/EnqueueFrontendTest.php +++ b/tests/phpunit/includes/classes/EnqueueFrontendTest.php @@ -73,6 +73,132 @@ public function testScannerBundleUrlIncludesVersionQueryString(): void { $this->assertStringContainsString( 'ver=' . EDAC_VERSION, $localized_data ); } + /** + * Helper: enqueue the frontend highlighter as an admin and return the localized data string. + * + * @return string The raw JS localized-data string for edac-frontend-highlighter-app. + */ + private function enqueueAndGetLocalizedData(): string { + $admin_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_id ); + + global $post; + $post = $this->factory()->post->create_and_get( [ 'post_type' => 'post' ] ); + + Enqueue_Frontend::maybe_enqueue_frontend_highlighter(); + + global $wp_scripts; + return (string) $wp_scripts->get_data( 'edac-frontend-highlighter-app', 'data' ); + } + + /** + * RestUrl is present in the localized data passed to the frontend highlighter script. + */ + public function testLocalizedDataIncludesRestUrl(): void { + $localized_data = $this->enqueueAndGetLocalizedData(); + + $this->assertNotEmpty( $localized_data ); + $this->assertStringContainsString( 'restUrl', $localized_data ); + } + + /** + * RestUrl uses the accessibility-checker/v1 namespace and matches rest_url(). + */ + public function testRestUrlMatchesRestUrlFunction(): void { + $localized_data = $this->enqueueAndGetLocalizedData(); + $expected = rest_url( 'accessibility-checker/v1' ); + + $this->assertStringContainsString( 'accessibility-checker', $localized_data ); + $this->assertStringContainsString( 'v1', $localized_data ); + // The URL must be derived from rest_url(), not hardcoded — verify the host is present. + $this->assertStringContainsString( (string) wp_parse_url( $expected, PHP_URL_HOST ), $localized_data ); + } + + /** + * RestUrl must be an absolute URL, not a root-relative path like /wp-json/... + * A root-relative URL on a subdomain multisite would resolve to the main site. + */ + public function testRestUrlIsAbsolute(): void { + $localized_data = $this->enqueueAndGetLocalizedData(); + + // The value following "restUrl" must not be a bare /wp-json path. + $this->assertDoesNotMatchRegularExpression( '/"restUrl"\s*:\s*"\\\\?\/wp-json/', $localized_data ); + // And the scheme must be present. + $this->assertMatchesRegularExpression( '/"restUrl"\s*:\s*"https?/', $localized_data ); + } + + /** + * FixesRestUrl is present in the localized data passed to the frontend highlighter script. + */ + public function testLocalizedDataIncludesFixesRestUrl(): void { + $localized_data = $this->enqueueAndGetLocalizedData(); + + $this->assertStringContainsString( 'fixesRestUrl', $localized_data ); + } + + /** + * FixesRestUrl uses the edac/v1 namespace and matches rest_url(). + */ + public function testFixesRestUrlContainsEdacV1Namespace(): void { + $localized_data = $this->enqueueAndGetLocalizedData(); + $expected = rest_url( 'edac/v1' ); + + $this->assertStringContainsString( 'edac', $localized_data ); + $this->assertStringContainsString( (string) wp_parse_url( $expected, PHP_URL_HOST ), $localized_data ); + } + + /** + * FixesRestUrl must be an absolute URL, not a root-relative path. + */ + public function testFixesRestUrlIsAbsolute(): void { + $localized_data = $this->enqueueAndGetLocalizedData(); + + $this->assertDoesNotMatchRegularExpression( '/"fixesRestUrl"\s*:\s*"\\\\?\/wp-json/', $localized_data ); + $this->assertMatchesRegularExpression( '/"fixesRestUrl"\s*:\s*"https?/', $localized_data ); + } + + /** + * RestUrl must follow a custom REST base prefix set via the rest_url_prefix filter. + * Verifies the URL is built with rest_url() rather than a hardcoded /wp-json/ string. + * Pretty permalinks are required for the prefix filter to be applied. + */ + public function testRestUrlRespectsCustomRestPrefix(): void { + update_option( 'permalink_structure', '/%postname%/' ); + flush_rewrite_rules(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules + + $prefix_callback = static fn() => 'custom-api'; + add_filter( 'rest_url_prefix', $prefix_callback ); + $this->added_filters['rest_url_prefix'] = $prefix_callback; + + $localized_data = $this->enqueueAndGetLocalizedData(); + + remove_filter( 'rest_url_prefix', $prefix_callback ); + delete_option( 'permalink_structure' ); + + $this->assertStringContainsString( 'custom-api', $localized_data ); + $this->assertStringNotContainsString( 'wp-json', $localized_data ); + } + + /** + * FixesRestUrl must follow a custom REST base prefix set via the rest_url_prefix filter. + */ + public function testFixesRestUrlRespectsCustomRestPrefix(): void { + update_option( 'permalink_structure', '/%postname%/' ); + flush_rewrite_rules(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules + + $prefix_callback = static fn() => 'custom-api'; + add_filter( 'rest_url_prefix', $prefix_callback ); + $this->added_filters['rest_url_prefix'] = $prefix_callback; + + $localized_data = $this->enqueueAndGetLocalizedData(); + + remove_filter( 'rest_url_prefix', $prefix_callback ); + delete_option( 'permalink_structure' ); + + $this->assertStringContainsString( 'custom-api', $localized_data ); + $this->assertStringNotContainsString( 'wp-json', $localized_data ); + } + /** * Ensure the highlighter uses the filtered post ID when determining scannable post types. */