From 1e3150814c30fa90ed9623bca83f7ff44b47a6fa Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Thu, 21 May 2026 16:44:43 +1200 Subject: [PATCH 1/4] Search: Filter_Static helper + REST endpoint (SEARCH-219, 2/3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side foundation for the upcoming jetpack-search/filter-static block. Inert until the block is registered (part 3/3) — exposes a helper class and REST route that the block's render.php and editor inspector will consume. - New `Filter_Static` class (block helpers package) reads the site-configured static-filter list, dedupes by `filter_id`, and exposes: - `get_static_filters_config()` — memoised lookup. Resolves the union of `jetpack_instant_search_options` (legacy options blob, used by the overlay today) and `jetpack_search_static_filters` (narrower sibling for blocks-only callers). Sites already wired up for the overlay pick up the new block with zero migration. - `filters_for_variation()` — narrow by `sidebar` vs `tabbed` variation + optional filter_id scoping. - `normalize_variation` / `normalize_entry` / `normalize_values` / `build_config` / `derive_label` / `parse_url_selections`. - Last-wins on duplicate `filter_id`; surfaces collisions via `_doing_it_wrong()`. - `Search_Blocks::register_rest_routes()` registers `GET /jetpack-search/v1/static-filters?variation=` (permission `edit_posts`) — backs the block's editor inspector picker. Falls under the existing `jetpack_search_blocks_enabled` gate. - `Search_Blocks::build_initial_state()` seeds an empty `staticFilterSelections` slot so JS readers see a defined shape on pages without the block. Test coverage: 15 new PHPUnit cases (`Filter_Static_Test`): - Both hook surfaces (`jetpack_instant_search_options` legacy seam and `jetpack_search_static_filters` narrower sibling). - Memoisation + the reset_cache_for_testing() seam. - Reserved-param `filter_id` rejection, empty-values rejection, per-value sanitisation with `name` falling back to `value`. - Last-wins dedupe on duplicate `filter_id`. - `filters_for_variation` scoping (variation + optional filter_id). - `normalize_variation` defaults, `build_config` shape lockdown (the `kind:'static'` marker is what the JS store keys off). - `derive_label` attribute override + fallback. - `parse_url_selections` configured-keys-only gate, array-shape misuse rejection, empty-string drop. --- .../changelog/search-219-filter-static-helper | 4 + .../filter-static/class-filter-static.php | 303 ++++++++++ .../src/search-blocks/class-search-blocks.php | 54 ++ .../search/tests/php/Filter_Static_Test.php | 543 ++++++++++++++++++ 4 files changed, 904 insertions(+) create mode 100644 projects/packages/search/changelog/search-219-filter-static-helper create mode 100644 projects/packages/search/src/search-blocks/blocks/filter-static/class-filter-static.php create mode 100644 projects/packages/search/tests/php/Filter_Static_Test.php diff --git a/projects/packages/search/changelog/search-219-filter-static-helper b/projects/packages/search/changelog/search-219-filter-static-helper new file mode 100644 index 00000000000..fdc1039dac4 --- /dev/null +++ b/projects/packages/search/changelog/search-219-filter-static-helper @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Search: internal — new `Filter_Static` helper class and `/wp-json/jetpack-search/v1/static-filters` REST endpoint, plus a `staticFilterSelections` seed slot on the shared Interactivity state. Foundation for the upcoming `jetpack-search/filter-static` block; no user-visible change on its own. diff --git a/projects/packages/search/src/search-blocks/blocks/filter-static/class-filter-static.php b/projects/packages/search/src/search-blocks/blocks/filter-static/class-filter-static.php new file mode 100644 index 00000000000..dfd35ade6cb --- /dev/null +++ b/projects/packages/search/src/search-blocks/blocks/filter-static/class-filter-static.php @@ -0,0 +1,303 @@ +>|null + */ + private static $config_cache = null; + + /** + * Read the site-configured static-filter list (memoised per request). + * + * Composes three steps: read raw entries from the host-site filter hooks, + * normalise each one, dedupe by `filter_id` (last write wins). + * + * @return array> + */ + public static function get_static_filters_config(): array { + if ( null !== self::$config_cache ) { + return self::$config_cache; + } + self::$config_cache = self::dedupe_by_filter_id( + self::normalize_entries( self::read_raw_entries() ) + ); + return self::$config_cache; + } + + /** + * Reset the per-request memo. Tests only. + */ + public static function reset_cache_for_testing(): void { + self::$config_cache = null; + } + + /** + * Read raw static-filter entries from the host-site filter hooks. Resolves + * the union of two seams so sites already wired up for the legacy overlay + * pick up the new block for free: + * + * 1. `jetpack_instant_search_options` — the legacy options blob's + * `staticFilters` key. Used by the overlay today. + * 2. `jetpack_search_static_filters` — narrower sibling whose payload is + * just the static-filter array. New, blocks-only callers register here. + * + * @return array + */ + private static function read_raw_entries(): array { + if ( ! function_exists( 'apply_filters' ) ) { + return array(); + } + $options = apply_filters( 'jetpack_instant_search_options', array() ); + $from_legacy = is_array( $options['staticFilters'] ?? null ) + ? $options['staticFilters'] + : array(); + return (array) apply_filters( 'jetpack_search_static_filters', $from_legacy ); + } + + /** + * Normalise a raw entry list, dropping ones that don't pass validation. + * + * @param array $raw Raw entries from the filter hooks. + * @return array> + */ + private static function normalize_entries( array $raw ): array { + $out = array(); + foreach ( $raw as $entry ) { + $normalized = self::normalize_entry( $entry ); + if ( null !== $normalized ) { + $out[] = $normalized; + } + } + return $out; + } + + /** + * Collapse entries with duplicate `filter_id` to a single entry per id, + * keeping the last registration (matches PHP `apply_filters` semantics). + * The duplicate's position is preserved so iteration order stays + * deterministic regardless of how many duplicates were registered. + * + * @param array> $entries Normalised entries. + * @return array> + */ + private static function dedupe_by_filter_id( array $entries ): array { + $position = array(); + $out = array(); + foreach ( $entries as $entry ) { + $filter_id = $entry['filter_id']; + if ( isset( $position[ $filter_id ] ) ) { + self::warn_duplicate_filter_id( $filter_id ); + $out[ $position[ $filter_id ] ] = $entry; + continue; + } + $position[ $filter_id ] = count( $out ); + $out[] = $entry; + } + return $out; + } + + /** + * Surface a duplicate-registration via `_doing_it_wrong()` so abuse is + * visible in debug. Silent in production. + * + * @param string $filter_id The colliding filter id. + */ + private static function warn_duplicate_filter_id( string $filter_id ): void { + if ( ! function_exists( '_doing_it_wrong' ) ) { + return; + } + $message = sprintf( + /* translators: %s: duplicate filter ID. */ + __( 'Duplicate static filter "%s" — last registration wins.', 'jetpack-search-pkg' ), + $filter_id + ); + _doing_it_wrong( __METHOD__, esc_html( $message ), 'jetpack-search 0.1.0' ); + } + + /** + * Narrow the configured list by `variation` and (optionally) `filter_id`. + * + * @param string $variation Either 'sidebar' or 'tabbed'. + * @param string $filter_id When non-empty, return only the matching entry. + * @return array> + */ + public static function filters_for_variation( string $variation, string $filter_id = '' ): array { + $variation = self::normalize_variation( $variation ); + $out = array(); + foreach ( self::get_static_filters_config() as $entry ) { + if ( self::normalize_variation( $entry['variation'] ?? '' ) !== $variation ) { + continue; + } + if ( '' !== $filter_id && $entry['filter_id'] !== $filter_id ) { + continue; + } + $out[] = $entry; + } + return $out; + } + + /** + * Normalize the variation value. Anything other than 'tabbed' collapses to + * 'sidebar' — matches the legacy `getAvailableStaticFilters()` default. + * + * @param mixed $value Raw value. + * @return string Either 'sidebar' or 'tabbed'. + */ + public static function normalize_variation( $value ): string { + return 'tabbed' === $value ? 'tabbed' : 'sidebar'; + } + + /** + * Sanitize and validate a single configured entry. Returns null when the + * entry is missing required fields or its `filter_id` collides with a + * reserved URL param — the block then renders nothing for that entry + * rather than silently failing on round-trip. + * + * @param mixed $entry Raw entry. + * @return array|null + */ + private static function normalize_entry( $entry ): ?array { + if ( ! is_array( $entry ) ) { + return null; + } + $filter_id = sanitize_key( (string) ( $entry['filter_id'] ?? '' ) ); + if ( '' === $filter_id || in_array( $filter_id, Search_Blocks::RESERVED_QUERY_PARAMS, true ) ) { + return null; + } + $values = self::normalize_values( $entry['values'] ?? array() ); + if ( empty( $values ) ) { + return null; + } + return array( + 'filter_id' => $filter_id, + 'name' => sanitize_text_field( (string) ( $entry['name'] ?? '' ) ), + 'type' => 'group', + 'variation' => self::normalize_variation( $entry['variation'] ?? '' ), + 'selected' => sanitize_text_field( (string) ( $entry['selected'] ?? '' ) ), + 'values' => $values, + ); + } + + /** + * Sanitize the per-entry `values` array. Drops entries that aren't + * objects, entries with an empty `value`, and any non-array element. + * A missing display `name` falls back to the value so the radio still + * has a visible label rather than rendering blank. + * + * @param mixed $raw Raw values list. + * @return array + */ + private static function normalize_values( $raw ): array { + $out = array(); + foreach ( (array) $raw as $entry ) { + if ( ! is_array( $entry ) ) { + continue; + } + $value = sanitize_text_field( (string) ( $entry['value'] ?? '' ) ); + if ( '' === $value ) { + continue; + } + $name = sanitize_text_field( (string) ( $entry['name'] ?? '' ) ); + $out[] = array( + 'value' => $value, + 'name' => '' === $name ? $value : $name, + ); + } + return $out; + } + + /** + * Build the filterConfig entry this block contributes to the shared + * Interactivity state. The `kind => 'static'` flag is what the JS store + * keys off to decide URL serialization (scalar vs array shape) and the + * single-select vs toggle action path. + * + * @param array $entry Normalized server-config entry. + * @param array $attributes Block attributes. + * @return array + */ + public static function build_config( array $entry, array $attributes ): array { + return array( + 'filterKey' => $entry['filter_id'], + 'kind' => 'static', + 'filterType' => 'static', + 'label' => self::derive_label( $entry, $attributes ), + 'values' => $entry['values'], + 'selected' => $entry['selected'], + 'variation' => $entry['variation'], + ); + } + + /** + * Block-attribute label override beats server name; empty falls back. + * + * @param array $entry Normalized server-config entry. + * @param array $attributes Block attributes. + * @return string + */ + public static function derive_label( array $entry, array $attributes ): string { + $override = sanitize_text_field( (string) ( $attributes['label'] ?? '' ) ); + if ( '' !== $override ) { + return $override; + } + return (string) ( $entry['name'] ?? '' ); + } + + /** + * Parse scalar URL params that match a configured static-filter key into a + * `{ filter_id => value }` map. Called from the seed path so a deep link + * pre-checks the right radio and the SSR pass shows the filtered count. + * + * Iterates the configured filters rather than the entire `$_GET` so + * arbitrary plugin-emitted params can't end up in the map. + * + * @return array + */ + public static function parse_url_selections(): array { + if ( ! function_exists( 'wp_unslash' ) ) { + return array(); + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only URL state; sanitized per-value below. + $raw = wp_unslash( $_GET ); + if ( ! is_array( $raw ) ) { + return array(); + } + $out = array(); + foreach ( self::get_static_filters_config() as $entry ) { + $filter_id = $entry['filter_id']; + $value = $raw[ $filter_id ] ?? null; + // `! is_string` drops both missing values and array-shaped misuse + // (e.g. a stray `?section[]=…` under a static-filter key). + if ( ! is_string( $value ) ) { + continue; + } + $clean = sanitize_text_field( $value ); + if ( '' !== $clean ) { + $out[ $filter_id ] = $clean; + } + } + return $out; + } +} diff --git a/projects/packages/search/src/search-blocks/class-search-blocks.php b/projects/packages/search/src/search-blocks/class-search-blocks.php index 406264ec640..75671bcefa6 100644 --- a/projects/packages/search/src/search-blocks/class-search-blocks.php +++ b/projects/packages/search/src/search-blocks/class-search-blocks.php @@ -168,6 +168,7 @@ public static function init() { add_action( 'init', array( static::class, 'register_blocks' ) ); add_filter( 'block_categories_all', array( static::class, 'register_block_category' ) ); add_action( 'enqueue_block_editor_assets', array( static::class, 'enqueue_editor_assets' ) ); + add_action( 'rest_api_init', array( static::class, 'register_rest_routes' ) ); Custom_Taxonomy_Slot_Mapping::init(); // FSE block-template rendering runs *before* `wp_head()` (see // `wp-includes/template-canvas.php`), so blocks would resolve @@ -692,6 +693,52 @@ public static function register_block_category( $categories ) { return $categories; } + /** + * Register editor-facing REST routes that back the search blocks' inspector + * controls. Currently exposes the site's static-filter configuration so + * the `jetpack-search/filter-static` block's filter picker can list what + * the host site has wired up via `jetpack_search_static_filters` without + * needing the editor bundle to localize a snapshot. + */ + public static function register_rest_routes() { + if ( ! function_exists( 'register_rest_route' ) ) { + return; + } + register_rest_route( + 'jetpack-search/v1', + '/static-filters', + array( + 'methods' => 'GET', + 'callback' => array( static::class, 'rest_get_static_filters' ), + 'permission_callback' => static function () { + return function_exists( 'current_user_can' ) && current_user_can( 'edit_posts' ); + }, + 'args' => array( + 'variation' => array( + 'type' => 'string', + 'enum' => array( 'sidebar', 'tabbed' ), + 'default' => 'sidebar', + 'sanitize_callback' => 'sanitize_key', + ), + ), + ) + ); + } + + /** + * REST callback: return the site-configured static filters for a given + * variation. Used by the filter-static block's editor inspector. + * + * @param \WP_REST_Request $request REST request. + * @return array> + */ + public static function rest_get_static_filters( $request ) { + $variation = is_object( $request ) && method_exists( $request, 'get_param' ) + ? (string) $request->get_param( 'variation' ) + : 'sidebar'; + return Filter_Static::filters_for_variation( $variation ); + } + /** * Register all search blocks from their block.json files. */ @@ -1716,6 +1763,13 @@ public static function build_initial_state() { 'activeFilters' => $active_filters, 'filterLogic' => $filter_logic, 'priceRange' => $price_range, + // Static filters (`jetpack-search/filter-static`) round-trip as + // scalar `?filter_id=value` URL params. The block's render.php + // merges the URL-seeded selections in alongside its filterConfig + // entry so a deep link pre-checks the right radio. Seeded as an + // empty object here so JS readers always see a defined shape on + // pages without the block. + 'staticFilterSelections' => (object) array(), // filterConfigs: each filter-checkbox block's render.php merges its // own entry here. Shape: { [filterKey]: { filterKey, filterType, diff --git a/projects/packages/search/tests/php/Filter_Static_Test.php b/projects/packages/search/tests/php/Filter_Static_Test.php new file mode 100644 index 00000000000..51a140f8043 --- /dev/null +++ b/projects/packages/search/tests/php/Filter_Static_Test.php @@ -0,0 +1,543 @@ + 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + ); + } + ); + + $configs = Filter_Static::get_static_filters_config(); + + $this->assertCount( 1, $configs ); + $this->assertSame( 'section', $configs[0]['filter_id'] ); + $this->assertSame( 'Section', $configs[0]['name'] ); + $this->assertSame( 'group', $configs[0]['type'] ); + // Variation defaults to 'sidebar' when unset on the entry. + $this->assertSame( 'sidebar', $configs[0]['variation'] ); + $this->assertSame( 'news', $configs[0]['values'][0]['value'] ); + } + + /** + * Sites that registered static filters for the legacy instant-search + * overlay via `jetpack_instant_search_options` should get the blocks for + * free — Filter_Static must pick the same payload up without a second + * registration. + */ + public function test_get_static_filters_config_reads_legacy_instant_search_options_hook() { + add_filter( + 'jetpack_instant_search_options', + static function ( $options ) { + $options['staticFilters'] = array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'variation' => 'tabbed', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + ); + return $options; + } + ); + + $configs = Filter_Static::get_static_filters_config(); + + $this->assertCount( 1, $configs ); + $this->assertSame( 'section', $configs[0]['filter_id'] ); + $this->assertSame( 'tabbed', $configs[0]['variation'] ); + } + + /** + * Result is memoised per request — re-registering after the first read + * shouldn't change subsequent reads until the cache is reset. + */ + public function test_get_static_filters_config_memoises_per_request() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'a', + 'name' => 'A', + 'values' => array( + array( + 'name' => 'One', + 'value' => 'one', + ), + ), + ), + ); + } + ); + $first = Filter_Static::get_static_filters_config(); + + // Re-register so the underlying hook returns a different list. The + // memoised first read should still win. + remove_all_filters( 'jetpack_search_static_filters' ); + add_filter( + 'jetpack_search_static_filters', + static function () { + return array(); + } + ); + + $second = Filter_Static::get_static_filters_config(); + $this->assertSame( $first, $second ); + } + + /** + * Entries whose `filter_id` collides with a reserved query param (`s`, + * `q`, `orderby`, `min_price`, `max_price`) round-trip through + * `parse_url_filters()` as something other than a filter and would + * silently fail. Reject them upfront so misconfiguration is visible. + */ + public function test_normalize_entry_rejects_reserved_filter_id() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 's', + 'name' => 'Search hijack', + 'values' => array( + array( + 'name' => 'One', + 'value' => 'one', + ), + ), + ), + array( + 'filter_id' => 'orderby', + 'name' => 'Sort hijack', + 'values' => array( + array( + 'name' => 'One', + 'value' => 'one', + ), + ), + ), + ); + } + ); + + $this->assertSame( array(), Filter_Static::get_static_filters_config() ); + } + + /** + * An entry with no usable values would render an empty fieldset. Drop it + * so render.php has nothing to emit rather than a labeled empty list. + */ + public function test_normalize_entry_rejects_empty_values() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array(), + ), + array( + 'filter_id' => 'topic', + 'name' => 'Topic', + 'values' => 'not-an-array', + ), + ); + } + ); + + $this->assertSame( array(), Filter_Static::get_static_filters_config() ); + } + + /** + * Values missing a `value` field are filtered out, and a missing display + * `name` falls back to the value. An entry left with no usable values + * after this is rejected. + */ + public function test_normalize_entry_sanitises_individual_values() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + // Missing value — dropped. + array( 'name' => 'Orphan' ), + // Missing name — falls back to the value. + array( 'value' => 'guides' ), + // Not an array — skipped. + 'garbage', + ), + ), + ); + } + ); + + $values = Filter_Static::get_static_filters_config()[0]['values']; + $this->assertSame( + array( + array( + 'value' => 'news', + 'name' => 'News', + ), + array( + 'value' => 'guides', + 'name' => 'guides', + ), + ), + $values + ); + } + + /** + * Two entries with the same `filter_id` create an ambiguous URL contract + * (which radio set owns `?section=...`?). Adopt the last write so the + * behaviour mirrors PHP `apply_filters` semantics, and surface the + * collision via `_doing_it_wrong()` so abuse is visible in debug. + */ + public function test_get_static_filters_config_dedupes_last_wins_on_duplicate_filter_id() { + add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'First registration', + 'values' => array( + array( + 'name' => 'One', + 'value' => 'one', + ), + ), + ), + array( + 'filter_id' => 'section', + 'name' => 'Second registration', + 'values' => array( + array( + 'name' => 'Two', + 'value' => 'two', + ), + ), + ), + ); + } + ); + + $configs = Filter_Static::get_static_filters_config(); + $this->assertCount( 1, $configs ); + $this->assertSame( 'Second registration', $configs[0]['name'] ); + $this->assertSame( 'two', $configs[0]['values'][0]['value'] ); + } + + /** + * `filters_for_variation` returns only entries whose `variation` matches. + * Defaults coerce 'sidebar' so an entry with the field unset shows under + * the sidebar variation and not the tabbed one. + */ + public function test_filters_for_variation_scopes_by_variation() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + // Variation omitted — defaults to sidebar. + 'values' => array( + array( + 'name' => 'A', + 'value' => 'a', + ), + ), + ), + array( + 'filter_id' => 'audience', + 'name' => 'Audience', + 'variation' => 'tabbed', + 'values' => array( + array( + 'name' => 'B', + 'value' => 'b', + ), + ), + ), + ); + } + ); + + $this->assertCount( 1, Filter_Static::filters_for_variation( 'sidebar' ) ); + $this->assertSame( 'section', Filter_Static::filters_for_variation( 'sidebar' )[0]['filter_id'] ); + + $this->assertCount( 1, Filter_Static::filters_for_variation( 'tabbed' ) ); + $this->assertSame( 'audience', Filter_Static::filters_for_variation( 'tabbed' )[0]['filter_id'] ); + } + + /** + * When the block scopes to a specific `filter_id`, only that one entry + * (intersected with the variation) is returned. + */ + public function test_filters_for_variation_filters_by_specific_filter_id() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'A', + 'value' => 'a', + ), + ), + ), + array( + 'filter_id' => 'topic', + 'name' => 'Topic', + 'values' => array( + array( + 'name' => 'B', + 'value' => 'b', + ), + ), + ), + ); + } + ); + + $narrowed = Filter_Static::filters_for_variation( 'sidebar', 'topic' ); + $this->assertCount( 1, $narrowed ); + $this->assertSame( 'topic', $narrowed[0]['filter_id'] ); + } + + /** + * Anything other than the literal string 'tabbed' collapses to 'sidebar', + * matching `getAvailableStaticFilters()` on the JS side. + */ + public function test_normalize_variation_defaults_to_sidebar() { + $this->assertSame( 'tabbed', Filter_Static::normalize_variation( 'tabbed' ) ); + $this->assertSame( 'sidebar', Filter_Static::normalize_variation( 'sidebar' ) ); + $this->assertSame( 'sidebar', Filter_Static::normalize_variation( '' ) ); + $this->assertSame( 'sidebar', Filter_Static::normalize_variation( 'garbage' ) ); + $this->assertSame( 'sidebar', Filter_Static::normalize_variation( null ) ); + } + + /** + * `build_config` produces the filterConfig entry that render.php pushes + * into the shared Interactivity state. The `kind => 'static'` marker is + * what the JS store keys off to decide URL serialization (scalar vs + * array shape) — locking it down here prevents silent drift between PHP + * and JS contracts. + */ + public function test_build_config_shape() { + $entry = array( + 'filter_id' => 'section', + 'name' => 'Section', + 'type' => 'group', + 'variation' => 'sidebar', + 'selected' => 'news', + 'values' => array( + array( + 'value' => 'news', + 'name' => 'News', + ), + ), + ); + + $config = Filter_Static::build_config( $entry, array() ); + + $this->assertSame( 'section', $config['filterKey'] ); + $this->assertSame( 'static', $config['kind'] ); + $this->assertSame( 'static', $config['filterType'] ); + $this->assertSame( 'Section', $config['label'] ); + $this->assertSame( 'news', $config['selected'] ); + $this->assertSame( 'sidebar', $config['variation'] ); + $this->assertSame( + array( + array( + 'value' => 'news', + 'name' => 'News', + ), + ), + $config['values'] + ); + } + + /** + * The block's `label` attribute overrides the server-supplied `name`. + * Empty falls back so a site that never set the override sees the + * registered display name. + */ + public function test_derive_label_attribute_override_beats_server_name() { + $entry = array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array(), + ); + + $this->assertSame( + 'Custom Heading', + Filter_Static::derive_label( $entry, array( 'label' => 'Custom Heading' ) ) + ); + $this->assertSame( 'Section', Filter_Static::derive_label( $entry, array( 'label' => '' ) ) ); + $this->assertSame( 'Section', Filter_Static::derive_label( $entry, array() ) ); + } + + /** + * `parse_url_selections` pulls scalar `?filter_id=value` URL params into a + * `{ filter_id => value }` map. Only keys that match a configured static + * filter get through — arbitrary scalar params from other plugins never + * reach `staticFilterSelections`. + */ + public function test_parse_url_selections_pulls_only_configured_keys() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + ); + } + ); + $_GET = array( + 'section' => 'news', + 'unrelated' => 'ignored', + 's' => 'search query', + ); + + $this->assertSame( array( 'section' => 'news' ), Filter_Static::parse_url_selections() ); + } + + /** + * Array-shaped params under a static-filter key are misuse (the URL + * contract for static filters is scalar). Drop them rather than guess + * which array element to use. + */ + public function test_parse_url_selections_skips_array_shaped_values() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + ); + } + ); + $_GET = array( 'section' => array( 'news', 'guides' ) ); + + $this->assertSame( array(), Filter_Static::parse_url_selections() ); + } + + /** + * Empty-string values mean "no selection" — drop them so the gate stays + * empty rather than seeding an empty string into staticFilterSelections. + */ + public function test_parse_url_selections_drops_empty_strings() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + ); + } + ); + $_GET = array( 'section' => '' ); + + $this->assertSame( array(), Filter_Static::parse_url_selections() ); + } +} From d787ed52a3159c68d7534d244b29fdd07c2098b5 Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Thu, 21 May 2026 17:42:19 +1200 Subject: [PATCH 2/4] Address review: _doing_it_wrong version + format, REST callback, missing tests (PR #49039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _doing_it_wrong call now matches the package convention: 'jetpack-search-pkg 7.0.0' (was 'jetpack-search 0.1.0'), and the message routes through esc_html__() + esc_html() with a phpcs:ignore comment — same pattern as Custom_Taxonomy_Slot_Mapping. (The earlier esc_html() wrapper around the already-translated message had the right intent but the wrong layer; switching to esc_html__() escapes at translation time, which is what the WP CS sniff wants.) - Drop the over-defensive is_object/method_exists duck-type in the REST callback — register_rest_route() always passes a WP_REST_Request. Type-hint the parameter explicitly to match the JSDoc. - Add 'validate_callback' => 'rest_validate_request_arg' to the route's variation arg so the enum check actually fires; without it, the enum declaration is only schema metadata. - Tests: - Filter_Static_Test now extends Search_TestCase so REST cases can dispatch through a real WP_REST_Server with admin/editor users. - Add test for the legacy + new hook union — the new jetpack_search_static_filters callback receives the legacy jetpack_instant_search_options.staticFilters list as input and can override entries by filter_id. - Add REST endpoint cases: anonymous request returns 401; authenticated editor returns the variation-scoped subset; invalid variation enum value returns 400; omitted variation defaults to sidebar. Addresses claude[bot]'s and copilot-swe-agent's reviews on PR #49039. --- .../filter-static/class-filter-static.php | 7 +- .../src/search-blocks/class-search-blocks.php | 12 +- .../search/tests/php/Filter_Static_Test.php | 203 +++++++++++++++++- 3 files changed, 205 insertions(+), 17 deletions(-) diff --git a/projects/packages/search/src/search-blocks/blocks/filter-static/class-filter-static.php b/projects/packages/search/src/search-blocks/blocks/filter-static/class-filter-static.php index dfd35ade6cb..e3a596a2a9b 100644 --- a/projects/packages/search/src/search-blocks/blocks/filter-static/class-filter-static.php +++ b/projects/packages/search/src/search-blocks/blocks/filter-static/class-filter-static.php @@ -130,10 +130,11 @@ private static function warn_duplicate_filter_id( string $filter_id ): void { } $message = sprintf( /* translators: %s: duplicate filter ID. */ - __( 'Duplicate static filter "%s" — last registration wins.', 'jetpack-search-pkg' ), - $filter_id + esc_html__( 'Duplicate static filter "%s" — last registration wins.', 'jetpack-search-pkg' ), + esc_html( $filter_id ) ); - _doing_it_wrong( __METHOD__, esc_html( $message ), 'jetpack-search 0.1.0' ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $message is sprintf() of esc_html__() with esc_html()-wrapped arg. + _doing_it_wrong( __METHOD__, $message, 'jetpack-search-pkg 7.0.0' ); } /** diff --git a/projects/packages/search/src/search-blocks/class-search-blocks.php b/projects/packages/search/src/search-blocks/class-search-blocks.php index 75671bcefa6..addc6e5c71f 100644 --- a/projects/packages/search/src/search-blocks/class-search-blocks.php +++ b/projects/packages/search/src/search-blocks/class-search-blocks.php @@ -719,6 +719,11 @@ public static function register_rest_routes() { 'enum' => array( 'sidebar', 'tabbed' ), 'default' => 'sidebar', 'sanitize_callback' => 'sanitize_key', + // Without an explicit validate_callback the enum check + // never fires — REST routes only run arg validation + // when this is wired up. `rest_validate_request_arg` + // is core's general-purpose dispatcher. + 'validate_callback' => 'rest_validate_request_arg', ), ), ) @@ -732,11 +737,8 @@ public static function register_rest_routes() { * @param \WP_REST_Request $request REST request. * @return array> */ - public static function rest_get_static_filters( $request ) { - $variation = is_object( $request ) && method_exists( $request, 'get_param' ) - ? (string) $request->get_param( 'variation' ) - : 'sidebar'; - return Filter_Static::filters_for_variation( $variation ); + public static function rest_get_static_filters( \WP_REST_Request $request ) { + return Filter_Static::filters_for_variation( (string) $request->get_param( 'variation' ) ); } /** diff --git a/projects/packages/search/tests/php/Filter_Static_Test.php b/projects/packages/search/tests/php/Filter_Static_Test.php index 51a140f8043..c66cd0c82a9 100644 --- a/projects/packages/search/tests/php/Filter_Static_Test.php +++ b/projects/packages/search/tests/php/Filter_Static_Test.php @@ -7,23 +7,41 @@ namespace Automattic\Jetpack\Search; -use PHPUnit\Framework\TestCase as PHPUnit_TestCase; +use Automattic\Jetpack\Search\TestCase as Search_TestCase; +use WP_REST_Request; +use WP_REST_Server; /** * Tests for the Filter_Static helpers that back the - * `jetpack-search/filter-static` block. + * `jetpack-search/filter-static` block. Extends Search_TestCase so the + * REST-endpoint cases can dispatch through a real WP_REST_Server with + * admin/editor users; the helper-only cases pay only a small WorDBless + * setup cost. */ -class Filter_Static_Test extends PHPUnit_TestCase { +class Filter_Static_Test extends Search_TestCase { + + /** + * REST Server, populated in setUp so REST-endpoint cases can dispatch. + * + * @var WP_REST_Server|null + */ + protected $server; /** * Reset the per-request memo before each test so registrations from a - * prior case don't leak into the next. + * prior case don't leak into the next. Wire up a real REST server so + * the endpoint cases can dispatch. */ - protected function setUp(): void { + public function setUp(): void { parent::setUp(); Filter_Static::reset_cache_for_testing(); - // Clear any $_GET state a prior test may have set. $_GET = array(); + + global $wp_rest_server; + $wp_rest_server = new WP_REST_Server(); + $this->server = $wp_rest_server; + add_action( 'rest_api_init', array( Search_Blocks::class, 'register_rest_routes' ) ); + do_action( 'rest_api_init' ); } /** @@ -31,13 +49,15 @@ protected function setUp(): void { * `add_filter()` inline, so the safest cleanup is to walk the two hooks * Filter_Static reads. */ - protected function tearDown(): void { - parent::tearDown(); + public function tearDown(): void { + remove_action( 'rest_api_init', array( Search_Blocks::class, 'register_rest_routes' ) ); remove_all_filters( 'jetpack_search_static_filters' ); remove_all_filters( 'jetpack_instant_search_options' ); remove_all_filters( 'doing_it_wrong_trigger_error' ); - $_GET = array(); + $_GET = array(); + $this->server = null; Filter_Static::reset_cache_for_testing(); + parent::tearDown(); } /** @@ -540,4 +560,169 @@ static function () { $this->assertSame( array(), Filter_Static::parse_url_selections() ); } + + /** + * The new `jetpack_search_static_filters` hook receives whatever the legacy + * `jetpack_instant_search_options.staticFilters` produced as its initial + * value, so callbacks on the new hook can see, override, or extend a + * site's legacy static-filter registration. This is the integration + * point sites would use to migrate without dropping the existing overlay. + */ + public function test_new_hook_receives_legacy_payload_as_input() { + add_filter( + 'jetpack_instant_search_options', + static function ( $options ) { + $options['staticFilters'] = array( + array( + 'filter_id' => 'section', + 'name' => 'Legacy section', + 'values' => array( + array( + 'name' => 'A', + 'value' => 'a', + ), + ), + ), + ); + return $options; + } + ); + // Override the same filter_id on the new hook — last-wins semantics + // mean the new hook's entry replaces the legacy one. + add_filter( + 'jetpack_search_static_filters', + static function ( $list ) { + // The list passed in MUST contain the legacy entry. + if ( ! is_array( $list ) || count( $list ) !== 1 || ( $list[0]['filter_id'] ?? '' ) !== 'section' ) { + return $list; + } + $list[] = array( + 'filter_id' => 'section', + 'name' => 'Blocks-side override', + 'values' => array( + array( + 'name' => 'B', + 'value' => 'b', + ), + ), + ); + return $list; + } + ); + add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); + + $configs = Filter_Static::get_static_filters_config(); + $this->assertCount( 1, $configs ); + $this->assertSame( 'Blocks-side override', $configs[0]['name'] ); + $this->assertSame( 'b', $configs[0]['values'][0]['value'] ); + } + + /** + * The editor REST endpoint surfaces the configured filters to authors so + * they can pick one in the block inspector. Editors (and anything with + * `edit_posts`) should be allowed; logged-out visitors must not be. + */ + public function test_rest_endpoint_requires_edit_posts_capability() { + wp_set_current_user( 0 ); + $request = new WP_REST_Request( 'GET', '/jetpack-search/v1/static-filters' ); + $response = $this->server->dispatch( $request ); + $this->assertSame( 401, $response->get_status(), 'Anonymous request should be unauthorized.' ); + } + + /** + * Authenticated editors get the configured filters back, scoped to the + * requested variation. + */ + public function test_rest_endpoint_returns_filters_for_authenticated_editor() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'variation' => 'sidebar', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + array( + 'filter_id' => 'audience', + 'name' => 'Audience', + 'variation' => 'tabbed', + 'values' => array( + array( + 'name' => 'Devs', + 'value' => 'dev', + ), + ), + ), + ); + } + ); + wp_set_current_user( $this->editor_id ); + + $request = new WP_REST_Request( 'GET', '/jetpack-search/v1/static-filters' ); + $request->set_query_params( array( 'variation' => 'sidebar' ) ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertCount( 1, $data, 'Should return only filters whose variation matches.' ); + $this->assertSame( 'section', $data[0]['filter_id'] ); + } + + /** + * The `variation` arg is enum-validated against `sidebar` / `tabbed`. A + * value that survives `sanitize_key` but isn't a member of the enum + * (e.g. `'invalid'`) must be rejected so the parameter typing surfaces + * misconfiguration to the caller rather than silently coercing it. + * + * (Garbage values that fail sanitize_key — e.g. `'sidebar; DROP TABLE'` + * — are coerced first and may end up matching the enum; that's the + * intentional defence-in-depth ordering of sanitize_callback before + * arg validation.) + */ + public function test_rest_endpoint_rejects_invalid_variation() { + wp_set_current_user( $this->admin_id ); + $request = new WP_REST_Request( 'GET', '/jetpack-search/v1/static-filters' ); + $request->set_query_params( array( 'variation' => 'invalid' ) ); + $response = $this->server->dispatch( $request ); + $this->assertSame( 400, $response->get_status() ); + } + + /** + * Default `variation` is `sidebar` when the param is omitted — matches + * the block.json attribute default so the editor inspector's initial + * dropdown state and the REST round-trip agree. + */ + public function test_rest_endpoint_defaults_variation_to_sidebar() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'variation' => 'sidebar', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + ); + } + ); + wp_set_current_user( $this->admin_id ); + $request = new WP_REST_Request( 'GET', '/jetpack-search/v1/static-filters' ); + $response = $this->server->dispatch( $request ); + $this->assertSame( 200, $response->get_status() ); + $this->assertCount( 1, $response->get_data() ); + $this->assertSame( 'section', $response->get_data()[0]['filter_id'] ); + } } From 20da295bbd7f6e31f5a841fd4ae0ed90e4c84081 Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Thu, 21 May 2026 17:54:25 +1200 Subject: [PATCH 3/4] Address re-review: drop dead register_rest_route guard + add 403 subscriber test (PR #49039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two non-blocking observations from claude[bot]'s re-review: - Remove the function_exists('register_rest_route') guard in register_rest_routes(). The callback only runs from the rest_api_init hook, which by definition fires after REST is loaded — the guard can't be true in production. - Add a test for a logged-in subscriber: REST should return 403 (not 401) so callers can tell 'not authenticated' from 'authenticated but unauthorised'. The 401 anonymous case already existed; this fills in the role-without-edit_posts side. --- .../src/search-blocks/class-search-blocks.php | 3 --- .../search/tests/php/Filter_Static_Test.php | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/projects/packages/search/src/search-blocks/class-search-blocks.php b/projects/packages/search/src/search-blocks/class-search-blocks.php index addc6e5c71f..33e200d1dab 100644 --- a/projects/packages/search/src/search-blocks/class-search-blocks.php +++ b/projects/packages/search/src/search-blocks/class-search-blocks.php @@ -701,9 +701,6 @@ public static function register_block_category( $categories ) { * needing the editor bundle to localize a snapshot. */ public static function register_rest_routes() { - if ( ! function_exists( 'register_rest_route' ) ) { - return; - } register_rest_route( 'jetpack-search/v1', '/static-filters', diff --git a/projects/packages/search/tests/php/Filter_Static_Test.php b/projects/packages/search/tests/php/Filter_Static_Test.php index c66cd0c82a9..864644165aa 100644 --- a/projects/packages/search/tests/php/Filter_Static_Test.php +++ b/projects/packages/search/tests/php/Filter_Static_Test.php @@ -629,6 +629,26 @@ public function test_rest_endpoint_requires_edit_posts_capability() { $this->assertSame( 401, $response->get_status(), 'Anonymous request should be unauthorized.' ); } + /** + * A logged-in subscriber lacks `edit_posts` — REST should distinguish + * "not authenticated" (401) from "authenticated but not authorised" + * (403). The error-status accuracy matters for any caller doing + * differentiated UI fallbacks. + */ + public function test_rest_endpoint_forbids_user_without_edit_posts() { + $subscriber_id = wp_insert_user( + array( + 'user_login' => 'dummy_subscriber', + 'user_pass' => 'dummy_pass_subscriber', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $subscriber_id ); + $request = new WP_REST_Request( 'GET', '/jetpack-search/v1/static-filters' ); + $response = $this->server->dispatch( $request ); + $this->assertSame( 403, $response->get_status(), 'Subscriber without edit_posts should be forbidden.' ); + } + /** * Authenticated editors get the configured filters back, scoped to the * requested variation. From b227916bbdbfbb8d064a9867451e28a49884e4c6 Mon Sep 17 00:00:00 2001 From: Jasper Kang Date: Fri, 22 May 2026 14:02:32 +1200 Subject: [PATCH 4/4] Search: drop static-filters REST endpoint, keep helper-only (PR #49039) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `/jetpack-search/v1/static-filters` route only existed to back a custom editor inspector picker for the upcoming `filter-static` block. The block's editor view will be a static placeholder (like the Post Type Scope block), so the route had no consumer — inert code. - Remove the `rest_api_init` hook, `register_rest_routes()` and `rest_get_static_filters()` from `Search_Blocks`. - Drop the REST dispatch tests and server scaffolding from `Filter_Static_Test` (helper-only coverage remains, 16 tests green). - `Filter_Static` helper is unchanged; `filters_for_variation()` still feeds part 3's front-end render. Front end never used REST — it reads the helper directly and round-trips via `?filter_id=value` params. --- .../changelog/search-219-filter-static-helper | 2 +- .../src/search-blocks/class-search-blocks.php | 46 ------ .../search/tests/php/Filter_Static_Test.php | 156 +----------------- 3 files changed, 4 insertions(+), 200 deletions(-) diff --git a/projects/packages/search/changelog/search-219-filter-static-helper b/projects/packages/search/changelog/search-219-filter-static-helper index fdc1039dac4..f59c25bcdbc 100644 --- a/projects/packages/search/changelog/search-219-filter-static-helper +++ b/projects/packages/search/changelog/search-219-filter-static-helper @@ -1,4 +1,4 @@ Significance: patch Type: added -Search: internal — new `Filter_Static` helper class and `/wp-json/jetpack-search/v1/static-filters` REST endpoint, plus a `staticFilterSelections` seed slot on the shared Interactivity state. Foundation for the upcoming `jetpack-search/filter-static` block; no user-visible change on its own. +Search: internal — new `Filter_Static` helper class, plus a `staticFilterSelections` seed slot on the shared Interactivity state. Foundation for the upcoming `jetpack-search/filter-static` block; no user-visible change on its own. diff --git a/projects/packages/search/src/search-blocks/class-search-blocks.php b/projects/packages/search/src/search-blocks/class-search-blocks.php index 33e200d1dab..92e65cfbea9 100644 --- a/projects/packages/search/src/search-blocks/class-search-blocks.php +++ b/projects/packages/search/src/search-blocks/class-search-blocks.php @@ -168,7 +168,6 @@ public static function init() { add_action( 'init', array( static::class, 'register_blocks' ) ); add_filter( 'block_categories_all', array( static::class, 'register_block_category' ) ); add_action( 'enqueue_block_editor_assets', array( static::class, 'enqueue_editor_assets' ) ); - add_action( 'rest_api_init', array( static::class, 'register_rest_routes' ) ); Custom_Taxonomy_Slot_Mapping::init(); // FSE block-template rendering runs *before* `wp_head()` (see // `wp-includes/template-canvas.php`), so blocks would resolve @@ -693,51 +692,6 @@ public static function register_block_category( $categories ) { return $categories; } - /** - * Register editor-facing REST routes that back the search blocks' inspector - * controls. Currently exposes the site's static-filter configuration so - * the `jetpack-search/filter-static` block's filter picker can list what - * the host site has wired up via `jetpack_search_static_filters` without - * needing the editor bundle to localize a snapshot. - */ - public static function register_rest_routes() { - register_rest_route( - 'jetpack-search/v1', - '/static-filters', - array( - 'methods' => 'GET', - 'callback' => array( static::class, 'rest_get_static_filters' ), - 'permission_callback' => static function () { - return function_exists( 'current_user_can' ) && current_user_can( 'edit_posts' ); - }, - 'args' => array( - 'variation' => array( - 'type' => 'string', - 'enum' => array( 'sidebar', 'tabbed' ), - 'default' => 'sidebar', - 'sanitize_callback' => 'sanitize_key', - // Without an explicit validate_callback the enum check - // never fires — REST routes only run arg validation - // when this is wired up. `rest_validate_request_arg` - // is core's general-purpose dispatcher. - 'validate_callback' => 'rest_validate_request_arg', - ), - ), - ) - ); - } - - /** - * REST callback: return the site-configured static filters for a given - * variation. Used by the filter-static block's editor inspector. - * - * @param \WP_REST_Request $request REST request. - * @return array> - */ - public static function rest_get_static_filters( \WP_REST_Request $request ) { - return Filter_Static::filters_for_variation( (string) $request->get_param( 'variation' ) ); - } - /** * Register all search blocks from their block.json files. */ diff --git a/projects/packages/search/tests/php/Filter_Static_Test.php b/projects/packages/search/tests/php/Filter_Static_Test.php index 864644165aa..eaa473b5f98 100644 --- a/projects/packages/search/tests/php/Filter_Static_Test.php +++ b/projects/packages/search/tests/php/Filter_Static_Test.php @@ -8,40 +8,21 @@ namespace Automattic\Jetpack\Search; use Automattic\Jetpack\Search\TestCase as Search_TestCase; -use WP_REST_Request; -use WP_REST_Server; /** * Tests for the Filter_Static helpers that back the - * `jetpack-search/filter-static` block. Extends Search_TestCase so the - * REST-endpoint cases can dispatch through a real WP_REST_Server with - * admin/editor users; the helper-only cases pay only a small WorDBless - * setup cost. + * `jetpack-search/filter-static` block. */ class Filter_Static_Test extends Search_TestCase { - /** - * REST Server, populated in setUp so REST-endpoint cases can dispatch. - * - * @var WP_REST_Server|null - */ - protected $server; - /** * Reset the per-request memo before each test so registrations from a - * prior case don't leak into the next. Wire up a real REST server so - * the endpoint cases can dispatch. + * prior case don't leak into the next. */ public function setUp(): void { parent::setUp(); Filter_Static::reset_cache_for_testing(); $_GET = array(); - - global $wp_rest_server; - $wp_rest_server = new WP_REST_Server(); - $this->server = $wp_rest_server; - add_action( 'rest_api_init', array( Search_Blocks::class, 'register_rest_routes' ) ); - do_action( 'rest_api_init' ); } /** @@ -50,12 +31,10 @@ public function setUp(): void { * Filter_Static reads. */ public function tearDown(): void { - remove_action( 'rest_api_init', array( Search_Blocks::class, 'register_rest_routes' ) ); remove_all_filters( 'jetpack_search_static_filters' ); remove_all_filters( 'jetpack_instant_search_options' ); remove_all_filters( 'doing_it_wrong_trigger_error' ); - $_GET = array(); - $this->server = null; + $_GET = array(); Filter_Static::reset_cache_for_testing(); parent::tearDown(); } @@ -616,133 +595,4 @@ static function ( $list ) { $this->assertSame( 'Blocks-side override', $configs[0]['name'] ); $this->assertSame( 'b', $configs[0]['values'][0]['value'] ); } - - /** - * The editor REST endpoint surfaces the configured filters to authors so - * they can pick one in the block inspector. Editors (and anything with - * `edit_posts`) should be allowed; logged-out visitors must not be. - */ - public function test_rest_endpoint_requires_edit_posts_capability() { - wp_set_current_user( 0 ); - $request = new WP_REST_Request( 'GET', '/jetpack-search/v1/static-filters' ); - $response = $this->server->dispatch( $request ); - $this->assertSame( 401, $response->get_status(), 'Anonymous request should be unauthorized.' ); - } - - /** - * A logged-in subscriber lacks `edit_posts` — REST should distinguish - * "not authenticated" (401) from "authenticated but not authorised" - * (403). The error-status accuracy matters for any caller doing - * differentiated UI fallbacks. - */ - public function test_rest_endpoint_forbids_user_without_edit_posts() { - $subscriber_id = wp_insert_user( - array( - 'user_login' => 'dummy_subscriber', - 'user_pass' => 'dummy_pass_subscriber', - 'role' => 'subscriber', - ) - ); - wp_set_current_user( $subscriber_id ); - $request = new WP_REST_Request( 'GET', '/jetpack-search/v1/static-filters' ); - $response = $this->server->dispatch( $request ); - $this->assertSame( 403, $response->get_status(), 'Subscriber without edit_posts should be forbidden.' ); - } - - /** - * Authenticated editors get the configured filters back, scoped to the - * requested variation. - */ - public function test_rest_endpoint_returns_filters_for_authenticated_editor() { - add_filter( - 'jetpack_search_static_filters', - static function () { - return array( - array( - 'filter_id' => 'section', - 'name' => 'Section', - 'variation' => 'sidebar', - 'values' => array( - array( - 'name' => 'News', - 'value' => 'news', - ), - ), - ), - array( - 'filter_id' => 'audience', - 'name' => 'Audience', - 'variation' => 'tabbed', - 'values' => array( - array( - 'name' => 'Devs', - 'value' => 'dev', - ), - ), - ), - ); - } - ); - wp_set_current_user( $this->editor_id ); - - $request = new WP_REST_Request( 'GET', '/jetpack-search/v1/static-filters' ); - $request->set_query_params( array( 'variation' => 'sidebar' ) ); - $response = $this->server->dispatch( $request ); - - $this->assertSame( 200, $response->get_status() ); - $data = $response->get_data(); - $this->assertCount( 1, $data, 'Should return only filters whose variation matches.' ); - $this->assertSame( 'section', $data[0]['filter_id'] ); - } - - /** - * The `variation` arg is enum-validated against `sidebar` / `tabbed`. A - * value that survives `sanitize_key` but isn't a member of the enum - * (e.g. `'invalid'`) must be rejected so the parameter typing surfaces - * misconfiguration to the caller rather than silently coercing it. - * - * (Garbage values that fail sanitize_key — e.g. `'sidebar; DROP TABLE'` - * — are coerced first and may end up matching the enum; that's the - * intentional defence-in-depth ordering of sanitize_callback before - * arg validation.) - */ - public function test_rest_endpoint_rejects_invalid_variation() { - wp_set_current_user( $this->admin_id ); - $request = new WP_REST_Request( 'GET', '/jetpack-search/v1/static-filters' ); - $request->set_query_params( array( 'variation' => 'invalid' ) ); - $response = $this->server->dispatch( $request ); - $this->assertSame( 400, $response->get_status() ); - } - - /** - * Default `variation` is `sidebar` when the param is omitted — matches - * the block.json attribute default so the editor inspector's initial - * dropdown state and the REST round-trip agree. - */ - public function test_rest_endpoint_defaults_variation_to_sidebar() { - add_filter( - 'jetpack_search_static_filters', - static function () { - return array( - array( - 'filter_id' => 'section', - 'name' => 'Section', - 'variation' => 'sidebar', - 'values' => array( - array( - 'name' => 'News', - 'value' => 'news', - ), - ), - ), - ); - } - ); - wp_set_current_user( $this->admin_id ); - $request = new WP_REST_Request( 'GET', '/jetpack-search/v1/static-filters' ); - $response = $this->server->dispatch( $request ); - $this->assertSame( 200, $response->get_status() ); - $this->assertCount( 1, $response->get_data() ); - $this->assertSame( 'section', $response->get_data()[0]['filter_id'] ); - } }