diff --git a/accessibility-checker.php b/accessibility-checker.php index d7b477c3a..d6b406194 100755 --- a/accessibility-checker.php +++ b/accessibility-checker.php @@ -10,7 +10,7 @@ * Plugin Name: Accessibility Checker * Plugin URI: https://equalizedigital.com/accessibility-checker * Description: Audit and check your website for accessibility before you hit publish. In-post accessibility scanner and guidance. - * Version: 1.46.0 + * Version: 1.47.0 * Requires PHP: 7.4 * Author: Equalize Digital * Author URI: https://equalizedigital.com @@ -36,7 +36,7 @@ // Current plugin version. if ( ! defined( 'EDAC_VERSION' ) ) { - define( 'EDAC_VERSION', '1.46.0' ); + define( 'EDAC_VERSION', '1.47.0' ); } // Current database version. diff --git a/admin/class-ajax.php b/admin/class-ajax.php index 3f34ddbe0..2bb38a891 100644 --- a/admin/class-ajax.php +++ b/admin/class-ajax.php @@ -526,7 +526,15 @@ function ( $a, $b ) { ) ) . '" />'; } elseif ( $object_svg ) { - $html .= $object_svg; + // Rendered as an via a data URI, not injected as inline markup - + // see edac_svg_markup_to_data_uri()'s docblock for why. + $html .= '' . esc_attr(
+								sprintf(
+									/* translators: %d: issue ID number */
+									__( 'image for issue %d', 'accessibility-checker' ),
+									$id
+								)
+							) . ''; } $html .= ''; diff --git a/admin/class-enqueue-admin.php b/admin/class-enqueue-admin.php index 46eb51590..fcc888026 100644 --- a/admin/class-enqueue-admin.php +++ b/admin/class-enqueue-admin.php @@ -78,6 +78,11 @@ public static function maybe_enqueue_admin_and_editor_app_scripts() { global $post; $post_id = is_object( $post ) ? $post->ID : null; + + // On a latest-posts homepage the global $post is the first blog post, not the page; + // let extensions supply the correct ID (e.g. a Pro virtual-page ID). + $post_id = apply_filters( 'edac_filter_admin_post_id', $post_id ); + wp_enqueue_script( 'edac', plugin_dir_url( EDAC_PLUGIN_FILE ) . 'build/admin.bundle.js', [ 'jquery' ], EDAC_VERSION, false ); wp_set_script_translations( 'edac', 'accessibility-checker', plugin_dir_path( EDAC_PLUGIN_FILE ) . 'languages' ); @@ -99,8 +104,9 @@ public static function maybe_enqueue_admin_and_editor_app_scripts() { if ( 'post.php' === $pagenow || 'post-new.php' === $pagenow ) { - // Is this posttype setup to be checked? - $active = $is_scannable_post; + // Base the scannable check on the filtered $post_id, not the original global $post. + $filtered_post_type = $post_id ? get_post_type( $post_id ) : false; + $active = $filtered_post_type && is_array( $post_types ) && in_array( $filtered_post_type, $post_types, true ); $pro = defined( 'EDACP_VERSION' ) && EDAC_KEY_VALID; @@ -113,8 +119,15 @@ public static function maybe_enqueue_admin_and_editor_app_scripts() { wp_enqueue_script( 'edac-editor-app', plugin_dir_url( EDAC_PLUGIN_FILE ) . 'build/editorApp.bundle.js', false, EDAC_VERSION, false ); wp_set_script_translations( 'edac-editor-app', 'accessibility-checker', plugin_dir_path( EDAC_PLUGIN_FILE ) . 'languages' ); - // If this is the frontpage or homepage, preview URLs won't work. Use the live URL. - if ( (int) get_option( 'page_on_front' ) === $post_id || (int) get_option( 'page_for_posts' ) === $post_id ) { + // Preview URLs don't work for the homepage. On a latest-posts homepage (including the + // show_on_front=page fallback with no static front page) use the live home URL instead. + $show_on_front = get_option( 'show_on_front', 'posts' ); + $is_latest_posts_home = ( 'posts' === $show_on_front || ( 'page' === $show_on_front && ! get_option( 'page_on_front' ) ) ) + && apply_filters( 'edac_filter_post_is_latest_posts_home', false, $post_id ); + + if ( $is_latest_posts_home ) { + $scan_url = add_query_arg( 'edac_pageScanner', 1, trailingslashit( get_home_url() ) ); + } elseif ( (int) get_option( 'page_on_front' ) === $post_id || (int) get_option( 'page_for_posts' ) === $post_id ) { $scan_url = add_query_arg( 'edac_pageScanner', 1, get_permalink( $post_id ) ); } else { $post_view_link = apply_filters( diff --git a/admin/class-helpers.php b/admin/class-helpers.php index 70b67db3e..3d700fe4d 100644 --- a/admin/class-helpers.php +++ b/admin/class-helpers.php @@ -139,54 +139,6 @@ public static function get_option_as_array( $option_name ) { return []; } - - /** - * Determine if a domain is hosted on a local loopback - * - * @param string $domain The domain to check. - * @return boolean - */ - public static function is_domain_loopback( $domain ) { - - // Check if this is an ipv4 address in the loopback range. - - $record = gethostbyname( $domain ); - $loopback_start = ip2long( '127.0.0.0' ); - $loopback_end = ip2long( '127.255.255.255' ); - $ip_long = ip2long( $record ); - - if ( $ip_long >= $loopback_start && $ip_long <= $loopback_end ) { - return true; - } - - // Check if this is an ipv6 loopback. - - try { - $records = dns_get_record( $domain, DNS_AAAA ); - } catch ( \Throwable $th ) { - return false; - } - - foreach ( $records as $record ) { - - // Do ipv6 check. - if ( isset( $record['type'] ) && 'AAAA' === $record['type'] ) { - - // Normalize the IPv6 address for comparison. - $normalized_ipv6 = inet_pton( $record['ipv6'] ); - - // Normalize the loopback address. - $loopback_ipv6 = inet_pton( '::1' ); - - if ( $normalized_ipv6 === $loopback_ipv6 ) { - return true; - } - } - } - - return false; - } - /** * Filter out inactive rules from the results returned. * diff --git a/changelog.txt b/changelog.txt index 93716661c..09348ae3b 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,15 @@ *** Accessibility Checker *** +2026-07-14 - version 1.47.0 +* Updated - the frontend highlighter now draws a white ring around the outline so highlighted elements stay visible on any background color. +* Updated - the empty alt text check no longer flags 1x1 tracking pixels. +* Updated - the link protocol checks now handle href values that contain leading whitespace. +* Updated - SVG code snippets in the Accessibility Analysis panel are now rendered as images instead of raw markup. +* Fix - scan results for the homepage are now stored against the homepage instead of the first blog post when the site is set to show latest posts. +* Fix - the Simplified Summary text areas in the editor sidebar now have a label that assistive technology can announce. +* Fix - corrected a caching issue that could cause database table name lookups to return the wrong table. +* Remove - deleted an unused internal helper function. + 2026-07-08 - version 1.46.0 * Updated - the aria-hidden rule no longer flags core Cover blocks. * Updated - the Incorrect Heading Order summary no longer claims specific heading levels that may not match the actual issue. diff --git a/includes/classes/class-enqueue-frontend.php b/includes/classes/class-enqueue-frontend.php index ace4787f3..a2f39efda 100644 --- a/includes/classes/class-enqueue-frontend.php +++ b/includes/classes/class-enqueue-frontend.php @@ -102,7 +102,14 @@ public static function maybe_enqueue_frontend_highlighter() { // Don't load on the frontend if we don't have a post to work with. global $post; - $post_id = apply_filters( 'edac_filter_frontend_highlight_post_id', is_object( $post ) ? $post->ID : null ); + + // On a latest-posts homepage the global $post is the first blog post, so using its ID + // would misattribute results; pass null and let the filter supply an ID (Pro) or bail. + $default_post_id = ( is_home() && is_front_page() ) + ? null + : ( is_object( $post ) ? $post->ID : null ); + + $post_id = apply_filters( 'edac_filter_frontend_highlight_post_id', $default_post_id ); if ( null === $post_id ) { return; diff --git a/includes/helper-functions.php b/includes/helper-functions.php index 7401ed93b..cb51b374b 100644 --- a/includes/helper-functions.php +++ b/includes/helper-functions.php @@ -248,10 +248,10 @@ function edac_get_post_type_label( string $post_type ): string { */ function edac_get_valid_table_name( $table_name ) { global $wpdb; - static $found_table_name; + static $found_table_names = []; - if ( isset( $found_table_name ) ) { - return $found_table_name; + if ( isset( $found_table_names[ $table_name ] ) ) { + return $found_table_names[ $table_name ]; } // Check if table name only contains alphanumeric characters, underscores, or hyphens. @@ -267,8 +267,8 @@ function edac_get_valid_table_name( $table_name ) { return null; } - $found_table_name = $table_name; - return $found_table_name; + $found_table_names[ $table_name ] = $table_name; + return $table_name; } /** @@ -788,6 +788,27 @@ function edac_parse_html_for_media( $html ) { ]; } +/** + * Convert raw SVG markup into a data: URI, safe as an src - browsers + * don't execute scripts or event handlers in SVGs loaded as images. Returns + * a bare (payload-less) data URI if given anything other than a string. + * + * @since 1.47.0 + * + * @param mixed $svg_markup Raw SVG markup - expected to be a string. + * @return string Unescaped data URI - callers must esc_url() it before output, + * passing a protocols list that includes 'data' (e.g. + * esc_url( $uri, [ 'data', 'http', 'https' ] )); with the + * default protocols esc_url() rejects data: URIs and returns ''. + */ +function edac_svg_markup_to_data_uri( $svg_markup ): string { + if ( ! is_string( $svg_markup ) ) { + return 'data:image/svg+xml,'; + } + + return 'data:image/svg+xml,' . rawurlencode( $svg_markup ); +} + /** * Remove corrected posts * diff --git a/package.json b/package.json index 093cd052a..0cb1e6217 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "accessibility-checker", - "version": "1.46.0", + "version": "1.47.0", "description": "Audit and check your website for accessibility before you hit publish. In-post accessibility scanner and guidance.", "author": "Equalize Digital", "license": "GPL-2.0+", diff --git a/readme.txt b/readme.txt index c16fea1d7..8854adaec 100644 --- a/readme.txt +++ b/readme.txt @@ -1,9 +1,9 @@ === Equalize Digital Accessibility Checker - WCAG, ADA, EAA and Section 508 compliance === Contributors: equalizedigital, alh0319, stevejonesdev Tags: accessibility, EAA, WCAG, ADA, WP accessibility -Requires at least: 6.7 +Requires at least: 6.8 Tested up to: 7.0 -Stable tag: 1.46.0 +Stable tag: 1.47.0 Requires PHP: 7.4 License: GPL-2.0-or-later License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -279,6 +279,16 @@ You can report security bugs through the Patchstack Vulnerability Disclosure Pro == Changelog == +2026-07-14 - version 1.47.0 +* Updated - the frontend highlighter now draws a white ring around the outline so highlighted elements stay visible on any background color. +* Updated - the empty alt text check no longer flags 1x1 tracking pixels. +* Updated - the link protocol checks now handle href values that contain leading whitespace. +* Updated - SVG code snippets in the Accessibility Analysis panel are now rendered as images instead of raw markup. +* Fix - scan results for the homepage are now stored against the homepage instead of the first blog post when the site is set to show latest posts. +* Fix - the Simplified Summary text areas in the editor sidebar now have a label that assistive technology can announce. +* Fix - corrected a caching issue that could cause database table name lookups to return the wrong table. +* Remove - deleted an unused internal helper function. + 2026-07-08 - version 1.46.0 * Updated - the aria-hidden rule no longer flags core Cover blocks. * Updated - the Incorrect Heading Order summary no longer claims specific heading levels that may not match the actual issue. diff --git a/src/frontendHighlighterApp/sass/app.scss b/src/frontendHighlighterApp/sass/app.scss index db48f0d6d..97fbb1803 100644 --- a/src/frontendHighlighterApp/sass/app.scss +++ b/src/frontendHighlighterApp/sass/app.scss @@ -49,8 +49,9 @@ body { &-element-selected { outline: dashed 4px transparent !important; - outline-offset: 5px !important; + outline-offset: 2px !important; outline-color: magenta !important; + box-shadow: 0 0 0 8px variables.$color-white !important; &-min-width { min-width: 25px !important; diff --git a/src/pageScanner/checks/img-alt-empty-check.js b/src/pageScanner/checks/img-alt-empty-check.js index b49ae1323..dd4ac40f9 100644 --- a/src/pageScanner/checks/img-alt-empty-check.js +++ b/src/pageScanner/checks/img-alt-empty-check.js @@ -24,6 +24,11 @@ export default { return true; } + // Skip 1x1 tracking pixels (any src or base64) + if ( hasEmptyAlt && isTrackingPixel( node ) ) { + return true; + } + // Return false if alt is empty and none of the exceptions apply return ! hasEmptyAlt; }, @@ -84,6 +89,33 @@ function isInsideValidCaption( node ) { return false; } +/** + * Check if image is a 1x1 tracking pixel. + * Checks both HTML attributes and computed natural dimensions. + * @param {HTMLElement} node - The node to check + * @return {boolean} True if image is a 1x1 tracking pixel + */ +function isTrackingPixel( node ) { + // Check HTML width/height attributes + const widthAttr = node.getAttribute( 'width' ); + const heightAttr = node.getAttribute( 'height' ); + if ( widthAttr === '1' && heightAttr === '1' ) { + return true; + } + + // Check computed natural dimensions (e.g. for base64 or loaded images without explicit attributes) + if ( + typeof node.naturalWidth === 'number' && + typeof node.naturalHeight === 'number' && + node.naturalWidth === 1 && + node.naturalHeight === 1 + ) { + return true; + } + + return false; +} + /** * Check if image should be ignored due to plugin-specific cases * @param {HTMLElement} node - The node to check diff --git a/src/pageScanner/checks/link-has-valid-href-or-role.js b/src/pageScanner/checks/link-has-valid-href-or-role.js index 7cdce72b5..19070e5b2 100644 --- a/src/pageScanner/checks/link-has-valid-href-or-role.js +++ b/src/pageScanner/checks/link-has-valid-href-or-role.js @@ -50,21 +50,22 @@ export default { } const trimmedHref = href ? href.trim() : ''; + const normalizedHref = trimmedHref.toLowerCase(); // Fail if href is missing, just '#', or contains invalid protocols - if ( ! href || + if ( ! trimmedHref || trimmedHref === '#' || - href.toLowerCase().startsWith( 'javascript:' ) || - href.toLowerCase().startsWith( 'data:' ) || - href.toLowerCase().startsWith( 'file:' ) + normalizedHref.startsWith( 'javascript:' ) || + normalizedHref.startsWith( 'data:' ) || + normalizedHref.startsWith( 'file:' ) ) { return false; } // Optionally validate URL format if it's an absolute URL - if ( href.includes( '://' ) ) { + if ( trimmedHref.includes( '://' ) ) { try { - new URL( href ); + new URL( trimmedHref ); } catch ( e ) { return false; // Invalid URL formats } diff --git a/src/sidebar/components/Panels/ReadabilityAnalysis.js b/src/sidebar/components/Panels/ReadabilityAnalysis.js index 120d08d1e..1c1e2cac0 100644 --- a/src/sidebar/components/Panels/ReadabilityAnalysis.js +++ b/src/sidebar/components/Panels/ReadabilityAnalysis.js @@ -362,6 +362,8 @@ const ReadabilityAnalysis = () => { { __( 'Simplified Summary', 'accessibility-checker' ) } { { __( 'Simplified Summary', 'accessibility-checker' ) } { shouldPass: true, }, + // Tracking pixel (1x1) edge cases + { + name: 'should pass for 1x1 tracking pixel with empty alt (gif)', + html: '', + shouldPass: true, + }, + { + name: 'should pass for 1x1 tracking pixel with empty alt (arbitrary src)', + html: '', + shouldPass: true, + }, + { + name: 'should fail for 2x1 image with empty alt (not a tracking pixel)', + html: '', + shouldPass: false, + }, + { + name: 'should fail for img with empty alt and no dimension attributes (cannot confirm tracking pixel without dimensions)', + html: '', + shouldPass: false, + }, + // Button context - image with empty alt inside button with accessible name { name: 'should pass for img with empty alt inside button with aria-label', diff --git a/tests/jest/rules/linkImproper.test.js b/tests/jest/rules/linkImproper.test.js index a4381598f..ee9248b35 100644 --- a/tests/jest/rules/linkImproper.test.js +++ b/tests/jest/rules/linkImproper.test.js @@ -39,6 +39,21 @@ describe( 'Link Improper Rule', () => { html: 'Bad practice', shouldPass: false, }, + { + name: 'Fails when anchor has javascript: href with leading whitespace', + html: 'Bad practice', + shouldPass: false, + }, + { + name: 'Fails when anchor href contains only whitespace', + html: 'Whitespace link', + shouldPass: false, + }, + { + name: 'Fails when anchor href contains only whitespace', + html: 'Whitespace link', + shouldPass: false, + }, { name: 'Fails when anchor has malformed URL', html: 'Invalid URL', diff --git a/tests/phpunit/Admin/EnqueueAdminTest.php b/tests/phpunit/Admin/EnqueueAdminTest.php index 09186d0be..b08173897 100644 --- a/tests/phpunit/Admin/EnqueueAdminTest.php +++ b/tests/phpunit/Admin/EnqueueAdminTest.php @@ -461,6 +461,147 @@ public function testSrOnlyFormatDoesNotEnqueueOnOtherAdminPages() { } + /** + * Test that edac_filter_admin_post_id overrides the post ID localized into edac_script_vars. + * + * @return void + */ + public function testAdminPostIdFilterOverridesLocalizedPostId() { + global $post, $pagenow, $wp_scripts; + + $original_post = $this->factory()->post->create_and_get(); + $alternate_post = $this->factory()->post->create_and_get(); + $post = $original_post; + $pagenow = 'post.php'; + + $filter_callback = static function () use ( $alternate_post ) { + return $alternate_post->ID; + }; + add_filter( 'edac_filter_admin_post_id', $filter_callback ); + + $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts(); + + remove_filter( 'edac_filter_admin_post_id', $filter_callback ); + + $localized_data = $wp_scripts->get_data( 'edac', 'data' ); + $this->assertStringContainsString( (string) $alternate_post->ID, $localized_data ); + } + + /** + * When the filter flags a latest-posts homepage (show_on_front=posts), the scan URL + * uses get_home_url() rather than an invalid preview link. + * + * @return void + */ + public function testScanUrlUsesHomeUrlWhenLatestPostsHomeFilterReturnsTrue() { + global $post, $pagenow, $wp_scripts; + + $post = $this->factory()->post->create_and_get( [ 'post_type' => 'page' ] ); + $pagenow = 'post.php'; + + update_option( 'show_on_front', 'posts' ); + update_option( 'page_for_posts', 0 ); + + $filter_callback = static function () { + return true; + }; + add_filter( 'edac_filter_post_is_latest_posts_home', $filter_callback ); + + $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts(); + + remove_filter( 'edac_filter_post_is_latest_posts_home', $filter_callback ); + delete_option( 'show_on_front' ); + delete_option( 'page_for_posts' ); + + $localized_data = $wp_scripts->get_data( 'edac-editor-app', 'data' ); + $this->assertStringContainsString( 'edac_pageScanner', $localized_data ); + $this->assertStringNotContainsString( 'preview=true', $localized_data ); + // The scan URL should be based on the home URL, not a preview link. + // In WP 6.9 forward slashes are no longer escaped in json_encode output; handle both forms. + // See: https://github.com/WordPress/wordpress-develop/pull/9557. + $expected_home = esc_url_raw( trailingslashit( get_home_url() ) ); + if ( version_compare( get_bloginfo( 'version' ), '6.9', '<' ) ) { + $expected_home = str_replace( '/', '\\/', $expected_home ); + } + $this->assertStringContainsString( $expected_home, $localized_data ); + } + + /** + * Fallback case: show_on_front=page with no static front page configured also + * counts as a latest-posts homepage, so the scan URL still uses get_home_url(). + * + * @return void + */ + public function testScanUrlUsesHomeUrlWhenShowOnFrontIsPageWithNoFrontPageConfigured() { + global $post, $pagenow, $wp_scripts; + + $post = $this->factory()->post->create_and_get( [ 'post_type' => 'page' ] ); + $pagenow = 'post.php'; + + update_option( 'show_on_front', 'page' ); + delete_option( 'page_on_front' ); // No static front page configured — WP falls back to latest posts. + + $filter_callback = static function () { + return true; + }; + add_filter( 'edac_filter_post_is_latest_posts_home', $filter_callback ); + + $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts(); + + remove_filter( 'edac_filter_post_is_latest_posts_home', $filter_callback ); + delete_option( 'show_on_front' ); + + $localized_data = $wp_scripts->get_data( 'edac-editor-app', 'data' ); + $this->assertStringContainsString( 'edac_pageScanner', $localized_data ); + $this->assertStringNotContainsString( 'preview=true', $localized_data ); + // The scan URL should be based on the home URL, not a preview link. + // In WP 6.9 forward slashes are no longer escaped in json_encode output; handle both forms. + // See: https://github.com/WordPress/wordpress-develop/pull/9557. + $expected_home = esc_url_raw( trailingslashit( get_home_url() ) ); + if ( version_compare( get_bloginfo( 'version' ), '6.9', '<' ) ) { + $expected_home = str_replace( '/', '\\/', $expected_home ); + } + $this->assertStringContainsString( $expected_home, $localized_data ); + } + + /** + * The 'active' flag must reflect the filtered post ID's type: when the filter returns a + * non-scannable post type, $active is false so the scanner doesn't run. + * + * @return void + */ + public function testActiveReflectsFilteredPostIdPostType() { + global $post, $pagenow, $wp_scripts; + + // Global $post is a 'post' type (scannable under current option). + $scannable_post = $this->factory()->post->create_and_get( [ 'post_type' => 'post' ] ); + // The filter will return a 'page' ID; make 'page' non-scannable for this test. + $non_scannable_post = $this->factory()->post->create_and_get( [ 'post_type' => 'page' ] ); + $post = $scannable_post; + $pagenow = 'post.php'; + + // Restrict scannable types to 'post' only so 'page' becomes non-scannable. + update_option( 'edac_post_types', [ 'post' ] ); + + $filter_callback = static function () use ( $non_scannable_post ) { + return $non_scannable_post->ID; + }; + add_filter( 'edac_filter_admin_post_id', $filter_callback ); + + $this->enqueue_admin::maybe_enqueue_admin_and_editor_app_scripts(); + + remove_filter( 'edac_filter_admin_post_id', $filter_callback ); + // Restore original scannable post types. + update_option( 'edac_post_types', [ 'post', 'page' ] ); + + $localized_data = $wp_scripts->get_data( 'edac-editor-app', 'data' ); + $this->assertNotEmpty( $localized_data ); + // $active must be false because the filtered post type ('page') is not scannable. + // wp_localize_script serializes PHP false as "" (empty string) in older WP versions + // and may serialize it as JSON false in newer ones — accept both forms. + $this->assertMatchesRegularExpression( '/"active"\s*:\s*(false|"")/', $localized_data ); + } + /** * Helper to set a mock current screen with block editor context. * diff --git a/tests/phpunit/Admin/HelpersLoopbackTest.php b/tests/phpunit/Admin/HelpersLoopbackTest.php deleted file mode 100644 index 503b1159d..000000000 --- a/tests/phpunit/Admin/HelpersLoopbackTest.php +++ /dev/null @@ -1,245 +0,0 @@ -assertIsBool( $result ); - $this->assertSame( $expected, $result ); - } - - /** - * Data provider for test_is_domain_loopback. - */ - public function domain_loopback_data() { - return [ - 'localhost' => [ - 'domain' => 'localhost', - 'expected' => true, - ], - '127.0.0.1 direct IP' => [ - 'domain' => '127.0.0.1', - 'expected' => true, - ], - '127.0.0.2 loopback range' => [ - 'domain' => '127.0.0.2', - 'expected' => true, - ], - '127.255.255.255 loopback range end' => [ - 'domain' => '127.255.255.255', - 'expected' => true, - ], - '127.100.50.25 mid loopback range' => [ - 'domain' => '127.100.50.25', - 'expected' => true, - ], - 'google.com external domain' => [ - 'domain' => 'google.com', - 'expected' => false, - ], - 'example.com external domain' => [ - 'domain' => 'example.com', - 'expected' => false, - ], - '192.168.1.1 private IP (not loopback)' => [ - 'domain' => '192.168.1.1', - 'expected' => false, - ], - '10.0.0.1 private IP (not loopback)' => [ - 'domain' => '10.0.0.1', - 'expected' => false, - ], - '8.8.8.8 public IP' => [ - 'domain' => '8.8.8.8', - 'expected' => false, - ], - ]; - } - - /** - * Test IPv4 loopback range boundaries. - */ - public function test_ipv4_loopback_boundaries() { - // Test the exact start of loopback range. - $this->assertTrue( Helpers::is_domain_loopback( '127.0.0.0' ) ); - - // Test just before loopback range. - $this->assertFalse( Helpers::is_domain_loopback( '126.255.255.255' ) ); - - // Test just after loopback range. - $this->assertFalse( Helpers::is_domain_loopback( '128.0.0.0' ) ); - } - - /** - * Test edge cases and invalid inputs. - */ - public function test_edge_cases() { - // Empty string. - $result = Helpers::is_domain_loopback( '' ); - $this->assertIsBool( $result ); - $this->assertFalse( $result ); - - // Invalid domain format. - $result = Helpers::is_domain_loopback( 'not-a-domain' ); - $this->assertIsBool( $result ); - - // Malformed IP. - $result = Helpers::is_domain_loopback( '999.999.999.999' ); - $this->assertIsBool( $result ); - $this->assertFalse( $result ); - - // Domain with protocol. - $result = Helpers::is_domain_loopback( 'http://localhost' ); - $this->assertIsBool( $result ); - } - - /** - * Test special localhost variations. - */ - public function test_localhost_variations() { - // Standard localhost should resolve to loopback. - $this->assertTrue( Helpers::is_domain_loopback( 'localhost' ) ); - - // Test case sensitivity (if applicable). - $result = Helpers::is_domain_loopback( 'LOCALHOST' ); - $this->assertIsBool( $result ); - - // Test with port (should still work since we're testing the domain part). - $result = Helpers::is_domain_loopback( 'localhost:8080' ); - $this->assertIsBool( $result ); - } - - /** - * Test that the method handles DNS resolution errors gracefully. - */ - public function test_dns_resolution_error_handling() { - // Test with a clearly non-existent domain. - $fake_domain = 'definitely-not-a-real-domain-' . uniqid() . '.invalid'; - $result = Helpers::is_domain_loopback( $fake_domain ); - - // Should return a boolean and not throw an exception. - $this->assertIsBool( $result ); - $this->assertFalse( $result ); - } - - /** - * Test IPv6 loopback detection (if supported). - */ - public function test_ipv6_loopback_detection() { - // The method checks for IPv6 AAAA records. - // This test may be limited by the test environment's DNS capabilities. - - // Test direct IPv6 loopback address (may not work in all environments). - $result = Helpers::is_domain_loopback( '::1' ); - $this->assertIsBool( $result ); - - // If IPv6 is supported, ::1 should be detected as loopback. - // However, we can't guarantee this in all test environments. - } - - /** - * Test that the method properly validates IP address formats. - */ - public function test_ip_address_validation() { - // Valid IPv4 addresses in loopback range. - $valid_loopback_ips = [ - '127.0.0.1', - '127.1.2.3', - '127.254.255.254', - ]; - - foreach ( $valid_loopback_ips as $ip ) { - $result = Helpers::is_domain_loopback( $ip ); - $this->assertTrue( $result, "Failed to detect loopback for IP: $ip" ); - } - - // Valid IPv4 addresses outside loopback range. - $non_loopback_ips = [ - '1.1.1.1', - '8.8.8.8', - '192.168.1.1', - '172.16.0.1', - '203.0.113.1', - ]; - - foreach ( $non_loopback_ips as $ip ) { - $result = Helpers::is_domain_loopback( $ip ); - $this->assertFalse( $result, "Incorrectly detected loopback for IP: $ip" ); - } - } - - /** - * Test behavior with domains that might have multiple A records. - */ - public function test_multiple_a_records() { - // Some domains may have multiple A records. - // The method should handle this correctly by checking the resolved IP. - - // Test a well-known domain that should resolve to non-loopback. - $result = Helpers::is_domain_loopback( 'github.com' ); - $this->assertIsBool( $result ); - $this->assertFalse( $result ); - } - - /** - * Test the method's handling of the gethostbyname function. - */ - public function test_gethostbyname_behavior() { - // gethostbyname returns the hostname unchanged if resolution fails. - // The method should handle this case. - - $non_resolvable = 'non-resolvable-domain-' . uniqid() . '.invalid'; - $result = Helpers::is_domain_loopback( $non_resolvable ); - - // Should return false for non-resolvable domains. - $this->assertIsBool( $result ); - $this->assertFalse( $result ); - } - - /** - * Test performance with multiple calls. - */ - public function test_performance_multiple_calls() { - // Test that the method performs reasonably with multiple calls. - $domains = [ - 'localhost', - '127.0.0.1', - 'google.com', - 'example.com', - '127.0.0.2', - ]; - - $start_time = microtime( true ); - - foreach ( $domains as $domain ) { - $result = Helpers::is_domain_loopback( $domain ); - $this->assertIsBool( $result ); - } - - $end_time = microtime( true ); - $execution_time = $end_time - $start_time; - - // Should complete within a reasonable time (5 seconds). - $this->assertLessThan( 5.0, $execution_time, 'Method took too long to execute multiple calls' ); - } -} diff --git a/tests/phpunit/helper-functions/GetValidTableNameTest.php b/tests/phpunit/helper-functions/GetValidTableNameTest.php new file mode 100644 index 000000000..a1421b8a5 --- /dev/null +++ b/tests/phpunit/helper-functions/GetValidTableNameTest.php @@ -0,0 +1,53 @@ +get_charset_collate(); + $main_table = $wpdb->prefix . 'accessibility_checker'; + $this->alt_table = $wpdb->posts; + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $db_schema = "CREATE TABLE %s ( + id bigint(20) NOT NULL AUTO_INCREMENT, + PRIMARY KEY (id) + ) $charset_collate;"; + + dbDelta( sprintf( $db_schema, $main_table ) ); + } + + /** + * Ensures the helper validates different tables without returning cached results. + */ + public function test_returns_requested_table_name_each_time() { + global $wpdb; + + $main_table = $wpdb->prefix . 'accessibility_checker'; + + $this->assertSame( $main_table, edac_get_valid_table_name( $main_table ) ); + $this->assertSame( $this->alt_table, edac_get_valid_table_name( $this->alt_table ) ); + } +} diff --git a/tests/phpunit/helper-functions/SvgMarkupToDataUriTest.php b/tests/phpunit/helper-functions/SvgMarkupToDataUriTest.php new file mode 100644 index 000000000..13c679e47 --- /dev/null +++ b/tests/phpunit/helper-functions/SvgMarkupToDataUriTest.php @@ -0,0 +1,105 @@ +'; + + $this->assertSame( + 'data:image/svg+xml,' . rawurlencode( $svg ), + edac_svg_markup_to_data_uri( $svg ) + ); + } + + /** + * Tests that no raw markup characters survive encoding - the whole point + * is that the string is inert HTML once placed in an attribute. + * + * @dataProvider malicious_svg_data + * + * @param string $svg The malicious SVG markup to encode. + */ + public function test_strips_no_bytes_but_leaves_no_raw_markup_characters( $svg ) { + $data_uri = edac_svg_markup_to_data_uri( $svg ); + + $this->assertStringStartsWith( 'data:image/svg+xml,', $data_uri ); + $this->assertStringNotContainsString( '<', $data_uri ); + $this->assertStringNotContainsString( '>', $data_uri ); + $this->assertStringNotContainsString( '"', $data_uri ); + $this->assertStringNotContainsString( "'", $data_uri ); + + // The encoded payload still round-trips back to the original markup. + $this->assertSame( + $svg, + rawurldecode( substr( $data_uri, strlen( 'data:image/svg+xml,' ) ) ) + ); + } + + /** + * Data provider of SVG markup containing common XSS vectors. + */ + public function malicious_svg_data() { + return [ + 'onload handler' => [ '' ], + 'script child' => [ '' ], + 'foreignObject' => [ '' ], + 'javascript: xlink' => [ 'click' ], + 'animate onbegin' => [ '' ], + 'combined all-in-one' => [ + 'hi', + ], + ]; + } + + /** + * Tests that the combined-vector SVG's dangerous constructs survive + * encoding intact (this function encodes, it does not sanitize - the + * caller's context is what neutralizes them, not this string). + */ + public function test_combined_vector_payload_is_preserved_not_stripped() { + $svg = 'hi'; + $decoded = rawurldecode( substr( edac_svg_markup_to_data_uri( $svg ), strlen( 'data:image/svg+xml,' ) ) ); + + $this->assertSame( $svg, $decoded ); + $this->assertStringContainsString( 'onload="alert(1)"', $decoded ); + $this->assertStringContainsString( '', $decoded ); + $this->assertStringContainsString( 'onclick="alert(3)"', $decoded ); + $this->assertStringContainsString( '', $decoded ); + } + + /** + * Tests that non-string input never fatals and always yields a bare, + * payload-less data URI rather than attempting to encode it. + * + * @dataProvider non_string_data + * + * @param mixed $value A non-string value. + */ + public function test_non_string_input_returns_bare_data_uri( $value ) { + $this->assertSame( 'data:image/svg+xml,', edac_svg_markup_to_data_uri( $value ) ); + } + + /** + * Data provider of non-string values. + */ + public function non_string_data() { + return [ + 'null' => [ null ], + 'array' => [ [ '' ] ], + 'int' => [ 42 ], + 'bool' => [ true ], + 'object' => [ (object) [ 'markup' => '' ] ], + ]; + } +} diff --git a/tests/phpunit/includes/classes/EnqueueFrontendTest.php b/tests/phpunit/includes/classes/EnqueueFrontendTest.php index 0e73e0857..3df6c0029 100644 --- a/tests/phpunit/includes/classes/EnqueueFrontendTest.php +++ b/tests/phpunit/includes/classes/EnqueueFrontendTest.php @@ -73,6 +73,58 @@ public function testScannerBundleUrlIncludesVersionQueryString(): void { $this->assertStringContainsString( 'ver=' . EDAC_VERSION, $localized_data ); } + /** + * Highlighter must NOT load on the "latest posts" homepage when no filter overrides the ID. + * + * When show_on_front=posts, the global $post is the first blog post from the main query, not + * the homepage itself. The fix passes null to the filter so the free plugin bails gracefully. + */ + public function testFrontendHighlighterDoesNotLoadOnLatestPostsHomepage(): void { + $admin_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_id ); + + // Create a post so the main query has results. + $this->factory()->post->create_and_get( [ 'post_type' => 'post' ] ); + + update_option( 'show_on_front', 'posts' ); + + // Simulate visiting the homepage so is_home() / is_front_page() return true. + $this->go_to( '/' ); + + Enqueue_Frontend::maybe_enqueue_frontend_highlighter(); + + $this->assertFalse( wp_script_is( 'edac-frontend-highlighter-app', 'enqueued' ) ); + + delete_option( 'show_on_front' ); + } + + /** + * A filter on edac_filter_frontend_highlight_post_id can enable the highlighter on the + * "latest posts" homepage by supplying a valid post ID (e.g. a Pro virtual-page ID). + */ + public function testFrontendHighlighterLoadsOnLatestPostsHomepageWhenFilterProvidesId(): void { + $admin_id = $this->factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_id ); + + $post = $this->factory()->post->create_and_get( [ 'post_type' => 'post' ] ); + + update_option( 'show_on_front', 'posts' ); + + $this->go_to( '/' ); + + $filter_callback = static function () use ( $post ) { + return $post->ID; + }; + $this->added_filters['edac_filter_frontend_highlight_post_id'] = $filter_callback; + add_filter( 'edac_filter_frontend_highlight_post_id', $filter_callback ); + + Enqueue_Frontend::maybe_enqueue_frontend_highlighter(); + + $this->assertTrue( wp_script_is( 'edac-frontend-highlighter-app', 'enqueued' ) ); + + delete_option( 'show_on_front' ); + } + /** * Helper: enqueue the frontend highlighter as an admin and return the localized data string. *