From 5b78a8dcac9cb9a269659271032cdb14b4bcec06 Mon Sep 17 00:00:00 2001 From: datorik Date: Thu, 13 Aug 2026 18:23:50 +0300 Subject: [PATCH 01/27] Udp.Code.WooCommerce integration --- .../IntegrationsByClass/Woocommerce.php | 93 +++++++++++++++---- lib/Cleantalk/ApbctWP/State.php | 2 +- 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index bafa1e415..ce9c2b06c 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -177,6 +177,11 @@ public function checkoutCheck($_data, $errors) { global $apbct, $cleantalk_executed; + if ( ! $apbct->settings['data__wc_store_blocked_orders'] ) { + // The checkout is left to WooCommerce as is: no check, no blocked order to store. + return; + } + if ( count($errors->errors) ) { return; } @@ -230,14 +235,10 @@ public function checkoutCheck($_data, $errors) ct_hash($ct_result->id); if ( $ct_result->allow == 0 ) { - if ( $apbct->settings['data__wc_store_blocked_orders'] ) { - $this->storeBlockedOrder(); - } + $this->handleBlockedOrder(); wp_send_json(array( - 'result' => 'failure', - 'messages' => "", - 'refresh' => 'false', - 'reload' => 'false' + 'result' => 'success', + 'redirect' => $this->getBlockedOrderRedirectUrl(), )); } } @@ -252,6 +253,11 @@ public function checkoutCheckFromRest($order) { global $apbct, $cleantalk_executed; + if ( ! $apbct->settings['data__wc_store_blocked_orders'] ) { + // The checkout is left to WooCommerce as is: no check, no blocked order to store. + return; + } + if ( is_null($order) || ! ($order instanceof \WC_Order) ) { return; } @@ -286,11 +292,15 @@ public function checkoutCheckFromRest($order) ct_hash($ct_result->id); if ( $ct_result->allow == 0 ) { - if ( $apbct->settings['data__wc_store_blocked_orders'] ) { - $this->storeBlockedOrder(); - } + $response = $this->getStoreApiPassedResponse($order); + + $this->handleBlockedOrder(); if ( $order->get_status() === 'pending' || $order->get_status() === 'checkout-draft' ) { + if ( function_exists('wc_release_stock_for_order') ) { + wc_release_stock_for_order($order); + } + try { $order->delete(true); } catch (\Exception $e) { @@ -298,22 +308,67 @@ public function checkoutCheckFromRest($order) } } - $response = [ - 'code' => 'woocommerce_store_api_checkout_order_processed', - 'message' => $ct_result->comment, - 'data' => [ - 'status' => 403 - ] - ]; - if ( ! headers_sent() ) { - http_response_code(403); + header('Content-Type: application/json; charset=utf-8'); } die(json_encode($response)); } } } + /** + * Common actions for an order blocked as spam. + * + * @return void + * @psalm-suppress UndefinedFunction + */ + private function handleBlockedOrder() + { + $this->storeBlockedOrder(); + + if ( function_exists('wc') && ! is_null(wc()->cart) ) { + wc()->cart->empty_cart(); + } + } + + /** + * URL of the page shown to the visitor whose order was blocked. + * + * @return string + * @psalm-suppress UndefinedFunction + */ + private function getBlockedOrderRedirectUrl() + { + return wc_get_endpoint_url('order-received', '', wc_get_checkout_url()); + } + + /** + * Response for the Store API checkout route imitating a passed checkout. + * + * @param \WC_Order $order + * + * @return array + * @psalm-suppress UndefinedClass, UndefinedFunction + */ + private function getStoreApiPassedResponse($order) + { + return array( + 'order_id' => $order->get_id(), + 'status' => $order->get_status(), + 'order_key' => $order->get_order_key(), + 'customer_note' => $order->get_customer_note(), + 'customer_id' => $order->get_customer_id(), + 'billing_address' => $order->get_address('billing'), + 'shipping_address' => $order->get_address('shipping'), + 'payment_method' => $order->get_payment_method(), + 'payment_result' => array( + 'payment_status' => 'success', + 'payment_details' => array(), + 'redirect_url' => $this->getBlockedOrderRedirectUrl(), + ), + ); + } + /** * @return void * @psalm-suppress UndefinedFunction diff --git a/lib/Cleantalk/ApbctWP/State.php b/lib/Cleantalk/ApbctWP/State.php index e0d637726..9e8ed2041 100644 --- a/lib/Cleantalk/ApbctWP/State.php +++ b/lib/Cleantalk/ApbctWP/State.php @@ -94,7 +94,7 @@ class State extends \Cleantalk\Common\State 'data__email_decoder_obfuscation_custom_text' => '', 'data__email_decoder_encode_phone_numbers' => 0, 'data__email_decoder_encode_email_addresses' => 1, - 'data__wc_store_blocked_orders' => 0, + 'data__wc_store_blocked_orders' => 1, // Exclusions // Send to the cloud some excepted requests From bccf9f8ac6a58504f0334c243515e141fb66c980 Mon Sep 17 00:00:00 2001 From: datorik Date: Fri, 14 Aug 2026 17:22:35 +0300 Subject: [PATCH 02/27] Udp.Code.Security WooCommerce UI --- css/cleantalk-admin.min.css | 2 +- css/src/cleantalk-admin.css | 45 +++ .../ApbctWP/WcSpamOrdersListTable.php | 285 ++++++++++++++++-- 3 files changed, 308 insertions(+), 24 deletions(-) diff --git a/css/cleantalk-admin.min.css b/css/cleantalk-admin.min.css index daf049497..c2c7a6d27 100644 --- a/css/cleantalk-admin.min.css +++ b/css/cleantalk-admin.min.css @@ -1 +1 @@ -:disabled{cursor:not-allowed!important}.apbct_color--gray{color:gray}.apbct_display--none{display:none}.apbct_bottom_links--left{margin-right:2pc}.apbct_bottom_links--other{margin-right:2pc;margin-left:2pc}.ct_translate_links{color:#969614}.ct_support_links{color:#961414}.ct_faq_links{color:#149614}.ct_setting_links{color:#141496}.ct_translate_links:hover{color:#d2d214!important}.ct_support_links:hover{color:#fa1414!important}.ct_faq_links:hover{color:#14fa14!important}.ct_setting_links:hover{color:#1414fa!important}.ct_link_new_tab img{float:none!important;margin:0 2px;border:0}#negative_reports_table tr td{padding:7px 5px!important}#wp-admin-bar-cleantalk_admin_bar__parent_node{margin-right:5px}#wp-admin-bar-cleantalk_admin_bar__parent_node span{display:inline-block}#wp-admin-bar-cleantalk_admin_bar__parent_node .apbct-icon-attention-alt{background:#d63638;color:#fff;border-radius:50%;font-size:12px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__spbc_icon{width:14px;height:17px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__apbct_icon{width:18px;height:18px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node div.cleantalk_admin_bar__sum_counter{color:#999;display:inline;padding:2px 5px!important}.cleantalk_admin_bar__blocked div{cursor:not-allowed!important}.cleantalk_admin_bar__blocked div a{color:#777!important}.cleantalk_admin_bar__title{vertical-align:top}.cleantalk_admin_bar__separator{height:0!important}.cleantalk-admin_bar--list_wrapper .ab-sub-wrapper ul:last-child{margin-bottom:5px!important}.apbct-plugin-errors{margin-left:0;margin-bottom:20px}#cleantalk_notice_review .caption{margin:0 0 15px;color:gray}#cleantalk_notice_review .button{margin-bottom:20px}.apbct-details-spam-order-button,.apbct-restore-spam-order-button{cursor:pointer}.apbct-details-spam-order-button{color:gray}.ct-modal-buttons{display:flex;align-items:center;padding:20px 0;justify-content:space-between}.ct-modal-message{font-weight:700;font-size:16px;line-height:2rem}.apbct-popup-fade:before{content:'';background:#000;position:fixed;left:0;top:0;width:100%;height:100%;opacity:.7;z-index:9999}.apbct-popup{position:fixed;top:20%;left:50%;padding:20px;width:360px;margin-left:-200px;background:#fff;border:1px solid;border-radius:4px;z-index:99999;opacity:1}.apbct-table-actions-wrapper{background:#fcfcfc;background:#fcfcfc;border-radius:2px;padding:5px!important;border:1px solid #d3d3d3;margin:0 5px 5px 0!important}.apbct_wc_details__table-container_header{text-align:center}.apbct_wc_details__wrapper{display:flex;flex-direction:column;scroll-behavior:smooth;overflow-y:auto;max-height:400px;min-width:50pc;position:relative}.apbct_wc_details__table-wrapper-inner{margin-bottom:25px;background:#f9f9f9;border-radius:8px;box-shadow:0 2px 4px rgba(0,0,0,.08);position:relative;padding:0 15px}.apbct_wc_details__table-wrapper-inner h4{margin:0 0 12px 0;color:#333;font-size:16px;font-weight:600;padding:10px 0 8px 0;border-bottom:2px solid #e0e0e0;position:sticky;top:0;background:#f9f9f9;z-index:10}.apbct_wc_details__table{width:100%;border-collapse:collapse;background:#fff;border-radius:6px;overflow:hidden}.apbct_wc_details__table-key-cell{border:1px solid #e8e8e8;padding:10px 12px;font-weight:600;background:#f5f7fa;color:#2c3e50;width:35%;font-size:13px}.apbct_wc_details__table-value-cell{border:1px solid #e8e8e8;padding:10px 12px;color:#34495e;font-size:13px;word-break:break-word}.apbct_wc_details__table tbody tr:hover{background:#fafbfc}.apbct_wc_details__table tbody tr:last-child td{border-bottom:none}.apbct_wc_details__table-value-cell--json{font-family:monospace;font-size:12px;white-space:pre-wrap}@supports (position:sticky){.apbct_wc_details__table th,.apbct_wc_details__table-wrapper-inner h4{position:sticky}}@media screen and (max-width:1120px){.apbct-tablenav{display:flex;flex-direction:column;flex-wrap:nowrap;height:100%;max-width:50%}}.apbct-notice.apbct-banner-success{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#00bb5d!important}.apbct-banner-content{display:flex;flex-wrap:wrap;justify-content:space-between;row-gap:16px}.apbct-banner-content-wrapper{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between}.apbct-banner-text-wrapper{margin-top:16px;margin-left:32px}.apbct-banner-button{display:block;padding:17px 53px;color:#fff!important;border-radius:8px;font-size:16px;font-weight:500;text-decoration:none!important;text-align:center;align-content:center}.apbct-banner-button-wrapper{display:grid;gap:12px;align-content:center;text-align:center;margin-right:24px;margin-left:32px}.apbct-banner-title{font-size:24px;line-height:29px;font-weight:500;color:#000}.apbct-banner-subtitle{font-size:14px;line-height:22px;font-weight:400;color:#444;margin-top:8px}.apbct-banner-big-subtitle{font-size:16px}.apbct-banner-link{font-size:16px;line-height:24px;font-weight:400;color:#026e88!important}.apbct-banner-dismiss-link{font-weight:400;font-size:14px;line-height:22px;color:#646464!important}.apbct-notice.apbct-banner-error{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#bb3323!important}.apbct-notice.apbct-banner-error .notice-dismiss::before{color:#646464}.apbct-banner-button-green{background-color:#00bb5d!important}.apbct-banner-button-green:hover{background-color:#00a34f!important}.apbct-banner-button-red{background-color:#bb3323!important}.apbct-banner-button-red:hover{background-color:#a82e20!important} \ No newline at end of file +:disabled{cursor:not-allowed!important}.apbct_color--gray{color:gray}.apbct_display--none{display:none}.apbct_bottom_links--left{margin-right:2pc}.apbct_bottom_links--other{margin-right:2pc;margin-left:2pc}.ct_translate_links{color:#969614}.ct_support_links{color:#961414}.ct_faq_links{color:#149614}.ct_setting_links{color:#141496}.ct_translate_links:hover{color:#d2d214!important}.ct_support_links:hover{color:#fa1414!important}.ct_faq_links:hover{color:#14fa14!important}.ct_setting_links:hover{color:#1414fa!important}.ct_link_new_tab img{float:none!important;margin:0 2px;border:0}#negative_reports_table tr td{padding:7px 5px!important}#wp-admin-bar-cleantalk_admin_bar__parent_node{margin-right:5px}#wp-admin-bar-cleantalk_admin_bar__parent_node span{display:inline-block}#wp-admin-bar-cleantalk_admin_bar__parent_node .apbct-icon-attention-alt{background:#d63638;color:#fff;border-radius:50%;font-size:12px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__spbc_icon{width:14px;height:17px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node img.cleantalk_admin_bar__apbct_icon{width:18px;height:18px;margin-top:7px}#wp-admin-bar-cleantalk_admin_bar__parent_node div.cleantalk_admin_bar__sum_counter{color:#999;display:inline;padding:2px 5px!important}.cleantalk_admin_bar__blocked div{cursor:not-allowed!important}.cleantalk_admin_bar__blocked div a{color:#777!important}.cleantalk_admin_bar__title{vertical-align:top}.cleantalk_admin_bar__separator{height:0!important}.cleantalk-admin_bar--list_wrapper .ab-sub-wrapper ul:last-child{margin-bottom:5px!important}.apbct-plugin-errors{margin-left:0;margin-bottom:20px}#cleantalk_notice_review .caption{margin:0 0 15px;color:gray}#cleantalk_notice_review .button{margin-bottom:20px}.apbct-details-spam-order-button,.apbct-restore-spam-order-button{cursor:pointer}.apbct-details-spam-order-button{color:gray}.wp-list-table.wc_spam_orders .column-ct_order_date,.wp-list-table.wc_spam_orders .column-ct_status,.wp-list-table.wc_spam_orders .column-ct_total{width:15%}.wp-list-table.wc_spam_orders td{vertical-align:middle}.wp-list-table.wc_spam_orders .apbct-order-view{color:#2271b1;text-decoration:none}.wp-list-table.wc_spam_orders .apbct-order-view:hover{color:#135e96}.apbct-order-status{display:inline-flex;line-height:2.5em;color:#454545;background:#e5e5e5;border-radius:4px;border-bottom:1px solid rgba(0,0,0,.05);margin:-.25em 0;cursor:inherit!important;white-space:nowrap;max-width:100%}.apbct-order-status>span{margin:0 1em;overflow:hidden;text-overflow:ellipsis}.apbct-order-status--spam{background:#eba3a3;color:#570000}.ct-modal-buttons{display:flex;align-items:center;padding:20px 0;justify-content:space-between}.ct-modal-message{font-weight:700;font-size:16px;line-height:2rem}.apbct-popup-fade:before{content:'';background:#000;position:fixed;left:0;top:0;width:100%;height:100%;opacity:.7;z-index:9999}.apbct-popup{position:fixed;top:20%;left:50%;padding:20px;width:360px;margin-left:-200px;background:#fff;border:1px solid;border-radius:4px;z-index:99999;opacity:1}.apbct-table-actions-wrapper{background:#fcfcfc;background:#fcfcfc;border-radius:2px;padding:5px!important;border:1px solid #d3d3d3;margin:0 5px 5px 0!important}.apbct_wc_details__table-container_header{text-align:center}.apbct_wc_details__wrapper{display:flex;flex-direction:column;scroll-behavior:smooth;overflow-y:auto;max-height:400px;min-width:50pc;position:relative}.apbct_wc_details__table-wrapper-inner{margin-bottom:25px;background:#f9f9f9;border-radius:8px;box-shadow:0 2px 4px rgba(0,0,0,.08);position:relative;padding:0 15px}.apbct_wc_details__table-wrapper-inner h4{margin:0 0 12px 0;color:#333;font-size:16px;font-weight:600;padding:10px 0 8px 0;border-bottom:2px solid #e0e0e0;position:sticky;top:0;background:#f9f9f9;z-index:10}.apbct_wc_details__table{width:100%;border-collapse:collapse;background:#fff;border-radius:6px;overflow:hidden}.apbct_wc_details__table-key-cell{border:1px solid #e8e8e8;padding:10px 12px;font-weight:600;background:#f5f7fa;color:#2c3e50;width:35%;font-size:13px}.apbct_wc_details__table-value-cell{border:1px solid #e8e8e8;padding:10px 12px;color:#34495e;font-size:13px;word-break:break-word}.apbct_wc_details__table tbody tr:hover{background:#fafbfc}.apbct_wc_details__table tbody tr:last-child td{border-bottom:none}.apbct_wc_details__table-value-cell--json{font-family:monospace;font-size:12px;white-space:pre-wrap}@supports (position:sticky){.apbct_wc_details__table th,.apbct_wc_details__table-wrapper-inner h4{position:sticky}}@media screen and (max-width:1120px){.apbct-tablenav{display:flex;flex-direction:column;flex-wrap:nowrap;height:100%;max-width:50%}}.apbct-notice.apbct-banner-success{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#00bb5d!important}.apbct-banner-content{display:flex;flex-wrap:wrap;justify-content:space-between;row-gap:16px}.apbct-banner-content-wrapper{display:flex;flex-wrap:wrap;gap:16px;justify-content:space-between}.apbct-banner-text-wrapper{margin-top:16px;margin-left:32px}.apbct-banner-button{display:block;padding:17px 53px;color:#fff!important;border-radius:8px;font-size:16px;font-weight:500;text-decoration:none!important;text-align:center;align-content:center}.apbct-banner-button-wrapper{display:grid;gap:12px;align-content:center;text-align:center;margin-right:24px;margin-left:32px}.apbct-banner-title{font-size:24px;line-height:29px;font-weight:500;color:#000}.apbct-banner-subtitle{font-size:14px;line-height:22px;font-weight:400;color:#444;margin-top:8px}.apbct-banner-big-subtitle{font-size:16px}.apbct-banner-link{font-size:16px;line-height:24px;font-weight:400;color:#026e88!important}.apbct-banner-dismiss-link{font-weight:400;font-size:14px;line-height:22px;color:#646464!important}.apbct-notice.apbct-banner-error{padding:16px 32px!important;background-color:#fff!important;box-shadow:0 4px 15px #84848440!important;border-left-color:#bb3323!important}.apbct-notice.apbct-banner-error .notice-dismiss::before{color:#646464}.apbct-banner-button-green{background-color:#00bb5d!important}.apbct-banner-button-green:hover{background-color:#00a34f!important}.apbct-banner-button-red{background-color:#bb3323!important}.apbct-banner-button-red:hover{background-color:#a82e20!important} \ No newline at end of file diff --git a/css/src/cleantalk-admin.css b/css/src/cleantalk-admin.css index 815b371aa..09ff7c189 100644 --- a/css/src/cleantalk-admin.css +++ b/css/src/cleantalk-admin.css @@ -105,6 +105,51 @@ .apbct-details-spam-order-button { color: gray; } + +/* WooCommerce orders list alike view of the spam orders table. + The order column has no width, so it takes all the space left. */ +.wp-list-table.wc_spam_orders .column-ct_order_date, +.wp-list-table.wc_spam_orders .column-ct_status, +.wp-list-table.wc_spam_orders .column-ct_total { + width: 15%; +} + +.wp-list-table.wc_spam_orders td { + vertical-align: middle; +} + +.wp-list-table.wc_spam_orders .apbct-order-view { + color: #2271b1; + text-decoration: none; +} + +.wp-list-table.wc_spam_orders .apbct-order-view:hover { + color: #135e96; +} + +.apbct-order-status { + display: inline-flex; + line-height: 2.5em; + color: #454545; + background: #e5e5e5; + border-radius: 4px; + border-bottom: 1px solid rgba(0, 0, 0, .05); + margin: -.25em 0; + cursor: inherit !important; + white-space: nowrap; + max-width: 100%; +} + +.apbct-order-status > span { + margin: 0 1em; + overflow: hidden; + text-overflow: ellipsis; +} + +.apbct-order-status--spam { + background: #eba3a3; + color: #570000; +} .ct-modal-buttons { display: flex; align-items: center; diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index b5afb11d8..3a25aa965 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -62,8 +62,13 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam $wc_spam_orders = $this->getWcSpamOrders(); $this->wc_spam_orders_count = count($wc_spam_orders); + // Blocked orders are never put on hold, so the view is always empty + if ( $this->getCurrentStatus() === 'on-hold' ) { + $wc_spam_orders = array(); + } + $this->set_pagination_args(array( - 'total_items' => $this->wc_spam_orders_count, + 'total_items' => count($wc_spam_orders), 'per_page' => $per_page, )); @@ -94,18 +99,22 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam 'details' => '' . esc_html__('See details', 'cleantalk-spam-protect') . '', ); - $order_id_column = sprintf('%1$s %2$s', $wc_spam_order->id, $this->row_actions($actions)); + $order_column = sprintf( + '%1$s %2$s', + $this->renderOrderColumn($wc_spam_order->id, $wc_spam_order->customer_details), + $this->row_actions($actions) + ); - $order_details_column = $this->renderOrderDetailsColumn($wc_spam_order->order_details); - $customer_details_column = $this->renderCustomerDetailsColumn($wc_spam_order->customer_details); - $order_date_column = $this->renderOrderDateColumn($wc_spam_order->order_date); + $order_date_column = $this->renderOrderDateColumn($wc_spam_order->order_date); + $status_column = $this->renderStatusColumn(); + $total_column = $this->renderTotalColumn($wc_spam_order->order_details); $this->items[] = array( - 'cb' => $wc_spam_order->id, - 'ct_order_id' => $order_id_column, - 'ct_order_details' => $order_details_column, - 'ct_customer_details' => $customer_details_column, - 'ct_order_date' => $order_date_column, + 'cb' => $wc_spam_order->id, + 'ct_order' => $order_column, + 'ct_order_date' => $order_date_column, + 'ct_status' => $status_column, + 'ct_total' => $total_column, ); } } @@ -113,11 +122,11 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam public function get_columns() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps { $columns = array( - 'cb' => '', - 'ct_order_id' => esc_html__('ID', 'cleantalk-spam-protect'), - 'ct_order_details' => esc_html__('Order details', 'cleantalk-spam-protect'), - 'ct_customer_details' => esc_html__('Customer details', 'cleantalk-spam-protect'), - 'ct_order_date' => esc_html__('Order date', 'cleantalk-spam-protect'), + 'cb' => '', + 'ct_order' => esc_html__('Order', 'cleantalk-spam-protect'), + 'ct_order_date' => esc_html__('Date', 'cleantalk-spam-protect'), + 'ct_status' => esc_html__('Status', 'cleantalk-spam-protect'), + 'ct_total' => esc_html__('Total', 'cleantalk-spam-protect'), ); return $columns; @@ -126,8 +135,75 @@ public function get_columns() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.N protected function get_sortable_columns() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps { return array( + 'ct_order' => array('id', false), 'ct_order_date' => array('order_date', false), + 'ct_total' => array('total', false), + ); + } + + /** + * Statuses row above the table, the same one as the WooCommerce orders list has. + * Every stored order is a blocked spam one, so the "On hold" view is always empty. + * + * @return array + */ + protected function get_views() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + $current_status = $this->getCurrentStatus(); + + $statuses = array( + 'all' => array(esc_html__('All', 'cleantalk-spam-protect'), $this->wc_spam_orders_count), + 'on-hold' => array(esc_html__('On hold', 'cleantalk-spam-protect'), 0), + 'spam' => array(esc_html__('Spam', 'cleantalk-spam-protect'), $this->wc_spam_orders_count), ); + + $views = array(); + + foreach ( $statuses as $status => $status_data ) { + list($title, $count) = $status_data; + + $url = admin_url('admin.php?page=' . Get::getString('page')); + if ( $status !== 'all' ) { + $url = add_query_arg('status', $status, $url); + } + + $views[$status] = sprintf( + '%3$s (%4$d)', + esc_url($url), + $status === $current_status ? ' class="current" aria-current="page"' : '', + $title, + $count + ); + } + + return $views; + } + + /** + * Currently selected status view. + * + * @return string all|on-hold|spam + */ + private function getCurrentStatus() + { + $status = Get::getString('status'); + + return in_array($status, array('on-hold', 'spam'), true) ? $status : 'all'; + } + + /** + * @inheritDoc + */ + public function display() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + $this->views(); + + parent::display(); + } + + public function no_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + esc_html_e('No orders found.', 'cleantalk-spam-protect'); } public function get_bulk_actions() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps @@ -256,6 +332,45 @@ private function renderCustomerDetailsColumn($customer_details) return $result; } + /** + * Order number and the customer name, the same way as WooCommerce orders list does it. + * + * @param int|string $spam_order_id + * @param string $customer_details + * + * @return string + */ + private function renderOrderColumn($spam_order_id, $customer_details) + { + $customer_details = json_decode($customer_details, true); + + $customer_name = ''; + + if ( is_array($customer_details) ) { + $customer_name = trim( + TT::getArrayValueAsString($customer_details, 'billing_first_name') + . ' ' + . TT::getArrayValueAsString($customer_details, 'billing_last_name') + ); + + if ( $customer_name === '' ) { + $customer_name = TT::getArrayValueAsString($customer_details, 'billing_email'); + } + } + + if ( $customer_name === '' ) { + $customer_name = esc_html__('Guest', 'cleantalk-spam-protect'); + } else { + $customer_name = esc_html($customer_name); + } + + return sprintf( + '#%1$s %2$s', + esc_attr(TT::toString($spam_order_id)), + $customer_name + ); + } + private function renderOrderDateColumn($order_date) { if ( ! $order_date ) { @@ -268,14 +383,96 @@ private function renderOrderDateColumn($order_date) return '-'; } + // Fresh orders are shown as "5 minutes ago", the older ones as a date. Same as WooCommerce does. + $diff = time() - $timestamp; + if ( $diff >= 0 && $diff < DAY_IN_SECONDS ) { + /* translators: %s: human-readable time difference */ + $show_date = sprintf(__('%s ago', 'cleantalk-spam-protect'), human_time_diff($timestamp, time())); + } else { + $show_date = date_i18n('M j, Y', $timestamp); // Feb 15, 2023 + } + return sprintf( '', esc_attr(date_i18n('c', $timestamp)), // 2023-02-15T20:25:06+00:00 - esc_html(date_i18n('d.m.Y H:i', $timestamp)), // 15.02.2023 20:25 - esc_html(date_i18n('M d, Y', $timestamp)) // Feb 15, 2023 + esc_attr(date_i18n('d.m.Y H:i', $timestamp)), // 15.02.2023 20:25 + esc_html($show_date) ); } + /** + * Every stored order is a blocked spam one, so the status is always the same. + * + * @return string + */ + private function renderStatusColumn() + { + return '' + . esc_html__('Spam', 'cleantalk-spam-protect') + . ''; + } + + /** + * @param string $order_details + * + * @return string Formatted order total or a dash if it can not be calculated. + */ + private function renderTotalColumn($order_details) + { + $total = $this->calcOrderTotal($order_details); + + if ( is_null($total) ) { + return '-'; + } + + /** @psalm-suppress UndefinedFunction */ + return function_exists('wc_price') + ? wc_price($total) + : esc_html(number_format_i18n($total, 2)); + } + + /** + * Sums up the stored cart items. Cart data keeps the calculated line totals, + * the product price is used as a fallback only. + * + * @param string $order_details + * + * @return float|null Null if the order details can not be decoded. + * + * @psalm-suppress UndefinedFunction + */ + private function calcOrderTotal($order_details) + { + $order_details = json_decode($order_details, true); + + if ( ! is_array($order_details) ) { + return null; + } + + $total = 0; + + foreach ( $order_details as $order_detail ) { + if ( ! is_array($order_detail) ) { + continue; + } + + if ( isset($order_detail['line_total']) ) { + $total += (float) $order_detail['line_total'] + (float) ($order_detail['line_tax'] ?? 0); + continue; + } + + if ( isset($order_detail['product_id']) && function_exists('wc_get_product') && class_exists('\WC_Product') ) { + $wc_product = wc_get_product($order_detail['product_id']); + $wc_product_class = '\WC_Product'; + if ( $wc_product instanceof $wc_product_class ) { + $total += (float) $wc_product->get_price() * (float) ($order_detail['quantity'] ?? 1); + } + } + } + + return (float) $total; + } + /** * @return array */ @@ -288,20 +485,62 @@ private function getWcSpamOrders() $sql = 'SELECT * FROM ' . APBCT_TBL_WC_SPAM_ORDERS; - if ($orderby) { - $sql .= ' ORDER BY ' . $orderby . ' ' . $order; - } + // The newest spam orders are shown first by default, the same way as WooCommerce orders list does it. + $sql .= ' ORDER BY ' . ($orderby ? $orderby : 'order_date') . ' ' . $order; $result = $wpdb->get_results($sql, OBJECT); - return is_array($result) ? $result : array(); + $result = is_array($result) ? $result : array(); + + if ( Get::getString('orderby') === 'total' ) { + $result = $this->sortByTotal($result, $order); + } + + return $result; + } + + /** + * The order total is not stored as a column, so it has to be sorted after the fetch. + * + * @param array $wc_spam_orders + * @param string $order ASC|DESC + * + * @return array + */ + private function sortByTotal($wc_spam_orders, $order) + { + $totals = array(); + + foreach ( $wc_spam_orders as $key => $wc_spam_order ) { + $totals[$key] = is_string($wc_spam_order->order_details) + ? (float) $this->calcOrderTotal($wc_spam_order->order_details) + : 0; + } + + uasort($totals, static function ($a, $b) { + if ( $a === $b ) { + return 0; + } + return $a < $b ? -1 : 1; + }); + + if ( $order === 'DESC' ) { + $totals = array_reverse($totals, true); + } + + $sorted = array(); + foreach ( array_keys($totals) as $key ) { + $sorted[] = $wc_spam_orders[$key]; + } + + return $sorted; } private function getSqlOrderBy() { $order_by = Get::getString('orderby'); - $allowed_order_by = array_keys($this->get_sortable_columns()); - return in_array('ct_' . $order_by, $allowed_order_by) ? $order_by : ''; + $allowed_order_by = array('id', 'order_date'); + return in_array($order_by, $allowed_order_by, true) ? $order_by : ''; } private function removeSpam($ids) From 871476a122dba6d3f45ad22ffbb0399187393db3 Mon Sep 17 00:00:00 2001 From: datorik Date: Tue, 18 Aug 2026 13:59:47 +0300 Subject: [PATCH 03/27] Udp.Code.Security WooCommerce UI --- .../IntegrationsByClass/Woocommerce.php | 210 ++++++++++++++++++ .../ApbctWP/WcSpamOrdersFunctions.php | 16 ++ .../ApbctWP/WcSpamOrdersListTable.php | 39 +++- 3 files changed, 259 insertions(+), 6 deletions(-) diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index ce9c2b06c..eb00731d7 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -94,6 +94,8 @@ public function doAjaxWork() public function doAdminWork() { + add_action('current_screen', [$this, 'addOrdersListStatusViews']); + add_action('admin_menu', function () { add_submenu_page( 'woocommerce', @@ -651,6 +653,214 @@ public function addOrdersSpamStatusHideFromList($query) } } + /** + * Enable the always visible status links on the HPOS orders list + * + * @param \WP_Screen $current_screen + * + * @return void + * @psalm-suppress PossiblyUnusedMethod + */ + public function addOrdersListStatusViews($current_screen) + { + if ( ! isset($current_screen->id) || ! function_exists('wc_get_page_screen_id') ) { + return; + } + + $orders_screen_id = wc_get_page_screen_id('shop_order'); + + // The legacy storage renders the orders on the posts list screen, the links there lead to another URL + if ( empty($orders_screen_id) || $orders_screen_id === 'shop_order' || $current_screen->id !== $orders_screen_id ) { + return; + } + + add_filter('views_' . $orders_screen_id, [$this, 'addOrdersListStatusLinks']); + + // The blocked orders are stored apart from the WooCommerce ones, so the 'Spam' view is rendered by the plugin + if ( Get::getString('status') === 'wc-spamorder' ) { + $this->replaceOrdersListRenderer($orders_screen_id); + } + } + + /** + * Hand the orders page over to the spam orders table. + * + * The page content is printed by the WooCommerce page controller hooked to the page hook, + * so that callback is taken off and replaced. If it can not be found (the WooCommerce + * internals have changed), nothing is replaced to avoid rendering two tables at once. + * + * @param string $page_hook + * + * @return void + */ + private function replaceOrdersListRenderer($page_hook) + { + global $wp_filter; + + if ( ! isset($wp_filter[$page_hook]) || ! isset($wp_filter[$page_hook]->callbacks) ) { + return; + } + + $removed = false; + + foreach ( $wp_filter[$page_hook]->callbacks as $priority => $callbacks ) { + foreach ( $callbacks as $callback ) { + if ( ! isset($callback['function'][0]) || ! is_object($callback['function'][0]) ) { + continue; + } + + if ( strpos(get_class($callback['function'][0]), 'Admin\\Orders\\PageController') === false ) { + continue; + } + + $removed = remove_action($page_hook, $callback['function'], $priority) || $removed; + } + } + + if ( $removed ) { + add_action($page_hook, [$this, 'renderSpamOrdersPage']); + } + } + + /** + * The spam orders table shown in place of the WooCommerce orders list, + * keeping the page markup and the status links of the original page. + * + * @return void + * @psalm-suppress PossiblyUnusedMethod + */ + public function renderSpamOrdersPage() + { + $post_type = get_post_type_object('shop_order'); + $title = $post_type && isset($post_type->labels->name) + ? $post_type->labels->name + : __('Orders', 'cleantalk-spam-protect'); + ?> +
+

+
+
+ getOrdersListViews()); + $list_table->display(); + ?> +
+
+ $label ) { + $count = wc_orders_count($status, 'shop_order'); + + $status_object = get_post_status_object($status); + if ( $status_object && ! empty($status_object->show_in_admin_all_list) ) { + $all_count += $count; + } + + if ( $count > 0 ) { + $views[$status] = $this->getOrdersListStatusLink($status, $label, false); + } + } + + return array_merge( + array('all' => $this->getOrdersListStatusLink('', __('All', 'cleantalk-spam-protect'), false, $all_count)), + $views + ); + } + + /** + * The orders list shows the statuses having orders only, so the spam workflow statuses + * are unreachable until an order gets marked as spam. Both of them are added back: + * 'Spam' itself and 'On hold' the unmarked orders are moved to. + * + * @param array $views + * + * @return array + * @psalm-suppress PossiblyUnusedMethod, PossiblyUnusedReturnValue + */ + public function addOrdersListStatusLinks($views) + { + if ( ! is_array($views) || ! function_exists('wc_get_order_statuses') ) { + return $views; + } + + $order_statuses = wc_get_order_statuses(); + $current_status = Get::getString('status'); + + foreach ( array('wc-on-hold', 'wc-spamorder') as $status ) { + if ( isset($views[$status]) || ! isset($order_statuses[$status]) ) { + continue; + } + + $views[$status] = $this->getOrdersListStatusLink( + $status, + $order_statuses[$status], + $current_status === $status + ); + } + + return $views; + } + + /** + * @param string $status Empty for the 'All' link + * @param string $label + * @param bool $is_current + * @param int|null $count Known count, counted by the status when not given + * + * @return string + */ + private function getOrdersListStatusLink($status, $label, $is_current, $count = null) + { + if ( is_null($count) ) { + $count = $this->getOrdersListStatusCount($status); + } + + $url = admin_url('admin.php?page=wc-orders'); + if ( ! empty($status) ) { + $url = add_query_arg('status', $status, $url); + } + + return sprintf( + '%s (%s)', + esc_url($url), + $is_current ? ' class="current"' : '', + esc_html($label), + number_format_i18n($count) + ); + } + + /** + * @param string $status + * + * @return int + */ + private function getOrdersListStatusCount($status) + { + // The blocked orders never become WooCommerce ones, they are counted in the plugin table + if ( $status === 'wc-spamorder' ) { + return WcSpamOrdersFunctions::getSpamOrdersCount(); + } + + return function_exists('wc_orders_count') ? wc_orders_count($status, 'shop_order') : 0; + } + /** * Add bulk actions: 'Mark as spam' and 'Unmark as spam' */ diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php index 327f28559..b75eabc35 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersFunctions.php @@ -6,6 +6,22 @@ class WcSpamOrdersFunctions { + /** + * Count of the stored blocked orders, used for the 'Spam' status counter of the orders list. + * + * @return int + */ + public static function getSpamOrdersCount() + { + global $wpdb; + + if ( ! defined('APBCT_TBL_WC_SPAM_ORDERS') ) { + return 0; + } + + return (int)$wpdb->get_var('SELECT COUNT(*) FROM ' . APBCT_TBL_WC_SPAM_ORDERS . ';'); + } + public static function restoreOrderAction() { AJAXService::checkNonceRestrictingNonAdmins(); diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index 3a25aa965..20adbc7a4 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -14,13 +14,26 @@ class WcSpamOrdersListTable extends CleantalkListTable protected $page_title = ''; protected $wc_spam_orders_count = 0; - public function __construct() + /** + * Status links of the hosting page when the table is embedded into it. + * Null means the table is rendered on its own page and builds the links itself. + * + * @var array|null + */ + protected $embedded_views = null; + + /** + * @param array|null $embedded_views Status links of the hosting page, see $embedded_views + */ + public function __construct($embedded_views = null) { parent::__construct(array( 'singular' => 'wc_spam_orders', 'plural' => 'wc_spam_orders' )); + $this->embedded_views = is_array($embedded_views) ? $embedded_views : null; + $this->bulk_actions_handler(); $this->row_actions_handler(); @@ -35,7 +48,10 @@ public function __construct() $this->apbct = $apbct; $this->page_title = 'WooCommerce spam orders'; - $this->generatePageHeader(); + // The hosting page renders its own header + if ( is_null($this->embedded_views) ) { + $this->generatePageHeader(); + } } /** @@ -88,11 +104,18 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam continue; } - $delete_url = wp_nonce_url( - admin_url('admin.php?page=' . Get::getString('page') . '&action=delete&spam=' . $wc_spam_order->id), - 'apbct_wc_spam_orders_row', - '_wpnonce' + // The status has to be kept, the hosting page is chosen by it + $current_status = Get::getString('status'); + $delete_url = admin_url('admin.php?page=' . Get::getString('page')); + $delete_url = add_query_arg( + array_filter(array( + 'status' => $current_status, + 'action' => 'delete', + 'spam' => $wc_spam_order->id, + )), + $delete_url ); + $delete_url = wp_nonce_url($delete_url, 'apbct_wc_spam_orders_row', '_wpnonce'); $actions = array( 'restore' => '' . esc_html__('Restore', 'cleantalk-spam-protect') . '', 'delete' => 'Delete', @@ -149,6 +172,10 @@ protected function get_sortable_columns() // phpcs:ignore PSR1.Methods.CamelCaps */ protected function get_views() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps { + if ( ! is_null($this->embedded_views) ) { + return $this->embedded_views; + } + $current_status = $this->getCurrentStatus(); $statuses = array( From d7932127d368db99d7fe93a03b10df7b5c69b73a Mon Sep 17 00:00:00 2001 From: datorik Date: Thu, 20 Aug 2026 18:38:22 +0300 Subject: [PATCH 04/27] Udp.Code.Security WooCommerce UI --- inc/cleantalk-admin.php | 2 +- inc/cleantalk-settings.php | 2 +- .../IntegrationsByClass/Woocommerce.php | 35 +-------- .../ApbctWP/WcSpamOrdersListTable.php | 75 ++++++++----------- 4 files changed, 39 insertions(+), 75 deletions(-) diff --git a/inc/cleantalk-admin.php b/inc/cleantalk-admin.php index 9cbb6f880..3c849464f 100644 --- a/inc/cleantalk-admin.php +++ b/inc/cleantalk-admin.php @@ -1197,7 +1197,7 @@ function apbct_admin__admin_bar__add_child_nodes($wp_admin_bar) array( 'parent' => 'apbct__parent_node', 'id' => 'ct_settings_bulk_orders', - 'title' => '' + 'title' => '' . __('WooCommerce spam orders', 'cleantalk-spam-protect') . '', ) ); diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index 244d612b5..33e48f9c5 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -2179,7 +2179,7 @@ function apbct_settings__field__action_buttons() if ( apbct_is_plugin_active('woocommerce/woocommerce.php') ) { add_filter('apbct_settings_action_buttons', function ($buttons_array) { $buttons_array[] = - '' + '' . __('WooCommerce spam orders', 'cleantalk-spam-protect') . ''; return $buttons_array; diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index eb00731d7..7dcb87ee9 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -95,28 +95,6 @@ public function doAjaxWork() public function doAdminWork() { add_action('current_screen', [$this, 'addOrdersListStatusViews']); - - add_action('admin_menu', function () { - add_submenu_page( - 'woocommerce', - __("WooCommerce spam orders", 'cleantalk-spam-protect'), - __("WooCommerce spam orders", 'cleantalk-spam-protect'), - 'activate_plugins', - 'apbct_wc_spam_orders', - function () { - ?> -
-
- display(); - ?> -
-
- labels->name) - ? $post_type->labels->name - : __('Orders', 'cleantalk-spam-protect'); + $list_table = new \Cleantalk\ApbctWP\WcSpamOrdersListTable($this->getOrdersListViews()); ?>
-

