Commit 58c8c2e269 for woocommerce
commit 58c8c2e2699fc96c5682f48772678064f23f8f88
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Wed Jan 14 16:38:52 2026 +0100
Add support for relevant files to the RestApiCache trait (#62768)
diff --git a/plugins/woocommerce/changelog/pr-62768 b/plugins/woocommerce/changelog/pr-62768
new file mode 100644
index 0000000000..beac6e0f6e
--- /dev/null
+++ b/plugins/woocommerce/changelog/pr-62768
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Add support for relevant files to the RestApiCache trait
diff --git a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
index 3af2eb8d3c..8d468f2cb8 100644
--- a/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
+++ b/plugins/woocommerce/src/Internal/Traits/RestApiCache.php
@@ -70,6 +70,10 @@ use WP_REST_Response;
* 'cache_ttl' => HOUR_IN_SECONDS,
* // Optional array, defaults to the controller's get_hooks_relevant_to_caching().
* 'relevant_hooks' => array( 'filter_name_1', 'filter_name_2' ),
+ * // Optional array, defaults to the controller's get_files_relevant_to_response_caching().
+ * // Paths can be absolute or relative to the first directory from
+ * // get_allowed_directories_for_file_based_response_caching() (WC_ABSPATH by default).
+ * 'relevant_files' => array( 'data/config.json', '/absolute/path/to/file.php' ),
* // Optional bool, defaults to the controller's response_cache_vary_by_user().
* 'vary_by_user' => true,
* // Optional array, defaults to the controller's get_response_headers_to_include_in_caching().
@@ -89,6 +93,9 @@ use WP_REST_Response;
* - get_default_response_entity_type(): Default entity type for endpoints without explicit config.
* - response_cache_vary_by_user(): Whether cache should be user-specific.
* - get_hooks_relevant_to_caching(): Hook names to track for cache invalidation.
+ * - get_files_relevant_to_response_caching(): File paths to track for cache invalidation.
+ * - get_allowed_directories_for_file_based_response_caching(): Directories allowed for file tracking.
+ * - get_file_check_interval_for_response_caching(): How long to cache file modification checks (default 10 minutes).
* - get_ttl_for_cached_response(): TTL for cached outputs in seconds.
* - get_response_headers_to_include_in_caching(): Headers to include in cache (false = use exclusion mode).
* - get_response_headers_to_exclude_from_caching(): Headers to exclude from cache (when in exclusion mode).
@@ -97,6 +104,8 @@ use WP_REST_Response;
* - Entity versions change (tracked via VersionStringGenerator).
* - Hook callbacks change
* (if the `get_hooks_relevant_to_caching()` call result or the 'relevant_hooks' array isn't empty).
+ * - Tracked files change or are deleted
+ * (if the `get_files_relevant_to_response_caching()` call result or the 'relevant_files' array isn't empty).
* - Cached response TTL expires.
*
* NOTE: This caching mechanism uses the WordPress cache (wp_cache_* functions).
@@ -131,6 +140,20 @@ trait RestApiCache {
'Pragma',
);
+ /**
+ * Cache group for warning suppression (separate from main cache to avoid interference).
+ *
+ * @var string
+ */
+ private static string $warning_cache_group = 'woocommerce_rest_api_cache_warnings';
+
+ /**
+ * TTL for suppressing duplicate file tracking warnings (1 hour).
+ *
+ * @var int
+ */
+ private static int $file_warning_suppression_ttl = HOUR_IN_SECONDS;
+
/**
* The instance of VersionStringGenerator to use, or null if caching is disabled.
*
@@ -184,6 +207,7 @@ trait RestApiCache {
* - endpoint_id: string|null (optional friendly identifier for the endpoint).
* - cache_ttl: int (defaults to get_ttl_for_cached_response()).
* - relevant_hooks: array (defaults to get_hooks_relevant_to_caching()).
+ * - relevant_files: array (defaults to get_files_relevant_to_response_caching()).
* - include_headers: array|false (defaults to get_response_headers_to_include_in_caching()).
* - exclude_headers: array (defaults to get_response_headers_to_exclude_from_caching()).
* @return callable Wrapped callback.
@@ -217,14 +241,7 @@ trait RestApiCache {
return call_user_func( $callback, $request );
}
- $cached_config = null;
- $should_skip_cache = ! $this->should_use_cache_for_request( $request );
- if ( ! $should_skip_cache ) {
- $cached_config = $this->build_cache_config( $request, $config );
- $should_skip_cache = is_null( $cached_config );
- }
-
- if ( $should_skip_cache || is_null( $cached_config ) ) {
+ if ( ! $this->should_use_cache_for_request( $request ) ) {
$response = call_user_func( $callback, $request );
if ( ! is_wp_error( $response ) ) {
$response = rest_ensure_response( $response );
@@ -233,6 +250,8 @@ trait RestApiCache {
return $response;
}
+ $cached_config = $this->build_cache_config( $request, $config );
+
$this->is_handling_cached_endpoint = true;
if ( $backend_caching_enabled ) {
@@ -286,24 +305,20 @@ trait RestApiCache {
* @param WP_REST_Request<array<string, mixed>> $request The request object.
* @param array $config Raw configuration array passed to with_cache.
*
- * @return array|null Normalized cache config with keys: endpoint_id, entity_type, vary_by_user, cache_ttl, relevant_hooks, include_headers, exclude_headers, cache_key. Returns null if entity type is not available.
+ * @return array Normalized cache config with keys: endpoint_id, entity_type, vary_by_user, cache_ttl, relevant_hooks, relevant_files, include_headers, exclude_headers, cache_key.
*
- * @throws \InvalidArgumentException If include_headers is not false or an array.
+ * @throws \InvalidArgumentException If entity_type is not provided and no default is available, or if include_headers is not false or an array.
*/
- private function build_cache_config( WP_REST_Request $request, array $config ): ?array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
+ private function build_cache_config( WP_REST_Request $request, array $config ): array { // phpcs:ignore Squiz.Commenting.FunctionComment.IncorrectTypeHint
$endpoint_id = $config['endpoint_id'] ?? null;
$entity_type = $config['entity_type'] ?? $this->get_default_response_entity_type();
$vary_by_user = $config['vary_by_user'] ?? $this->response_cache_vary_by_user( $request, $endpoint_id );
if ( ! $entity_type ) {
- $legacy_proxy = wc_get_container()->get( LegacyProxy::class );
- $legacy_proxy->call_function(
- 'wc_doing_it_wrong',
- __METHOD__,
- 'No entity type provided and no default entity type available. Skipping cache.',
- '10.5.0'
+ throw new \InvalidArgumentException(
+ 'REST API cache: No entity type provided in with_cache() config and no default entity type available from get_default_response_entity_type(). ' .
+ 'Either pass "entity_type" in the config array or override get_default_response_entity_type() in your controller.'
);
- return null;
}
$include_headers = $config['include_headers'] ?? $this->get_response_headers_to_include_in_caching( $request, $endpoint_id );
@@ -319,6 +334,7 @@ trait RestApiCache {
'vary_by_user' => $vary_by_user,
'cache_ttl' => $config['cache_ttl'] ?? $this->get_ttl_for_cached_response( $request, $endpoint_id ),
'relevant_hooks' => $config['relevant_hooks'] ?? $this->get_hooks_relevant_to_caching( $request, $endpoint_id ),
+ 'relevant_files' => $config['relevant_files'] ?? $this->get_files_relevant_to_response_caching( $request, $endpoint_id ),
'include_headers' => $include_headers,
'exclude_headers' => $config['exclude_headers'] ?? $this->get_response_headers_to_exclude_from_caching( $request, $endpoint_id ),
'cache_key' => $this->get_key_for_cached_response( $request, $entity_type, $vary_by_user, $endpoint_id ),
@@ -376,6 +392,7 @@ trait RestApiCache {
$entity_ids,
$cached_config['cache_ttl'],
$cached_config['relevant_hooks'],
+ $cached_config['relevant_files'],
$cacheable_headers,
$etag
);
@@ -554,6 +571,62 @@ trait RestApiCache {
return array();
}
+ /**
+ * Get the paths of files whose modification affects the response.
+ *
+ * All the returned files will be tracked for changes: whenever a response is cached,
+ * each file's modification time is recorded, and if any file has changed or disappeared
+ * when the cached response is retrieved, the cache entry will be invalidated.
+ *
+ * Paths can be absolute or relative. Relative paths are resolved relative to the first
+ * directory returned by get_allowed_directories_for_file_based_response_caching().
+ *
+ * This can be customized per-endpoint via the config array
+ * passed to with_cache() ('relevant_files' key).
+ *
+ * @since 10.6.0
+ *
+ * @param WP_REST_Request<array<string, mixed>> $request Request object.
+ * @param string|null $endpoint_id Optional friendly identifier for the endpoint.
+ *
+ * @return array Array of file paths to track.
+ */
+ protected function get_files_relevant_to_response_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed, Squiz.Commenting.FunctionComment.IncorrectTypeHint
+ return array();
+ }
+
+ /**
+ * Get directories allowed for file-based response caching.
+ *
+ * Returns an array of directory paths that are allowed to contain files tracked
+ * for cache invalidation. The first directory in the array is also used as the
+ * base path for resolving relative file paths.
+ *
+ * @since 10.6.0
+ *
+ * @return array Array of absolute directory paths.
+ */
+ protected function get_allowed_directories_for_file_based_response_caching(): array {
+ return defined( 'WC_ABSPATH' ) ? array( WC_ABSPATH ) : array();
+ }
+
+ /**
+ * Get the interval for caching file modification checks.
+ *
+ * To avoid checking file modification times on every request, file checks are cached
+ * for this interval. During this period, files are assumed to be unchanged.
+ *
+ * Override this method to customize the interval. Return 0 to disable caching
+ * and check files on every request.
+ *
+ * @since 10.6.0
+ *
+ * @return int Interval in seconds. Default is 10 minutes (600 seconds).
+ */
+ protected function get_file_check_interval_for_response_caching(): int {
+ return 10 * MINUTE_IN_SECONDS;
+ }
+
/**
* Get the names of response headers to include in caching.
*
@@ -854,6 +927,292 @@ trait RestApiCache {
return md5( false === $json ? '' : $json );
}
+ /**
+ * Get the filtered list of allowed directories for file-based response caching.
+ *
+ * This method retrieves the allowed directories from the protected method
+ * and applies the woocommerce_rest_api_cache_allowed_file_directories filter.
+ *
+ * @since 10.6.0
+ *
+ * @return array Array of absolute directory paths.
+ */
+ private function get_filtered_allowed_directories_for_response_caching(): array {
+ $allowed_directories = $this->get_allowed_directories_for_file_based_response_caching();
+
+ /**
+ * Filter the directories allowed for file-based REST API response caching.
+ *
+ * This filter allows extensions to add additional directories that can contain
+ * files tracked for cache invalidation. The first directory in the array is
+ * used as the base path for resolving relative file paths.
+ *
+ * @since 10.6.0
+ *
+ * @param array $allowed_directories Array of absolute directory paths.
+ * @param object $controller The controller instance.
+ *
+ * @return array Filtered array of directory paths.
+ */
+ return apply_filters(
+ 'woocommerce_rest_api_cache_allowed_file_directories',
+ $allowed_directories,
+ $this
+ );
+ }
+
+ /**
+ * Generate a hash for the given file paths based on their modification times.
+ *
+ * This method resolves relative paths (relative to the first allowed directory),
+ * gets file modification times, and generates a hash for cache invalidation.
+ * Files that cannot be accessed (permissions, non-existent) are logged as warnings
+ * and excluded from tracking.
+ *
+ * To avoid filesystem calls on every request, file check results are cached
+ * for the interval returned by get_file_check_interval_for_response_caching().
+ *
+ * @since 10.6.0
+ *
+ * @param array $file_paths Array of file paths (absolute or relative to the first allowed directory).
+ *
+ * @return string Hash string, or empty string if no files could be tracked.
+ */
+ private function generate_files_hash( array $file_paths ): string {
+ if ( empty( $file_paths ) ) {
+ return '';
+ }
+
+ $allowed_directories = $this->get_filtered_allowed_directories_for_response_caching();
+ if ( empty( $allowed_directories ) ) {
+ $this->log_file_tracking_warning( '', 'No allowed directories configured for file tracking' );
+ return '';
+ }
+
+ $files_data = null;
+ $check_interval = $this->get_file_check_interval_for_response_caching();
+
+ // Try to get cached file check results to avoid filesystem calls on every request.
+ if ( $check_interval > 0 ) {
+ $file_check_cache_key = $this->get_file_check_cache_key( $file_paths, $allowed_directories );
+ $files_data = wp_cache_get( $file_check_cache_key, self::$cache_group );
+ if ( false === $files_data ) {
+ $files_data = null;
+ }
+ }
+
+ // Cache miss or caching disabled - check all files.
+ if ( is_null( $files_data ) ) {
+ $files_data = $this->check_files( $file_paths, $allowed_directories );
+
+ // Cache the results if caching is enabled.
+ if ( $check_interval > 0 && ! empty( $files_data ) ) {
+ wp_cache_set( $file_check_cache_key, $files_data, self::$cache_group, $check_interval );
+ }
+ }
+
+ /**
+ * Filter the file data used for REST API response cache invalidation.
+ *
+ * This filter allows modification of the file tracking data before it is stored
+ * in the cache and used for invalidation checks.
+ *
+ * @since 10.6.0
+ *
+ * @param array $files_data Array of file data, each with 'path' and 'time' keys.
+ * @param array $file_paths Original file paths passed to the method.
+ * @param object $controller Controller instance.
+ */
+ $files_data = apply_filters(
+ 'woocommerce_rest_api_cache_files_hash_data',
+ $files_data,
+ $file_paths,
+ $this
+ );
+
+ if ( empty( $files_data ) ) {
+ return '';
+ }
+
+ $json = wp_json_encode( $files_data );
+ return md5( false === $json ? '' : $json );
+ }
+
+ /**
+ * Generate a cache key for file check results.
+ *
+ * @param array $file_paths Array of file paths to track.
+ * @param array $allowed_directories Array of allowed directory paths.
+ *
+ * @return string Cache key.
+ */
+ private function get_file_check_cache_key( array $file_paths, array $allowed_directories ): string {
+ sort( $file_paths );
+ sort( $allowed_directories );
+ $key_data = array(
+ 'files' => $file_paths,
+ 'dirs' => $allowed_directories,
+ );
+ $json = wp_json_encode( $key_data );
+ return 'wc_rest_file_check_' . md5( false === $json ? '' : $json );
+ }
+
+ /**
+ * Check files and return their tracking data.
+ *
+ * @param array $file_paths Array of file paths to check.
+ * @param array $allowed_directories Array of allowed directory paths.
+ *
+ * @return array Array of file data, each with 'path' and 'time' keys.
+ */
+ private function check_files( array $file_paths, array $allowed_directories ): array {
+ $files_data = array();
+
+ foreach ( $file_paths as $file_path ) {
+ $resolved_path = $this->resolve_file_path( $file_path, $allowed_directories );
+
+ if ( is_null( $resolved_path ) ) {
+ $this->log_file_tracking_warning( $file_path, 'Path could not be resolved or is outside allowed directories' );
+ continue;
+ }
+
+ $file_entry = $this->get_file_tracking_entry( $resolved_path );
+ if ( is_null( $file_entry ) ) {
+ $this->log_file_tracking_warning( $resolved_path, 'File does not exist or cannot be accessed' );
+ continue;
+ }
+
+ $files_data[] = $file_entry;
+ }
+
+ return $files_data;
+ }
+
+ /**
+ * Resolve a file path to an absolute path.
+ *
+ * Relative paths are resolved relative to the first directory in the allowed directories list.
+ * All paths are converted to physical paths (symlinks resolved) for consistent comparison.
+ * Paths that resolve outside the allowed directories are rejected for security.
+ *
+ * @param string $file_path The file path to resolve (absolute or relative).
+ * @param array $allowed_directories Array of allowed directory paths.
+ *
+ * @return string|null The resolved absolute path, or null if the path is invalid or outside allowed directories.
+ */
+ private function resolve_file_path( string $file_path, array $allowed_directories ): ?string {
+ if ( empty( $allowed_directories ) ) {
+ return null;
+ }
+
+ if ( ! path_is_absolute( $file_path ) ) {
+ $base_path = trailingslashit( $allowed_directories[0] );
+ $file_path = $base_path . ltrim( $file_path, '/' );
+ }
+
+ $legacy_proxy = wc_get_container()->get( LegacyProxy::class );
+
+ $physical_path = $legacy_proxy->call_function( 'realpath', $file_path );
+ if ( false === $physical_path ) {
+ return null;
+ }
+
+ $normalized_path = wp_normalize_path( $physical_path );
+
+ foreach ( $allowed_directories as $dir ) {
+ $real_dir = $legacy_proxy->call_function( 'realpath', $dir );
+ if ( false === $real_dir ) {
+ continue;
+ }
+
+ $normalized_dir = trailingslashit( wp_normalize_path( $real_dir ) );
+ if ( 0 === strpos( $normalized_path, $normalized_dir ) ) {
+ return $normalized_path;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Log a warning about a file that couldn't be tracked.
+ *
+ * Each unique file path + reason combination is logged only once per the
+ * suppression TTL period to avoid flooding the log with repeated warnings.
+ * With a persistent object cache (Redis, Memcached), this works across requests.
+ * Without one, it prevents duplicates within the same request.
+ *
+ * @since 10.6.0
+ *
+ * @param string $file_path The file path that couldn't be tracked.
+ * @param string $reason The reason the file couldn't be tracked.
+ */
+ private function log_file_tracking_warning( string $file_path, string $reason ): void {
+ /**
+ * Filter the TTL for suppressing duplicate file tracking warnings.
+ *
+ * By default, each unique warning (file path + reason) is logged only once per hour
+ * to avoid flooding the log. Use this filter to customize the suppression period.
+ * Return 0 to disable suppression and log all warnings.
+ *
+ * @since 10.6.0
+ *
+ * @param int $ttl The suppression TTL in seconds. Default is HOUR_IN_SECONDS.
+ * @param string $file_path The file path that couldn't be tracked.
+ * @param string $reason The reason the file couldn't be tracked.
+ */
+ $suppression_ttl = apply_filters(
+ 'woocommerce_rest_api_cache_file_warning_suppression_ttl',
+ self::$file_warning_suppression_ttl,
+ $file_path,
+ $reason
+ );
+
+ if ( $suppression_ttl > 0 ) {
+ $warning_key = 'wc_rest_file_warning_' . md5( $file_path . '|' . $reason );
+
+ if ( false !== wp_cache_get( $warning_key, self::$warning_cache_group ) ) {
+ return;
+ }
+
+ wp_cache_set( $warning_key, true, self::$warning_cache_group, $suppression_ttl );
+ }
+
+ $logger = wc_get_container()->get( LegacyProxy::class )->call_function( 'wc_get_logger' );
+ $logger->warning(
+ sprintf(
+ 'REST API cache: Could not track file "%s" for cache invalidation. Reason: %s',
+ $file_path,
+ $reason
+ ),
+ array( 'source' => 'rest-api-cache' )
+ );
+ }
+
+ /**
+ * Get file tracking entry for a resolved path.
+ *
+ * @since 10.6.0
+ *
+ * @param string $resolved_path The resolved absolute file path.
+ *
+ * @return array{path: string, time: int}|null File entry with path and time, or null if file can't be accessed.
+ */
+ private function get_file_tracking_entry( string $resolved_path ): ?array {
+ $legacy_proxy = wc_get_container()->get( LegacyProxy::class );
+
+ // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- We handle the error gracefully.
+ $mtime = @$legacy_proxy->call_function( 'filemtime', $resolved_path );
+ if ( false === $mtime ) {
+ return null;
+ }
+
+ return array(
+ 'path' => $resolved_path,
+ 'time' => $mtime,
+ );
+ }
+
/**
* Get a cached response, but only if it's valid (otherwise the cached response will be invalidated).
*
@@ -894,6 +1253,18 @@ trait RestApiCache {
}
}
+ // Validate files hash if files are being tracked.
+ $relevant_files = $cached_config['relevant_files'];
+ if ( ! empty( $relevant_files ) ) {
+ $cached_files_hash = $cached['files_hash'] ?? '';
+ $current_files_hash = $this->generate_files_hash( $relevant_files );
+
+ if ( $current_files_hash !== $cached_files_hash ) {
+ wp_cache_delete( $cache_key, self::$cache_group );
+ return null;
+ }
+ }
+
if ( ! is_null( $this->version_string_generator ) ) {
foreach ( $cached['entity_versions'] as $entity_id => $cached_version ) {
$version_id = "{$entity_type}_{$entity_id}";
@@ -966,10 +1337,11 @@ trait RestApiCache {
* @param array $entity_ids Array of entity IDs in the response.
* @param int $cache_ttl Cache TTL in seconds.
* @param array $relevant_hooks Hook names to track for invalidation.
+ * @param array $relevant_files File paths to track for invalidation.
* @param array $headers Response headers to cache.
* @param string $etag ETag for the response.
*/
- private function store_cached_response( string $cache_key, $data, int $status_code, string $entity_type, array $entity_ids, int $cache_ttl, array $relevant_hooks, array $headers = array(), string $etag = '' ): void {
+ private function store_cached_response( string $cache_key, $data, int $status_code, string $entity_type, array $entity_ids, int $cache_ttl, array $relevant_hooks, array $relevant_files, array $headers = array(), string $etag = '' ): void {
$entity_versions = array();
if ( ! is_null( $this->version_string_generator ) ) {
foreach ( $entity_ids as $entity_id ) {
@@ -996,6 +1368,13 @@ trait RestApiCache {
$cache_data['hooks_hash'] = $this->generate_hooks_hash( $relevant_hooks );
}
+ if ( ! empty( $relevant_files ) ) {
+ $files_hash = $this->generate_files_hash( $relevant_files );
+ if ( ! empty( $files_hash ) ) {
+ $cache_data['files_hash'] = $files_hash;
+ }
+ }
+
if ( ! empty( $headers ) ) {
$cache_data['headers'] = $headers;
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
index 9bc2fb799b..9dc3209673 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Traits/RestApiCacheTest.php
@@ -68,6 +68,12 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
$this->version_generator = wc_get_container()->get( VersionStringGenerator::class );
$this->sut = $this->create_test_controller();
+ // Set default allowed directories to include WC_ABSPATH and temp dir for file tracking tests.
+ $this->sut->allowed_directories = array( WC_ABSPATH, sys_get_temp_dir() );
+
+ // Disable file check caching by default so tests can modify files and expect immediate invalidation.
+ $this->sut->file_check_interval = 0;
+
$this->reset_rest_server();
}
@@ -960,6 +966,10 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
'id' => 90,
'name' => 'Product with Controller Hooks',
),
+ 'with_controller_files' => array(
+ 'id' => 91,
+ 'name' => 'Product with Controller Files',
+ ),
'standard' => array(
'id' => 10,
'name' => 'Standard Product',
@@ -982,10 +992,13 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
public $endpoint_vary_by_user = array();
public $endpoint_ids = array();
public $controller_hooks = array();
+ public $controller_files = array();
public $response_headers = array();
public $custom_exclude_headers = array();
public $custom_include_headers = false;
public $endpoint_cache_config = array();
+ public $allowed_directories = null;
+ public $file_check_interval = null;
public function __construct() {
$this->namespace = 'wc/v3';
@@ -1005,6 +1018,7 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
$this->register_multi_method_route();
$this->register_cached_route( 'with_endpoint_hooks', array( 'relevant_hooks' => array( 'test_endpoint_hook_for_caching' ) ) );
$this->register_cached_route( 'with_controller_hooks' );
+ $this->register_cached_route( 'with_controller_files' );
$this->register_cached_route( 'standard' );
$this->register_custom_config_route( 'custom_endpoint_config' );
$this->register_custom_config_route( 'test_false_override' );
@@ -1092,6 +1106,24 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
return $this->controller_hooks;
}
+ protected function get_files_relevant_to_response_caching( WP_REST_Request $request, ?string $endpoint_id = null ): array {
+ return $this->controller_files;
+ }
+
+ protected function get_allowed_directories_for_file_based_response_caching(): array {
+ if ( null !== $this->allowed_directories ) {
+ return $this->allowed_directories;
+ }
+ return parent::get_allowed_directories_for_file_based_response_caching();
+ }
+
+ protected function get_file_check_interval_for_response_caching(): int {
+ if ( null !== $this->file_check_interval ) {
+ return $this->file_check_interval;
+ }
+ return parent::get_file_check_interval_for_response_caching();
+ }
+
protected function get_response_headers_to_include_in_caching( WP_REST_Request $request, ?string $endpoint_id = null ) {
return $this->custom_include_headers;
}
@@ -1210,54 +1242,30 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
}
/**
- * @testdox Cache-Control header is "private" when user is logged in and vary_by_user is true.
- */
- public function test_cache_control_private_when_user_logged_in() {
- wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
- array( 'is_user_logged_in' => fn() => true )
- );
-
- $response = $this->query_endpoint( 'single_entity' );
-
- $headers = $response->get_headers();
- $this->assertArrayHasKey( 'Cache-Control', $headers );
- $this->assertStringContainsString( 'private', $headers['Cache-Control'] );
- $this->assertStringContainsString( 'must-revalidate', $headers['Cache-Control'] );
- $this->assertStringContainsString( 'max-age=', $headers['Cache-Control'] );
- }
-
- /**
- * @testdox Cache-Control header is "public" when no user is logged in even with vary_by_user true.
+ * @testdox Cache-Control header visibility is set correctly based on login status and vary_by_user setting.
+ *
+ * @testWith [true, "single_entity", "private"]
+ * [false, "single_entity", "public"]
+ * [true, "no_vary_by_user", "public"]
+ *
+ * @param bool $logged_in Whether user is logged in.
+ * @param string $endpoint The endpoint to query.
+ * @param string $expected_visibility Expected Cache-Control visibility (public or private).
*/
- public function test_cache_control_public_when_no_user() {
+ public function test_cache_control_header_visibility( bool $logged_in, string $endpoint, string $expected_visibility ) {
wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
- array( 'is_user_logged_in' => fn() => false )
+ array( 'is_user_logged_in' => fn() => $logged_in )
);
- $response = $this->query_endpoint( 'single_entity' );
+ $response = $this->query_endpoint( $endpoint );
$headers = $response->get_headers();
$this->assertArrayHasKey( 'Cache-Control', $headers );
- $this->assertStringContainsString( 'public', $headers['Cache-Control'] );
+ $this->assertStringContainsString( $expected_visibility, $headers['Cache-Control'] );
$this->assertStringContainsString( 'must-revalidate', $headers['Cache-Control'] );
$this->assertStringContainsString( 'max-age=', $headers['Cache-Control'] );
}
- /**
- * @testdox Cache-Control header is "public" when vary_by_user is false regardless of login status.
- */
- public function test_cache_control_public_when_vary_by_user_false() {
- wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
- array( 'is_user_logged_in' => fn() => true )
- );
-
- $response = $this->query_endpoint( 'no_vary_by_user' );
-
- $headers = $response->get_headers();
- $this->assertArrayHasKey( 'Cache-Control', $headers );
- $this->assertStringContainsString( 'public', $headers['Cache-Control'] );
- }
-
/**
* @testdox Date and X-WC-Date headers are present and correctly formatted on cache HIT.
*/
@@ -1605,4 +1613,690 @@ class RestApiCacheTest extends WC_REST_Unit_Test_Case {
$has_filter = has_filter( 'rest_send_nocache_headers', array( $controller, 'handle_rest_send_nocache_headers' ) );
$this->assertFalse( $has_filter );
}
+
+ /**
+ * @testdox Cache is invalidated when a controller-level tracked file is modified or deleted.
+ *
+ * @testWith ["modify"]
+ * ["delete"]
+ *
+ * @param string $action The action to perform on the file (modify or delete).
+ */
+ public function test_cache_invalidated_when_controller_file_changes( string $action ) {
+ $test_file = $this->create_temp_test_file();
+
+ $this->sut->controller_files = array( $test_file );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+ $this->assertCount( 1, $this->get_all_cache_keys() );
+
+ $response2 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+
+ if ( 'delete' === $action ) {
+ unlink( $test_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
+ } else {
+ touch( $test_file, time() + 10 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch
+ }
+ clearstatcache( true, $test_file );
+
+ $response3 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response3, 'MISS', "Cache should be invalidated when tracked file is {$action}d" );
+
+ if ( 'modify' === $action ) {
+ unlink( $test_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
+ }
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox Files hash is stored in cache when tracking files.
+ */
+ public function test_files_hash_stored_in_cache() {
+ $test_file = $this->create_temp_test_file();
+
+ $this->sut->controller_files = array( $test_file );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $this->assertCount( 1, $cache_keys );
+ $cache_key = $cache_keys[0];
+ $cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+ $this->assertArrayHasKey( 'files_hash', $cached_entry );
+ $this->assertNotEmpty( $cached_entry['files_hash'] );
+
+ unlink( $test_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox Cache is invalidated when endpoint-level tracked files change.
+ */
+ public function test_cache_invalidated_when_endpoint_files_change() {
+ $test_file = $this->create_temp_test_file();
+
+ $this->sut->endpoint_cache_config['custom_endpoint_config']['config'] = array(
+ 'relevant_files' => array( $test_file ),
+ );
+ $this->sut->reinitialize_cache();
+ $this->reset_rest_server();
+
+ $response1 = $this->query_endpoint( 'custom_endpoint_config' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $response2 = $this->query_endpoint( 'custom_endpoint_config' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+
+ touch( $test_file, time() + 10 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch
+ clearstatcache( true, $test_file );
+
+ $response3 = $this->query_endpoint( 'custom_endpoint_config' );
+ $this->assertCacheHeader( $response3, 'MISS', 'Cache should be invalidated when endpoint-tracked file changes' );
+
+ unlink( $test_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink
+ }
+
+ /**
+ * @testdox Non-existent files are not tracked and do not prevent caching.
+ */
+ public function test_non_existent_files_not_tracked() {
+ $non_existent_file = sys_get_temp_dir() . '/wc_test_non_existent_' . uniqid() . '.txt';
+
+ $this->sut->controller_files = array( $non_existent_file );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $this->assertCount( 1, $cache_keys );
+ $cache_key = $cache_keys[0];
+ $cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+ $this->assertArrayNotHasKey( 'files_hash', $cached_entry, 'Files hash should not be stored when no files are tracked' );
+
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox Filter woocommerce_rest_api_cache_files_hash_data can modify file tracking data.
+ */
+ public function test_filter_can_modify_files_hash_data() {
+ $test_file = $this->create_temp_test_file();
+
+ $this->sut->controller_files = array( $test_file );
+
+ $filter_called = false;
+ $filter = function ( $files_data, $file_paths, $controller ) use ( &$filter_called, $test_file ) {
+ unset( $controller );
+ $filter_called = true;
+ $this->assertIsArray( $files_data );
+ $this->assertIsArray( $file_paths );
+ $this->assertContains( $test_file, $file_paths );
+ return array();
+ };
+ add_filter( 'woocommerce_rest_api_cache_files_hash_data', $filter, 10, 3 );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+ $this->assertTrue( $filter_called, 'Filter should have been called' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cache_key = $cache_keys[0];
+
+ $cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+ $this->assertArrayNotHasKey( 'files_hash', $cached_entry, 'Filter cleared files data, so no hash should be stored' );
+
+ remove_filter( 'woocommerce_rest_api_cache_files_hash_data', $filter, 10 );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+ unlink( $test_file );
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox Relative file paths are resolved relative to the first allowed directory.
+ */
+ public function test_relative_file_paths_resolved() {
+ $this->sut->controller_files = array( 'woocommerce.php' );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cache_key = $cache_keys[0];
+
+ $cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+ $this->assertArrayHasKey( 'files_hash', $cached_entry, 'Relative path should be resolved and tracked' );
+ $this->assertNotEmpty( $cached_entry['files_hash'] );
+
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox Filter woocommerce_rest_api_cache_allowed_file_directories can add directories.
+ */
+ public function test_filter_can_add_allowed_directories() {
+ $this->sut->allowed_directories = array( WC_ABSPATH );
+
+ $test_file = $this->create_temp_test_file();
+
+ $this->sut->controller_files = array( $test_file );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cached_entry = wp_cache_get( $cache_keys[0], self::CACHE_GROUP );
+ $this->assertArrayNotHasKey( 'files_hash', $cached_entry, 'File should not be tracked without temp dir in allowed directories' );
+
+ $filter = function ( $directories, $controller ) {
+ unset( $controller );
+ $directories[] = sys_get_temp_dir();
+ return $directories;
+ };
+ add_filter( 'woocommerce_rest_api_cache_allowed_file_directories', $filter, 10, 2 );
+
+ wp_cache_delete( $cache_keys[0], self::CACHE_GROUP );
+
+ $response2 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response2, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cached_entry = wp_cache_get( $cache_keys[0], self::CACHE_GROUP );
+ $this->assertArrayHasKey( 'files_hash', $cached_entry, 'File should be tracked after adding temp dir via filter' );
+
+ remove_filter( 'woocommerce_rest_api_cache_allowed_file_directories', $filter, 10 );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+ unlink( $test_file );
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox Controller can override get_allowed_directories_for_file_based_response_caching.
+ */
+ public function test_controller_can_override_allowed_directories() {
+ $test_file = $this->create_temp_test_file();
+
+ $this->sut->allowed_directories = array( WC_ABSPATH );
+ $this->sut->controller_files = array( $test_file );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cached_entry = wp_cache_get( $cache_keys[0], self::CACHE_GROUP );
+ $this->assertArrayNotHasKey( 'files_hash', $cached_entry, 'File outside allowed directories should not be tracked' );
+
+ $this->sut->allowed_directories = array( WC_ABSPATH, sys_get_temp_dir() );
+
+ wp_cache_delete( $cache_keys[0], self::CACHE_GROUP );
+
+ $response2 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response2, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cached_entry = wp_cache_get( $cache_keys[0], self::CACHE_GROUP );
+ $this->assertArrayHasKey( 'files_hash', $cached_entry, 'File should be tracked when its directory is allowed' );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+ unlink( $test_file );
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox File check results are cached for the configured interval.
+ */
+ public function test_file_check_results_are_cached() {
+ $test_file = $this->create_temp_test_file();
+
+ $this->sut->controller_files = array( $test_file );
+ $this->sut->file_check_interval = 600; // 10 minutes.
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $file_check_keys = array_filter(
+ $this->get_all_cache_keys(),
+ fn( $key ) => strpos( $key, 'wc_rest_file_check_' ) === 0
+ );
+ $this->assertCount( 1, $file_check_keys, 'File check cache entry should be created' );
+
+ // Modify the file - but since file check is cached, it won't be detected yet.
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- Test file modification.
+ touch( $test_file, time() + 10 );
+ clearstatcache( true, $test_file );
+
+ $response2 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response2, 'HIT', 'File change should not be detected while file check is cached' );
+
+ // Delete the file check cache to simulate interval expiration.
+ $file_check_key = array_values( $file_check_keys )[0];
+ wp_cache_delete( $file_check_key, self::CACHE_GROUP );
+
+ $response3 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response3, 'MISS', 'File change should be detected after file check cache expires' );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+ unlink( $test_file );
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox File check caching can be disabled by returning 0 from get_file_check_interval_for_response_caching.
+ */
+ public function test_file_check_caching_disabled_with_zero_interval() {
+ $test_file = $this->create_temp_test_file();
+
+ $this->sut->controller_files = array( $test_file );
+ $this->sut->file_check_interval = 0; // Disabled.
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $file_check_keys = array_filter(
+ $this->get_all_cache_keys(),
+ fn( $key ) => strpos( $key, 'wc_rest_file_check_' ) === 0
+ );
+ $this->assertCount( 0, $file_check_keys, 'No file check cache entry should be created when interval is 0' );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- Test file modification.
+ touch( $test_file, time() + 10 );
+ clearstatcache( true, $test_file );
+
+ $response2 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response2, 'MISS', 'File change should be detected immediately when caching is disabled' );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+ unlink( $test_file );
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox Multiple files can be tracked simultaneously.
+ */
+ public function test_multiple_files_tracked() {
+ $test_file1 = $this->create_temp_test_file();
+ $test_file2 = $this->create_temp_test_file();
+
+ $this->sut->controller_files = array( $test_file1, $test_file2 );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cache_key = $cache_keys[0];
+
+ $cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+ $this->assertArrayHasKey( 'files_hash', $cached_entry );
+ $this->assertNotEmpty( $cached_entry['files_hash'] );
+
+ $response2 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch -- Test file modification.
+ touch( $test_file2, time() + 10 );
+ clearstatcache( true, $test_file2 );
+
+ $response3 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response3, 'MISS', 'Cache should be invalidated when any tracked file changes' );
+
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+ unlink( $test_file1 );
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Test cleanup.
+ unlink( $test_file2 );
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox File path resolution failure (realpath returns false) is handled gracefully.
+ */
+ public function test_file_path_resolution_failure_handled() {
+ $fake_file = '/some/nonexistent/path/file.txt';
+
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array(
+ 'realpath' => function ( $path ) use ( $fake_file ) {
+ // Return false for the fake file, real path for others.
+ return $path === $fake_file ? false : \realpath( $path );
+ },
+ )
+ );
+
+ $this->sut->controller_files = array( $fake_file );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cache_key = $cache_keys[0];
+ $cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+ // No files_hash should be stored since file resolution failed.
+ $this->assertArrayNotHasKey( 'files_hash', $cached_entry );
+
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox File access error (filemtime returns false) is handled gracefully.
+ */
+ public function test_file_access_error_handled() {
+ $fake_file = WC_ABSPATH . 'unreadable_file.txt';
+ $fake_file_real = WC_ABSPATH . 'unreadable_file_real.txt';
+
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array(
+ 'realpath' => function ( $path ) use ( $fake_file, $fake_file_real ) {
+ if ( $path === $fake_file ) {
+ return $fake_file_real;
+ }
+ return \realpath( $path );
+ },
+ 'filemtime' => function ( $path ) use ( $fake_file_real ) {
+ // Return false for the fake file (simulating access error).
+ return $path === $fake_file_real ? false : \filemtime( $path );
+ },
+ )
+ );
+
+ $this->sut->controller_files = array( $fake_file );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cache_key = $cache_keys[0];
+ $cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+ // No files_hash should be stored since file access failed.
+ $this->assertArrayNotHasKey( 'files_hash', $cached_entry );
+
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox Cache is invalidated when file becomes inaccessible after being cached.
+ */
+ public function test_cache_invalidated_when_file_becomes_inaccessible() {
+ $fake_file = WC_ABSPATH . 'accessible_file.txt';
+ $fake_file_real = WC_ABSPATH . 'accessible_file_real.txt';
+ $file_accessible = true;
+
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array(
+ 'realpath' => function ( $path ) use ( $fake_file, $fake_file_real, &$file_accessible ) {
+ if ( $path === $fake_file ) {
+ return $file_accessible ? $fake_file_real : false;
+ }
+ return \realpath( $path );
+ },
+ 'filemtime' => function ( $path ) use ( $fake_file_real, &$file_accessible ) {
+ if ( $path === $fake_file_real ) {
+ return $file_accessible ? 1234567890 : false;
+ }
+ return \filemtime( $path );
+ },
+ )
+ );
+
+ $this->sut->controller_files = array( $fake_file );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cached_entry = wp_cache_get( $cache_keys[0], self::CACHE_GROUP );
+ $this->assertArrayHasKey( 'files_hash', $cached_entry );
+
+ $response2 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+
+ $file_accessible = false;
+
+ $response3 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response3, 'MISS', 'Cache should be invalidated when file becomes inaccessible' );
+
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox File outside allowed directories is rejected.
+ */
+ public function test_file_outside_allowed_directories_rejected() {
+ $outside_file = '/etc/passwd';
+ $outside_file_real = '/etc/passwd';
+
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array(
+ 'realpath' => function ( $path ) use ( $outside_file, $outside_file_real ) {
+ if ( $path === $outside_file ) {
+ return $outside_file_real;
+ }
+ return \realpath( $path );
+ },
+ )
+ );
+
+ $this->sut->controller_files = array( $outside_file );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cache_key = $cache_keys[0];
+ $cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+ // No files_hash should be stored since file is outside allowed directories.
+ $this->assertArrayNotHasKey( 'files_hash', $cached_entry );
+
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox Files in directories with similar prefix names are correctly rejected (prefix collision prevention).
+ */
+ public function test_directory_prefix_collision_prevented() {
+ // Simulate a directory structure where /var/www/htmlevil exists alongside /var/www/html.
+ // A file in /var/www/htmlevil should NOT be allowed when only /var/www/html is permitted.
+ $allowed_dir = '/var/www/html';
+ $evil_file = '/var/www/htmlevil/malicious.php';
+
+ $this->sut->allowed_directories = array( $allowed_dir );
+
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array(
+ 'realpath' => function ( $path ) use ( $allowed_dir, $evil_file ) {
+ if ( $path === $allowed_dir || $path === $evil_file ) {
+ return $path;
+ }
+ return \realpath( $path );
+ },
+ )
+ );
+
+ $this->sut->controller_files = array( $evil_file );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $cache_keys = $this->get_all_cache_keys();
+ $cache_key = $cache_keys[0];
+ $cached_entry = wp_cache_get( $cache_key, self::CACHE_GROUP );
+
+ $this->assertArrayNotHasKey( 'files_hash', $cached_entry, 'File in /var/www/htmlevil should not be allowed when only /var/www/html is permitted' );
+
+ $this->sut->controller_files = array();
+ $this->sut->allowed_directories = null;
+ }
+
+ /**
+ * @testdox Cache is invalidated when file modification time changes (using mocked filemtime).
+ */
+ public function test_cache_invalidated_when_mocked_file_mtime_changes() {
+ $fake_file = WC_ABSPATH . 'tracked_file.txt';
+ $fake_file_real = WC_ABSPATH . 'tracked_file_real.txt';
+ $file_mtime = 1234567890;
+
+ wc_get_container()->get( LegacyProxy::class )->register_function_mocks(
+ array(
+ 'realpath' => function ( $path ) use ( $fake_file, $fake_file_real ) {
+ if ( $path === $fake_file ) {
+ return $fake_file_real;
+ }
+ return \realpath( $path );
+ },
+ 'filemtime' => function ( $path ) use ( $fake_file_real, &$file_mtime ) {
+ if ( $path === $fake_file_real ) {
+ return $file_mtime;
+ }
+ return \filemtime( $path );
+ },
+ )
+ );
+
+ $this->sut->controller_files = array( $fake_file );
+
+ $response1 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response1, 'MISS' );
+
+ $response2 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response2, 'HIT' );
+
+ $file_mtime = 1234567999;
+
+ $response3 = $this->query_endpoint( 'with_controller_files' );
+ $this->assertCacheHeader( $response3, 'MISS', 'Cache should be invalidated when file mtime changes' );
+
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox File tracking warnings are suppressed by default to avoid log flooding.
+ */
+ public function test_file_tracking_warnings_are_suppressed_by_default() {
+ wp_cache_flush_group( 'woocommerce_rest_api_cache_warnings' );
+
+ $warning_count = 0;
+ $logger_mock = $this->createMock( \WC_Logger::class );
+ $logger_mock->expects( $this->any() )
+ ->method( 'warning' )
+ ->willReturnCallback(
+ function () use ( &$warning_count ) {
+ ++$warning_count;
+ }
+ );
+
+ $this->register_legacy_proxy_function_mocks(
+ array( 'wc_get_logger' => fn() => $logger_mock )
+ );
+
+ // Use a file path in temp dir (which is in allowed_directories).
+ $non_existent_file = sys_get_temp_dir() . '/wc_test_warning_' . uniqid() . '.txt';
+ $this->sut->controller_files = array( $non_existent_file );
+
+ // First request should log the warning.
+ $this->query_endpoint( 'with_controller_files' );
+ $this->assertSame( 1, $warning_count, 'First request should log the warning' );
+
+ // Subsequent request should NOT log the warning again (suppressed).
+ $this->query_endpoint( 'with_controller_files' );
+ $this->assertSame( 1, $warning_count, 'Second request should not log again (suppressed)' );
+
+ $this->query_endpoint( 'with_controller_files' );
+ $this->assertSame( 1, $warning_count, 'Third request should not log again (still suppressed)' );
+
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox File tracking warnings are not suppressed when filter returns zero.
+ */
+ public function test_file_tracking_warnings_not_suppressed_when_filter_returns_zero() {
+ wp_cache_flush_group( 'woocommerce_rest_api_cache_warnings' );
+
+ $warning_count = 0;
+ $logger_mock = $this->createMock( \WC_Logger::class );
+ $logger_mock->expects( $this->any() )
+ ->method( 'warning' )
+ ->willReturnCallback(
+ function () use ( &$warning_count ) {
+ ++$warning_count;
+ }
+ );
+
+ $this->register_legacy_proxy_function_mocks(
+ array( 'wc_get_logger' => fn() => $logger_mock )
+ );
+
+ // Disable warning suppression.
+ add_filter( 'woocommerce_rest_api_cache_file_warning_suppression_ttl', '__return_zero' );
+
+ // Use a file path in temp dir (which is in allowed_directories).
+ $non_existent_file = sys_get_temp_dir() . '/wc_test_warning_' . uniqid() . '.txt';
+ $this->sut->controller_files = array( $non_existent_file );
+
+ // All three requests should log the warning.
+ $this->query_endpoint( 'with_controller_files' );
+ $this->assertSame( 1, $warning_count, 'First request should log the warning' );
+
+ $this->query_endpoint( 'with_controller_files' );
+ $this->assertSame( 2, $warning_count, 'Second request should log again (suppression disabled)' );
+
+ $this->query_endpoint( 'with_controller_files' );
+ $this->assertSame( 3, $warning_count, 'Third request should log again (suppression disabled)' );
+
+ remove_filter( 'woocommerce_rest_api_cache_file_warning_suppression_ttl', '__return_zero' );
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * @testdox File tracking warning suppression filter receives correct parameters.
+ */
+ public function test_file_tracking_warning_suppression_filter_receives_correct_parameters() {
+ wp_cache_flush_group( 'woocommerce_rest_api_cache_warnings' );
+
+ $filter_called = false;
+ $received_params = array();
+
+ $filter = function ( $ttl, $file_path, $reason ) use ( &$filter_called, &$received_params ) {
+ $filter_called = true;
+ $received_params = array(
+ 'ttl' => $ttl,
+ 'file_path' => $file_path,
+ 'reason' => $reason,
+ );
+ return $ttl;
+ };
+
+ add_filter( 'woocommerce_rest_api_cache_file_warning_suppression_ttl', $filter, 10, 3 );
+
+ // Use a file path in temp dir (which is in allowed_directories).
+ $non_existent_file = sys_get_temp_dir() . '/wc_test_warning_' . uniqid() . '.txt';
+ $this->sut->controller_files = array( $non_existent_file );
+
+ $this->query_endpoint( 'with_controller_files' );
+
+ $this->assertTrue( $filter_called, 'Filter should be called' );
+ $this->assertSame( HOUR_IN_SECONDS, $received_params['ttl'], 'Default TTL should be HOUR_IN_SECONDS' );
+ $this->assertSame( $non_existent_file, $received_params['file_path'], 'File path should be passed to filter' );
+ $this->assertNotEmpty( $received_params['reason'], 'Reason should be passed to filter' );
+
+ remove_filter( 'woocommerce_rest_api_cache_file_warning_suppression_ttl', $filter, 10 );
+ $this->sut->controller_files = array();
+ }
+
+ /**
+ * Create a temporary test file for file tracking tests.
+ *
+ * @return string Path to the created file.
+ */
+ private function create_temp_test_file(): string {
+ $temp_file = sys_get_temp_dir() . '/wc_test_file_' . uniqid() . '.txt';
+ // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Test file creation.
+ file_put_contents( $temp_file, 'test content' );
+ return $temp_file;
+ }
}