hotfix 1.1.1: 2meet-data-optimizer — keep private fields out of the public listings REST endpoints #5
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: 2meet Data Optimizer
|
* Plugin Name: 2meet Data Optimizer
|
||||||
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
* Plugin URI: https://2meet.io/2meet-data-optimizer
|
||||||
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
* Description: 通用 WordPress 反 EAV 引擎:將 postmeta / usermeta / termmeta / commentmeta 自動分流至四象限扁平表(Hot / Warm / Cold / Archive),大幅提升搜尋與篩選效能。從 wp-data-optimizer v2.16.0 提煉的純核心,整合層交給 11 個 AddOn。
|
||||||
* Version: 1.1.0
|
* Version: 1.1.1
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Tested up to: 6.9.4
|
* Tested up to: 6.9.4
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Constants ──────────────────────────────────────────────────────────────
|
// ── Constants ──────────────────────────────────────────────────────────────
|
||||||
define( 'TMDO_VERSION', '1.1.0' );
|
define( 'TMDO_VERSION', '1.1.1' );
|
||||||
define( 'TMDO_DB_VERSION', '2.1.0' );
|
define( 'TMDO_DB_VERSION', '2.1.0' );
|
||||||
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
define( 'TMDO_PATH', plugin_dir_path( __FILE__ ) );
|
||||||
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
define( 'TMDO_URL', plugin_dir_url( __FILE__ ) );
|
||||||
|
|||||||
@@ -7,6 +7,28 @@ Versioning follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.1.1] — 2026-09-26 — Security:公開 REST 不再回傳私有欄位
|
||||||
|
|
||||||
|
**性質**:安全修正(Critical)。1.0.x 起即存在;1.1.0 zip 已撤回(`dist/withdrawn/`)。
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **公開的 `GET /wpdo/v1/listings`、`/wpdo/v1/listings/{id}` 會把所有已註冊的 hot / cold
|
||||||
|
欄位回傳給未登入者**:`TMDO_Schema_Registry::register()` 的 `show_in_rest` 預設為 true,
|
||||||
|
而 addon 都沒有設定它。dev36 實測可取得 infocards 的 `tmeetic_ig_access_token_enc`、
|
||||||
|
`tmeetic_ig_token_expiry`、`tmeetic_ig_user_id`,以及 WooCommerce 的 `_purchase_note`。
|
||||||
|
- `show_in_rest` 預設改為 **false**:要公開的欄位必須明確宣告。
|
||||||
|
- 受保護的 meta key(底線開頭)與名稱像憑證的 key(token、secret、password、api key、
|
||||||
|
private key、credential、salt、nonce)即使宣告公開也一律不輸出
|
||||||
|
(`TMDO_Schema_Registry::rest_exposable()`)。
|
||||||
|
- Zone A 列表路徑在沒有任何公開欄位時不再回傳整列(fail-closed);篩選參數與 orderby
|
||||||
|
只接受公開欄位,避免藉由篩選 / 排序推測隱藏值。
|
||||||
|
- **相容性**:依賴預設公開的欄位,現在需在註冊時加上 `'show_in_rest' => true`,或使用
|
||||||
|
既有的 `tmdo_rest_listing_visible_fields` filter。
|
||||||
|
- 測試:`RestApiTest` 新增 3 個;live suite `rest`(未登入 GET 不得出現私有 / 憑證類 key)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.1.0] — 2026-09-26 — HivePress / WooCommerce 搜尋索引模式(hardening 2.1)
|
## [1.1.0] — 2026-09-26 — HivePress / WooCommerce 搜尋索引模式(hardening 2.1)
|
||||||
|
|
||||||
**性質**:資料正確性修正 + 查詢改寫補強。無 schema 變更。決策與設計見
|
**性質**:資料正確性修正 + 查詢改寫補強。無 schema 變更。決策與設計見
|
||||||
|
|||||||
@@ -1396,8 +1396,13 @@ class TMDO_REST_API {
|
|||||||
): WP_REST_Response {
|
): WP_REST_Response {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
|
|
||||||
|
// Filters and ordering only on public columns: hidden values must not be
|
||||||
|
// inferable by filtering or sorting on them.
|
||||||
$table = TMDO_Zone_Hot::table( $post_type );
|
$table = TMDO_Zone_Hot::table( $post_type );
|
||||||
$columns = TMDO_Schema_Registry::instance()->get_hot_columns( $post_type );
|
$columns = array_intersect_key(
|
||||||
|
TMDO_Schema_Registry::instance()->get_hot_columns( $post_type ),
|
||||||
|
array_flip( TMDO_Schema_Registry::instance()->get_rest_visible_hot_columns( $post_type ) )
|
||||||
|
);
|
||||||
|
|
||||||
// Build WHERE clauses from numeric filter params.
|
// Build WHERE clauses from numeric filter params.
|
||||||
$where = array();
|
$where = array();
|
||||||
@@ -1443,8 +1448,8 @@ class TMDO_REST_API {
|
|||||||
'post_type' => $post_type,
|
'post_type' => $post_type,
|
||||||
);
|
);
|
||||||
unset( $row['post_id'], $row['updated_at'] );
|
unset( $row['post_id'], $row['updated_at'] );
|
||||||
// Keep only columns with show_in_rest => true (or all if no registry entry).
|
// Keep only public columns (none when nothing is declared public).
|
||||||
$row = $visible_cols ? array_intersect_key( $row, $visible_cols ) : $row;
|
$row = array_intersect_key( $row, $visible_cols );
|
||||||
return array_merge( $item, $row );
|
return array_merge( $item, $row );
|
||||||
},
|
},
|
||||||
$rows
|
$rows
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ class TMDO_Schema_Registry {
|
|||||||
* - ttl (int) TTL in seconds (Zone B only, null = no expiry).
|
* - ttl (int) TTL in seconds (Zone B only, null = no expiry).
|
||||||
* - cache_group (string) Object cache group (Zone C only).
|
* - cache_group (string) Object cache group (Zone C only).
|
||||||
* - cache_ttl (int) Cache TTL in seconds (Zone C only).
|
* - cache_ttl (int) Cache TTL in seconds (Zone C only).
|
||||||
* - show_in_rest (bool) Whether to expose via GET /wpdo/v1/listings. Default true.
|
* - show_in_rest (bool) Whether to expose via the public GET /wpdo/v1/listings
|
||||||
|
* endpoints. Default false; protected (underscore) keys and
|
||||||
|
* credential-like keys are never exposed (see rest_exposable()).
|
||||||
*/
|
*/
|
||||||
public function register( string $provider, array $config ): void {
|
public function register( string $provider, array $config ): void {
|
||||||
$config = wp_parse_args(
|
$config = wp_parse_args(
|
||||||
@@ -120,7 +122,7 @@ class TMDO_Schema_Registry {
|
|||||||
'cache_group' => '',
|
'cache_group' => '',
|
||||||
'cache_ttl' => HOUR_IN_SECONDS,
|
'cache_ttl' => HOUR_IN_SECONDS,
|
||||||
'provider' => $provider,
|
'provider' => $provider,
|
||||||
'show_in_rest' => true,
|
'show_in_rest' => false,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -276,6 +278,22 @@ class TMDO_Schema_Registry {
|
|||||||
return array_unique( $this->cold_fields[ $post_type ] ?? array() );
|
return array_unique( $this->cold_fields[ $post_type ] ?? array() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a field may appear in the public listings endpoints: declared
|
||||||
|
* show_in_rest, not a protected meta key (leading underscore — WordPress
|
||||||
|
* hides those from public output, e.g. _purchase_note), and not named like a
|
||||||
|
* credential (tokens, secrets, passwords, API keys), whatever it declares.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $field Registered field.
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
private static function rest_exposable( array $field ): bool {
|
||||||
|
$key = (string) ( $field['meta_key'] ?? '' );
|
||||||
|
return ! empty( $field['show_in_rest'] )
|
||||||
|
&& 0 !== strpos( $key, '_' )
|
||||||
|
&& 1 !== preg_match( '/token|secret|passw|api_?key|private_?key|credential|salt|nonce/i', $key . ' ' . ( $field['column'] ?? '' ) );
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns hot column names for a post type where show_in_rest is true.
|
* Returns hot column names for a post type where show_in_rest is true.
|
||||||
* Used by REST endpoints to restrict publicly exposed fields.
|
* Used by REST endpoints to restrict publicly exposed fields.
|
||||||
@@ -286,7 +304,7 @@ class TMDO_Schema_Registry {
|
|||||||
public function get_rest_visible_hot_columns( string $post_type ): array {
|
public function get_rest_visible_hot_columns( string $post_type ): array {
|
||||||
$visible = array();
|
$visible = array();
|
||||||
foreach ( $this->fields as $field ) {
|
foreach ( $this->fields as $field ) {
|
||||||
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
|
if ( 'hot' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
|
||||||
$visible[] = $field['column'];
|
$visible[] = $field['column'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,7 +321,7 @@ class TMDO_Schema_Registry {
|
|||||||
public function get_rest_visible_cold_keys( string $post_type ): array {
|
public function get_rest_visible_cold_keys( string $post_type ): array {
|
||||||
$visible = array();
|
$visible = array();
|
||||||
foreach ( $this->fields as $field ) {
|
foreach ( $this->fields as $field ) {
|
||||||
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && ! empty( $field['show_in_rest'] ) ) {
|
if ( 'cold' === $field['zone'] && $field['post_type'] === $post_type && self::rest_exposable( $field ) ) {
|
||||||
$visible[] = $field['meta_key'];
|
$visible[] = $field['meta_key'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -4,7 +4,7 @@ Tags: performance, database, postmeta, optimization, hivepress
|
|||||||
Requires at least: 6.0
|
Requires at least: 6.0
|
||||||
Tested up to: 6.9
|
Tested up to: 6.9
|
||||||
Requires PHP: 8.1
|
Requires PHP: 8.1
|
||||||
Stable tag: 1.1.0
|
Stable tag: 1.1.1
|
||||||
License: GPLv2 or later
|
License: GPLv2 or later
|
||||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
|
||||||
@@ -107,6 +107,10 @@ The Classifier tab analyzes your existing `wp_postmeta` data (access frequency,
|
|||||||
|
|
||||||
== Changelog ==
|
== Changelog ==
|
||||||
|
|
||||||
|
= 1.1.1 =
|
||||||
|
* Security: the public GET /wpdo/v1/listings endpoints returned every registered field (show_in_rest defaulted to true), including private values such as integration tokens and WooCommerce purchase notes. Fields are now private unless registered with show_in_rest => true, and protected (underscore) or credential-like keys are never exposed
|
||||||
|
* Security: the listings endpoint no longer returns whole rows when no field is public, and only filters / sorts on public columns
|
||||||
|
|
||||||
= 1.1.0 =
|
= 1.1.0 =
|
||||||
* Search-index mode for HivePress / WooCommerce sites (docs/adr-004): native meta stays the source of truth, flat tables are its search index
|
* Search-index mode for HivePress / WooCommerce sites (docs/adr-004): native meta stays the source of truth, flat tables are its search index
|
||||||
* Promotion Guard: aeav_only and cleanup are refused while HivePress or WooCommerce is active (filter `tmdo_promotion_guard_allow`)
|
* Promotion Guard: aeav_only and cleanup are refused while HivePress or WooCommerce is active (filter `tmdo_promotion_guard_allow`)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
'zone' => 'hot',
|
'zone' => 'hot',
|
||||||
'column' => 'rp_price',
|
'column' => 'rp_price',
|
||||||
'type' => 'decimal',
|
'type' => 'decimal',
|
||||||
|
'show_in_rest' => true,
|
||||||
] );
|
] );
|
||||||
$registry->register( 'integration_rest', [
|
$registry->register( 'integration_rest', [
|
||||||
'post_type' => 'restapi',
|
'post_type' => 'restapi',
|
||||||
@@ -40,11 +41,21 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
'zone' => 'hot',
|
'zone' => 'hot',
|
||||||
'column' => 'rp_featured',
|
'column' => 'rp_featured',
|
||||||
'type' => 'tinyint',
|
'type' => 'tinyint',
|
||||||
|
'show_in_rest' => true,
|
||||||
] );
|
] );
|
||||||
$registry->register( 'integration_rest', [
|
$registry->register( 'integration_rest', [
|
||||||
'post_type' => 'restapi',
|
'post_type' => 'restapi',
|
||||||
'meta_key' => 'rp_description',
|
'meta_key' => 'rp_description',
|
||||||
'zone' => 'cold',
|
'zone' => 'cold',
|
||||||
|
'show_in_rest' => true,
|
||||||
|
] );
|
||||||
|
// Registered but not public (1.1.1: fields are private by default).
|
||||||
|
$registry->register( 'integration_rest', [
|
||||||
|
'post_type' => 'restapi',
|
||||||
|
'meta_key' => 'rp_cost',
|
||||||
|
'zone' => 'hot',
|
||||||
|
'column' => 'rp_cost',
|
||||||
|
'type' => 'decimal',
|
||||||
] );
|
] );
|
||||||
|
|
||||||
// Create hot table.
|
// Create hot table.
|
||||||
@@ -53,6 +64,7 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
post_id BIGINT UNSIGNED NOT NULL,
|
post_id BIGINT UNSIGNED NOT NULL,
|
||||||
rp_price DECIMAL(10,2) DEFAULT NULL,
|
rp_price DECIMAL(10,2) DEFAULT NULL,
|
||||||
rp_featured TINYINT(1) DEFAULT NULL,
|
rp_featured TINYINT(1) DEFAULT NULL,
|
||||||
|
rp_cost DECIMAL(10,2) DEFAULT NULL,
|
||||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (post_id)
|
PRIMARY KEY (post_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
||||||
@@ -91,7 +103,8 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
$featured = $i % 2;
|
$featured = $i % 2;
|
||||||
$status = $statuses[ $i ] ?? 'publish';
|
$status = $statuses[ $i ] ?? 'publish';
|
||||||
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES ($i, 'restapi', '$status')" );
|
$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)" );
|
$cost = 1000 - $price;
|
||||||
|
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured, rp_cost) VALUES ($i, $price, $featured, $cost)" );
|
||||||
}
|
}
|
||||||
$wpdb->query( "INSERT INTO `{$wpdb->posts}` (ID, post_type, post_status) VALUES (9, 'post', 'publish')" );
|
$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)" );
|
$wpdb->query( "INSERT INTO `" . self::$hot_table . "` (post_id, rp_price, rp_featured) VALUES (9, 900, 1)" );
|
||||||
@@ -194,6 +207,33 @@ class RestApiIntegrationTest extends TestCase {
|
|||||||
$this->assertTrue( true );
|
$this->assertTrue( true );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_listings_never_return_a_private_column(): void {
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'restapi' );
|
||||||
|
|
||||||
|
foreach ( self::$api->get_listings( $req )->get_data() as $item ) {
|
||||||
|
$this->assertArrayNotHasKey( 'rp_cost', $item );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_private_column_cannot_be_used_as_a_filter(): void {
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'restapi' );
|
||||||
|
$req->set_param( 'rp_cost_min', 800 );
|
||||||
|
|
||||||
|
$this->assertSame( '5', self::$api->get_listings( $req )->get_headers()['X-WP-Total'], 'filter on a private column is ignored' );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_private_column_cannot_be_used_for_ordering(): void {
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
|
$req->set_param( 'post_type', 'restapi' );
|
||||||
|
$req->set_param( 'orderby', 'rp_cost' );
|
||||||
|
$req->set_param( 'order', 'ASC' );
|
||||||
|
|
||||||
|
// rp_cost descends as post_id rises; ignoring it falls back to post_id ASC.
|
||||||
|
$this->assertSame( array( 1, 2, 3, 4, 5 ), array_column( self::$api->get_listings( $req )->get_data(), 'id' ) );
|
||||||
|
}
|
||||||
|
|
||||||
public function test_listings_filter_price_min(): void {
|
public function test_listings_filter_price_min(): void {
|
||||||
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
||||||
$req->set_param( 'post_type', 'restapi' );
|
$req->set_param( 'post_type', 'restapi' );
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ Exit code 0 when every test passed, 1 otherwise. Output per test:
|
|||||||
| `serialized` | array meta values are stored serialized in flat tables, never "Array" |
|
| `serialized` | array meta values are stored serialized in flat tables, never "Array" |
|
||||||
| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) |
|
| `resync` | dual_write / shadow_read: flat follows native after every write (4 entities × 2 modes: update, same value, delete, refused writes, stale flat, key rename by meta_id) |
|
||||||
| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route, meta orderby: HivePress sort shape, named clause, meta_key + meta_value_num); routed SQL has no GROUP BY |
|
| `router` | a routed WP_Query returns the same ids, in order, as native (OR, several post types, untyped numeric compares, named orderby, LIKE, keys of two groups, NOT EXISTS with the status/date copy route, meta orderby: HivePress sort shape, named clause, meta_key + meta_value_num); routed SQL has no GROUP BY |
|
||||||
|
| `rest` | anonymous GET of the public listings endpoints returns no protected or credential-like field (1.1.1) |
|
||||||
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
|
| `zero_side_effect` | zone tables that were never created are never queried on save / delete |
|
||||||
|
|
||||||
## Tools (`tools/`)
|
## Tools (`tools/`)
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Suite: the public listings endpoints never return private fields to anonymous users.
|
||||||
|
*
|
||||||
|
* Bug (dev36, 2026-09-26, fixed in 1.1.1): GET /wpdo/v1/listings/{id} is public and
|
||||||
|
* returned every registered hot / cold field, because show_in_rest defaulted to true —
|
||||||
|
* e.g. the infocards IG token fields on hp_vendor and WooCommerce's _purchase_note.
|
||||||
|
*
|
||||||
|
* @package WP_Data_Optimizer
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET a wpdo REST route as an anonymous visitor.
|
||||||
|
*
|
||||||
|
* @param string $route Route, e.g. /wpdo/v1/listings/12.
|
||||||
|
* @param array<string, mixed> $params Query parameters.
|
||||||
|
* @return array<int|string, mixed> Response data.
|
||||||
|
*/
|
||||||
|
function tmdo_live_rest_anonymous( string $route, array $params = array() ): array {
|
||||||
|
$user = get_current_user_id();
|
||||||
|
$request = new WP_REST_Request( 'GET', $route );
|
||||||
|
$request->set_query_params( $params );
|
||||||
|
wp_set_current_user( 0 );
|
||||||
|
try {
|
||||||
|
$response = rest_do_request( $request );
|
||||||
|
} finally {
|
||||||
|
wp_set_current_user( $user );
|
||||||
|
}
|
||||||
|
tmdo_live_assert_same( 200, $response->get_status(), "GET {$route}" );
|
||||||
|
return (array) $response->get_data();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys that must never reach an anonymous visitor.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
function tmdo_live_rest_private_keys(): array {
|
||||||
|
return array( 'tmeetic_ig_access_token_enc', 'tmeetic_ig_token_expiry', 'tmeetic_ig_user_id', '_purchase_note' );
|
||||||
|
}
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'GET /wpdo/v1/listings/{id} returns no private field to an anonymous visitor',
|
||||||
|
static function () {
|
||||||
|
$checked = 0;
|
||||||
|
foreach ( array( 'hp_vendor', 'product' ) as $post_type ) {
|
||||||
|
$ids = get_posts( array( 'post_type' => $post_type, 'post_status' => 'publish', 'numberposts' => 1, 'fields' => 'ids' ) );
|
||||||
|
if ( ! $ids ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$data = tmdo_live_rest_anonymous( '/wpdo/v1/listings/' . (int) $ids[0] );
|
||||||
|
tmdo_live_assert_same( array(), array_values( array_intersect( tmdo_live_rest_private_keys(), array_keys( $data ) ) ), "{$post_type} #{$ids[0]} private keys" );
|
||||||
|
tmdo_live_assert_same( array(), array_values( preg_grep( '/^_|token|secret|passw|api_?key/i', array_keys( $data ) ) ), "{$post_type} #{$ids[0]} protected / credential-like keys" );
|
||||||
|
++$checked;
|
||||||
|
}
|
||||||
|
if ( 0 === $checked ) {
|
||||||
|
throw new TMDO_Live_Skip( 'no published hp_vendor or product on this site' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
TMDO_Live::test(
|
||||||
|
'GET /wpdo/v1/listings returns no private field to an anonymous visitor',
|
||||||
|
static function () {
|
||||||
|
foreach ( array( 'hp_vendor', 'product', 'hp_listing' ) as $post_type ) {
|
||||||
|
if ( ! post_type_exists( $post_type ) ) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ( tmdo_live_rest_anonymous( '/wpdo/v1/listings', array( 'post_type' => $post_type, 'per_page' => 5 ) ) as $item ) {
|
||||||
|
tmdo_live_assert_same( array(), array_values( preg_grep( '/^_|token|secret|passw|api_?key/i', array_keys( (array) $item ) ) ), "{$post_type} list item keys" );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -95,11 +95,13 @@ class RestApiTest extends TestCase {
|
|||||||
'zone' => 'hot',
|
'zone' => 'hot',
|
||||||
'column' => 'hp_price',
|
'column' => 'hp_price',
|
||||||
'type' => 'decimal',
|
'type' => 'decimal',
|
||||||
|
'show_in_rest' => true,
|
||||||
] );
|
] );
|
||||||
$registry->register( 'test', [
|
$registry->register( 'test', [
|
||||||
'post_type' => 'hp_listing',
|
'post_type' => 'hp_listing',
|
||||||
'meta_key' => 'hp_description',
|
'meta_key' => 'hp_description',
|
||||||
'zone' => 'cold',
|
'zone' => 'cold',
|
||||||
|
'show_in_rest' => true,
|
||||||
] );
|
] );
|
||||||
|
|
||||||
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' );
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' );
|
||||||
@@ -115,6 +117,62 @@ class RestApiTest extends TestCase {
|
|||||||
$this->assertSame( 'Test desc', $data['hp_description'] );
|
$this->assertSame( 'Test desc', $data['hp_description'] );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── public field exposure (1.1.1) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register fields on hp_listing #43 and GET it anonymously.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $fields Field configs (post_type added).
|
||||||
|
* @param array<string, string> $meta Stored meta.
|
||||||
|
* @return array<string, mixed> Response data.
|
||||||
|
*/
|
||||||
|
private function listing_with( array $fields, array $meta ): array {
|
||||||
|
$GLOBALS['_wp_post_types'][43] = 'hp_listing';
|
||||||
|
$GLOBALS['_wp_postmeta'][43] = $meta;
|
||||||
|
foreach ( $fields as $field ) {
|
||||||
|
WPDO_Schema_Registry::instance()->register( 'test', $field + array( 'post_type' => 'hp_listing' ) );
|
||||||
|
}
|
||||||
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/43' );
|
||||||
|
$req->set_param( 'id', 43 );
|
||||||
|
return $this->api->get_listing( $req )->get_data();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_registered_fields_are_not_public_unless_declared(): void {
|
||||||
|
$data = $this->listing_with(
|
||||||
|
array(
|
||||||
|
array( 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price' ),
|
||||||
|
array( 'meta_key' => 'hp_notes', 'zone' => 'cold' ),
|
||||||
|
),
|
||||||
|
array( 'hp_price' => '500', 'hp_notes' => 'internal' )
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame( array( 'id', 'post_type' ), array_keys( $data ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_protected_and_credential_keys_stay_private_even_when_declared_public(): void {
|
||||||
|
$data = $this->listing_with(
|
||||||
|
array(
|
||||||
|
array( 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ),
|
||||||
|
array( 'meta_key' => '_purchase_note', 'zone' => 'cold', 'show_in_rest' => true ),
|
||||||
|
array( 'meta_key' => 'tmeetic_ig_access_token_enc', 'zone' => 'cold', 'show_in_rest' => true ),
|
||||||
|
array( 'meta_key' => 'hp_api_key', 'zone' => 'hot', 'column' => 'hp_api_key', 'show_in_rest' => true ),
|
||||||
|
array( 'meta_key' => 'hp_client_secret', 'zone' => 'cold', 'show_in_rest' => true ),
|
||||||
|
),
|
||||||
|
array( 'hp_price' => '500', '_purchase_note' => 'x', 'tmeetic_ig_access_token_enc' => 'y', 'hp_api_key' => 'z', 'hp_client_secret' => 'w' )
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame( array( 'id', 'post_type', 'hp_price' ), array_keys( $data ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rest_visible_columns_exclude_protected_and_credential_keys(): void {
|
||||||
|
$registry = WPDO_Schema_Registry::instance();
|
||||||
|
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) );
|
||||||
|
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => '_hp_token', 'zone' => 'hot', 'column' => 'hp_token', 'show_in_rest' => true ) );
|
||||||
|
$registry->register( 'test', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_draft', 'zone' => 'hot', 'column' => 'hp_draft' ) );
|
||||||
|
|
||||||
|
$this->assertSame( array( 'hp_price' ), $registry->get_rest_visible_hot_columns( 'hp_listing' ) );
|
||||||
|
}
|
||||||
|
|
||||||
// ── get_stats ─────────────────────────────────────────────────────────────
|
// ── get_stats ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function test_get_stats_404_when_post_not_found(): void {
|
public function test_get_stats_404_when_post_not_found(): void {
|
||||||
@@ -185,7 +243,7 @@ class RestApiTest extends TestCase {
|
|||||||
|
|
||||||
public function test_listings_fallback_only_returns_show_in_rest_columns(): void {
|
public function test_listings_fallback_only_returns_show_in_rest_columns(): void {
|
||||||
$registry = WPDO_Schema_Registry::instance();
|
$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_price', 'zone' => 'hot', 'column' => 'hp_price', 'show_in_rest' => true ) );
|
||||||
$registry->register( 'unit_rest', array( 'post_type' => 'hp_listing', 'meta_key' => 'hp_secret', 'zone' => 'hot', 'column' => 'hp_secret', 'show_in_rest' => false ) );
|
$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 = new stdClass();
|
||||||
|
|||||||
Reference in New Issue
Block a user