+


+ renderPageNotices(); ?>
- getOrdersListViews()); - $list_table->display(); - ?> + display(); ?>
prepare_items(); global $apbct; - $this->apbct = $apbct; - $this->page_title = 'WooCommerce spam orders'; - - // The hosting page renders its own header - if ( is_null($this->embedded_views) ) { - $this->generatePageHeader(); - } + $this->apbct = $apbct; } /** @@ -591,10 +584,16 @@ private function removeSpam($ids) ); } - private function generatePageHeader() + /** + * Notices shown above the table: the stored orders count and the warnings about the data. + * + * @return void + * @psalm-suppress PossiblyUnusedMethod + */ + public function renderPageNotices() { if ( ! apbct_api_key__is_correct() ) { - if ( 1 == $this->spam_checker->getApbct()->moderate_ip ) { + if ( 1 == $this->apbct->moderate_ip ) { echo '

' . sprintf( __( @@ -612,39 +611,31 @@ private function generatePageHeader() } ?> -
-

- apbct->data["wl_mode_enabled"]) { - echo $this->apbct->data["wl_brandname"]; - } else { - echo 'CleanTalk logo' . $this->apbct->plugin_name; - } - ?> -

- -
-

page_title; ?>

-

Total count of spam orders: wc_spam_orders_count ?>

