diff --git a/.gitignore b/.gitignore index 77bc0b703..1bdaa4794 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ dist # Exclude coverage reports coverage/ + +# Local only working fules +*.log +*.sql +docs-local diff --git a/accessibility-checker.php b/accessibility-checker.php index 206a82c38..ba5548d91 100755 --- a/accessibility-checker.php +++ b/accessibility-checker.php @@ -10,7 +10,7 @@ * Plugin Name: Accessibility Checker * Plugin URI: https://a11ychecker.com * Description: Audit and check your website for accessibility before you hit publish. In-post accessibility scanner and guidance. - * Version: 1.39.0 + * Version: 1.40.0-alpha * Requires PHP: 7.4 * Author: Equalize Digital * Author URI: https://equalizedigital.com @@ -36,12 +36,12 @@ // Current plugin version. if ( ! defined( 'EDAC_VERSION' ) ) { - define( 'EDAC_VERSION', '1.39.0' ); + define( 'EDAC_VERSION', '1.40.0-alpha' ); } // Current database version. if ( ! defined( 'EDAC_DB_VERSION' ) ) { - define( 'EDAC_DB_VERSION', '1.0.6' ); + define( 'EDAC_DB_VERSION', '1.0.7' ); } // Plugin Folder Path. diff --git a/admin/AdminPage/AccessibilityReportsPage.php b/admin/AdminPage/AccessibilityReportsPage.php new file mode 100644 index 000000000..31bd2586b --- /dev/null +++ b/admin/AdminPage/AccessibilityReportsPage.php @@ -0,0 +1,525 @@ +settings_capability = $settings_capability; + } + + /** + * Register hooks. + * + * @return void + */ + public function add_page() { + add_filter( 'edac_filter_settings_tab_items', [ $this, 'add_reports_tab' ] ); + add_action( 'edac_settings_tab_content', [ $this, 'maybe_render_tab_content' ] ); + } + + /** + * Add the Accessibility Reports tab. + * + * @param array $tabs Existing tabs. + * @return array + */ + public function add_reports_tab( $tabs ) { + $tabs[] = [ + 'slug' => 'accessibility-reports', + 'label' => esc_html__( 'Accessibility Reports', 'accessibility-checker' ), + 'badge' => esc_html__( 'New', 'accessibility-checker' ), + 'order' => defined( 'EDACP_VERSION' ) ? 4 : 3, + 'capability' => $this->settings_capability, + ]; + + return $tabs; + } + + /** + * Render content for the active tab. + * + * @param string|null $settings_tab Active tab slug. + * @return void + */ + public function maybe_render_tab_content( $settings_tab ) { + if ( 'accessibility-reports' === $settings_tab ) { + $this->render_page(); + } + } + + /** + * Render the page. + * + * @return void + */ + public function render_page() { + $license_context = $this->get_license_context(); + $is_pro = $license_context['is_pro']; + $license_key = (string) get_option( 'edacp_license_key', '' ); + $is_connected = $license_context['is_connected']; + $next_collection = $this->get_next_collection_date(); + $scans_stats = new Scans_Stats(); + $summary = $scans_stats->summary(); + $preview_data = is_array( $summary ) ? $this->get_preview_data( $summary, $is_pro ) : []; + + $upgrade_url = edac_generate_link_type( + [ + 'utm_campaign' => 'settings-page', + 'utm_content' => 'accessibility-reports-upgrade', + ] + ); + $dashboard_url = edac_link_wrapper( 'https://my.equalizedigital.com/', 'accessibility-reports', 'account', false ); + $email_reports_url = edac_link_wrapper( 'https://my.equalizedigital.com/email-reports', 'accessibility-reports', 'email-reports', false ); + $signup_url = edac_link_wrapper( 'https://my.equalizedigital.com/sign-up/', 'accessibility-reports', 'signup', false ); + $terms_url = edac_link_wrapper( 'https://equalizedigital.com/terms-of-service/', 'accessibility-reports', 'terms', false ); + $privacy_url = edac_link_wrapper( 'https://equalizedigital.com/privacy-policy/', 'accessibility-reports', 'privacy', false ); + $allowed_icon_html = [ + 'span' => [ + 'class' => true, + 'aria-hidden' => true, + 'aria-label' => true, + ], + 'svg' => [ + 'width' => true, + 'height' => true, + 'viewbox' => true, + 'fill' => true, + 'xmlns' => true, + ], + 'path' => [ + 'd' => true, + 'stroke' => true, + 'stroke-width' => true, + 'stroke-linecap' => true, + 'stroke-linejoin' => true, + ], + 'rect' => [ + 'x' => true, + 'y' => true, + 'width' => true, + 'height' => true, + 'rx' => true, + 'stroke' => true, + 'stroke-width' => true, + ], + ]; + ?> +
+
+
+

+ + +

+ +

+ + +
+
+ + + +
+
+ +
+
+

+
+ + + + + +
+
+ +
+

+

+ dashboard.', 'accessibility-checker' ) ), + esc_url( $dashboard_url ) + ); + ?> +

+

+ Create a free account to get one.', 'accessibility-checker' ) ), + esc_url( $signup_url ) + ); + ?> +

+

+ create a free account. It only takes a minute.', 'accessibility-checker' ) ), + esc_url( $signup_url ) + ); + ?> +

+
+
+ + +
+
+
+

+ +
+

+

+ + + + +

+

+ +

+
+ +
+
+

+ +
+ +

+
    +
  • +
  • +
  • +
+ +

+

+
    +
  • +
  • +
  • +
+

+ +

+ +
+ +
+

+

+
+
+
+
+
+ +
+

