From 104900266bd15b3b633172daf247a137040d4b13 Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Wed, 27 May 2026 17:50:15 -0500 Subject: [PATCH 1/7] feat: add opt-in strict chunk_index validation Co-Authored-By: Claude Opus 4.7 (1M context) --- includes/class-wpvdb-rest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/includes/class-wpvdb-rest.php b/includes/class-wpvdb-rest.php index 80a75e8..5e08ccc 100644 --- a/includes/class-wpvdb-rest.php +++ b/includes/class-wpvdb-rest.php @@ -912,6 +912,29 @@ public static function handle_metadata( \WP_REST_Request $request ) { // phpcs:i * @return int|\WP_Error Row ID, or WP_Error on validation failure or DB insert failure. */ public static function insert_embedding_row( $doc_id, $chunk_id, $chunk_content, $summary, $embedding, $model = '', $doc_type = 'post', $chunk_index = null ) { + /** + * Filters whether a missing or invalid chunk_index is a hard error. + * + * Default false preserves the legacy behavior (warn, then default to 0). + * Return true to reject a null, non-numeric, or negative chunk_index with a + * WP_Error instead of silently storing 0, so caller regressions fail loudly. + * + * @param bool $strict Whether to reject an invalid chunk_index. Default false. + */ + if ( apply_filters( 'wpvdb_strict_chunk_index', false ) + && ( null === $chunk_index || ! is_numeric( $chunk_index ) || (int) $chunk_index < 0 ) ) { + Logger::error( 'insert_embedding_row rejected invalid chunk_index for doc_id=' . $doc_id ); + return new \WP_Error( + 'chunk_index_invalid', + 'Refused to store an embedding with a missing, non-numeric, or negative chunk_index while strict mode is enabled.', + array( + 'doc_id' => $doc_id, + 'chunk_index' => $chunk_index, + 'status' => 400, + ) + ); + } + // Detect callers using the legacy 5/6/7-arg signature; fall back to 0 for backward // compatibility but emit a warning so the regression is visible. if ( null === $chunk_index ) { From c4c5a01eebbc3d1f301eef8baf7e8a1476f1fce3 Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Wed, 27 May 2026 18:15:31 -0500 Subject: [PATCH 2/7] fix(rest): tighten strict chunk index validation Co-Authored-By: Codex --- includes/class-wpvdb-rest.php | 10 ++- tests/bootstrap.php | 58 ++++++++++++++- tests/unit/RESTTest.php | 134 ++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 6 deletions(-) create mode 100644 tests/unit/RESTTest.php diff --git a/includes/class-wpvdb-rest.php b/includes/class-wpvdb-rest.php index 5e08ccc..c157a7b 100644 --- a/includes/class-wpvdb-rest.php +++ b/includes/class-wpvdb-rest.php @@ -916,17 +916,19 @@ public static function insert_embedding_row( $doc_id, $chunk_id, $chunk_content, * Filters whether a missing or invalid chunk_index is a hard error. * * Default false preserves the legacy behavior (warn, then default to 0). - * Return true to reject a null, non-numeric, or negative chunk_index with a + * Return true to reject a null, non-integer, or negative chunk_index with a * WP_Error instead of silently storing 0, so caller regressions fail loudly. * * @param bool $strict Whether to reject an invalid chunk_index. Default false. */ - if ( apply_filters( 'wpvdb_strict_chunk_index', false ) - && ( null === $chunk_index || ! is_numeric( $chunk_index ) || (int) $chunk_index < 0 ) ) { + $strict_chunk_index = (bool) apply_filters( 'wpvdb_strict_chunk_index', false ); + $valid_chunk_index = is_int( $chunk_index ) + || ( is_string( $chunk_index ) && preg_match( '/^\d+$/', $chunk_index ) ); + if ( $strict_chunk_index && ( ! $valid_chunk_index || (int) $chunk_index < 0 ) ) { Logger::error( 'insert_embedding_row rejected invalid chunk_index for doc_id=' . $doc_id ); return new \WP_Error( 'chunk_index_invalid', - 'Refused to store an embedding with a missing, non-numeric, or negative chunk_index while strict mode is enabled.', + 'Refused to store an embedding with a missing, non-integer, or negative chunk_index while strict mode is enabled.', array( 'doc_id' => $doc_id, 'chunk_index' => $chunk_index, diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0667da7..beff10c 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -12,6 +12,9 @@ echo "Using standalone testing mode.\n"; // Mock essential WordPress functions for testing +global $_wp_filters; +$_wp_filters = []; + if ( ! function_exists( 'add_action' ) ) { function add_action( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) { return true; @@ -20,10 +23,39 @@ function add_action( $tag, $function_to_add, $priority = 10, $accepted_args = 1 if ( ! function_exists( 'add_filter' ) ) { function add_filter( $tag, $function_to_add, $priority = 10, $accepted_args = 1 ) { + global $_wp_filters; + if ( ! isset( $_wp_filters[ $tag ] ) ) { + $_wp_filters[ $tag ] = []; + } + if ( ! isset( $_wp_filters[ $tag ][ $priority ] ) ) { + $_wp_filters[ $tag ][ $priority ] = []; + } + $_wp_filters[ $tag ][ $priority ][] = [ + 'function' => $function_to_add, + 'accepted_args' => $accepted_args, + ]; return true; } } +if ( ! function_exists( 'remove_filter' ) ) { + function remove_filter( $tag, $function_to_remove, $priority = 10 ) { + global $_wp_filters; + if ( empty( $_wp_filters[ $tag ][ $priority ] ) ) { + return false; + } + + foreach ( $_wp_filters[ $tag ][ $priority ] as $index => $filter ) { + if ( $filter['function'] === $function_to_remove ) { + unset( $_wp_filters[ $tag ][ $priority ][ $index ] ); + return true; + } + } + + return false; + } +} + if ( ! function_exists( 'do_action' ) ) { function do_action( $hook_name, ...$args ) { // Mock implementation @@ -32,8 +64,22 @@ function do_action( $hook_name, ...$args ) { } if ( ! function_exists( 'apply_filters' ) ) { - function apply_filters( $tag, $value ) { - // Simple mock that just returns the value unchanged + function apply_filters( $tag, $value, ...$args ) { + global $_wp_filters; + if ( empty( $_wp_filters[ $tag ] ) ) { + return $value; + } + + ksort( $_wp_filters[ $tag ] ); + $filter_args = array_merge( [ $value ], $args ); + foreach ( $_wp_filters[ $tag ] as $filters ) { + foreach ( $filters as $filter ) { + $accepted_args = max( 1, (int) $filter['accepted_args'] ); + $value = call_user_func_array( $filter['function'], array_slice( $filter_args, 0, $accepted_args ) ); + $filter_args[0] = $value; + } + } + return $value; } } @@ -266,6 +312,12 @@ function stripslashes_from_strings_only( $value ) { define( 'REST_REQUEST', false ); } +if ( ! function_exists( 'wpvdb_should_log_to_error_log' ) ) { + function wpvdb_should_log_to_error_log() { + return false; + } +} + if ( ! defined( 'OBJECT' ) ) { define( 'OBJECT', 'OBJECT' ); } @@ -435,8 +487,10 @@ public function suppress_errors( $suppress = true ) { require_once dirname( __DIR__ ) . '/includes/class-wpvdb-providers.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-models.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-settings.php'; +require_once dirname( __DIR__ ) . '/includes/class-wpvdb-cache.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-core.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-database.php'; +require_once dirname( __DIR__ ) . '/includes/class-wpvdb-rest.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-embedding-enqueuer.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-queue.php'; require_once dirname( __DIR__ ) . '/includes/class-wpvdb-security.php'; diff --git a/tests/unit/RESTTest.php b/tests/unit/RESTTest.php new file mode 100644 index 0000000..bf108b8 --- /dev/null +++ b/tests/unit/RESTTest.php @@ -0,0 +1,134 @@ +assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'chunk_index_invalid', $result->get_error_code() ); + $this->assertSame( $chunk_index, $result->get_error_data()['chunk_index'] ); + } + + /** + * Test strict chunk index mode accepts integer values. + * + * @dataProvider valid_chunk_indexes + * + * @param mixed $chunk_index Valid chunk index. + */ + public function test_strict_chunk_index_allows_valid_integer_values( $chunk_index ) { + $strict = function () { + return true; + }; + add_filter( 'wpvdb_strict_chunk_index', $strict ); + + try { + $result = REST::insert_embedding_row( + 123, + 'chunk-0', + 'Chunk content', + '', + array( 0.0 ), + 'test-model', + 'post', + $chunk_index + ); + } finally { + remove_filter( 'wpvdb_strict_chunk_index', $strict ); + } + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'embedding_invalid', $result->get_error_code() ); + $this->assertSame( (int) $chunk_index, $result->get_error_data()['chunk_index'] ); + } + + /** + * Test default mode keeps the legacy null-to-zero fallback. + */ + public function test_default_chunk_index_mode_preserves_null_fallback() { + $result = REST::insert_embedding_row( + 123, + 'chunk-0', + 'Chunk content', + '', + array( 0.0 ), + 'test-model', + 'post', + null + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'embedding_invalid', $result->get_error_code() ); + $this->assertSame( 0, $result->get_error_data()['chunk_index'] ); + } + + /** + * Invalid chunk index provider. + * + * @return array + */ + public function invalid_chunk_indexes() { + return array( + 'null' => array( null ), + 'text' => array( 'abc' ), + 'negative int' => array( -1 ), + 'negative text' => array( '-1' ), + 'fraction float' => array( 1.5 ), + 'fraction text' => array( '1.5' ), + 'boolean' => array( true ), + ); + } + + /** + * Valid chunk index provider. + * + * @return array + */ + public function valid_chunk_indexes() { + return array( + 'zero int' => array( 0 ), + 'positive int' => array( 3 ), + 'zero text' => array( '0' ), + 'number text' => array( '3' ), + ); + } +} From 5cddfd355189c0a6128dc27cc8078f285ef22de1 Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Wed, 27 May 2026 18:28:14 -0500 Subject: [PATCH 3/7] fix(rest): handle strict chunk index edge cases Co-Authored-By: Codex --- includes/class-wpvdb-rest.php | 24 ++++++++++++++----- tests/bootstrap.php | 27 +++++++++++++++++++-- tests/unit/RESTTest.php | 44 ++++++++++++++++++++++++++++++++--- 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/includes/class-wpvdb-rest.php b/includes/class-wpvdb-rest.php index c157a7b..c5a12fc 100644 --- a/includes/class-wpvdb-rest.php +++ b/includes/class-wpvdb-rest.php @@ -916,19 +916,31 @@ public static function insert_embedding_row( $doc_id, $chunk_id, $chunk_content, * Filters whether a missing or invalid chunk_index is a hard error. * * Default false preserves the legacy behavior (warn, then default to 0). - * Return true to reject a null, non-integer, or negative chunk_index with a - * WP_Error instead of silently storing 0, so caller regressions fail loudly. + * Return true to reject a null, non-integer, negative, or out-of-range + * chunk_index with a WP_Error instead of silently storing 0, so caller + * regressions fail loudly. * * @param bool $strict Whether to reject an invalid chunk_index. Default false. */ $strict_chunk_index = (bool) apply_filters( 'wpvdb_strict_chunk_index', false ); - $valid_chunk_index = is_int( $chunk_index ) - || ( is_string( $chunk_index ) && preg_match( '/^\d+$/', $chunk_index ) ); - if ( $strict_chunk_index && ( ! $valid_chunk_index || (int) $chunk_index < 0 ) ) { + $valid_chunk_index = false; + if ( is_int( $chunk_index ) ) { + $valid_chunk_index = $chunk_index >= 0; + } elseif ( is_string( $chunk_index ) && preg_match( '/^\d+$/', $chunk_index ) ) { + $normalized_chunk_index = ltrim( $chunk_index, '0' ); + $max_chunk_index = (string) PHP_INT_MAX; + $valid_chunk_index = '' === $normalized_chunk_index + || strlen( $normalized_chunk_index ) < strlen( $max_chunk_index ) + || ( + strlen( $normalized_chunk_index ) === strlen( $max_chunk_index ) + && strcmp( $normalized_chunk_index, $max_chunk_index ) <= 0 + ); + } + if ( $strict_chunk_index && ! $valid_chunk_index ) { Logger::error( 'insert_embedding_row rejected invalid chunk_index for doc_id=' . $doc_id ); return new \WP_Error( 'chunk_index_invalid', - 'Refused to store an embedding with a missing, non-integer, or negative chunk_index while strict mode is enabled.', + 'Refused to store an embedding with a missing, non-integer, negative, or out-of-range chunk_index while strict mode is enabled.', array( 'doc_id' => $doc_id, 'chunk_index' => $chunk_index, diff --git a/tests/bootstrap.php b/tests/bootstrap.php index beff10c..9ea17d7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -229,7 +229,24 @@ function has_action( $tag, $function_to_check = false ) { if ( ! function_exists( 'has_filter' ) ) { function has_filter( $tag, $function_to_check = false ) { - return false; // Mock - always return false + global $_wp_filters; + if ( empty( $_wp_filters[ $tag ] ) ) { + return false; + } + + if ( false === $function_to_check ) { + return true; + } + + foreach ( $_wp_filters[ $tag ] as $priority => $filters ) { + foreach ( $filters as $filter ) { + if ( $filter['function'] === $function_to_check ) { + return $priority; + } + } + } + + return false; } } @@ -312,8 +329,14 @@ function stripslashes_from_strings_only( $value ) { define( 'REST_REQUEST', false ); } +if ( ! function_exists( 'wpvdb_is_sqlite' ) ) { + function wpvdb_is_sqlite() { + return false; + } +} + if ( ! function_exists( 'wpvdb_should_log_to_error_log' ) ) { - function wpvdb_should_log_to_error_log() { + function wpvdb_should_log_to_error_log( $level = 'debug', $message = '', $context = array() ) { return false; } } diff --git a/tests/unit/RESTTest.php b/tests/unit/RESTTest.php index bf108b8..6dfc356 100644 --- a/tests/unit/RESTTest.php +++ b/tests/unit/RESTTest.php @@ -16,7 +16,7 @@ class RESTTest extends TestCase { /** - * Test strict chunk index mode rejects missing and non-integer values. + * Test strict chunk index mode rejects invalid chunk index values. * * @dataProvider invalid_chunk_indexes * @@ -45,11 +45,14 @@ public function test_strict_chunk_index_rejects_invalid_values( $chunk_index ) { $this->assertInstanceOf( \WP_Error::class, $result ); $this->assertSame( 'chunk_index_invalid', $result->get_error_code() ); - $this->assertSame( $chunk_index, $result->get_error_data()['chunk_index'] ); + $error_data = $result->get_error_data(); + $this->assertSame( 123, $error_data['doc_id'] ); + $this->assertSame( $chunk_index, $error_data['chunk_index'] ); + $this->assertSame( 400, $error_data['status'] ); } /** - * Test strict chunk index mode accepts integer values. + * Test strict chunk index mode accepts valid chunk index values. * * @dataProvider valid_chunk_indexes * @@ -81,6 +84,32 @@ public function test_strict_chunk_index_allows_valid_integer_values( $chunk_inde $this->assertSame( (int) $chunk_index, $result->get_error_data()['chunk_index'] ); } + /** + * Test default mode does not reject invalid chunk index values. + * + * @dataProvider invalid_chunk_indexes + * + * @param mixed $chunk_index Invalid chunk index. + */ + public function test_default_chunk_index_mode_does_not_reject_invalid_values( $chunk_index ) { + $result = REST::insert_embedding_row( + 123, + 'chunk-0', + 'Chunk content', + '', + array( 0.0 ), + 'test-model', + 'post', + $chunk_index + ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertSame( 'embedding_invalid', $result->get_error_code() ); + $error_data = $result->get_error_data(); + $this->assertSame( 123, $error_data['doc_id'] ); + $this->assertSame( 400, $error_data['status'] ); + } + /** * Test default mode keeps the legacy null-to-zero fallback. */ @@ -115,6 +144,14 @@ public function invalid_chunk_indexes() { 'fraction float' => array( 1.5 ), 'fraction text' => array( '1.5' ), 'boolean' => array( true ), + 'false' => array( false ), + 'empty string' => array( '' ), + 'spaced number' => array( ' 1' ), + 'float one' => array( 1.0 ), + 'float zero' => array( 0.0 ), + 'plus one' => array( '+1' ), + 'exponent' => array( '1e3' ), + 'oversized' => array( PHP_INT_MAX . '0' ), ); } @@ -129,6 +166,7 @@ public function valid_chunk_indexes() { 'positive int' => array( 3 ), 'zero text' => array( '0' ), 'number text' => array( '3' ), + 'max text' => array( (string) PHP_INT_MAX ), ); } } From 0ff4ada247254aa904eef12732976fe78ef1d856 Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Wed, 27 May 2026 18:43:23 -0500 Subject: [PATCH 4/7] test(bootstrap): honor zero accepted filter args Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- tests/bootstrap.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 9ea17d7..739826b 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -74,7 +74,7 @@ function apply_filters( $tag, $value, ...$args ) { $filter_args = array_merge( [ $value ], $args ); foreach ( $_wp_filters[ $tag ] as $filters ) { foreach ( $filters as $filter ) { - $accepted_args = max( 1, (int) $filter['accepted_args'] ); + $accepted_args = max( 0, (int) $filter['accepted_args'] ); $value = call_user_func_array( $filter['function'], array_slice( $filter_args, 0, $accepted_args ) ); $filter_args[0] = $value; } From 5cb69923f7caa867bdd1ca293a1fb44ed0867ac3 Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Wed, 27 May 2026 18:46:56 -0500 Subject: [PATCH 5/7] test(bootstrap): reset mocked filters per test Co-Authored-By: Codex --- phpunit.xml | 5 ++++- tests/filter-reset-hook.php | 41 +++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 tests/filter-reset-hook.php diff --git a/phpunit.xml b/phpunit.xml index a4dccfa..ab20bd4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -21,6 +21,9 @@ tests/api + + + includes @@ -38,4 +41,4 @@ - \ No newline at end of file + diff --git a/tests/filter-reset-hook.php b/tests/filter-reset-hook.php new file mode 100644 index 0000000..1fcef20 --- /dev/null +++ b/tests/filter-reset-hook.php @@ -0,0 +1,41 @@ +reset_filters(); + } + + /** + * Reset filters after each test finishes. + * + * @param string $test Test name. + * @param float $time Test runtime. + */ + public function executeAfterTest( string $test, float $time ): void { + $this->reset_filters(); + } + + /** + * Reset the standalone filter registry. + */ + private function reset_filters(): void { + $GLOBALS['_wp_filters'] = array(); + } +} From 85d511e7a148d2b57f0dac99abdb9e04f96bf8ab Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Wed, 27 May 2026 18:56:21 -0500 Subject: [PATCH 6/7] test(bootstrap): mirror filter edge behavior Co-Authored-By: Codex --- tests/bootstrap.php | 6 ++++++ tests/unit/RESTTest.php | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 739826b..36382bb 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -48,6 +48,12 @@ function remove_filter( $tag, $function_to_remove, $priority = 10 ) { foreach ( $_wp_filters[ $tag ][ $priority ] as $index => $filter ) { if ( $filter['function'] === $function_to_remove ) { unset( $_wp_filters[ $tag ][ $priority ][ $index ] ); + if ( empty( $_wp_filters[ $tag ][ $priority ] ) ) { + unset( $_wp_filters[ $tag ][ $priority ] ); + } + if ( empty( $_wp_filters[ $tag ] ) ) { + unset( $_wp_filters[ $tag ] ); + } return true; } } diff --git a/tests/unit/RESTTest.php b/tests/unit/RESTTest.php index 6dfc356..7a1cd0c 100644 --- a/tests/unit/RESTTest.php +++ b/tests/unit/RESTTest.php @@ -26,7 +26,7 @@ public function test_strict_chunk_index_rejects_invalid_values( $chunk_index ) { $strict = function () { return true; }; - add_filter( 'wpvdb_strict_chunk_index', $strict ); + add_filter( 'wpvdb_strict_chunk_index', $strict, 10, 0 ); try { $result = REST::insert_embedding_row( @@ -62,7 +62,7 @@ public function test_strict_chunk_index_allows_valid_integer_values( $chunk_inde $strict = function () { return true; }; - add_filter( 'wpvdb_strict_chunk_index', $strict ); + add_filter( 'wpvdb_strict_chunk_index', $strict, 10, 0 ); try { $result = REST::insert_embedding_row( From 3d9bc86d09ef525b63e0bcf4a4d445f0bdadec19 Mon Sep 17 00:00:00 2001 From: Ramon Corrales Date: Wed, 27 May 2026 19:09:00 -0500 Subject: [PATCH 7/7] test: drop phpunit api testsuite missing its directory Co-Authored-By: Claude Opus 4.7 (1M context) --- phpunit.xml | 3 --- 1 file changed, 3 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index ab20bd4..cefa8d7 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -17,9 +17,6 @@ tests/integration - - tests/api -