-

Please do backup of WordPress database before delete any orders!

-

Results are based on the decision of our spam checking system and do not give a complete guarantee that - these orders are spam.

- apbct->settings['data__wc_store_blocked_orders'] != 1) { - echo '

' - . __( - 'To store WooCommerce spam orders, enable the "Store blocked WooCommerce orders" option in CleanTalk settings.', - 'cleantalk-spam-protect' - ) - . '

'; - } - ?> -
+

wc_spam_orders_count)) + ); ?>

+

+

apbct->settings['data__wc_store_blocked_orders'] != 1) { + echo '

' + . esc_html__( + 'To store WooCommerce spam orders, enable the "Store blocked WooCommerce orders" option in CleanTalk settings.', + 'cleantalk-spam-protect' + ) + . '

'; + } } private function deleteFromDb($spam_ids) From 5304c09bff5bb6886a18803d7701ef6df608abbe Mon Sep 17 00:00:00 2001 From: datorik Date: Thu, 20 Aug 2026 23:33:10 +0300 Subject: [PATCH 05/27] Udp.Code.Security WooCommerce UI --- lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index 6efb37daa..5ee5d9344 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -611,18 +611,21 @@ public function renderPageNotices() } ?> +

wc_spam_orders_count)) - ); ?>

-

-

Date: Fri, 21 Aug 2026 12:45:45 +0300 Subject: [PATCH 06/27] Udp.Code.Security WooCommerce UI --- .../ApbctWP/WcSpamOrdersListTable.php | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index 5ee5d9344..50ae0d113 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -611,16 +611,12 @@ public function renderPageNotices() } ?> -

wc_spam_orders_count)) - ); - echo ' '; + ); ?>

+

apbct->settings['data__wc_store_blocked_orders'] != 1) { + if ($this->apbct->settings['forms__wc_checkout_test'] != 1) { echo '

' - . esc_html__( - 'To store WooCommerce spam orders, enable the "Store blocked WooCommerce orders" option in CleanTalk settings.', - 'cleantalk-spam-protect' + . sprintf( + esc_html__( + 'To store Spam orders, enable the "Store blocked WooCommerce orders" option in %1$sCleanTalk settings%2$s.', + 'cleantalk-spam-protect' + ), + '', + '' ) . '

'; } From f311aa3614286dbd3da5f462f693a8a8b6a5ed15 Mon Sep 17 00:00:00 2001 From: datorik Date: Fri, 21 Aug 2026 19:08:52 +0300 Subject: [PATCH 07/27] Udp.Code.Security WooCommerce UI --- lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index 50ae0d113..7e2d3dd43 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -611,11 +611,6 @@ public function renderPageNotices() } ?> -

wc_spam_orders_count)) - ); ?>

Date: Mon, 24 Aug 2026 13:50:02 +0300 Subject: [PATCH 08/27] Code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index 7e2d3dd43..c9342cf4a 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -622,7 +622,7 @@ public function renderPageNotices() 'cleantalk-spam-protect' ); ?>