+ +
mask_license_key( $license_key ) ); ?>
+ +
+ + + +
+
+
+ + + +
+
+ + +
+ $has_pro_plugin, + 'is_pro' => $is_pro, + 'status' => $status, + 'is_connected' => $is_connected, + ]; + } + + /** + * Get the next report collection date for display. + * + * Prefers the schedule returned by the connector service and falls back to a + * local estimate when no remote schedule has been stored yet. + * + * @return string + */ + private function get_next_collection_date(): string { + $next_collection = (string) get_option( 'edac_next_collection', '' ); + if ( '' !== $next_collection ) { + return $next_collection; + } + + $next_monday = new \DateTime( 'next monday', wp_timezone() ); + return $next_monday->format( 'Y-m-d' ); + } + + /** + * Build preview data from current site stats. + * + * @param array $summary Current site summary. + * @param bool $is_pro Whether the effective active license is Pro. + * @return array + */ + private function get_preview_data( array $summary, bool $is_pro ): array { + $problems = (int) ( $summary['errors'] ?? 0 ); + $needs_review = (int) ( $summary['warnings'] ?? 0 ); + $total_issues = $problems + $needs_review; + $urls_scanned = (int) ( $summary['posts_scanned'] ?? 0 ); + $post_types_checked = (int) ( $summary['scannable_post_types_count'] ?? 0 ); + $public_post_types = (int) ( $summary['public_post_types_count'] ?? 0 ); + $taxonomies_checked = $this->get_taxonomy_coverage_counts( $is_pro ); + $rules = $this->get_rule_index(); + + return [ + 'total_issues' => $total_issues, + 'problems' => $problems, + 'needs_review' => $needs_review, + 'passed_checks' => (string) ( $summary['passed_percentage_formatted'] ?? __( 'N/A', 'accessibility-checker' ) ), + 'urls_scanned' => $urls_scanned, + 'post_types_checked' => sprintf( '%1$d/%2$d', $post_types_checked, max( $public_post_types, $post_types_checked ) ), + 'taxonomies_checked' => sprintf( '%1$d/%2$d', $taxonomies_checked['checked'], max( $taxonomies_checked['total'], $taxonomies_checked['checked'] ) ), + 'top_pages' => array_slice( $summary['top_pages_with_issues'] ?? [], 0, 5 ), + 'top_issues' => $this->format_top_issues( $summary['top_issues_found_on_site'] ?? [], $rules ), + ]; + } + + /** + * Map rules by slug for display. + * + * @return array + */ + private function get_rule_index(): array { + $rules = edac_register_rules(); + $index = []; + + foreach ( $rules as $rule ) { + if ( empty( $rule['slug'] ) ) { + continue; + } + + $index[ $rule['slug'] ] = $rule; + } + + return $index; + } + + /** + * Get a resized status icon using the shared helper output. + * + * @param string $icon_name Icon name for edac_icon(). + * @return string + */ + private function get_status_icon( string $icon_name ): string { + return edac_icon( $icon_name, '', true, '', 'edac-reports-card__status-icon' ); + } + + /** + * Format top issue rows for the preview table. + * + * @param array $issues Raw issue counts. + * @param array $rules Indexed rules. + * @return array + */ + private function format_top_issues( array $issues, array $rules ): array { + $formatted = []; + + foreach ( array_slice( $issues, 0, 10 ) as $issue ) { + $rule = $rules[ $issue['rule_slug'] ] ?? []; + $severity = isset( $rule['severity'] ) ? (int) $rule['severity'] : 99; + + $formatted[] = [ + 'title' => $rule['title'] ?? $issue['rule_slug'], + 'severity' => $this->format_severity( $severity ), + 'rank' => $severity, + 'count' => (int) $issue['issue_count'], + ]; + } + + usort( + $formatted, + function ( $a, $b ) { + if ( $a['rank'] === $b['rank'] ) { + return $b['count'] <=> $a['count']; + } + + return $a['rank'] <=> $b['rank']; + } + ); + + return array_map( + function ( $issue ) { + unset( $issue['rank'] ); + return $issue; + }, + array_slice( $formatted, 0, 5 ) + ); + } + + /** + * Convert a numeric severity to a human-readable label. + * + * @param int $severity Severity number. + * @return string + */ + private function format_severity( int $severity ): string { + switch ( $severity ) { + case 1: + return __( 'Critical', 'accessibility-checker' ); + case 2: + return __( 'High', 'accessibility-checker' ); + case 3: + return __( 'Medium', 'accessibility-checker' ); + case 4: + return __( 'Low', 'accessibility-checker' ); + default: + return __( 'Unknown', 'accessibility-checker' ); + } + } + + /** + * Get taxonomy coverage counts for the preview panel. + * + * @param bool $is_pro Whether the effective active license is Pro. + * @return array + */ + private function get_taxonomy_coverage_counts( bool $is_pro ): array { + $taxonomies = get_taxonomies( + [ + 'public' => true, + ], + 'names' + ); + + unset( $taxonomies['post_format'] ); + + $total = count( $taxonomies ); + $checked = 0; + + if ( $is_pro && get_option( 'edacp_enable_archive_scanning' ) ) { + $checked = $total; + } + + return [ + 'checked' => $checked, + 'total' => $total, + ]; + } + + /** + * Mask a license key for display. + * + * @param string $license_key License key. + * @return string + */ + private function mask_license_key( string $license_key ): string { + if ( strlen( $license_key ) <= 8 ) { + return $license_key; + } + + return substr( $license_key, 0, 4 ) . str_repeat( '*', max( strlen( $license_key ) - 8, 4 ) ) . substr( $license_key, -4 ); + } +} diff --git a/admin/AdminPage/ConnectedServicesPage.php b/admin/AdminPage/ConnectedServicesPage.php new file mode 100644 index 000000000..4ef8c1e13 --- /dev/null +++ b/admin/AdminPage/ConnectedServicesPage.php @@ -0,0 +1,498 @@ +settings_capability = $settings_capability; + } + + /** + * Register hooks to add the connected services page to the settings tabs + * and handle license-related notices. + * + * @since 1.xx.x + * + * @return void + */ + public function add_page() { + // Add Connected Services tab to settings page. + add_filter( + 'edac_filter_settings_tab_items', + [ $this, 'add_connected_services_tab' ] + ); + + // Render Connected Services tab content when active. + add_action( + 'edac_settings_tab_content', + [ $this, 'maybe_render_tab_content' ] + ); + + // Inject degraded-state messaging above the Pro license form when Pro renders the license tab. + add_action( + 'edacp_license_page_before_form', + [ $this, 'render_pro_license_degraded_notice' ] + ); + + // Register admin notice display with higher priority than default. + add_action( + 'in_admin_header', + [ $this, 'register_admin_notices' ], + 1001 + ); + } + + /** + * Add Connected Services tab to settings tabs array. + * + * @since 1.xx.x + * + * @param array $tabs Array of registered settings tabs. + * + * @return array Modified tabs array with Connected Services tab added. + */ + public function add_connected_services_tab( $tabs ) { + // Free plugin no longer owns the Pro License tab. + return $tabs; + } + + /** + * Conditionally render the tab content if the current tab is 'connected-services'. + * + * @since 1.xx.x + * + * @param string $settings_tab The current active settings tab. + * + * @return void + */ + public function maybe_render_tab_content( $settings_tab ) { + if ( 'connected-services' === $settings_tab ) { + $this->render_page(); + } + } + + /** + * Register the admin notices function to display license-related messages. + * + * @since 1.xx.x + * + * @return void + */ + public function register_admin_notices() { + add_action( 'admin_notices', [ $this, 'admin_notices' ] ); + } + + /** + * Render the connected services settings page content. + * + * @since 1.xx.x + * + * @return void + */ + public function render_page() { + $license_context = $this->get_license_context(); + $is_pro = $license_context['is_pro']; + $status = $license_context['status']; + $license = get_option( 'edacp_license_key' ); + $is_connected = $license_context['is_connected']; + $degraded_context = $this->get_degraded_notice_context( $license_context ); + $degraded_notice = $this->get_degraded_notice_message( $license_context ); + $dashboard_link = 'my.equalizedigital.com'; + $create_account_link = '' . esc_html__( 'create a free account', 'accessibility-checker' ) . ''; + ?> + +

+ + + + render_degraded_connected_state( $dashboard_link ); ?> + +

+
+ + + + + + + + + + + + +
+ + + + +
+ + + + + + + +
+
+ +

+ +

+

+ +

+ + + + +

+

+

+ +

+
+ + + +
+ get_license_context(); + $degraded_notice = $this->get_degraded_notice_message( $license_context ); + + if ( empty( $degraded_notice ) ) { + return; + } + ?> +

+ $has_pro_plugin, + 'is_pro' => $is_pro, + 'status' => $status, + 'is_connected' => $is_connected, + ]; + } + + /** + * Resolve which error option and settings tab should be used for notices. + * + * @return array{error:string,tab:string} + */ + private function get_error_context(): array { + $license_context = $this->get_license_context(); + + return self::resolve_error_context( $license_context['is_pro'] ); + } + + /** + * Resolve notice context from effective license authority. + * + * @param bool $is_pro Whether Pro is currently authoritative. + * @return array{error:string,tab:string} + */ + private static function resolve_error_context( bool $is_pro ): array { + return [ + 'error' => (string) get_option( $is_pro ? 'edacp_license_error' : 'edac_license_error', '' ), + 'tab' => $is_pro ? 'license' : 'accessibility-reports', + ]; + } + + /** + * Resolve whether to show a Pro-to-Free degraded-state notice. + * + * @param bool $has_pro_plugin Whether Pro plugin is installed. + * @param bool $is_pro Whether Pro is currently authoritative. + * @param string $pro_status Current Pro status option. + * @param string $effective_status Effective authority status. + * @param bool $is_connected Whether UI currently considers site connected. + * @return array{show:bool,mode:string} + */ + private static function resolve_degraded_notice_context( bool $has_pro_plugin, bool $is_pro, string $pro_status, string $effective_status, bool $is_connected ): array { + $show = $has_pro_plugin + && ! $is_pro + && 'valid' === $effective_status + && '' !== $pro_status + && 'valid' !== $pro_status; + + if ( ! $show ) { + return [ + 'show' => false, + 'mode' => '', + ]; + } + + return [ + 'show' => true, + 'mode' => ! $is_connected ? 'reconnect' : 'connected', + ]; + } + + /** + * Get degraded-state notice message when Pro is invalid and Free is active. + * + * @param array{has_pro_plugin:bool,is_pro:bool,status:string,is_connected:bool} $license_context Effective license context. + * @return string|null + */ + private function get_degraded_notice_message( array $license_context ): ?string { + $notice_context = $this->get_degraded_notice_context( $license_context ); + + if ( ! $notice_context['show'] ) { + return null; + } + + if ( 'connected' === $notice_context['mode'] ) { + return __( 'Your Pro license is no longer valid. This site is using a valid Free license, and email reports remain connected as Free email reports. Renew your Pro license to restore Pro-only features.', 'accessibility-checker' ); + } + + return __( 'Your Pro license is no longer valid. This site is using a valid Free license, but email reports are not currently connected. Connect this site from the Free License section to resume Free email reports.', 'accessibility-checker' ); + } + + /** + * Display admin notices when the license added is invalid or expired. + * + * @since 1.xx.x + * + * @return void + */ + public function admin_notices() { + $error_context = $this->get_error_context(); + $error = $error_context['error']; + $license_url = admin_url( 'admin.php?page=accessibility_checker_settings&tab=' . $error_context['tab'] ); + $message = null; + + if ( $error ) { + $message = $this->get_error_message( $error, $license_url ); + } + + if ( isset( $message ) ) { + printf( + '

%s

', + wp_kses_post( $message ) + ); + } + } + + /** + * Get appropriate error message based on the license error code. + * + * @since 1.xx.x + * + * @param string $error_code The license error code. + * @param string $license_url URL to the license settings page. + * + * @return string The formatted error message. + */ + private function get_error_message( $error_code, $license_url ) { + switch ( $error_code ) { + case 'expired': + $renew_link = '' . esc_html__( 'Please renew your license', 'accessibility-checker' ) . ''; + return sprintf( + /* translators: %1$s: item name, %2$s: item name, %3$s: renew license link */ + __( 'Your %1$s license has expired. %3$s to continue accessing additional features and services for %2$s.', 'accessibility-checker' ), + Connector::PRODUCT_NAME, + Connector::PRODUCT_NAME, + wp_kses_post( $renew_link ) + ); + + case 'disabled': + case 'revoked': + return sprintf( + /* translators: %s: item name */ + __( 'Your %s license key has been disabled. Please contact our support team if you believe this is an error or would like to continue accessing additional features.', 'accessibility-checker' ), + Connector::PRODUCT_NAME + ); + + case 'missing': + return sprintf( + /* translators: %1$s: item name, %2$s: license url, %3$s: item name */ + __( 'The license key for %1$s appears to be invalid. Please check your license key to access additional accessibility features and services for %3$s.', 'accessibility-checker' ), + Connector::PRODUCT_NAME, + $license_url, + Connector::PRODUCT_NAME + ); + + case 'invalid': + case 'site_inactive': + return sprintf( + /* translators: %s: item name */ + __( 'This license key for %s is not active for this site. Please activate it to access additional features on this website.', 'accessibility-checker' ), + Connector::PRODUCT_NAME + ); + + case 'item_name_mismatch': + return sprintf( + /* translators: %s: the plugins item name */ + __( 'This license key does not appear to be valid for %s. Please verify you\'ve entered the correct license key.', 'accessibility-checker' ), + Connector::PRODUCT_NAME + ); + + case 'no_activations_left': + return sprintf( + /* translators: %s: the plugins item name */ + __( 'Your %s license key has reached its site activation limit. You can upgrade your license or deactivate it on another site to use the additional features here.', 'accessibility-checker' ), + Connector::PRODUCT_NAME + ); + + default: + return sprintf( + /* translators: %s: the plugins item name */ + __( 'There was an issue validating your %s license key. Please try again or contact our support team if the problem persists.', 'accessibility-checker' ), + Connector::PRODUCT_NAME + ); + } + } +} diff --git a/admin/class-scans-stats.php b/admin/class-scans-stats.php index 5aa0ee62e..2f3d4ec62 100644 --- a/admin/class-scans-stats.php +++ b/admin/class-scans-stats.php @@ -344,6 +344,10 @@ function ( $item ) { } + // Get top 5 posts with issues. + $data['top_pages_with_issues'] = $this->get_top_pages_with_issues( 5 ); + $data['top_issues_found_on_site'] = $this->get_top_issues_found_on_site( 10 ); + $data['cache_id'] = $transient_name; $data['cached_at'] = time(); $data['expires_at'] = time() + $this->cache_time; @@ -362,6 +366,7 @@ function ( $item ) { 'cached_at', 'expires_at', 'cache_hit', + 'top_pages_with_issues', ]; foreach ( $data as $key => $value ) { @@ -468,4 +473,154 @@ public function issues_summary_by_post_type( $post_type ) { return $data; } + + /** + * Get top N posts with the most issues. + * + * @param int $limit Number of posts to return (default 5). + * @return array Array of arrays with post_title, post_url, and issue_count. + */ + private function get_top_pages_with_issues( int $limit = 5 ) { + global $wpdb; + + $limit = max( 0, $limit ); + if ( 0 === $limit ) { + return []; + } + + $ac_table_name = $wpdb->prefix . 'accessibility_checker'; + $siteid = get_current_blog_id(); + + // Get scannable post types and statuses to match site scope. + $post_types = Settings::get_scannable_post_types(); + $post_statuses = Settings::get_scannable_post_statuses(); + + // Return empty array if no scannable content is configured. + if ( empty( $post_types ) || empty( $post_statuses ) ) { + return []; + } + + // Build SQL-safe lists for IN clauses (these are sanitized by the helper). + $post_types_list = Helpers::array_to_sql_safe_list( $post_types ); + $post_statuses_list = Helpers::array_to_sql_safe_list( $post_statuses ); + + // Build the SQL query with siteid and post filters. + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- post_types_list and post_statuses_list are sanitized by Helpers::array_to_sql_safe_list(). + $sql = $wpdb->prepare( + "SELECT %i.ID, %i.post_title, COUNT(%i.id) as issue_count + FROM %i + INNER JOIN %i ON %i.ID = %i.postid + WHERE %i.siteid = %d + AND %i.ignre = 0 + AND %i.ignre_global = 0 + AND %i.post_type IN({$post_types_list}) + AND %i.post_status IN({$post_statuses_list}) + GROUP BY %i.ID + ORDER BY issue_count DESC + LIMIT %d", + $wpdb->posts, + $wpdb->posts, + $ac_table_name, + $wpdb->posts, + $ac_table_name, + $wpdb->posts, + $ac_table_name, + $ac_table_name, + $siteid, + $ac_table_name, + $ac_table_name, + $wpdb->posts, + $wpdb->posts, + $wpdb->posts, + $limit + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Direct query for stats calculation, SQL is prepared above. + $posts = $wpdb->get_results( $sql ); + + $result = []; + if ( $posts ) { + foreach ( $posts as $post ) { + $result[] = [ + 'post_title' => esc_html( $post->post_title ), + 'post_url' => esc_url( get_permalink( $post->ID ) ), + 'issue_count' => (int) $post->issue_count, + ]; + } + } + + return $result; + } + + /** + * Get top N issues found on the site. + * + * @param int $limit Number of issues to return (default 10). + * @return array Array of arrays with rule_slug and issue_count. + */ + private function get_top_issues_found_on_site( int $limit = 10 ) { + global $wpdb; + + $limit = max( 0, $limit ); + if ( 0 === $limit ) { + return []; + } + + $ac_table_name = $wpdb->prefix . 'accessibility_checker'; + $siteid = get_current_blog_id(); + + $rules_raw = edac_register_rules(); + $rules_parsed = []; + $severity_case = '0'; + + foreach ( $rules_raw as $rule ) { + if ( ! isset( $rule['slug'] ) ) { + continue; + } + + $slug = (string) $rule['slug']; + $rules_parsed[ $slug ] = $rule; + $severity = isset( $rule['severity'] ) ? (int) $rule['severity'] : 0; + $severity_case .= $wpdb->prepare( ' + (CASE WHEN rule = %s THEN %d ELSE 0 END)', $slug, $severity ); + } + + // Build the SQL query to get top issues by severity, then count. + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared -- No user input, and table name is properly escaped, severity cases is prepared above. + $sql = $wpdb->prepare( + 'SELECT rule, COUNT(id) AS issue_count, (' . $severity_case . ') AS severity + FROM %i + WHERE siteid = %d + AND ignre = 0 + AND ignre_global = 0 + GROUP BY rule + ORDER BY severity DESC, issue_count DESC, rule ASC + LIMIT %d', + $ac_table_name, + $siteid, + $limit + ); + // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Direct query for stats calculation, SQL is prepared above. + $issues = $wpdb->get_results( $sql ); + + if ( ! $issues ) { + return []; + } + + return array_map( + function ( $issue ) use ( $rules_parsed ) { + $rule = $rules_parsed[ $issue->rule ] ?? []; + + return [ + 'rule_nicename' => sanitize_text_field( $rule['title'] ?? $issue->rule ), + 'rule_slug' => sanitize_text_field( $rule['slug'] ?? $issue->rule ), + 'issue_count' => (int) $issue->issue_count, + 'severity' => isset( $rule['severity'] ) ? (int) $rule['severity'] : (int) $issue->severity, + ]; + }, + $issues + ); + } } diff --git a/admin/class-update-database.php b/admin/class-update-database.php index 851221f3b..81cadff3b 100644 --- a/admin/class-update-database.php +++ b/admin/class-update-database.php @@ -82,13 +82,42 @@ public function edac_update_database() { // Run migration for selector-based unique identifiers if upgrading from older versions. if ( version_compare( $db_version, '1.0.5', '<' ) ) { $this->migrate_to_selector_based_unique_id(); - } + } + + // Migrate free plugin license key to shared option key used by both free and pro. + if ( version_compare( $db_version, '1.0.7', '<' ) ) { + $this->migrate_license_key_to_shared_option(); + } } // Update database version option. update_option( 'edac_db_version', sanitize_text_field( EDAC_DB_VERSION ) ); } + /** + * Migrate early testing key to the shared option used by both free and pro. + * + * Copies `edac_license_key` to `edacp_license_key` if the free key exists and the + * shared key is not already set, then deletes the old option. + * + * @since 1.0.7 + * @return void + */ + private function migrate_license_key_to_shared_option() { + $migratable_key = get_option( 'edac_license_key', '' ); + + if ( empty( $migratable_key ) ) { + return; + } + + // Only copy if the shared key is not already occupied. + if ( empty( get_option( 'edacp_license_key', '' ) ) ) { + update_option( 'edacp_license_key', $migratable_key ); + } + + delete_option( 'edac_license_key' ); + } + /** * Migrate existing records to use selector-based unique identifiers. * diff --git a/assets/images/accessibility-reports-email-preview.png b/assets/images/accessibility-reports-email-preview.png new file mode 100644 index 000000000..99f8399d8 Binary files /dev/null and b/assets/images/accessibility-reports-email-preview.png differ diff --git a/composer.json b/composer.json index 69db98b2c..c4ab9bd82 100644 --- a/composer.json +++ b/composer.json @@ -47,8 +47,10 @@ "require": { "davechild/textstatistics": "dev-master", "php": ">=7.4", - "composer/installers": "^1.12.0" - }, + "composer/installers": "^1.12.0", + "ext-openssl": "*", + "ext-json": "*" + }, "autoload": { "classmap": [ "admin/", diff --git a/composer.lock b/composer.lock index 177ce29af..2b9648a10 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "61b1718f886504a4958c41f3d46bb148", + "content-hash": "dc43f50218d9d0f2eca30fc534d2d330", "packages": [ { "name": "composer/installers", @@ -1451,11 +1451,11 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.32", + "version": "1.12.33", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/2770dcdf5078d0b0d53f94317e06affe88419aa8", - "reference": "2770dcdf5078d0b0d53f94317e06affe88419aa8", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1", + "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1", "shasum": "" }, "require": { @@ -1500,7 +1500,7 @@ "type": "github" } ], - "time": "2025-09-30T10:16:31+00:00" + "time": "2026-02-28T20:30:03+00:00" }, { "name": "phpunit/php-code-coverage", @@ -3339,7 +3339,7 @@ }, { "name": "wp-phpunit/wp-phpunit", - "version": "6.9.1", + "version": "6.9.4", "source": { "type": "git", "url": "https://github.com/wp-phpunit/wp-phpunit.git", @@ -3529,8 +3529,10 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": ">=7.4" + "php": ">=7.4", + "ext-openssl": "*", + "ext-json": "*" }, "platform-dev": [], - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.2.0" } diff --git a/includes/classes/MyDot/Connector.php b/includes/classes/MyDot/Connector.php new file mode 100644 index 000000000..887ca04dd --- /dev/null +++ b/includes/classes/MyDot/Connector.php @@ -0,0 +1,1353 @@ +add_page(); + + // Expose the free product ID so the Pro plugin can infer license type from API response product IDs. + add_filter( 'edac_free_product_id', [ __CLASS__, 'get_free_product_id' ] ); + + // Ensure the license options group is registered so options.php allows saves. + add_action( 'admin_init', [ $this, 'register_license_settings' ] ); + + // Admin-post handler for license activate/deactivate. + add_action( 'admin_post_edac_license', [ $this, 'handle_license_post' ] ); + + // Schedule periodic license checks. + add_action( 'init', [ $this, 'check_license_cron' ] ); + add_action( 'edac_check_license_hook', [ $this, 'periodic_check_license' ] ); + + // The admin-post handlers for register/unregister buttons. + add_action( 'admin_post_edac_jwt_register', [ $this, 'handle_jwt_register_post' ] ); + add_action( 'admin_post_edac_jwt_unregister', [ $this, 'handle_jwt_unregister_post' ] ); + + // When the pro license is deactivated, unregister the site to avoid orphaned registrations. + add_action( 'edacp_license_deactivated', [ $this, 'handle_site_unregistration' ], 10, 3 ); + + // When a Pro license is activated on an already-connected site, refresh registration + // so enrollment context is updated for Pro. + add_action( 'edacp_license_activated', [ $this, 'handle_pro_license_activation' ], 10, 3 ); + + add_action( + 'in_admin_header', + function () { + // Display transient-backed admin notices after redirects. + add_action( 'admin_notices', [ $this, 'display_admin_notices' ] ); + }, + 1000 + ); + } + + /** + * Register license settings so the edac_license group is allowed by options.php. + * + * @since 1.xx.x + * + * @return void + */ + public function register_license_settings() { + register_setting( + 'edac_license', + 'edacp_license_key', + [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ] + ); + + register_setting( + 'edac_license', + 'edac_license_status', + [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ] + ); + + register_setting( + 'edac_license', + 'edac_license_error', + [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + ] + ); + } + + /** + * Handle license activate/deactivate from admin-post. + * + * @since 1.xx.x + * + * @return void + */ + public function handle_license_post() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to manage this license.', 'accessibility-checker' ) ); + } + + check_admin_referer( 'edac_license_nonce', 'edac_license_nonce' ); + + // Normalize license key from the form. + if ( isset( $_POST['edacp_license_key'] ) ) { + $license = sanitize_text_field( wp_unslash( $_POST['edacp_license_key'] ) ); + update_option( 'edacp_license_key', $license ); + } + + if ( isset( $_POST['edac_license_activate'] ) ) { + $this->activate_license(); + } elseif ( isset( $_POST['edac_license_deactivate'] ) ) { + $this->deactivate_license(); + } + + $redirect = wp_get_referer(); + if ( ! $redirect ) { + $redirect = admin_url(); + } + wp_safe_redirect( $redirect ); + exit; + } + + /** + * Activate the license via API and store status/error. + * + * @since 1.xx.x + * + * @return void + */ + private function activate_license() { + // If pro plugin is enabled with a valid license, it takes precedence. + // Do not allow free license activation to overwrite pro state. + if ( defined( 'EDACP_VERSION' ) && 'valid' === get_option( 'edacp_license_status' ) ) { + update_option( 'edac_license_error', __( 'Pro license is active. Please deactivate the Pro license first if you want to use a free license.', 'accessibility-checker' ) ); + return; + } + + $license = trim( get_option( 'edacp_license_key' ) ); + if ( empty( $license ) ) { + update_option( 'edac_license_error', 'missing' ); + return; + } + + $api_params = [ + 'edd_action' => 'activate_license', + 'license' => $license, + 'item_id' => self::PRODUCT_ID, + 'url' => home_url(), + 'environment' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production', + 'wp_version' => get_bloginfo( 'version' ), + 'php_version' => phpversion(), + ]; + + $response = wp_remote_post( + self::get_api_endpoint(), + [ + 'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments. + 'sslverify' => self::verify_ssl(), + 'body' => $api_params, + ] + ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + $message = is_wp_error( $response ) ? $response->get_error_message() : esc_html__( 'An error occurred, please try again.', 'accessibility-checker' ); + update_option( 'edac_license_error', $message ); + return; + } + + $license_data = json_decode( wp_remote_retrieve_body( $response ) ); + self::store_license_metadata_from_response( $license_data, 'free' ); + + if ( isset( $license_data->error ) ) { + update_option( 'edac_license_error', $license_data->error ); + update_option( 'edac_license_status', $license_data->license ?? '' ); + return; + } + + delete_option( 'edac_license_error' ); + update_option( 'edac_license_status', $license_data->license ?? '' ); + + // Automatically register the site after successful license activation. + if ( 'valid' === ( $license_data->license ?? '' ) ) { + $this->handle_site_registration(); + // Always clear fallback marker when license recovers to valid state, + // regardless of whether registration succeeded. License validity and + // registration status are independent concerns (license = key validity, + // registration = reports feature). The registration can be retried separately. + delete_option( 'edac_fallback_active' ); + } + } + + /** + * Clear all license and enrollment state. + * + * Called when deactivating or removing a license to ensure a clean slate. + * + * @return void + */ + private static function clear_all_license_state(): void { + delete_option( 'edacp_license_key' ); + delete_option( 'edacp_license_status' ); + delete_option( 'edacp_license_error' ); + delete_option( 'edac_license_status' ); + delete_option( 'edac_license_error' ); + self::clear_stored_license_metadata(); + self::clear_report_connection_state(); + delete_option( 'edac_fallback_active' ); + } + + /** + * Clear report-connection specific state only. + * + * @return void + */ + private static function clear_report_connection_state(): void { + delete_option( 'edac_jwt_public_key' ); + delete_option( 'edac_site_id' ); + delete_option( 'edac_collection_interval_days' ); + delete_option( 'edac_next_collection' ); + } + + /** + * Clear free-authority license state while preserving active Pro status. + * + * @return void + */ + private static function clear_free_disconnect_license_state(): void { + delete_option( 'edacp_license_key' ); + delete_option( 'edac_license_status' ); + delete_option( 'edac_license_error' ); + self::clear_stored_license_metadata(); + delete_option( 'edac_fallback_active' ); + } + + /** + * Determine whether an unregister action should preserve current license state. + * + * Active Pro licenses should remain active when only disabling reports. + * + * @return bool + */ + private static function should_preserve_license_on_unregistration(): bool { + return defined( 'EDACP_VERSION' ) && self::LICENSE_STATUS_VALID === get_option( 'edacp_license_status' ); + } + + /** + * Deactivate the license via API and always clear local stored values. + * + * @since 1.xx.x + * + * @return void + */ + private function deactivate_license() { + $license = trim( get_option( 'edacp_license_key' ) ); + if ( empty( $license ) ) { + self::clear_all_license_state(); + return; + } + + // Best effort unregister: do not block local disconnect on remote failures. + $site_id = (string) get_option( 'edac_site_id' ); + if ( '' !== $site_id ) { + self::unregister_site( $site_id, get_site_url(), $license ); + } + + $api_params = [ + 'edd_action' => 'deactivate_license', + 'license' => $license, + 'item_name' => rawurlencode( self::PRODUCT_NAME ), + 'url' => home_url(), + ]; + + wp_remote_post( + self::get_api_endpoint(), + [ + 'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments. + 'sslverify' => self::verify_ssl(), + 'body' => $api_params, + ] + ); + + // Remote deactivation is a best effort. Intentionally clear local + // state regardless of API response so users can always disconnect. + self::clear_all_license_state(); + } + + /** + * License check + * + * Also includes proactive JWT public key verification as part of key rotation strategy. + * + * Bails early if the pro plugin (EDACP) is enabled to let it handle license checking. + * + * @return void + */ + public function periodic_check_license() { + // Guard: Only bail if Pro is active with VALID license. + // This allows fallback when Pro license becomes invalid (expired, disabled, etc). + // + // Safe from race conditions: + // - Once Pro's license status changes from 'valid' to anything else, this guard + // stops bailing and free plugin resumes checking. + // - Both plugins check the same 'edacp_license_status' option atomically + // - Concurrent reads of the same option value are thread-safe in WordPress. + if ( defined( 'EDACP_VERSION' ) && self::LICENSE_STATUS_VALID === get_option( 'edacp_license_status' ) ) { + return; + } + + $license = trim( get_option( 'edacp_license_key' ) ); + if ( ! $license ) { + return; + } + + $api_params = [ + 'edd_action' => 'check_license', + 'license' => $license, + 'item_id' => self::PRODUCT_ID, + 'item_name' => rawurlencode( self::PRODUCT_NAME ), + 'url' => home_url(), + 'environment' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production', + 'edac_version' => defined( 'EDAC_VERSION' ) ? EDAC_VERSION : '0.0.0', + 'wp_version' => get_bloginfo( 'version' ), + 'php_version' => phpversion(), + ]; + + // Call the custom API. + $response = wp_remote_post( + self::get_api_endpoint(), + [ + 'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- 15 seconds is needed for now. + 'sslverify' => self::verify_ssl(), + 'body' => $api_params, + ] + ); + if ( is_wp_error( $response ) ) { + // this is a silent failure, we should log this or flag it somehow. + return; + } + + if ( 200 !== wp_remote_retrieve_response_code( $response ) ) { + // this is a silent failure, we should log this or flag it somehow. + return; + } + + $license_data = json_decode( wp_remote_retrieve_body( $response ) ); + self::store_license_metadata_from_response( $license_data, 'free' ); + + if ( isset( $license_data->license ) ) { + update_option( 'edac_license_status', $license_data->license ); + if ( 'valid' === $license_data->license ) { + // License has recovered to valid, so clear error notices and fallback marker. + delete_option( 'edac_license_error' ); + delete_option( 'edacp_license_error' ); + // Free revalidated successfully after fallback; remove the temporary + // fallback marker so UI can reflect connected state again. + delete_option( 'edac_fallback_active' ); + } + } + + // Verify and update JWT public key daily before validation fails. + // This ensures the site always has the latest key from the issuer without any downtime. + self::verify_and_update_public_key(); + } + + /** + * License check cron schedule + * + * @return void + */ + public function check_license_cron() { + if ( ! wp_next_scheduled( 'edac_check_license_hook' ) ) { + wp_schedule_event( time(), 'daily', 'edac_check_license_hook' ); + } + } + + /** + * Determines whether to verify SSL for licensing requests. + * + * Can be disabled by returning `false` to the `edac_verify_ssl_for_licensing` filter. + * + * @since 1.xx.x + * + * @return bool Whether to verify SSL. Defaults to `true`. + */ + public static function verify_ssl() { + return (bool) apply_filters( 'edac_verify_ssl_for_licensing', true ); + } + + /** + * Get the MyDot API endpoint. + * + * Can be overridden by filtering the value with the `edac_mydot_api_endpoint` filter. + * + * @since 1.xx.x + * + * @return string The API endpoint URL (with protocol). Defaults to `https://my.equalizedigital.com`. + */ + public static function get_api_endpoint() { + /** + * Filters the MyDot API endpoint URL. + * + * @since 1.xx.x + * + * @param string $default The default or environment-overridden API endpoint URL. + */ + return apply_filters( 'edac_mydot_api_endpoint', self::API_ENDPOINT ); + } + + /** + * Get the MyDot product ID. + * + * Can be overridden by filtering the value with the `edac_mydot_product_id` filter. + * + * @since 1.xx.x + * + * @return int The product ID. Defaults to 1666. + */ + public static function get_product_id(): int { + /** + * Filters the MyDot product ID. + * + * @since 1.xx.x + * + * @param int $default The default product ID. + */ + return (int) apply_filters( 'edac_mydot_product_id', self::PRODUCT_ID ); + } + + /** + * Get the active license key. + * + * Both free and pro plugins store their license key in the same option 'edacp_license_key'. + * The actual product type (free vs pro) is determined by the EDD response item_id at activation + * time and stored in the metadata. This function simply retrieves the key itself. + * + * @return string The license key or empty string if none stored. + * + * @since 1.xx.x + */ + public static function get_license_key(): string { + return (string) get_option( 'edacp_license_key', '' ); + } + + /** + * Handle admin-post for site registration (button on License page). + * + * @since 1.xx.x + * + * @return void + */ + public function handle_jwt_register_post() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to register this site.', 'accessibility-checker' ) ); + } + check_admin_referer( 'edac_jwt_register', 'edac_jwt_register_nonce' ); + $this->handle_site_registration(); + $redirect = wp_get_referer(); + if ( ! $redirect ) { + $redirect = admin_url(); + } + wp_safe_redirect( $redirect ); + exit; + } + + /** + * Handle admin-post for site unregistration (button on License page). + * + * @since 1.xx.x + * + * @return void + */ + public function handle_jwt_unregister_post() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to unregister this site.', 'accessibility-checker' ) ); + } + check_admin_referer( 'edac_jwt_unregister', 'edac_jwt_unregister_nonce' ); + $this->handle_site_unregistration(); + $redirect = wp_get_referer(); + if ( ! $redirect ) { + $redirect = admin_url(); + } + wp_safe_redirect( $redirect ); + exit; + } + + /** + * Handle the site registration process including UI feedback. + * + * @since 1.xx.x + * + * @return bool True when registration succeeded and state was saved. + */ + private function handle_site_registration(): bool { + $license_key = self::get_license_key(); + if ( empty( $license_key ) ) { + set_transient( + $this->get_notice_transient_key(), + [ + 'type' => 'error', + 'message' => __( 'No license key found. Please activate a license before registering your site.', 'accessibility-checker' ), + ], + self::NOTICE_TRANSIENT_TTL + ); + return false; + } + $site_url = site_url(); + $site_name = get_bloginfo( 'name' ); + + $response_data = self::register_site( $license_key, $site_url, $site_name, true, true ); + if ( empty( $response_data['success'] ) ) { + $error_msg = ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while registering the site.', 'accessibility-checker' ); + set_transient( + $this->get_notice_transient_key(), + [ + 'type' => 'error', + 'message' => $error_msg, + ], + self::NOTICE_TRANSIENT_TTL + ); + return false; + } + if ( isset( $response_data['data'] ) ) { + $data = $response_data['data']; + if ( ! empty( $data['jwt_public_key'] ) ) { + update_option( 'edac_jwt_public_key', $data['jwt_public_key'] ); + } + if ( ! empty( $data['site_id'] ) ) { + update_option( 'edac_site_id', $data['site_id'] ); + } + if ( ! empty( $data['collection_interval_days'] ) ) { + update_option( 'edac_collection_interval_days', $data['collection_interval_days'] ); + } + if ( ! empty( $data['next_collection'] ) ) { + update_option( 'edac_next_collection', $data['next_collection'] ); + } + set_transient( + $this->get_notice_transient_key(), + [ + 'type' => 'success', + 'message' => __( 'Site registered successfully. Your site is now configured to use additional accessibility services.', 'accessibility-checker' ), + ], + self::NOTICE_TRANSIENT_TTL + ); + return true; + } else { + set_transient( + $this->get_notice_transient_key(), + [ + 'type' => 'warning', + 'message' => __( 'Site registration completed, but the response data was not in the expected format. Some features may not work correctly.', 'accessibility-checker' ), + ], + self::NOTICE_TRANSIENT_TTL + ); + return false; + } + } + + /** + * Refresh enrollment after Pro activation when the site is already connected. + * + * This keeps backend enrollment context aligned on free->pro upgrades without + * requiring users to manually disconnect/reconnect reports. + * + * @param string $license Activated license key. + * @param string $url Site URL from activation hook. + * @param object|null $license_data Activation response payload. + * @return void + */ + public function handle_pro_license_activation( $license = '', $url = '', $license_data = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed,VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Hook signature intentionally accepts action args for compatibility. + $site_id = (string) get_option( 'edac_site_id', '' ); + if ( '' === $site_id ) { + return; + } + + $license_key = self::get_license_key(); + if ( '' === $license_key ) { + return; + } + + $response_data = self::register_site( $license_key, site_url(), get_bloginfo( 'name' ), true, true ); + if ( empty( $response_data['success'] ) || empty( $response_data['data'] ) ) { + return; + } + + $data = $response_data['data']; + if ( ! empty( $data['jwt_public_key'] ) ) { + update_option( 'edac_jwt_public_key', $data['jwt_public_key'] ); + } + if ( ! empty( $data['site_id'] ) ) { + update_option( 'edac_site_id', $data['site_id'] ); + } + if ( ! empty( $data['collection_interval_days'] ) ) { + update_option( 'edac_collection_interval_days', $data['collection_interval_days'] ); + } + if ( ! empty( $data['next_collection'] ) ) { + update_option( 'edac_next_collection', $data['next_collection'] ); + } + } + + /** + * Handle the site unregistration process including UI feedback. + * + * @since 1.xx.x + * + * @param string $license Optional license key passed from deactivation hooks. + * @param string $url Optional site URL from deactivation hooks. + * @param object|null $license_data Optional license payload from deactivation hooks. + * + * @return void + */ + public function handle_site_unregistration( $license = '', $url = '', $license_data = null ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed,VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- Hook signature intentionally accepts action args for compatibility. + $preserve_license = self::should_preserve_license_on_unregistration(); + $site_id = get_option( 'edac_site_id' ); + $license_key = '' !== (string) $license ? (string) $license : self::get_license_key(); + if ( empty( $site_id ) || empty( $license_key ) ) { + // Clear local report connection state even when required data is missing. + self::clear_report_connection_state(); + if ( ! $preserve_license ) { + // Free disconnect keeps the historical behavior of clearing the key. + self::clear_free_disconnect_license_state(); + } + set_transient( + $this->get_notice_transient_key(), + [ + 'type' => 'error', + 'message' => __( 'Unable to unregister site. Required registration data is missing.', 'accessibility-checker' ), + ], + self::NOTICE_TRANSIENT_TTL + ); + return; + } + $response_data = self::unregister_site( $site_id, get_site_url(), $license_key ); + + // Always clear local report state so reports are disabled immediately. + self::clear_report_connection_state(); + if ( ! $preserve_license ) { + // Free disconnect keeps the historical behavior of clearing the key, + // even when the API response is an error. + self::clear_free_disconnect_license_state(); + } + + if ( empty( $response_data['success'] ) ) { + $error_msg = ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while unregistering the site.', 'accessibility-checker' ); + set_transient( + $this->get_notice_transient_key(), + [ + 'type' => 'error', + 'message' => $error_msg, + ], + self::NOTICE_TRANSIENT_TTL + ); + return; + } + set_transient( + $this->get_notice_transient_key(), + [ + 'type' => 'success', + 'message' => __( 'Site unregistered successfully. Your site will no longer receive email reports.', 'accessibility-checker' ), + ], + self::NOTICE_TRANSIENT_TTL + ); + } + + /** + * Register a site with the MyDot API. + * + * @since 1.xx.x + * + * @param string $license_key The license key to register the site with. + * @param string $site_url The URL of the site to register. + * @param string $site_name The name of the site to register. + * @param bool $weekly_reports Whether to enable weekly reports. + * @param bool $monthly_reports Whether to enable monthly reports. + * + * @return array The response data from the API. + */ + public static function register_site( $license_key, $site_url, $site_name, $weekly_reports = true, $monthly_reports = true ) { + if ( empty( $license_key ) ) { + return [ + 'success' => false, + 'message' => __( 'No license key provided.', 'accessibility-checker' ), + ]; + } + $request_data = [ + 'site_url' => $site_url, + 'site_name' => $site_name, + 'license_key' => $license_key, + 'weekly_reports' => $weekly_reports, + 'monthly_reports' => $monthly_reports, + ]; + + if ( self::should_use_filtered_product_id_for_enrollment() ) { + $request_data['product_id'] = self::get_product_id(); + } + $response = wp_remote_post( + self::get_api_endpoint() . '/wp-json/myed-email-reports/v1/register-site', + [ + 'headers' => [ 'Content-Type' => 'application/json' ], + 'body' => wp_json_encode( $request_data ), + 'method' => 'POST', + 'data_format' => 'body', + 'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments. + 'sslverify' => self::verify_ssl(), + ] + ); + if ( is_wp_error( $response ) ) { + return [ + 'success' => false, + 'message' => $response->get_error_message(), + ]; + } + $response_code = wp_remote_retrieve_response_code( $response ); + $response_body = wp_remote_retrieve_body( $response ); + $response_data = json_decode( $response_body, true ); + if ( 200 !== $response_code || empty( $response_body ) ) { + return [ + 'success' => false, + 'message' => ( ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while registering the site.', 'accessibility-checker' ) ), + ]; + } + return $response_data; + } + + /** + * Unregister a site from the MyDot API. + * + * @since 1.xx.x + * + * @param string $site_id The site ID for the registered site. + * @param string $site_url The URL of the site to unregister. + * @param string $license_key The license key associated with the site. + * + * @return array The response data from the API. + */ + public static function unregister_site( $site_id, $site_url, $license_key ) { + if ( empty( $site_id ) || empty( $site_url ) || empty( $license_key ) ) { + return [ + 'success' => false, + 'message' => __( 'Missing required parameters for unregistration.', 'accessibility-checker' ), + ]; + } + $request_data = [ + 'site_id' => $site_id, + 'site_url' => $site_url, + 'license_key' => $license_key, + ]; + + if ( self::should_use_filtered_product_id_for_enrollment() ) { + $request_data['product_id'] = self::get_product_id(); + } + $response = wp_remote_post( + self::get_api_endpoint() . '/wp-json/myed-email-reports/v1/unregister-site', + [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode( $request_data ), + 'method' => 'POST', + 'data_format' => 'body', + 'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments. + 'sslverify' => self::verify_ssl(), + ] + ); + if ( is_wp_error( $response ) ) { + return [ + 'success' => false, + 'message' => $response->get_error_message(), + ]; + } + $response_code = wp_remote_retrieve_response_code( $response ); + $response_body = wp_remote_retrieve_body( $response ); + $response_data = json_decode( $response_body, true ); + if ( 200 !== $response_code || empty( $response_body ) ) { + return [ + 'success' => false, + 'message' => ( ! empty( $response_data['message'] ) ? $response_data['message'] : __( 'Unknown error occurred while unregistering the site.', 'accessibility-checker' ) ), + ]; + } + return $response_data; + } + + /** + * Get the expected issuer for JWT validation (RFC 8725). + * + * @since 1.xx.x + * + * @return string The issuer URL/identifier. + */ + public static function get_jwt_issuer() { + // strip the protocol for issuer comparison. + return apply_filters( 'edac_jwt_issuer', preg_replace( '#^https?://#', '', self::get_api_endpoint() ) ); + } + + /** + * Get the expected audience for JWT validation (RFC 8725). + * + * @since 1.xx.x + * + * @return string The audience identifier (site URL or API endpoint identifier). + */ + public static function get_jwt_audience() { + // strip the protocol for audience comparison. + return apply_filters( 'edac_jwt_audience', preg_replace( '#^https?://#', '', home_url() ) ); + } + + /** + * Validate a JWT token using the stored public key (RFC 8725 compliant). + * + * Validates: + * - Token structure (3 parts separated by dots) + * - Header algorithm (RS256) + * - Signature using stored public key + * - Token expiration (exp claim) + * - Issuer (iss claim) per RFC 8725 to prevent token substitution attacks + * - Audience (aud claim) per RFC 8725 to ensure token is for this recipient + * - Not Before (nbf claim) if present + * + * @since 1.xx.x + * + * @param string $token The JWT token to validate. + * @return bool True if the token is valid, false otherwise. + */ + public static function validate_jwt_token( $token ) { + if ( empty( $token ) ) { + return false; + } + $public_key = get_option( 'edac_jwt_public_key' ); + if ( empty( $public_key ) ) { + return false; + } + $parts = explode( '.', $token ); + if ( count( $parts ) !== 3 ) { + return false; + } + list( $header_b64, $payload_b64, $signature_b64 ) = $parts; + + $header_json = self::base64url_decode_strict( $header_b64 ); + $payload_json = self::base64url_decode_strict( $payload_b64 ); + if ( false === $header_json || false === $payload_json ) { + return false; + } + + $header = json_decode( $header_json, true ); + $payload = json_decode( $payload_json, true ); + if ( ! $header || ! $payload ) { + return false; + } + + // Require that aud, iss and exp all exist. + if ( ! isset( $payload['aud'], $payload['iss'], $payload['exp'] ) ) { + return false; + } + // The exp should be numeric and an int. + if ( ! is_numeric( $payload['exp'] ) ) { + return false; + } + $exp = (int) $payload['exp']; + + $message = $header_b64 . '.' . $payload_b64; + $signature_decoded = self::base64url_decode_strict( $signature_b64 ); + if ( false === $signature_decoded ) { + return false; + } + + $algo = $header['alg'] ?? 'RS256'; + if ( 'RS256' !== $algo ) { + return false; + } + + $public_key_resource = openssl_pkey_get_public( $public_key ); + if ( ! $public_key_resource ) { + return false; + } + + $verify_result = openssl_verify( $message, $signature_decoded, $public_key_resource, OPENSSL_ALGO_SHA256 ); + if ( 1 !== $verify_result ) { + return false; + } + + $current_time = time(); + // Validate expiration (exp claim) - required by RFC 8725. + if ( $exp < $current_time ) { + return false; + } + + // RFC 8725: Validate issuer claim to prevent token substitution attacks. + $expected_iss = self::get_jwt_issuer(); + if ( $payload['iss'] !== $expected_iss ) { + return false; + } + + // RFC 8725: Validate audience claim - if issuer issues JWTs for multiple recipients, + // the JWT must contain an "aud" claim and must be validated. + $expected_aud = self::get_jwt_audience(); + $token_aud = $payload['aud']; + // aud can be a string or an array of strings per RFC 7519. + $aud_list = is_array( $token_aud ) ? $token_aud : [ $token_aud ]; + if ( ! in_array( $expected_aud, $aud_list, true ) ) { + return false; + } + + // RFC 8725: Validate not-before claim (nbf) if present. + if ( isset( $payload['nbf'] ) ) { + if ( ! is_numeric( $payload['nbf'] ) ) { + return false; + } + if ( (int) $payload['nbf'] > $current_time ) { + return false; + } + } + + return true; + } + + /** + * Validate JWT token with reactive fallback. + * + * If validation fails, attempt to refresh the public key from the issuer and retry. + * This handles cases where the issuer rotated keys but the site's cron hasn't run yet. + * + * @since 1.xx.x + * + * @param string $token The JWT token to validate. + * @return bool True if valid (either on first try or after key refresh), false otherwise. + */ + public static function validate_jwt_token_with_fallback( $token ) { + // Try initial validation. + if ( self::validate_jwt_token( $token ) ) { + return true; + } + + // Validation failed. Try to refresh the public key from the issuer. + if ( self::refresh_public_key_from_issuer() ) { + // Key was refreshed, retry validation with the new key. + return self::validate_jwt_token( $token ); + } + + // Still invalid after refresh attempt. + return false; + } + + /** + * Permission helper for validating JWT token in REST request with fallback (Option 2 + 3). + * + * @since 1.xx.x + * + * @param \WP_REST_Request $request The REST request object. + * @return bool True if valid JWT token is present, false otherwise. + */ + public static function validate_jwt_token_in_request_with_fallback( $request ) { + if ( ! $request instanceof \WP_REST_Request ) { + return false; + } + + // Extract the JWT token from the Authorization header. + $auth_header = $request->get_header( 'Authorization' ); + $parts = null !== $auth_header ? explode( ' ', $auth_header ) : []; + if ( ! empty( $auth_header ) ) { + if ( count( $parts ) === 2 && 'Bearer' === $parts[0] ) { + // Use the fallback validator which will refresh key if needed. + return self::validate_jwt_token_with_fallback( $parts[1] ); + } + } + + // No valid Bearer token found. + return false; + } + + /** + * Check if stored JWT public key needs to be updated from a fresh registration. + * + * Called after successful site registration to verify the stored key is current. + * If the stored key doesn't match what the issuer sent, it's already been rotated. + * + * Uses a simple GET request since public keys don't require authentication. + * + * @since 1.xx.x + * + * @return bool True if public key was updated or is current, false on error. + */ + public static function verify_and_update_public_key() { + $stored_key = get_option( 'edac_jwt_public_key' ); + + if ( empty( $stored_key ) ) { + return false; + } + + // Make a lightweight GET request for the current public key. + $response = self::safe_remote_get( self::get_api_endpoint() . '/wp-json/myed-email-reports/v1/public-key' ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return false; + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + + // If issuer returned a new public key, store it immediately. + if ( ! empty( $data['jwt_public_key'] ) && $data['jwt_public_key'] !== $stored_key ) { + update_option( 'edac_jwt_public_key', $data['jwt_public_key'] ); + return true; // Key was updated. + } + + return true; // Key is current. + } + + /** + * Attempt to update the public key from API on failed JWT validation. + * + * If JWT validation fails, this optional step re-requests the public key + * from the issuer. Useful if the issuer rotated keys but the site hasn't + * refreshed them yet. + * + * This is called AFTER a JWT fails validation, so only use as a fallback + * to avoid constant API calls. + * + * Uses a simple GET request since public keys don't require authentication. + * + * @since 1.xx.x + * + * @return bool True if key was retrieved and stored, false otherwise. + */ + public static function refresh_public_key_from_issuer() { + $response = self::safe_remote_get( self::get_api_endpoint() . '/wp-json/myed-email-reports/v1/get-public-key' ); + + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + return false; + } + + $data = json_decode( wp_remote_retrieve_body( $response ), true ); + if ( ! empty( $data['public_key'] ) ) { + update_option( 'edac_jwt_public_key', $data['public_key'] ); + return true; + } + + return false; + } + + /** + * Perform a safe GET request compatible with VIP and non-VIP environments. + * + * Uses vip_safe_wp_remote_get() if available, otherwise falls back to wp_remote_get(). + * + * @param string $url The URL to request. + * @param array $args Optional request args. + * @return array|\WP_Error Response array or WP_Error on failure. + */ + private static function safe_remote_get( string $url, array $args = [] ) { + $defaults = [ + 'timeout' => 15, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- accommodation for slow hosting environments. + 'sslverify' => self::verify_ssl(), + ]; + $args = wp_parse_args( $args, $defaults ); + + if ( function_exists( 'vip_safe_wp_remote_get' ) ) { + $timeout = isset( $args['timeout'] ) ? max( 1, min( 5, (int) $args['timeout'] ) ) : 5; + $retry_count = isset( $args['retry'] ) ? (int) $args['retry'] : 10; + return vip_safe_wp_remote_get( $url, '', 3, $timeout, $retry_count, $args ); + } + + return wp_remote_get( $url, $args ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get -- fallback for non-VIP environments. + } + + /** + * Display transient-based admin notices for the current user. + */ + public function display_admin_notices() { + $key = $this->get_notice_transient_key(); + $notice = get_transient( $key ); + + if ( empty( $notice['type'] ) || empty( $notice['message'] ) ) { + return; + } + delete_transient( $key ); + + $allowed_types = [ 'success', 'error', 'warning', 'info' ]; + $type = in_array( $notice['type'], $allowed_types, true ) ? $notice['type'] : 'info'; + $message = is_string( $notice['message'] ) ? $notice['message'] : ''; + if ( '' === $message ) { + return; + } + + printf( + '

