diff --git a/.gitignore b/.gitignore index c23d97c..dd2612b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ /.claude +/vendor +PROJECT/.DS_Store +composer.lock +PROJECT/1-INBOX/.DS_Store +PROJECT/2-WORKING/.DS_Store +PROJECT/3-DONE/.DS_Store +PROJECT/4-MISC/.DS_Store +.phpunit.result.cache diff --git a/CHANGELOG.md b/CHANGELOG.md index c372f22..2b257bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,25 @@ All notable changes to Hypercart Query Guard are documented here. -The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Every merged build carries a version number. -## [Unreleased] +## [1.1.0] — 2026-05-30 + +### Changed + +- **`admin_ajax` execution-time ceiling tightened from 20 s to 10 s.** `admin-ajax.php` is the primary vector for runaway read queries (WC order-note loading, Facebook background sync, NoFraud). Halving the ceiling halves the per-execution DB exposure when concurrent workers pile up. Legitimate heavyweight reads that need > 10 s should use the REST API (30 s ceiling) or `wp-admin` (45 s ceiling). The `checkout` context (60 s) is unaffected — it is detected before `admin_ajax`. Operators can relax individual endpoints via the `hypercart_query_guard_limit_ms` filter. + +### Added + +- **SQL shape classifier (`classify_sql`).** A new `public static` method inspects a SQL string cheaply (plain string operations + one anchored regex for `IN (`) and returns six classification fields: `table_hint`, `is_comment_query`, `has_large_in_list`, `estimated_in_list_size`, `is_probable_woocommerce`, and `is_probable_order_note_query`. Only `SELECT` statements are classified; writes return all-default values. The classifier's large-IN threshold is 200 items (constant `LARGE_IN_LIST_THRESHOLD`). + +- **`is_admin_ajax_request` helper.** Public static method — takes a URI string and returns `true` when it routes through `admin-ajax.php`. Used for logging; no behaviour change. + +- **Seven new structured fields on `slow_query` and `query_killed` log events.** Both event types now carry `is_admin_ajax`, `table_hint`, `is_comment_query`, `has_large_in_list`, `estimated_in_list_size`, `is_probable_woocommerce`, and `is_probable_order_note_query`. These fields directly address the May 2026 production incident where a WooCommerce order-note query (`wp_comments … comment_ID IN (… 10,639 IDs …)`) running via `admin-ajax.php` consumed a large share of DB time and caused repeated `MySQL server has gone away` errors. + +- **`hypercart_query_guard_log_payload` filter.** Fires inside `log()` before emission. Allows callers to add custom fields, route to additional sinks, or inspect payloads in integration tests. + +- **PHPUnit test suite.** `phpunit.xml`, `tests/bootstrap.php` (WordPress function stubs), `tests/SqlClassifierTest.php` (22 tests covering classifier edge cases), and `tests/PayloadFieldsTest.php` (13 tests verifying both `query_killed` and `slow_query` payload shapes). 35 tests, 117 assertions. ### Fixed @@ -16,6 +32,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and - **Every killed query in a request is now logged, not just the last one.** `detect_and_log_kill()` previously ran only on `shutdown` and read `$wpdb->last_error`, which gets overwritten by each subsequent query. A request that triggered N kills produced exactly one log line — the final one. Capture is now incremental: a `query` filter callback inspects `$wpdb->last_error` before each subsequent `wpdb::query()` clears it via `flush()`, and a high-water mark on `$wpdb->num_queries` makes the capture idempotent so duplicate error strings (the NoFraud thundering-herd pattern) are still counted as distinct kills. Shutdown remains the fallback for the request's final query. -## [1.0.0] +## [1.0.0] — 2026-05-01 Initial release. diff --git a/README.md b/README.md index a3f175f..a9d00e2 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Limits are applied per request context. Override via the `hypercart_query_guard_ | WP-CLI | unlimited | Long-running migrations, imports | | Action Scheduler | unlimited | Background workers, writes anyway | | `wp-cron.php` | 10 seconds | Cron should never be slow; cheap signal | -| `admin-ajax.php` | 20 seconds | Most spike vectors live here (FB sync, NoFraud) | +| `admin-ajax.php` | 10 seconds | Most spike vectors live here (FB sync, WC order-note IN lists, NoFraud). Tightened from 20 s to reduce per-execution DB exposure; heavyweight reads should use REST API or `wp-admin` instead. | | REST API | 30 seconds | Klaviyo polling, WC REST `/orders` | | Frontend (default) | 30 seconds | Product pages, my-account | | `wp-admin` | 45 seconds | Admin search/filter operations tolerated longer | @@ -78,9 +78,43 @@ If `Hypercart_Logger` (from the Hypercart Performance Monitor plugin) is present Two event types: -- **`slow_query`** *(warn)* — query exceeded 5s but completed; sampled in observe mode, always in enforce mode. +- **`slow_query`** *(warn)* — query exceeded 5 s but completed; sampled in observe mode, always in enforce mode. - **`query_killed`** *(error)* — MySQL killed the query for hitting the limit; only emitted in enforce mode. +### SQL classification fields + +Both events carry additional classification fields derived from cheap, safe string inspection of the SQL. These fields are designed for post-incident filtering and alerting. + +| Field | Type | Description | +|---|---|---| +| `is_admin_ajax` | bool | `true` when the request URI routes through `admin-ajax.php` | +| `table_hint` | string | `'wp_comments'` when that table appears in the SQL, else `''` | +| `is_comment_query` | bool | `true` when `table_hint === 'wp_comments'` | +| `has_large_in_list` | bool | `true` when the SQL contains an `IN (…)` list with ≥ 200 items | +| `estimated_in_list_size` | int | Comma count + 1 inside the first `IN (…)` found; `0` if none | +| `is_probable_woocommerce` | bool | `true` when WooCommerce table/keyword hints are present | +| `is_probable_order_note_query` | bool | `true` when the query targets `wp_comments` and mentions `order_note` (WC stores order notes as comment rows) | + +The classifier only runs on `SELECT` statements; writes return all-default values. No SQL mutation occurs. + +### Filtering the log payload + +A `hypercart_query_guard_log_payload` filter fires before each log emission. Use it to add custom fields, route to additional sinks, or tighten alerting: + +```php +add_filter( 'hypercart_query_guard_log_payload', function( $payload, $level ) { + // Page-level alert for killed order-note queries from admin-ajax. + if ( + $level === 'error' && + $payload['is_probable_order_note_query'] && + $payload['is_admin_ajax'] + ) { + my_pagerduty_alert( $payload ); + } + return $payload; +}, 10, 2 ); +``` + ## Limitations and caveats - **`init` priority 1 is not the earliest possible hook — early-boot queries are unprotected.** The `SET SESSION` is applied on `init` priority 1, so anything that queries the database before then runs without the ceiling. In practice the unprotected window contains: @@ -94,10 +128,33 @@ Two event types: - **WP Engine reconnects.** WPE's MySQL proxy occasionally rotates connections mid-request. The static `$last_dbh` identity check detects this and re-applies the limit automatically. - **Older MySQL.** `MAX_EXECUTION_TIME` requires MySQL 5.7.8+ or Percona/MariaDB equivalents. The plugin suppresses errors on the `SET SESSION` itself, so an unsupported server fails open (no protection, no breakage). +## Enforce-mode rollout recommendation + +For stores exposed to high-frequency admin-ajax traffic (WooCommerce order management, background syncs, order-note loading): + +1. **Start in observe mode for at least 48 hours.** Watch for `slow_query` events with `is_admin_ajax: true`, `is_comment_query: true`, or `has_large_in_list: true`. These identify the queries that enforce mode will kill. +2. **Check Action Scheduler detection.** If you see `slow_query` events with `context: admin_ajax` from queries you know are AS workers, verify the AS action names in `detect_context()` match your version. The static memo in `apply_session_timeout()` locks in the tier on first call; a mis-detected AS worker gets a 10 s ceiling for the rest of that request. +3. **Enable enforce mode.** Set `define( 'HYPERCART_QUERY_GUARD_MODE', 'enforce' )` in `wp-config.php`. Monitor `query_killed` events for 24 hours. Any killed query with `event: query_killed`, `is_probable_order_note_query: true`, `has_large_in_list: true`, and `is_admin_ajax: true` is the exact failure mode this plugin was hardened for. +4. **If a legitimate endpoint needs > 10 s,** use the limit filter to relax that specific action — do not raise the global `admin_ajax` ceiling: + +```php +add_filter( 'hypercart_query_guard_limit_ms', function( $ms, $context ) { + if ( $context === 'admin_ajax' && isset( $_REQUEST['action'] ) ) { + // Relax only for the specific WC AJAX action that legitimately needs more time. + if ( $_REQUEST['action'] === 'my_slow_but_necessary_action' ) { + return 30000; + } + } + return $ms; +}, 10, 2 ); +``` + ## Origin Built in response to a CPU-saturation incident on a high-volume WooCommerce store where a single Facebook background sync query (`SELECT … FROM wp_comments WHERE comment_ID IN (… 10,010 items …)`) running concurrently with itself took down the whole pod. The kill switch is the cheapest insurance against that class of failure: a few hours of work, indefinite payoff. +On 2026-05-30 a production store suffered the same shape of incident via WooCommerce order-note loading: `comment_ID IN (… 10,639 IDs …)` fired repeatedly through `admin-ajax.php`, consumed large DB time per execution, and generated repeated `MySQL server has gone away` errors. The SQL classification fields and 10 s `admin_ajax` ceiling were added in response. + ## License This plugin is licensed under the **GNU General Public License v2.0 or later** (GPL-2.0-or-later), the same license as WordPress itself. diff --git a/hypercart-query-guard.php b/hypercart-query-guard.php index 19af369..3f1d53a 100644 --- a/hypercart-query-guard.php +++ b/hypercart-query-guard.php @@ -3,7 +3,7 @@ * Plugin Name: Hypercart Query Guard * Plugin URI: https://hypercart.io * Description: PHP-side circuit breaker that enforces MySQL MAX_EXECUTION_TIME on read queries to prevent runaway SELECTs from saturating the pod. Tiered limits per request context, observe-mode for safe rollout, automatic re-application on connection rotation, and admin-search timeout fallback. - * Version: 1.0.0 + * Version: 1.1.0 * Author: Hypercart / Neochrome * License: GPL-2.0-or-later * License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -63,7 +63,16 @@ final class Hypercart_Query_Guard { 'wp_cli' => 0, 'action_scheduler' => 0, 'wp_cron' => 10000, - 'admin_ajax' => 20000, + // Tightened from 20 s → 10 s (2026-05-30). + // admin-ajax.php is the dominant vector for runaway read queries (Facebook + // background sync, WC order-note loading via oversized IN lists, NoFraud). + // Halving the ceiling halves the per-execution DB exposure when concurrent + // workers pile up. Legitimate heavyweight reads that truly need > 10 s should + // use the REST API (30 s ceiling) or be routed through WP-Admin (45 s ceiling). + // Operators can relax for specific actions via the hypercart_query_guard_limit_ms + // filter. The checkout context (60 s) is unchanged — it is detected before + // admin_ajax and covers both the AJAX and block REST checkout endpoints. + 'admin_ajax' => 10000, 'rest_api' => 30000, 'checkout' => 60000, 'wp_admin' => 45000, @@ -270,6 +279,14 @@ public static function apply_session_timeout() { $last_limit = $limit_ms; } + /** + * Threshold for classifying an IN-list as "large". Queries with an IN + * clause containing this many or more items are flagged in log payloads. + * Typical WordPress IN lists (get_posts with specific IDs, WC item rows) + * stay well under 50; anything above 200 is unusual enough to log. + */ + const LARGE_IN_LIST_THRESHOLD = 200; + /** * High-water mark for the last query number we've already inspected * for a kill. wpdb::$num_queries is monotonic, so comparing against @@ -332,14 +349,24 @@ private static function capture_pending_kill() { return; } + $uri = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : ''; + $classification = self::classify_sql( (string) $wpdb->last_query ); + $payload = array( - 'event' => 'query_killed', - 'context' => self::detect_context(), - 'limit_ms' => self::get_limit_ms(), - 'last_query' => self::truncate( (string) $wpdb->last_query, 500 ), - 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '', - 'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0, - 'time' => time(), + 'event' => 'query_killed', + 'context' => self::detect_context(), + 'limit_ms' => self::get_limit_ms(), + 'last_query' => self::truncate( (string) $wpdb->last_query, 500 ), + 'uri' => $uri, + 'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0, + 'time' => time(), + 'is_admin_ajax' => self::is_admin_ajax_request( $uri ), + 'table_hint' => $classification['table_hint'], + 'is_comment_query' => $classification['is_comment_query'], + 'has_large_in_list' => $classification['has_large_in_list'], + 'estimated_in_list_size' => $classification['estimated_in_list_size'], + 'is_probable_woocommerce' => $classification['is_probable_woocommerce'], + 'is_probable_order_note_query' => $classification['is_probable_order_note_query'], ); self::log( 'error', $payload ); @@ -415,20 +442,129 @@ public static function log_slow_queries() { if ( ! isset( $row[1] ) || $row[1] < $threshold_s ) { continue; } + $query_sql = (string) $row[0]; + $classification = self::classify_sql( $query_sql ); self::log( 'warn', array( - 'event' => 'slow_query', - 'context' => $context, - 'duration_ms' => (int) ( $row[1] * 1000 ), - 'query' => self::truncate( (string) $row[0], 500 ), - 'caller' => isset( $row[2] ) ? self::truncate( (string) $row[2], 500 ) : '', - 'uri' => $uri, + 'event' => 'slow_query', + 'context' => $context, + 'duration_ms' => (int) ( $row[1] * 1000 ), + 'query' => self::truncate( $query_sql, 500 ), + 'caller' => isset( $row[2] ) ? self::truncate( (string) $row[2], 500 ) : '', + 'uri' => $uri, + 'is_admin_ajax' => self::is_admin_ajax_request( $uri ), + 'table_hint' => $classification['table_hint'], + 'is_comment_query' => $classification['is_comment_query'], + 'has_large_in_list' => $classification['has_large_in_list'], + 'estimated_in_list_size' => $classification['estimated_in_list_size'], + 'is_probable_woocommerce' => $classification['is_probable_woocommerce'], + 'is_probable_order_note_query' => $classification['is_probable_order_note_query'], ) ); } } + /** + * Classify a SQL string for incident-visibility fields. Returns an array + * of cheap, safe heuristics derived from plain string operations. + * Only classifies reads (SELECT); writes return all-default values because + * MAX_EXECUTION_TIME cannot kill writes by MySQL design. + * + * Returned keys: + * table_hint – 'wp_comments' when the table appears in the SQL, else '' + * is_comment_query – true when table_hint === 'wp_comments' + * has_large_in_list – true when an IN list has >= LARGE_IN_LIST_THRESHOLD items + * estimated_in_list_size – item count (commas + 1) of the first IN list found, else 0 + * is_probable_woocommerce – true when WC table / keyword hints are found + * is_probable_order_note_query – true when it's a wp_comments query with 'order_note' + * + * @param string $sql Raw SQL string (unsanitised, read-only inspection). + * @return array + */ + public static function classify_sql( $sql ) { + $result = array( + 'table_hint' => '', + 'is_comment_query' => false, + 'has_large_in_list' => false, + 'estimated_in_list_size' => 0, + 'is_probable_woocommerce' => false, + 'is_probable_order_note_query' => false, + ); + + if ( ! is_string( $sql ) || '' === $sql ) { + return $result; + } + + // Only classify reads; writes can't be killed by MAX_EXECUTION_TIME anyway. + if ( 0 !== strncasecmp( ltrim( $sql ), 'SELECT', 6 ) ) { + return $result; + } + + // ---- Table hint: wp_comments ---- + if ( false !== stripos( $sql, 'wp_comments' ) ) { + $result['table_hint'] = 'wp_comments'; + $result['is_comment_query'] = true; + } + + // ---- Large IN list ---- + // preg_match_all finds every IN ( in the query so that an earlier + // subquery IN (...) cannot mask a later large literal list — the + // exact failure mode of the May 2026 incident pattern. We keep the + // largest list found. Each body is measured with substr_count (O(n), + // no regex backtracking). + if ( preg_match_all( '/\bIN\s*\(/i', $sql, $in_matches, PREG_OFFSET_CAPTURE ) ) { + $max_size = 0; + foreach ( $in_matches[0] as $in_match ) { + $open = $in_match[1] + strlen( $in_match[0] ); + $scan_limit = min( strlen( $sql ) - $open, 524288 ); // cap at 512 KB + $body_chunk = substr( $sql, $open, $scan_limit ); + $close = strpos( $body_chunk, ')' ); + if ( false !== $close ) { + $commas = substr_count( substr( $body_chunk, 0, $close ), ',' ); + if ( $commas + 1 > $max_size ) { + $max_size = $commas + 1; + } + } + } + if ( $max_size > 0 ) { + $result['estimated_in_list_size'] = $max_size; + $result['has_large_in_list'] = ( $max_size >= self::LARGE_IN_LIST_THRESHOLD ); + } + } + + // ---- WooCommerce heuristics ---- + foreach ( array( 'woocommerce', 'wc_order', "post_type = 'shop_order'", "post_type='shop_order'" ) as $needle ) { + if ( false !== stripos( $sql, $needle ) ) { + $result['is_probable_woocommerce'] = true; + break; + } + } + + // ---- Order-note heuristic ---- + // WC stores order notes as wp_comments rows with comment_type = 'order_note'. + // The query that caused the May 2026 production incident was exactly this shape. + if ( + $result['is_comment_query'] && + ( false !== stripos( $sql, 'order_note' ) || false !== stripos( $sql, 'order-note' ) ) + ) { + $result['is_probable_order_note_query'] = true; + $result['is_probable_woocommerce'] = true; + } + + return $result; + } + + /** + * Return true when the given request URI routes through admin-ajax.php. + * + * @param string $uri Value of $_SERVER['REQUEST_URI']. + * @return bool + */ + public static function is_admin_ajax_request( $uri ) { + return false !== strpos( (string) $uri, 'admin-ajax.php' ); + } + /** * Centralized log emitter. Prefers Hypercart_Logger if present (your * existing file-based logger from the Performance Monitor plugin), @@ -438,6 +574,24 @@ public static function log_slow_queries() { * @param array $payload Structured fields. */ private static function log( $level, array $payload ) { + /** + * Filter the structured log payload before emission. Use this hook to + * add custom fields, route to additional sinks, or intercept payloads + * in integration tests. + * + * Callbacks MUST return $payload. A callback that omits the return + * would yield null here; (array) null === [] which silently drops + * every field. We guard against that: if the filtered value is not a + * non-empty array, the original $payload is used as-is. + * + * @param array $payload Log fields. + * @param string $level 'warn' | 'error' + */ + $filtered = apply_filters( 'hypercart_query_guard_log_payload', $payload, $level ); + if ( is_array( $filtered ) && ! empty( $filtered ) ) { + $payload = $filtered; + } + if ( class_exists( 'Hypercart_Logger' ) ) { if ( 'error' === $level && method_exists( 'Hypercart_Logger', 'error' ) ) { Hypercart_Logger::error( 'query_guard', $payload ); diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..9d37c6b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,14 @@ + + + + + tests + + + diff --git a/tests/PayloadFieldsTest.php b/tests/PayloadFieldsTest.php new file mode 100644 index 0000000..088d6cf --- /dev/null +++ b/tests/PayloadFieldsTest.php @@ -0,0 +1,325 @@ +captured = null; + // Clear any test-specific hook registered by a previous test. + remove_all_filters( 'hypercart_query_guard_log_payload' ); + // Reset SERVER URI so context detection doesn't bleed across tests. + $_SERVER['REQUEST_URI'] = '/'; + } + + protected function tearDown(): void { + // Remove the capture hook and unset the mock $wpdb global. + remove_all_filters( 'hypercart_query_guard_log_payload' ); + global $wpdb; + $wpdb = null; // phpcs:ignore WordPress.WP.GlobalVariablesOverride + $_SERVER['REQUEST_URI'] = '/'; + } + + // ----------------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------------- + + /** + * Install the log-payload capture hook and store the payload for assertion. + */ + private function capture_payload() { + add_filter( + 'hypercart_query_guard_log_payload', + function ( $payload ) { + $this->captured = $payload; + return $payload; + } + ); + } + + /** + * Reset the private static kill-detection high-water mark to -1. + */ + private function reset_kill_counter() { + $ref = new ReflectionClass( Hypercart_Query_Guard::class ); + $prop = $ref->getProperty( 'last_checked_query_num' ); + $prop->setAccessible( true ); + $prop->setValue( null, -1 ); + } + + /** + * Build a minimal $wpdb mock that looks like a killed query. + * + * @param string $last_query SQL of the killed query. + * @param int $num_queries Monotonic query counter value. + * @return stdClass + */ + private function mock_killed_wpdb( $last_query, $num_queries = 1 ) { + $mock = new stdClass(); + $mock->last_error = 'Query execution was interrupted, maximum statement execution time exceeded'; + $mock->last_query = $last_query; + $mock->num_queries = $num_queries; + return $mock; + } + + // ----------------------------------------------------------------------- + // query_killed event — classification fields present + // ----------------------------------------------------------------------- + + public function test_kill_payload_has_all_classification_fields() { + global $wpdb; + $ids = implode( ', ', range( 1, 300 ) ); + $wpdb = $this->mock_killed_wpdb( + "SELECT comment_ID FROM wp_comments WHERE comment_ID IN ({$ids})", + 10 + ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured, 'log payload was not captured' ); + $expected_keys = array( + 'table_hint', + 'is_comment_query', + 'has_large_in_list', + 'estimated_in_list_size', + 'is_probable_woocommerce', + 'is_probable_order_note_query', + 'is_admin_ajax', + ); + foreach ( $expected_keys as $key ) { + $this->assertArrayHasKey( $key, $this->captured, "Missing key: {$key}" ); + } + } + + public function test_kill_payload_classifies_large_wp_comments_query() { + global $wpdb; + $ids = implode( ', ', range( 1, 500 ) ); + $wpdb = $this->mock_killed_wpdb( + "SELECT comment_ID, comment_content FROM wp_comments WHERE comment_ID IN ({$ids})", + 20 + ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured ); + $this->assertSame( 'query_killed', $this->captured['event'] ); + $this->assertSame( 'wp_comments', $this->captured['table_hint'] ); + $this->assertTrue( $this->captured['is_comment_query'] ); + $this->assertTrue( $this->captured['has_large_in_list'] ); + $this->assertSame( 500, $this->captured['estimated_in_list_size'] ); + $this->assertFalse( $this->captured['is_probable_order_note_query'] ); + } + + public function test_kill_payload_classifies_order_note_query() { + global $wpdb; + $ids = implode( ', ', range( 1, 300 ) ); + $wpdb = $this->mock_killed_wpdb( + "SELECT * FROM wp_comments WHERE comment_type = 'order_note' AND comment_ID IN ({$ids})", + 30 + ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured ); + $this->assertTrue( $this->captured['is_probable_order_note_query'] ); + $this->assertTrue( $this->captured['is_probable_woocommerce'] ); + $this->assertTrue( $this->captured['is_comment_query'] ); + } + + public function test_kill_payload_is_admin_ajax_true_for_ajax_uri() { + global $wpdb; + $_SERVER['REQUEST_URI'] = '/wp-admin/admin-ajax.php?action=woocommerce_load_order_notes'; + $ids = implode( ', ', range( 1, 300 ) ); + $wpdb = $this->mock_killed_wpdb( + "SELECT * FROM wp_comments WHERE comment_ID IN ({$ids})", + 40 + ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured ); + $this->assertTrue( $this->captured['is_admin_ajax'] ); + $this->assertSame( '/wp-admin/admin-ajax.php?action=woocommerce_load_order_notes', $this->captured['uri'] ); + } + + public function test_kill_payload_is_admin_ajax_false_for_non_ajax_uri() { + global $wpdb; + $_SERVER['REQUEST_URI'] = '/wp-admin/edit.php?post_type=shop_order'; + $ids = implode( ', ', range( 1, 300 ) ); + $wpdb = $this->mock_killed_wpdb( + "SELECT * FROM wp_comments WHERE comment_ID IN ({$ids})", + 50 + ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured ); + $this->assertFalse( $this->captured['is_admin_ajax'] ); + } + + public function test_kill_payload_clean_query_no_large_in_or_comments_flags() { + global $wpdb; + $wpdb = $this->mock_killed_wpdb( 'SELECT ID FROM wp_posts WHERE post_status = \'publish\'', 60 ); + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNotNull( $this->captured ); + $this->assertSame( '', $this->captured['table_hint'] ); + $this->assertFalse( $this->captured['is_comment_query'] ); + $this->assertFalse( $this->captured['has_large_in_list'] ); + $this->assertSame( 0, $this->captured['estimated_in_list_size'] ); + $this->assertFalse( $this->captured['is_probable_woocommerce'] ); + $this->assertFalse( $this->captured['is_probable_order_note_query'] ); + } + + public function test_non_kill_error_does_not_emit_payload() { + global $wpdb; + $mock = new stdClass(); + $mock->last_error = 'Table wp_comments does not exist'; + $mock->last_query = 'SELECT * FROM wp_comments'; + $mock->num_queries = 70; + $wpdb = $mock; + $this->reset_kill_counter(); + $this->capture_payload(); + + Hypercart_Query_Guard::detect_and_log_kill(); + + $this->assertNull( $this->captured, 'Non-kill error should not emit a payload' ); + } + + // ----------------------------------------------------------------------- + // slow_query event — classification fields present + // ----------------------------------------------------------------------- + + public function test_slow_query_payload_has_classification_fields() { + global $wpdb; + $ids = implode( ', ', range( 1, 300 ) ); + $wpdb = new stdClass(); + $wpdb->queries = array( + array( + "SELECT comment_ID FROM wp_comments WHERE comment_type = 'order_note' AND comment_ID IN ({$ids})", + 6.5, // 6.5 s > WARN_THRESHOLD_MS (5 s) + 'WC_Order_Notes::get_notes', + ), + ); + $this->capture_payload(); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertNotNull( $this->captured, 'slow_query payload was not captured' ); + $this->assertSame( 'slow_query', $this->captured['event'] ); + + foreach ( array( 'table_hint', 'is_comment_query', 'has_large_in_list', 'estimated_in_list_size', 'is_probable_woocommerce', 'is_probable_order_note_query', 'is_admin_ajax' ) as $key ) { + $this->assertArrayHasKey( $key, $this->captured, "Missing key: {$key}" ); + } + + $this->assertSame( 'wp_comments', $this->captured['table_hint'] ); + $this->assertTrue( $this->captured['is_comment_query'] ); + $this->assertTrue( $this->captured['has_large_in_list'] ); + $this->assertTrue( $this->captured['is_probable_order_note_query'] ); + $this->assertTrue( $this->captured['is_probable_woocommerce'] ); + } + + public function test_slow_query_payload_is_admin_ajax_for_ajax_uri() { + global $wpdb; + $_SERVER['REQUEST_URI'] = '/wp-admin/admin-ajax.php?action=woocommerce_load_order_notes'; + $wpdb = new stdClass(); + $wpdb->queries = array( + array( 'SELECT ID FROM wp_posts WHERE post_status = \'publish\'', 7.0, 'SomeClass::method' ), + ); + $this->capture_payload(); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertNotNull( $this->captured ); + $this->assertTrue( $this->captured['is_admin_ajax'] ); + } + + public function test_slow_query_below_threshold_not_emitted() { + global $wpdb; + $wpdb = new stdClass(); + $wpdb->queries = array( + array( 'SELECT ID FROM wp_posts WHERE post_status = \'publish\'', 1.5, 'SomeClass::method' ), + ); + $this->capture_payload(); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertNull( $this->captured, 'Sub-threshold query must not produce a payload' ); + } + + public function test_slow_query_normal_select_flags_are_false() { + global $wpdb; + $wpdb = new stdClass(); + $wpdb->queries = array( + array( 'SELECT ID FROM wp_posts WHERE post_status = \'publish\'', 6.0, 'SomeClass::method' ), + ); + $this->capture_payload(); + + Hypercart_Query_Guard::log_slow_queries(); + + $this->assertNotNull( $this->captured ); + $this->assertSame( '', $this->captured['table_hint'] ); + $this->assertFalse( $this->captured['is_comment_query'] ); + $this->assertFalse( $this->captured['has_large_in_list'] ); + $this->assertFalse( $this->captured['is_probable_woocommerce'] ); + $this->assertFalse( $this->captured['is_probable_order_note_query'] ); + } + + // ----------------------------------------------------------------------- + // Log filter safety — null-return regression (filter cast fix) + // A hook callback that forgets return $payload must not reduce the payload + // to [] and silently drop all structured fields. + // ----------------------------------------------------------------------- + + public function test_filter_callback_returning_null_preserves_original_payload() { + global $wpdb; + $wpdb = $this->mock_killed_wpdb( 'SELECT ID FROM wp_posts WHERE post_status = \'publish\'', 80 ); + $this->reset_kill_counter(); + $GLOBALS['_qg_last_encoded'] = null; + + // Register a badly-written filter that omits return $payload. + // This returns null; (array) null would have been [] before the fix. + add_filter( + 'hypercart_query_guard_log_payload', + function ( $payload ) { + // Intentionally omits: return $payload; + } + ); + + Hypercart_Query_Guard::detect_and_log_kill(); + + // wp_json_encode is our bootstrap stub and records the last argument it + // received — i.e. the payload $log() actually emitted. If the null-return + // guard is working, this must be the original payload, not []. + $emitted = $GLOBALS['_qg_last_encoded']; + $this->assertNotNull( $emitted, 'log() must still emit when a filter returns null' ); + $this->assertNotEmpty( $emitted, 'payload must not be reduced to [] by a null-return filter' ); + $this->assertArrayHasKey( 'event', $emitted ); + $this->assertSame( 'query_killed', $emitted['event'] ); + } +} diff --git a/tests/SqlClassifierTest.php b/tests/SqlClassifierTest.php new file mode 100644 index 0000000..b38089e --- /dev/null +++ b/tests/SqlClassifierTest.php @@ -0,0 +1,276 @@ +assertSame( '', $r['table_hint'] ); + $this->assertFalse( $r['is_comment_query'] ); + $this->assertFalse( $r['has_large_in_list'] ); + $this->assertSame( 0, $r['estimated_in_list_size'] ); + $this->assertFalse( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['is_probable_order_note_query'] ); + } + + public function test_non_string_returns_all_defaults() { + // @phpstan-ignore-next-line intentional wrong type for guard test + $r = Hypercart_Query_Guard::classify_sql( null ); + $this->assertSame( '', $r['table_hint'] ); + $this->assertFalse( $r['has_large_in_list'] ); + } + + // ----------------------------------------------------------------------- + // classify_sql — write queries are not classified + // ----------------------------------------------------------------------- + + public function test_update_query_not_classified() { + $ids = implode( ', ', range( 1, 300 ) ); + $sql = "UPDATE wp_comments SET comment_content = 'x' WHERE comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( '', $r['table_hint'] ); + $this->assertFalse( $r['is_comment_query'] ); + $this->assertFalse( $r['has_large_in_list'] ); + $this->assertSame( 0, $r['estimated_in_list_size'] ); + } + + public function test_delete_query_not_classified() { + $sql = 'DELETE FROM wp_comments WHERE comment_ID IN (1, 2, 3)'; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( '', $r['table_hint'] ); + $this->assertFalse( $r['has_large_in_list'] ); + } + + public function test_insert_query_not_classified() { + $sql = "INSERT INTO wp_comments (comment_content) SELECT comment_content FROM wp_comments WHERE comment_ID IN (1,2,3)"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( '', $r['table_hint'] ); + } + + // ----------------------------------------------------------------------- + // classify_sql — normal SELECT with no notable characteristics + // ----------------------------------------------------------------------- + + public function test_normal_select_returns_clean_defaults() { + $sql = 'SELECT ID, post_title FROM wp_posts WHERE post_status = \'publish\''; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( '', $r['table_hint'] ); + $this->assertFalse( $r['is_comment_query'] ); + $this->assertFalse( $r['has_large_in_list'] ); + $this->assertSame( 0, $r['estimated_in_list_size'] ); + $this->assertFalse( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['is_probable_order_note_query'] ); + } + + public function test_small_in_list_is_not_flagged_as_large() { + // 10 IDs — well below the 200-item threshold. + $ids = implode( ', ', range( 1, 10 ) ); + $sql = "SELECT comment_ID FROM wp_comments WHERE comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertFalse( $r['has_large_in_list'] ); + $this->assertSame( 10, $r['estimated_in_list_size'] ); + // Table hint should still fire. + $this->assertSame( 'wp_comments', $r['table_hint'] ); + $this->assertTrue( $r['is_comment_query'] ); + } + + public function test_in_list_at_boundary_not_large() { + // 199 IDs — one below threshold, must not be flagged. + $ids = implode( ', ', range( 1, 199 ) ); + $sql = "SELECT * FROM wp_posts WHERE ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertFalse( $r['has_large_in_list'] ); + $this->assertSame( 199, $r['estimated_in_list_size'] ); + } + + public function test_in_list_at_threshold_is_large() { + // Exactly 200 IDs — at threshold, must be flagged. + $ids = implode( ', ', range( 1, 200 ) ); + $sql = "SELECT * FROM wp_posts WHERE ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['has_large_in_list'] ); + $this->assertSame( 200, $r['estimated_in_list_size'] ); + } + + // ----------------------------------------------------------------------- + // classify_sql — wp_comments large IN list (the incident pattern) + // ----------------------------------------------------------------------- + + public function test_large_wp_comments_in_list_classified_correctly() { + // Mirrors the production-incident query shape: ~10 k IDs. + $ids = implode( ', ', range( 1, 500 ) ); + $sql = "SELECT comment_ID, comment_author, comment_content FROM wp_comments WHERE comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( 'wp_comments', $r['table_hint'] ); + $this->assertTrue( $r['is_comment_query'] ); + $this->assertTrue( $r['has_large_in_list'] ); + $this->assertSame( 500, $r['estimated_in_list_size'] ); + } + + public function test_large_wp_comments_in_list_without_space_before_paren() { + // Some ORMs emit IN( without a space. + $ids = implode( ',', range( 1, 250 ) ); + $sql = "SELECT comment_ID FROM wp_comments WHERE comment_ID IN({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['has_large_in_list'] ); + $this->assertSame( 250, $r['estimated_in_list_size'] ); + } + + public function test_wp_comments_table_alias_still_detected() { + // Table present in FROM clause even with alias. + $ids = implode( ', ', range( 1, 300 ) ); + $sql = "SELECT c.comment_ID FROM wp_comments AS c WHERE c.comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertSame( 'wp_comments', $r['table_hint'] ); + $this->assertTrue( $r['is_comment_query'] ); + $this->assertTrue( $r['has_large_in_list'] ); + } + + // ----------------------------------------------------------------------- + // classify_sql — WooCommerce / order-note heuristics + // ----------------------------------------------------------------------- + + public function test_order_note_query_flagged_as_woocommerce_and_order_note() { + $ids = implode( ', ', range( 1, 300 ) ); + $sql = "SELECT * FROM wp_comments WHERE comment_type = 'order_note' AND comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_comment_query'] ); + $this->assertTrue( $r['is_probable_order_note_query'] ); + $this->assertTrue( $r['is_probable_woocommerce'] ); + $this->assertTrue( $r['has_large_in_list'] ); + } + + public function test_order_note_keyword_without_large_in_still_flagged_as_wc() { + // Even a small order-note query should surface the WC heuristic. + $sql = "SELECT * FROM wp_comments WHERE comment_type = 'order_note' AND comment_post_ID = 42"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_comment_query'] ); + $this->assertTrue( $r['is_probable_order_note_query'] ); + $this->assertTrue( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['has_large_in_list'] ); + } + + public function test_woocommerce_order_table_detected_without_comments() { + $sql = "SELECT * FROM wp_wc_orders WHERE status = 'wc-processing'"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['is_comment_query'] ); + $this->assertFalse( $r['is_probable_order_note_query'] ); + } + + public function test_shop_order_post_type_detected() { + $sql = "SELECT ID FROM wp_posts WHERE post_type = 'shop_order' AND post_status = 'wc-processing'"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['is_comment_query'] ); + } + + public function test_non_wc_comment_query_not_flagged_as_woocommerce() { + // A standard WordPress comment query with no WC signals. + $ids = implode( ', ', range( 1, 300 ) ); + $sql = "SELECT * FROM wp_comments WHERE comment_post_ID IN ({$ids}) AND comment_approved = '1'"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_comment_query'] ); + $this->assertFalse( $r['is_probable_woocommerce'] ); + $this->assertFalse( $r['is_probable_order_note_query'] ); + } + + // ----------------------------------------------------------------------- + // classify_sql — leading whitespace tolerance + // ----------------------------------------------------------------------- + + public function test_select_with_leading_whitespace_is_classified() { + $ids = implode( ', ', range( 1, 250 ) ); + $sql = " \n SELECT comment_ID FROM wp_comments WHERE comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['is_comment_query'] ); + $this->assertTrue( $r['has_large_in_list'] ); + } + + // ----------------------------------------------------------------------- + // classify_sql — subquery masking regression (preg_match_all fix) + // A query whose first IN ( is a small subquery must still surface the + // large literal list that follows it. + // ----------------------------------------------------------------------- + + public function test_large_in_list_not_masked_by_earlier_subquery_in() { + // First IN ( is a subquery with no commas; second IN ( is the 10k ID list. + // The old preg_match would stop at the subquery and return size=1. + $ids = implode( ', ', range( 1, 500 ) ); + $sql = "SELECT * FROM wp_comments + WHERE comment_type IN (SELECT slug FROM wp_term_taxonomy WHERE taxonomy = 'comment_type') + AND comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['has_large_in_list'], 'large literal list masked by earlier subquery IN' ); + $this->assertSame( 500, $r['estimated_in_list_size'] ); + $this->assertTrue( $r['is_comment_query'] ); + } + + public function test_small_subquery_in_before_large_list_reports_max_size() { + // Subquery IN with a small comma list (3 items), then a big literal list. + // estimated_in_list_size must reflect the larger one. + $ids = implode( ', ', range( 1, 300 ) ); + $sql = "SELECT * FROM wp_comments + WHERE comment_type IN ('order_note', 'note', 'status') + AND comment_ID IN ({$ids})"; + $r = Hypercart_Query_Guard::classify_sql( $sql ); + + $this->assertTrue( $r['has_large_in_list'] ); + $this->assertSame( 300, $r['estimated_in_list_size'] ); + } + + // ----------------------------------------------------------------------- + // is_admin_ajax_request + // ----------------------------------------------------------------------- + + public function test_admin_ajax_php_uri_is_detected() { + $this->assertTrue( Hypercart_Query_Guard::is_admin_ajax_request( '/wp-admin/admin-ajax.php' ) ); + } + + public function test_admin_ajax_php_uri_with_query_string_is_detected() { + $this->assertTrue( + Hypercart_Query_Guard::is_admin_ajax_request( '/wp-admin/admin-ajax.php?action=woocommerce_load_order_notes' ) + ); + } + + public function test_non_ajax_admin_uri_is_not_detected() { + $this->assertFalse( Hypercart_Query_Guard::is_admin_ajax_request( '/wp-admin/post.php' ) ); + } + + public function test_frontend_uri_is_not_detected() { + $this->assertFalse( Hypercart_Query_Guard::is_admin_ajax_request( '/shop/my-product/' ) ); + } + + public function test_rest_api_uri_is_not_detected() { + $this->assertFalse( Hypercart_Query_Guard::is_admin_ajax_request( '/wp-json/wc/v3/orders' ) ); + } + + public function test_empty_uri_is_not_detected() { + $this->assertFalse( Hypercart_Query_Guard::is_admin_ajax_request( '' ) ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..bd08777 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,155 @@ +dbh connection object). +if ( ! defined( 'HYPERCART_QUERY_GUARD_MODE' ) ) { + define( 'HYPERCART_QUERY_GUARD_MODE', 'observe' ); +} + +// SAVEQUERIES must be true so log_slow_queries() processes the mock query list. +if ( ! defined( 'SAVEQUERIES' ) ) { + define( 'SAVEQUERIES', true ); +} + +// --------------------------------------------------------------------------- +// Lightweight filter/action registry. +// apply_filters and add_filter behave like WordPress so test code can use +// the hypercart_query_guard_log_payload hook to capture payloads. +// --------------------------------------------------------------------------- + +$GLOBALS['_qg_test_filters'] = array(); + +function add_filter( $hook, $callback, $priority = 10, $accepted_args = 1 ) { + $GLOBALS['_qg_test_filters'][ $hook ][] = $callback; +} + +function add_action( $hook, $callback, $priority = 10, $accepted_args = 1 ) { + add_filter( $hook, $callback, $priority, $accepted_args ); +} + +function apply_filters( $hook, $value ) { + $extra = array_slice( func_get_args(), 2 ); + if ( ! empty( $GLOBALS['_qg_test_filters'][ $hook ] ) ) { + foreach ( $GLOBALS['_qg_test_filters'][ $hook ] as $cb ) { + $value = $cb( $value, ...$extra ); + } + } + return $value; +} + +function remove_all_filters( $hook ) { + unset( $GLOBALS['_qg_test_filters'][ $hook ] ); +} + +// --------------------------------------------------------------------------- +// WordPress function stubs +// --------------------------------------------------------------------------- + +if ( ! function_exists( 'did_action' ) ) { + function did_action( $hook ) { + return 0; + } +} + +if ( ! function_exists( 'wp_doing_ajax' ) ) { + function wp_doing_ajax() { + return defined( 'DOING_AJAX' ) && DOING_AJAX; + } +} + +if ( ! function_exists( 'wp_doing_cron' ) ) { + function wp_doing_cron() { + return defined( 'DOING_CRON' ) && DOING_CRON; + } +} + +if ( ! function_exists( 'is_admin' ) ) { + function is_admin() { + return defined( 'WP_ADMIN' ) && WP_ADMIN; + } +} + +if ( ! function_exists( 'get_current_user_id' ) ) { + function get_current_user_id() { + return 0; + } +} + +if ( ! function_exists( 'wp_json_encode' ) ) { + $GLOBALS['_qg_last_encoded'] = null; + function wp_json_encode( $data, $options = 0, $depth = 512 ) { + $GLOBALS['_qg_last_encoded'] = $data; + return json_encode( $data, $options, $depth ); + } +} + +if ( ! function_exists( 'sanitize_text_field' ) ) { + function sanitize_text_field( $str ) { + return $str; + } +} + +if ( ! function_exists( 'sanitize_key' ) ) { + function sanitize_key( $str ) { + return $str; + } +} + +if ( ! function_exists( 'wp_unslash' ) ) { + function wp_unslash( $value ) { + return $value; + } +} + +if ( ! function_exists( 'set_transient' ) ) { + function set_transient( $key, $value, $expiration = 0 ) { + return true; + } +} + +if ( ! function_exists( 'get_transient' ) ) { + function get_transient( $key ) { + return false; + } +} + +if ( ! function_exists( 'delete_transient' ) ) { + function delete_transient( $key ) { + return true; + } +} + +if ( ! function_exists( 'esc_html__' ) ) { + function esc_html__( $text, $domain = 'default' ) { + return $text; + } +} + +if ( ! function_exists( 'esc_html' ) ) { + function esc_html( $text ) { + return $text; + } +} + +if ( ! function_exists( '__' ) ) { + function __( $text, $domain = 'default' ) { + return $text; + } +} + +// Load the plugin (registers the class; calls init() which adds WordPress +// hooks via add_action / add_filter — both stubbed above). +require_once dirname( __DIR__ ) . '/hypercart-query-guard.php';