diff --git a/includes/class-tmdo-rest-api.php b/includes/class-tmdo-rest-api.php index e3616b0..439245a 100644 --- a/includes/class-tmdo-rest-api.php +++ b/includes/class-tmdo-rest-api.php @@ -530,6 +530,15 @@ class TMDO_REST_API { */ public function get_listings( WP_REST_Request $request ): WP_REST_Response { $post_type = $request->get_param( 'post_type' ) ?? 'hp_listing'; + if ( ! post_type_exists( $post_type ) || ! is_post_type_viewable( $post_type ) ) { + return new WP_REST_Response( + array( + 'code' => 'rest_invalid_post_type', + 'message' => 'Invalid post type.', + ), + 400 + ); + } // Defense-in-depth: clamp per_page even if validate_callback in listings_args() fails open. // Filter `wpdo_rest_max_per_page` allows site owners to adjust the upper bound. $max_per_page = (int) apply_filters( 'wpdo_rest_max_per_page', 100 ); @@ -1365,6 +1374,10 @@ class TMDO_REST_API { /** * Query Zone A flat table directly. * + * Rows are joined to the posts table so only published posts of the + * requested type are listed (the hot table also holds drafts, private + * and trashed posts). + * * @param string $post_type Post type slug. * @param int $per_page Number of items per page. * @param int $offset Database offset. @@ -1396,6 +1409,8 @@ class TMDO_REST_API { } $where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : ''; + $join_sql = "INNER JOIN `{$wpdb->posts}` p ON p.ID = t.post_id AND p.post_type = %s AND p.post_status = 'publish'"; + array_unshift( $params, $post_type ); // Validate orderby against known columns + post_id. $allowed_orderby = array_merge( array( 'post_id' ), array_keys( $columns ) ); @@ -1404,16 +1419,12 @@ class TMDO_REST_API { // Count total. // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- table from TMDO_Zone_Hot::table(); where_sql/orderby_col/order_dir are sanitized/validated. - if ( $params ) { - $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` {$where_sql}", ...$params ) ); - } else { - $total = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); - } + $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM `{$table}` t {$join_sql} {$where_sql}", ...$params ) ); // Fetch rows. $rows = $wpdb->get_results( $wpdb->prepare( - "SELECT * FROM `{$table}` {$where_sql} ORDER BY `{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d", + "SELECT t.* FROM `{$table}` t {$join_sql} {$where_sql} ORDER BY t.`{$orderby_col}` {$order_dir} LIMIT %d OFFSET %d", ...array_merge( $params, array( $per_page, $offset ) ) ), ARRAY_A @@ -1476,8 +1487,8 @@ class TMDO_REST_API { 'id' => $post->ID, 'post_type' => $post->post_type, ); - $cols = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ); - foreach ( array_keys( $cols ) as $col ) { + // Same show_in_rest restriction as the Zone A path. + foreach ( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) as $col ) { $item[ $col ] = get_post_meta( $post->ID, $col, true ); } $items[] = $item; @@ -1507,7 +1518,7 @@ class TMDO_REST_API { $min = $request->get_param( $col . '_min' ); if ( null !== $min && is_numeric( $min ) ) { $filters[] = array( - 'sql' => "`{$col}` >= %f", + 'sql' => "t.`{$col}` >= %f", 'value' => (float) $min, ); } @@ -1515,7 +1526,7 @@ class TMDO_REST_API { $max = $request->get_param( $col . '_max' ); if ( null !== $max && is_numeric( $max ) ) { $filters[] = array( - 'sql' => "`{$col}` <= %f", + 'sql' => "t.`{$col}` <= %f", 'value' => (float) $max, ); } @@ -1524,12 +1535,12 @@ class TMDO_REST_API { if ( null !== $exact && '' !== $exact ) { if ( is_numeric( $exact ) ) { $filters[] = array( - 'sql' => "`{$col}` = %f", + 'sql' => "t.`{$col}` = %f", 'value' => (float) $exact, ); } else { $filters[] = array( - 'sql' => "`{$col}` = %s", + 'sql' => "t.`{$col}` = %s", 'value' => (string) $exact, ); } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f10be06..8c1440e 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -426,6 +426,18 @@ if ( ! function_exists( 'wp_cache_delete' ) ) { function wp_cache_delete( $key, $group = '' ): bool { unset( $GLOBALS['_wp_cache'][ $group ][ $key ] ); return true; } } +// Registered post types: name => publicly viewable (bool). +if ( ! function_exists( 'post_type_exists' ) ) { + function post_type_exists( string $post_type ): bool { + return isset( $GLOBALS['_wp_registered_post_types'][ $post_type ] ); + } +} +if ( ! function_exists( 'is_post_type_viewable' ) ) { + function is_post_type_viewable( $post_type ): bool { + return ! empty( $GLOBALS['_wp_registered_post_types'][ (string) $post_type ] ); + } +} + // ── Stub classes ─────────────────────────────────────────────────────────── if ( ! class_exists( 'WP_Query' ) ) { @@ -434,7 +446,7 @@ if ( ! class_exists( 'WP_Query' ) ) { public int $found_posts = 0; public int $max_num_pages = 0; private array $args = []; - public function __construct( array $args = [] ) { $this->args = $args; } + public function __construct( array $args = [] ) { $this->args = $args; $this->posts = $GLOBALS['_wp_query_posts'] ?? []; } public function get( string $key, $default = '' ) { return $this->args[ $key ] ?? $default; } public function set( string $key, $value ): void { $this->args[ $key ] = $value; } } diff --git a/tests/integration/RestApiIntegrationTest.php b/tests/integration/RestApiIntegrationTest.php index bc2343a..4580cd2 100644 --- a/tests/integration/RestApiIntegrationTest.php +++ b/tests/integration/RestApiIntegrationTest.php @@ -72,12 +72,29 @@ class RestApiIntegrationTest extends TestCase { ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" ); - // Seed 5 rows. - for ( $i = 1; $i <= 5; $i++ ) { + // Posts table: listings are filtered by post_status against it. + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" ); + $wpdb->query( + "CREATE TABLE `{$wpdb->posts}` ( + ID BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + post_type VARCHAR(20) NOT NULL DEFAULT 'post', + post_status VARCHAR(20) NOT NULL DEFAULT 'publish', + PRIMARY KEY (ID) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4" + ); + + // Seed 5 published rows, plus draft/private/trash rows (ids 6-8) and + // a published post of another type (id 9) that must never be listed. + $statuses = array( 6 => 'draft', 7 => 'private', 8 => 'trash' ); + for ( $i = 1; $i <= 8; $i++ ) { $price = $i * 100; $featured = $i % 2; + $status = $statuses[ $i ] ?? 'publish'; + $wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES ($i, 'restapi', '$status')" ); $wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES ($i, $price, $featured)" ); } + $wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES (9, 'post', 'publish')" ); + $wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES (9, 900, 1)" ); // Set module to cutover so REST API reads from Zone A. WPDO_Feature_Flags::set( 'hot_restapi', 'cutover' ); @@ -87,6 +104,7 @@ class RestApiIntegrationTest extends TestCase { global $wpdb; $wpdb->query( "DROP TABLE IF EXISTS `" . self::$hot_table . "`" ); $wpdb->query( "DROP TABLE IF EXISTS `" . self::$warm_table . "`" ); + $wpdb->query( "DROP TABLE IF EXISTS `{$wpdb->posts}`" ); WPDO_Feature_Flags::reset( 'hot_restapi' ); } @@ -97,6 +115,7 @@ class RestApiIntegrationTest extends TestCase { $GLOBALS['_wp_current_user_can'] = []; $GLOBALS['_wp_valid_nonces'] = []; $GLOBALS['_wp_transients'] = []; // Reset rate-limit transients between tests. + $GLOBALS['_wp_registered_post_types'] = [ 'restapi' => true ]; // Note: do NOT reset _wp_options here — Feature Flags state is stored there. // Invalidate Feature Flags static request cache so each test reads fresh. @@ -232,6 +251,37 @@ class RestApiIntegrationTest extends TestCase { $this->assertSame( $sorted, $prices ); } + public function test_listings_excludes_unpublished_posts(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'per_page', 20 ); + + $response = self::$api->get_listings( $req ); + $ids = array_column( $response->get_data(), 'id' ); + sort( $ids ); + + $this->assertSame( array( 1, 2, 3, 4, 5 ), $ids, 'draft/private/trash and other post types must not be listed.' ); + $this->assertSame( '5', $response->get_headers()['X-WP-Total'] ); + } + + public function test_listings_filters_still_apply_with_status_join(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'restapi' ); + $req->set_param( 'rp_price_min', 400 ); + $req->set_param( 'per_page', 20 ); + + $ids = array_column( self::$api->get_listings( $req )->get_data(), 'id' ); + sort( $ids ); + $this->assertSame( array( 4, 5 ), $ids ); + } + + public function test_listings_rejects_unregistered_post_type(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'nope' ); + + $this->assertSame( 400, self::$api->get_listings( $req )->get_status() ); + } + // ── GET /listings/{id} ─────────────────────────────────────────────────── public function test_get_listing_404_for_unknown_post(): void { diff --git a/tests/integration/bootstrap.php b/tests/integration/bootstrap.php index 4b3a0a3..23616ef 100644 --- a/tests/integration/bootstrap.php +++ b/tests/integration/bootstrap.php @@ -337,6 +337,9 @@ if ( ! function_exists( 'is_post_publicly_viewable' ) ) { return 'publish' === ( $GLOBALS['_wp_post_status'][ (int) $post_id ] ?? 'publish' ); } } +// Registered post types: name => publicly viewable (bool). +if ( ! function_exists( 'post_type_exists' ) ) { function post_type_exists( string $post_type ): bool { return isset( $GLOBALS['_wp_registered_post_types'][ $post_type ] ); } } +if ( ! function_exists( 'is_post_type_viewable' ) ) { function is_post_type_viewable( $post_type ): bool { return ! empty( $GLOBALS['_wp_registered_post_types'][ (string) $post_type ] ); } } if ( ! function_exists( 'current_user_can' ) ) { function current_user_can( string $cap, ...$args ): bool { if ( ! empty( $args ) ) { $key = $cap . ':' . implode( ',', array_map( 'strval', $args ) ); if ( isset( $GLOBALS['_wp_current_user_can'][ $key ] ) ) { return (bool) $GLOBALS['_wp_current_user_can'][ $key ]; } } diff --git a/tests/unit/RestApiTest.php b/tests/unit/RestApiTest.php index a24085c..ede0a39 100644 --- a/tests/unit/RestApiTest.php +++ b/tests/unit/RestApiTest.php @@ -21,6 +21,8 @@ class RestApiTest extends TestCase { $GLOBALS['_wp_post_types'] = []; $GLOBALS['_wp_current_user_can'] = []; $GLOBALS['_wp_valid_nonces'] = []; + $GLOBALS['_wp_query_posts'] = []; + $GLOBALS['_wp_registered_post_types'] = [ 'hp_listing' => true ]; $_COOKIE = []; // Reset Feature Flags cache via reflection. @@ -153,6 +155,42 @@ class RestApiTest extends TestCase { $this->assertIsArray( $response->get_data() ); } + public function test_get_listings_rejects_unregistered_post_type(): void { + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'no_such_type' ); + + $response = $this->api->get_listings( $req ); + $this->assertSame( 400, $response->get_status() ); + } + + public function test_get_listings_rejects_non_viewable_post_type(): void { + $GLOBALS['_wp_registered_post_types']['hp_internal'] = false; + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'hp_internal' ); + + $response = $this->api->get_listings( $req ); + $this->assertSame( 400, $response->get_status() ); + } + + public function test_listings_fallback_only_returns_show_in_rest_columns(): void { + $registry = WPDO_Schema_Registry::instance(); + $registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price' ) ); + $registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_secret', 'zone' => 'hot', 'column' => 'hp_secret', 'show_in_rest' => false ) ); + + $post = new stdClass(); + $post->ID = 21; + $post->post_type = 'hp_listing'; + $GLOBALS['_wp_query_posts'] = array( $post ); + $GLOBALS['_wp_postmeta'][21] = array( 'hp_price' => '99', 'hp_secret' => 'hidden' ); + + $req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' ); + $req->set_param( 'post_type', 'hp_listing' ); + + $data = $this->api->get_listings( $req )->get_data(); + $this->assertSame( '99', $data[0]['hp_price'] ); + $this->assertArrayNotHasKey( 'hp_secret', $data[0] ); + } + // ── Pagination headers ──────────────────────────────────────────────────── public function test_listings_fallback_sets_pagination_headers(): void {