22b46ad175
- blocked POST /listings/{id}/view requests update wpdo_rl_stats 1 in 10,
each sample adding 10: anonymous traffic no longer turns 1:1 into DB writes,
and the admin top list stays representative (unbiased estimate)
- Verify: RestApiTest counts option writes (about 20 for 200 blocked requests),
unit 698, integration 577, phpcs, stan
- PR: PR-094
415 lines
17 KiB
PHP
415 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* Unit tests for WPDO_REST_API.
|
|
*
|
|
* Covers route registration, handler logic, permission callback,
|
|
* filter param extraction, and both zone-active + fallback paths.
|
|
*/
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class RestApiTest extends TestCase {
|
|
|
|
private WPDO_REST_API $api;
|
|
|
|
protected function setUp(): void {
|
|
$this->api = new WPDO_REST_API();
|
|
|
|
// Reset globals.
|
|
$GLOBALS['_wp_options'] = [];
|
|
$GLOBALS['_wp_postmeta'] = [];
|
|
$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.
|
|
$ff_ref = new ReflectionClass( WPDO_Feature_Flags::class );
|
|
$ff_prop = $ff_ref->getProperty( 'cache' );
|
|
$ff_prop->setAccessible( true );
|
|
$ff_prop->setValue( null, null );
|
|
|
|
// Reset Schema Registry singleton.
|
|
$ref = new ReflectionClass( WPDO_Schema_Registry::class );
|
|
$prop = $ref->getProperty( 'instance' );
|
|
$prop->setAccessible( true );
|
|
$prop->setValue( null, null );
|
|
}
|
|
|
|
// ── Route registration ────────────────────────────────────────────────────
|
|
|
|
public function test_register_routes_calls_register_rest_route(): void {
|
|
// register_rest_route is stubbed to return true — just confirm no exception.
|
|
$this->api->register_routes();
|
|
$this->assertTrue( true );
|
|
}
|
|
|
|
// ── Permission callback ───────────────────────────────────────────────────
|
|
|
|
public function test_require_manage_options_false_when_not_admin(): void {
|
|
$GLOBALS['_wp_current_user_can']['manage_options'] = false;
|
|
$this->assertFalse( $this->api->require_manage_options() );
|
|
}
|
|
|
|
public function test_require_manage_options_true_when_admin(): void {
|
|
$GLOBALS['_wp_current_user_can']['manage_options'] = true;
|
|
$this->assertTrue( $this->api->require_manage_options() );
|
|
}
|
|
|
|
// ── get_status ────────────────────────────────────────────────────────────
|
|
|
|
public function test_get_status_returns_version_and_engine(): void {
|
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
|
|
$response = $this->api->get_status( $req );
|
|
|
|
$this->assertSame( 200, $response->get_status() );
|
|
$data = $response->get_data();
|
|
$this->assertSame( WPDO_VERSION, $data['version'] );
|
|
$this->assertSame( 'mysql', $data['engine'] );
|
|
$this->assertArrayHasKey( 'fields', $data );
|
|
$this->assertArrayHasKey( 'modules', $data );
|
|
}
|
|
|
|
// ── get_listing (single) ──────────────────────────────────────────────────
|
|
|
|
public function test_get_listing_404_when_post_not_found(): void {
|
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/9999' );
|
|
$req->set_param( 'id', 9999 );
|
|
|
|
$response = $this->api->get_listing( $req );
|
|
$this->assertSame( 404, $response->get_status() );
|
|
}
|
|
|
|
public function test_get_listing_returns_postmeta_when_zones_idle(): void {
|
|
$GLOBALS['_wp_post_types'][42] = 'hp_listing';
|
|
$GLOBALS['_wp_postmeta'][42]['hp_price'] = '500';
|
|
$GLOBALS['_wp_postmeta'][42]['hp_description'] = 'Test desc';
|
|
|
|
// Register hot + cold fields.
|
|
$registry = WPDO_Schema_Registry::instance();
|
|
$registry->register( 'test', [
|
|
'post_type' => 'hp_listing',
|
|
'meta_key' => 'hp_price',
|
|
'zone' => 'hot',
|
|
'column' => 'hp_price',
|
|
'type' => 'decimal',
|
|
'show_in_rest' => true,
|
|
] );
|
|
$registry->register( 'test', [
|
|
'post_type' => 'hp_listing',
|
|
'meta_key' => 'hp_description',
|
|
'zone' => 'cold',
|
|
'show_in_rest' => true,
|
|
] );
|
|
|
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings/42' );
|
|
$req->set_param( 'id', 42 );
|
|
|
|
$response = $this->api->get_listing( $req );
|
|
$this->assertSame( 200, $response->get_status() );
|
|
|
|
$data = $response->get_data();
|
|
$this->assertSame( 42, $data['id'] );
|
|
$this->assertSame( 'hp_listing', $data['post_type'] );
|
|
$this->assertSame( '500', $data['hp_price'] );
|
|
$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 ─────────────────────────────────────────────────────────────
|
|
|
|
public function test_get_stats_404_when_post_not_found(): void {
|
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/9999' );
|
|
$req->set_param( 'id', 9999 );
|
|
|
|
$response = $this->api->get_stats( $req );
|
|
$this->assertSame( 404, $response->get_status() );
|
|
}
|
|
|
|
public function test_get_stats_returns_view_count(): void {
|
|
$GLOBALS['_wp_post_types'][55] = 'hp_listing';
|
|
// Warm zone idle, falls back to postmeta.
|
|
$GLOBALS['_wp_postmeta'][55]['hp_view_count'] = '17';
|
|
|
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/stats/55' );
|
|
$req->set_param( 'id', 55 );
|
|
|
|
$response = $this->api->get_stats( $req );
|
|
$this->assertSame( 200, $response->get_status() );
|
|
|
|
$data = $response->get_data();
|
|
$this->assertSame( 55, $data['post_id'] );
|
|
$this->assertIsInt( $data['view_count'] );
|
|
}
|
|
|
|
// ── get_listings (WP_Query fallback) ─────────────────────────────────────
|
|
|
|
public function test_get_listings_returns_200_via_wp_query_fallback(): void {
|
|
// Zone idle → WP_Query path.
|
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
|
$req->set_param( 'post_type', 'hp_listing' );
|
|
$req->set_param( 'per_page', 10 );
|
|
$req->set_param( 'page', 1 );
|
|
|
|
$response = $this->api->get_listings( $req );
|
|
$this->assertSame( 200, $response->get_status() );
|
|
$this->assertIsArray( $response->get_data() );
|
|
}
|
|
|
|
public function test_default_post_type_is_post_and_filterable(): void {
|
|
$this->assertSame( 'post', WPDO_REST_API::default_post_type() );
|
|
|
|
$GLOBALS['_wp_filter_callbacks']['tmdo_rest_default_post_type'][] = static fn() => 'hp_listing';
|
|
try {
|
|
$this->assertSame( 'hp_listing', WPDO_REST_API::default_post_type() );
|
|
} finally {
|
|
unset( $GLOBALS['_wp_filter_callbacks']['tmdo_rest_default_post_type'] );
|
|
}
|
|
}
|
|
|
|
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', '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 ) );
|
|
|
|
$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 {
|
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/listings' );
|
|
$req->set_param( 'post_type', 'hp_listing' );
|
|
$req->set_param( 'per_page', 10 );
|
|
$req->set_param( 'page', 1 );
|
|
|
|
$response = $this->api->get_listings( $req );
|
|
$headers = $response->get_headers();
|
|
|
|
$this->assertArrayHasKey( 'X-WP-Total', $headers );
|
|
$this->assertArrayHasKey( 'X-WP-TotalPages', $headers );
|
|
}
|
|
|
|
// ── post_view ─────────────────────────────────────────────────────────────
|
|
|
|
public function test_post_view_403_without_nonce(): void {
|
|
$GLOBALS['_wp_post_types'][10] = 'hp_listing';
|
|
|
|
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/10/view' );
|
|
$req->set_param( 'id', 10 );
|
|
// No nonce set.
|
|
|
|
$response = $this->api->post_view( $req );
|
|
$this->assertSame( 403, $response->get_status() );
|
|
}
|
|
|
|
public function test_post_view_403_with_invalid_nonce(): void {
|
|
$GLOBALS['_wp_post_types'][11] = 'hp_listing';
|
|
|
|
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/11/view' );
|
|
$req->set_param( 'id', 11 );
|
|
$req->set_header( 'X-WP-Nonce', 'bad_nonce' );
|
|
|
|
$response = $this->api->post_view( $req );
|
|
$this->assertSame( 403, $response->get_status() );
|
|
}
|
|
|
|
public function test_post_view_404_when_post_not_found(): void {
|
|
$nonce = wp_create_nonce( 'wp_rest' );
|
|
|
|
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/9999/view' );
|
|
$req->set_param( 'id', 9999 );
|
|
$req->set_header( 'X-WP-Nonce', $nonce );
|
|
|
|
$response = $this->api->post_view( $req );
|
|
$this->assertSame( 404, $response->get_status() );
|
|
}
|
|
|
|
public function test_post_view_returns_view_count(): void {
|
|
$GLOBALS['_wp_post_types'][20] = 'hp_listing';
|
|
$GLOBALS['_wp_postmeta'][20]['hp_view_count'] = '5';
|
|
$nonce = wp_create_nonce( 'wp_rest' );
|
|
|
|
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/20/view' );
|
|
$req->set_param( 'id', 20 );
|
|
$req->set_header( 'X-WP-Nonce', $nonce );
|
|
|
|
$response = $this->api->post_view( $req );
|
|
$this->assertSame( 200, $response->get_status() );
|
|
|
|
$data = $response->get_data();
|
|
$this->assertSame( 20, $data['post_id'] );
|
|
$this->assertIsInt( $data['view_count'] );
|
|
}
|
|
|
|
// ── post_view: rate limiting ──────────────────────────────────────────────
|
|
|
|
public function test_post_view_success_sets_set_cookie_header(): void {
|
|
$GLOBALS['_wp_post_types'][25] = 'hp_listing';
|
|
$nonce = wp_create_nonce( 'wp_rest' );
|
|
|
|
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/25/view' );
|
|
$req->set_param( 'id', 25 );
|
|
$req->set_header( 'X-WP-Nonce', $nonce );
|
|
|
|
$response = $this->api->post_view( $req );
|
|
$this->assertSame( 200, $response->get_status() );
|
|
$this->assertArrayHasKey( 'Set-Cookie', $response->get_headers() );
|
|
$this->assertStringContainsString( 'wpdo_view_25', $response->get_headers()['Set-Cookie'] );
|
|
}
|
|
|
|
public function test_post_view_429_when_ip_rate_limited(): void {
|
|
$GLOBALS['_wp_post_types'][30] = 'hp_listing';
|
|
$nonce = wp_create_nonce( 'wp_rest' );
|
|
|
|
// First call succeeds and sets IP transient.
|
|
$req1 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' );
|
|
$req1->set_param( 'id', 30 );
|
|
$req1->set_header( 'X-WP-Nonce', $nonce );
|
|
$resp1 = $this->api->post_view( $req1 );
|
|
$this->assertSame( 200, $resp1->get_status() );
|
|
|
|
// Second call (same IP, within TTL) must be rate-limited.
|
|
$req2 = new WP_REST_Request( 'POST', '/wpdo/v1/listings/30/view' );
|
|
$req2->set_param( 'id', 30 );
|
|
$req2->set_header( 'X-WP-Nonce', $nonce );
|
|
$resp2 = $this->api->post_view( $req2 );
|
|
$this->assertSame( 429, $resp2->get_status() );
|
|
$this->assertSame( 'too_many_requests', $resp2->get_data()['code'] );
|
|
}
|
|
|
|
public function test_post_view_429_when_cookie_present(): void {
|
|
$GLOBALS['_wp_post_types'][35] = 'hp_listing';
|
|
$_COOKIE['wpdo_view_35'] = '1';
|
|
$nonce = wp_create_nonce( 'wp_rest' );
|
|
|
|
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/35/view' );
|
|
$req->set_param( 'id', 35 );
|
|
$req->set_header( 'X-WP-Nonce', $nonce );
|
|
|
|
$response = $this->api->post_view( $req );
|
|
$this->assertSame( 429, $response->get_status() );
|
|
$this->assertSame( 'too_many_requests', $response->get_data()['code'] );
|
|
}
|
|
|
|
/**
|
|
* Blocked requests are counted 1 in 10 (each sample adds 10), so anonymous
|
|
* traffic cannot turn every blocked request into an option write.
|
|
*/
|
|
public function test_post_view_429_samples_rate_limit_stats(): void {
|
|
$GLOBALS['_wp_post_types'][40] = 'hp_listing';
|
|
$_COOKIE['wpdo_view_40'] = '1'; // Trigger cookie block.
|
|
$nonce = wp_create_nonce( 'wp_rest' );
|
|
|
|
$req = new WP_REST_Request( 'POST', '/wpdo/v1/listings/40/view' );
|
|
$req->set_param( 'id', 40 );
|
|
$req->set_header( 'X-WP-Nonce', $nonce );
|
|
$writes = 0;
|
|
for ( $i = 0; $i < 200; $i++ ) {
|
|
$before = get_option( 'wpdo_rl_stats', [] );
|
|
$this->assertSame( 429, $this->api->post_view( $req )->get_status() );
|
|
$writes += get_option( 'wpdo_rl_stats', [] ) === $before ? 0 : 1;
|
|
}
|
|
|
|
$count = (int) ( get_option( 'wpdo_rl_stats', [] )['40'] ?? 0 );
|
|
$this->assertSame( 10 * $writes, $count, 'each sample adds 10' );
|
|
$this->assertGreaterThan( 0, $writes );
|
|
$this->assertLessThan( 60, $writes, 'about 20 writes for 200 blocked requests, not 200' );
|
|
}
|
|
|
|
// ── get_status: rate_limit_stats ─────────────────────────────────────────
|
|
|
|
public function test_get_status_includes_rate_limit_stats(): void {
|
|
$req = new WP_REST_Request( 'GET', '/wpdo/v1/status' );
|
|
$data = $this->api->get_status( $req )->get_data();
|
|
|
|
$this->assertArrayHasKey( 'rate_limit_stats', $data );
|
|
$this->assertIsArray( $data['rate_limit_stats'] );
|
|
}
|
|
}
|