%2$s

', + esc_attr( $type ), + esc_html( $message ) + ); + } + + /** + * Build the transient key for connector notices. + * + * @param int|null $user_id Optional user ID; defaults to current user. + * + * @return string + */ + private function get_notice_transient_key( $user_id = null ) { + $user_id = null === $user_id ? get_current_user_id() : (int) $user_id; + + return 'edac_connector_notice_' . absint( $user_id ); + } + + /** + * Strict Base64URL decode that returns false on invalid input. + * + * @since 1.xx.x + * + * @param string $b64url The Base64URL encoded string. + * @return string|false The decoded string, or false on failure. + */ + private static function base64url_decode_strict( string $b64url ) { + $b64 = strtr( $b64url, '-_', '+/' ); + $pad = strlen( $b64 ) % 4; + if ( $pad ) { + $b64 .= str_repeat( '=', 4 - $pad ); + } + return base64_decode( $b64, true ); + } + + /** + * Infer license metadata from an EDD response. + * + * Determines the license type (free/pro), level (single-site/multi-site/unlimited/lifetime), + * and formats response fields for storage. + * + * Primary inference uses product ID (most reliable when free/pro have distinct IDs). + * Secondary fallback uses item_name string matching. + * Tertiary fallback uses the source context (e.g., 'free', 'pro'). + * + * @param object|array $license_data EDD response payload. + * @param string $source Activation/check source context ('free' or 'pro'). + * @return array Inferred metadata with keys: type, level, item_id, item_name, expires, license_limit, site_count, activations_left, last_response_at. + * + * @since 1.xx.x + */ + public static function infer_license_metadata_from_response( $license_data, string $source ): array { + if ( ! is_object( $license_data ) && ! is_array( $license_data ) ) { + return [ + 'type' => self::LICENSE_TYPE_UNKNOWN, + 'level' => self::LICENSE_LEVEL_UNKNOWN, + 'item_id' => 0, + 'item_name' => '', + 'expires' => '', + 'license_limit' => '', + 'site_count' => '', + 'activations_left' => '', + 'last_response_at' => time(), + ]; + } + + $data = is_object( $license_data ) ? get_object_vars( $license_data ) : $license_data; + + // Validate response has at least basic structure (item_id or item_name). + if ( empty( $data['item_id'] ) && empty( $data['item_name'] ) ) { + // Incomplete response; use source as last resort. + return [ + 'type' => in_array( $source, [ self::LICENSE_TYPE_FREE, self::LICENSE_TYPE_PRO ], true ) ? $source : self::LICENSE_TYPE_UNKNOWN, + 'level' => self::LICENSE_LEVEL_UNKNOWN, + 'item_id' => 0, + 'item_name' => '', + 'expires' => '', + 'license_limit' => '', + 'site_count' => '', + 'activations_left' => '', + 'last_response_at' => time(), + ]; + } + + $item_name = sanitize_text_field( (string) ( $data['item_name'] ?? '' ) ); + $item_id = absint( $data['item_id'] ?? 0 ); + $limit_raw = $data['license_limit'] ?? ''; + + // Primary: infer from product ID in response — most reliable when free/pro have distinct IDs. + $type = self::LICENSE_TYPE_UNKNOWN; + $pro_product_id = (int) apply_filters( 'edac_pro_product_id', 0 ); + if ( $item_id > 0 ) { + if ( self::PRODUCT_ID === $item_id ) { + $type = self::LICENSE_TYPE_FREE; + } elseif ( $pro_product_id > 0 && $pro_product_id === $item_id ) { + // Inferred as Pro because Pro's product ID filter matched. + $type = self::LICENSE_TYPE_PRO; + } + } + + // Secondary: infer from item_name string match. + if ( self::LICENSE_TYPE_UNKNOWN === $type && '' !== $item_name ) { + $item_name_normalized = strtolower( $item_name ); + if ( false !== strpos( $item_name_normalized, 'pro' ) ) { + $type = self::LICENSE_TYPE_PRO; + } elseif ( false !== strpos( $item_name_normalized, 'free' ) ) { + $type = self::LICENSE_TYPE_FREE; + } + } + + // Fallback: use source context. + if ( self::LICENSE_TYPE_UNKNOWN === $type ) { + $type = in_array( $source, [ self::LICENSE_TYPE_FREE, self::LICENSE_TYPE_PRO ], true ) ? $source : self::LICENSE_TYPE_UNKNOWN; + } + + $level = self::LICENSE_LEVEL_UNKNOWN; + if ( is_numeric( $limit_raw ) ) { + $limit = (int) $limit_raw; + if ( 0 === $limit ) { + $level = self::LICENSE_LEVEL_UNLIMITED; // EDD uses 0 to mean no activation limit. + } elseif ( 1 === $limit ) { + $level = self::LICENSE_LEVEL_SINGLE_SITE; + } elseif ( $limit > 1 ) { + $level = self::LICENSE_LEVEL_MULTI_SITE; + } + } elseif ( is_string( $limit_raw ) ) { + $limit_normalized = strtolower( trim( $limit_raw ) ); + if ( in_array( $limit_normalized, [ self::LICENSE_LEVEL_LIFETIME, self::LICENSE_LEVEL_UNLIMITED ], true ) ) { + $level = $limit_normalized; + } + } + + return [ + 'type' => $type, + 'level' => $level, + 'item_id' => $item_id, + 'item_name' => $item_name, + 'expires' => sanitize_text_field( (string) ( $data['expires'] ?? '' ) ), + 'license_limit' => sanitize_text_field( (string) $limit_raw ), + 'site_count' => sanitize_text_field( (string) ( $data['site_count'] ?? '' ) ), + 'activations_left' => sanitize_text_field( (string) ( $data['activations_left'] ?? '' ) ), + 'last_response_at' => time(), + ]; + } + + /** + * Persist inferred license metadata from an EDD response. + * + * @param object|array|null $license_data EDD response payload. + * @param string $source Activation/check source context. + * @return void + */ + private static function store_license_metadata_from_response( $license_data, string $source ): void { + $metadata = self::infer_license_metadata_from_response( $license_data, $source ); + update_option( self::LICENSE_METADATA_OPTION, $metadata ); + } + + /** + * Clear stored inferred license metadata. + * + * @return void + */ + private static function clear_stored_license_metadata(): void { + delete_option( self::LICENSE_METADATA_OPTION ); + } +} diff --git a/includes/classes/class-plugin.php b/includes/classes/class-plugin.php index 76f33e9fa..1818822a6 100644 --- a/includes/classes/class-plugin.php +++ b/includes/classes/class-plugin.php @@ -10,6 +10,8 @@ use EDAC\Admin\Admin; use EDAC\Admin\Meta_Boxes; use EDAC\Admin\Orphaned_Issues_Cleanup; +use EqualizeDigital\AccessibilityChecker\Admin\AdminPage\AccessibilityReportsPage; +use EqualizeDigital\AccessibilityChecker\MyDot\Connector; use EqualizeDigital\AccessibilityChecker\WPCLI\BootstrapCLI; use EqualizeDigital\AccessibilityChecker\Fixes\FixesManager; @@ -47,6 +49,12 @@ public function __construct() { $this->register_fixes_manager(); + $accessibility_reports = new AccessibilityReportsPage( 'manage_options' ); + $accessibility_reports->add_page(); + + $connector = new Connector(); + $connector->init(); + // When WP CLI is enabled, load the CLI commands. if ( defined( 'WP_CLI' ) && WP_CLI ) { add_action( diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php index e364bfb81..c1ca2a606 100644 --- a/includes/classes/class-rest-api.php +++ b/includes/classes/class-rest-api.php @@ -11,6 +11,7 @@ use EDAC\Admin\Scans_Stats; use EDAC\Admin\Settings; use EDAC\Admin\Purge_Post_Data; +use EqualizeDigital\AccessibilityChecker\MyDot\Connector; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -107,7 +108,12 @@ function () use ( $ns, $version ) { [ 'methods' => 'GET', 'callback' => [ $this, 'get_scans_stats' ], - 'permission_callback' => function () { + 'permission_callback' => function ( $request ) { + if ( Connector::validate_jwt_token_in_request_with_fallback( $request ) ) { + // Only allow if the site is still registered (site_id present). + $site_id = (string) get_option( 'edac_site_id', '' ); + return '' !== $site_id; + } return current_user_can( 'edit_posts' ); }, ] diff --git a/includes/deactivation.php b/includes/deactivation.php index c6f76cfd9..030381949 100644 --- a/includes/deactivation.php +++ b/includes/deactivation.php @@ -21,4 +21,7 @@ function edac_deactivation() { // Unschedule cleanup of orphaned issues. Orphaned_Issues_Cleanup::unschedule_event(); + + // Unschedule the daily license check cron event. + wp_clear_scheduled_hook( 'edac_check_license_hook' ); } diff --git a/package-lock.json b/package-lock.json index 16537c244..059f80146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "accessibility-checker", - "version": "1.38.0", + "version": "1.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "accessibility-checker", - "version": "1.38.0", + "version": "1.39.0", "hasInstallScript": true, "license": "GPL-2.0+", "devDependencies": { diff --git a/partials/settings-page.php b/partials/settings-page.php index 7992f5af9..bcf81e8f0 100644 --- a/partials/settings-page.php +++ b/partials/settings-page.php @@ -29,6 +29,38 @@ // sort settings tab items. if ( is_array( $edac_settings_tab_items ) ) { + $edac_settings_tab_items = array_values( + array_filter( + $edac_settings_tab_items, + function ( $tab ) { + if ( empty( $tab['capability'] ) ) { + return true; + } + + return current_user_can( $tab['capability'] ); + } + ) + ); + + $edac_tab_aliases = [ + 'connected-services' => 'license', + ]; + $edac_normalized_tabs = []; + $edac_seen_tab_slugs = []; + + foreach ( $edac_settings_tab_items as $edac_settings_tab_item ) { + $edac_settings_tab_item['slug'] = $edac_tab_aliases[ $edac_settings_tab_item['slug'] ] ?? $edac_settings_tab_item['slug']; + + if ( in_array( $edac_settings_tab_item['slug'], $edac_seen_tab_slugs, true ) ) { + continue; + } + + $edac_seen_tab_slugs[] = $edac_settings_tab_item['slug']; + $edac_normalized_tabs[] = $edac_settings_tab_item; + } + + $edac_settings_tab_items = $edac_normalized_tabs; + usort( $edac_settings_tab_items, function ( $a, $b ) { @@ -54,10 +86,27 @@ function ( $a, $b ) { } // phpcs:enable WordPress.Security.NonceVerification.Recommended -$edac_settings_tab = ( array_search( $edac_settings_tab, array_column( $edac_settings_tab_items, 'slug' ), true ) !== false ) ? $edac_settings_tab : $edac_default_tab; +if ( 'connected-services' === $edac_settings_tab && array_search( 'license', array_column( $edac_settings_tab_items, 'slug' ), true ) !== false ) { + $edac_settings_tab = 'license'; +} + +if ( 'license' === $edac_settings_tab && array_search( 'license', array_column( $edac_settings_tab_items, 'slug' ), true ) === false && array_search( 'accessibility-reports', array_column( $edac_settings_tab_items, 'slug' ), true ) !== false ) { + $edac_settings_tab = 'accessibility-reports'; +} + +$edac_settings_tab = ( array_search( $edac_settings_tab, array_column( $edac_settings_tab_items, 'slug' ), true ) !== false ) ? $edac_settings_tab : $edac_default_tab; +$edac_settings_classes = [ 'wrap', 'edac-settings' ]; + +if ( ! EDAC_KEY_VALID ) { + $edac_settings_classes[] = 'pro-callout-wrapper'; +} + +if ( 'accessibility-reports' === $edac_settings_tab ) { + $edac_settings_classes[] = 'edac-settings--reports'; +} ?> -
+