apbct->settings['forms__wc_checkout_test'] != 1) { + if ( empty($this->apbct->settings['data__wc_store_blocked_orders']) ) { echo '

' . sprintf( esc_html__( From 06c8bedfa23de7e4314cee94faff6bcc662ec289 Mon Sep 17 00:00:00 2001 From: Aleksandr Banins Date: Mon, 24 Aug 2026 13:59:40 +0300 Subject: [PATCH 09/27] Code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index c9342cf4a..e244050f6 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -532,9 +532,16 @@ private function sortByTotal($wc_spam_orders, $order) $totals = array(); foreach ( $wc_spam_orders as $key => $wc_spam_order ) { - $totals[$key] = is_string($wc_spam_order->order_details) - ? (float) $this->calcOrderTotal($wc_spam_order->order_details) - : 0; + $calculated_total = is_string($wc_spam_order->order_details) + ? $this->calcOrderTotal($wc_spam_order->order_details) + : null; + + if ( is_null($calculated_total) ) { + // Keep undecodable totals consistently at the end for both ASC and DESC. + $calculated_total = $order === 'DESC' ? -PHP_FLOAT_MAX : PHP_FLOAT_MAX; + } + + $totals[$key] = (float) $calculated_total; } uasort($totals, static function ($a, $b) { From 1400e41e9e727958918321f0ab22aa86923ae690 Mon Sep 17 00:00:00 2001 From: AntonV1211 Date: Mon, 24 Aug 2026 14:01:07 +0700 Subject: [PATCH 10/27] Fix errors psalm --- inc/cleantalk-ajax.php | 1 - inc/cleantalk-public-integrations.php | 3 --- lib/Cleantalk/Antispam/Integrations/ChatyContactForm.php | 1 - .../Antispam/Integrations/ElementorUltimateAddonsRegister.php | 1 - lib/Cleantalk/Antispam/Integrations/HivePress.php | 1 - lib/Cleantalk/Antispam/Integrations/HivePressRegistration.php | 1 - lib/Cleantalk/Antispam/Integrations/MailPoet.php | 1 - lib/Cleantalk/Antispam/Integrations/SureForms.php | 1 - lib/Cleantalk/Antispam/Integrations/UserRegistrationPro.php | 3 --- lib/Cleantalk/ApbctWP/Variables/AltSessions.php | 3 --- 10 files changed, 16 deletions(-) diff --git a/inc/cleantalk-ajax.php b/inc/cleantalk-ajax.php index 48d12a792..e8b9be86a 100644 --- a/inc/cleantalk-ajax.php +++ b/inc/cleantalk-ajax.php @@ -894,7 +894,6 @@ function ct_ajax_hook($message_obj = null) 'message' => $ct_result->comment ) ); - die(); } // Plugin Name: eForm - WordPress Form Builder; ajax action ipt_fsqm_save_form diff --git a/inc/cleantalk-public-integrations.php b/inc/cleantalk-public-integrations.php index 5fd280d9c..b22c6df36 100644 --- a/inc/cleantalk-public-integrations.php +++ b/inc/cleantalk-public-integrations.php @@ -2448,9 +2448,6 @@ function apbct_form__the7_contact_form() ); wp_send_json($response); - - // IMPORTANT: don't forget to "exit" @todo AG: Why? Exit does not terminate connection, but I can't see how it is applicable - exit; } } } diff --git a/lib/Cleantalk/Antispam/Integrations/ChatyContactForm.php b/lib/Cleantalk/Antispam/Integrations/ChatyContactForm.php index 7836148e3..12599885b 100644 --- a/lib/Cleantalk/Antispam/Integrations/ChatyContactForm.php +++ b/lib/Cleantalk/Antispam/Integrations/ChatyContactForm.php @@ -41,6 +41,5 @@ public function doBlock($message) 'message' => $message, ]; wp_send_json($response); - exit; } } diff --git a/lib/Cleantalk/Antispam/Integrations/ElementorUltimateAddonsRegister.php b/lib/Cleantalk/Antispam/Integrations/ElementorUltimateAddonsRegister.php index feaa89b16..f5a839cc0 100644 --- a/lib/Cleantalk/Antispam/Integrations/ElementorUltimateAddonsRegister.php +++ b/lib/Cleantalk/Antispam/Integrations/ElementorUltimateAddonsRegister.php @@ -24,7 +24,6 @@ public function doBlock($message) ) ) ); - return false; } public function allow() diff --git a/lib/Cleantalk/Antispam/Integrations/HivePress.php b/lib/Cleantalk/Antispam/Integrations/HivePress.php index 50a1eda3c..c5f227594 100644 --- a/lib/Cleantalk/Antispam/Integrations/HivePress.php +++ b/lib/Cleantalk/Antispam/Integrations/HivePress.php @@ -39,6 +39,5 @@ public function doBlock($message) 'error' => ['message' => $message], ] ); - die(); } } diff --git a/lib/Cleantalk/Antispam/Integrations/HivePressRegistration.php b/lib/Cleantalk/Antispam/Integrations/HivePressRegistration.php index fc5044faa..0279910a2 100644 --- a/lib/Cleantalk/Antispam/Integrations/HivePressRegistration.php +++ b/lib/Cleantalk/Antispam/Integrations/HivePressRegistration.php @@ -35,6 +35,5 @@ public function doBlock($message) 'error' => ['message' => $message], ] ); - die(); } } diff --git a/lib/Cleantalk/Antispam/Integrations/MailPoet.php b/lib/Cleantalk/Antispam/Integrations/MailPoet.php index 5343bf60b..df1bb14e0 100644 --- a/lib/Cleantalk/Antispam/Integrations/MailPoet.php +++ b/lib/Cleantalk/Antispam/Integrations/MailPoet.php @@ -58,6 +58,5 @@ public function doBlock($message) ), 403 ); - die(); } } diff --git a/lib/Cleantalk/Antispam/Integrations/SureForms.php b/lib/Cleantalk/Antispam/Integrations/SureForms.php index 8e9a83d5f..eeb997515 100644 --- a/lib/Cleantalk/Antispam/Integrations/SureForms.php +++ b/lib/Cleantalk/Antispam/Integrations/SureForms.php @@ -29,6 +29,5 @@ public function doBlock($message) 'message' => $message ) ); - die(); } } diff --git a/lib/Cleantalk/Antispam/Integrations/UserRegistrationPro.php b/lib/Cleantalk/Antispam/Integrations/UserRegistrationPro.php index d6011c211..d4719fecd 100644 --- a/lib/Cleantalk/Antispam/Integrations/UserRegistrationPro.php +++ b/lib/Cleantalk/Antispam/Integrations/UserRegistrationPro.php @@ -38,8 +38,6 @@ public function getDataForChecking($argument) /** * @param $message - * - * @psalm-suppress UnusedVariable */ public function doBlock($message) { @@ -48,6 +46,5 @@ public function doBlock($message) 'message' => $message ) ); - die(); } } diff --git a/lib/Cleantalk/ApbctWP/Variables/AltSessions.php b/lib/Cleantalk/ApbctWP/Variables/AltSessions.php index 82e41d3ea..3694d7e62 100644 --- a/lib/Cleantalk/ApbctWP/Variables/AltSessions.php +++ b/lib/Cleantalk/ApbctWP/Variables/AltSessions.php @@ -192,7 +192,6 @@ public static function setFromRemote($request = null) 'error' => 'AltSessions: No cookies data provided.' ) ); - die(); // Need to prevent psalm further processing checking } if ( ! wp_verify_nonce($nonce, $action) ) { @@ -211,7 +210,6 @@ public static function setFromRemote($request = null) try { $cookies_array = json_decode($cookies_to_set, true); } catch ( \Exception $e ) { - $cookies_array = array(); unset($e); wp_send_json(array( 'success' => false, @@ -234,7 +232,6 @@ public static function setFromRemote($request = null) //other versions json errors if json_decode returns null if ( is_null($cookies_array) ) { - $cookies_array = array(); wp_send_json(array( 'success' => false, 'error' => 'AltSessions: Internal JSON error: $cookies_array is null.')); From 8b6b2d05d4c60675f09a2d1b43f791b3a83deee9 Mon Sep 17 00:00:00 2001 From: datorik Date: Tue, 8 Sep 2026 23:45:56 +0300 Subject: [PATCH 11/27] Udp.Code.Security WooCommerce UI. Task 1 --- .../IntegrationsByClass/Woocommerce.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index 7dcb87ee9..e05b1f7fc 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -654,12 +654,40 @@ public function addOrdersListStatusViews($current_screen) add_filter('views_' . $orders_screen_id, [$this, 'addOrdersListStatusLinks']); + // WooCommerce replaces the whole list with a notice while the store has no orders of its own. + // The status links go away with it, so the 'Spam' view becomes unreachable - keep the list on screen. + add_filter( + 'woocommerce_shop_order_list_table_should_render_blank_state', + [$this, 'keepOrdersListWhenSpamOrdersExist'] + ); + // The blocked orders are stored apart from the WooCommerce ones, so the 'Spam' view is rendered by the plugin if ( Get::getString('status') === 'wc-spamorder' ) { $this->replaceOrdersListRenderer($orders_screen_id); } } + /** + * Keep the orders list on screen when the store has no orders of its own but spam ones exist. + * + * A brand new store has nothing in the WooCommerce tables, so the list is replaced with the + * "When you receive a new order, it will appear here." notice. The blocked orders live in a table + * of the plugin and are not counted there, so the shop owner loses the only way to reach them. + * + * @param bool|null $should_render_blank_state Null keeps the WooCommerce own decision + * + * @return bool|null + * @psalm-suppress PossiblyUnusedMethod + */ + public function keepOrdersListWhenSpamOrdersExist($should_render_blank_state) + { + if ( WcSpamOrdersFunctions::getSpamOrdersCount() > 0 ) { + return false; + } + + return $should_render_blank_state; + } + /** * Hand the orders page over to the spam orders table. * From 1303148c791b69119608fd24559729c4ebe9502d Mon Sep 17 00:00:00 2001 From: datorik Date: Wed, 9 Sep 2026 12:22:42 +0300 Subject: [PATCH 12/27] Udp.Code.Security WooCommerce UI. Task 2 --- lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index e244050f6..ca7a4b8b2 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -627,6 +627,12 @@ public function renderPageNotices() esc_html_e( 'Results are based on the decision of our spam checking system and do not give a complete guarantee that these orders are spam.', 'cleantalk-spam-protect' + ); + echo ' '; + printf( + '%2$s', + esc_url(TT::toString($this->apbct->settings_link)), + esc_html__('Anti-Spam by CleanTalk', 'cleantalk-spam-protect') ); ?>

apbct->settings['data__wc_store_blocked_orders']) ) { From 2abe12c5c4a0e7057e47b28b0d4f3e974e3dae54 Mon Sep 17 00:00:00 2001 From: datorik Date: Wed, 9 Sep 2026 15:32:30 +0300 Subject: [PATCH 13/27] Udp.Code.Security WooCommerce UI. Task 3 --- .../IntegrationsByClass/Woocommerce.php | 155 +++++++++++++++++- 1 file changed, 151 insertions(+), 4 deletions(-) diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index e05b1f7fc..c9e3709b1 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -34,8 +34,19 @@ */ class Woocommerce extends IntegrationByClassBase { + /** + * Prefix of the transient keeping the details of a blocked order. + */ + const BLOCKED_ORDER_TRANSIENT = 'apbct_blocked_order_'; + private $event_token = null; + /** + * Key of the transient holding the details of the order blocked in this request. + * @var string + */ + private $blocked_order_key = ''; + /** * @return void * @psalm-suppress PossiblyUnusedMethod @@ -47,6 +58,12 @@ public function doPublicWork() // honeypot add_filter('woocommerce_checkout_fields', [$this, 'addHoneypotField']); + // The blocked visitor gets a thank you page with no order behind it - fill in the details. + // Only the redirect of a blocked order carries the key, so the rest of the pages are left alone. + if ( Get::getString('key') !== '' ) { + add_action('woocommerce_after_template_part', [$this, 'renderBlockedOrderOverview'], 10, 4); + } + // add to cart hooks if cart works with non-ajax requests $this->addCartActions(); @@ -272,9 +289,10 @@ public function checkoutCheckFromRest($order) ct_hash($ct_result->id); if ( $ct_result->allow == 0 ) { - $response = $this->getStoreApiPassedResponse($order); + // The details must be stored before the response carrying their key is built + $this->handleBlockedOrder($order); - $this->handleBlockedOrder(); + $response = $this->getStoreApiPassedResponse($order); if ( $order->get_status() === 'pending' || $order->get_status() === 'checkout-draft' ) { if ( function_exists('wc_release_stock_for_order') ) { @@ -302,15 +320,139 @@ public function checkoutCheckFromRest($order) * @return void * @psalm-suppress UndefinedFunction */ - private function handleBlockedOrder() + private function handleBlockedOrder($order = null) { $this->storeBlockedOrder(); + $this->rememberBlockedOrderOverview($order); + if ( function_exists('wc') && ! is_null(wc()->cart) ) { wc()->cart->empty_cart(); } } + /** + * Store the details of the blocked order to show them on the thank you page. + * + * The blocked order never becomes a WooCommerce one, so the thank you page has nothing to + * render and gives the spammer a hint that the order went wrong. The details are kept in a + * transient addressed by the key of the redirect URL and printed by renderBlockedOrderOverview(). + * + * @param \WC_Order|null $order Order created by the Store API checkout, absent for the classic one + * + * @return void + * @psalm-suppress UndefinedClass, UndefinedFunction + */ + private function rememberBlockedOrderOverview($order = null) + { + $overview = array( + 'date' => date_i18n(get_option('date_format')), + 'total' => $this->getBlockedOrderTotal($order), + 'payment_method' => $this->getBlockedOrderPaymentMethod($order), + ); + + $this->blocked_order_key = 'wc_order_' . wp_generate_password(13, false); + + set_transient(self::BLOCKED_ORDER_TRANSIENT . $this->blocked_order_key, $overview, HOUR_IN_SECONDS); + } + + /** + * @param \WC_Order|null $order + * + * @return string Formatted total, the cart one when there is no order + * @psalm-suppress UndefinedClass, UndefinedFunction + */ + private function getBlockedOrderTotal($order = null) + { + if ( $order instanceof \WC_Order ) { + return $order->get_formatted_order_total(); + } + + return ( function_exists('wc') && ! is_null(wc()->cart) ) ? wc()->cart->get_total() : ''; + } + + /** + * @param \WC_Order|null $order + * + * @return string Title of the chosen gateway, empty when it can not be resolved + * @psalm-suppress UndefinedClass, UndefinedFunction + */ + private function getBlockedOrderPaymentMethod($order = null) + { + if ( $order instanceof \WC_Order ) { + return $order->get_payment_method_title(); + } + + $chosen_method = Post::getString('payment_method'); + + if ( $chosen_method === '' || ! function_exists('WC') ) { + return ''; + } + + $gateways = WC()->payment_gateways() ? WC()->payment_gateways()->payment_gateways() : array(); + + return isset($gateways[$chosen_method]) ? $gateways[$chosen_method]->get_title() : ''; + } + + /** + * Print the order details on the thank you page shown to the blocked visitor. + * + * WooCommerce renders the details itself when an order stands behind the page. There is none + * for a blocked order, so the same markup is printed right after the template that says + * the order has been received. + * + * @param string $template_name + * @param string $template_path + * @param string $located + * @param array $args + * + * @return void + * @psalm-suppress PossiblyUnusedMethod, UndefinedFunction + */ + public function renderBlockedOrderOverview($template_name, $template_path, $located, $args) + { + if ( $template_name !== 'checkout/order-received.php' || ! empty($args['order']) ) { + return; + } + + $blocked_order_key = Get::getString('key'); + + if ( $blocked_order_key === '' ) { + return; + } + + $overview = get_transient(self::BLOCKED_ORDER_TRANSIENT . $blocked_order_key); + + if ( ! is_array($overview) || empty($overview['date']) ) { + return; + } + + $rows = array( + 'date' => array(__('Date:', 'cleantalk-spam-protect'), $overview['date']), + 'total' => array(__('Total:', 'cleantalk-spam-protect'), $overview['total']), + ); + + if ( ! empty($overview['payment_method']) ) { + $rows['method'] = array( + __('Payment method:', 'cleantalk-spam-protect'), + $overview['payment_method'] + ); + } + + echo '
    '; + + foreach ( $rows as $key => $row ) { + printf( + '
  • %2$s %3$s
  • ', + esc_attr($key), + esc_html($row[0]), + wp_kses_post($row[1]) + ); + } + + echo '
'; + } + /** * URL of the page shown to the visitor whose order was blocked. * @@ -319,7 +461,12 @@ private function handleBlockedOrder() */ private function getBlockedOrderRedirectUrl() { - return wc_get_endpoint_url('order-received', '', wc_get_checkout_url()); + $url = wc_get_endpoint_url('order-received', '', wc_get_checkout_url()); + + // The key makes the page look like the usual one and points at the stored details + return $this->blocked_order_key === '' + ? $url + : add_query_arg('key', $this->blocked_order_key, $url); } /** From dde7ba8dca736310dec2558d1e2a62c400607c6d Mon Sep 17 00:00:00 2001 From: alexandergull Date: Tue, 22 Sep 2026 21:20:31 +0500 Subject: [PATCH 14/27] Upd. WooCommerce. New option to manage if rejection message have to be shown for blocked visitor. --- inc/cleantalk-settings.php | 11 +- inc/cleantalk-updater.php | 16 ++ .../IntegrationsByClass/Woocommerce.php | 54 +++++-- lib/Cleantalk/ApbctWP/State.php | 1 + .../ApbctWP/WcSpamOrdersListTable.php | 3 +- .../TestWoocommerceCheckoutGuards.php | 126 ++++++++++++++++ .../TestWcShowRejectionMessageOption.php | 140 ++++++++++++++++++ tests/Inc/TestCleantalkUpdater.php | 75 ++++++++++ 8 files changed, 408 insertions(+), 18 deletions(-) create mode 100644 tests/Antispam/IntegrationsByClass/TestWoocommerceCheckoutGuards.php create mode 100644 tests/ApbctWP/TestWcShowRejectionMessageOption.php diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index 7dce8f85e..474d499d3 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -366,7 +366,16 @@ function apbct_settings__set_fields() ), 'data__wc_store_blocked_orders' => array( 'title' => __('Store blocked orders', 'cleantalk-spam-protect'), - 'description' => __('The orders which was blocked by the Anti-Spam will be stored and could be restored manually later if its needed.', 'cleantalk-spam-protect'), + 'description' => __('Orders blocked by Anti-Spam will be stored and can be restored manually later if needed.', 'cleantalk-spam-protect'), + 'class' => 'apbct_settings-field_wrapper--sub', + 'options' => array( + array('val' => 1, 'label' => __('On')), + array('val' => 0, 'label' => __('Off')), + ), + ), + 'forms__wc_show_rejection_message' => array( + 'title' => __('Show rejection message to customers', 'cleantalk-spam-protect'), + 'description' => __('This message tells the customer why their order was filtered, allowing them to fix the issue that caused it. However, this may result in multiple orders from the same customer because all rejected orders are saved in the Spam folder. By default, this option is OFF.', 'cleantalk-spam-protect'), 'class' => 'apbct_settings-field_wrapper--sub', 'options' => array( array('val' => 1, 'label' => __('On')), diff --git a/inc/cleantalk-updater.php b/inc/cleantalk-updater.php index d4c865378..67d547c82 100644 --- a/inc/cleantalk-updater.php +++ b/inc/cleantalk-updater.php @@ -1388,3 +1388,19 @@ function apbct_update_to_6_76_0() $apbct->saveData(); } } + +/** + * Preserve the legacy WooCommerce checkout behavior (rejection message shown to the customer) + * for users who already had "Store blocked orders" enabled before the new option was introduced. + * + * @return void + */ +function apbct_update_to_6_89_0() +{ + global $apbct; + + if ( ! empty($apbct->settings['data__wc_store_blocked_orders']) ) { + $apbct->settings['forms__wc_show_rejection_message'] = 1; + $apbct->saveSettings(); + } +} diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index 69d6d8cad..f45b24e37 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -189,11 +189,6 @@ public function checkoutCheck($_data, $errors) __FUNCTION__ ); - if ( ! $apbct->settings['data__wc_store_blocked_orders'] ) { - // The checkout is left to WooCommerce as is: no check, no blocked order to store. - return; - } - if ( count($errors->errors) ) { return; } @@ -252,6 +247,17 @@ public function checkoutCheck($_data, $errors) if ( $ct_result->allow == 0 ) { $this->handleBlockedOrder(); + + if ( $apbct->settings['forms__wc_show_rejection_message'] ) { + // Legacy behavior: show the rejection reason directly to the customer. + wp_send_json(array( + 'result' => 'failure', + 'messages' => "
  • " . $ct_result->comment . "
", + 'refresh' => 'false', + 'reload' => 'false' + )); + } + wp_send_json(array( 'result' => 'success', 'redirect' => $this->getBlockedOrderRedirectUrl(), @@ -269,11 +275,6 @@ public function checkoutCheckFromRest($order) { global $apbct, $cleantalk_executed; - if ( ! $apbct->settings['data__wc_store_blocked_orders'] ) { - // The checkout is left to WooCommerce as is: no check, no blocked order to store. - return; - } - if ( is_null($order) || ! ($order instanceof \WC_Order) ) { return; } @@ -316,8 +317,6 @@ public function checkoutCheckFromRest($order) // The details must be stored before the response carrying their key is built $this->handleBlockedOrder($order); - $response = $this->getStoreApiPassedResponse($order); - if ( $order->get_status() === 'pending' || $order->get_status() === 'checkout-draft' ) { if ( function_exists('wc_release_stock_for_order') ) { wc_release_stock_for_order($order); @@ -330,6 +329,24 @@ public function checkoutCheckFromRest($order) } } + if ( $apbct->settings['forms__wc_show_rejection_message'] ) { + // Legacy behavior: show the rejection reason directly to the customer. + $response = array( + 'code' => 'woocommerce_store_api_checkout_order_processed', + 'message' => $ct_result->comment, + 'data' => array( + 'status' => 403 + ) + ); + + if ( ! headers_sent() ) { + http_response_code(403); + } + die(json_encode($response)); + } + + $response = $this->getStoreApiPassedResponse($order); + if ( ! headers_sent() ) { header('Content-Type: application/json; charset=utf-8'); } @@ -346,7 +363,12 @@ public function checkoutCheckFromRest($order) */ private function handleBlockedOrder($order = null) { - $this->storeBlockedOrder(); + global $apbct; + + // The option controls the storage only, the rest of the handling is always done + if ( $apbct->settings['data__wc_store_blocked_orders'] ) { + $this->storeBlockedOrder(); + } $this->rememberBlockedOrderOverview($order); @@ -431,7 +453,7 @@ private function getBlockedOrderPaymentMethod($order = null) * @param array $args * * @return void - * @psalm-suppress PossiblyUnusedMethod, UndefinedFunction + * @psalm-suppress PossiblyUnusedMethod, UndefinedFunction, PossiblyUnusedParam */ public function renderBlockedOrderOverview($template_name, $template_path, $located, $args) { @@ -453,7 +475,7 @@ public function renderBlockedOrderOverview($template_name, $template_path, $loca $rows = array( 'date' => array(__('Date:', 'cleantalk-spam-protect'), $overview['date']), - 'total' => array(__('Total:', 'cleantalk-spam-protect'), $overview['total']), + 'total' => array(__('Total:', 'cleantalk-spam-protect'), isset($overview['total']) ? $overview['total'] : ''), ); if ( ! empty($overview['payment_method']) ) { @@ -858,7 +880,7 @@ public function addOrdersListStatusViews($current_screen) * @param bool|null $should_render_blank_state Null keeps the WooCommerce own decision * * @return bool|null - * @psalm-suppress PossiblyUnusedMethod + * @psalm-suppress PossiblyUnusedMethod, PossiblyUnusedReturnValue */ public function keepOrdersListWhenSpamOrdersExist($should_render_blank_state) { diff --git a/lib/Cleantalk/ApbctWP/State.php b/lib/Cleantalk/ApbctWP/State.php index 729c09114..9d0cf5005 100644 --- a/lib/Cleantalk/ApbctWP/State.php +++ b/lib/Cleantalk/ApbctWP/State.php @@ -96,6 +96,7 @@ class State extends \Cleantalk\Common\State 'data__email_decoder_encode_email_addresses' => 1, 'data__email_decoder_excluded_strings' => '', 'data__wc_store_blocked_orders' => 1, + 'forms__wc_show_rejection_message' => 0, // Exclusions // Send to the cloud some excepted requests diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index ca7a4b8b2..e49567b02 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -370,7 +370,8 @@ private function renderOrderColumn($spam_order_id, $customer_details) $customer_name = trim( TT::getArrayValueAsString($customer_details, 'billing_first_name') . ' ' - . TT::getArrayValueAsString($customer_details, 'billing_last_name') + . TT::getArrayValueAsString($customer_details, 'billing_last_name'), + " \n\r\t\v\x00" ); if ( $customer_name === '' ) { diff --git a/tests/Antispam/IntegrationsByClass/TestWoocommerceCheckoutGuards.php b/tests/Antispam/IntegrationsByClass/TestWoocommerceCheckoutGuards.php new file mode 100644 index 000000000..9bed18a0e --- /dev/null +++ b/tests/Antispam/IntegrationsByClass/TestWoocommerceCheckoutGuards.php @@ -0,0 +1,126 @@ +apbct_backup = $apbct; + $apbct = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + $this->integration = new Woocommerce(); + } + + public function tearDown(): void + { + global $apbct; + + $apbct = $this->apbct_backup; + + parent::tearDown(); + } + + /** + * Minimal stand-in for the WP_Error object passed by woocommerce_after_checkout_validation. + * + * @param array $errors + * + * @return object + */ + private function makeErrors($errors = array()) + { + $holder = new \stdClass(); + $holder->errors = $errors; + + return $holder; + } + + /** + * WooCommerce reports its own validation errors first, the anti-spam check is skipped then. + */ + public function testCheckoutCheckReturnsEarlyWhenWooCommerceAlreadyFoundErrors() + { + global $apbct; + $apbct->settings['data__wc_store_blocked_orders'] = 1; + + $this->assertNull( + $this->integration->checkoutCheck( + array(), + $this->makeErrors(array('billing_email' => array('Invalid email'))) + ) + ); + } + + /** + * The storage option must not disable the anti-spam check itself, it only controls + * whether a blocked order is kept in the Spam folder. + */ + public function testCheckoutCheckIsNotGatedByTheStorageOption() + { + $source = file_get_contents( + CLEANTALK_PLUGIN_DIR . 'lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php' + ); + + $this->assertStringNotContainsString( + 'if ( ! $apbct->settings[\'data__wc_store_blocked_orders\'] ) {', + $source + ); + } + + /** + * Anything that is not a WC_Order can not be checked, so the call is dropped. + */ + public function testCheckoutCheckFromRestReturnsEarlyWithoutAnOrder() + { + global $apbct; + $apbct->settings['data__wc_store_blocked_orders'] = 1; + + $this->assertNull($this->integration->checkoutCheckFromRest(null)); + $this->assertNull($this->integration->checkoutCheckFromRest(new \stdClass())); + } + + /** + * The order is not checkable regardless of the storage option value. + */ + public function testCheckoutCheckFromRestReturnsEarlyWithoutAnOrderWhenStorageIsOff() + { + global $apbct; + $apbct->settings['data__wc_store_blocked_orders'] = 0; + + $this->assertNull($this->integration->checkoutCheckFromRest(null)); + $this->assertNull($this->integration->checkoutCheckFromRest(new \stdClass())); + } + + /** + * Both checkout handlers stay attached to their WooCommerce hooks. + */ + public function testCheckoutHandlersAreCallable() + { + $this->assertTrue(is_callable(array($this->integration, 'checkoutCheck'))); + $this->assertTrue(is_callable(array($this->integration, 'checkoutCheckFromRest'))); + } +} diff --git a/tests/ApbctWP/TestWcShowRejectionMessageOption.php b/tests/ApbctWP/TestWcShowRejectionMessageOption.php new file mode 100644 index 000000000..cee922302 --- /dev/null +++ b/tests/ApbctWP/TestWcShowRejectionMessageOption.php @@ -0,0 +1,140 @@ +apbct_backup = $apbct; + $apbct = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + } + + public function tearDown(): void + { + global $apbct; + + $apbct = $this->apbct_backup; + + parent::tearDown(); + } + + /** + * @return array + */ + private function getWcFields() + { + $fields = apbct_settings__set_fields(); + + $this->assertArrayHasKey('wc', $fields); + $this->assertArrayHasKey('fields', $fields['wc']); + + return $fields['wc']['fields']; + } + + public function testOptionIsDisabledByDefault() + { + $state = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + $this->assertArrayHasKey(self::OPTION, $state->default_settings); + $this->assertSame(0, $state->default_settings[self::OPTION]); + } + + public function testStoreBlockedOrdersStaysEnabledByDefault() + { + $state = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + $this->assertArrayHasKey(self::STORE_BLOCKED_ORDERS_OPTION, $state->default_settings); + $this->assertSame(1, $state->default_settings[self::STORE_BLOCKED_ORDERS_OPTION]); + } + + public function testOptionIsRegisteredInWooCommerceSettingsSection() + { + $wc_fields = $this->getWcFields(); + + $this->assertArrayHasKey(self::OPTION, $wc_fields); + $this->assertSame( + 'Show rejection message to customers', + $wc_fields[self::OPTION]['title'] + ); + } + + public function testOptionHasOnAndOffChoicesOnly() + { + $wc_fields = $this->getWcFields(); + + $this->assertArrayHasKey('options', $wc_fields[self::OPTION]); + + $values = array_column($wc_fields[self::OPTION]['options'], 'val'); + + $this->assertSame(array(1, 0), $values); + } + + public function testOptionIsRenderedAsSubFieldOfTheCheckoutGroup() + { + $wc_fields = $this->getWcFields(); + + $this->assertSame( + 'apbct_settings-field_wrapper--sub', + $wc_fields[self::OPTION]['class'] + ); + } + + public function testOptionDescriptionExplainsTheSideEffects() + { + $wc_fields = $this->getWcFields(); + $description = $wc_fields[self::OPTION]['description']; + + $this->assertStringContainsString( + 'This message tells the customer why their order was filtered', + $description + ); + $this->assertStringContainsString('By default, this option is OFF.', $description); + } + + public function testStoreBlockedOrdersDescriptionIsUpdated() + { + $wc_fields = $this->getWcFields(); + $description = $wc_fields[self::STORE_BLOCKED_ORDERS_OPTION]['description']; + + $this->assertStringContainsString( + 'Orders blocked by Anti-Spam will be stored and can be restored manually later if needed.', + $description + ); + $this->assertStringNotContainsString('could be restored manually later if its needed', $description); + } + + public function testOptionIsPlacedRightAfterStoreBlockedOrders() + { + $wc_fields = $this->getWcFields(); + $keys = array_keys($wc_fields); + + $store_position = array_search(self::STORE_BLOCKED_ORDERS_OPTION, $keys, true); + $message_position = array_search(self::OPTION, $keys, true); + + $this->assertNotFalse($store_position); + $this->assertNotFalse($message_position); + $this->assertSame($store_position + 1, $message_position); + } +} diff --git a/tests/Inc/TestCleantalkUpdater.php b/tests/Inc/TestCleantalkUpdater.php index c0b3d2280..c94776e2c 100644 --- a/tests/Inc/TestCleantalkUpdater.php +++ b/tests/Inc/TestCleantalkUpdater.php @@ -16,4 +16,79 @@ public function testApbctUpdateTo_6_76_0() // Assert $this->assertEquals('1', $apbct_rebuilt->data['bot_detector_enabled']); } + + /** + * Users who already stored blocked orders have to keep the legacy checkout behavior, + * so the rejection message stays visible for them after the update. + */ + public function testApbctUpdateTo_6_89_0_EnablesRejectionMessageWhenBlockedOrdersAreStored() + { + // Arrange + global $apbct; + $apbct->settings['data__wc_store_blocked_orders'] = 1; + $apbct->settings['forms__wc_show_rejection_message'] = 0; + $apbct->saveSettings(); + + // Act + apbct_update_to_6_89_0(); + $apbct_rebuilt = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + // Assert + $this->assertEquals(1, $apbct_rebuilt->settings['forms__wc_show_rejection_message']); + } + + /** + * Sites without the stored blocked orders get the new silent behavior, so the option stays off. + */ + public function testApbctUpdateTo_6_89_0_KeepsRejectionMessageOffWhenBlockedOrdersAreNotStored() + { + // Arrange + global $apbct; + $apbct->settings['data__wc_store_blocked_orders'] = 0; + $apbct->settings['forms__wc_show_rejection_message'] = 0; + $apbct->saveSettings(); + + // Act + apbct_update_to_6_89_0(); + $apbct_rebuilt = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + // Assert + $this->assertEquals(0, $apbct_rebuilt->settings['forms__wc_show_rejection_message']); + } + + /** + * The migration must not be triggered by an empty or missing setting value. + */ + public function testApbctUpdateTo_6_89_0_DoesNothingWhenBlockedOrdersSettingIsMissing() + { + // Arrange + global $apbct; + unset($apbct->settings['data__wc_store_blocked_orders']); + $apbct->settings['forms__wc_show_rejection_message'] = 0; + $apbct->saveSettings(); + + // Act + apbct_update_to_6_89_0(); + $apbct_rebuilt = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + // Assert + $this->assertEquals(0, $apbct_rebuilt->settings['forms__wc_show_rejection_message']); + } + + /** + * The migration is registered under a version that the updater loop actually reaches. + */ + public function testApbctUpdateTo_6_89_0_IsReachableByTheUpdaterLoop() + { + $this->assertTrue(function_exists('apbct_update_to_6_89_0')); + + $version_arr = apbct_version_standardization('6.89.0'); + + $this->assertSame(6, $version_arr[0]); + $this->assertSame(89, $version_arr[1]); + $this->assertSame(0, $version_arr[2]); + + // The loop iterates minor versions up to 300, so 6.89.0 is inside the scanned range + $this->assertLessThanOrEqual(300, $version_arr[1]); + } } From b59f535499e54a288482c7d073c9c2c8201d52d8 Mon Sep 17 00:00:00 2001 From: alexandergull Date: Tue, 22 Sep 2026 22:41:14 +0500 Subject: [PATCH 15/27] Code. Tests. --- .../TestWoocommerceSimpleGetters.php | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 tests/Antispam/IntegrationsByClass/TestWoocommerceSimpleGetters.php diff --git a/tests/Antispam/IntegrationsByClass/TestWoocommerceSimpleGetters.php b/tests/Antispam/IntegrationsByClass/TestWoocommerceSimpleGetters.php new file mode 100644 index 000000000..5ab7c39c0 --- /dev/null +++ b/tests/Antispam/IntegrationsByClass/TestWoocommerceSimpleGetters.php @@ -0,0 +1,262 @@ +formatted_total; + } + + public function get_payment_method_title() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + return $this->payment_method_title; + } + } +} + +namespace Antispam\IntegrationsByClass { + + use Cleantalk\Antispam\IntegrationsByClass\Woocommerce; + use Cleantalk\ApbctWP\State; + use Cleantalk\ApbctWP\Variables\Post; + use PHPUnit\Framework\TestCase; + use WcOrderStub; + + /** + * Unit tests for the small getter methods of the WooCommerce integration. + * + * WooCommerce itself is never loaded in the test environment, so every getter is exercised + * through the "WooCommerce is not active" branch, plus the WC_Order branch using the + * minimal WcOrderStub declared at the top of this file. + */ + class TestWoocommerceSimpleGetters extends TestCase + { + /** + * @var Woocommerce + */ + private $integration; + + /** + * @var mixed + */ + private $apbct_backup; + + /** + * @var array Backup of the 'active_plugins' option to restore after the test. + */ + private $active_plugins_backup; + + public function setUp(): void + { + global $apbct; + parent::setUp(); + + $this->apbct_backup = $apbct; + $apbct = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + $this->active_plugins_backup = get_option('active_plugins'); + + // Reflected variables cache their values for the process lifetime, drop it + // before every test so $_POST changes actually take effect. + Post::getInstance()->variables = array(); + + $this->integration = new Woocommerce(); + } + + public function tearDown(): void + { + global $apbct; + + $apbct = $this->apbct_backup; + update_option('active_plugins', $this->active_plugins_backup); + Post::getInstance()->variables = array(); + unset($_POST['payment_method']); + + parent::tearDown(); + } + + /** + * @return \ReflectionMethod + */ + private function getMethod($name) + { + $method = new \ReflectionMethod(Woocommerce::class, $name); + $method->setAccessible(true); + + return $method; + } + + // ------------------------------------------------------------------- + // getCompletedOrders() + // ------------------------------------------------------------------- + + public function testGetCompletedOrdersReturnsEmptyStringWhenWooCommerceIsNotActive() + { + update_option('active_plugins', array()); + + $this->assertSame('', Woocommerce::getCompletedOrders()); + } + + public function testGetCompletedOrdersReturnsSqlFragmentWhenWooCommerceIsActive() + { + update_option('active_plugins', array('woocommerce/woocommerce.php')); + + $result = Woocommerce::getCompletedOrders(); + + $this->assertIsString($result); + $this->assertNotSame('', $result); + $this->assertStringContainsString('wc-completed', $result); + } + + // ------------------------------------------------------------------- + // getBlockedOrderTotal() + // ------------------------------------------------------------------- + + public function testGetBlockedOrderTotalReturnsEmptyStringWithoutAnOrderAndWithoutWooCommerce() + { + $result = $this->getMethod('getBlockedOrderTotal')->invoke($this->integration, null); + + $this->assertSame('', $result); + } + + public function testGetBlockedOrderTotalUsesTheOrderWhenGiven() + { + $order = new WcOrderStub(); + $order->formatted_total = '$25.00'; + + $result = $this->getMethod('getBlockedOrderTotal')->invoke($this->integration, $order); + + $this->assertSame('$25.00', $result); + } + + // ------------------------------------------------------------------- + // getBlockedOrderPaymentMethod() + // ------------------------------------------------------------------- + + public function testGetBlockedOrderPaymentMethodReturnsEmptyStringWithoutAnOrderAndWithoutAChosenMethod() + { + $result = $this->getMethod('getBlockedOrderPaymentMethod')->invoke($this->integration, null); + + $this->assertSame('', $result); + } + + public function testGetBlockedOrderPaymentMethodReturnsEmptyStringWithoutAnOrderWhenWooCommerceIsNotActive() + { + $_POST['payment_method'] = 'bacs'; + + $result = $this->getMethod('getBlockedOrderPaymentMethod')->invoke($this->integration, null); + + // WC() is never defined in the test environment, so the gateway title can not be resolved. + $this->assertSame('', $result); + } + + public function testGetBlockedOrderPaymentMethodUsesTheOrderWhenGiven() + { + $order = new WcOrderStub(); + $order->payment_method_title = 'Direct bank transfer'; + + $result = $this->getMethod('getBlockedOrderPaymentMethod')->invoke($this->integration, $order); + + $this->assertSame('Direct bank transfer', $result); + } + + // ------------------------------------------------------------------- + // getOrdersListStatusCount() + // ------------------------------------------------------------------- + + public function testGetOrdersListStatusCountReturnsZeroWhenWooCommerceIsNotActive() + { + $result = $this->getMethod('getOrdersListStatusCount')->invoke($this->integration, 'wc-processing'); + + $this->assertSame(0, $result); + } + + public function testGetOrdersListStatusCountReadsTheSpamTableForTheSpamStatus() + { + $result = $this->getMethod('getOrdersListStatusCount')->invoke($this->integration, 'wc-spamorder'); + + // No WooCommerce/spam table is guaranteed in the test environment, only the type matters here. + $this->assertIsInt($result); + $this->assertGreaterThanOrEqual(0, $result); + } + + // ------------------------------------------------------------------- + // getOrdersListStatusLink() + // ------------------------------------------------------------------- + + public function testGetOrdersListStatusLinkRendersAnAnchorWithTheGivenCount() + { + $result = $this->getMethod('getOrdersListStatusLink')->invoke( + $this->integration, + 'wc-processing', + 'Processing', + false, + 7 + ); + + $this->assertStringContainsString('assertStringContainsString('Processing', $result); + $this->assertStringContainsString('(7)', $result); + $this->assertStringNotContainsString('class="current"', $result); + } + + public function testGetOrdersListStatusLinkMarksTheCurrentStatus() + { + $result = $this->getMethod('getOrdersListStatusLink')->invoke( + $this->integration, + 'wc-spamorder', + 'Spam', + true, + 3 + ); + + $this->assertStringContainsString('class="current"', $result); + } + + public function testGetOrdersListStatusLinkOmitsTheStatusQueryArgForAll() + { + $result = $this->getMethod('getOrdersListStatusLink')->invoke( + $this->integration, + '', + 'All', + true, + 42 + ); + + $this->assertStringNotContainsString('status=', $result); + $this->assertStringContainsString('(42)', $result); + } + + public function testGetOrdersListStatusLinkCountsTheStatusWhenNotGiven() + { + $result = $this->getMethod('getOrdersListStatusLink')->invoke( + $this->integration, + 'wc-processing', + 'Processing', + false, + null + ); + + // Without WooCommerce active the count resolves to 0 via getOrdersListStatusCount(). + $this->assertStringContainsString('(0)', $result); + } + } +} From 558f3d5f1084a14d3f99ab03d8c0794414ba0f16 Mon Sep 17 00:00:00 2001 From: alexandergull Date: Tue, 22 Sep 2026 22:51:46 +0500 Subject: [PATCH 16/27] Fix. WooCommerce. Match the actual thank-you template name for the blocked order overview. WooCommerce renders checkout/thankyou.php for the no-order confirmation page, not checkout/order-received.php. The overview was never shown because of this mismatch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index f45b24e37..62e442f6d 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -457,7 +457,7 @@ private function getBlockedOrderPaymentMethod($order = null) */ public function renderBlockedOrderOverview($template_name, $template_path, $located, $args) { - if ( $template_name !== 'checkout/order-received.php' || ! empty($args['order']) ) { + if ( $template_name !== 'checkout/thankyou.php' || ! empty($args['order']) ) { return; } From 0f15ecf5b89fb3a1897187973e33c7bdc3a11626 Mon Sep 17 00:00:00 2001 From: alexandergull Date: Tue, 22 Sep 2026 22:55:24 +0500 Subject: [PATCH 17/27] Fix. WooCommerce. Restore a legacy admin page fallback for stored spam orders. addOrdersListStatusViews() only integrates the 'Spam' view into the native orders screen when wc_get_page_screen_id() reports the HPOS screen. Legacy (posts table) installations render orders on the shared post-type screen instead, so the option was lost when the old admin_menu submenu page was removed. A conditional fallback page is registered again for that case, reusing the existing renderSpamOrdersPage() renderer. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IntegrationsByClass/Woocommerce.php | 29 +++++ ...estWoocommerceLegacySpamOrdersMenuPage.php | 109 ++++++++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 tests/Antispam/IntegrationsByClass/TestWoocommerceLegacySpamOrdersMenuPage.php diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index 62e442f6d..4b2783fae 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -122,6 +122,35 @@ public function doAjaxWork() public function doAdminWork() { add_action('current_screen', [$this, 'addOrdersListStatusViews']); + add_action('admin_menu', [$this, 'addLegacySpamOrdersMenuPage']); + } + + /** + * Legacy fallback: register a dedicated admin page for the stored spam orders. + * + * addOrdersListStatusViews() integrates the 'Spam' view into the native orders screen, + * but that only works when wc_get_page_screen_id() reports the HPOS orders screen. + * Legacy (posts table) installations render orders on the shared post-type screen instead, + * so the blocked orders would become unreachable without a standalone page. + * + * @return void + * @psalm-suppress PossiblyUnusedMethod + */ + public function addLegacySpamOrdersMenuPage() + { + if ( function_exists('wc_get_page_screen_id') && wc_get_page_screen_id('shop_order') !== 'shop_order' ) { + // HPOS handles the integration inline, no separate page is needed. + return; + } + + add_submenu_page( + 'woocommerce', + __('WooCommerce spam orders', 'cleantalk-spam-protect'), + __('WooCommerce spam orders', 'cleantalk-spam-protect'), + 'activate_plugins', + 'apbct_wc_spam_orders', + [$this, 'renderSpamOrdersPage'] + ); } public function addActions() diff --git a/tests/Antispam/IntegrationsByClass/TestWoocommerceLegacySpamOrdersMenuPage.php b/tests/Antispam/IntegrationsByClass/TestWoocommerceLegacySpamOrdersMenuPage.php new file mode 100644 index 000000000..e5fd46c54 --- /dev/null +++ b/tests/Antispam/IntegrationsByClass/TestWoocommerceLegacySpamOrdersMenuPage.php @@ -0,0 +1,109 @@ +submenu_backup = $submenu; + $submenu = array(); + + // Grant the capability required by add_submenu_page() regardless of the current user. + add_filter('user_has_cap', array($this, 'grantActivatePluginsCap')); + + $this->integration = new Woocommerce(); + } + + public function tearDown(): void + { + global $submenu; + + $submenu = $this->submenu_backup; + remove_filter('user_has_cap', array($this, 'grantActivatePluginsCap')); + + parent::tearDown(); + } + + /** + * @param array $allcaps + * + * @return array + */ + public function grantActivatePluginsCap($allcaps) + { + $allcaps['activate_plugins'] = true; + + return $allcaps; + } + + /** + * Without wc_get_page_screen_id() (older WooCommerce, or the function unavailable + * as is the case in this test environment) the fallback page must still be registered. + */ + public function testAddsTheFallbackPageWhenTheHposScreenIdCanNotBeResolved() + { + $this->assertFalse(function_exists('wc_get_page_screen_id')); + + $this->integration->addLegacySpamOrdersMenuPage(); + + global $submenu; + + $this->assertArrayHasKey('woocommerce', $submenu); + + $found = false; + foreach ( $submenu['woocommerce'] as $item ) { + if ( $item[2] === 'apbct_wc_spam_orders' ) { + $found = true; + } + } + + $this->assertTrue($found, 'The apbct_wc_spam_orders submenu page was not registered.'); + } + + /** + * Regression guard: the fallback must stay conditional on the HPOS screen id, otherwise + * two competing "Spam" views would be registered on HPOS stores. + */ + public function testFallbackStaysConditionalOnTheHposScreenId() + { + $source = file_get_contents( + CLEANTALK_PLUGIN_DIR . 'lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php' + ); + + $this->assertStringContainsString( + "function_exists('wc_get_page_screen_id') && wc_get_page_screen_id('shop_order') !== 'shop_order'", + $source + ); + } + + public function testHandlerIsCallable() + { + $this->assertTrue(is_callable(array($this->integration, 'addLegacySpamOrdersMenuPage'))); + } +} From b40a3f4731ca147a9d0d8155070b1b2eb4e3e52c Mon Sep 17 00:00:00 2001 From: alexandergull Date: Tue, 22 Sep 2026 22:58:18 +0500 Subject: [PATCH 18/27] Fix. WooCommerce. Rename the spam orders 'Total' column to 'Items total'. calcOrderTotal() only sums the stored cart line items and their tax - shipping, fees and other checkout charges are not persisted for a blocked order, so the value was understated relative to a real order total. The label now reflects what is actually shown; the underlying calculation is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index e49567b02..9d1ebfa03 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -142,7 +142,9 @@ public function get_columns() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.N 'ct_order' => esc_html__('Order', 'cleantalk-spam-protect'), 'ct_order_date' => esc_html__('Date', 'cleantalk-spam-protect'), 'ct_status' => esc_html__('Status', 'cleantalk-spam-protect'), - 'ct_total' => esc_html__('Total', 'cleantalk-spam-protect'), + // Sums up only the stored cart line items - shipping, fees and other checkout + // charges are not persisted for a blocked order, so this is an items subtotal. + 'ct_total' => esc_html__('Items total', 'cleantalk-spam-protect'), ); return $columns; From b651bd542410c522447367b9b41c826c0e40f40e Mon Sep 17 00:00:00 2001 From: alexandergull Date: Tue, 22 Sep 2026 23:07:02 +0500 Subject: [PATCH 19/27] Fix. WooCommerce. Prevent a fatal error in replaceOrdersListRenderer() when a Closure is hooked to the orders page. isset($callback['function'][0]) throws 'Cannot use object of type Closure as array' when another plugin/theme hooks a Closure to the same orders page hook, since Closure does not implement ArrayAccess. An is_array() guard is added before the offset access. Also adds unit tests for the admin/list-table hooks of the WooCommerce integration: addOrdersListStatusViews, addOrdersListStatusLinks, getOrdersListViews, keepOrdersListWhenSpamOrdersExist, replaceOrdersListRenderer, addOrdersSpamStatus*, addSpamActionToBulk* and renderSpamOrdersPage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IntegrationsByClass/Woocommerce.php | 2 +- .../TestWoocommerceAdminListTableHooks.php | 395 ++++++++++++++++++ 2 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 tests/Antispam/IntegrationsByClass/TestWoocommerceAdminListTableHooks.php diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index 4b2783fae..9abaa2dda 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -943,7 +943,7 @@ private function replaceOrdersListRenderer($page_hook) foreach ( $wp_filter[$page_hook]->callbacks as $priority => $callbacks ) { foreach ( $callbacks as $callback ) { - if ( ! isset($callback['function'][0]) || ! is_object($callback['function'][0]) ) { + if ( ! is_array($callback['function']) || ! isset($callback['function'][0]) || ! is_object($callback['function'][0]) ) { continue; } diff --git a/tests/Antispam/IntegrationsByClass/TestWoocommerceAdminListTableHooks.php b/tests/Antispam/IntegrationsByClass/TestWoocommerceAdminListTableHooks.php new file mode 100644 index 000000000..145e7eb2f --- /dev/null +++ b/tests/Antispam/IntegrationsByClass/TestWoocommerceAdminListTableHooks.php @@ -0,0 +1,395 @@ +id = $id; + } + + public function update_status($new_status) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + self::$update_status_calls[] = array($this->id, $new_status); + + return true; + } + } + } +} + +namespace Antispam\IntegrationsByClass { + + use Cleantalk\Antispam\IntegrationsByClass\Woocommerce; + use Cleantalk\ApbctWP\State; + use Cleantalk\ApbctWP\Variables\Get; + use Cleantalk\ApbctWP\Variables\Post; + use PHPUnit\Framework\TestCase; + + /** + * Unit tests for the admin/list-table hooks of the WooCommerce integration: + * the HPOS 'Spam' view wiring, the legacy status filters and the bulk actions. + */ + class TestWoocommerceAdminListTableHooks extends TestCase + { + /** + * @var Woocommerce + */ + private $integration; + + /** + * @var mixed + */ + private $apbct_backup; + + public function setUp(): void + { + global $apbct, $wpdb; + parent::setUp(); + + $this->apbct_backup = $apbct; + $apbct = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); + + $wpdb->query('TRUNCATE TABLE ' . APBCT_TBL_WC_SPAM_ORDERS); + + Get::getInstance()->variables = array(); + Post::getInstance()->variables = array(); + \WC_Order::$update_status_calls = array(); + + $this->integration = new Woocommerce(); + } + + public function tearDown(): void + { + global $apbct, $wpdb; + + $apbct = $this->apbct_backup; + $wpdb->query('TRUNCATE TABLE ' . APBCT_TBL_WC_SPAM_ORDERS); + + Get::getInstance()->variables = array(); + Post::getInstance()->variables = array(); + unset($GLOBALS['post_status'], $_GET['status']); + + parent::tearDown(); + } + + /** + * @return \ReflectionMethod + */ + private function getMethod($name) + { + $method = new \ReflectionMethod(Woocommerce::class, $name); + $method->setAccessible(true); + + return $method; + } + + // ------------------------------------------------------------------- + // addOrdersListStatusViews() + // ------------------------------------------------------------------- + + public function testAddOrdersListStatusViewsReturnsEarlyWithoutAScreenId() + { + $this->assertNull( + $this->integration->addOrdersListStatusViews(new \stdClass()) + ); + } + + public function testAddOrdersListStatusViewsReturnsEarlyWithoutHposFunction() + { + $this->assertFalse(function_exists('wc_get_page_screen_id')); + + $screen = new \stdClass(); + $screen->id = 'edit-shop_order'; + + $this->assertNull($this->integration->addOrdersListStatusViews($screen)); + } + + // ------------------------------------------------------------------- + // addOrdersListStatusLinks() + // ------------------------------------------------------------------- + + public function testAddOrdersListStatusLinksReturnsNonArrayInputUnchanged() + { + $this->assertSame('not-an-array', $this->integration->addOrdersListStatusLinks('not-an-array')); + $this->assertNull($this->integration->addOrdersListStatusLinks(null)); + } + + public function testAddOrdersListStatusLinksLeavesArrayUnchangedWithoutWooCommerce() + { + $this->assertFalse(function_exists('wc_get_order_statuses')); + + $views = array('all' => 'All'); + + $this->assertSame($views, $this->integration->addOrdersListStatusLinks($views)); + } + + // ------------------------------------------------------------------- + // getOrdersListViews() (private) + // ------------------------------------------------------------------- + + public function testGetOrdersListViewsReturnsEmptyArrayWithoutWooCommerce() + { + $this->assertFalse(function_exists('wc_get_order_statuses')); + + $result = $this->getMethod('getOrdersListViews')->invoke($this->integration); + + $this->assertSame(array(), $result); + } + + // ------------------------------------------------------------------- + // keepOrdersListWhenSpamOrdersExist() + // ------------------------------------------------------------------- + + public function testKeepOrdersListWhenSpamOrdersExistPassesThroughWithNoSpamOrders() + { + $this->assertNull($this->integration->keepOrdersListWhenSpamOrdersExist(null)); + $this->assertTrue($this->integration->keepOrdersListWhenSpamOrdersExist(true)); + } + + public function testKeepOrdersListWhenSpamOrdersExistForcesFalseWithStoredSpamOrders() + { + global $wpdb; + + $wpdb->insert( + APBCT_TBL_WC_SPAM_ORDERS, + array( + 'order_details' => '[]', + 'customer_details' => '[]', + 'order_date' => time(), + ) + ); + + $this->assertFalse($this->integration->keepOrdersListWhenSpamOrdersExist(true)); + $this->assertFalse($this->integration->keepOrdersListWhenSpamOrdersExist(null)); + } + + // ------------------------------------------------------------------- + // replaceOrdersListRenderer() (private) + // ------------------------------------------------------------------- + + public function testReplaceOrdersListRendererReturnsEarlyWhenPageHookIsUnknown() + { + $this->getMethod('replaceOrdersListRenderer')->invoke($this->integration, 'no_such_page_hook'); + + $this->assertFalse(has_action('no_such_page_hook', array($this->integration, 'renderSpamOrdersPage'))); + } + + public function testReplaceOrdersListRendererReplacesThePageController() + { + $page_hook = 'apbct_test_page_hook'; + $controller = new WcOrdersPageControllerStub(); + + add_action($page_hook, array($controller, 'output')); + + $this->getMethod('replaceOrdersListRenderer')->invoke($this->integration, $page_hook); + + $this->assertFalse( + has_action($page_hook, array($controller, 'output')), + 'The original WooCommerce page controller must be removed.' + ); + $this->assertNotFalse( + has_action($page_hook, array($this->integration, 'renderSpamOrdersPage')), + 'The spam orders renderer must take over the page hook.' + ); + + remove_action($page_hook, array($this->integration, 'renderSpamOrdersPage')); + } + + public function testReplaceOrdersListRendererLeavesUnrelatedCallbacksAlone() + { + $page_hook = 'apbct_test_page_hook_unrelated'; + $unrelated = new \stdClass(); + $unrelated_callback = function () { + }; + + add_action($page_hook, $unrelated_callback); + + $this->getMethod('replaceOrdersListRenderer')->invoke($this->integration, $page_hook); + + $this->assertNotFalse(has_action($page_hook, $unrelated_callback)); + $this->assertFalse(has_action($page_hook, array($this->integration, 'renderSpamOrdersPage'))); + + remove_action($page_hook, $unrelated_callback); + } + + // ------------------------------------------------------------------- + // addOrdersSpamStatus() / addOrdersSpamStatusSelect() / addOrdersSpamStatusHideFromList() + // ------------------------------------------------------------------- + + public function testAddOrdersSpamStatusRegistersTheSpamStatus() + { + $result = $this->integration->addOrdersSpamStatus(array('wc-processing' => 'Processing')); + + $this->assertArrayHasKey('wc-spamorder', $result); + $this->assertSame('Spam', $result['wc-spamorder']['label']); + $this->assertTrue($result['wc-spamorder']['show_in_admin_all_list']); + } + + public function testAddOrdersSpamStatusSelectRegistersTheSpamOption() + { + $result = $this->integration->addOrdersSpamStatusSelect(array('wc-processing' => 'Processing')); + + $this->assertSame('Spam', $result['wc-spamorder']); + } + + public function testAddOrdersSpamStatusHideFromListRemovesTheSpamStatusOnTheOrdersScreen() + { + global $pagenow; + + $pagenow_backup = $pagenow; + $pagenow = 'edit.php'; + + $query = new \stdClass(); + $query->query_vars = array( + 'post_type' => 'shop_order', + 'post_status' => array('wc-processing', 'wc-spamorder'), + ); + + $this->integration->addOrdersSpamStatusHideFromList($query); + + $this->assertSame(array('wc-processing'), array_values($query->query_vars['post_status'])); + + $pagenow = $pagenow_backup; + } + + public function testAddOrdersSpamStatusHideFromListLeavesOtherScreensAlone() + { + global $pagenow; + + $pagenow_backup = $pagenow; + $pagenow = 'index.php'; + + $query = new \stdClass(); + $query->query_vars = array( + 'post_type' => 'shop_order', + 'post_status' => array('wc-processing', 'wc-spamorder'), + ); + + $this->integration->addOrdersSpamStatusHideFromList($query); + + $this->assertContains('wc-spamorder', $query->query_vars['post_status']); + + $pagenow = $pagenow_backup; + } + + // ------------------------------------------------------------------- + // addSpamActionToBulk() + // ------------------------------------------------------------------- + + public function testAddSpamActionToBulkOffersMarkAsSpamByDefault() + { + $result = $this->integration->addSpamActionToBulk(array()); + + $this->assertArrayHasKey('spamorder', $result); + $this->assertArrayNotHasKey('unspamorder', $result); + } + + public function testAddSpamActionToBulkOffersUnmarkOnTheSpamScreen() + { + set_query_var('post_status', 'wc-spamorder'); + + $result = $this->integration->addSpamActionToBulk(array()); + + $this->assertArrayHasKey('unspamorder', $result); + $this->assertArrayNotHasKey('spamorder', $result); + + set_query_var('post_status', null); + } + + // ------------------------------------------------------------------- + // addSpamActionToBulkHandle() + // ------------------------------------------------------------------- + + public function testAddSpamActionToBulkHandleIgnoresUnrelatedActions() + { + $redirect = 'https://example.test/wp-admin/edit.php'; + + $result = $this->integration->addSpamActionToBulkHandle($redirect, 'trash', array(1, 2)); + + $this->assertSame($redirect, $result); + $this->assertSame(array(), \WC_Order::$update_status_calls); + } + + public function testAddSpamActionToBulkHandleMarksOrdersAsSpam() + { + $redirect = 'https://example.test/wp-admin/edit.php'; + + $result = $this->integration->addSpamActionToBulkHandle($redirect, 'spamorder', array(11, 12)); + + $this->assertSame( + array(array(11, 'wc-spamorder'), array(12, 'wc-spamorder')), + \WC_Order::$update_status_calls + ); + $this->assertStringContainsString('bulk_action=marked_spamorder', $result); + $this->assertStringContainsString('changed=2', $result); + } + + public function testAddSpamActionToBulkHandleUnmarksOrders() + { + $redirect = 'https://example.test/wp-admin/edit.php'; + + $result = $this->integration->addSpamActionToBulkHandle($redirect, 'unspamorder', array(21)); + + $this->assertSame(array(array(21, 'wc-on-hold')), \WC_Order::$update_status_calls); + $this->assertStringContainsString('bulk_action=marked_unspamorder', $result); + $this->assertStringContainsString('changed=1', $result); + } + + // ------------------------------------------------------------------- + // renderSpamOrdersPage() + // ------------------------------------------------------------------- + + public function testRenderSpamOrdersPagePrintsTheWrapperMarkup() + { + ob_start(); + $this->integration->renderSpamOrdersPage(); + $output = ob_get_clean(); + + $this->assertStringContainsString('class="wrap"', $output); + $this->assertStringContainsString('Spam orders', $output); + $this->assertStringContainsString(' Date: Tue, 22 Sep 2026 23:23:48 +0500 Subject: [PATCH 20/27] Code. Fixed test falling. --- .../TestWoocommerceAdminListTableHooks.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/Antispam/IntegrationsByClass/TestWoocommerceAdminListTableHooks.php b/tests/Antispam/IntegrationsByClass/TestWoocommerceAdminListTableHooks.php index 145e7eb2f..48f375812 100644 --- a/tests/Antispam/IntegrationsByClass/TestWoocommerceAdminListTableHooks.php +++ b/tests/Antispam/IntegrationsByClass/TestWoocommerceAdminListTableHooks.php @@ -41,6 +41,7 @@ public function update_status($new_status) // phpcs:ignore PSR1.Methods.CamelCap use Cleantalk\Antispam\IntegrationsByClass\Woocommerce; use Cleantalk\ApbctWP\State; + use Cleantalk\ApbctWP\UpdatePlugin\DbAnalyzer; use Cleantalk\ApbctWP\Variables\Get; use Cleantalk\ApbctWP\Variables\Post; use PHPUnit\Framework\TestCase; @@ -69,8 +70,6 @@ public function setUp(): void $this->apbct_backup = $apbct; $apbct = new State('cleantalk', array('settings', 'data', 'errors', 'remote_calls', 'stats', 'fw_stats')); - $wpdb->query('TRUNCATE TABLE ' . APBCT_TBL_WC_SPAM_ORDERS); - Get::getInstance()->variables = array(); Post::getInstance()->variables = array(); \WC_Order::$update_status_calls = array(); @@ -78,6 +77,15 @@ public function setUp(): void $this->integration = new Woocommerce(); } + public static function setUpBeforeClass(): void + { + global $wpdb; + + $creator = new \Cleantalk\ApbctWP\UpdatePlugin\DbTablesCreator(); + $creator->createAllTables(); + $wpdb->query('TRUNCATE TABLE ' . APBCT_TBL_WC_SPAM_ORDERS); + } + public function tearDown(): void { global $apbct, $wpdb; From d406afd29aeb4e6c32f669610b71402c9592d88a Mon Sep 17 00:00:00 2001 From: alexandergull Date: Tue, 22 Sep 2026 23:38:44 +0500 Subject: [PATCH 21/27] Fix. WooCommerce. Build the Store API checkout response before deleting the blocked order. getStoreApiPassedResponse($order) was called after $order->delete(true), which clears the in-memory order ID and data. The Store API client could then receive order_id 0 and empty fields instead of a valid response. The response is now captured right after handleBlockedOrder() and before any cleanup, then emitted afterwards. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Antispam/IntegrationsByClass/Woocommerce.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index 9abaa2dda..7b10e10aa 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -346,6 +346,10 @@ public function checkoutCheckFromRest($order) // The details must be stored before the response carrying their key is built $this->handleBlockedOrder($order); + // The response must be captured before the order gets deleted below - deletion + // clears the in-memory order ID and data, which would break the imitated response. + $store_api_response = $this->getStoreApiPassedResponse($order); + if ( $order->get_status() === 'pending' || $order->get_status() === 'checkout-draft' ) { if ( function_exists('wc_release_stock_for_order') ) { wc_release_stock_for_order($order); @@ -374,12 +378,10 @@ public function checkoutCheckFromRest($order) die(json_encode($response)); } - $response = $this->getStoreApiPassedResponse($order); - if ( ! headers_sent() ) { header('Content-Type: application/json; charset=utf-8'); } - die(json_encode($response)); + die(json_encode($store_api_response)); } } } From 94cb1b559fb006a4f36e38e99be0c8914d1b2f5b Mon Sep 17 00:00:00 2001 From: alexandergull Date: Tue, 22 Sep 2026 23:51:55 +0500 Subject: [PATCH 22/27] Fix. WooCommerce. Point the settings 'spam orders' link to the correct admin page on legacy installs. The link always targeted the HPOS 'wc-orders' screen. On legacy (non-HPOS) installations addLegacySpamOrdersMenuPage() registers 'apbct_wc_spam_orders' instead, so the link led to an unregistered screen and left stored spam orders unreachable from Settings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- inc/cleantalk-settings.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/inc/cleantalk-settings.php b/inc/cleantalk-settings.php index 474d499d3..b15c77e55 100644 --- a/inc/cleantalk-settings.php +++ b/inc/cleantalk-settings.php @@ -2196,8 +2196,14 @@ function apbct_settings__field__action_buttons() if ( apbct_is_plugin_active('woocommerce/woocommerce.php') ) { add_filter('apbct_settings_action_buttons', function ($buttons_array) { + // HPOS installations render the spam orders view inline on the wc-orders screen; + // legacy (posts table) installations get a separate fallback admin page instead. + $spam_orders_page = function_exists('wc_get_page_screen_id') && wc_get_page_screen_id('shop_order') !== 'shop_order' + ? 'wc-orders&status=wc-spamorder' + : 'apbct_wc_spam_orders'; + $buttons_array[] = - '' + '' . __('WooCommerce spam orders', 'cleantalk-spam-protect') . ''; return $buttons_array; From c38aae0459269085b63ab36cf261dc1b68f67adb Mon Sep 17 00:00:00 2001 From: alexandergull Date: Tue, 22 Sep 2026 23:53:02 +0500 Subject: [PATCH 23/27] Fix. WooCommerce. Align spam orders capability checks on manage_options. addOrdersListStatusViews() registered the 'Spam' view/status links for any user who could open the orders screen, without a capability check. The row action buttons and the restore/details AJAX handlers already required manage_options, so a Shop Manager (without manage_options) could see the view but got a 403 trying to use it. Now the view registration, the legacy admin menu page and the row action handler all consistently require manage_options. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Antispam/IntegrationsByClass/Woocommerce.php | 7 +++++-- lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php | 5 ++++- .../TestWoocommerceLegacySpamOrdersMenuPage.php | 8 ++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index 7b10e10aa..a02357694 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -147,7 +147,7 @@ public function addLegacySpamOrdersMenuPage() 'woocommerce', __('WooCommerce spam orders', 'cleantalk-spam-protect'), __('WooCommerce spam orders', 'cleantalk-spam-protect'), - 'activate_plugins', + 'manage_options', 'apbct_wc_spam_orders', [$this, 'renderSpamOrdersPage'] ); @@ -875,7 +875,10 @@ public function addOrdersSpamStatusHideFromList($query) */ public function addOrdersListStatusViews($current_screen) { - if ( ! isset($current_screen->id) || ! function_exists('wc_get_page_screen_id') ) { + // Keep in sync with the capability required by the row actions and AJAX handlers + // (WcSpamOrdersListTable::row_actions_handler(), AJAXService::checkNonceRestrictingNonAdmins()), + // otherwise a user could see the 'Spam' view but get a 403 trying to use it. + if ( ! current_user_can('manage_options') || ! isset($current_screen->id) || ! function_exists('wc_get_page_screen_id') ) { return; } diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index 9d1ebfa03..c0aabc9e3 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -284,7 +284,10 @@ public function row_actions_handler() // phpcs:ignore PSR1.Methods.CamelCapsMeth wp_die(esc_html__('Security check failed. Please try again.', 'cleantalk-spam-protect'), 403); } - if ( ! current_user_can('activate_plugins') ) { + // Kept in sync with the capability required by the restore/details AJAX handlers + // (AJAXService::checkNonceRestrictingNonAdmins()) and the view registration + // (Woocommerce::addOrdersListStatusViews()). + if ( ! current_user_can('manage_options') ) { wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'cleantalk-spam-protect'), 403); } diff --git a/tests/Antispam/IntegrationsByClass/TestWoocommerceLegacySpamOrdersMenuPage.php b/tests/Antispam/IntegrationsByClass/TestWoocommerceLegacySpamOrdersMenuPage.php index e5fd46c54..0f112a706 100644 --- a/tests/Antispam/IntegrationsByClass/TestWoocommerceLegacySpamOrdersMenuPage.php +++ b/tests/Antispam/IntegrationsByClass/TestWoocommerceLegacySpamOrdersMenuPage.php @@ -35,7 +35,7 @@ public function setUp(): void $submenu = array(); // Grant the capability required by add_submenu_page() regardless of the current user. - add_filter('user_has_cap', array($this, 'grantActivatePluginsCap')); + add_filter('user_has_cap', array($this, 'grantManageOptionsCap')); $this->integration = new Woocommerce(); } @@ -45,7 +45,7 @@ public function tearDown(): void global $submenu; $submenu = $this->submenu_backup; - remove_filter('user_has_cap', array($this, 'grantActivatePluginsCap')); + remove_filter('user_has_cap', array($this, 'grantManageOptionsCap')); parent::tearDown(); } @@ -55,9 +55,9 @@ public function tearDown(): void * * @return array */ - public function grantActivatePluginsCap($allcaps) + public function grantManageOptionsCap($allcaps) { - $allcaps['activate_plugins'] = true; + $allcaps['manage_options'] = true; return $allcaps; } From 053d35499e45773584ffde4e1be04003438f3135 Mon Sep 17 00:00:00 2001 From: alexandergull Date: Tue, 22 Sep 2026 23:54:11 +0500 Subject: [PATCH 24/27] Fix. WooCommerce. Don't pass HPOS view links into the legacy spam orders page. renderSpamOrdersPage() always built getOrdersListViews(), which links to 'page=wc-orders' (HPOS only). It's also the callback for the legacy 'apbct_wc_spam_orders' fallback page (addLegacySpamOrdersMenuPage()), so on non-HPOS installations the embedded views pointed at the wrong screen. Now null is passed on legacy installs, so the list table builds its own standalone (legacy-correct) views instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Antispam/IntegrationsByClass/Woocommerce.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index a02357694..967d51ee4 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -974,7 +974,14 @@ private function replaceOrdersListRenderer($page_hook) */ public function renderSpamOrdersPage() { - $list_table = new \Cleantalk\ApbctWP\WcSpamOrdersListTable($this->getOrdersListViews()); + // This is also the callback for the legacy 'apbct_wc_spam_orders' fallback page (addLegacySpamOrdersMenuPage()). + // getOrdersListViews() builds HPOS-style links (page=wc-orders), so it's only valid to embed on the HPOS screen - + // on legacy installations pass null so the list table builds its own standalone views. + $embedded_views = function_exists('wc_get_page_screen_id') && wc_get_page_screen_id('shop_order') !== 'shop_order' + ? $this->getOrdersListViews() + : null; + + $list_table = new \Cleantalk\ApbctWP\WcSpamOrdersListTable($embedded_views); ?>

From 89a22cc438e406f121f11e6beb44458522f51473 Mon Sep 17 00:00:00 2001 From: alexandergull Date: Wed, 23 Sep 2026 11:01:54 +0500 Subject: [PATCH 25/27] Fix. WooCommerce. Route the admin bar 'spam orders' link by HPOS/legacy mode. The admin bar node always targeted the HPOS 'wc-orders' screen. On legacy (non-HPOS) installations addLegacySpamOrdersMenuPage() registers 'apbct_wc_spam_orders' instead, so the link led to an unregistered screen. Uses the same predicate as the settings action button. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- inc/cleantalk-admin.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/inc/cleantalk-admin.php b/inc/cleantalk-admin.php index fe2204ec9..ea0f05b6b 100644 --- a/inc/cleantalk-admin.php +++ b/inc/cleantalk-admin.php @@ -1194,11 +1194,17 @@ function apbct_admin__admin_bar__add_child_nodes($wp_admin_bar) // Add a child item to our parent item. Bulk checks. if ( ! is_network_admin() && apbct_is_plugin_active('woocommerce/woocommerce.php') ) { + // HPOS installations render the spam orders view inline on the wc-orders screen; + // legacy (posts table) installations get a separate fallback admin page instead. + $spam_orders_page = function_exists('wc_get_page_screen_id') && wc_get_page_screen_id('shop_order') !== 'shop_order' + ? 'wc-orders&status=wc-spamorder' + : 'apbct_wc_spam_orders'; + $wp_admin_bar->add_node( array( 'parent' => 'apbct__parent_node', 'id' => 'ct_settings_bulk_orders', - 'title' => '' + 'title' => '' . __('WooCommerce spam orders', 'cleantalk-spam-protect') . '', ) ); From 37c3240c5082b005c64db4253598a782a548917e Mon Sep 17 00:00:00 2001 From: alexandergull Date: Wed, 23 Sep 2026 14:27:00 +0500 Subject: [PATCH 26/27] Fix. WooCommerce. Render the blocked order overview on the block based confirmation page. Block themes route the 'order-received' endpoint to the 'order-confirmation' template built of the woocommerce/order-confirmation-* blocks. In that case checkout/thankyou.php is never loaded, so woocommerce_after_template_part never fired and the visitor saw a bare confirmation page with no details. The markup building is extracted into getBlockedOrderOverviewHtml() and is now reused by a render_block filter appending it to the status block, which WooCommerce keeps rendering when no order stands behind the page. The classic template path is left as it was. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../IntegrationsByClass/Woocommerce.php | 46 +++- .../TestWoocommerceBlockedOrderOverview.php | 237 ++++++++++++++++++ 2 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 tests/Antispam/IntegrationsByClass/TestWoocommerceBlockedOrderOverview.php diff --git a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php index 967d51ee4..9e9240c5d 100644 --- a/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php +++ b/lib/Cleantalk/Antispam/IntegrationsByClass/Woocommerce.php @@ -71,7 +71,10 @@ public function doPublicWork() // The blocked visitor gets a thank you page with no order behind it - fill in the details. // Only the redirect of a blocked order carries the key, so the rest of the pages are left alone. if ( Get::getString('key') !== '' ) { + // Classic themes render the confirmation with the checkout/thankyou.php template add_action('woocommerce_after_template_part', [$this, 'renderBlockedOrderOverview'], 10, 4); + // Block themes render it with the woocommerce/order-confirmation-* blocks instead + add_filter('render_block', [$this, 'appendBlockedOrderOverviewToBlock'], 10, 2); } // add to cart hooks if cart works with non-ajax requests @@ -492,16 +495,49 @@ public function renderBlockedOrderOverview($template_name, $template_path, $loca return; } + echo $this->getBlockedOrderOverviewHtml(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + /** + * Print the order details on the block based order confirmation page. + * + * Block themes route the 'order-received' endpoint to the 'order-confirmation' template + * built of the woocommerce/order-confirmation-* blocks, so checkout/thankyou.php is never + * loaded and renderBlockedOrderOverview() never fires. The details are appended to the + * status block instead, which is the one WooCommerce keeps rendering without an order. + * + * @param string $block_content + * @param array $block + * + * @return string + * @psalm-suppress PossiblyUnusedMethod, PossiblyUnusedReturnValue + */ + public function appendBlockedOrderOverviewToBlock($block_content, $block) + { + if ( ! isset($block['blockName']) || $block['blockName'] !== 'woocommerce/order-confirmation-status' ) { + return $block_content; + } + + return $block_content . $this->getBlockedOrderOverviewHtml(); + } + + /** + * Markup of the stored order details, empty when there is nothing to show. + * + * @return string + */ + private function getBlockedOrderOverviewHtml() + { $blocked_order_key = Get::getString('key'); if ( $blocked_order_key === '' ) { - return; + return ''; } $overview = get_transient(self::BLOCKED_ORDER_TRANSIENT . $blocked_order_key); if ( ! is_array($overview) || empty($overview['date']) ) { - return; + return ''; } $rows = array( @@ -516,10 +552,10 @@ public function renderBlockedOrderOverview($template_name, $template_path, $loca ); } - echo '
    '; + $html = '
      '; foreach ( $rows as $key => $row ) { - printf( + $html .= sprintf( '
    • %2$s %3$s
    • ', esc_attr($key), esc_html($row[0]), @@ -527,7 +563,7 @@ public function renderBlockedOrderOverview($template_name, $template_path, $loca ); } - echo '
    '; + return $html . '
'; } /** diff --git a/tests/Antispam/IntegrationsByClass/TestWoocommerceBlockedOrderOverview.php b/tests/Antispam/IntegrationsByClass/TestWoocommerceBlockedOrderOverview.php new file mode 100644 index 000000000..f2d64d9fa --- /dev/null +++ b/tests/Antispam/IntegrationsByClass/TestWoocommerceBlockedOrderOverview.php @@ -0,0 +1,237 @@ +integration = new Woocommerce(); + + $this->forgetKey(); + } + + public function tearDown(): void + { + delete_transient(self::TRANSIENT_PREFIX . self::KEY); + + $this->forgetKey(); + + parent::tearDown(); + } + + /** + * The key is read through the cached variables storage, so both have to be set. + * + * @param string $key + * + * @return void + */ + private function setKey($key) + { + $_GET['key'] = $key; + + Get::getInstance()->variables = array(); + } + + /** + * @return void + */ + private function forgetKey() + { + unset($_GET['key']); + + Get::getInstance()->variables = array(); + } + + /** + * @return void + */ + private function storeOverview() + { + set_transient( + self::TRANSIENT_PREFIX . self::KEY, + array( + 'date' => 'September 23, 2026', + 'total' => '99.00', + 'payment_method' => 'Cash on delivery', + ), + HOUR_IN_SECONDS + ); + } + + // ------------------------------------------------------------------- + // appendBlockedOrderOverviewToBlock() - the block theme entry point + // ------------------------------------------------------------------- + + public function testOverviewIsAppendedToTheOrderConfirmationStatusBlock() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + $result = $this->integration->appendBlockedOrderOverviewToBlock( + 'STATUS', + array('blockName' => self::STATUS_BLOCK) + ); + + $this->assertStringStartsWith('STATUS', $result, 'The original block content must be kept.'); + $this->assertStringContainsString('woocommerce-order-overview', $result); + $this->assertStringContainsString('September 23, 2026', $result); + $this->assertStringContainsString('99.00', $result); + $this->assertStringContainsString('Cash on delivery', $result); + } + + public function testUnrelatedBlocksAreLeftUntouched() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + $result = $this->integration->appendBlockedOrderOverviewToBlock( + 'PARAGRAPH', + array('blockName' => 'core/paragraph') + ); + + $this->assertSame('PARAGRAPH', $result); + } + + public function testBlockWithoutNameIsLeftUntouched() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + $result = $this->integration->appendBlockedOrderOverviewToBlock('RAW', array()); + + $this->assertSame('RAW', $result); + } + + public function testNothingIsAppendedWithoutTheKey() + { + $this->storeOverview(); + $this->forgetKey(); + + $result = $this->integration->appendBlockedOrderOverviewToBlock( + 'STATUS', + array('blockName' => self::STATUS_BLOCK) + ); + + $this->assertSame('STATUS', $result); + } + + public function testNothingIsAppendedWhenTheDetailsAreGone() + { + // The transient is deliberately not stored - it expires an hour after the block + $this->setKey(self::KEY); + + $result = $this->integration->appendBlockedOrderOverviewToBlock( + 'STATUS', + array('blockName' => self::STATUS_BLOCK) + ); + + $this->assertSame('STATUS', $result); + } + + public function testPaymentMethodRowIsOmittedWhenUnknown() + { + set_transient( + self::TRANSIENT_PREFIX . self::KEY, + array('date' => 'September 23, 2026', 'total' => '10.00'), + HOUR_IN_SECONDS + ); + $this->setKey(self::KEY); + + $result = $this->integration->appendBlockedOrderOverviewToBlock( + 'STATUS', + array('blockName' => self::STATUS_BLOCK) + ); + + $this->assertStringContainsString('woocommerce-order-overview__date', $result); + $this->assertStringContainsString('woocommerce-order-overview__total', $result); + $this->assertStringNotContainsString('woocommerce-order-overview__method', $result); + } + + // ------------------------------------------------------------------- + // renderBlockedOrderOverview() - the classic theme entry point + // ------------------------------------------------------------------- + + public function testOverviewIsPrintedAfterTheThankYouTemplate() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + ob_start(); + $this->integration->renderBlockedOrderOverview('checkout/thankyou.php', '', '', array('order' => false)); + $output = ob_get_clean(); + + $this->assertStringContainsString('woocommerce-order-overview', $output); + $this->assertStringContainsString('September 23, 2026', $output); + } + + public function testNothingIsPrintedForAnotherTemplate() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + ob_start(); + $this->integration->renderBlockedOrderOverview('checkout/form-checkout.php', '', '', array()); + $output = ob_get_clean(); + + $this->assertSame('', $output); + } + + public function testNothingIsPrintedWhenARealOrderStandsBehindThePage() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + ob_start(); + $this->integration->renderBlockedOrderOverview( + 'checkout/thankyou.php', + '', + '', + array('order' => new \stdClass()) + ); + $output = ob_get_clean(); + + $this->assertSame('', $output); + } + + /** + * Both entry points have to show the very same details. + */ + public function testBothEntryPointsProduceTheSameMarkup() + { + $this->storeOverview(); + $this->setKey(self::KEY); + + ob_start(); + $this->integration->renderBlockedOrderOverview('checkout/thankyou.php', '', '', array('order' => false)); + $printed = ob_get_clean(); + + $appended = $this->integration->appendBlockedOrderOverviewToBlock( + '', + array('blockName' => self::STATUS_BLOCK) + ); + + $this->assertSame($printed, $appended); + } +} From 735d3a08c9b3e19770ca6cb5c83bd3bc586d3655 Mon Sep 17 00:00:00 2001 From: alexandergull Date: Fri, 25 Sep 2026 14:31:33 +0500 Subject: [PATCH 27/27] Fix. WooCommerce. Use dedicated request parameters for the spam orders actions. On HPOS installations the spam orders table is embedded into the WooCommerce orders screen, where the HPOS page controller runs on the 'load-{page}' hook, before the table is built. It inspects $_REQUEST['action'] and verifies it against its own 'bulk-orders' nonce, so both the row delete link and the bulk delete form were aborted with "The link you followed has expired". The same controller also redirects to an URL stripped of '_wpnonce', dropping the nonce of the row delete link. Renaming the action and nonce parameters keeps our actions invisible to the HPOS controller while leaving the legacy page untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ApbctWP/WcSpamOrdersListTable.php | 86 ++++++++++++++++-- tests/ApbctWP/TestWcSpamOrdersListTable.php | 87 +++++++++++++++++++ 2 files changed, 166 insertions(+), 7 deletions(-) diff --git a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php index c0aabc9e3..25ace553a 100644 --- a/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php +++ b/lib/Cleantalk/ApbctWP/WcSpamOrdersListTable.php @@ -8,6 +8,26 @@ class WcSpamOrdersListTable extends CleantalkListTable { + /** + * Names of the request parameters carrying the actions of the table. + * + * The table is embedded into the WooCommerce orders screen on HPOS installations, where the + * HPOS page controller inspects $_REQUEST['action'] on the 'load-{page}' hook and runs its own + * 'bulk-orders' nonce check, aborting the whole request with "The link you followed has expired" + * long before the table is built. Dedicated parameter names keep our actions invisible to it. + */ + const BULK_ACTION_PARAM = 'apbct_bulk_action'; + const ROW_ACTION_PARAM = 'apbct_row_action'; + + /** + * Name of the nonce of the row actions. + * + * The HPOS page controller redirects to an URL stripped of '_wpnonce' and '_wp_http_referer' + * (see PageController::strip_http_referer()), which would drop the nonce of our delete link + * before it is ever verified. A name of our own survives that redirect. + */ + const ROW_NONCE_PARAM = 'apbct_row_nonce'; + protected $apbct; protected $wc_active = false; @@ -102,13 +122,13 @@ public function prepare_items() // phpcs:ignore PSR1.Methods.CamelCapsMethodNam $delete_url = admin_url('admin.php?page=' . Get::getString('page')); $delete_url = add_query_arg( array_filter(array( - 'status' => $current_status, - 'action' => 'delete', - 'spam' => $wc_spam_order->id, + 'status' => $current_status, + self::ROW_ACTION_PARAM => 'delete', + 'spam' => $wc_spam_order->id, )), $delete_url ); - $delete_url = wp_nonce_url($delete_url, 'apbct_wc_spam_orders_row', '_wpnonce'); + $delete_url = wp_nonce_url($delete_url, 'apbct_wc_spam_orders_row', self::ROW_NONCE_PARAM); $actions = array( 'restore' => '' . esc_html__('Restore', 'cleantalk-spam-protect') . '', 'delete' => 'Delete', @@ -235,6 +255,58 @@ public function get_bulk_actions() // phpcs:ignore PSR1.Methods.CamelCapsMethodN ); } + /** + * The bulk actions select rendered under a name of our own, see BULK_ACTION_PARAM. + * + * Mirrors the markup of the parent, the flat list of actions of this table needs no optgroups. + * + * @param string $which 'top' or 'bottom' + * + * @return void + */ + protected function bulk_actions($which = '') // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + $actions = $this->get_bulk_actions(); + + if ( empty($actions) ) { + return; + } + + $two = $which === 'bottom' ? '2' : ''; + $name = self::BULK_ACTION_PARAM . $two; + + echo ''; + echo ''; + + submit_button(__('Apply'), 'action', '', false, array('id' => 'doaction' . $two)); + } + + /** + * Action chosen in the bulk actions select, read from our own parameter, see BULK_ACTION_PARAM. + * + * @return string|false + */ + public function current_action() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + foreach ( array(self::BULK_ACTION_PARAM, self::BULK_ACTION_PARAM . '2') as $param ) { + $action = Post::getString($param); + + if ( $action !== '' && $action !== '-1' ) { + return $action; + } + } + + return false; + } + public function bulk_actions_handler() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps { if ( empty(Post::get('spamorderids')) || empty(Post::get('_wpnonce')) ) { @@ -276,11 +348,11 @@ public function column_default($item, $column_name) // phpcs:ignore PSR1.Methods public function row_actions_handler() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps { - if ( empty(Get::get('action')) ) { + if ( empty(Get::get(self::ROW_ACTION_PARAM)) ) { return; } - if ( ! wp_verify_nonce(Get::getString('_wpnonce'), 'apbct_wc_spam_orders_row') ) { + if ( ! wp_verify_nonce(Get::getString(self::ROW_NONCE_PARAM), 'apbct_wc_spam_orders_row') ) { wp_die(esc_html__('Security check failed. Please try again.', 'cleantalk-spam-protect'), 403); } @@ -291,7 +363,7 @@ public function row_actions_handler() // phpcs:ignore PSR1.Methods.CamelCapsMeth wp_die(esc_html__('You do not have sufficient permissions to perform this action.', 'cleantalk-spam-protect'), 403); } - if ( Get::get('action') === 'delete' ) { + if ( Get::get(self::ROW_ACTION_PARAM) === 'delete' ) { $id = filter_input(INPUT_GET, 'spam', FILTER_SANITIZE_ENCODED, FILTER_FLAG_STRIP_HIGH); $this->removeSpam(array($id)); } diff --git a/tests/ApbctWP/TestWcSpamOrdersListTable.php b/tests/ApbctWP/TestWcSpamOrdersListTable.php index efdeadda5..605ac4030 100644 --- a/tests/ApbctWP/TestWcSpamOrdersListTable.php +++ b/tests/ApbctWP/TestWcSpamOrdersListTable.php @@ -182,4 +182,91 @@ public function testRenderCustomerDetailsColumnEmptyValues() $expectedCount = substr_count($result, ''); $this->assertEquals(3, $expectedCount); } + + /** + * Resets the memory of the request variables handler so that $_POST is read again. + * + * @return void + */ + private function resetPostMemory() + { + \Cleantalk\ApbctWP\Variables\Post::getInstance()->variables = array(); + } + + /** + * Test the action parameters are named so that the HPOS page controller ignores them. + */ + public function testActionParamsAreNamespaced() + { + $this->assertEquals('apbct_bulk_action', WcSpamOrdersListTable::BULK_ACTION_PARAM); + $this->assertEquals('apbct_row_action', WcSpamOrdersListTable::ROW_ACTION_PARAM); + $this->assertEquals('apbct_row_nonce', WcSpamOrdersListTable::ROW_NONCE_PARAM); + + foreach ( array('action', '_wpnonce', '_wp_http_referer') as $reserved ) { + $this->assertNotEquals($reserved, WcSpamOrdersListTable::BULK_ACTION_PARAM); + $this->assertNotEquals($reserved, WcSpamOrdersListTable::ROW_ACTION_PARAM); + $this->assertNotEquals($reserved, WcSpamOrdersListTable::ROW_NONCE_PARAM); + } + } + + /** + * Test current_action reads the select of the top tablenav. + */ + public function testCurrentActionReadsTopSelect() + { + $_POST = array(WcSpamOrdersListTable::BULK_ACTION_PARAM => 'delete'); + $this->resetPostMemory(); + + $this->assertEquals('delete', $this->instance->current_action()); + } + + /** + * Test current_action falls back to the select of the bottom tablenav. + */ + public function testCurrentActionReadsBottomSelect() + { + $_POST = array( + WcSpamOrdersListTable::BULK_ACTION_PARAM => '-1', + WcSpamOrdersListTable::BULK_ACTION_PARAM . '2' => 'delete', + ); + $this->resetPostMemory(); + + $this->assertEquals('delete', $this->instance->current_action()); + } + + /** + * Test current_action ignores the placeholder option of the selects. + */ + public function testCurrentActionIgnoresPlaceholder() + { + $_POST = array( + WcSpamOrdersListTable::BULK_ACTION_PARAM => '-1', + WcSpamOrdersListTable::BULK_ACTION_PARAM . '2' => '-1', + ); + $this->resetPostMemory(); + + $this->assertFalse($this->instance->current_action()); + } + + /** + * Test current_action reports no action when the selects were not submitted. + */ + public function testCurrentActionWithoutSubmission() + { + $_POST = array(); + $this->resetPostMemory(); + + $this->assertFalse($this->instance->current_action()); + } + + /** + * Test current_action stays blind to the 'action' parameter of the WooCommerce orders screen. + */ + public function testCurrentActionIgnoresWooCommerceActionParam() + { + $_POST = array('action' => 'delete', 'action2' => 'delete'); + $this->resetPostMemory(); + + $this->assertFalse($this->instance->current_action()); + } }