diff --git a/docs/plugins/wpgraphql-logging/how-to/log-rest-requests/index.md b/docs/plugins/wpgraphql-logging/how-to/log-rest-requests/index.md new file mode 100644 index 000000000..3057624dc --- /dev/null +++ b/docs/plugins/wpgraphql-logging/how-to/log-rest-requests/index.md @@ -0,0 +1,111 @@ +--- +title: "How To Guide: Log WordPress REST API Requests" +description: "Turn on logging of WordPress REST API requests in the WPGraphQL Logging plugin, exclude routes, and tell REST entries apart from GraphQL entries." +--- + +The WPGraphQL Logging plugin can log WordPress REST API requests alongside GraphQL requests. REST entries go through the same Monolog pipeline (processors, handlers and data sanitization) and show up on the same admin screen. + +REST logging is off by default. Turning it on does not change how GraphQL requests are logged. + +> \[!NOTE] +> The plugin still requires WPGraphQL to be active, even if you only want to log REST requests. + +## Turn on REST logging + +1. Go to **GraphQL Logs -> Settings** and open the **Basic Configuration** tab. +2. Make sure **Enabled** is checked. +3. Check **Log REST API Requests**. +4. Optionally, list routes to skip in **Exclude REST Routes**. +5. Save the settings. + +These Basic Configuration settings also apply to REST requests: + +| Setting | Effect on REST requests | +| ------------------- | --------------------------------------------------------------------------- | +| Enabled | Master switch. REST requests are not logged when it is off. | +| IP Restrictions | Only REST requests from these IPs are logged. | +| Data Sampling Rate | Only this percentage of REST requests is logged. | +| Log Response | Adds the response body to the entry. Error bodies are always logged. | +| Exclude Queries | Does not apply. Use **Exclude REST Routes** instead. | +| Log Points | Does not apply. Each REST request produces one entry. | + +## What gets logged + +The plugin writes one entry for each REST request served over HTTP, just before the response is sent. CORS preflight (`OPTIONS`) requests are skipped. + +Requests that are not served over HTTP are not logged. That covers PHP calls to `rest_do_request()`, embedded (`_embed`) sub-requests and block editor preloads. A batch request (`/batch/v1`) is logged as one entry. + +Each entry has: + +* **Channel**: `wpgraphql_logging_rest`. GraphQL entries use `wpgraphql_logging`. +* **Message**: the method and route, for example `WP REST GET /wp/v2/posts`. The **Event** column on the All Logs screen shows this message. +* **Level**: `INFO`, or `ERROR` when the response status is 400 or higher. +* **Context**: + * `method`, `route` and `matched_route` (the registered route pattern, such as `/wp/v2/posts/(?P[\d]+)`) + * `params`: the parameters the client sent (URL, query string, form body and JSON body). Endpoint defaults are left out. + * `status`: the HTTP status code + * `duration_ms`: time from dispatch until the response is ready to send, in milliseconds + * `errors`: the error body, when the status is 400 or higher + * `response`: the response body, when **Log Response** is on and the request succeeded + +The usual `extra` data (memory usage, request headers, process ID and so on) is added by the default processors. + +## Exclude routes + +**Exclude REST Routes** takes a comma-separated list of routes. Each entry matches that route and every route below it, ignoring case and leading or trailing slashes. + +```text +/wp/v2/users, /wp-site-health/v1 +``` + +With this value, `/wp/v2/users` and `/wp/v2/users/me` are skipped, but `/wp/v2/users-extra` and `/wp/v2/posts` are still logged. + +## Sanitize sensitive data + +When **Data Sanitization** uses the **Recommended** rules, the plugin anonymizes `params.username`, `params.password`, `params.email` and `response.password` in REST entries. + +For anything else, choose **Custom** rules on the **Data Management** tab and use dot notation against the context. For example, `params.token` or `params.billing.phone`. + +## Tell REST and GraphQL entries apart + +The channel column is stored in the database and can be filtered on. To show only REST entries on the All Logs screen, add a where clause: + +```php +add_filter( 'wpgraphql_logging_logs_table_where_clauses', function ( array $where ) { + $where[] = [ + 'column' => 'channel', + 'operator' => '=', + 'value' => \WPGraphQL\Logging\Events\RestRequestLogger::CHANNEL, + ]; + return $where; +} ); +``` + +The channel is also included when you download a log as CSV. + +## Customize which REST requests are logged + +REST requests use their own rule set, filtered with `wpgraphql_logging_rest_rules`. Rules receive the REST route in place of the GraphQL query string. See [How to add or remove a rule](../logger-add-remove-rules/index.md) for how to write a rule. + +```php +add_filter( 'wpgraphql_logging_rest_rules', function ( array $rules ) { + $rules[] = new MyPlugin\Logging\Rules\OnlyCustomNamespaceRule(); + return $rules; +} ); +``` + +The `wpgraphql_logging_is_enabled` filter runs for both GraphQL and REST requests. + +## Change or listen to REST entries + +REST entries go through the event system under `Events::REST_REQUEST_SERVED`, so you can transform or subscribe to them like GraphQL events. See [How to use the pub/sub event system](../event-pub-sub/index.md). + +```php +use WPGraphQL\Logging\Events\Events; +use WPGraphQL\Logging\Plugin; + +Plugin::transform( Events::REST_REQUEST_SERVED, function ( array $payload ): array { + $payload['context']['site_region'] = 'eu-west'; + return $payload; +} ); +``` diff --git a/docs/plugins/wpgraphql-logging/index.md b/docs/plugins/wpgraphql-logging/index.md index 353878bff..0b38db6d6 100644 --- a/docs/plugins/wpgraphql-logging/index.md +++ b/docs/plugins/wpgraphql-logging/index.md @@ -33,6 +33,10 @@ The timer starts at the earliest WPGraphQL hook the plugin can observe (`graphql Records logged outside of a WPGraphQL request do not include `request_duration_ms`. +### WordPress REST API Requests (opt-in) + +The plugin can also log WordPress REST API requests through the same pipeline, rules and admin screen. This is off by default. See [How to log WordPress REST API requests](how-to/log-rest-requests/index.md). + ### Pub/Sub Event System Built with developers and plugins in mind, the plugin allows users to listen to individual events and transform or collect data from those events. @@ -63,12 +67,13 @@ By default the following settings are pre-configured. ### Basic Configuration -| Setting | Default Value | Description | -| --------------- | ---------------------- | ---------------------------------------------------- | -| Enabled | true | Master switch to enable/disable logging | -| Exclude Queries | `__schema,GetSeedNode` | To exclude introspection and Faust Seed Node queries | -| Data Sampling | 10% | Log only 10% of the queries | -| Log Points | Selects all | Which lifecycle events to log | +| Setting | Default Value | Description | +| --------------------- | ---------------------- | ---------------------------------------------------- | +| Enabled | true | Master switch to enable/disable logging | +| Exclude Queries | `__schema,GetSeedNode` | To exclude introspection and Faust Seed Node queries | +| Data Sampling | 10% | Log only 10% of the queries | +| Log Points | Selects all | Which lifecycle events to log | +| Log REST API Requests | false | Also log WordPress REST API requests | ### Data Management @@ -88,7 +93,9 @@ By default the following settings are pre-configured. * **Exclude Queries**: A comma-separated list of GraphQL query or mutation names to be excluded from logging. * **Data Sampling Rate**: A dropdown to select the percentage of requests that will be logged. * **Log Points**: A multi-select field to choose the specific WPGraphQL lifecycle events for which data should be logged. -* **Log Response**: A toggle to determine whether the GraphQL response body should be included in the log. +* **Log Response**: A toggle to determine whether the GraphQL response body should be included in the log. Also applies to REST API responses when REST logging is enabled. +* **Log REST API Requests**: Also log WordPress REST API requests. Off by default. See [How to log WordPress REST API requests](how-to/log-rest-requests/index.md). +* **Exclude REST Routes**: A comma-separated list of REST routes to skip, such as `/wp/v2/users`. Each entry also excludes the routes below it. Only used when REST logging is enabled. ### Data Management @@ -98,7 +105,7 @@ By default the following settings are pre-configured. * **Number of Days to Retain Logs**: Number of days logged should be stored. * **Data Sanitization Enabled**: Enable data sanitization. * **Data Sanitization Method**: Choose between two sanitization methods (default is recommended) - * **Recommended Rules (Default)**: The following fields will be removed from the event data. + * **Recommended Rules (Default)**: The following fields will be removed or anonymized in the event data. * `request.app_context.viewer.data` * `request.app_context.viewer.allcaps` * `request.app_context.viewer.cap_key` @@ -106,6 +113,8 @@ By default the following settings are pre-configured. * `variables.username` * `variables.password` * `variables.email` + * `params.username`, `params.password`, `params.email` (REST API request parameters) + * `response.password` (for example, a newly created application password in a REST response) * **Custom Rules**: Define your own sanitization rules by specifying fields to anonymize, remove, or truncate. ## Project Structure diff --git a/llms.txt b/llms.txt index 57a18064a..eb8a46ed6 100644 --- a/llms.txt +++ b/llms.txt @@ -54,6 +54,7 @@ Documentation for the Headless WordPress Toolkit, a modern, framework-agnostic c - [How To Guide: Update the Admin Grid](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/admin-add-view-column/index.md): Learn how to add custom columns to the WPGraphQL Logging plugin admin logs table. - [How To Guide: Add Data to an Event](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/event-add-context/index.md): Learn how to add custom context data to WPGraphQL Logging event. - [How To Guide: Use the Events Pub/Sub System](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/event-pub-sub/index.md): Learn how to use the WPGraphQL Logging plugin's event pub/sub system to subscribe, transform, and emit events. + - [How To Guide: Log WordPress REST API Requests](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/log-rest-requests/index.md): Turn on logging of WordPress REST API requests in the WPGraphQL Logging plugin, exclude routes, and tell REST entries apart from GraphQL entries. - [How To Guide: Add a new Handler](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/logger-add-handler/index.md): This guide shows how to log to a file using a Monolog handler, either in addition to the default WordPress database handler or as a replacement. It also covers per-instance overrides. - [How To Guide: Add a new Processor](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/logger-add-processor/index.md): Learn how to add custom Monolog processors to the WPGraphQL Logging plugin to transform log records. - [How To Guide: Add or Remove a Rule](https://github.com/wpengine/hwptoolkit/blob/main/docs/plugins/wpgraphql-logging/how-to/logger-add-remove-rules/index.md): Learn how to create and register custom logging rules with the WPGraphQL Logging plugin. diff --git a/plugins/wpgraphql-logging/src/Admin/Settings/Fields/Tab/BasicConfigurationTab.php b/plugins/wpgraphql-logging/src/Admin/Settings/Fields/Tab/BasicConfigurationTab.php index 47b6a823b..ad850d276 100644 --- a/plugins/wpgraphql-logging/src/Admin/Settings/Fields/Tab/BasicConfigurationTab.php +++ b/plugins/wpgraphql-logging/src/Admin/Settings/Fields/Tab/BasicConfigurationTab.php @@ -59,6 +59,20 @@ class BasicConfigurationTab implements SettingsTabInterface { */ public const LOG_RESPONSE = 'log_response'; + /** + * The field ID for whether to also log WordPress REST API requests. + * + * @var string + */ + public const LOG_REST_REQUESTS = 'log_rest_requests'; + + /** + * The field ID for the exclude REST routes text input. + * + * @var string + */ + public const EXCLUDE_REST_ROUTES = 'exclude_rest_routes'; + /** * Get the fields for this tab. * @@ -134,6 +148,23 @@ public function get_fields(): array { __( 'Whether or not to log the response from the WPGraphQL query into the context object.', 'wpgraphql-logging' ), ); + $fields[ self::LOG_REST_REQUESTS ] = new CheckboxField( + self::LOG_REST_REQUESTS, + self::get_name(), + __( 'Log REST API Requests', 'wpgraphql-logging' ), + '', + __( 'Also log WordPress REST API requests. Uses the same Enabled, IP Restrictions, Data Sampling Rate and Log Response settings as GraphQL logging. Log Points and Exclude Queries do not apply.', 'wpgraphql-logging' ), + ); + + $fields[ self::EXCLUDE_REST_ROUTES ] = new TextInputField( + self::EXCLUDE_REST_ROUTES, + self::get_name(), + __( 'Exclude REST Routes', 'wpgraphql-logging' ), + '', + __( 'Comma-separated list of REST route prefixes to exclude from logging. Only used when Log REST API Requests is enabled.', 'wpgraphql-logging' ), + __( 'e.g., /wp/v2/users, /wp-site-health/v1', 'wpgraphql-logging' ) + ); + return apply_filters( 'wpgraphql_logging_basic_configuration_fields', $fields ); } diff --git a/plugins/wpgraphql-logging/src/Events/Events.php b/plugins/wpgraphql-logging/src/Events/Events.php index 99a084a33..faa5bb204 100644 --- a/plugins/wpgraphql-logging/src/Events/Events.php +++ b/plugins/wpgraphql-logging/src/Events/Events.php @@ -64,4 +64,14 @@ final class Events { * @var string */ public const REQUEST_RESULTS = 'graphql_request_results'; + + /** + * WordPress filter: rest_pre_serve_request. + * + * A WordPress REST API request is about to be served. Only used when + * REST API request logging is enabled. + * + * @var string + */ + public const REST_REQUEST_SERVED = 'rest_pre_serve_request'; } diff --git a/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php b/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php new file mode 100644 index 000000000..6002f03fa --- /dev/null +++ b/plugins/wpgraphql-logging/src/Events/RestRequestLogger.php @@ -0,0 +1,311 @@ + + */ + protected array $config; + + /** + * When dispatching of the timed request started, keyed by its object ID. + * + * Only one request is timed at a time: the top-level HTTP request is + * always dispatched first, and nested dispatches (batch sub-requests, + * rest_do_request() calls) must not grow this indefinitely. + * + * @var array + */ + protected array $start_time = []; + + /** + * The single instance of the class. + * + * @var \WPGraphQL\Logging\Events\RestRequestLogger|null + */ + private static ?RestRequestLogger $instance = null; + + /** + * RestRequestLogger constructor. + * + * @param \WPGraphQL\Logging\Logger\LoggerService $logger The logger instance. + * @param array $config The logging configuration. + */ + public function __construct( LoggerService $logger, array $config ) { + $this->logger = $logger; + $this->config = $config; + } + + /** + * Register the REST hooks when REST logging has been switched on. + * + * Nothing is hooked while the setting is off, so installs that have not + * opted in pay no cost on REST requests. + */ + public static function init(): ?RestRequestLogger { + if ( null !== self::$instance ) { + return self::$instance; + } + + $config = ConfigurationHelper::get_instance()->get_basic_config(); + if ( ! (bool) ( $config[ BasicConfigurationTab::LOG_REST_REQUESTS ] ?? false ) ) { + return null; + } + + self::$instance = new self( LoggerService::get_instance( self::CHANNEL ), $config ); + self::$instance->register_hooks(); + + return self::$instance; + } + + /** + * Register the WordPress REST API hooks. + */ + public function register_hooks(): void { + add_filter( 'rest_pre_dispatch', [ $this, 'start_timer' ], 0, 3 ); + add_filter( 'rest_pre_serve_request', [ $this, 'log_rest_request' ], 10, 3 ); + } + + /** + * Record when dispatching of a REST request started. + * + * Hooked to the `rest_pre_dispatch` filter. The filtered value is + * returned untouched. + * + * @param mixed $result Response to replace the requested version with. + * @param \WP_REST_Server|mixed $server Server instance. + * @param \WP_REST_Request|mixed $request Request used to generate the response. + * + * @return mixed The unmodified result. + */ + public function start_timer( mixed $result, mixed $server, mixed $request ): mixed { + if ( $request instanceof WP_REST_Request && [] === $this->start_time ) { + $this->start_time = [ spl_object_id( $request ) => microtime( true ) ]; + } + return $result; + } + + /** + * Log a REST request just before its response is sent to the client. + * + * Hooked to the `rest_pre_serve_request` filter, which only runs for + * requests served over HTTP. The filtered value is returned untouched. + * + * @param bool|mixed $served Whether the request has already been served. + * @param \WP_HTTP_Response|mixed $result Result to send to the client. + * @param \WP_REST_Request|mixed $request Request used to generate the response. + * + * @return bool|mixed The unmodified served flag. + */ + public function log_rest_request( mixed $served, mixed $result, mixed $request ): mixed { + if ( ! $request instanceof WP_REST_Request ) { + return $served; + } + + try { + $duration_ms = $this->stop_timer( $request ); + if ( ! $this->should_log_rest_request( $request ) ) { + return $served; + } + + $route = $request->get_route(); + + $context = $this->get_context( $request, $result, $duration_ms ); + $level = Level::Info; + if ( isset( $context['errors'] ) ) { + $level = Level::Error; + } + + $payload = EventManager::transform( Events::REST_REQUEST_SERVED, [ + 'context' => $context, + 'level' => $level, + ] ); + $message = sprintf( 'WP REST %s %s', $request->get_method(), $route ); + $this->logger->log( $payload['level'], $message, $payload['context'] ); + EventManager::publish( Events::REST_REQUEST_SERVED, [ 'context' => $payload['context'] ] ); + } catch ( \Throwable $e ) { + $this->process_application_error( Events::REST_REQUEST_SERVED, $e ); + } + + return $served; + } + + /** + * Stop the dispatch timer and return the elapsed time for the request. + * + * @param \WP_REST_Request $request The REST request. + * + * @return float|null Milliseconds since dispatch started, or null if the request was not timed. + */ + protected function stop_timer( WP_REST_Request $request ): ?float { + $request_id = spl_object_id( $request ); + $duration_ms = isset( $this->start_time[ $request_id ] ) ? round( ( microtime( true ) - $this->start_time[ $request_id ] ) * 1000.0, 2 ) : null; + + $this->start_time = []; + return $duration_ms; + } + + /** + * Whether the REST request should be logged. + * + * @param \WP_REST_Request $request The REST request. + */ + protected function should_log_rest_request( WP_REST_Request $request ): bool { + // CORS preflight requests carry no payload and would double the entries for browser clients. + if ( 'OPTIONS' === $request->get_method() ) { + return false; + } + + // HTTP requests to the GraphQL endpoint are logged by the GraphQL lifecycle. This is a + // safeguard: WPGraphQL does not serve those through the REST server. is_graphql_request() + // is deliberately not used, as it is also true while a REST callback runs graphql(). + if ( function_exists( 'is_graphql_http_request' ) && is_graphql_http_request() ) { + return false; + } + + return $this->is_logging_enabled( $this->config, $request->get_route() ); + } + + /** + * Build the log context for a REST request. + * + * @param \WP_REST_Request $request The REST request. + * @param \WP_HTTP_Response|mixed $result The response about to be served. + * @param float|null $duration_ms Milliseconds spent dispatching the request. + * + * @return array + */ + protected function get_context( WP_REST_Request $request, mixed $result, ?float $duration_ms = null ): array { + $context = [ + 'method' => $request->get_method(), + 'route' => $request->get_route(), + 'params' => $this->get_sent_params( $request ), + ]; + + if ( null !== $duration_ms ) { + $context['duration_ms'] = $duration_ms; + } + + if ( ! $result instanceof WP_HTTP_Response ) { + return $context; + } + + $context['status'] = $result->get_status(); + if ( $result instanceof WP_REST_Response ) { + $context['matched_route'] = $result->get_matched_route(); + } + + // Error bodies are small (code, message, data) and always useful, so they are logged + // regardless of the Log Response setting. Success bodies are only logged when opted in. + if ( $result->get_status() >= 400 ) { + $context['errors'] = $result->get_data(); + return $context; + } + + if ( $this->should_log_response( $this->config ) ) { + $context['response'] = $result->get_data(); + } + + return $context; + } + + /** + * Get the parameters the client sent, leaving out endpoint defaults. + * + * WP_REST_Request::get_params() also returns every registered default + * (page, per_page, context, order...), which would bloat each entry. + * Values come from get_params(), so they are the sanitized values the + * endpoint received. + * + * @param \WP_REST_Request $request The REST request. + * + * @return array + */ + protected function get_sent_params( WP_REST_Request $request ): array { + $sent = array_merge( + $request->get_url_params(), + $request->get_query_params(), + $request->get_body_params(), + $request->get_json_params() ?? [] + ); + + return array_intersect_key( $request->get_params(), $sent ); + } + + /** + * Get the rules that decide whether a REST request is logged. + * + * GraphQL specific rules (query required, excluded queries) are swapped + * for REST equivalents. The route is passed to rules in place of the + * GraphQL query string. Sampling runs last so the dice are only rolled + * for requests that are otherwise eligible. + * + * @return array<\WPGraphQL\Logging\Logger\Api\LoggingRuleInterface> + */ + protected function get_logging_rules(): array { + $default_rules = [ + new LogRestRequestsRule(), + new EnabledRule(), + new IpRestrictionsRule(), + new ExcludeRestRouteRule(), + new SamplingRateRule(), + ]; + + /** + * Filter the logging rules used for WordPress REST API requests. + * + * @param array<\WPGraphQL\Logging\Logger\Api\LoggingRuleInterface> $rules Array of rule objects. + */ + return apply_filters( 'wpgraphql_logging_rest_rules', $default_rules ); + } +} diff --git a/plugins/wpgraphql-logging/src/Logger/LoggingHelper.php b/plugins/wpgraphql-logging/src/Logger/LoggingHelper.php index 73c116f4a..2f6d1711d 100644 --- a/plugins/wpgraphql-logging/src/Logger/LoggingHelper.php +++ b/plugins/wpgraphql-logging/src/Logger/LoggingHelper.php @@ -98,6 +98,22 @@ protected function get_rule_manager(): RuleManager { return $this->rule_manager; } + $this->rule_manager = new RuleManager(); + + /** @var \WPGraphQL\Logging\Logger\Api\LoggingRuleInterface $rule */ + foreach ( $this->get_logging_rules() as $rule ) { + $this->rule_manager->add_rule( $rule ); + } + + return $this->rule_manager; + } + + /** + * Get the rules that decide whether a GraphQL event is logged. + * + * @return array<\WPGraphQL\Logging\Logger\Api\LoggingRuleInterface> + */ + protected function get_logging_rules(): array { $default_rules = [ new QueryNullRule(), new SamplingRateRule(), @@ -111,16 +127,7 @@ protected function get_rule_manager(): RuleManager { * * @param array<\WPGraphQL\Logging\Logger\Api\LoggingRuleInterface> $rules Array of rule objects. */ - $rules = apply_filters( 'wpgraphql_logging_rules', $default_rules ); - - $this->rule_manager = new RuleManager(); - - /** @var \WPGraphQL\Logging\Logger\Api\LoggingRuleInterface $rule */ - foreach ( $rules as $rule ) { - $this->rule_manager->add_rule( $rule ); - } - - return $this->rule_manager; + return apply_filters( 'wpgraphql_logging_rules', $default_rules ); } /** diff --git a/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php b/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php index 7ec4043af..7fe214d40 100644 --- a/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php +++ b/plugins/wpgraphql-logging/src/Logger/Processors/DataSanitizationProcessor.php @@ -70,6 +70,13 @@ protected function get_recommended_rules(): array { 'variables.username' => 'anonymize', 'variables.password' => 'anonymize', 'variables.email' => 'anonymize', + 'params.username' => 'anonymize', + 'params.password' => 'anonymize', + 'params.email' => 'anonymize', + 'params.requests.*.body.password' => 'anonymize', + 'response.password' => 'anonymize', // e.g. a new REST application password. + 'response.responses.*.body.password' => 'anonymize', + 'response.body.password' => 'anonymize', ]; return apply_filters( 'wpgraphql_logging_data_sanitization_recommended_rules', $rules ); @@ -126,7 +133,21 @@ protected function apply_rule(array &$data, string $key, string $rule): void { $last_key = array_pop( $keys ); $current = &$data; - foreach ( $keys as $segment ) { + foreach ( $keys as $index => $segment ) { + if ( '*' === $segment ) { + if ( ! is_array( $current ) ) { + return; + } + + $remaining_key = implode( '.', array_slice( $keys, $index + 1 ) ); + foreach ( array_keys( $current ) as $array_key ) { + if ( is_array( $current[ $array_key ] ) ) { + $this->apply_rule( $current[ $array_key ], $remaining_key, $rule ); + } + } + return; + } + if ( ! is_array( $current ) || ! isset( $current[ $segment ] ) ) { return; } diff --git a/plugins/wpgraphql-logging/src/Logger/Rules/ExcludeRestRouteRule.php b/plugins/wpgraphql-logging/src/Logger/Rules/ExcludeRestRouteRule.php new file mode 100644 index 000000000..a366e6269 --- /dev/null +++ b/plugins/wpgraphql-logging/src/Logger/Rules/ExcludeRestRouteRule.php @@ -0,0 +1,73 @@ + $config The logging configuration. + * @param string|null $query_string The REST route, e.g. `/wp/v2/posts`. + * + * @return bool True if the rule passes (logging should continue). + */ + public function passes(array $config, ?string $query_string = null): bool { + $routes = $config[ BasicConfigurationTab::EXCLUDE_REST_ROUTES ] ?? ''; + if ( null === $query_string || ! is_string( $routes ) || '' === trim( $routes ) ) { + return true; + } + + $route = $this->normalize_route( $query_string ); + $excluded_routes = array_filter( array_map( 'trim', explode( ',', $routes ) ), static fn( string $value ): bool => '' !== $value ); + foreach ( $excluded_routes as $excluded_route ) { + if ( $this->matches( $route, $this->normalize_route( $excluded_route ) ) ) { + return false; + } + } + + return true; + } + + /** + * Get the rule name for debugging. + */ + public function get_name(): string { + return 'exclude_rest_route_rule'; + } + + /** + * Whether a route is the excluded route or sits below it. + * + * @param string $route The normalized request route. + * @param string $excluded_route The normalized excluded route. + */ + protected function matches( string $route, string $excluded_route ): bool { + return $route === $excluded_route || str_starts_with( $route, rtrim( $excluded_route, '/' ) . '/' ); + } + + /** + * Normalize a route to a lowercase path with one leading slash and no trailing slash. + * + * @param string $route The route to normalize. + */ + protected function normalize_route( string $route ): string { + return '/' . strtolower( trim( $route, '/' ) ); + } +} diff --git a/plugins/wpgraphql-logging/src/Logger/Rules/LogRestRequestsRule.php b/plugins/wpgraphql-logging/src/Logger/Rules/LogRestRequestsRule.php new file mode 100644 index 000000000..4df176377 --- /dev/null +++ b/plugins/wpgraphql-logging/src/Logger/Rules/LogRestRequestsRule.php @@ -0,0 +1,36 @@ + $config The logging configuration. + * @param string|null $query_string The REST route (unused). + * + * @return bool True if the rule passes (logging should continue). + */ + public function passes(array $config, ?string $query_string = null): bool { + return (bool) ( $config[ BasicConfigurationTab::LOG_REST_REQUESTS ] ?? false ); + } + + /** + * Get the rule name for debugging. + */ + public function get_name(): string { + return 'log_rest_requests_rule'; + } +} diff --git a/plugins/wpgraphql-logging/src/Plugin.php b/plugins/wpgraphql-logging/src/Plugin.php index 298642a08..e18f9bc22 100644 --- a/plugins/wpgraphql-logging/src/Plugin.php +++ b/plugins/wpgraphql-logging/src/Plugin.php @@ -13,6 +13,7 @@ use WPGraphQL\Logging\Events\EventManager; use WPGraphQL\Logging\Events\Events; use WPGraphQL\Logging\Events\QueryEventLifecycle; +use WPGraphQL\Logging\Events\RestRequestLogger; use WPGraphQL\Logging\Logger\Api\LogServiceInterface; use WPGraphQL\Logging\Logger\Scheduler\DataDeletionScheduler; use WPGraphQL\Logging\Logger\Store\LogStoreService; @@ -62,6 +63,7 @@ public static function init(): self { */ public function setup(): void { QueryEventLifecycle::init(); // Event lifecycle for capturing logs. + RestRequestLogger::init(); // REST API request logging (opt-in). DataDeletionScheduler::init(); // Data deletion scheduler. if ( is_admin() ) { @@ -150,6 +152,7 @@ public static function set_default_configuration(): void { Events::RESPONSE_HEADERS_TO_SEND, ], BasicConfigurationTab::LOG_RESPONSE => false, + BasicConfigurationTab::LOG_REST_REQUESTS => false, ], DataManagementTab::get_name() => [ DataManagementTab::DATA_DELETION_ENABLED => true, diff --git a/plugins/wpgraphql-logging/tests/wpunit/Admin/Settings/Fields/Tab/BasicConfigurationTabTest.php b/plugins/wpgraphql-logging/tests/wpunit/Admin/Settings/Fields/Tab/BasicConfigurationTabTest.php new file mode 100644 index 000000000..9c5b2d6ae --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Admin/Settings/Fields/Tab/BasicConfigurationTabTest.php @@ -0,0 +1,41 @@ +get_fields(); + + $this->assertInstanceOf( CheckboxField::class, $fields[ BasicConfigurationTab::LOG_REST_REQUESTS ] ); + $this->assertInstanceOf( TextInputField::class, $fields[ BasicConfigurationTab::EXCLUDE_REST_ROUTES ] ); + $this->assertSame( BasicConfigurationTab::get_name(), $fields[ BasicConfigurationTab::LOG_REST_REQUESTS ]->tab ); + } + + public function test_log_rest_requests_is_off_unless_checked(): void { + $field = ( new BasicConfigurationTab() )->get_fields()[ BasicConfigurationTab::LOG_REST_REQUESTS ]; + + $this->assertFalse( $field->sanitize_field( null ) ); + $this->assertTrue( $field->sanitize_field( '1' ) ); + } + + public function test_exclude_rest_routes_is_sanitized_as_text(): void { + $field = ( new BasicConfigurationTab() )->get_fields()[ BasicConfigurationTab::EXCLUDE_REST_ROUTES ]; + + $this->assertSame( '/wp/v2/users, /wp/v2/types', $field->sanitize_field( '/wp/v2/users, /wp/v2/types' ) ); + } +} diff --git a/plugins/wpgraphql-logging/tests/wpunit/Core/PluginTest.php b/plugins/wpgraphql-logging/tests/wpunit/Core/PluginTest.php index 21ddfeaa0..f841d0a7c 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Core/PluginTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Core/PluginTest.php @@ -98,6 +98,7 @@ public function test_plugin_activate() { Events::RESPONSE_HEADERS_TO_SEND, ], BasicConfigurationTab::LOG_RESPONSE => false, + BasicConfigurationTab::LOG_REST_REQUESTS => false, ], DataManagementTab::get_name() => [ DataManagementTab::DATA_DELETION_ENABLED => true, diff --git a/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php new file mode 100644 index 000000000..521060518 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Events/RestRequestLoggerTest.php @@ -0,0 +1,382 @@ +log_service = LogStoreService::get_log_service(); + $this->logger = LoggerService::get_instance( RestRequestLogger::CHANNEL ); + } + + public function tearDown(): void { + if ( null !== $this->instance ) { + remove_filter( 'rest_pre_dispatch', [ $this->instance, 'start_timer' ], 0 ); + remove_filter( 'rest_pre_serve_request', [ $this->instance, 'log_rest_request' ], 10 ); + $this->instance = null; + } + // The channel logger is a shared singleton with a BufferHandler. Flush records a test + // wrote but never read, so they are deleted here instead of leaking into the next test. + $this->flush(); + $this->log_service->delete_all_entities(); + parent::tearDown(); + } + + /** + * @param array $config + */ + protected function create_instance( array $config, ?LoggerService $logger = null ): RestRequestLogger { + $config = array_merge( + [ + BasicConfigurationTab::ENABLED => true, + BasicConfigurationTab::LOG_REST_REQUESTS => true, + BasicConfigurationTab::DATA_SAMPLING => '100', + ], + $config + ); + + $this->instance = new RestRequestLogger( $logger ?? $this->logger, $config ); + $this->instance->register_hooks(); + return $this->instance; + } + + /** + * Dispatch a REST request and fire the serve hook like WP_REST_Server::serve_request(). + * + * @param array $query_params + */ + protected function serve( string $method, string $route, array $query_params = [] ): void { + $request = new WP_REST_Request( $method, $route ); + $request->set_query_params( $query_params ); + $response = rest_do_request( $request ); + apply_filters( 'rest_pre_serve_request', false, $response, $request, rest_get_server() ); + } + + protected function flush( ?LoggerService $logger = null ): void { + foreach ( ( $logger ?? $this->logger )->get_monolog()->getHandlers() as $handler ) { + if ( $handler instanceof BufferHandler ) { + $handler->flush(); + } + } + } + + /** + * @return array<\WPGraphQL\Logging\Logger\Database\WordPressDatabaseEntity> + */ + protected function get_logs(): array { + $this->flush(); + return $this->log_service->find_entities_by_where( [] ); + } + + public function test_logs_served_rest_request(): void { + $this->create_instance( [] ); + $this->serve( 'GET', '/wp/v2/posts', [ 'per_page' => 1 ] ); + + $logs = $this->get_logs(); + $this->assertCount( 1, $logs ); + + $log = $logs[0]; + $this->assertInstanceOf( WordPressDatabaseEntity::class, $log ); + $this->assertSame( RestRequestLogger::CHANNEL, $log->get_channel() ); + $this->assertSame( Level::Info->value, $log->get_level() ); + $this->assertSame( 'WP REST GET /wp/v2/posts', $log->get_message() ); + + $context = $log->get_context(); + $this->assertSame( 'GET', $context['method'] ); + $this->assertSame( '/wp/v2/posts', $context['route'] ); + $this->assertSame( 200, $context['status'] ); + $this->assertSame( '/wp/v2/posts', $context['matched_route'] ); + $this->assertEquals( 1, $context['params']['per_page'] ); + $this->assertArrayNotHasKey( 'order', $context['params'], 'Endpoint defaults should not be logged.' ); + $this->assertArrayHasKey( 'duration_ms', $context ); + $this->assertArrayNotHasKey( 'response', $context ); + $this->assertArrayNotHasKey( 'errors', $context ); + } + + public function test_internal_rest_do_request_is_not_logged(): void { + $this->create_instance( [] ); + rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/posts' ) ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_does_not_log_when_rest_logging_disabled(): void { + $this->create_instance( [ BasicConfigurationTab::LOG_REST_REQUESTS => false ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_does_not_log_when_logging_disabled(): void { + $this->create_instance( [ BasicConfigurationTab::ENABLED => false ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_does_not_log_when_sampling_rate_is_zero(): void { + $this->create_instance( [ BasicConfigurationTab::DATA_SAMPLING => '0' ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_does_not_log_when_ip_not_allowed(): void { + $remote_addr = $_SERVER['REMOTE_ADDR'] ?? null; + $_SERVER['REMOTE_ADDR'] = '127.0.0.1'; + $this->create_instance( [ BasicConfigurationTab::IP_RESTRICTIONS => '10.0.0.1' ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + if ( null === $remote_addr ) { + unset( $_SERVER['REMOTE_ADDR'] ); + } else { + $_SERVER['REMOTE_ADDR'] = $remote_addr; + } + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_does_not_log_excluded_route(): void { + $this->create_instance( [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => '/wp/v2/types, /wp/v2/posts' ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + $this->serve( 'GET', '/wp/v2/taxonomies' ); + + $logs = $this->get_logs(); + $this->assertCount( 1, $logs ); + $this->assertSame( '/wp/v2/taxonomies', $logs[0]->get_context()['route'] ); + } + + public function test_graphql_exclude_queries_does_not_apply_to_rest(): void { + $this->create_instance( [ BasicConfigurationTab::EXCLUDE_QUERY => 'posts' ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $this->assertCount( 1, $this->get_logs() ); + } + + public function test_log_points_setting_does_not_apply_to_rest(): void { + $this->create_instance( [ BasicConfigurationTab::EVENT_LOG_SELECTION => [] ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $this->assertCount( 1, $this->get_logs() ); + } + + public function test_rest_rules_filter_can_replace_rules(): void { + $callback = static fn(): array => []; + add_filter( 'wpgraphql_logging_rest_rules', $callback ); + $this->create_instance( [ BasicConfigurationTab::LOG_REST_REQUESTS => false ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + remove_filter( 'wpgraphql_logging_rest_rules', $callback ); + + $this->assertCount( 1, $this->get_logs() ); + } + + public function test_is_enabled_filter_applies_to_rest(): void { + add_filter( 'wpgraphql_logging_is_enabled', '__return_false' ); + $this->create_instance( [] ); + $this->serve( 'GET', '/wp/v2/posts' ); + remove_filter( 'wpgraphql_logging_is_enabled', '__return_false' ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_duration_is_omitted_when_request_was_not_dispatched(): void { + $instance = $this->create_instance( [] ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $instance->log_rest_request( false, rest_ensure_response( [] ), $request ); + + $context = $this->get_logs()[0]->get_context(); + $this->assertArrayNotHasKey( 'duration_ms', $context ); + $this->assertSame( 200, $context['status'] ); + } + + public function test_only_the_first_dispatched_request_is_timed(): void { + $instance = $this->create_instance( [] ); + $outer = new WP_REST_Request( 'POST', '/batch/v1' ); + $inner = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $instance->start_timer( null, rest_get_server(), $outer ); + $instance->start_timer( null, rest_get_server(), $inner ); + + $property = new \ReflectionProperty( RestRequestLogger::class, 'start_time' ); + $property->setAccessible( true ); + $this->assertSame( [ spl_object_id( $outer ) ], array_keys( $property->getValue( $instance ) ) ); + } + + public function test_timer_is_reset_when_request_is_not_logged(): void { + $instance = $this->create_instance( [ BasicConfigurationTab::DATA_SAMPLING => '0' ] ); + $this->serve( 'GET', '/wp/v2/posts' ); + + $property = new \ReflectionProperty( RestRequestLogger::class, 'start_time' ); + $property->setAccessible( true ); + $this->assertSame( [], $property->getValue( $instance ) ); + } + + public function test_error_response_logged_as_error(): void { + $this->create_instance( [] ); + $this->serve( 'GET', '/wp/v2/does-not-exist' ); + + $logs = $this->get_logs(); + $this->assertCount( 1, $logs ); + $this->assertSame( Level::Error->value, $logs[0]->get_level() ); + + $context = $logs[0]->get_context(); + $this->assertSame( 404, $context['status'] ); + $this->assertSame( 'rest_no_route', $context['errors']['code'] ); + } + + public function test_error_body_is_not_duplicated_as_response(): void { + $this->create_instance( [ BasicConfigurationTab::LOG_RESPONSE => true ] ); + $this->serve( 'GET', '/wp/v2/does-not-exist' ); + + $context = $this->get_logs()[0]->get_context(); + $this->assertArrayHasKey( 'errors', $context ); + $this->assertArrayNotHasKey( 'response', $context ); + } + + public function test_options_preflight_requests_are_not_logged(): void { + $this->create_instance( [] ); + $this->serve( 'OPTIONS', '/wp/v2/posts' ); + + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_non_rest_request_arguments_are_ignored(): void { + $instance = $this->create_instance( [] ); + + $this->assertSame( 'served', $instance->log_rest_request( 'served', null, 'not-a-request' ) ); + $this->assertSame( 'pre', $instance->start_timer( 'pre', null, null ) ); + $this->assertCount( 0, $this->get_logs() ); + } + + public function test_logs_response_when_log_response_enabled(): void { + $this->create_instance( [ BasicConfigurationTab::LOG_RESPONSE => true ] ); + $this->serve( 'GET', '/wp/v2/types/post' ); + + $logs = $this->get_logs(); + $this->assertCount( 1, $logs ); + $this->assertSame( 'post', $logs[0]->get_context()['response']['slug'] ); + } + + public function test_sensitive_params_are_anonymized_by_recommended_rules(): void { + $processor = new DataSanitizationProcessor(); + $reflection = new \ReflectionProperty( $processor, 'config' ); + $reflection->setAccessible( true ); + $reflection->setValue( $processor, [ + DataManagementTab::DATA_SANITIZATION_ENABLED => true, + DataManagementTab::DATA_SANITIZATION_METHOD => 'recommended', + ] ); + + $logger = LoggerService::get_instance( + RestRequestLogger::CHANNEL . '_sanitization_test', + [ new BufferHandler( new WordPressDatabaseHandler() ) ], + [ $processor ] + ); + + $this->create_instance( [], $logger ); + $this->serve( 'GET', '/wp/v2/posts', [ 'password' => 'secret', 'search' => 'hello' ] ); + $this->flush( $logger ); + + $logs = $this->log_service->find_entities_by_where( [] ); + $this->assertCount( 1, $logs ); + $params = $logs[0]->get_context()['params']; + $this->assertSame( '***', $params['password'] ); + $this->assertSame( 'hello', $params['search'] ); + } + + public function test_serve_filter_value_is_returned_unchanged(): void { + $instance = $this->create_instance( [] ); + $request = new WP_REST_Request( 'GET', '/wp/v2/posts' ); + $response = rest_do_request( $request ); + + $this->assertTrue( $instance->log_rest_request( true, $response, $request ) ); + $this->assertFalse( $instance->log_rest_request( false, $response, $request ) ); + $this->assertSame( 'pre', $instance->start_timer( 'pre', rest_get_server(), $request ) ); + } + + public function test_event_transform_can_modify_context(): void { + $this->create_instance( [] ); + $callback = static function ( array $payload ): array { + $payload['context']['custom'] = 'value'; + return $payload; + }; + add_filter( 'wpgraphql_logging_filter_' . Events::REST_REQUEST_SERVED, $callback ); + + $this->serve( 'GET', '/wp/v2/posts' ); + remove_filter( 'wpgraphql_logging_filter_' . Events::REST_REQUEST_SERVED, $callback ); + + $logs = $this->get_logs(); + $this->assertCount( 1, $logs ); + $this->assertSame( 'value', $logs[0]->get_context()['custom'] ); + } + + public function test_init_does_not_hook_when_setting_is_off(): void { + $this->reset_singleton(); + $this->set_basic_config( [ BasicConfigurationTab::LOG_REST_REQUESTS => false ] ); + + $this->assertNull( RestRequestLogger::init() ); + } + + public function test_init_hooks_when_setting_is_on(): void { + $this->reset_singleton(); + $this->set_basic_config( [ BasicConfigurationTab::LOG_REST_REQUESTS => true ] ); + + $instance = RestRequestLogger::init(); + $this->assertInstanceOf( RestRequestLogger::class, $instance ); + $this->assertSame( $instance, RestRequestLogger::init() ); + $this->assertSame( 0, has_filter( 'rest_pre_dispatch', [ $instance, 'start_timer' ] ) ); + $this->assertSame( 10, has_filter( 'rest_pre_serve_request', [ $instance, 'log_rest_request' ] ) ); + + $this->instance = $instance; + $this->reset_singleton(); + } + + protected function reset_singleton(): void { + $property = new \ReflectionProperty( RestRequestLogger::class, 'instance' ); + $property->setAccessible( true ); + $property->setValue( null, null ); + } + + /** + * @param array $basic_config + */ + protected function set_basic_config( array $basic_config ): void { + $helper = ConfigurationHelper::get_instance(); + update_option( $helper->get_option_key(), [ BasicConfigurationTab::get_name() => $basic_config ] ); + $helper->clear_cache(); + } +} diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Processors/DataSanizationProcessorTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Processors/DataSanizationProcessorTest.php index 56ef880bd..762136c25 100644 --- a/plugins/wpgraphql-logging/tests/wpunit/Logger/Processors/DataSanizationProcessorTest.php +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Processors/DataSanizationProcessorTest.php @@ -179,4 +179,94 @@ public function test_process_record_process_custom_rules(): void 'query' => 'query { posts { id } }' ]); } + + public function test_recommended_rules_anonymize_rest_params_and_response_password(): void { + $processor = $this->create_mock_processor( + [ + DataManagementTab::DATA_SANITIZATION_ENABLED => true, + DataManagementTab::DATA_SANITIZATION_METHOD => 'recommended', + ] + ); + + $record = new LogRecord( + new DateTimeImmutable('now'), + 'wpgraphql_logging_rest', + Level::Info, + 'WP REST POST /wp/v2/users', + [ + 'params' => [ + 'username' => 'admin', + 'password' => 'secret', + 'email' => 'admin@example.com', + 'name' => 'Admin', + ], + 'response' => [ + 'name' => 'App', + 'password' => 'abcd efgh ijkl mnop', + ], + ], + [] + ); + + $context = $processor->__invoke($record)->toArray()['context']; + + $this->assertSame('***', $context['params']['username']); + $this->assertSame('***', $context['params']['password']); + $this->assertSame('***', $context['params']['email']); + $this->assertSame('Admin', $context['params']['name']); + $this->assertSame('***', $context['response']['password']); + $this->assertSame('App', $context['response']['name']); + } + + public function test_recommended_rules_anonymize_nested_rest_passwords(): void { + $processor = $this->create_mock_processor( + [ + DataManagementTab::DATA_SANITIZATION_ENABLED => true, + DataManagementTab::DATA_SANITIZATION_METHOD => 'recommended', + ] + ); + + $record = new LogRecord( + new DateTimeImmutable('now'), + 'wpgraphql_logging_rest', + Level::Info, + 'WP REST POST /batch/v1', + [ + 'params' => [ + 'requests' => [ + [ + 'body' => [ + 'password' => 'batch request secret', + 'name' => 'App', + ], + ], + ], + ], + 'response' => [ + 'responses' => [ + [ + 'body' => [ + 'password' => 'batch response secret', + 'name' => 'App', + ], + ], + ], + 'body' => [ + 'password' => 'enveloped response secret', + 'name' => 'App', + ], + ], + ], + [] + ); + + $context = $processor->__invoke($record)->toArray()['context']; + + $this->assertSame('***', $context['params']['requests'][0]['body']['password']); + $this->assertSame('App', $context['params']['requests'][0]['body']['name']); + $this->assertSame('***', $context['response']['responses'][0]['body']['password']); + $this->assertSame('App', $context['response']['responses'][0]['body']['name']); + $this->assertSame('***', $context['response']['body']['password']); + $this->assertSame('App', $context['response']['body']['name']); + } } diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/ExcludeRestRouteRuleTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/ExcludeRestRouteRuleTest.php new file mode 100644 index 000000000..f53869a21 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/ExcludeRestRouteRuleTest.php @@ -0,0 +1,68 @@ +rule = new ExcludeRestRouteRule(); + } + + public function test_passes_when_no_routes_configured(): void { + $this->assertTrue( $this->rule->passes( [], '/wp/v2/posts' ) ); + $this->assertTrue( $this->rule->passes( [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => ' ' ], '/wp/v2/posts' ) ); + } + + public function test_passes_when_route_is_null(): void { + $this->assertTrue( $this->rule->passes( [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => '/wp/v2/posts' ], null ) ); + } + + public function test_fails_for_exact_and_prefixed_routes(): void { + $config = [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => '/wp/v2/users' ]; + + $this->assertFalse( $this->rule->passes( $config, '/wp/v2/users' ) ); + $this->assertFalse( $this->rule->passes( $config, '/wp/v2/users/me' ) ); + $this->assertTrue( $this->rule->passes( $config, '/wp/v2/posts' ) ); + } + + public function test_handles_whitespace_empty_entries_and_missing_slashes(): void { + $config = [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => ' , wp/v2/types , /wp-site-health/v1 ,' ]; + + $this->assertFalse( $this->rule->passes( $config, '/wp/v2/types/post' ) ); + $this->assertFalse( $this->rule->passes( $config, 'wp-site-health/v1/tests/background-updates' ) ); + $this->assertTrue( $this->rule->passes( $config, '/wp/v2/posts' ) ); + } + + public function test_does_not_match_sibling_routes_sharing_a_prefix(): void { + $config = [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => '/wp/v2/users/' ]; + + $this->assertFalse( $this->rule->passes( $config, '/wp/v2/users' ) ); + $this->assertTrue( $this->rule->passes( $config, '/wp/v2/users-extra' ) ); + } + + public function test_matching_is_case_insensitive(): void { + $config = [ BasicConfigurationTab::EXCLUDE_REST_ROUTES => '/wp/v2/Users' ]; + + $this->assertFalse( $this->rule->passes( $config, '/WP/V2/USERS/1' ) ); + } + + public function test_get_name(): void { + $this->assertSame( 'exclude_rest_route_rule', $this->rule->get_name() ); + } +} diff --git a/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/LogRestRequestsRuleTest.php b/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/LogRestRequestsRuleTest.php new file mode 100644 index 000000000..cf4c83f53 --- /dev/null +++ b/plugins/wpgraphql-logging/tests/wpunit/Logger/Rules/LogRestRequestsRuleTest.php @@ -0,0 +1,40 @@ +rule = new LogRestRequestsRule(); + } + + public function test_passes_when_enabled(): void { + $this->assertTrue( $this->rule->passes( [ BasicConfigurationTab::LOG_REST_REQUESTS => true ], '/wp/v2/posts' ) ); + $this->assertTrue( $this->rule->passes( [ BasicConfigurationTab::LOG_REST_REQUESTS => 1 ] ) ); + } + + public function test_fails_when_disabled_or_missing(): void { + $this->assertFalse( $this->rule->passes( [ BasicConfigurationTab::LOG_REST_REQUESTS => false ] ) ); + $this->assertFalse( $this->rule->passes( [] ) ); + } + + public function test_get_name(): void { + $this->assertSame( 'log_rest_requests_rule', $this->rule->get_name() ); + } +}