@@ -68,6 +117,7 @@ function ( $a, $b ) { $edac_slug = $edac_settings_tab_item['slug'] ? $edac_settings_tab_item['slug'] : null; $edac_query_var = $edac_slug ? '&tab=' . $edac_slug : ''; $edac_label = $edac_settings_tab_item['label']; + $edac_badge = $edac_settings_tab_item['badge'] ?? ''; ?> - nav-tab-active"> + nav-tab-active"> + + + + + '; diff --git a/src/admin/sass/accessibility-checker-admin.scss b/src/admin/sass/accessibility-checker-admin.scss index 9f123733d..16bd13099 100644 --- a/src/admin/sass/accessibility-checker-admin.scss +++ b/src/admin/sass/accessibility-checker-admin.scss @@ -719,6 +719,205 @@ } } +.nav-tab-wrapper .nav-tab { + .edac-settings-tab__label, + .edac-settings-tab__badge { + display: inline-flex; + align-items: center; + } + + .edac-settings-tab__badge { + margin-left: 8px; + padding: 2px 12px; + border-radius: 999px; + background: #ffcc17; + color: #101828; + font-size: 0.875em; + font-weight: 600; + line-height: 1.6; + } +} + +.edac-reports-page { + margin-top: 24px; + display: grid; + gap: 16px; + + * { + box-sizing: border-box; + } + + @include helpers.breakpoint(lg) { + grid-template-columns: minmax(0, 1fr) 320px; + align-items: start; + } + + &__main { + display: grid; + gap: 24px; + + min-width: 0; + } + + &__panel { + + h2 { + margin: 0 0 24px; + } + } + + &__intro { + max-width: 980px; + margin: 0 0 32px; + } + + &__single-action { + margin-top: 24px; + } + + &__button { + min-height: 52px; + padding: 0 20px; + border-radius: 8px; + } + + &__button--secondary { + color: #3f5bf6; + border-color: #3f5bf6; + background: #fff; + } + + &__license-input, + &__license-mask { + display: block; + width: 100%; + max-width: none; + margin: 0 0 20px; + padding: 18px 20px; + border: 1px solid #b5bcc4; + border-radius: 0; + background: #fff; + } + + &__license-mask { + color: #1d2327; + } + + &__legal { + margin: 24px 0 0; + } +} + +.edac-reports-grid { + display: grid; + gap: 13px; + + &--two { + @include helpers.breakpoint(md) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } +} + +.edac-reports-card { + padding: 26px 28px 30px; + border: 1px solid #d7dce1; + border-radius: 10px; + background: #fff; + + h3 { + margin: 0 0 18px; + } + + ul { + margin: 0 0 20px 20px; + list-style-type: disc; + } + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + } + + &__status { + display: inline-flex; + align-items: center; + justify-content: center; + color: #117d11; + flex: 0 0 auto; + } + + &__status-icon { + display: inline-flex; + width: 27px; + height: 27px; + } + + &__status-icon svg { + display: inline-flex; + width: 27px; + height: 27px; + } + + &__status--warning { + border-color: #d17b00; + color: #d17b00; + } + + &__meta { + display: grid; + gap: 4px; + margin: 20px 0 28px; + } +} + +.edac-reports-stat { + text-align: center; + padding-top: 28px; + + &__label { + margin-bottom: 10px; + color: #1d2327; + font-size: 15px; + font-weight: 600; + line-height: 1.2; + } + + &__value { + color: #1d2327; + font-size: 36px; + font-weight: 500; + line-height: 1.05; + } + + &__caption { + margin-top: 12px; + color: #1d2327; + font-size: 20px; + font-weight: 400; + line-height: 1.2; + } +} + +.edac-reports-preview { + display: none; + min-width: 0; + + @include helpers.breakpoint(lg) { + display: block; + width: 320px; + } + + &__image { + display: block; + width: 100%; + height: auto; + margin-bottom: -15px; + } +} + .ac-simplified-summary { max-width: 800px; margin-left: auto; @@ -732,6 +931,10 @@ .edac-settings { max-width: 800px; + &--reports { + max-width: 1390px; + } + &.pro-callout-wrapper { max-width: fit-content; } @@ -745,6 +948,7 @@ padding: 15px; border: solid 1px variables.$color-gray-light; margin-top: 20px; + border-radius: 10px; } &-general { @@ -794,6 +998,15 @@ } } +.edac-settings--reports { + + .edac-reports-page__legal { + @include helpers.breakpoint(lg) { + grid-column: 1 / -1; + } + } +} + .edac-welcome { background-color: variables.$color-white; border: 1px solid variables.$color-gray-light; diff --git a/tests/phpunit/Admin/AccessibilityReportsPageTest.php b/tests/phpunit/Admin/AccessibilityReportsPageTest.php new file mode 100644 index 000000000..7ab715234 --- /dev/null +++ b/tests/phpunit/Admin/AccessibilityReportsPageTest.php @@ -0,0 +1,161 @@ +page = new AccessibilityReportsPage( 'manage_options' ); + + delete_option( 'edac_license_status' ); + delete_option( 'edacp_license_status' ); + delete_option( 'edac_site_id' ); + delete_option( 'edac_fallback_active' ); + delete_option( 'edacp_enable_archive_scanning' ); + delete_option( 'edac_next_collection' ); + } + + /** + * Clean up test state. + */ + public function tearDown(): void { + delete_option( 'edac_license_status' ); + delete_option( 'edacp_license_status' ); + delete_option( 'edac_site_id' ); + delete_option( 'edac_fallback_active' ); + delete_option( 'edacp_enable_archive_scanning' ); + delete_option( 'edac_next_collection' ); + + parent::tearDown(); + } + + /** + * Invoke a private method on the reports page. + * + * @param string $method_name Method name. + * @param array $arguments Arguments. + * @return mixed + * @throws ReflectionException If reflection fails. + */ + private function invoke_private_method( string $method_name, array $arguments = [] ) { + $reflection = new ReflectionClass( AccessibilityReportsPage::class ); + $method = $reflection->getMethod( $method_name ); + $method->setAccessible( true ); + + return $method->invokeArgs( $this->page, $arguments ); + } + + /** + * Invoke a private static method on the reports page. + * + * @param string $method_name Method name. + * @param array $arguments Arguments. + * @return mixed + * @throws ReflectionException If reflection fails. + */ + private function invoke_private_static_method( string $method_name, array $arguments = [] ) { + $reflection = new ReflectionClass( AccessibilityReportsPage::class ); + $method = $reflection->getMethod( $method_name ); + $method->setAccessible( true ); + + return $method->invokeArgs( null, $arguments ); + } + + /** + * Ensures valid Pro remains authoritative when it is installed and connected. + * + * @throws ReflectionException If reflection fails. + */ + public function test_resolve_license_context_uses_pro_when_pro_is_valid() { + $context = $this->invoke_private_static_method( + 'resolve_license_context', + [ true, 'valid', 'valid', 'site-123', false ] + ); + + $this->assertTrue( $context['has_pro_plugin'] ); + $this->assertTrue( $context['is_pro'] ); + $this->assertSame( 'valid', $context['status'] ); + $this->assertTrue( $context['is_connected'] ); + } + + /** + * Ensures the reports page falls back to free state when Pro is installed but no longer valid. + * + * @throws ReflectionException If reflection fails. + */ + public function test_resolve_license_context_falls_back_to_free_when_pro_is_invalid() { + $context = $this->invoke_private_static_method( + 'resolve_license_context', + [ true, 'expired', 'valid', 'site-123', false ] + ); + + $this->assertTrue( $context['has_pro_plugin'] ); + $this->assertFalse( $context['is_pro'] ); + $this->assertSame( 'valid', $context['status'] ); + $this->assertTrue( $context['is_connected'] ); + } + + /** + * Ensures taxonomy coverage only shows full coverage when the effective license is Pro. + * + * @throws ReflectionException If reflection fails. + */ + public function test_get_taxonomy_coverage_counts_requires_effective_pro_license() { + update_option( 'edacp_enable_archive_scanning', 1 ); + + $free_counts = $this->invoke_private_method( 'get_taxonomy_coverage_counts', [ false ] ); + $pro_counts = $this->invoke_private_method( 'get_taxonomy_coverage_counts', [ true ] ); + + $this->assertSame( 0, $free_counts['checked'] ); + $this->assertGreaterThanOrEqual( $free_counts['checked'], $free_counts['total'] ); + $this->assertSame( $pro_counts['total'], $pro_counts['checked'] ); + } + + /** + * Ensures fallback marker does not disconnect reports when Free is valid and the site remains enrolled. + * + * @throws ReflectionException If reflection fails. + */ + public function test_resolve_license_context_keeps_free_fallback_connected_when_site_id_exists() { + $context = $this->invoke_private_static_method( + 'resolve_license_context', + [ true, 'expired', 'valid', 'site-123', true ] + ); + + $this->assertFalse( $context['is_pro'] ); + $this->assertSame( 'valid', $context['status'] ); + $this->assertTrue( $context['is_connected'] ); + } + + /** + * Ensures stored next collection date is preferred over local fallback estimate. + * + * @throws ReflectionException If reflection fails. + */ + public function test_get_next_collection_date_uses_stored_value_when_available() { + update_option( 'edac_next_collection', '2030-01-15' ); + + $next_collection = $this->invoke_private_method( 'get_next_collection_date' ); + + $this->assertSame( '2030-01-15', $next_collection ); + } +} diff --git a/tests/phpunit/Admin/ConnectedServicesPageTest.php b/tests/phpunit/Admin/ConnectedServicesPageTest.php new file mode 100644 index 000000000..67649f263 --- /dev/null +++ b/tests/phpunit/Admin/ConnectedServicesPageTest.php @@ -0,0 +1,328 @@ +page = new ConnectedServicesPage( 'manage_options' ); + + delete_option( 'edac_license_status' ); + delete_option( 'edacp_license_status' ); + delete_option( 'edac_site_id' ); + delete_option( 'edac_license_error' ); + delete_option( 'edacp_license_error' ); + delete_option( 'edac_fallback_active' ); + } + + /** + * Clean up test state. + */ + public function tearDown(): void { + delete_option( 'edac_license_status' ); + delete_option( 'edacp_license_status' ); + delete_option( 'edac_site_id' ); + delete_option( 'edac_license_error' ); + delete_option( 'edacp_license_error' ); + delete_option( 'edac_fallback_active' ); + + parent::tearDown(); + } + + /** + * Invoke a private static method on the connected services page. + * + * @param string $method_name Method name. + * @param array $arguments Arguments. + * @return mixed + * @throws ReflectionException If reflection fails. + */ + private function invoke_private_static_method( string $method_name, array $arguments = [] ) { + $reflection = new ReflectionClass( ConnectedServicesPage::class ); + $method = $reflection->getMethod( $method_name ); + $method->setAccessible( true ); + + return $method->invokeArgs( null, $arguments ); + } + + /** + * Invoke a private method on the connected services page. + * + * @param string $method_name Method name. + * @param array $arguments Arguments. + * @return mixed + * @throws ReflectionException If reflection fails. + */ + private function invoke_private_method( string $method_name, array $arguments = [] ) { + $reflection = new ReflectionClass( ConnectedServicesPage::class ); + $method = $reflection->getMethod( $method_name ); + $method->setAccessible( true ); + + return $method->invokeArgs( $this->page, $arguments ); + } + + /** + * Ensures valid Pro remains authoritative when it is installed and connected. + * + * @throws ReflectionException If reflection fails. + */ + public function test_resolve_license_context_uses_pro_when_pro_is_valid() { + $context = $this->invoke_private_static_method( + 'resolve_license_context', + [ true, 'valid', 'valid', 'site-123', false ] + ); + + $this->assertTrue( $context['has_pro_plugin'] ); + $this->assertTrue( $context['is_pro'] ); + $this->assertSame( 'valid', $context['status'] ); + $this->assertTrue( $context['is_connected'] ); + } + + /** + * Ensures fallback to free state when Pro is installed but no longer valid. + * + * @throws ReflectionException If reflection fails. + */ + public function test_resolve_license_context_falls_back_to_free_when_pro_is_invalid() { + $context = $this->invoke_private_static_method( + 'resolve_license_context', + [ true, 'expired', 'valid', 'site-123', false ] + ); + + $this->assertTrue( $context['has_pro_plugin'] ); + $this->assertFalse( $context['is_pro'] ); + $this->assertSame( 'valid', $context['status'] ); + $this->assertTrue( $context['is_connected'] ); + } + + /** + * Ensures notice context uses free error and reports tab when Pro is not effective. + * + * @throws ReflectionException If reflection fails. + */ + public function test_resolve_error_context_uses_free_when_effective_license_is_not_pro() { + update_option( 'edac_license_error', 'invalid' ); + update_option( 'edacp_license_error', 'expired' ); + + $context = $this->invoke_private_static_method( 'resolve_error_context', [ false ] ); + + $this->assertSame( 'invalid', $context['error'] ); + $this->assertSame( 'accessibility-reports', $context['tab'] ); + } + + /** + * Ensures notice context uses Pro error and license tab when Pro is effective. + * + * @throws ReflectionException If reflection fails. + */ + public function test_resolve_error_context_uses_pro_when_effective_license_is_pro() { + update_option( 'edac_license_error', 'invalid' ); + update_option( 'edacp_license_error', 'expired' ); + + $context = $this->invoke_private_static_method( 'resolve_error_context', [ true ] ); + + $this->assertSame( 'expired', $context['error'] ); + $this->assertSame( 'license', $context['tab'] ); + } + + /** + * Ensures dynamic error context follows effective license authority. + * + * @throws ReflectionException If reflection fails. + */ + public function test_get_error_context_follows_effective_license_context() { + update_option( 'edac_license_status', 'valid' ); + update_option( 'edacp_license_status', 'expired' ); + update_option( 'edac_site_id', 'site-123' ); + update_option( 'edac_license_error', 'invalid' ); + update_option( 'edacp_license_error', 'expired' ); + + $context = $this->invoke_private_method( 'get_error_context' ); + + $this->assertSame( 'invalid', $context['error'] ); + $this->assertSame( 'accessibility-reports', $context['tab'] ); + } + + /** + * Ensures fallback marker does not disconnect reports when Free is valid and the site remains connected. + * + * @throws ReflectionException If reflection fails. + */ + public function test_resolve_license_context_keeps_free_fallback_connected_when_site_id_exists() { + $context = $this->invoke_private_static_method( + 'resolve_license_context', + [ true, 'expired', 'valid', 'site-123', true ] + ); + + $this->assertFalse( $context['is_pro'] ); + $this->assertSame( 'valid', $context['status'] ); + $this->assertTrue( $context['is_connected'] ); + } + + /** + * Ensures degraded notice context is shown when Pro is invalid and Free is valid. + * + * @throws ReflectionException If reflection fails. + */ + public function test_resolve_degraded_notice_context_connected_mode() { + $context = $this->invoke_private_static_method( + 'resolve_degraded_notice_context', + [ true, false, 'expired', 'valid', true, false ] + ); + + $this->assertTrue( $context['show'] ); + $this->assertSame( 'connected', $context['mode'] ); + } + + /** + * Ensures degraded notice context switches to reconnect mode while fallback is active. + * + * @throws ReflectionException If reflection fails. + */ + public function test_resolve_degraded_notice_context_reconnect_mode_during_fallback() { + $context = $this->invoke_private_static_method( + 'resolve_degraded_notice_context', + [ true, false, 'expired', 'valid', false, true ] + ); + + $this->assertTrue( $context['show'] ); + $this->assertSame( 'reconnect', $context['mode'] ); + } + + /** + * Ensures degraded notice does not show when Pro is still authoritative. + * + * @throws ReflectionException If reflection fails. + */ + public function test_resolve_degraded_notice_context_hidden_when_pro_is_valid() { + $context = $this->invoke_private_static_method( + 'resolve_degraded_notice_context', + [ true, true, 'valid', 'valid', true, false ] + ); + + $this->assertFalse( $context['show'] ); + $this->assertSame( '', $context['mode'] ); + } + + /** + * Ensures degraded notice message explains connected degraded mode. + * + * @throws ReflectionException If reflection fails. + */ + public function test_get_degraded_notice_message_connected_mode() { + update_option( 'edacp_license_status', 'expired' ); + update_option( 'edac_fallback_active', false ); + + $message = $this->invoke_private_method( + 'get_degraded_notice_message', + [ + [ + 'has_pro_plugin' => true, + 'is_pro' => false, + 'status' => 'valid', + 'is_connected' => true, + ], + ] + ); + + $this->assertIsString( $message ); + $this->assertStringContainsString( 'Free email reports', $message ); + } + + /** + * Ensures degraded notice message explains reconnect mode during fallback. + * + * @throws ReflectionException If reflection fails. + */ + public function test_get_degraded_notice_message_reconnect_mode() { + update_option( 'edacp_license_status', 'expired' ); + update_option( 'edac_fallback_active', true ); + + $message = $this->invoke_private_method( + 'get_degraded_notice_message', + [ + [ + 'has_pro_plugin' => true, + 'is_pro' => false, + 'status' => 'valid', + 'is_connected' => false, + ], + ] + ); + + $this->assertIsString( $message ); + $this->assertStringContainsString( 'not currently connected', $message ); + } + + /** + * Ensures free connected services renderer ignores the Pro license tab. + */ + public function test_maybe_render_tab_content_does_not_render_on_license_tab() { + ob_start(); + $this->page->maybe_render_tab_content( 'license' ); + $output = ob_get_clean(); + + $this->assertSame( '', $output ); + } + + /** + * Ensures degraded-state notice can be injected into the Pro license page hook. + */ + public function test_render_pro_license_degraded_notice_outputs_when_pro_is_invalid_and_free_is_valid() { + if ( ! defined( 'EDACP_VERSION' ) ) { + define( 'EDACP_VERSION', 'test-pro-version' ); + } + + update_option( 'edacp_license_status', 'expired' ); + update_option( 'edac_license_status', 'valid' ); + update_option( 'edac_site_id', 'site-123' ); + update_option( 'edac_fallback_active', false ); + + ob_start(); + $this->page->render_pro_license_degraded_notice(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Free email reports', $output ); + $this->assertStringNotContainsString( 'Pro License Degraded to Free', $output ); + } + + /** + * Ensures connected services shows a connected-as-free state instead of the free license key form during degraded fallback. + */ + public function test_render_page_shows_connected_as_free_in_degraded_connected_state() { + if ( ! defined( 'EDACP_VERSION' ) ) { + define( 'EDACP_VERSION', 'test-pro-version' ); + } + + update_option( 'edacp_license_status', 'expired' ); + update_option( 'edac_license_status', 'valid' ); + update_option( 'edac_site_id', 'site-123' ); + update_option( 'edac_fallback_active', true ); + + ob_start(); + $this->page->render_page(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'Connected as Free', $output ); + $this->assertStringNotContainsString( 'Free License Key', $output ); + } +} diff --git a/tests/phpunit/includes/classes/MyDot/ConnectorTest.php b/tests/phpunit/includes/classes/MyDot/ConnectorTest.php new file mode 100644 index 000000000..f6f21d32d --- /dev/null +++ b/tests/phpunit/includes/classes/MyDot/ConnectorTest.php @@ -0,0 +1,656 @@ +getMethod( $method_name ); + $method->setAccessible( true ); + + return $method->invokeArgs( null, $arguments ); + } + + /** + * Build a mocked HTTP response payload for pre_http_request. + * + * @param array $body Response body data. + * @return Closure + */ + private function mock_http_response( array $body ) { + return function () use ( $body ) { + return [ + 'headers' => [], + 'body' => wp_json_encode( $body ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }; + } + + /** + * Ensures the free product ID is exposed correctly. + */ + public function test_get_free_product_id_returns_expected_id() { + $this->assertSame( 1666, Connector::get_free_product_id() ); + } + + /** + * Ensures metadata is stored as a single array option for free licenses. + * + * @throws ReflectionException If the method cannot be reflected. + */ + public function test_store_license_metadata_saves_single_array_option_for_free_license() { + $license_data = (object) [ + 'item_id' => 1666, + 'item_name' => 'Accessibility Checker Free', + 'license_limit' => 1, + 'expires' => '2026-12-31 00:00:00', + 'site_count' => '1', + 'activations_left' => '0', + ]; + + $this->invoke_private_static_method( 'store_license_metadata_from_response', [ $license_data, 'free' ] ); + + $metadata = get_option( 'edac_license_metadata' ); + + $this->assertIsArray( $metadata ); + $this->assertSame( 'free', $metadata['type'] ); + $this->assertSame( 'single-site', $metadata['level'] ); + $this->assertSame( 1666, $metadata['item_id'] ); + $this->assertSame( 'Accessibility Checker Free', $metadata['item_name'] ); + $this->assertSame( '2026-12-31 00:00:00', $metadata['expires'] ); + $this->assertSame( '1', $metadata['license_limit'] ); + $this->assertSame( '1', $metadata['site_count'] ); + $this->assertSame( '0', $metadata['activations_left'] ); + $this->assertIsInt( $metadata['last_response_at'] ); + // Verify individual keys are NOT scattered across separate wp_options. + $this->assertFalse( get_option( 'edac_license_type', false ) ); + $this->assertFalse( get_option( 'edac_license_item_id', false ) ); + } + + /** + * Ensures EDD license_limit of 0 (no activation cap) maps to 'unlimited', not 'single-site'. + * + * @throws ReflectionException If the method cannot be reflected. + */ + public function test_store_license_metadata_treats_zero_limit_as_unlimited() { + $license_data = (object) [ + 'item_id' => 1666, + 'item_name' => 'Accessibility Checker Free', + 'license_limit' => 0, // EDD uses 0 to mean no limit. + ]; + + $this->invoke_private_static_method( 'store_license_metadata_from_response', [ $license_data, 'free' ] ); + + $metadata = get_option( 'edac_license_metadata' ); + + $this->assertSame( 'unlimited', $metadata['level'] ); + } + + /** + * Ensures multi-site level is inferred correctly for limits > 1. + * + * @throws ReflectionException If the method cannot be reflected. + */ + public function test_store_license_metadata_treats_limit_above_one_as_multi_site() { + $license_data = (object) [ + 'item_id' => 1666, + 'item_name' => 'Accessibility Checker Free', + 'license_limit' => 5, + ]; + + $this->invoke_private_static_method( 'store_license_metadata_from_response', [ $license_data, 'free' ] ); + + $metadata = get_option( 'edac_license_metadata' ); + + $this->assertSame( 'multi-site', $metadata['level'] ); + } + + /** + * Ensures Pro item IDs can still be inferred from the filtered product ID. + * + * @throws ReflectionException If the method cannot be reflected. + */ + public function test_store_license_metadata_infers_pro_type_from_filtered_product_id() { + add_filter( + 'edac_pro_product_id', + static function () { + return 24; + } + ); + + $license_data = (object) [ + 'item_id' => 24, + 'item_name' => 'Accessibility Checker', + 'license_limit' => '5', + ]; + + $this->invoke_private_static_method( 'store_license_metadata_from_response', [ $license_data, 'free' ] ); + + $metadata = get_option( 'edac_license_metadata' ); + + $this->assertSame( 'pro', $metadata['type'] ); + $this->assertSame( 'multi-site', $metadata['level'] ); + $this->assertSame( 24, $metadata['item_id'] ); + } + + /** + * Ensures fallback inference still works when only item name identifies the license. + * + * @throws ReflectionException If the method cannot be reflected. + */ + public function test_store_license_metadata_falls_back_to_item_name_and_lifetime_level() { + $license_data = (object) [ + 'item_id' => 0, + 'item_name' => 'Accessibility Checker Pro', + 'license_limit' => 'lifetime', + ]; + + $this->invoke_private_static_method( 'store_license_metadata_from_response', [ $license_data, 'free' ] ); + + $metadata = get_option( 'edac_license_metadata' ); + + $this->assertSame( 'pro', $metadata['type'] ); + $this->assertSame( 'lifetime', $metadata['level'] ); + } + + /** + * Ensures clearing metadata removes the single option. + * + * @throws ReflectionException If the method cannot be reflected. + */ + public function test_clear_stored_license_metadata_deletes_single_option() { + update_option( + 'edac_license_metadata', + [ + 'type' => 'free', + ] + ); + + $this->invoke_private_static_method( 'clear_stored_license_metadata' ); + + $this->assertFalse( get_option( 'edac_license_metadata', false ) ); + } + + /** + * Ensures the public periodic license check stores metadata in the single option. + */ + public function test_periodic_check_license_updates_status_and_metadata_option() { + $connector = new Connector(); + $filter = $this->mock_http_response( + [ + 'license' => 'valid', + 'item_id' => 1666, + 'item_name' => 'Accessibility Checker Free', + 'license_limit' => '1', + 'expires' => '2026-12-31 00:00:00', + 'site_count' => '1', + 'activations_left' => '0', + ] + ); + + update_option( 'edacp_license_key', 'free-license-key' ); + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $connector->periodic_check_license(); + + $metadata = get_option( 'edac_license_metadata' ); + + $this->assertSame( 'valid', get_option( 'edac_license_status' ) ); + $this->assertIsArray( $metadata ); + $this->assertSame( 'free', $metadata['type'] ); + $this->assertSame( 1666, $metadata['item_id'] ); + $this->assertSame( 'single-site', $metadata['level'] ); + $this->assertFalse( get_option( 'edac_fallback_active', false ) ); + } + + /** + * Ensures free plugin's activate_license() does not run when Pro is installed with valid license. + * + * This prevents the free activation form from overwriting Pro license state. + * + * @throws ReflectionException If the method cannot be reflected. + */ + public function test_activate_license_bails_when_pro_is_active_with_valid_license() { + // Simulate Pro plugin installed with valid license. + if ( ! defined( 'EDACP_VERSION' ) ) { + define( 'EDACP_VERSION', '1.19.0' ); + } + update_option( 'edacp_license_status', 'valid' ); + update_option( 'edacp_license_key', 'free-license-key' ); + + // Create a new Connector instance and call activate_license via reflection. + $connector = new Connector(); + $reflection = new ReflectionClass( Connector::class ); + $method = $reflection->getMethod( 'activate_license' ); + $method->setAccessible( true ); + + // Invoke activate_license. + $method->invoke( $connector ); + + // Verify error was set and no HTTP request was attempted. + $error = get_option( 'edac_license_error' ); + $this->assertNotEmpty( $error ); + $this->assertStringContainsString( 'Pro license is active', $error ); + + // Verify metadata was NOT updated (no HTTP call happened). + $this->assertFalse( get_option( 'edac_license_metadata', false ) ); + } + + /** + * Ensures free plugin's periodic_check_license resumes when Pro is defined but license is not valid. + * + * This enables automatic fallback from Pro to free when Pro's license expires or is disabled. + */ + public function test_periodic_check_license_resumes_when_pro_license_becomes_invalid() { + // Simulate Pro plugin installed but with expired license. + if ( ! defined( 'EDACP_VERSION' ) ) { + define( 'EDACP_VERSION', '1.19.0' ); + } + update_option( 'edacp_license_status', 'expired' ); // Pro license is no longer valid. + update_option( 'edacp_license_key', 'free-license-key' ); + + $connector = new Connector(); + $filter = $this->mock_http_response( + [ + 'license' => 'valid', + 'item_id' => 1666, + 'item_name' => 'Accessibility Checker Free', + 'license_limit' => 1, + 'expires' => '2026-12-31 00:00:00', + 'site_count' => '1', + 'activations_left' => '0', + ] + ); + + add_filter( 'pre_http_request', $filter, 10, 3 ); + + // Call periodic_check_license — it should NOT bail even though Pro is defined. + $connector->periodic_check_license(); + + $metadata = get_option( 'edac_license_metadata' ); + + // Verify free plugin checked and stored metadata (type: 'free', not Pro). + $this->assertIsArray( $metadata ); + $this->assertSame( 'free', $metadata['type'] ); + $this->assertSame( 1666, $metadata['item_id'] ); + $this->assertSame( 'valid', get_option( 'edac_license_status' ) ); + } + + /** + * Ensures free plugin's deactivate_license clears all expected options. + * + * @throws ReflectionException If the method cannot be reflected. + */ + public function test_deactivate_license_clears_all_options() { + // Set up initial state with all license-related options. + update_option( 'edacp_license_key', 'test-key' ); + update_option( 'edacp_license_status', 'valid' ); + update_option( 'edacp_license_error', 'test-pro-error' ); + update_option( 'edac_license_status', 'valid' ); + update_option( 'edac_license_error', 'test-error' ); + update_option( 'edac_license_metadata', [ 'type' => 'free' ] ); + update_option( 'edac_site_id', 'test-site-id' ); + update_option( 'edac_jwt_public_key', 'test-public-key' ); + update_option( 'edac_collection_interval_days', '7' ); + update_option( 'edac_next_collection', '2026-04-22' ); + update_option( 'edac_fallback_active', 1 ); + + // Deactivate via reflection. + $connector = new Connector(); + $reflection = new ReflectionClass( Connector::class ); + $method = $reflection->getMethod( 'deactivate_license' ); + $method->setAccessible( true ); + $method->invoke( $connector ); + + // Verify all options are cleared. + $this->assertFalse( get_option( 'edacp_license_key', false ) ); + $this->assertFalse( get_option( 'edacp_license_status', false ) ); + $this->assertFalse( get_option( 'edacp_license_error', false ) ); + $this->assertFalse( get_option( 'edac_license_status', false ) ); + $this->assertFalse( get_option( 'edac_license_error', false ) ); + $this->assertFalse( get_option( 'edac_license_metadata', false ) ); + $this->assertFalse( get_option( 'edac_site_id', false ) ); + $this->assertFalse( get_option( 'edac_jwt_public_key', false ) ); + $this->assertFalse( get_option( 'edac_collection_interval_days', false ) ); + $this->assertFalse( get_option( 'edac_next_collection', false ) ); + $this->assertFalse( get_option( 'edac_fallback_active', false ) ); + } + + /** + * Ensures that when Pro license expires and free resumes, no automatic enrollment occurs. + * + * The free plugin should resume checking the license, but NOT automatically + * register the site for email reports without explicit user action. + */ + public function test_periodic_check_license_fallback_does_not_auto_enroll() { + // Simulate Pro installed but expired. + if ( ! defined( 'EDACP_VERSION' ) ) { + define( 'EDACP_VERSION', '1.19.0' ); + } + update_option( 'edacp_license_status', 'expired' ); + update_option( 'edacp_license_key', 'free-license-key' ); + + // Mock HTTP response. + $filter = $this->mock_http_response( + [ + 'license' => 'valid', + 'item_id' => 1666, + 'item_name' => 'Accessibility Checker Free', + 'license_limit' => 1, + 'expires' => '2026-12-31 00:00:00', + 'site_count' => '1', + 'activations_left' => '0', + ] + ); + + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $connector = new Connector(); + + + $connector->periodic_check_license(); + + // Verify metadata was stored (license check succeeded). + $metadata = get_option( 'edac_license_metadata' ); + $this->assertIsArray( $metadata ); + $this->assertSame( 'free', $metadata['type'] ); + + // Verify that site ID is still empty (no enrollment happened). + $site_id = get_option( 'edac_site_id', false ); + $this->assertFalse( $site_id, 'Site should not be auto-registered during fallback' ); + } + + /** + * Ensures fallback marker is cleared when free periodic check validates successfully. + */ + public function test_periodic_check_license_clears_fallback_marker_when_free_becomes_valid() { + update_option( 'edac_fallback_active', 1 ); + update_option( 'edacp_license_key', 'free-license-key' ); + + $filter = $this->mock_http_response( + [ + 'license' => 'valid', + 'item_id' => 1666, + 'item_name' => 'Accessibility Checker Free', + 'license_limit' => '1', + 'expires' => '2026-12-31 00:00:00', + 'site_count' => '1', + 'activations_left' => '0', + ] + ); + add_filter( 'pre_http_request', $filter, 10, 3 ); + + $connector = new Connector(); + $connector->periodic_check_license(); + + $this->assertFalse( get_option( 'edac_fallback_active', false ) ); + } + + /** + * Ensures Pro activation hook refreshes existing registration context. + */ + public function test_handle_pro_license_activation_refreshes_registration_when_already_connected() { + update_option( 'edac_site_id', 'existing-site-id' ); + update_option( 'edacp_license_key', 'pro-license-key' ); + + add_filter( + 'pre_http_request', + function ( $preempt, $args, $url ) { + if ( false === strpos( $url, '/wp-json/myed-email-reports/v1/register-site' ) ) { + return $preempt; + } + + $body = json_decode( $args['body'], true ); + if ( empty( $body['license_key'] ) || 'pro-license-key' !== $body['license_key'] ) { + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'success' => false, + 'message' => 'Unexpected request body', + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + } + + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'success' => true, + 'data' => [ + 'site_id' => 'pro-site-id', + 'jwt_public_key' => 'pro-public-key', + 'collection_interval_days' => '7', + 'next_collection' => '2030-01-01', + ], + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }, + 10, + 3 + ); + + $connector = new Connector(); + $connector->handle_pro_license_activation( 'pro-license-key', home_url(), (object) [ 'license' => 'valid' ] ); + + $this->assertSame( 'pro-site-id', get_option( 'edac_site_id' ) ); + $this->assertSame( 'pro-public-key', get_option( 'edac_jwt_public_key' ) ); + $this->assertSame( '7', get_option( 'edac_collection_interval_days' ) ); + $this->assertSame( '2030-01-01', get_option( 'edac_next_collection' ) ); + } + + /** + * Ensures missing-data unregistration preserves active Pro license state. + */ + public function test_handle_site_unregistration_preserves_active_pro_license_when_registration_data_is_missing() { + if ( ! defined( 'EDACP_VERSION' ) ) { + define( 'EDACP_VERSION', '1.19.0' ); + } + + update_option( 'edacp_license_key', 'test-key' ); + update_option( 'edacp_license_status', 'valid' ); + update_option( 'edacp_license_error', 'test-pro-error' ); + update_option( 'edac_license_status', 'valid' ); + update_option( 'edac_site_id', '' ); + + $connector = new Connector(); + $connector->handle_site_unregistration(); + + $this->assertSame( 'test-key', get_option( 'edacp_license_key' ) ); + $this->assertSame( 'valid', get_option( 'edacp_license_status' ) ); + $this->assertSame( 'test-pro-error', get_option( 'edacp_license_error' ) ); + $this->assertFalse( get_option( 'edac_site_id', false ) ); + } + + /** + * Ensures free-authority unregistration clears the shared key even when data is missing. + */ + public function test_handle_site_unregistration_clears_free_license_state_when_registration_data_is_missing() { + update_option( 'edacp_license_key', 'free-key' ); + update_option( 'edac_license_status', 'valid' ); + update_option( 'edac_site_id', '' ); + + $connector = new Connector(); + $connector->handle_site_unregistration(); + + $this->assertFalse( get_option( 'edacp_license_key', false ) ); + $this->assertFalse( get_option( 'edac_license_status', false ) ); + } + + /** + * Ensures active Pro license is preserved even when remote unregistration fails. + */ + public function test_handle_site_unregistration_preserves_active_pro_license_on_remote_failure() { + if ( ! defined( 'EDACP_VERSION' ) ) { + define( 'EDACP_VERSION', '1.19.0' ); + } + + update_option( 'edacp_license_key', 'pro-key' ); + update_option( 'edacp_license_status', 'valid' ); + update_option( 'edac_site_id', 'existing-site-id' ); + update_option( 'edac_jwt_public_key', 'public-key' ); + + add_filter( + 'pre_http_request', + function ( $preempt, $args, $url ) { + if ( false === strpos( $url, '/wp-json/myed-email-reports/v1/unregister-site' ) ) { + return $preempt; + } + + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'success' => false, + 'message' => 'Remote failure', + ] + ), + 'response' => [ + 'code' => 500, + 'message' => 'Server Error', + ], + ]; + }, + 10, + 3 + ); + + $connector = new Connector(); + $connector->handle_site_unregistration(); + + $this->assertSame( 'pro-key', get_option( 'edacp_license_key' ) ); + $this->assertSame( 'valid', get_option( 'edacp_license_status' ) ); + $this->assertFalse( get_option( 'edac_site_id', false ) ); + $this->assertFalse( get_option( 'edac_jwt_public_key', false ) ); + } + + /** + * Ensures hook-provided license key is used for unregistration when option key is missing. + */ + public function test_handle_site_unregistration_uses_hook_license_when_stored_key_missing() { + if ( ! defined( 'EDACP_VERSION' ) ) { + define( 'EDACP_VERSION', '1.19.0' ); + } + + update_option( 'edacp_license_status', 'valid' ); + update_option( 'edac_site_id', 'existing-site-id' ); + + add_filter( + 'pre_http_request', + function ( $preempt, $args, $url ) { + if ( false === strpos( $url, '/wp-json/myed-email-reports/v1/unregister-site' ) ) { + return $preempt; + } + + $body = json_decode( $args['body'], true ); + if ( empty( $body['license_key'] ) || 'hook-key' !== $body['license_key'] ) { + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'success' => false, + 'message' => 'Unexpected license key', + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + } + + return [ + 'headers' => [], + 'body' => wp_json_encode( + [ + 'success' => true, + 'data' => [], + ] + ), + 'response' => [ + 'code' => 200, + 'message' => 'OK', + ], + ]; + }, + 10, + 3 + ); + + $connector = new Connector(); + $connector->handle_site_unregistration( 'hook-key', home_url(), (object) [ 'license' => 'deactivated' ] ); + + $this->assertFalse( get_option( 'edac_site_id', false ) ); + } +}