Commit 5346e698975 for woocommerce
commit 5346e698975fac42df2d1043d06c273675cca43c
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Tue May 12 10:30:57 2026 +0200
Add filters for GraphQL debug and introspection (#64729)
diff --git a/plugins/woocommerce/changelog/add-filters-for-graphql-debug-and-introspection b/plugins/woocommerce/changelog/add-filters-for-graphql-debug-and-introspection
new file mode 100644
index 00000000000..f439b73e9fd
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-filters-for-graphql-debug-and-introspection
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add filters for dual API debug mode and introspection, and surface status resolver failures in debug mode.
diff --git a/plugins/woocommerce/src/Api/Infrastructure/Principal.php b/plugins/woocommerce/src/Api/Infrastructure/Principal.php
index d16bd5d4208..448655271f5 100644
--- a/plugins/woocommerce/src/Api/Infrastructure/Principal.php
+++ b/plugins/woocommerce/src/Api/Infrastructure/Principal.php
@@ -57,11 +57,9 @@ class Principal {
* model rather than inheriting a debug mode policy by accident.
*
* Note that this method's outcome is necessary but not sufficient for debug
- * mode to be active: the controller also requires either `WP_DEBUG` to be
- * defined and true, or the request to carry `_debug=1`. There's also a
- * developer escape hatch that bypasses this method entirely — when the
- * controller's `is_local_environment()` check is true, debug mode is
- * available without consulting the principal.
+ * mode to be active: the controller also requires the request to carry
+ * `_debug=1`. The decision can be overridden by the
+ * `woocommerce_graphql_can_use_debug_mode` filter.
*/
public function can_use_debug_mode(): bool {
return user_can( $this->user, 'manage_options' );
diff --git a/plugins/woocommerce/src/Internal/Api/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
index 8782e0d71d1..3e108608a0b 100644
--- a/plugins/woocommerce/src/Internal/Api/GraphQLController.php
+++ b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
@@ -242,7 +242,7 @@ abstract class GraphQLController {
// Resolver threw on one of the decision points inside
// process_request(). Produce a clean 500 without re-invoking
// the (broken) resolver.
- return $this->build_resolver_failure_response();
+ return $this->build_resolver_failure_response( $e, $request, $principal );
} catch ( \Throwable $e ) {
$output = array(
'errors' => array(
@@ -257,7 +257,7 @@ abstract class GraphQLController {
// Resolver threw specifically when handed the synthetic
// errors shape from this catch block. Fall through to the
// fixed 500; do not loop back into the resolver.
- return $this->build_resolver_failure_response();
+ return $this->build_resolver_failure_response( $e2, $request, $principal );
}
return new \WP_REST_Response( $output, $status );
@@ -266,21 +266,45 @@ abstract class GraphQLController {
/**
* Build the canonical 500 response used when the HTTP status resolver
- * throws. Body shape matches an unhandled internal error so callers
- * don't need a separate path for "resolver blew up".
+ * throws or returns an out-of-range value. Body shape matches an
+ * unhandled internal error so callers don't need a separate path for
+ * "resolver blew up".
+ *
+ * In debug mode, attaches `extensions.debug` (message, file, line, trace)
+ * for the wrapper exception, plus an `extensions.previous` chain when the
+ * resolver itself threw — mirroring the shape that {@see self::format_exception()}
+ * produces for the generic-Throwable path. Outside debug mode the body
+ * stays purely generic so resolver internals never leak to anonymous callers.
+ *
+ * @param StatusResolverFailedException $e The wrapper exception thrown by {@see self::pick_status()}.
+ * @param \WP_REST_Request $request The originating REST request.
+ * @param ?object $principal The resolved principal, or null when resolution failed.
*/
- private function build_resolver_failure_response(): \WP_REST_Response {
- return new \WP_REST_Response(
- array(
- 'errors' => array(
- array(
- 'message' => 'An unexpected error occurred.',
- 'extensions' => array( 'code' => 'INTERNAL_ERROR' ),
- ),
- ),
- ),
- 500
+ private function build_resolver_failure_response(
+ StatusResolverFailedException $e,
+ \WP_REST_Request $request,
+ ?object $principal
+ ): \WP_REST_Response {
+ $error = array(
+ 'message' => 'An unexpected error occurred.',
+ 'extensions' => array( 'code' => 'INTERNAL_ERROR' ),
);
+
+ if ( $this->is_debug_mode( $principal, $request ) ) {
+ $error['extensions']['debug'] = array(
+ 'message' => $e->getMessage(),
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'trace' => $e->getTraceAsString(),
+ );
+
+ $chain = $this->extract_previous_chain( $e );
+ if ( ! empty( $chain ) ) {
+ $error['extensions']['previous'] = $chain;
+ }
+ }
+
+ return new \WP_REST_Response( array( 'errors' => array( $error ) ), 500 );
}
/**
@@ -637,7 +661,7 @@ abstract class GraphQLController {
}
/**
- * Determine debug flags based on WP_DEBUG, principal, and query string.
+ * Determine debug flags for the request, based on {@see self::is_debug_mode()}.
*
* @param \WP_REST_Request $request The REST request.
* @param ?object $principal The resolved principal, or null if resolution itself failed.
@@ -652,52 +676,110 @@ abstract class GraphQLController {
/**
* Check whether GraphQL introspection is allowed for this request.
*
- * Introspection is permitted if either condition holds:
- * - The request is in debug mode ({@see self::is_debug_mode()}).
- * - The principal opts in via a `can_introspect(): bool` method.
+ * The principal opts in via a `can_introspect(): bool` method; principals
+ * that don't declare it are denied by default. The decision is then passed
+ * through the {@see 'woocommerce_graphql_can_introspect'} filter so sites
+ * can grant or revoke access without subclassing the principal — useful
+ * for per-request rules (specific IPs, headers, query parameters, etc.).
*
- * @param ?object $principal The resolved principal, or null if resolution failed.
+ * Fail-closed contract: the principal must be non-null (principal-resolution
+ * failures deny outright, before the filter is consulted), the principal
+ * method's return value is treated with `=== true`, and any throw from
+ * either the principal method or the filter callback denies. The filter
+ * must likewise return strictly `true` to allow; any other value denies.
+ *
+ * @param ?object $principal The resolved principal, or null when principal resolution failed.
* @param \WP_REST_Request $request The REST request.
*/
private function is_introspection_allowed( ?object $principal, \WP_REST_Request $request ): bool {
- if ( $this->is_debug_mode( $principal, $request ) ) {
- return true;
+ if ( is_null( $principal ) ) {
+ return false;
}
- return null !== $principal
- && method_exists( $principal, 'can_introspect' )
- && $principal->can_introspect();
+
+ try {
+ $can_introspect = method_exists( $principal, 'can_introspect' )
+ && true === $principal->can_introspect();
+
+ /**
+ * Filters whether the current principal may run GraphQL introspection.
+ *
+ * The filter receives the principal-derived decision (false when the
+ * principal doesn't declare `can_introspect()` or its `can_introspect()`
+ * doesn't return strictly `true`) and must return strictly `true` to
+ * grant access; any other return value denies. The filter is not
+ * invoked when principal resolution failed (i.e. when the controller
+ * passes a null principal) — that case denies outright.
+ *
+ * @since 10.9.0
+ *
+ * @internal
+ *
+ * @param bool $can_introspect Whether the principal can introspect, derived from `$principal->can_introspect()`.
+ * @param object $principal The resolved principal.
+ * @param \WP_REST_Request $request The REST request being processed.
+ */
+ $can_introspect = apply_filters( 'woocommerce_graphql_can_introspect', $can_introspect, $principal, $request );
+ } catch ( \Throwable $e ) {
+ return false;
+ }
+
+ return true === $can_introspect;
}
/**
* Check if debug mode is active.
*
- * Debug mode is active when both:
- * - The principal can use it (opted in via `can_use_debug_mode(): bool`,
- * or a local environment is detected — the developer escape hatch).
- * - WP_DEBUG is enabled OR `_debug=1` is set on the request.
+ * Debug mode is gated on `_debug=1` being set on the request: when absent,
+ * debug mode is off regardless of any other signal. When present, the
+ * principal opts in via a `can_use_debug_mode(): bool` method (principals
+ * that don't declare it are denied by default), and the decision is then
+ * passed through the {@see 'woocommerce_graphql_can_use_debug_mode'} filter.
*
- * The principal-method check follows the same opt-in convention as
- * {@see self::is_introspection_allowed()} — plugin principals must
- * declare `can_use_debug_mode()` to grant access; absence denies.
+ * Fail-closed contract: the principal must be non-null (principal-resolution
+ * failures deny outright, before the filter is consulted), the principal
+ * method's return value is treated with `=== true`, and any throw from
+ * either the principal method or the filter callback denies. The filter
+ * must likewise return strictly `true` to allow; any other value denies.
*
- * @param ?object $principal The resolved principal, or null if resolution failed.
+ * @param ?object $principal The resolved principal, or null when principal resolution failed.
* @param \WP_REST_Request $request The REST request.
*/
private function is_debug_mode( ?object $principal, \WP_REST_Request $request ): bool {
- if ( ! $this->is_local_environment() ) {
- $allowed = null !== $principal
- && method_exists( $principal, 'can_use_debug_mode' )
- && $principal->can_use_debug_mode();
- if ( ! $allowed ) {
- return false;
- }
+ if ( '1' !== $request->get_param( '_debug' ) ) {
+ return false;
+ }
+ if ( is_null( $principal ) ) {
+ return false;
}
- if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
- return true;
+ try {
+ $can_debug = method_exists( $principal, 'can_use_debug_mode' )
+ && true === $principal->can_use_debug_mode();
+
+ /**
+ * Filters whether the current principal may activate GraphQL debug mode.
+ *
+ * Only invoked when the request carries `_debug=1` and a principal was
+ * resolved successfully, so the filter is not called on every GraphQL
+ * request. The filter receives the principal-derived decision (false
+ * when the principal doesn't declare `can_use_debug_mode()` or its
+ * `can_use_debug_mode()` doesn't return strictly `true`) and must
+ * return strictly `true` to grant access; any other return value denies.
+ *
+ * @since 10.9.0
+ *
+ * @internal
+ *
+ * @param bool $can_debug Whether the principal can use debug mode, derived from `$principal->can_use_debug_mode()`.
+ * @param object $principal The resolved principal.
+ * @param \WP_REST_Request $request The REST request being processed.
+ */
+ $can_debug = apply_filters( 'woocommerce_graphql_can_use_debug_mode', $can_debug, $principal, $request );
+ } catch ( \Throwable $e ) {
+ return false;
}
- return '1' === $request->get_param( '_debug' );
+ return true === $can_debug;
}
/**
@@ -954,26 +1036,4 @@ abstract class GraphQLController {
return false;
}
-
- /**
- * Check if running in a local/development environment.
- *
- * Prefers {@see wp_get_environment_type()} when available. Otherwise
- * parses the site URL and performs a case-insensitive *exact* match
- * against the hostname — not a substring check, to avoid matching
- * impostor domains like `mylocalhost.com` or `127.0.0.1.attacker.example`.
- */
- private function is_local_environment(): bool {
- if ( function_exists( 'wp_get_environment_type' ) && 'local' === wp_get_environment_type() ) {
- return true;
- }
-
- $host = wp_parse_url( get_site_url(), PHP_URL_HOST );
- if ( ! is_string( $host ) ) {
- return false;
- }
-
- $host = strtolower( $host );
- return 'localhost' === $host || '127.0.0.1' === $host;
- }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php
index f03689fe5d2..17ccb74c37d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerDebugModeTest.php
@@ -102,21 +102,16 @@ class GraphQLControllerDebugModeTest extends WC_REST_Unit_Test_Case {
}
/**
- * @testdox debug mode is off without _debug=1 and WP_DEBUG, even for an admin.
+ * @testdox debug mode is off without _debug=1, even for an admin.
*/
public function test_debug_mode_is_off_without_trigger(): void {
- // Define WP_DEBUG only if it isn't already; we can't undefine constants.
- if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
- $this->markTestSkipped( 'Cannot exercise the "no debug trigger" branch when WP_DEBUG is on.' );
- }
-
$response = $this->sut->handle_request(
$this->post_request( array( 'query' => '{ greeting { result } }' ), false )
);
$this->assertSame( 200, $response->get_status() );
$data = $response->get_data();
- // No debug metrics should be attached when neither _debug=1 nor WP_DEBUG is set.
+ // No debug metrics should be attached when _debug=1 is missing.
$this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
}
@@ -197,20 +192,8 @@ class GraphQLControllerDebugModeTest extends WC_REST_Unit_Test_Case {
/**
* @testdox debug mode stays off for an authenticated low-privilege user even with `_debug=1`.
- *
- * Skipped when the test environment is local (`wp_get_environment_type()`
- * returns `'local'` or the site URL host is `localhost` / `127.0.0.1`),
- * because in that case `is_local_environment()` short-circuits the
- * admin/_debug gate and the test can't tell the two paths apart.
*/
public function test_debug_mode_is_off_for_low_privilege_user_with_debug_param(): void {
- $reflection = new \ReflectionClass( $this->sut );
- $is_local = $reflection->getMethod( 'is_local_environment' );
- $is_local->setAccessible( true );
- if ( $is_local->invoke( $this->sut ) ) {
- $this->markTestSkipped( 'is_local_environment() returns true in this environment; the non-admin debug gate is not exercisable.' );
- }
-
$editor = self::factory()->user->create( array( 'role' => 'editor' ) );
wp_set_current_user( $editor );
@@ -222,4 +205,198 @@ class GraphQLControllerDebugModeTest extends WC_REST_Unit_Test_Case {
$data = $response->get_data();
$this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
}
+
+ /**
+ * @testdox the woocommerce_graphql_can_use_debug_mode filter can grant debug mode to a user the principal would deny.
+ */
+ public function test_filter_can_grant_debug_mode_to_low_privilege_user(): void {
+ $editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $editor );
+
+ $received_principal = null;
+ $received_request = null;
+ $filter = function ( bool $can_debug, ?object $principal, \WP_REST_Request $request ) use ( &$received_principal, &$received_request ): bool {
+ $received_principal = $principal;
+ $received_request = $request;
+ return true;
+ };
+ add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter, 10, 3 );
+
+ try {
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ greeting { result } }' ), true )
+ );
+ } finally {
+ remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter, 10 );
+ }
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertArrayHasKey( 'debug', $data['extensions'] ?? array() );
+
+ $this->assertNotNull( $received_principal, 'The filter should receive the resolved principal.' );
+ $this->assertInstanceOf( \WP_REST_Request::class, $received_request );
+ }
+
+ /**
+ * @testdox the woocommerce_graphql_can_use_debug_mode filter can revoke debug mode from an admin.
+ */
+ public function test_filter_can_revoke_debug_mode_from_admin(): void {
+ $filter = function (): bool {
+ return false;
+ };
+ add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+
+ try {
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ greeting { result } }' ), true )
+ );
+ } finally {
+ remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+ }
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
+ }
+
+ /**
+ * @testdox the woocommerce_graphql_can_use_debug_mode filter must return strictly true; truthy non-bool denies.
+ */
+ public function test_filter_requires_strict_true_to_grant_debug_mode(): void {
+ $editor = self::factory()->user->create( array( 'role' => 'editor' ) );
+ wp_set_current_user( $editor );
+
+ $filter = function () {
+ return 1;
+ };
+ add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+
+ try {
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ greeting { result } }' ), true )
+ );
+ } finally {
+ remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+ }
+
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
+ }
+
+ /**
+ * @testdox the woocommerce_graphql_can_use_debug_mode filter is not invoked when _debug=1 is absent.
+ */
+ public function test_filter_is_not_invoked_without_debug_param(): void {
+ $invoked = false;
+ $filter = function ( bool $can_debug ) use ( &$invoked ): bool {
+ $invoked = true;
+ return $can_debug;
+ };
+ add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+
+ try {
+ $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ greeting { result } }' ), false )
+ );
+ } finally {
+ remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+ }
+
+ $this->assertFalse( $invoked );
+ }
+
+ /**
+ * @testdox a throw from the woocommerce_graphql_can_use_debug_mode filter callback denies debug mode (fail-closed).
+ *
+ * Defensive: is_debug_mode() is invoked from format_exception() and
+ * build_resolver_failure_response(), both of which run while the controller
+ * is already handling another exception. A throw escaping is_debug_mode()
+ * there would corrupt the error pipeline, so any throw must be caught.
+ */
+ public function test_filter_callback_throw_denies_debug_mode(): void {
+ $filter = function () {
+ throw new \RuntimeException( 'broken-filter-callback' );
+ };
+ add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+
+ try {
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ greeting { result } }' ), true )
+ );
+ } finally {
+ remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+ }
+
+ // Response is well-formed (the throw didn't escape the controller),
+ // and debug mode stayed off (the throw was treated as a deny).
+ $this->assertSame( 200, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertArrayNotHasKey( 'debug', $data['extensions'] ?? array() );
+
+ // The filter's exception message must not appear anywhere on the wire.
+ $wire = wp_json_encode( $data );
+ $this->assertIsString( $wire );
+ $this->assertStringNotContainsString( 'broken-filter-callback', $wire );
+ }
+
+ /**
+ * @testdox a null principal denies debug mode even when a permissive filter is installed.
+ *
+ * Honours the contract documented on handle_request(): when principal
+ * resolution itself fails ($principal stays null), no debug info is
+ * surfaced — the filter is not consulted in that case.
+ */
+ public function test_null_principal_denies_debug_mode_even_with_permissive_filter(): void {
+ $filter_invoked = false;
+ $filter = function ( bool $can_debug ) use ( &$filter_invoked ): bool {
+ // Avoid parameter not used PHPCS errors.
+ unset( $can_debug );
+ $filter_invoked = true;
+ return true;
+ };
+ add_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+
+ try {
+ $reflection = new \ReflectionClass( GraphQLController::class );
+ $method = $reflection->getMethod( 'is_debug_mode' );
+ $method->setAccessible( true );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+ $request->set_query_params( array( '_debug' => '1' ) );
+
+ $result = $method->invoke( $this->sut, null, $request );
+ } finally {
+ remove_filter( 'woocommerce_graphql_can_use_debug_mode', $filter );
+ }
+
+ $this->assertFalse( $result );
+ $this->assertFalse( $filter_invoked, 'Filter must not be consulted for null principals.' );
+ }
+
+ /**
+ * @testdox a throw from the principal's can_use_debug_mode() method denies debug mode (fail-closed).
+ */
+ public function test_principal_method_throw_denies_debug_mode(): void {
+ $throwing_principal = new class() {
+ /**
+ * Always throw to simulate a buggy plugin principal.
+ *
+ * @throws \RuntimeException Always.
+ */
+ public function can_use_debug_mode(): bool {
+ throw new \RuntimeException( 'broken-principal-method' );
+ }
+ };
+
+ $reflection = new \ReflectionClass( GraphQLController::class );
+ $method = $reflection->getMethod( 'is_debug_mode' );
+ $method->setAccessible( true );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+ $request->set_query_params( array( '_debug' => '1' ) );
+
+ $this->assertFalse( $method->invoke( $this->sut, $throwing_principal, $request ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
index 2879774ec8e..57a53e248d2 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerExecutionTest.php
@@ -256,6 +256,83 @@ class GraphQLControllerExecutionTest extends WC_REST_Unit_Test_Case {
$this->assertSame( 'Query', $response->get_data()['data']['__schema']['queryType']['name'] ?? null );
}
+ /**
+ * @testdox the woocommerce_graphql_can_introspect filter can grant introspection to an anonymous request.
+ */
+ public function test_filter_can_grant_introspection_to_anonymous_user(): void {
+ wp_set_current_user( 0 );
+
+ $received_principal = null;
+ $received_request = null;
+ $filter = function ( bool $can_introspect, ?object $principal, \WP_REST_Request $request ) use ( &$received_principal, &$received_request ): bool {
+ $received_principal = $principal;
+ $received_request = $request;
+ return true;
+ };
+ add_filter( 'woocommerce_graphql_can_introspect', $filter, 10, 3 );
+
+ try {
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ __schema { queryType { name } } }' ) )
+ );
+ } finally {
+ remove_filter( 'woocommerce_graphql_can_introspect', $filter, 10 );
+ }
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( 'Query', $response->get_data()['data']['__schema']['queryType']['name'] ?? null );
+
+ $this->assertNotNull( $received_principal, 'The filter should receive the resolved principal even for anonymous requests.' );
+ $this->assertInstanceOf( \WP_REST_Request::class, $received_request );
+ }
+
+ /**
+ * @testdox the woocommerce_graphql_can_introspect filter can revoke introspection from an admin.
+ */
+ public function test_filter_can_revoke_introspection_from_admin(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $filter = function (): bool {
+ return false;
+ };
+ add_filter( 'woocommerce_graphql_can_introspect', $filter );
+
+ try {
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ __schema { queryType { name } } }' ) )
+ );
+ } finally {
+ remove_filter( 'woocommerce_graphql_can_introspect', $filter );
+ }
+
+ $data = $response->get_data();
+ $this->assertNotEmpty( $data['errors'] ?? array() );
+ }
+
+ /**
+ * @testdox the woocommerce_graphql_can_introspect filter must return strictly true; truthy non-bool denies.
+ */
+ public function test_filter_requires_strict_true_to_grant_introspection(): void {
+ wp_set_current_user( 0 );
+
+ $filter = function () {
+ return 1;
+ };
+ add_filter( 'woocommerce_graphql_can_introspect', $filter );
+
+ try {
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ __schema { queryType { name } } }' ) )
+ );
+ } finally {
+ remove_filter( 'woocommerce_graphql_can_introspect', $filter );
+ }
+
+ $data = $response->get_data();
+ $this->assertNotEmpty( $data['errors'] ?? array() );
+ }
+
/**
* @testdox handle_request returns a syntax error as HTTP 400 GRAPHQL_PARSE_ERROR.
*/
@@ -473,4 +550,85 @@ class GraphQLControllerExecutionTest extends WC_REST_Unit_Test_Case {
$this->assertSame( 200, $response->get_status() );
$this->assertSame( 'anonymous', $response->get_data()['data']['principalAware']['result'] ?? null );
}
+
+ /**
+ * @testdox a throw from the woocommerce_graphql_can_introspect filter callback denies introspection (fail-closed).
+ */
+ public function test_filter_callback_throw_denies_introspection(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $filter = function () {
+ throw new \RuntimeException( 'broken-filter-callback' );
+ };
+ add_filter( 'woocommerce_graphql_can_introspect', $filter );
+
+ try {
+ $response = $this->sut->handle_request(
+ $this->post_request( array( 'query' => '{ __schema { queryType { name } } }' ) )
+ );
+ } finally {
+ remove_filter( 'woocommerce_graphql_can_introspect', $filter );
+ }
+
+ // Validation rejects the introspection query (fail-closed), and the
+ // filter's exception message must not appear anywhere on the wire.
+ $data = $response->get_data();
+ $this->assertNotEmpty( $data['errors'] ?? array() );
+
+ $wire = wp_json_encode( $data );
+ $this->assertIsString( $wire );
+ $this->assertStringNotContainsString( 'broken-filter-callback', $wire );
+ }
+
+ /**
+ * @testdox a null principal denies introspection even when a permissive filter is installed.
+ */
+ public function test_null_principal_denies_introspection_even_with_permissive_filter(): void {
+ $filter_invoked = false;
+ $filter = function ( bool $can_introspect ) use ( &$filter_invoked ): bool {
+ // Avoid parameter not used PHPCS errors.
+ unset( $can_introspect );
+ $filter_invoked = true;
+ return true;
+ };
+ add_filter( 'woocommerce_graphql_can_introspect', $filter );
+
+ try {
+ $reflection = new \ReflectionClass( GraphQLController::class );
+ $method = $reflection->getMethod( 'is_introspection_allowed' );
+ $method->setAccessible( true );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+ $result = $method->invoke( $this->sut, null, $request );
+ } finally {
+ remove_filter( 'woocommerce_graphql_can_introspect', $filter );
+ }
+
+ $this->assertFalse( $result );
+ $this->assertFalse( $filter_invoked, 'Filter must not be consulted for null principals.' );
+ }
+
+ /**
+ * @testdox a throw from the principal's can_introspect() method denies introspection (fail-closed).
+ */
+ public function test_principal_method_throw_denies_introspection(): void {
+ $throwing_principal = new class() {
+ /**
+ * Always throw to simulate a buggy plugin principal.
+ *
+ * @throws \RuntimeException Always.
+ */
+ public function can_introspect(): bool {
+ throw new \RuntimeException( 'broken-principal-method' );
+ }
+ };
+
+ $reflection = new \ReflectionClass( GraphQLController::class );
+ $method = $reflection->getMethod( 'is_introspection_allowed' );
+ $method->setAccessible( true );
+
+ $request = new \WP_REST_Request( 'POST', '/wc/graphql' );
+ $this->assertFalse( $method->invoke( $this->sut, $throwing_principal, $request ) );
+ }
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php
index 8e5729810f1..421bbf9a372 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerStatusResolverTest.php
@@ -483,4 +483,98 @@ class GraphQLControllerStatusResolverTest extends WC_REST_Unit_Test_Case {
)->get_status()
);
}
+
+ // ------------------------------------------------------------------
+ // Debug-mode enrichment for the canonical 500: extensions.debug
+ // surfaces the wrapper exception, and extensions.previous chains
+ // through to the resolver's own throw when there was one.
+ // ------------------------------------------------------------------
+
+ /**
+ * @testdox A throwing resolver in debug mode surfaces the wrapper message and the resolver's previous chain.
+ */
+ public function test_throwing_resolver_in_debug_mode_surfaces_previous_chain(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $sut = $this->controller_with_resolver( new AlwaysThrowingResolver() );
+ $request = $this->post_request( array( 'query' => '{ greeting { result } }' ) );
+ $request->set_query_params( array( '_debug' => '1' ) );
+
+ $response = $sut->handle_request( $request );
+ $this->assertSame( 500, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertSame( 'INTERNAL_ERROR', $data['errors'][0]['extensions']['code'] ?? null );
+ $this->assertSame( 'An unexpected error occurred.', $data['errors'][0]['message'] ?? null );
+
+ $debug = $data['errors'][0]['extensions']['debug'] ?? null;
+ $this->assertIsArray( $debug );
+ $this->assertSame( 'HTTP status resolver threw.', $debug['message'] ?? null );
+ $this->assertArrayHasKey( 'file', $debug );
+ $this->assertArrayHasKey( 'line', $debug );
+ $this->assertArrayHasKey( 'trace', $debug );
+
+ // The previous chain must contain the resolver's own RuntimeException
+ // with its distinctive message — that's the actual cause a developer
+ // needs to see.
+ $previous = $data['errors'][0]['extensions']['previous'] ?? null;
+ $this->assertIsArray( $previous );
+ $this->assertContains( \RuntimeException::class, array_column( $previous, 'class' ) );
+ $this->assertContains( AlwaysThrowingResolver::THROW_MESSAGE, array_column( $previous, 'message' ) );
+ }
+
+ /**
+ * @testdox An out-of-range resolver return in debug mode surfaces the wrapper message; no previous chain (the wrapper had no cause).
+ */
+ public function test_out_of_range_resolver_return_in_debug_mode_surfaces_wrapper_message(): void {
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $sut = $this->controller_with_resolver( new FixedReturnResolver( 0 ) );
+ $request = $this->post_request( array( 'query' => '{ greeting { result } }' ) );
+ $request->set_query_params( array( '_debug' => '1' ) );
+
+ $response = $sut->handle_request( $request );
+ $this->assertSame( 500, $response->get_status() );
+
+ $data = $response->get_data();
+ $this->assertSame( 'INTERNAL_ERROR', $data['errors'][0]['extensions']['code'] ?? null );
+
+ $debug = $data['errors'][0]['extensions']['debug'] ?? null;
+ $this->assertIsArray( $debug );
+ // The wrapper message is what surfaces here — and it's the message a
+ // developer needs to identify the bug ("the resolver returned 0").
+ $this->assertStringContainsString( 'out-of-range status code', $debug['message'] ?? '' );
+ $this->assertStringContainsString( '0', $debug['message'] ?? '' );
+
+ // Out-of-range path constructs the wrapper without a $previous, so
+ // no previous chain should be attached.
+ $this->assertArrayNotHasKey( 'previous', $data['errors'][0]['extensions'] );
+ }
+
+ /**
+ * @testdox A throwing resolver without _debug=1 produces the canonical body — no debug or previous keys leak.
+ */
+ public function test_throwing_resolver_without_debug_param_produces_canonical_body(): void {
+ // Even an admin: without _debug=1 the resolver-failure response stays
+ // purely generic so no resolver internals leak.
+ $admin = self::factory()->user->create( array( 'role' => 'administrator' ) );
+ wp_set_current_user( $admin );
+
+ $sut = $this->controller_with_resolver( new AlwaysThrowingResolver() );
+ $response = $sut->handle_request( $this->post_request( array( 'query' => '{ greeting { result } }' ) ) );
+
+ $this->assertSame( 500, $response->get_status() );
+ $data = $response->get_data();
+ $this->assertSame( 'INTERNAL_ERROR', $data['errors'][0]['extensions']['code'] ?? null );
+ $this->assertArrayNotHasKey( 'debug', $data['errors'][0]['extensions'] );
+ $this->assertArrayNotHasKey( 'previous', $data['errors'][0]['extensions'] );
+
+ // Belt and braces: the resolver's exception message must NOT appear
+ // anywhere in the serialized response.
+ $wire = wp_json_encode( $data );
+ $this->assertIsString( $wire );
+ $this->assertStringNotContainsString( AlwaysThrowingResolver::THROW_MESSAGE, $wire );
+ }
}