Cherry pick 51013 and 51046 into release/9.3 (#51108)

* add: sanitise query params in remote logging (PHP) (#51013)

* add: remote logger request uri sanitisation (JS) (#51046)

* add: remote logger request uri sanitisation

* md lint

* Update packages/js/remote-logging/README.md

Co-authored-by: Paul Sealock <psealock@gmail.com>

* pr feedback

---------

Co-authored-by: Paul Sealock <psealock@gmail.com>

---------

Co-authored-by: RJ <27843274+rjchow@users.noreply.github.com>
Co-authored-by: Paul Sealock <psealock@gmail.com>
This commit is contained in:
Chi-Hsuan Huang 2024-09-03 17:41:51 +08:00 committed by GitHub
parent f1f3bbee38
commit c82500bbe8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 786 additions and 421 deletions

View File

@ -2,6 +2,12 @@
A remote logging package for Automattic based projects. This package provides error tracking and logging capabilities, with support for rate limiting, stack trace formatting, and customizable error filtering.
## Installation
```bash
npm install @woocommerce/remote-logging --save
```
## Description
The WooCommerce Remote Logging package offers the following features:
@ -44,16 +50,42 @@ The WooCommerce Remote Logging package offers the following features:
}
```
## Remote Logging Conditions
Remote logging is subject to the following conditions:
1. **Remote Logging Enabled**: The package checks `window.wcSettings.isRemoteLoggingEnabled` to determine if the feature should be enabled. The value is set via PHP and passed to JS as a boolean. It requires tracks to be enabled and a few other conditions internally. Please see the [RemoteLogger.php](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php) for more details.
2. **Non-Development Environment**: It also checks `process.env.NODE_ENV` to ensure logging only occurs in non-development environments.
If either of these conditions are not met (Tracks is not enabled or the environment is development), no logs will be transmitted to the remote server.
## API Reference
- `init(config: RemoteLoggerConfig): void`: Initializes the remote logger with the given configuration.
- `log(severity: LogSeverity, message: string, extraData?: object): Promise<boolean>`: Logs a message with the specified severity and optional extra data.
- `captureException(error: Error, extraData?: object): void`: Captures an error and sends it to the remote API.
For more detailed information about types and interfaces, refer to the source code and inline documentation.
## Customization
You can customize the behavior of the remote logger using WordPress filters:
- `woocommerce_remote_logging_should_send_error`: Control whether an error should be sent to the remote API.
- `woocommerce_remote_logging_error_data`: Modify the error data before sending it to the remote API.
- `woocommerce_remote_logging_log_endpoint`: Customize the endpoint URL for sending log messages.
- `woocommerce_remote_logging_js_error_endpoint`: Customize the endpoint URL for sending JavaScript errors.
You can customize the behavior of the remote logger using WordPress filters. Here are the available filters:
### Example
### `woocommerce_remote_logging_should_send_error`
Control whether an error should be sent to the remote API.
**Parameters:**
- `shouldSend` (boolean): The default decision on whether to send the error.
- `error` (Error): The error object.
- `stackFrames` (Array): An array of stack frames from the error.
**Return value:** (boolean) Whether the error should be sent.
**Usage example:**
```js
import { addFilter } from '@wordpress/hooks';
@ -62,20 +94,105 @@ addFilter(
'woocommerce_remote_logging_should_send_error',
'my-plugin',
(shouldSend, error, stackFrames) => {
const containsPluginFrame = stackFrames.some(
( frame ) =>
frame.url && frame.url.includes( '/my-plugin/' );
);
// Custom logic to determine if the error should be sent
const containsPluginFrame = stackFrames.some(
(frame) => frame.url && frame.url.includes( /YOUR_PLUGIN_ASSET_PATH/ )
);
// Only send errors that originate from our plugin
return shouldSend && containsPluginFrame;
}
);
```
### API Reference
### `woocommerce_remote_logging_error_data`
- `init(config: RemoteLoggerConfig): void`: Initializes the remote logger with the given configuration.
- `log(severity: LogSeverity, message: string, extraData?: object): Promise<boolean>`: Logs a message with the specified severity and optional extra data.
- `captureException(error: Error, extraData?: object): void`: Captures an error and sends it to the remote API.
Modify the error data before sending it to the remote API.
For more detailed information about types and interfaces, refer to the source code and inline documentation.
**Parameters:**
- `errorData` (ErrorData): The error data object to be sent.
**Return value:** (ErrorData) The modified error data object.
**Usage example:**
```js
import { addFilter } from '@wordpress/hooks';
addFilter(
'woocommerce_remote_logging_error_data',
'my-plugin',
(errorData) => {
// Custom logic to modify error data
errorData.tags = [ ...errorData.tags, 'my-plugin' ];
return errorData;
}
);
```
### `woocommerce_remote_logging_log_endpoint`
Modify the URL of the remote logging API endpoint.
**Parameters:**
- `endpoint` (string): The default endpoint URL.
**Return value:** (string) The modified endpoint URL.
**Usage example:**
```js
import { addFilter } from '@wordpress/hooks';
addFilter(
'woocommerce_remote_logging_log_endpoint',
'my-plugin',
(endpoint) => 'https://my-custom-endpoint.com/log'
);
```
### `woocommerce_remote_logging_js_error_endpoint`
Modify the URL of the remote logging API endpoint for JavaScript errors.
**Parameters:**
- `endpoint` (string): The default endpoint URL for JavaScript errors.
**Return value:** (string) The modified endpoint URL for JavaScript errors.
**Usage example:**
```js
import { addFilter } from '@wordpress/hooks';
addFilter(
'woocommerce_remote_logging_js_error_endpoint',
'my-plugin',
(endpoint) => 'https://my-custom-endpoint.com/js-error-log'
);
```
### `woocommerce_remote_logging_request_uri_whitelist`
Modifies the list of whitelisted query parameters that won't be masked in the logged request URI
**Parameters:**
- `whitelist` (string[]): The default whitelist.
**Return value:** (string[]) The modified whitelist.
**Usage example:**
```js
import { addFilter } from '@wordpress/hooks';
addFilter(
'woocommerce_remote_logging_request_uri_whitelist',
'my-plugin',
( whitelist ) => {
return [ ...whitelist, 'exampleParam' ]
}
);
```

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Add query params sanitisation

View File

@ -33,6 +33,44 @@ export const REMOTE_LOGGING_LOG_ENDPOINT_FILTER =
export const REMOTE_LOGGING_JS_ERROR_ENDPOINT_FILTER =
'woocommerce_remote_logging_js_error_endpoint';
export const REMOTE_LOGGING_REQUEST_URI_PARAMS_WHITELIST_FILTER =
'woocommerce_remote_logging_request_uri_whitelist';
export const REMPOTE_LOGGING_REQUEST_URI_PARAMS_DEFAULT_WHITELIST = [
'path',
'page',
'step',
'task',
'tab',
'section',
'status',
'post_type',
'taxonomy',
'action',
];
export const sanitiseRequestUriParams = ( search: string ) => {
const params = new URLSearchParams( search );
/**
* This filter modifies the list of whitelisted query parameters that won't be masked
* in the logged request URI
*
* @filter woocommerce_remote_logging_request_uri_whitelist
* @param {string[]} whitelist The default whitelist
*/
const whitelist = applyFilters(
REMOTE_LOGGING_REQUEST_URI_PARAMS_WHITELIST_FILTER,
REMPOTE_LOGGING_REQUEST_URI_PARAMS_DEFAULT_WHITELIST
) as typeof REMPOTE_LOGGING_REQUEST_URI_PARAMS_DEFAULT_WHITELIST;
for ( const [ key ] of params ) {
if ( ! whitelist.includes( key ) ) {
params.set( key, 'xxxxxx' );
}
}
return params.toString();
};
const REMOTE_LOGGING_LAST_ERROR_SENT_KEY =
'wc_remote_logging_last_error_sent_time';
@ -106,7 +144,8 @@ export class RemoteLogger {
properties: {
...extraData?.properties,
request_uri:
window.location.pathname + window.location.search,
window.location.pathname +
sanitiseRequestUriParams( window.location.search ),
},
} ),
trace: this.getFormattedStackFrame(
@ -213,7 +252,8 @@ export class RemoteLogger {
tags: [ 'js-unhandled-error' ],
properties: {
request_uri:
window.location.pathname + window.location.search,
window.location.pathname +
sanitiseRequestUriParams( window.location.search ),
},
} ),
trace: this.getFormattedStackFrame( trace ),

View File

@ -13,6 +13,8 @@ import {
REMOTE_LOGGING_ERROR_DATA_FILTER,
REMOTE_LOGGING_LOG_ENDPOINT_FILTER,
REMOTE_LOGGING_JS_ERROR_ENDPOINT_FILTER,
sanitiseRequestUriParams,
REMOTE_LOGGING_REQUEST_URI_PARAMS_WHITELIST_FILTER,
} from '../remote-logger';
import { fetchMock } from './__mocks__/fetch';
@ -380,3 +382,24 @@ describe( 'captureException', () => {
expect( fetchMock ).not.toHaveBeenCalled();
} );
} );
describe( 'sanitiseRequestUriParams', () => {
afterEach(() => {
removeFilter(REMOTE_LOGGING_REQUEST_URI_PARAMS_WHITELIST_FILTER, 'test' );
})
it( 'should replace non-whitelisted params with xxxxxx', () => {
expect(sanitiseRequestUriParams('?path=home&user=admin&token=abc123')).toEqual('path=home&user=xxxxxx&token=xxxxxx')
})
it( 'should not replace whitelisted params with xxxxxx', () => {
expect(sanitiseRequestUriParams('?path=home')).toEqual('path=home')
})
it( 'should not do anything if empty string is passed in', () => {
expect(sanitiseRequestUriParams('')).toEqual('')
})
it( 'should apply filters correctly', () => {
addFilter( REMOTE_LOGGING_REQUEST_URI_PARAMS_WHITELIST_FILTER, 'test', (defaultWhitelist) => {
return [ ... defaultWhitelist, 'foo' ];
})
expect(sanitiseRequestUriParams('?path=home&foo=bar&user=admin&token=abc123')).toEqual('path=home&foo=bar&user=xxxxxx&token=xxxxxx')
})
})

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Added more paths to remote logger query param whitelist

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Add query params masking to remote logger

View File

@ -71,7 +71,7 @@ class RemoteLogger extends \WC_Log_Handler {
'wc_version' => WC()->version,
'php_version' => phpversion(),
'wp_version' => get_bloginfo( 'version' ),
'request_uri' => filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ),
'request_uri' => $this->sanitize_request_uri( filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ) ),
),
);
@ -435,4 +435,63 @@ class RemoteLogger extends \WC_Log_Handler {
protected function is_dev_or_local_environment() {
return in_array( wp_get_environment_type(), array( 'development', 'local' ), true );
}
/**
* Sanitize the request URI to only allow certain query parameters.
*
* @param string $request_uri The request URI to sanitize.
* @return string The sanitized request URI.
*/
private function sanitize_request_uri( $request_uri ) {
$default_whitelist = array(
'path',
'page',
'step',
'task',
'tab',
'section',
'status',
'post_type',
'taxonomy',
'action',
);
/**
* Filter to allow other plugins to whitelist request_uri query parameter values for unmasked remote logging.
*
* @since 9.4.0
*
* @param string $default_whitelist The default whitelist of query parameters.
*/
$whitelist = apply_filters( 'woocommerce_remote_logger_request_uri_whitelist', $default_whitelist );
$parsed_url = wp_parse_url( $request_uri );
if ( ! isset( $parsed_url['query'] ) ) {
return $request_uri;
}
parse_str( $parsed_url['query'], $query_params );
foreach ( $query_params as $key => &$value ) {
if ( ! in_array( $key, $whitelist, true ) ) {
$value = 'xxxxxx';
}
}
$parsed_url['query'] = http_build_query( $query_params );
return $this->build_url( $parsed_url );
}
/**
* Build a URL from its parsed components.
*
* @param array $parsed_url The parsed URL components.
* @return string The built URL.
*/
private function build_url( $parsed_url ) {
$path = $parsed_url['path'] ?? '';
$query = isset( $parsed_url['query'] ) ? "?{$parsed_url['query']}" : '';
$fragment = isset( $parsed_url['fragment'] ) ? "#{$parsed_url['fragment']}" : '';
return "$path$query$fragment";
}
}

View File

@ -1,469 +1,583 @@
<?php
declare( strict_types = 1 );
namespace Automattic\WooCommerce\Tests\Internal\Logging;
use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
use WC_Rate_Limiter;
use WC_Cache_Helper;
/**
* Class RemoteLoggerTest.
*/
class RemoteLoggerTest extends \WC_Unit_Test_Case {
/**
* System under test.
*
* @var RemoteLogger
*/
private $sut;
// phpcs:disable Universal.Namespaces.DisallowCurlyBraceSyntax.Forbidden -- need to override filter_input
// phpcs:disable Universal.Files.SeparateFunctionsFromOO.Mixed -- same
// phpcs:disable Universal.Namespaces.OneDeclarationPerFile.MultipleFound -- same
namespace Automattic\WooCommerce\Tests\Internal\Logging {
use Automattic\WooCommerce\Internal\Logging\RemoteLogger;
use WC_Rate_Limiter;
use WC_Cache_Helper;
/**
* Set up test
*
* @return void
* Class RemoteLoggerTest.
*/
public function setUp(): void {
parent::setUp();
$this->sut = wc_get_container()->get( RemoteLogger::class );
WC()->version = '9.2.0';
}
class RemoteLoggerTest extends \WC_Unit_Test_Case {
/**
* System under test.
*
* @var RemoteLogger
*/
private $sut;
/**
* Tear down.
*
* @return void
*/
public function tearDown(): void {
$this->cleanup_filters();
delete_option( 'woocommerce_feature_remote_logging_enabled' );
delete_transient( RemoteLogger::WC_LATEST_VERSION_TRANSIENT );
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_rate_limits" );
WC_Cache_Helper::invalidate_cache_group( WC_Rate_Limiter::CACHE_GROUP );
}
/**
* Cleanup filters used in tests.
*
* @return void
*/
private function cleanup_filters() {
$filters = array(
'option_woocommerce_admin_remote_feature_enabled',
'option_woocommerce_allow_tracking',
'option_woocommerce_version',
'option_woocommerce_remote_variant_assignment',
'plugins_api',
'pre_http_request',
'woocommerce_remote_logger_formatted_log_data',
);
foreach ( $filters as $filter ) {
remove_all_filters( $filter );
/**
* Set up test
*
* @return void
*/
public function setUp(): void {
parent::setUp();
$this->sut = wc_get_container()->get( RemoteLogger::class );
}
}
/**
* @testdox Remote logging is allowed when all conditions are met
*/
public function test_remote_logging_allowed() {
$this->setup_remote_logging_conditions( true );
$this->assertTrue( $this->sut->is_remote_logging_allowed() );
}
/**
* @testdox Remote logging is not allowed under various conditions
* @dataProvider remote_logging_disallowed_provider
*
* @param string $condition The condition being tested.
* @param callable $setup_callback Callback to set up the test condition.
*/
public function test_remote_logging_not_allowed( $condition, $setup_callback ) {
$this->setup_remote_logging_conditions( true );
$setup_callback( $this );
$this->assertFalse( $this->sut->is_remote_logging_allowed() );
}
/**
* Data provider for test_remote_logging_not_allowed.
*
* @return array[] Test cases with conditions and setup callbacks.
*/
public function remote_logging_disallowed_provider() {
return array(
'feature flag disabled' => array(
'condition' => 'feature flag disabled',
'setup' => fn() => update_option( 'woocommerce_feature_remote_logging_enabled', 'no' ),
),
'tracking opted out' => array(
'condition' => 'tracking opted out',
'setup' => fn() => add_filter( 'option_woocommerce_allow_tracking', fn() => 'no' ),
),
'outdated version' => array(
'condition' => 'outdated version',
'setup' => fn() => WC()->version = '9.0.0',
),
'high variant assignment' => array(
'condition' => 'high variant assignment',
'setup' => fn() => add_filter( 'option_woocommerce_remote_variant_assignment', fn() => 15 ),
),
);
}
/**
* @testdox Fetch latest WooCommerce version retries on API failure
*/
public function test_fetch_latest_woocommerce_version_retry() {
$this->setup_remote_logging_conditions( true );
add_filter( 'plugins_api', fn() => new \WP_Error(), 10, 3 );
for ( $i = 1; $i <= 4; $i++ ) {
$this->sut->is_remote_logging_allowed();
$retry_count = get_transient( RemoteLogger::FETCH_LATEST_VERSION_RETRY );
$this->assertEquals( min( $i, 3 ), $retry_count );
/**
* Tear down.
*
* @return void
*/
public function tearDown(): void {
$this->cleanup_filters();
delete_option( 'woocommerce_feature_remote_logging_enabled' );
delete_transient( RemoteLogger::WC_LATEST_VERSION_TRANSIENT );
global $wpdb;
$wpdb->query( "DELETE FROM {$wpdb->prefix}wc_rate_limits" );
WC_Cache_Helper::invalidate_cache_group( WC_Rate_Limiter::CACHE_GROUP );
}
}
/**
* @testdox get_formatted_log method returns expected array structure
* @dataProvider get_formatted_log_provider
*
* @param string $level The log level.
* @param string $message The log message.
* @param array $context The log context.
* @param array $expected The expected formatted log array.
*/
public function test_get_formatted_log( $level, $message, $context, $expected ) {
$formatted_log = $this->sut->get_formatted_log( $level, $message, $context );
foreach ( $expected as $key => $value ) {
$this->assertArrayHasKey( $key, $formatted_log );
$this->assertEquals( $value, $formatted_log[ $key ] );
/**
* Cleanup filters used in tests.
*
* @return void
*/
private function cleanup_filters() {
$filters = array(
'option_woocommerce_admin_remote_feature_enabled',
'option_woocommerce_allow_tracking',
'option_woocommerce_version',
'option_woocommerce_remote_variant_assignment',
'plugins_api',
'pre_http_request',
'woocommerce_remote_logger_formatted_log_data',
'pre_site_transient_update_plugins',
'woocommerce_remote_logger_request_uri_whitelist',
);
foreach ( $filters as $filter ) {
remove_all_filters( $filter );
}
}
}
/**
* Data provider for test_get_formatted_log.
*
* @return array[] Test cases with log data and expected formatted output.
*/
public function get_formatted_log_provider() {
return array(
'basic log data' => array(
'error',
'Fatal error occurred at line 123 in ' . ABSPATH . 'wp-content/file.php',
array( 'tags' => array( 'tag1', 'tag2' ) ),
array(
'feature' => 'woocommerce_core',
'severity' => 'error',
'message' => 'Fatal error occurred at line 123 in **/wp-content/file.php',
'tags' => array( 'woocommerce', 'php', 'tag1', 'tag2' ),
/**
* @testdox Remote logging is allowed when all conditions are met
*/
public function test_remote_logging_allowed() {
$this->setup_remote_logging_conditions( true );
$this->assertTrue( $this->sut->is_remote_logging_allowed() );
}
/**
* @testdox Remote logging is not allowed under various conditions
* @dataProvider remote_logging_disallowed_provider
*
* @param string $condition The condition being tested.
* @param callable $setup_callback Callback to set up the test condition.
*/
public function test_remote_logging_not_allowed( $condition, $setup_callback ) {
$this->setup_remote_logging_conditions( true );
$setup_callback( $this );
$this->assertFalse( $this->sut->is_remote_logging_allowed() );
}
/**
* Data provider for test_remote_logging_not_allowed.
*
* @return array[] Test cases with conditions and setup callbacks.
*/
public function remote_logging_disallowed_provider() {
return array(
'feature flag disabled' => array(
'condition' => 'feature flag disabled',
'setup' => fn() => update_option( 'woocommerce_feature_remote_logging_enabled', 'no' ),
),
),
'log with backtrace' => array(
'error',
'Test error message',
array( 'backtrace' => ABSPATH . 'wp-content/plugins/woocommerce/file.php' ),
array( 'trace' => '**/woocommerce/file.php' ),
),
'log with extra attributes' => array(
'error',
'Test error message',
array(
'extra' => array(
'key1' => 'value1',
'key2' => 'value2',
'tracking opted out' => array(
'condition' => 'tracking opted out',
'setup' => fn() => add_filter( 'option_woocommerce_allow_tracking', fn() => 'no' ),
),
'outdated version' => array(
'condition' => 'outdated version',
'setup' => function () {
$version = WC()->version;
$next_version = implode(
'.',
array_map(
function ( $n, $i ) {
return 0 === $i ? $n + 1 : 0;
},
explode( '.', $version ),
array_keys( explode( '.', $version ) )
)
);
set_transient( RemoteLogger::WC_LATEST_VERSION_TRANSIENT, $next_version );
},
'high variant assignment' => array(
'condition' => 'high variant assignment',
'setup' => fn() => add_filter( 'option_woocommerce_remote_variant_assignment', fn() => 15 ),
),
),
array(
'extra' => array(
'key1' => 'value1',
'key2' => 'value2',
);
}
/**
* @testdox Fetch latest WooCommerce version retries on API failure
*/
public function test_fetch_latest_woocommerce_version_retry() {
$this->setup_remote_logging_conditions( true );
add_filter( 'plugins_api', fn() => new \WP_Error(), 10, 3 );
for ( $i = 1; $i <= 4; $i++ ) {
$this->sut->is_remote_logging_allowed();
$retry_count = get_transient( RemoteLogger::FETCH_LATEST_VERSION_RETRY );
$this->assertEquals( min( $i, 3 ), $retry_count );
}
}
/**
* @testdox get_formatted_log method returns expected array structure
* @dataProvider get_formatted_log_provider
*
* @param string $level The log level.
* @param string $message The log message.
* @param array $context The log context.
* @param array $expected The expected formatted log array.
*/
public function test_get_formatted_log( $level, $message, $context, $expected ) {
$formatted_log = $this->sut->get_formatted_log( $level, $message, $context );
foreach ( $expected as $key => $value ) {
$this->assertArrayHasKey( $key, $formatted_log );
$this->assertEquals( $value, $formatted_log[ $key ] );
}
}
/**
* Data provider for test_get_formatted_log.
*
* @return array[] Test cases with log data and expected formatted output.
*/
public function get_formatted_log_provider() {
return array(
'basic log data' => array(
'error',
'Fatal error occurred at line 123 in ' . ABSPATH . 'wp-content/file.php',
array( 'tags' => array( 'tag1', 'tag2' ) ),
array(
'feature' => 'woocommerce_core',
'severity' => 'error',
'message' => 'Fatal error occurred at line 123 in **/wp-content/file.php',
'tags' => array( 'woocommerce', 'php', 'tag1', 'tag2' ),
),
),
),
);
}
'log with backtrace' => array(
'error',
'Test error message',
array( 'backtrace' => ABSPATH . 'wp-content/plugins/woocommerce/file.php' ),
array( 'trace' => '**/woocommerce/file.php' ),
),
'log with extra attributes' => array(
'error',
'Test error message',
array(
'extra' => array(
'key1' => 'value1',
'key2' => 'value2',
),
),
array(
'extra' => array(
'key1' => 'value1',
'key2' => 'value2',
),
),
),
);
}
/**
* @testdox should_handle method behaves correctly under different conditions
* @dataProvider should_handle_provider
*
* @param callable $setup Function to set up the test environment.
* @param string $level Log level to test.
* @param bool $expected Expected result of should_handle method.
*/
public function test_should_handle( $setup, $level, $expected ) {
$this->sut = $this->getMockBuilder( RemoteLogger::class )
/**
* @testdox get_formatted_log method correctly sanitizes request URI
*/
public function test_get_formatted_log_sanitizes_request_uri() {
global $mock_filter_input, $mock_return;
$mock_filter_input = true;
$mock_return = '/shop?path=home&user=admin&token=abc123';
$formatted_log = $this->sut->get_formatted_log( 'error', 'Test message', array() );
$mock_filter_input = false;
$this->assertArrayHasKey( 'properties', $formatted_log );
$this->assertArrayHasKey( 'request_uri', $formatted_log['properties'] );
$this->assertNotNull( $formatted_log['properties']['request_uri'], 'Request URI should not be null' );
$this->assertStringContainsString( 'path=home', $formatted_log['properties']['request_uri'] );
$this->assertStringContainsString( 'user=xxxxxx', $formatted_log['properties']['request_uri'] );
$this->assertStringContainsString( 'token=xxxxxx', $formatted_log['properties']['request_uri'] );
}
/**
* @testdox sanitize_request_uri method respects whitelist filter
*/
public function test_sanitize_request_uri_respects_whitelist_filter() {
add_filter(
'woocommerce_remote_logger_request_uri_whitelist',
function ( $whitelist ) {
$whitelist[] = 'custom_param';
return $whitelist;
}
);
$request_uri = '/shop?path=home&custom_param=value&token=abc123';
$sanitized_uri = $this->invoke_private_method( $this->sut, 'sanitize_request_uri', array( $request_uri ) );
$this->assertStringContainsString( 'path=home', $sanitized_uri );
$this->assertStringContainsString( 'custom_param=value', $sanitized_uri );
$this->assertStringContainsString( 'token=xxxxxx', $sanitized_uri );
}
/**
* @testdox sanitize_request_uri method correctly sanitizes request URIs
*/
public function test_sanitize_request_uri() {
$reflection = new \ReflectionClass( $this->sut );
$method = $reflection->getMethod( 'sanitize_request_uri' );
$method->setAccessible( true );
// Test with whitelisted parameters.
$request_uri = '/shop?path=home&page=2&step=1&task=checkout';
$sanitized_uri = $method->invokeArgs( $this->sut, array( $request_uri ) );
$this->assertStringContainsString( 'path=home', $sanitized_uri );
$this->assertStringContainsString( 'page=2', $sanitized_uri );
$this->assertStringContainsString( 'step=1', $sanitized_uri );
$this->assertStringContainsString( 'task=checkout', $sanitized_uri );
// Test with non-whitelisted parameters.
$request_uri = '/shop?path=home&user=admin&token=abc123';
$sanitized_uri = $method->invokeArgs( $this->sut, array( $request_uri ) );
$this->assertStringContainsString( 'path=home', $sanitized_uri );
$this->assertStringContainsString( 'user=xxxxxx', $sanitized_uri );
$this->assertStringContainsString( 'token=xxxxxx', $sanitized_uri );
// Test with mixed parameters.
$request_uri = '/shop?path=home&page=2&user=admin&step=1&token=abc123';
$sanitized_uri = $method->invokeArgs( $this->sut, array( $request_uri ) );
$this->assertStringContainsString( 'path=home', $sanitized_uri );
$this->assertStringContainsString( 'page=2', $sanitized_uri );
$this->assertStringContainsString( 'step=1', $sanitized_uri );
$this->assertStringContainsString( 'user=xxxxxx', $sanitized_uri );
$this->assertStringContainsString( 'token=xxxxxx', $sanitized_uri );
}
/**
* @testdox should_handle method behaves correctly under different conditions
* @dataProvider should_handle_provider
*
* @param callable $setup Function to set up the test environment.
* @param string $level Log level to test.
* @param bool $expected Expected result of should_handle method.
*/
public function test_should_handle( $setup, $level, $expected ) {
$this->sut = $this->getMockBuilder( RemoteLogger::class )
->onlyMethods( array( 'is_remote_logging_allowed', 'is_third_party_error' ) )
->getMock();
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
$this->sut->method( 'is_third_party_error' )->willReturn( false );
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
$this->sut->method( 'is_third_party_error' )->willReturn( false );
$setup( $this );
$setup( $this );
$result = $this->invoke_private_method( $this->sut, 'should_handle', array( $level, 'Test message', array() ) );
$this->assertEquals( $expected, $result );
}
$result = $this->invoke_private_method( $this->sut, 'should_handle', array( $level, 'Test message', array() ) );
$this->assertEquals( $expected, $result );
}
/**
* Data provider for test_should_handle method.
*
* @return array Test cases for should_handle method.
*/
public function should_handle_provider() {
return array(
'throttled' => array(
fn() => WC_Rate_Limiter::set_rate_limit( RemoteLogger::RATE_LIMIT_ID, 10 ),
'critical',
false,
),
'less severe than critical' => array(
fn() => null,
'error',
false,
),
'critical level' => array(
fn() => null,
'critical',
true,
),
);
}
/**
* Data provider for test_should_handle method.
*
* @return array Test cases for should_handle method.
*/
public function should_handle_provider() {
return array(
'throttled' => array(
fn() => WC_Rate_Limiter::set_rate_limit( RemoteLogger::RATE_LIMIT_ID, 10 ),
'critical',
false,
),
'less severe than critical' => array(
fn() => null,
'error',
false,
),
'critical level' => array(
fn() => null,
'critical',
true,
),
);
}
/**
* @testdox handle method applies filter and doesn't send logs when filtered to null
*/
public function test_handle_filtered_log_null() {
$this->sut = $this->getMockBuilder( RemoteLogger::class )
/**
* @testdox handle method applies filter and doesn't send logs when filtered to null
*/
public function test_handle_filtered_log_null() {
$this->sut = $this->getMockBuilder( RemoteLogger::class )
->onlyMethods( array( 'is_remote_logging_allowed' ) )
->getMock();
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
add_filter( 'woocommerce_remote_logger_formatted_log_data', fn() => null, 10, 4 );
add_filter( 'pre_http_request', fn() => $this->fail( 'wp_safe_remote_post should not be called' ), 10, 3 );
add_filter( 'woocommerce_remote_logger_formatted_log_data', fn() => null, 10, 4 );
add_filter( 'pre_http_request', fn() => $this->fail( 'wp_safe_remote_post should not be called' ), 10, 3 );
$this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array() ) );
}
$this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array() ) );
}
/**
* @testdox handle method does not send logs in dev environment
*/
public function test_handle_does_not_send_logs_in_dev_environment() {
$this->sut = $this->getMockBuilder( RemoteLoggerWithEnvironmentOverride::class )
/**
* @testdox handle method does not send logs in dev environment
*/
public function test_handle_does_not_send_logs_in_dev_environment() {
$this->sut = $this->getMockBuilder( RemoteLoggerWithEnvironmentOverride::class )
->onlyMethods( array( 'is_remote_logging_allowed' ) )
->getMock();
$this->sut->set_is_dev_or_local( true );
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
$this->sut->set_is_dev_or_local( true );
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
$this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array() ) );
}
$this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array() ) );
}
/**
* @testdox handle method successfully sends log
*/
public function test_handle_successful() {
$this->sut = $this->getMockBuilder( RemoteLoggerWithEnvironmentOverride::class )
/**
* @testdox handle method successfully sends log
*/
public function test_handle_successful() {
$this->sut = $this->getMockBuilder( RemoteLoggerWithEnvironmentOverride::class )
->onlyMethods( array( 'is_remote_logging_allowed' ) )
->getMock();
$this->sut->set_is_dev_or_local( false );
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
$this->sut->set_is_dev_or_local( false );
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
add_filter(
'pre_http_request',
function ( $preempt, $args ) {
$this->assertArrayHasKey( 'body', $args );
$this->assertArrayHasKey( 'headers', $args );
return array(
'response' => array(
'code' => 200,
'message' => 'OK',
add_filter(
'pre_http_request',
function ( $preempt, $args ) {
$this->assertArrayHasKey( 'body', $args );
$this->assertArrayHasKey( 'headers', $args );
return array(
'response' => array(
'code' => 200,
'message' => 'OK',
),
'body' => wp_json_encode( array( 'success' => true ) ),
);
},
10,
3
);
$this->assertTrue( $this->sut->handle( time(), 'critical', 'Test message', array() ) );
$this->assertTrue( WC_Rate_Limiter::retried_too_soon( RemoteLogger::RATE_LIMIT_ID ) );
}
/**
* @testdox handle method handles remote logging failure
*/
public function test_handle_remote_logging_failure() {
$this->sut = $this->getMockBuilder( RemoteLoggerWithEnvironmentOverride::class )
->onlyMethods( array( 'is_remote_logging_allowed' ) )
->getMock();
$this->sut->set_is_dev_or_local( false );
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
add_filter(
'pre_http_request',
function ( $preempt, $args, $url ) {
if ( 'https://public-api.wordpress.com/rest/v1.1/logstash' === $url ) {
throw new \Exception( 'Remote logging failed: A valid URL was not provided.' );
}
return $preempt;
},
10,
3
);
$this->assertFalse( $this->sut->handle( time(), 'critical', 'Test message', array() ) );
$this->assertTrue( WC_Rate_Limiter::retried_too_soon( RemoteLogger::RATE_LIMIT_ID ) );
}
/**
* @testdox is_third_party_error method correctly identifies third-party errors
* @dataProvider is_third_party_error_provider
* @param string $message The error message to check.
* @param array $context The context of the error.
* @param bool $expected_result The expected result of the check.
*/
public function test_is_third_party_error( $message, $context, $expected_result ) {
$result = $this->invoke_private_method( $this->sut, 'is_third_party_error', array( $message, $context ) );
$this->assertEquals( $expected_result, $result );
}
/**
* Data provider for test_is_third_party_error.
*
* @return array[] Test cases.
*/
public function is_third_party_error_provider() {
return array(
array( 'Fatal error in ' . WC_ABSPATH . 'file.php', array(), false ),
array( 'Fatal error in /wp-content/file.php', array(), false ),
array( 'Fatal error in /wp-content/file.php', array( 'source' => 'fatal-errors' ), false ),
array(
'Fatal error in /wp-content/plugins/3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array( '/wp-content/plugins/3rd-plugin/file.php', WC_ABSPATH . 'file.php' ),
),
'body' => wp_json_encode( array( 'success' => true ) ),
);
},
10,
3
);
$this->assertTrue( $this->sut->handle( time(), 'critical', 'Test message', array() ) );
$this->assertTrue( WC_Rate_Limiter::retried_too_soon( RemoteLogger::RATE_LIMIT_ID ) );
}
/**
* @testdox handle method handles remote logging failure
*/
public function test_handle_remote_logging_failure() {
$this->sut = $this->getMockBuilder( RemoteLoggerWithEnvironmentOverride::class )
->onlyMethods( array( 'is_remote_logging_allowed' ) )
->getMock();
$this->sut->set_is_dev_or_local( false );
$this->sut->method( 'is_remote_logging_allowed' )->willReturn( true );
add_filter(
'pre_http_request',
function ( $preempt, $args, $url ) {
if ( 'https://public-api.wordpress.com/rest/v1.1/logstash' === $url ) {
throw new \Exception( 'Remote logging failed: A valid URL was not provided.' );
}
return $preempt;
},
10,
3
);
$this->assertFalse( $this->sut->handle( time(), 'critical', 'Test message', array() ) );
$this->assertTrue( WC_Rate_Limiter::retried_too_soon( RemoteLogger::RATE_LIMIT_ID ) );
}
/**
* @testdox is_third_party_error method correctly identifies third-party errors
* @dataProvider is_third_party_error_provider
* @param string $message The error message to check.
* @param array $context The context of the error.
* @param bool $expected_result The expected result of the check.
*/
public function test_is_third_party_error( $message, $context, $expected_result ) {
$result = $this->invoke_private_method( $this->sut, 'is_third_party_error', array( $message, $context ) );
$this->assertEquals( $expected_result, $result );
}
/**
* Data provider for test_is_third_party_error.
*
* @return array[] Test cases.
*/
public function is_third_party_error_provider() {
return array(
array( 'Fatal error in ' . WC_ABSPATH . 'file.php', array(), false ),
array( 'Fatal error in /wp-content/file.php', array(), false ),
array( 'Fatal error in /wp-content/file.php', array( 'source' => 'fatal-errors' ), false ),
array(
'Fatal error in /wp-content/plugins/3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array( '/wp-content/plugins/3rd-plugin/file.php', WC_ABSPATH . 'file.php' ),
false,
),
false,
),
array(
'Fatal error in /wp-content/plugins/woocommerce-3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array( WP_PLUGIN_DIR . 'woocommerce-3rd-plugin/file.php' ),
'Fatal error in /wp-content/plugins/woocommerce-3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array( WP_PLUGIN_DIR . 'woocommerce-3rd-plugin/file.php' ),
),
true,
),
true,
),
array(
'Fatal error in /wp-content/plugins/3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array( WP_PLUGIN_DIR . '3rd-plugin/file.php' ),
'Fatal error in /wp-content/plugins/3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array( WP_PLUGIN_DIR . '3rd-plugin/file.php' ),
),
true,
),
true,
),
array(
'Fatal error in /wp-content/plugins/3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array( array( 'file' => WP_PLUGIN_DIR . '3rd-plugin/file.php' ) ),
'Fatal error in /wp-content/plugins/3rd-plugin/file.php',
array(
'source' => 'fatal-errors',
'backtrace' => array( array( 'file' => WP_PLUGIN_DIR . '3rd-plugin/file.php' ) ),
),
true,
),
true,
),
);
}
);
}
/**
* @testdox sanitize method correctly sanitizes paths
*/
public function test_sanitize() {
$message = WC_ABSPATH . 'includes/class-wc-test.php on line 123';
$expected = '**/woocommerce/includes/class-wc-test.php on line 123';
$result = $this->invoke_private_method( $this->sut, 'sanitize', array( $message ) );
$this->assertEquals( $expected, $result );
}
/**
* @testdox sanitize method correctly sanitizes paths
*/
public function test_sanitize() {
$message = WC_ABSPATH . 'includes/class-wc-test.php on line 123';
$expected = '**/woocommerce/includes/class-wc-test.php on line 123';
$result = $this->invoke_private_method( $this->sut, 'sanitize', array( $message ) );
$this->assertEquals( $expected, $result );
}
/**
* @testdox sanitize_trace method correctly sanitizes stack traces
*/
public function test_sanitize_trace() {
$trace = array(
WC_ABSPATH . 'includes/class-wc-test.php:123',
ABSPATH . 'wp-includes/plugin.php:456',
);
$expected = "**/woocommerce/includes/class-wc-test.php:123\n**/wp-includes/plugin.php:456";
$result = $this->invoke_private_method( $this->sut, 'sanitize_trace', array( $trace ) );
$this->assertEquals( $expected, $result );
}
/**
* @testdox sanitize_trace method correctly sanitizes stack traces
*/
public function test_sanitize_trace() {
$trace = array(
WC_ABSPATH . 'includes/class-wc-test.php:123',
ABSPATH . 'wp-includes/plugin.php:456',
);
$expected = "**/woocommerce/includes/class-wc-test.php:123\n**/wp-includes/plugin.php:456";
$result = $this->invoke_private_method( $this->sut, 'sanitize_trace', array( $trace ) );
$this->assertEquals( $expected, $result );
}
/**
* Setup common conditions for remote logging tests.
*
* @param bool $enabled Whether remote logging is enabled.
*/
private function setup_remote_logging_conditions( $enabled = true ) {
update_option( 'woocommerce_feature_remote_logging_enabled', $enabled ? 'yes' : 'no' );
add_filter( 'option_woocommerce_allow_tracking', fn() => 'yes' );
add_filter( 'option_woocommerce_remote_variant_assignment', fn() => 5 );
add_filter(
'plugins_api',
function ( $result, $action, $args ) {
if ( 'plugin_information' === $action && 'woocommerce' === $args->slug ) {
return (object) array( 'version' => '9.2.0' );
}
return $result;
},
10,
3
);
}
/**
* Helper method to invoke private methods.
*
* @param object $obj Object instance.
* @param string $method_name Name of the private method.
* @param array $parameters Parameters to pass to the method.
* @return mixed
*/
private function invoke_private_method( $obj, $method_name, $parameters = array() ) {
$reflection = new \ReflectionClass( get_class( $obj ) );
$method = $reflection->getMethod( $method_name );
$method->setAccessible( true );
return $method->invokeArgs( $obj, $parameters );
/**
* Setup common conditions for remote logging tests.
*
* @param bool $enabled Whether remote logging is enabled.
*/
private function setup_remote_logging_conditions( $enabled = true ) {
update_option( 'woocommerce_feature_remote_logging_enabled', $enabled ? 'yes' : 'no' );
add_filter( 'option_woocommerce_allow_tracking', fn() => 'yes' );
add_filter( 'option_woocommerce_remote_variant_assignment', fn() => 5 );
add_filter(
'plugins_api',
function ( $result, $action, $args ) use ( $enabled ) {
if ( 'plugin_information' === $action && 'woocommerce' === $args->slug ) {
return (object) array( 'version' => $enabled ? WC()->version : '9.0.0' );
}
return $result;
},
10,
3
);
}
/**
* Helper method to invoke private methods.
*
* @param object $obj Object instance.
* @param string $method_name Name of the private method.
* @param array $parameters Parameters to pass to the method.
* @return mixed
*/
private function invoke_private_method( $obj, $method_name, $parameters = array() ) {
$reflection = new \ReflectionClass( get_class( $obj ) );
$method = $reflection->getMethod( $method_name );
$method->setAccessible( true );
return $method->invokeArgs( $obj, $parameters );
}
}
}
//phpcs:disable Generic.Files.OneObjectStructurePerFile.MultipleFound, Squiz.Classes.ClassFileName.NoMatch, Suin.Classes.PSR4.IncorrectClassName
/**
* Mock class that extends RemoteLogger to allow overriding is_dev_or_local_environment.
*/
class RemoteLoggerWithEnvironmentOverride extends RemoteLogger {
/**
* The is_dev_or_local value.
*
* @var bool
* Mock class that extends RemoteLogger to allow overriding is_dev_or_local_environment.
*/
private $is_dev_or_local = false;
class RemoteLoggerWithEnvironmentOverride extends RemoteLogger {
/**
* The is_dev_or_local value.
*
* @var bool
*/
private $is_dev_or_local = false;
/**
* Set the is_dev_or_local value.
*
* @param bool $value The value to set.
*/
public function set_is_dev_or_local( $value ) {
$this->is_dev_or_local = $value;
/**
* Set the is_dev_or_local value.
*
* @param bool $value The value to set.
*/
public function set_is_dev_or_local( $value ) {
$this->is_dev_or_local = $value;
}
/**
* @inheritDoc
*/
protected function is_dev_or_local_environment() {
return $this->is_dev_or_local;
}
}
//phpcs:enable Generic.Files.OneObjectStructurePerFile.MultipleFound, Squiz.Classes.ClassFileName.NoMatch, Suin.Classes.PSR4.IncorrectClassName
}
/**
* Mocks for global functions used in RemoteLogger.php
*/
namespace Automattic\WooCommerce\Internal\Logging {
/**
* @inheritDoc
* The filter_input function will return NULL if we change the $_SERVER variables at runtime, so we
* need to override it in RemoteLogger's namespace when we want it to return a specific value for testing.
*
* @return mixed
*/
protected function is_dev_or_local_environment() {
return $this->is_dev_or_local;
function filter_input() {
global $mock_filter_input, $mock_return;
if ( true === $mock_filter_input ) {
return $mock_return;
} else {
return call_user_func_array( '\filter_input', func_get_args() );
}
}
}
//phpcs:enable Generic.Files.OneObjectStructurePerFile.MultipleFound, Squiz.Classes.ClassFileName.NoMatch, Suin.Classes.PSR4.IncorrectClassName