Commit 97a2fb57ed6 for woocommerce
commit 97a2fb57ed64231be1d3390e1cdac5d4442dfc05
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Tue Nov 10 12:10:10 2026 +0200
[tests] Fail PHPUnit tests that send unmocked HTTP requests to external hosts (#70611)
* test: Block test HTTP requests to external hosts
Tests that send real HTTP requests slow the suite down and depend on
the network. #70524 removed nine such requests, which saved about 51
seconds per local full run, but every one of those tests passed while
reaching the network, so nothing in the suite would notice new ones.
Register a last-priority pre_http_request filter from the test
bootstrap. A request that no mock answered and that targets a host
other than the test site or a loopback address now gets a WP_Error
instead of leaving the machine, and is recorded with the code that
sent it. A listener added next turns those records into test failures.
Refs #70579
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: Fail tests that send HTTP requests to external hosts
The external HTTP guard blocks and records requests to external hosts,
but a blocked request alone does not fail anything: code under test
often turns the error into a fallback, and the test passes.
Add a PHPUnit listener, registered in phpunit.xml so it covers every
test class whatever its base, that resets the guard before each test
and fails the test after it when the guard blocked any request. The
message names each request and the code that sent it, and both fixes:
mock the request, or declare @group external-http when the test needs
the real network. That group also lets a run skip such tests with
--exclude-group external-http.
Refs #70579
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(tests): Document the external HTTP guard and its opt-out
Contributors meet the guard as a failing test. Note it in the testing
principles next to the existing "never fetch" rule, with the
@group external-http opt-out and how offline runs can skip those tests,
and add a dev changelog entry with no merchant-facing body.
Refs #70579
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: Report external requests from class setup and arm the guard from the listener
The external HTTP guard blocked requests sent outside any test, such as
from setUpBeforeClass or wpSetUpBeforeClass, but the next test's start
discarded the record, so nothing failed. The listener now clears the
list and disallows external requests when each test ends, and a test
that starts with requests already recorded fails with them, labelled as
sent before it started. Requests from the last class's
tearDownAfterClass still go unreported, since no test follows them.
Other projects, such as woocommerce-services, load this bootstrap with
their own phpunit.xml and so without the listener. There every external
request silently became an error and no test failed. The guard now does
nothing until the listener arms it when the suite starts, so those
suites behave as before the guard existed.
The failure message now names the code outside WordPress that sent the
request, rather than a WordPress helper such as download_url(). The
README says loopback addresses are also allowed, and a URL with
credentials that disguise an external host is covered by a test.
Refs #70579
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: Install the external HTTP guard from its listener
The bootstrap installed the guard for every suite that loads it, and
the listener then armed it, so that projects loading WooCommerce's
bootstrap with their own phpunit.xml were not blocked silently. That
took two entry points, an armed flag, and reflection in both test
classes to flip it.
Let the listener's constructor install the guard instead. PHPUnit
creates listeners after the bootstrap and before the first test's
set_up() takes WordPress's hook snapshot, so hook restoration still
keeps the filter registered. Suites without the listener never install
it, which is the same behavior with no flag. The bootstrap is back to
trunk.
Also drain the blocked list with one take_blocked_requests() call
instead of get-then-clear pairs, label requests sent between tests
once when the next test starts, and trim restating docblocks.
Refs #70579
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: Ignore non-HTTP URLs in the external HTTP guard
WP_Http::request() fires pre_http_request before it strips protocols
other than http, https and ssl and rejects a URL left without a scheme,
and its transport sends only HTTP(S). An ftp:// or scheme-less URL
therefore never reaches the network, but the guard recorded it as an
external request and failed the test.
Treat a URL as outbound only when it has a host and an http or https
scheme, and cover both cases in the pass-through test.
Refs #70579
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: Check redirects and cleaned URLs in the external HTTP guard
Two gaps let an external request through the guard. Requests follows
redirects inside WP_Http::request() without calling pre_http_request
again, so a test site URL that redirected to an external host was
never checked. And WP_Http::request() strips disallowed protocols only
after the filter runs, so ftp:https://api.example.com/feed looked
hostless to the guard but went out as an HTTPS request.
Stop redirects to external hosts from requests-requests.before_redirect,
throwing a Requests exception as WordPress's own validate_redirects()
does, and classify the URL after the same wp_kses_bad_protocol()
cleanup WordPress applies.
Refs #70579
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: Answer webhook pings locally in the stage delivery tests
Two WC_Webhook_Test cases added in #70387 save an active webhook with
a pending delivery, which pings https://example.com/webhook for real.
The external HTTP guard now fails tests that do that. The ping's result
does not affect what either test asserts, so answer it with the file's
existing mock_http_response() helper.
Refs #70579
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: Answer the webhook stage-test pings through the HTTP responder
WC_Webhook_Test extends WP_HTTP_TestCase, whose $http_responder is the
suite's mechanism for answering HTTP requests in a test and the fix the
external HTTP guard's failure message names. Use it instead of a raw
pre_http_request filter for the two incidental pings.
Refs #70579
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: Reword the guard test's query-string assertion message
The message justified stripping query strings as a secrets boundary.
The guard only records URLs tests build from fixture data, so the
stripping keeps failure messages short and stable rather than
protecting credentials. Say what the assertion checks instead.
Refs #70579
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test: Fix unmocked ping when reactivating a webhook
The external HTTP guard flags the pending delivery ping sent when the
webhook is reactivated and saved. This causes every PHPUnit matrix job
to fail on the disabled-action regression test.
Answer the ping with a successful local HTTP response, matching the
neighboring webhook tests while preserving the disabled-action assertion.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Thilina Pituwala <thilina.hasantha@gmail.com>
diff --git a/plugins/woocommerce/changelog/test-phpunit-flag-unmocked-http-requests b/plugins/woocommerce/changelog/test-phpunit-flag-unmocked-http-requests
new file mode 100644
index 00000000000..0960be9b276
--- /dev/null
+++ b/plugins/woocommerce/changelog/test-phpunit-flag-unmocked-http-requests
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Fail PHPUnit tests that send unmocked HTTP requests to external hosts; no production change.
+
diff --git a/plugins/woocommerce/phpunit.xml b/plugins/woocommerce/phpunit.xml
index 53b5fdf5e99..54910f5e264 100644
--- a/plugins/woocommerce/phpunit.xml
+++ b/plugins/woocommerce/phpunit.xml
@@ -35,6 +35,7 @@
</array>
</arguments>
</listener>
+ <listener class="WC_External_HTTP_Guard_Listener" file="./tests/legacy/framework/class-wc-external-http-guard-listener.php" />
</listeners>
<coverage includeUncoveredFiles="true">
<include>
diff --git a/plugins/woocommerce/tests/README.md b/plugins/woocommerce/tests/README.md
index c37b5d6433a..fb9dc1abdb3 100644
--- a/plugins/woocommerce/tests/README.md
+++ b/plugins/woocommerce/tests/README.md
@@ -194,7 +194,7 @@ The full suite runs in a few minutes because every test pays only for what it as
- **Clean only state the base class does not own.** WordPress parent teardown restores its hook snapshot and the globals it manages; `WC_Unit_Test_Case` also resets its documented WooCommerce singleton state. Inspect other bases before relying on or duplicating cleanup. Resetting preloaded state during test arrangement is not teardown cleanup.
- **Seed at the boundary the code reads.** If the code under test only reads persisted rows (aggregate SQL, lookup tables, migration sources), fixtures may insert those rows directly — with exactly the columns production writes and collision-safe IDs. Keep at least one real write-path (CRUD) test per area so the sync path stays covered.
- **Register only the REST surface you exercise.** `WC_REST_Unit_Test_Case` registers routes lazily per namespace; scoped tests can use `WC_Unit_Test_Case::create_rest_server_with_routes()`. Avoid re-firing the full `rest_api_init` per test.
-- **Never sleep, never fetch.** Control timestamps through the object setters instead of `sleep()`, and mock HTTP through the framework's interception layer (`$this->http_responder`, local fixture files) so the suite is deterministic and passes without network access.
+- **Never sleep, never fetch.** Control timestamps through the object setters instead of `sleep()`, and mock HTTP through the framework's interception layer (`$this->http_responder`, local fixture files) so the suite is deterministic and passes without network access. A guard enforces the fetch half: a test that sends an unmocked request to a host other than the test site or a loopback address fails, and the failure names the request and the code that sent it. A test that genuinely needs the network declares `@group external-http`, which also lets offline runs skip it with `--exclude-group external-http`. Unlike WordPress core, WooCommerce's phpunit.xml runs these tests by default; the "Not running external-http tests" notice comes from the WordPress test bootstrap and can be ignored.
- **Use the shared helpers instead of re-rolling them**, e.g. `WC_Unit_Test_Case::with_direct_product_attribute_lookup_updates()` for fixture work that would otherwise leave scheduled lookup actions behind.
The `SpeedTrapListener` reports tests slower than one second at the end of each run — treat an entry there as a review flag.
diff --git a/plugins/woocommerce/tests/legacy/framework/class-wc-external-http-guard-listener.php b/plugins/woocommerce/tests/legacy/framework/class-wc-external-http-guard-listener.php
new file mode 100644
index 00000000000..91acebd2d88
--- /dev/null
+++ b/plugins/woocommerce/tests/legacy/framework/class-wc-external-http-guard-listener.php
@@ -0,0 +1,108 @@
+<?php
+/**
+ * Fails a test that sent an HTTP request to an external host.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types = 1 );
+
+use PHPUnit\Framework\AssertionFailedError;
+use PHPUnit\Framework\Test;
+use PHPUnit\Framework\TestCase;
+use PHPUnit\Framework\TestListener;
+use PHPUnit\Framework\TestListenerDefaultImplementation;
+
+require_once __DIR__ . '/class-wc-external-http-guard.php';
+
+/**
+ * Installs WC_External_HTTP_Guard and fails each test whose requests it blocked. A listener
+ * rather than an exception from the filter, because code under test often catches exceptions.
+ * Registered in phpunit.xml, so it covers every test whatever its base class. A request sent
+ * outside any test, such as from class setup or teardown, fails the next test.
+ */
+final class WC_External_HTTP_Guard_Listener implements TestListener {
+
+ use TestListenerDefaultImplementation;
+
+ /**
+ * Appended to a request blocked outside any test, which fails the next test instead.
+ */
+ private const BEFORE_TEST_NOTE = "sent before this test started, during class setup or teardown (possibly the previous class's teardown)";
+
+ /**
+ * Requests blocked between the previous test and this one, already labelled with BEFORE_TEST_NOTE.
+ *
+ * @var string[]
+ */
+ private $requests_before_test = array();
+
+ /**
+ * Install the guard. PHPUnit creates listeners after the bootstrap and before any class setup.
+ */
+ public function __construct() {
+ WC_External_HTTP_Guard::register();
+ }
+
+ /**
+ * Keep requests blocked since the previous test for this test's failure, then allow external
+ * requests for tests in the external-http group. Requests from the last class's
+ * tearDownAfterClass have no following test and go unreported.
+ *
+ * @param Test $test The test about to run.
+ */
+ public function startTest( Test $test ): void {
+ $this->requests_before_test = array();
+ foreach ( WC_External_HTTP_Guard::take_blocked_requests() as $request ) {
+ $this->requests_before_test[] = $request . ', ' . self::BEFORE_TEST_NOTE;
+ }
+ WC_External_HTTP_Guard::allow_external( $test instanceof TestCase && in_array( WC_External_HTTP_Guard::GROUP, $test->getGroups(), true ) );
+ }
+
+ /**
+ * Fail the test if the guard blocked any request during it or before it started, then
+ * block external requests until the next test starts.
+ *
+ * @param Test $test The test that just ran.
+ * @param float $time How long it took, in seconds.
+ */
+ public function endTest( Test $test, float $time ): void {
+ WC_External_HTTP_Guard::allow_external( false );
+
+ $before_test = $this->requests_before_test;
+ $this->requests_before_test = array();
+ $blocked = array_merge( $before_test, WC_External_HTTP_Guard::take_blocked_requests() );
+
+ if ( array() === $blocked || ! $test instanceof TestCase ) {
+ return;
+ }
+
+ $result = $test->getTestResultObject();
+ if ( null === $result ) {
+ return;
+ }
+
+ // TestResult::endTest() runs listeners before it records the test as passed, so this failure sticks.
+ $result->addFailure( $test, new AssertionFailedError( self::failure_message( $blocked, array() !== $before_test ) ), $time );
+ }
+
+ /**
+ * Explain which requests were blocked and how to fix the test.
+ *
+ * @param string[] $blocked The blocked requests.
+ * @param bool $any_before_test Whether any of them was sent outside the test.
+ * @return string
+ */
+ private static function failure_message( array $blocked, bool $any_before_test ): string {
+ $message = sprintf(
+ "The PHPUnit external HTTP guard blocked %d HTTP request(s) to external hosts:\n- %s\nMock the request with \$this->http_responder or a pre_http_request filter, or add @group %s if the test needs the real network.",
+ count( $blocked ),
+ implode( "\n- ", $blocked ),
+ WC_External_HTTP_Guard::GROUP
+ );
+ if ( $any_before_test ) {
+ $message .= sprintf( ' @group %s does not cover requests sent outside a test; mock those or move them into the test.', WC_External_HTTP_Guard::GROUP );
+ }
+ return $message;
+ }
+}
diff --git a/plugins/woocommerce/tests/legacy/framework/class-wc-external-http-guard.php b/plugins/woocommerce/tests/legacy/framework/class-wc-external-http-guard.php
new file mode 100644
index 00000000000..c1b948f67b9
--- /dev/null
+++ b/plugins/woocommerce/tests/legacy/framework/class-wc-external-http-guard.php
@@ -0,0 +1,190 @@
+<?php
+/**
+ * Blocks HTTP requests from tests to external hosts.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Answers unmocked requests to external hosts with a WP_Error and records them. Runs last on
+ * `pre_http_request`, so mocks and WP_HTTP_TestCase::$http_responder answer first. Only
+ * WC_External_HTTP_Guard_Listener installs it, and it fails the test that sent the request,
+ * so suites that load the bootstrap without that listener are unaffected.
+ */
+final class WC_External_HTTP_Guard {
+
+ /**
+ * The PHPUnit group that allows a test to reach external hosts.
+ */
+ const GROUP = 'external-http';
+
+ /**
+ * The WP_Error code returned for a blocked request.
+ */
+ const ERROR_CODE = 'wc_tests_external_http_blocked';
+
+ /**
+ * Requests blocked since the list was last taken, as 'METHOD URL (from file:line)'.
+ *
+ * @var string[]
+ */
+ private static $blocked = array();
+
+ /**
+ * Whether the current test may reach external hosts.
+ *
+ * @var bool
+ */
+ private static $allow_external = false;
+
+ /**
+ * Lowercase hosts that count as local, captured when the guard is registered.
+ *
+ * @var string[]
+ */
+ private static $local_hosts = array();
+
+ /**
+ * Install the guard, after WordPress has loaded. Calling it again is harmless.
+ */
+ public static function register(): void {
+ // Captured once, so a test that changes the home or site URL cannot make an external host count as local.
+ if ( array() === self::$local_hosts ) {
+ $hosts = array( 'localhost', '127.0.0.1', '::1' );
+ foreach ( array( home_url(), site_url() ) as $url ) {
+ $host = wp_parse_url( $url, PHP_URL_HOST );
+ if ( is_string( $host ) && '' !== $host ) {
+ $hosts[] = strtolower( $host );
+ }
+ }
+ self::$local_hosts = array_unique( $hosts );
+ }
+
+ add_filter( 'pre_http_request', array( self::class, 'filter_request' ), PHP_INT_MAX, 3 );
+ add_action( 'requests-requests.before_redirect', array( self::class, 'check_redirect' ), PHP_INT_MAX );
+ }
+
+ /**
+ * Set whether external requests are allowed until the next call.
+ *
+ * @param bool $allow_external Whether external hosts may be reached.
+ */
+ public static function allow_external( bool $allow_external ): void {
+ self::$allow_external = $allow_external;
+ }
+
+ /**
+ * Requests blocked since the list was last taken.
+ *
+ * @return string[] Each as 'METHOD URL (from file:line)', without query strings.
+ */
+ public static function get_blocked_requests(): array {
+ return self::$blocked;
+ }
+
+ /**
+ * Return the requests blocked so far and forget them.
+ *
+ * @return string[] Each as 'METHOD URL (from file:line)', without query strings.
+ */
+ public static function take_blocked_requests(): array {
+ $blocked = self::$blocked;
+ self::$blocked = array();
+ return $blocked;
+ }
+
+ /**
+ * Block an unanswered request to an external host.
+ *
+ * @param false|array|WP_Error $preempt A response from an earlier filter, or false.
+ * @param array $args The request arguments.
+ * @param string $url The request URL.
+ * @return false|array|WP_Error The earlier answer unchanged, or an error for a blocked request.
+ */
+ public static function filter_request( $preempt, $args, $url ) {
+ if ( false !== $preempt || self::$allow_external ) {
+ return $preempt;
+ }
+
+ // WP_Http::request() strips disallowed protocols only after this filter, so classify the URL it will send.
+ $url = wp_kses_bad_protocol( (string) $url, array( 'http', 'https', 'ssl' ) );
+ if ( ! self::is_external_url( $url ) ) {
+ return $preempt;
+ }
+
+ $request = self::record( strtoupper( (string) ( $args['method'] ?? 'GET' ) ), $url );
+
+ return new WP_Error( self::ERROR_CODE, 'The PHPUnit external HTTP guard blocked this request: ' . $request );
+ }
+
+ /**
+ * Stop a redirect to an external host. Requests follows redirects without calling
+ * pre_http_request again; WP_Http::request() turns the exception into a WP_Error.
+ *
+ * @param string $location The redirect target.
+ * @throws WpOrg\Requests\Exception For a redirect to an external host.
+ */
+ public static function check_redirect( $location ): void {
+ if ( self::$allow_external || ! self::is_external_url( (string) $location ) ) {
+ return;
+ }
+
+ $request = self::record( 'REDIRECT', (string) $location );
+
+ throw new WpOrg\Requests\Exception( esc_html( 'The PHPUnit external HTTP guard blocked this redirect: ' . $request ), self::ERROR_CODE ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- The message is escaped; the second argument is a fixed error type, not output.
+ }
+
+ /**
+ * Record a blocked request without its query string.
+ *
+ * @param string $method The HTTP method, or REDIRECT.
+ * @param string $url The URL that would have been requested.
+ * @return string The record, as 'METHOD URL (from file:line)'.
+ */
+ private static function record( string $method, string $url ): string {
+ $request = sprintf( '%s %s (from %s)', $method, preg_replace( '/[?#].*$/s', '', $url ), self::caller() );
+ self::$blocked[] = $request;
+ return $request;
+ }
+
+ /**
+ * Whether a URL points at a host other than the test site and the loopback addresses.
+ *
+ * @param string $url The request URL.
+ * @return bool False for a URL without a host or with a scheme other than HTTP(S), which WordPress refuses to send.
+ */
+ private static function is_external_url( string $url ): bool {
+ $parts = wp_parse_url( $url );
+ if ( ! is_array( $parts ) || empty( $parts['host'] ) || ! in_array( strtolower( $parts['scheme'] ?? '' ), array( 'http', 'https' ), true ) ) {
+ return false;
+ }
+
+ return ! in_array( strtolower( trim( $parts['host'], '[]' ) ), self::$local_hosts, true );
+ }
+
+ /**
+ * The file and line that sent the request: the first frame outside WordPress, or else
+ * the first one outside WordPress's HTTP and hook plumbing.
+ *
+ * @return string A path relative to ABSPATH and a line, or 'unknown caller'.
+ */
+ private static function caller(): string {
+ $fallback = null;
+ foreach ( debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ) as $frame ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Names the code that sent the blocked request.
+ $file = $frame['file'] ?? '';
+ if ( '' === $file || __FILE__ === $file ) {
+ continue;
+ }
+ $location = str_replace( ABSPATH, '', $file ) . ':' . ( $frame['line'] ?? 0 );
+ if ( ! preg_match( '#/wp-(?:includes|admin)/#', $file ) ) {
+ return $location;
+ }
+ if ( null === $fallback && ! preg_match( '#/wp-includes/(?:class-wp-http[^/]*|http|class-wp-hook|plugin)\.php$#', $file ) ) {
+ $fallback = $location;
+ }
+ }
+ return $fallback ?? 'unknown caller';
+ }
+}
diff --git a/plugins/woocommerce/tests/php/framework/class-wc-external-http-guard-listener-test.php b/plugins/woocommerce/tests/php/framework/class-wc-external-http-guard-listener-test.php
new file mode 100644
index 00000000000..5976f1d5b24
--- /dev/null
+++ b/plugins/woocommerce/tests/php/framework/class-wc-external-http-guard-listener-test.php
@@ -0,0 +1,159 @@
+<?php
+/**
+ * Tests for the listener that fails tests sending external HTTP requests.
+ *
+ * @package WooCommerce\Tests\Framework
+ */
+
+declare( strict_types = 1 );
+
+use PHPUnit\Framework\TestCase;
+use PHPUnit\Framework\TestResult;
+
+/**
+ * WC_External_HTTP_Guard_Listener_Test class.
+ *
+ * Drives the listener against a separate, never-run test and its own TestResult,
+ * so the failures it adds do not land on this test.
+ */
+class WC_External_HTTP_Guard_Listener_Test extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var WC_External_HTTP_Guard_Listener
+ */
+ private $sut;
+
+ /**
+ * The test the listener observes.
+ *
+ * @var TestCase
+ */
+ private $observed_test;
+
+ /**
+ * The result the listener reports the observed test's failures to.
+ *
+ * @var TestResult
+ */
+ private $observed_result;
+
+ /**
+ * Create the listener and the test it observes.
+ */
+ public function setUp(): void {
+ parent::setUp();
+
+ $this->sut = new WC_External_HTTP_Guard_Listener();
+ $this->observed_result = new TestResult();
+ $this->observed_test = new class( 'test_noop' ) extends TestCase {
+ /**
+ * Never run; the listener only needs a test to observe.
+ */
+ public function test_noop(): void {}
+ };
+ $this->observed_test->setTestResultObject( $this->observed_result );
+ }
+
+ /**
+ * Drop requests recorded here and disallow external requests again, so the listener does not fail this test.
+ */
+ public function tearDown(): void {
+ try {
+ WC_External_HTTP_Guard::take_blocked_requests();
+ WC_External_HTTP_Guard::allow_external( false );
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox A test that sent an external request fails with the request and both fixes in the message.
+ */
+ public function test_blocked_request_fails_the_test(): void {
+ $this->sut->startTest( $this->observed_test );
+ WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'GET' ), 'https://api.example.com/feed?token=secret' );
+ $this->sut->endTest( $this->observed_test, 0.0 );
+
+ $this->assertSame( 1, $this->observed_result->failureCount(), 'The observed test should fail once.' );
+ $message = $this->observed_result->failures()[0]->exceptionMessage();
+ $this->assertStringContainsString( 'GET https://api.example.com/feed (from ', $message, 'The message should name the blocked request.' );
+ $this->assertStringContainsString( '$this->http_responder', $message, 'The message should suggest mocking the request.' );
+ $this->assertStringContainsString( '@group external-http', $message, 'The message should name the opt-out.' );
+ }
+
+ /**
+ * @testdox A test that sent no external request is left untouched.
+ */
+ public function test_test_without_blocked_requests_passes(): void {
+ $this->sut->startTest( $this->observed_test );
+ $this->sut->endTest( $this->observed_test, 0.0 );
+
+ $this->assertSame( 0, $this->observed_result->failureCount(), 'The observed test should not fail.' );
+ }
+
+ /**
+ * @testdox A test in the external-http group may reach external hosts.
+ */
+ public function test_external_http_group_allows_external_requests(): void {
+ $this->observed_test->setGroups( array( 'default', WC_External_HTTP_Guard::GROUP ) );
+
+ $this->sut->startTest( $this->observed_test );
+ $result = WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'GET' ), 'https://api.example.com/feed' );
+ $this->sut->endTest( $this->observed_test, 0.0 );
+
+ $this->assertFalse( $result, 'The request should reach the network.' );
+ $this->assertSame( 0, $this->observed_result->failureCount(), 'The observed test should not fail.' );
+ }
+
+ /**
+ * @testdox Requests recorded before a test starts fail that test, labelled as sent before it.
+ */
+ public function test_requests_before_start_fail_the_next_test(): void {
+ WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'GET' ), 'https://api.example.com/before' );
+
+ $this->sut->startTest( $this->observed_test );
+ WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'GET' ), 'https://api.example.com/during' );
+ $this->sut->endTest( $this->observed_test, 0.0 );
+
+ $this->assertSame( 1, $this->observed_result->failureCount(), 'The observed test should fail once.' );
+ $message = $this->observed_result->failures()[0]->exceptionMessage();
+ $this->assertMatchesRegularExpression( '#GET https://api\.example\.com/before \(from [^)]+\), sent before this test started#', $message, 'The earlier request should be labelled as sent before the test.' );
+ $this->assertMatchesRegularExpression( '#GET https://api\.example\.com/during \(from [^)]+\)$#m', $message, 'The test\'s own request should be listed without the label.' );
+ }
+
+ /**
+ * @testdox Ending a test forgets its requests.
+ */
+ public function test_end_clears_blocked_requests(): void {
+ $this->sut->startTest( $this->observed_test );
+ WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'GET' ), 'https://api.example.com/feed' );
+ $this->sut->endTest( $this->observed_test, 0.0 );
+
+ $this->assertSame( array(), WC_External_HTTP_Guard::get_blocked_requests(), 'The next test should not see the requests of the one that ended.' );
+ }
+
+ /**
+ * @testdox Ending a test in the external-http group blocks external requests again.
+ */
+ public function test_end_disallows_external_requests(): void {
+ $this->observed_test->setGroups( array( WC_External_HTTP_Guard::GROUP ) );
+
+ $this->sut->startTest( $this->observed_test );
+ $this->sut->endTest( $this->observed_test, 0.0 );
+
+ $this->assertWPError( WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'GET' ), 'https://api.example.com/feed' ), 'A request between tests should be blocked.' );
+ }
+
+ /**
+ * @testdox Creating the listener installs the guard at the last priority.
+ */
+ public function test_listener_installs_the_guard(): void {
+ remove_filter( 'pre_http_request', array( WC_External_HTTP_Guard::class, 'filter_request' ), PHP_INT_MAX );
+
+ new WC_External_HTTP_Guard_Listener();
+
+ $this->assertSame( PHP_INT_MAX, has_filter( 'pre_http_request', array( WC_External_HTTP_Guard::class, 'filter_request' ) ), 'The listener should install the guard.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/framework/class-wc-external-http-guard-test.php b/plugins/woocommerce/tests/php/framework/class-wc-external-http-guard-test.php
new file mode 100644
index 00000000000..783271487ff
--- /dev/null
+++ b/plugins/woocommerce/tests/php/framework/class-wc-external-http-guard-test.php
@@ -0,0 +1,186 @@
+<?php
+/**
+ * Tests for the guard against external HTTP requests from tests.
+ *
+ * @package WooCommerce\Tests\Framework
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * WC_External_HTTP_Guard_Test class.
+ *
+ * Requests go straight to the guard's filter callback, so nothing reaches the network.
+ */
+class WC_External_HTTP_Guard_Test extends WC_Unit_Test_Case {
+
+ /**
+ * Drop requests recorded here and disallow external requests again, so the listener does not fail this test.
+ */
+ public function tearDown(): void {
+ try {
+ WC_External_HTTP_Guard::take_blocked_requests();
+ WC_External_HTTP_Guard::allow_external( false );
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox An unanswered request to an external host is blocked and recorded without its query string.
+ */
+ public function test_external_request_is_blocked_and_recorded(): void {
+ $result = WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'POST' ), 'https://api.example.com/v1/items?key=secret#top' );
+
+ $this->assertWPError( $result, 'The guard should answer an external request with an error.' );
+ $this->assertSame( WC_External_HTTP_Guard::ERROR_CODE, $result->get_error_code(), 'The error should name the guard.' );
+
+ $blocked = WC_External_HTTP_Guard::get_blocked_requests();
+ $this->assertCount( 1, $blocked, 'The blocked request should be recorded once.' );
+ $this->assertStringStartsWith( 'POST https://api.example.com/v1/items (from ', $blocked[0], 'The record should hold the method and the URL without its query string.' );
+ $this->assertStringContainsString( 'class-wc-external-http-guard-test.php:', $blocked[0], 'The record should name the calling file.' );
+ $this->assertStringNotContainsString( 'secret', $blocked[0], 'The record should not include the query string.' );
+ }
+
+ /**
+ * @testdox A request to an external IP address or a disguised external host is blocked.
+ *
+ * @testWith ["http://203.0.113.10/feed"]
+ * ["https://[2001:db8::1]/feed"]
+ * ["https://localhost@evil.example/feed"]
+ * ["ftp:https://api.example.com/feed"]
+ * ["ftp://example.org/path:https://api.example.com/feed"]
+ *
+ * @param string $url The request URL.
+ */
+ public function test_external_ip_address_is_blocked( string $url ): void {
+ $result = WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'GET' ), $url );
+
+ $this->assertWPError( $result, "The guard should block {$url}." );
+ $this->assertCount( 1, WC_External_HTTP_Guard::get_blocked_requests(), "The request to {$url} should be recorded." );
+ }
+
+ /**
+ * @testdox A request to a loopback host, or one WordPress refuses to send, passes through untouched.
+ *
+ * @testWith ["http://localhost:8188/wp-admin/admin-ajax.php"]
+ * ["http://LOCALHOST/feed"]
+ * ["http://127.0.0.1/feed"]
+ * ["http://[::1]/feed"]
+ * ["bad-data-source.json"]
+ * ["ftp://api.example.com/feed"]
+ * ["//api.example.com/feed"]
+ *
+ * @param string $url The request URL.
+ */
+ public function test_request_that_stays_local_passes_through( string $url ): void {
+ $this->assertFalse( WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'GET' ), $url ), "The guard should not answer {$url}." );
+ $this->assertSame( array(), WC_External_HTTP_Guard::get_blocked_requests(), "The request to {$url} should not be recorded." );
+ }
+
+ /**
+ * @testdox A request to the test site's own URLs passes through untouched.
+ */
+ public function test_test_site_request_passes_through(): void {
+ foreach ( array( home_url( '/shop/' ), site_url( '/wp-admin/admin-ajax.php' ) ) as $url ) {
+ $this->assertFalse( WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'GET' ), $url ), "The guard should not answer {$url}." );
+ }
+ $this->assertSame( array(), WC_External_HTTP_Guard::get_blocked_requests(), 'Requests to the test site should not be recorded.' );
+ }
+
+ /**
+ * @testdox A request another filter already answered passes through untouched.
+ */
+ public function test_answered_request_passes_through(): void {
+ $response = array(
+ 'response' => array( 'code' => 200 ),
+ 'body' => '',
+ );
+ $error = new WP_Error( 'mocked', 'Mocked failure.' );
+
+ $this->assertSame( $response, WC_External_HTTP_Guard::filter_request( $response, array( 'method' => 'GET' ), 'https://api.example.com/feed' ), 'A mocked response should be returned unchanged.' );
+ $this->assertSame( $error, WC_External_HTTP_Guard::filter_request( $error, array( 'method' => 'GET' ), 'https://api.example.com/feed' ), 'A mocked error should be returned unchanged.' );
+ $this->assertSame( array(), WC_External_HTTP_Guard::get_blocked_requests(), 'Answered requests should not be recorded.' );
+ }
+
+ /**
+ * @testdox An external request passes through untouched while external requests are allowed.
+ */
+ public function test_external_request_passes_through_when_allowed(): void {
+ WC_External_HTTP_Guard::allow_external( true );
+
+ $this->assertFalse( WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'GET' ), 'https://api.example.com/feed' ), 'An allowed request should reach the network.' );
+ $this->assertSame( array(), WC_External_HTTP_Guard::get_blocked_requests(), 'An allowed request should not be recorded.' );
+ }
+
+ /**
+ * @testdox A request sent through a WordPress admin helper names the code that called the helper.
+ */
+ public function test_record_names_caller_outside_wordpress(): void {
+ require_once ABSPATH . 'wp-admin/includes/file.php';
+
+ $result = download_url( 'https://api.example.com/plugin.zip' );
+
+ $this->assertWPError( $result, 'The guard should block the download.' );
+ $this->assertSame( WC_External_HTTP_Guard::ERROR_CODE, $result->get_error_code(), 'The error should name the guard.' );
+ $blocked = WC_External_HTTP_Guard::get_blocked_requests();
+ $this->assertCount( 1, $blocked, 'The blocked download should be recorded once.' );
+ $this->assertStringContainsString( 'class-wc-external-http-guard-test.php:', $blocked[0], 'The record should name this test, not the WordPress helper it called.' );
+ }
+
+ /**
+ * @testdox A disguised request is recorded as the URL WordPress would send after stripping disallowed protocols.
+ */
+ public function test_disguised_request_is_recorded_as_sent(): void {
+ WC_External_HTTP_Guard::filter_request( false, array( 'method' => 'GET' ), 'ftp://example.org/path:https://api.example.com/feed' );
+
+ $blocked = WC_External_HTTP_Guard::get_blocked_requests();
+ $this->assertCount( 1, $blocked, 'The disguised request should be recorded once.' );
+ $this->assertStringStartsWith( 'GET https://api.example.com/feed (from ', $blocked[0], 'The record should hold the URL WordPress would request.' );
+ }
+
+ /**
+ * @testdox A redirect from an allowed request to an external host is blocked and recorded.
+ */
+ public function test_redirect_to_external_host_is_blocked(): void {
+ try {
+ self::dispatch_redirect( 'https://api.example.com/next?token=secret' );
+ $this->fail( 'The guard should stop a redirect to an external host.' );
+ } catch ( WpOrg\Requests\Exception $e ) {
+ $this->assertSame( WC_External_HTTP_Guard::ERROR_CODE, $e->getType(), 'The exception should name the guard.' );
+ }
+
+ $blocked = WC_External_HTTP_Guard::get_blocked_requests();
+ $this->assertCount( 1, $blocked, 'The blocked redirect should be recorded once.' );
+ $this->assertStringStartsWith( 'REDIRECT https://api.example.com/next (from ', $blocked[0], 'The record should name the redirect target without its query string.' );
+ }
+
+ /**
+ * @testdox A redirect to the test site is followed untouched.
+ */
+ public function test_redirect_to_test_site_passes_through(): void {
+ self::dispatch_redirect( home_url( '/next/' ) );
+
+ $this->assertSame( array(), WC_External_HTTP_Guard::get_blocked_requests(), 'A redirect to the test site should not be recorded.' );
+ }
+
+ /**
+ * Fire the hook WordPress's HTTP API fires before following a redirect, as Requests does.
+ *
+ * @param string $location The redirect target.
+ */
+ private static function dispatch_redirect( string $location ): void {
+ $headers = array();
+ $data = array();
+ $options = array();
+ ( new WP_HTTP_Requests_Hooks( home_url( '/start/' ), array() ) )->dispatch( 'requests.before_redirect', array( &$location, &$headers, &$data, &$options, null ) );
+ }
+
+ /**
+ * @testdox The guard is registered on pre_http_request after every other filter.
+ */
+ public function test_guard_runs_last(): void {
+ $this->assertSame( PHP_INT_MAX, has_filter( 'pre_http_request', array( WC_External_HTTP_Guard::class, 'filter_request' ) ), 'The guard should be registered at the last priority.' );
+ $this->assertSame( PHP_INT_MAX, has_action( 'requests-requests.before_redirect', array( WC_External_HTTP_Guard::class, 'check_redirect' ) ), 'The redirect check should be registered at the last priority.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
index 8f6bd653779..cc678657134 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-webhook-tests.php
@@ -872,6 +872,9 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
$webhook->set_failure_count( 0 );
$webhook->save();
+ // Saving an active webhook with a pending delivery pings its URL; answer locally.
+ $this->http_responder = fn() => $this->mock_http_response( 200 );
+
$response = array(
'response' => array(
'code' => 500,
@@ -928,6 +931,9 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
$webhook->set_failure_count( 0 );
$webhook->save();
+ // Saving an active webhook with a pending delivery pings its URL; answer locally.
+ $this->http_responder = fn() => $this->mock_http_response( 200 );
+
$webhook->stage_delivery_result( 500 );
$this->assertSame( 1, $webhook->get_failure_count() );
$this->assertSame( 0, wc_get_webhook( $webhook->get_id() )->get_failure_count() );
@@ -990,6 +996,8 @@ class WC_Webhook_Test extends WC_Unit_Test_Case {
$webhook = $this->create_failing_webhook();
$fired = did_action( 'woocommerce_webhook_disabled_due_delivery_failures' );
+ $this->http_responder = fn() => $this->mock_http_response( 200 );
+
$webhook->stage_delivery_result( 500 );
$webhook->set_status( 'active' );
$webhook->save();